Code

Finished implementation of session and one-time-key stores for RDBMS
[roundup.git] / roundup / backends / back_metakit.py
1 # $Id: back_metakit.py,v 1.62 2004-03-18 01:58:45 richard Exp $
2 '''Metakit backend for Roundup, originally by Gordon McMillan.
4 Known Current Bugs:
6 - You can't change a class' key properly. This shouldn't be too hard to fix.
7 - Some unit tests are overridden.
9 Notes by Richard:
11 This backend has some behaviour specific to metakit:
13 - there's no concept of an explicit "unset" in metakit, so all types
14   have some "unset" value:
16   ========= ===== ======================================================
17   Type      Value Action when fetching from mk
18   ========= ===== ======================================================
19   Strings   ''    convert to None
20   Date      0     (seconds since 1970-01-01.00:00:00) convert to None
21   Interval  ''    convert to None
22   Number    0     ambiguious :( - do nothing (see BACKWARDS_COMPATIBLE)
23   Boolean   0     ambiguious :( - do nothing (see BACKWARDS_COMPATABILE)
24   Link      0     convert to None
25   Multilink []    actually, mk can handle this one ;)
26   Password  ''    convert to None
27   ========= ===== ======================================================
29   The get/set routines handle these values accordingly by converting
30   to/from None where they can. The Number/Boolean types are not able
31   to handle an "unset" at all, so they default the "unset" to 0.
32 - Metakit relies in reference counting to close the database, there is
33   no explicit close call.  This can cause issues if a metakit
34   database is referenced multiple times, one might not actually be
35   closing the db.                                    
36 - probably a bunch of stuff that I'm not aware of yet because I haven't
37   fully read through the source. One of these days....
38 '''
39 __docformat__ = 'restructuredtext'
40 # Enable this flag to break backwards compatibility (i.e. can't read old
41 # databases) but comply with more roundup features, like adding NULL support.
42 BACKWARDS_COMPATIBLE = True
44 from roundup import hyperdb, date, password, roundupdb, security
45 import metakit
46 from sessions_dbm import Sessions, OneTimeKeys
47 import re, marshal, os, sys, time, calendar
48 from roundup import indexer
49 import locking
50 from roundup.date import Range
52 # view modes for opening
53 # XXX FIXME BPK -> these don't do anything, they are ignored
54 #  should we just get rid of them for simplicities sake?
55 READ = 0
56 READWRITE = 1
58 # general metakit error
59 class MKBackendError(Exception):
60     pass
62 _dbs = {}
64 def Database(config, journaltag=None):
65     ''' Only have a single instance of the Database class for each instance
66     '''
67     db = _dbs.get(config.DATABASE, None)
68     if db is None or db._db is None:
69         db = _Database(config, journaltag)
70         _dbs[config.DATABASE] = db
71     else:
72         db.journaltag = journaltag
73     return db
75 class _Database(hyperdb.Database, roundupdb.Database):
76     def __init__(self, config, journaltag=None):
77         self.config = config
78         self.journaltag = journaltag
79         self.classes = {}
80         self.dirty = 0
81         self.lockfile = None
82         self._db = self.__open()
83         self.indexer = Indexer(self.config.DATABASE, self._db)
84         self.security = security.Security(self)
86         os.umask(0002)
88     def post_init(self):
89         if self.indexer.should_reindex():
90             self.reindex()
92     def refresh_database(self):
93         # XXX handle refresh
94         self.reindex()
96     def reindex(self):
97         for klass in self.classes.values():
98             for nodeid in klass.list():
99                 klass.index(nodeid)
100         self.indexer.save_index()
102     def getSessionManager(self):
103         return Sessions(self)
105     def getOTKManager(self):
106         return OneTimeKeys(self)
108     # --- defined in ping's spec
109     def __getattr__(self, classname):
110         if classname == 'transactions':
111             return self.dirty
112         # fall back on the classes
113         try:
114             return self.getclass(classname)
115         except KeyError, msg:
116             # KeyError's not appropriate here
117             raise AttributeError, str(msg)
118     def getclass(self, classname):
119         try:
120             return self.classes[classname]
121         except KeyError:
122             raise KeyError, 'There is no class called "%s"'%classname
123     def getclasses(self):
124         return self.classes.keys()
125     # --- end of ping's spec 
127     # --- exposed methods
128     def commit(self):
129         '''commit all changes to the database'''
130         if self.dirty:
131             self._db.commit()
132             for cl in self.classes.values():
133                 cl._commit()
134             self.indexer.save_index()
135         self.dirty = 0
136     def rollback(self):
137         '''roll back all changes since the last commit'''
138         if self.dirty:
139             for cl in self.classes.values():
140                 cl._rollback()
141             self._db.rollback()
142             self._db = None
143             self._db = metakit.storage(self.dbnm, 1)
144             self.hist = self._db.view('history')
145             self.tables = self._db.view('tables')
146             self.indexer.rollback()
147             self.indexer.datadb = self._db
148         self.dirty = 0
149     def clearCache(self):
150         '''clear the internal cache by committing all pending database changes'''
151         for cl in self.classes.values():
152             cl._commit()
153     def clear(self):
154         '''clear the internal cache but don't commit any changes'''
155         for cl in self.classes.values():
156             cl._clear()
157     def hasnode(self, classname, nodeid):
158         '''does a particular class contain a nodeid?'''
159         return self.getclass(classname).hasnode(nodeid)
160     def pack(self, pack_before):
161         ''' Delete all journal entries except "create" before 'pack_before'.
162         '''
163         mindate = int(calendar.timegm(pack_before.get_tuple()))
164         i = 0
165         while i < len(self.hist):
166             if self.hist[i].date < mindate and self.hist[i].action != _CREATE:
167                 self.hist.delete(i)
168             else:
169                 i = i + 1
170     def addclass(self, cl):
171         ''' Add a Class to the hyperdatabase.
172         '''
173         self.classes[cl.classname] = cl
174         if self.tables.find(name=cl.classname) < 0:
175             self.tables.append(name=cl.classname)
177         # add default Edit and View permissions
178         self.security.addPermission(name="Edit", klass=cl.classname,
179             description="User is allowed to edit "+cl.classname)
180         self.security.addPermission(name="View", klass=cl.classname,
181             description="User is allowed to access "+cl.classname)
183     def addjournal(self, tablenm, nodeid, action, params, creator=None,
184                    creation=None):
185         ''' Journal the Action
186         'action' may be:
188             'create' or 'set' -- 'params' is a dictionary of property values
189             'link' or 'unlink' -- 'params' is (classname, nodeid, propname)
190             'retire' -- 'params' is None
191         '''
192         tblid = self.tables.find(name=tablenm)
193         if tblid == -1:
194             tblid = self.tables.append(name=tablenm)
195         if creator is None:
196             creator = int(self.getuid())
197         else:
198             try:
199                 creator = int(creator)
200             except TypeError:
201                 creator = int(self.getclass('user').lookup(creator))
202         if creation is None:
203             creation = int(time.time())
204         elif isinstance(creation, date.Date):
205             creation = int(calendar.timegm(creation.get_tuple()))
206         # tableid:I,nodeid:I,date:I,user:I,action:I,params:B
207         self.hist.append(tableid=tblid,
208                          nodeid=int(nodeid),
209                          date=creation,
210                          action=action,
211                          user = creator,
212                          params = marshal.dumps(params))
213     def getjournal(self, tablenm, nodeid):
214         ''' get the journal for id
215         '''
216         rslt = []
217         tblid = self.tables.find(name=tablenm)
218         if tblid == -1:
219             return rslt
220         q = self.hist.select(tableid=tblid, nodeid=int(nodeid))
221         if len(q) == 0:
222             raise IndexError, "no history for id %s in %s" % (nodeid, tablenm)
223         i = 0
224         #userclass = self.getclass('user')
225         for row in q:
226             try:
227                 params = marshal.loads(row.params)
228             except ValueError:
229                 print "history couldn't unmarshal %r" % row.params
230                 params = {}
231             #usernm = userclass.get(str(row.user), 'username')
232             dt = date.Date(time.gmtime(row.date))
233             #rslt.append((nodeid, dt, usernm, _actionnames[row.action], params))
234             rslt.append((nodeid, dt, str(row.user), _actionnames[row.action],
235                 params))
236         return rslt
238     def destroyjournal(self, tablenm, nodeid):
239         nodeid = int(nodeid)
240         tblid = self.tables.find(name=tablenm)
241         if tblid == -1:
242             return 
243         i = 0
244         hist = self.hist
245         while i < len(hist):
246             if hist[i].tableid == tblid and hist[i].nodeid == nodeid:
247                 hist.delete(i)
248             else:
249                 i = i + 1
250         self.dirty = 1
251         
252     def close(self):
253         ''' Close off the connection.
254         '''
255         # de-reference count the metakit databases,
256         #  as this is the only way they will be closed
257         for cl in self.classes.values():
258             cl.db = None
259         self._db = None
260         if self.lockfile is not None:
261             locking.release_lock(self.lockfile)
262         if _dbs.has_key(self.config.DATABASE):
263             del _dbs[self.config.DATABASE]
264         if self.lockfile is not None:
265             self.lockfile.close()
266             self.lockfile = None
267         self.classes = {}
269         # force the indexer to close
270         self.indexer.close()
271         self.indexer = None
273     # --- internal
274     def __open(self):
275         ''' Open the metakit database
276         '''
277         # make the database dir if it doesn't exist
278         if not os.path.exists(self.config.DATABASE):
279             os.makedirs(self.config.DATABASE)
281         # figure the file names
282         self.dbnm = db = os.path.join(self.config.DATABASE, 'tracker.mk4')
283         lockfilenm = db[:-3]+'lck'
285         # get the database lock
286         self.lockfile = locking.acquire_lock(lockfilenm)
287         self.lockfile.write(str(os.getpid()))
288         self.lockfile.flush()
290         # see if the schema has changed since last db access
291         self.fastopen = 0
292         if os.path.exists(db):
293             dbtm = os.path.getmtime(db)
294             pkgnm = self.config.__name__.split('.')[0]
295             schemamod = sys.modules.get(pkgnm+'.dbinit', None)
296             if schemamod:
297                 if os.path.exists(schemamod.__file__):
298                     schematm = os.path.getmtime(schemamod.__file__)
299                     if schematm < dbtm:
300                         # found schema mod - it's older than the db
301                         self.fastopen = 1
302                 else:
303                      # can't find schemamod - must be frozen
304                     self.fastopen = 1
306         # open the db
307         db = metakit.storage(db, 1)
308         hist = db.view('history')
309         tables = db.view('tables')
310         if not self.fastopen:
311             # create the database if it's brand new
312             if not hist.structure():
313                 hist = db.getas('history[tableid:I,nodeid:I,date:I,user:I,action:I,params:B]')
314             if not tables.structure():
315                 tables = db.getas('tables[name:S]')
316             db.commit()
318         # we now have an open, initialised database
319         self.tables = tables
320         self.hist = hist
321         return db
323     def setid(self, classname, maxid):
324         ''' No-op in metakit
325         '''
326         pass
327         
328 _STRINGTYPE = type('')
329 _LISTTYPE = type([])
330 _CREATE, _SET, _RETIRE, _LINK, _UNLINK, _RESTORE = range(6)
332 _actionnames = {
333     _CREATE : 'create',
334     _SET : 'set',
335     _RETIRE : 'retire',
336     _RESTORE : 'restore',
337     _LINK : 'link',
338     _UNLINK : 'unlink',
341 _marker = []
343 _ALLOWSETTINGPRIVATEPROPS = 0
345 class Class(hyperdb.Class):
346     ''' The handle to a particular class of nodes in a hyperdatabase.
347         
348         All methods except __repr__ and getnode must be implemented by a
349         concrete backend Class of which this is one.
350     '''
352     privateprops = None
353     def __init__(self, db, classname, **properties):
354         if (properties.has_key('creation') or properties.has_key('activity')
355             or properties.has_key('creator')):
356             raise ValueError, '"creation", "activity" and "creator" are '\
357                   'reserved'
358         if hasattr(db, classname):
359             raise ValueError, "Class %s already exists"%classname
361         self.db = db
362         self.classname = classname
363         self.key = None
364         self.ruprops = properties
365         self.privateprops = { 'id' : hyperdb.String(),
366                               'activity' : hyperdb.Date(),
367                               'creation' : hyperdb.Date(),
368                               'creator'  : hyperdb.Link('user') }
370         # event -> list of callables
371         self.auditors = {'create': [], 'set': [], 'retire': [], 'restore': []}
372         self.reactors = {'create': [], 'set': [], 'retire': [], 'restore': []}
374         view = self.__getview()
375         self.maxid = 1
376         if view:
377             self.maxid = view[-1].id + 1
378         self.uncommitted = {}
379         self.rbactions = []
381         # people reach inside!!
382         self.properties = self.ruprops
383         self.db.addclass(self)
384         self.idcache = {}
386         # default is to journal changes
387         self.do_journal = 1
389     def enableJournalling(self):
390         '''Turn journalling on for this class
391         '''
392         self.do_journal = 1
394     def disableJournalling(self):
395         '''Turn journalling off for this class
396         '''
397         self.do_journal = 0
398         
399     #
400     # Detector/reactor interface
401     #
402     def audit(self, event, detector):
403         '''Register a detector
404         '''
405         l = self.auditors[event]
406         if detector not in l:
407             self.auditors[event].append(detector)
409     def fireAuditors(self, action, nodeid, newvalues):
410        '''Fire all registered auditors.
411         '''
412        for audit in self.auditors[action]:
413             audit(self.db, self, nodeid, newvalues)
415     def react(self, event, detector):
416        '''Register a reactor
417        '''
418        l = self.reactors[event]
419        if detector not in l:
420            self.reactors[event].append(detector)
422     def fireReactors(self, action, nodeid, oldvalues):
423         '''Fire all registered reactors.
424         '''
425         for react in self.reactors[action]:
426             react(self.db, self, nodeid, oldvalues)
427             
428     # --- the hyperdb.Class methods
429     def create(self, **propvalues):
430         ''' Create a new node of this class and return its id.
432         The keyword arguments in 'propvalues' map property names to values.
434         The values of arguments must be acceptable for the types of their
435         corresponding properties or a TypeError is raised.
436         
437         If this class has a key property, it must be present and its value
438         must not collide with other key strings or a ValueError is raised.
439         
440         Any other properties on this class that are missing from the
441         'propvalues' dictionary are set to None.
442         
443         If an id in a link or multilink property does not refer to a valid
444         node, an IndexError is raised.
445         '''
446         if not propvalues:
447             raise ValueError, "Need something to create!"
448         self.fireAuditors('create', None, propvalues)
449         newid = self.create_inner(**propvalues)
450         # self.set() (called in self.create_inner()) does reactors)
451         return newid
453     def create_inner(self, **propvalues):
454        ''' Called by create, in-between the audit and react calls.
455        '''
456        rowdict = {}
457        rowdict['id'] = newid = self.maxid
458        self.maxid += 1
459        ndx = self.getview(READWRITE).append(rowdict)
460        propvalues['#ISNEW'] = 1
461        try:
462            self.set(str(newid), **propvalues)
463        except Exception:
464            self.maxid -= 1
465            raise
466        return str(newid)
467     
468     def get(self, nodeid, propname, default=_marker, cache=1):
469         '''Get the value of a property on an existing node of this class.
471         'nodeid' must be the id of an existing node of this class or an
472         IndexError is raised.  'propname' must be the name of a property
473         of this class or a KeyError is raised.
475         'cache' exists for backwards compatibility, and is not used.
476         '''
477         view = self.getview()
478         id = int(nodeid)
479         if cache == 0:
480             oldnode = self.uncommitted.get(id, None)
481             if oldnode and oldnode.has_key(propname):
482                 raw = oldnode[propname]
483                 converter = _converters.get(rutyp.__class__, None)
484                 if converter:
485                     return converter(raw)
486                 return raw
487         ndx = self.idcache.get(id, None)
489         if ndx is None:
490             ndx = view.find(id=id)
491             if ndx < 0:
492                 raise IndexError, "%s has no node %s" % (self.classname, nodeid)
493             self.idcache[id] = ndx
494         try:
495             raw = getattr(view[ndx], propname)
496         except AttributeError:
497             raise KeyError, propname
498         rutyp = self.ruprops.get(propname, None)
500         if rutyp is None:
501             rutyp = self.privateprops[propname]
503         converter = _converters.get(rutyp.__class__, None)
504         if converter:
505             raw = converter(raw)
506         return raw
507         
508     def set(self, nodeid, **propvalues):
509         '''Modify a property on an existing node of this class.
510         
511         'nodeid' must be the id of an existing node of this class or an
512         IndexError is raised.
514         Each key in 'propvalues' must be the name of a property of this
515         class or a KeyError is raised.
517         All values in 'propvalues' must be acceptable types for their
518         corresponding properties or a TypeError is raised.
520         If the value of the key property is set, it must not collide with
521         other key strings or a ValueError is raised.
523         If the value of a Link or Multilink property contains an invalid
524         node id, a ValueError is raised.
525         '''
526         isnew = 0
527         if propvalues.has_key('#ISNEW'):
528             isnew = 1
529             del propvalues['#ISNEW']
530         if not isnew:
531             self.fireAuditors('set', nodeid, propvalues)
532         if not propvalues:
533             return propvalues
534         if propvalues.has_key('id'):
535             raise KeyError, '"id" is reserved'
536         if self.db.journaltag is None:
537             raise hyperdb.DatabaseError, 'Database open read-only'
538         view = self.getview(READWRITE)
540         # node must exist & not be retired
541         id = int(nodeid)
542         ndx = view.find(id=id)
543         if ndx < 0:
544             raise IndexError, "%s has no node %s" % (self.classname, nodeid)
545         row = view[ndx]
546         if row._isdel:
547             raise IndexError, "%s has no node %s" % (self.classname, nodeid)
548         oldnode = self.uncommitted.setdefault(id, {})
549         changes = {}
551         for key, value in propvalues.items():
552             # this will raise the KeyError if the property isn't valid
553             # ... we don't use getprops() here because we only care about
554             # the writeable properties.
555             if _ALLOWSETTINGPRIVATEPROPS:
556                 prop = self.ruprops.get(key, None)
557                 if not prop:
558                     prop = self.privateprops[key]
559             else:
560                 prop = self.ruprops[key]
561             converter = _converters.get(prop.__class__, lambda v: v)
562             # if the value's the same as the existing value, no sense in
563             # doing anything
564             oldvalue = converter(getattr(row, key))
565             if  value == oldvalue:
566                 del propvalues[key]
567                 continue
568             
569             # check to make sure we're not duplicating an existing key
570             if key == self.key:
571                 iv = self.getindexview(READWRITE)
572                 ndx = iv.find(k=value)
573                 if ndx == -1:
574                     iv.append(k=value, i=row.id)
575                     if not isnew:
576                         ndx = iv.find(k=oldvalue)
577                         if ndx > -1:
578                             iv.delete(ndx)
579                 else:
580                     raise ValueError, 'node with key "%s" exists'%value
582             # do stuff based on the prop type
583             if isinstance(prop, hyperdb.Link):
584                 link_class = prop.classname
585                 # must be a string or None
586                 if value is not None and not isinstance(value, type('')):
587                     raise ValueError, 'property "%s" link value be a string'%(
588                         key)
589                 # Roundup sets to "unselected" by passing None
590                 if value is None:
591                     value = 0   
592                 # if it isn't a number, it's a key
593                 try:
594                     int(value)
595                 except ValueError:
596                     try:
597                         value = self.db.getclass(link_class).lookup(value)
598                     except (TypeError, KeyError):
599                         raise IndexError, 'new property "%s": %s not a %s'%(
600                             key, value, prop.classname)
602                 if (value is not None and
603                         not self.db.getclass(link_class).hasnode(value)):
604                     raise IndexError, '%s has no node %s'%(link_class, value)
606                 setattr(row, key, int(value))
607                 changes[key] = oldvalue
608                 
609                 if self.do_journal and prop.do_journal:
610                     # register the unlink with the old linked node
611                     if oldvalue:
612                         self.db.addjournal(link_class, oldvalue, _UNLINK,
613                             (self.classname, str(row.id), key))
615                     # register the link with the newly linked node
616                     if value:
617                         self.db.addjournal(link_class, value, _LINK,
618                             (self.classname, str(row.id), key))
620             elif isinstance(prop, hyperdb.Multilink):
621                 if value is not None and type(value) != _LISTTYPE:
622                     raise TypeError, 'new property "%s" not a list of ids'%key
623                 link_class = prop.classname
624                 l = []
625                 if value is None:
626                     value = []
627                 for entry in value:
628                     if type(entry) != _STRINGTYPE:
629                         raise ValueError, 'new property "%s" link value ' \
630                             'must be a string'%key
631                     # if it isn't a number, it's a key
632                     try:
633                         int(entry)
634                     except ValueError:
635                         try:
636                             entry = self.db.getclass(link_class).lookup(entry)
637                         except (TypeError, KeyError):
638                             raise IndexError, 'new property "%s": %s not a %s'%(
639                                 key, entry, prop.classname)
640                     l.append(entry)
641                 propvalues[key] = value = l
643                 # handle removals
644                 rmvd = []
645                 for id in oldvalue:
646                     if id not in value:
647                         rmvd.append(id)
648                         # register the unlink with the old linked node
649                         if self.do_journal and prop.do_journal:
650                             self.db.addjournal(link_class, id, _UNLINK,
651                                 (self.classname, str(row.id), key))
653                 # handle additions
654                 adds = []
655                 for id in value:
656                     if id not in oldvalue:
657                         if not self.db.getclass(link_class).hasnode(id):
658                             raise IndexError, '%s has no node %s'%(
659                                 link_class, id)
660                         adds.append(id)
661                         # register the link with the newly linked node
662                         if self.do_journal and prop.do_journal:
663                             self.db.addjournal(link_class, id, _LINK,
664                                 (self.classname, str(row.id), key))
666                 # perform the modifications on the actual property value
667                 sv = getattr(row, key)
668                 i = 0
669                 while i < len(sv):
670                     if str(sv[i].fid) in rmvd:
671                         sv.delete(i)
672                     else:
673                         i += 1
674                 for id in adds:
675                     sv.append(fid=int(id))
677                 # figure the journal entry
678                 l = []
679                 if adds:
680                     l.append(('+', adds))
681                 if rmvd:
682                     l.append(('-', rmvd))
683                 if l:
684                     changes[key] = tuple(l)
685                 #changes[key] = oldvalue
687                 if not rmvd and not adds:
688                     del propvalues[key]
690             elif isinstance(prop, hyperdb.String):
691                 if value is not None and type(value) != _STRINGTYPE:
692                     raise TypeError, 'new property "%s" not a string'%key
693                 if value is None:
694                     value = ''
695                 setattr(row, key, value)
696                 changes[key] = oldvalue
697                 if hasattr(prop, 'isfilename') and prop.isfilename:
698                     propvalues[key] = os.path.basename(value)
699                 if prop.indexme:
700                     self.db.indexer.add_text((self.classname, nodeid, key),
701                         value, 'text/plain')
703             elif isinstance(prop, hyperdb.Password):
704                 if value is not None and not isinstance(value, password.Password):
705                     raise TypeError, 'new property "%s" not a Password'% key
706                 if value is None:
707                     value = ''
708                 setattr(row, key, str(value))
709                 changes[key] = str(oldvalue)
710                 propvalues[key] = str(value)
712             elif isinstance(prop, hyperdb.Date):
713                 if value is not None and not isinstance(value, date.Date):
714                     raise TypeError, 'new property "%s" not a Date'% key
715                 if value is None:
716                     setattr(row, key, 0)
717                 else:
718                     setattr(row, key, int(calendar.timegm(value.get_tuple())))
719                 changes[key] = str(oldvalue)
720                 propvalues[key] = str(value)
722             elif isinstance(prop, hyperdb.Interval):
723                 if value is not None and not isinstance(value, date.Interval):
724                     raise TypeError, 'new property "%s" not an Interval'% key
725                 if value is None:
726                     setattr(row, key, '')
727                 else:
728                     # kedder: we should store interval values serialized
729                     setattr(row, key, value.serialise())
730                 changes[key] = str(oldvalue)
731                 propvalues[key] = str(value)
732  
733             elif isinstance(prop, hyperdb.Number):
734                 if value is None:
735                     v = 0
736                 else:
737                     try:
738                         v = int(value)
739                     except ValueError:
740                         raise TypeError, "%s (%s) is not numeric"%(key, repr(value))
741                     if not BACKWARDS_COMPATIBLE:
742                         if v >=0:
743                             v = v + 1
744                 setattr(row, key, v)
745                 changes[key] = oldvalue
746                 propvalues[key] = value
748             elif isinstance(prop, hyperdb.Boolean):
749                 if value is None:
750                     bv = 0
751                 elif value not in (0,1):
752                     raise TypeError, "%s (%s) is not boolean"%(key, repr(value))
753                 else:
754                     bv = value
755                     if not BACKWARDS_COMPATIBLE:
756                         bv += 1
757                 setattr(row, key, bv)
758                 changes[key] = oldvalue
759                 propvalues[key] = value
761             oldnode[key] = oldvalue
763         # nothing to do?
764         if not propvalues:
765             return propvalues
766         if not propvalues.has_key('activity'):
767             row.activity = int(time.time())
768         if isnew:
769             if not row.creation:
770                 row.creation = int(time.time())
771             if not row.creator:
772                 row.creator = int(self.db.getuid())
774         self.db.dirty = 1
775         if self.do_journal:
776             if isnew:
777                 self.db.addjournal(self.classname, nodeid, _CREATE, {})
778                 self.fireReactors('create', nodeid, None)
779             else:
780                 self.db.addjournal(self.classname, nodeid, _SET, changes)
781                 self.fireReactors('set', nodeid, oldnode)
783         return propvalues
784     
785     def retire(self, nodeid):
786         '''Retire a node.
787         
788         The properties on the node remain available from the get() method,
789         and the node's id is never reused.
790         
791         Retired nodes are not returned by the find(), list(), or lookup()
792         methods, and other nodes may reuse the values of their key properties.
793         '''
794         if self.db.journaltag is None:
795             raise hyperdb.DatabaseError, 'Database open read-only'
796         self.fireAuditors('retire', nodeid, None)
797         view = self.getview(READWRITE)
798         ndx = view.find(id=int(nodeid))
799         if ndx < 0:
800             raise KeyError, "nodeid %s not found" % nodeid
802         row = view[ndx]
803         oldvalues = self.uncommitted.setdefault(row.id, {})
804         oldval = oldvalues['_isdel'] = row._isdel
805         row._isdel = 1
807         if self.do_journal:
808             self.db.addjournal(self.classname, nodeid, _RETIRE, {})
809         if self.key:
810             iv = self.getindexview(READWRITE)
811             ndx = iv.find(k=getattr(row, self.key))
812             # find is broken with multiple attribute lookups
813             # on ordered views
814             #ndx = iv.find(k=getattr(row, self.key),i=row.id)
815             if ndx > -1 and iv[ndx].i == row.id:
816                 iv.delete(ndx)
818         self.db.dirty = 1
819         self.fireReactors('retire', nodeid, None)
821     def restore(self, nodeid):
822         '''Restore a retired node.
824         Make node available for all operations like it was before retirement.
825         '''
826         if self.db.journaltag is None:
827             raise hyperdb.DatabaseError, 'Database open read-only'
829         # check if key property was overrided
830         key = self.getkey()
831         keyvalue = self.get(nodeid, key)
832         
833         try:
834             id = self.lookup(keyvalue)
835         except KeyError:
836             pass
837         else:
838             raise KeyError, "Key property (%s) of retired node clashes with \
839                 existing one (%s)" % (key, keyvalue)
840         # Now we can safely restore node
841         self.fireAuditors('restore', nodeid, None)
842         view = self.getview(READWRITE)
843         ndx = view.find(id=int(nodeid))
844         if ndx < 0:
845             raise KeyError, "nodeid %s not found" % nodeid
847         row = view[ndx]
848         oldvalues = self.uncommitted.setdefault(row.id, {})
849         oldval = oldvalues['_isdel'] = row._isdel
850         row._isdel = 0
852         if self.do_journal:
853             self.db.addjournal(self.classname, nodeid, _RESTORE, {})
854         if self.key:
855             iv = self.getindexview(READWRITE)
856             ndx = iv.find(k=getattr(row, self.key),i=row.id)
857             if ndx > -1:
858                 iv.delete(ndx)
859         self.db.dirty = 1
860         self.fireReactors('restore', nodeid, None)
862     def is_retired(self, nodeid):
863         '''Return true if the node is retired
864         '''
865         view = self.getview(READWRITE)
866         # node must exist & not be retired
867         id = int(nodeid)
868         ndx = view.find(id=id)
869         if ndx < 0:
870             raise IndexError, "%s has no node %s" % (self.classname, nodeid)
871         row = view[ndx]
872         return row._isdel
874     def history(self, nodeid):
875         '''Retrieve the journal of edits on a particular node.
877         'nodeid' must be the id of an existing node of this class or an
878         IndexError is raised.
880         The returned list contains tuples of the form
882             (nodeid, date, tag, action, params)
884         'date' is a Timestamp object specifying the time of the change and
885         'tag' is the journaltag specified when the database was opened.
886         '''        
887         if not self.do_journal:
888             raise ValueError, 'Journalling is disabled for this class'
889         return self.db.getjournal(self.classname, nodeid)
891     def setkey(self, propname):
892         '''Select a String property of this class to be the key property.
894         'propname' must be the name of a String property of this class or
895         None, or a TypeError is raised.  The values of the key property on
896         all existing nodes must be unique or a ValueError is raised.
897         '''
898         if self.key:
899             if propname == self.key:
900                 return
901             else:
902                 # drop the old key table
903                 tablename = "_%s.%s"%(self.classname, self.key)
904                 self.db._db.getas(tablename)
905                 
906             #raise ValueError, "%s already indexed on %s"%(self.classname,
907             #    self.key)
909         prop = self.properties.get(propname, None)
910         if prop is None:
911             prop = self.privateprops.get(propname, None)
912         if prop is None:
913             raise KeyError, "no property %s" % propname
914         if not isinstance(prop, hyperdb.String):
915             raise TypeError, "%s is not a String" % propname
917         # the way he index on properties is by creating a
918         # table named _%(classname)s.%(key)s, if this table
919         # exists then everything is okay.  If this table
920         # doesn't exist, then generate a new table on the
921         # key value.
922         
923         # first setkey for this run or key has been changed
924         self.key = propname
925         tablename = "_%s.%s"%(self.classname, self.key)
926         
927         iv = self.db._db.view(tablename)
928         if self.db.fastopen and iv.structure():
929             return
931         # very first setkey ever or the key has changed
932         self.db.dirty = 1
933         iv = self.db._db.getas('_%s[k:S,i:I]' % tablename)
934         iv = iv.ordered(1)
935         for row in self.getview():
936             iv.append(k=getattr(row, propname), i=row.id)
937         self.db.commit()
939     def getkey(self):
940        '''Return the name of the key property for this class or None.'''
941        return self.key
943     def lookup(self, keyvalue):
944         '''Locate a particular node by its key property and return its id.
946         If this class has no key property, a TypeError is raised.  If the
947         keyvalue matches one of the values for the key property among
948         the nodes in this class, the matching node's id is returned;
949         otherwise a KeyError is raised.
950         '''
951         if not self.key:
952             raise TypeError, 'No key property set for class %s'%self.classname
953         
954         if type(keyvalue) is not _STRINGTYPE:
955             raise TypeError, '%r is not a string'%keyvalue
957         # XXX FIX ME -> this is a bit convoluted
958         # First we search the index view to get the id
959         # which is a quicker look up.
960         # Then we lookup the row with id=id
961         # if the _isdel property of the row is 0, return the
962         # string version of the id. (Why string version???)
963         #
964         # Otherwise, just lookup the non-indexed key
965         # in the non-index table and check the _isdel property
966         iv = self.getindexview()
967         if iv:
968             # look up the index view for the id,
969             # then instead of looking up the keyvalue, lookup the
970             # quicker id
971             ndx = iv.find(k=keyvalue)
972             if ndx > -1:
973                 view = self.getview()
974                 ndx = view.find(id=iv[ndx].i)
975                 if ndx > -1:
976                     row = view[ndx]
977                     if not row._isdel:
978                         return str(row.id)
979         else:
980             # perform the slower query
981             view = self.getview()
982             ndx = view.find({self.key:keyvalue})
983             if ndx > -1:
984                 row = view[ndx]
985                 if not row._isdel:
986                     return str(row.id)
988         raise KeyError, keyvalue
990     def destroy(self, id):
991         '''Destroy a node.
992         
993         WARNING: this method should never be used except in extremely rare
994                  situations where there could never be links to the node being
995                  deleted
997         WARNING: use retire() instead
999         WARNING: the properties of this node will not be available ever again
1001         WARNING: really, use retire() instead
1003         Well, I think that's enough warnings. This method exists mostly to
1004         support the session storage of the cgi interface.
1006         The node is completely removed from the hyperdb, including all journal
1007         entries. It will no longer be available, and will generally break code
1008         if there are any references to the node.
1009         '''
1010         view = self.getview(READWRITE)
1011         ndx = view.find(id=int(id))
1012         if ndx > -1:
1013             if self.key:
1014                 keyvalue = getattr(view[ndx], self.key)
1015                 iv = self.getindexview(READWRITE)
1016                 if iv:
1017                     ivndx = iv.find(k=keyvalue)
1018                     if ivndx > -1:
1019                         iv.delete(ivndx)
1020             view.delete(ndx)
1021             self.db.destroyjournal(self.classname, id)
1022             self.db.dirty = 1
1023         
1024     def find(self, **propspec):
1025         '''Get the ids of nodes in this class which link to the given nodes.
1027         'propspec'
1028              consists of keyword args propname={nodeid:1,}   
1029         'propname'
1030              must be the name of a property in this class, or a
1031              KeyError is raised.  That property must be a Link or
1032              Multilink property, or a TypeError is raised.
1034         Any node in this class whose propname property links to any of the
1035         nodeids will be returned. Used by the full text indexing, which knows
1036         that "foo" occurs in msg1, msg3 and file7; so we have hits on these
1037         issues::
1039             db.issue.find(messages={'1':1,'3':1}, files={'7':1})
1040         '''
1041         propspec = propspec.items()
1042         for propname, nodeid in propspec:
1043             # check the prop is OK
1044             prop = self.ruprops[propname]
1045             if (not isinstance(prop, hyperdb.Link) and
1046                     not isinstance(prop, hyperdb.Multilink)):
1047                 raise TypeError, "'%s' not a Link/Multilink property"%propname
1049         vws = []
1050         for propname, ids in propspec:
1051             if type(ids) is _STRINGTYPE:
1052                 ids = {int(ids):1}
1053             elif ids is None:
1054                 ids = {0:1}
1055             else:
1056                 d = {}
1057                 for id in ids.keys():
1058                     if id is None:
1059                         d[0] = 1
1060                     else:
1061                         d[int(id)] = 1
1062                 ids = d
1063             prop = self.ruprops[propname]
1064             view = self.getview()
1065             if isinstance(prop, hyperdb.Multilink):
1066                 def ff(row, nm=propname, ids=ids):
1067                     if not row._isdel:
1068                         sv = getattr(row, nm)
1069                         for sr in sv:
1070                             if ids.has_key(sr.fid):
1071                                 return 1
1072                     return 0
1073             else:
1074                 def ff(row, nm=propname, ids=ids):
1075                     return not row._isdel and ids.has_key(getattr(row, nm))
1076             ndxview = view.filter(ff)
1077             vws.append(ndxview.unique())
1079         # handle the empty match case
1080         if not vws:
1081             return []
1083         ndxview = vws[0]
1084         for v in vws[1:]:
1085             ndxview = ndxview.union(v)
1086         view = self.getview().remapwith(ndxview)
1087         rslt = []
1088         for row in view:
1089             rslt.append(str(row.id))
1090         return rslt
1091             
1093     def list(self):
1094         ''' Return a list of the ids of the active nodes in this class.
1095         '''
1096         l = []
1097         for row in self.getview().select(_isdel=0):
1098             l.append(str(row.id))
1099         return l
1101     def getnodeids(self):
1102         ''' Retrieve all the ids of the nodes for a particular Class.
1104             Set retired=None to get all nodes. Otherwise it'll get all the 
1105             retired or non-retired nodes, depending on the flag.
1106         '''
1107         l = []
1108         for row in self.getview():
1109             l.append(str(row.id))
1110         return l
1112     def count(self):
1113         return len(self.getview())
1115     def getprops(self, protected=1):
1116         # protected is not in ping's spec
1117         allprops = self.ruprops.copy()
1118         if protected and self.privateprops is not None:
1119             allprops.update(self.privateprops)
1120         return allprops
1122     def addprop(self, **properties):
1123         for key in properties.keys():
1124             if self.ruprops.has_key(key):
1125                 raise ValueError, "%s is already a property of %s"%(key,
1126                     self.classname)
1127         self.ruprops.update(properties)
1128         # Class structure has changed
1129         self.db.fastopen = 0
1130         view = self.__getview()
1131         self.db.commit()
1132     # ---- end of ping's spec
1134     def filter(self, search_matches, filterspec, sort=(None,None),
1135             group=(None,None)):
1136         '''Return a list of the ids of the active nodes in this class that
1137         match the 'filter' spec, sorted by the group spec and then the
1138         sort spec
1140         "filterspec" is {propname: value(s)}
1142         "sort" and "group" are (dir, prop) where dir is '+', '-' or None
1143         and prop is a prop name or None
1145         "search_matches" is {nodeid: marker}
1147         The filter must match all properties specificed - but if the
1148         property value to match is a list, any one of the values in the
1149         list may match for that property to match.
1150         '''        
1151         # search_matches is None or a set (dict of {nodeid: {propname:[nodeid,...]}})
1152         # filterspec is a dict {propname:value}
1153         # sort and group are (dir, prop) where dir is '+', '-' or None
1154         #                    and prop is a prop name or None
1156         timezone = self.db.getUserTimezone()
1158         where = {'_isdel':0}
1159         wherehigh = {}
1160         mlcriteria = {}
1161         regexes = {}
1162         orcriteria = {}
1163         for propname, value in filterspec.items():
1164             prop = self.ruprops.get(propname, None)
1165             if prop is None:
1166                 prop = self.privateprops[propname]
1167             if isinstance(prop, hyperdb.Multilink):
1168                 if value in ('-1', ['-1']):
1169                     value = []
1170                 elif type(value) is not _LISTTYPE:
1171                     value = [value]
1172                 # transform keys to ids
1173                 u = []
1174                 for item in value:
1175                     try:
1176                         item = int(item)
1177                     except (TypeError, ValueError):
1178                         item = int(self.db.getclass(prop.classname).lookup(item))
1179                     if item == -1:
1180                         item = 0
1181                     u.append(item)
1182                 mlcriteria[propname] = u
1183             elif isinstance(prop, hyperdb.Link):
1184                 if type(value) is not _LISTTYPE:
1185                     value = [value]
1186                 # transform keys to ids
1187                 u = []
1188                 for item in value:
1189                     try:
1190                         item = int(item)
1191                     except (TypeError, ValueError):
1192                         item = int(self.db.getclass(prop.classname).lookup(item))
1193                     if item == -1:
1194                         item = 0
1195                     u.append(item)
1196                 if len(u) == 1:
1197                     where[propname] = u[0]
1198                 else:
1199                     orcriteria[propname] = u
1200             elif isinstance(prop, hyperdb.String):
1201                 if type(value) is not type([]):
1202                     value = [value]
1203                 m = []
1204                 for v in value:
1205                     # simple glob searching
1206                     v = re.sub(r'([\|\{\}\\\.\+\[\]\(\)])', r'\\\1', v)
1207                     v = v.replace('?', '.')
1208                     v = v.replace('*', '.*?')
1209                     m.append(v)
1210                 regexes[propname] = re.compile('(%s)'%('|'.join(m)), re.I)
1211             elif propname == 'id':
1212                 where[propname] = int(value)
1213             elif isinstance(prop, hyperdb.Boolean):
1214                 if type(value) is _STRINGTYPE:
1215                     bv = value.lower() in ('yes', 'true', 'on', '1')
1216                 else:
1217                     bv = value
1218                 where[propname] = bv
1219             elif isinstance(prop, hyperdb.Date):
1220                 try:
1221                     # Try to filter on range of dates
1222                     date_rng = Range(value, date.Date, offset=timezone)
1223                     if date_rng.from_value:
1224                         t = date_rng.from_value.get_tuple()
1225                         where[propname] = int(calendar.timegm(t))
1226                     else:
1227                         # use minimum possible value to exclude items without
1228                         # 'prop' property
1229                         where[propname] = 0
1230                     if date_rng.to_value:
1231                         t = date_rng.to_value.get_tuple()
1232                         wherehigh[propname] = int(calendar.timegm(t))
1233                     else:
1234                         wherehigh[propname] = None
1235                 except ValueError:
1236                     # If range creation fails - ignore that search parameter
1237                     pass                        
1238             elif isinstance(prop, hyperdb.Interval):
1239                 try:
1240                     # Try to filter on range of intervals
1241                     date_rng = Range(value, date.Interval)
1242                     if date_rng.from_value:
1243                         #t = date_rng.from_value.get_tuple()
1244                         where[propname] = date_rng.from_value.serialise()
1245                     else:
1246                         # use minimum possible value to exclude items without
1247                         # 'prop' property
1248                         where[propname] = '-99999999999999'
1249                     if date_rng.to_value:
1250                         #t = date_rng.to_value.get_tuple()
1251                         wherehigh[propname] = date_rng.to_value.serialise()
1252                     else:
1253                         wherehigh[propname] = None
1254                 except ValueError:
1255                     # If range creation fails - ignore that search parameter
1256                     pass                        
1257             elif isinstance(prop, hyperdb.Number):
1258                 where[propname] = int(value)
1259             else:
1260                 where[propname] = str(value)
1261         v = self.getview()
1262         #print "filter start at  %s" % time.time() 
1263         if where:
1264             where_higherbound = where.copy()
1265             where_higherbound.update(wherehigh)
1266             v = v.select(where, where_higherbound)
1267         #print "filter where at  %s" % time.time() 
1269         if mlcriteria:
1270             # multilink - if any of the nodeids required by the
1271             # filterspec aren't in this node's property, then skip it
1272             def ff(row, ml=mlcriteria):
1273                 for propname, values in ml.items():
1274                     sv = getattr(row, propname)
1275                     if not values and sv:
1276                         return 0
1277                     for id in values:
1278                         if sv.find(fid=id) == -1:
1279                             return 0
1280                 return 1
1281             iv = v.filter(ff)
1282             v = v.remapwith(iv)
1284         #print "filter mlcrit at %s" % time.time() 
1285         
1286         if orcriteria:
1287             def ff(row, crit=orcriteria):
1288                 for propname, allowed in crit.items():
1289                     val = getattr(row, propname)
1290                     if val not in allowed:
1291                         return 0
1292                 return 1
1293             
1294             iv = v.filter(ff)
1295             v = v.remapwith(iv)
1296         
1297         #print "filter orcrit at %s" % time.time() 
1298         if regexes:
1299             def ff(row, r=regexes):
1300                 for propname, regex in r.items():
1301                     val = str(getattr(row, propname))
1302                     if not regex.search(val):
1303                         return 0
1304                 return 1
1305             
1306             iv = v.filter(ff)
1307             v = v.remapwith(iv)
1308         #print "filter regexs at %s" % time.time() 
1309         
1310         if sort or group:
1311             sortspec = []
1312             rev = []
1313             for dir, propname in group, sort:
1314                 if propname is None: continue
1315                 isreversed = 0
1316                 if dir == '-':
1317                     isreversed = 1
1318                 try:
1319                     prop = getattr(v, propname)
1320                 except AttributeError:
1321                     print "MK has no property %s" % propname
1322                     continue
1323                 propclass = self.ruprops.get(propname, None)
1324                 if propclass is None:
1325                     propclass = self.privateprops.get(propname, None)
1326                     if propclass is None:
1327                         print "Schema has no property %s" % propname
1328                         continue
1329                 if isinstance(propclass, hyperdb.Link):
1330                     linkclass = self.db.getclass(propclass.classname)
1331                     lv = linkclass.getview()
1332                     lv = lv.rename('id', propname)
1333                     v = v.join(lv, prop, 1)
1334                     if linkclass.getprops().has_key('order'):
1335                         propname = 'order'
1336                     else:
1337                         propname = linkclass.labelprop()
1338                     prop = getattr(v, propname)
1339                 if isreversed:
1340                     rev.append(prop)
1341                 sortspec.append(prop)
1342             v = v.sortrev(sortspec, rev)[:] #XXX Metakit bug
1343         #print "filter sort   at %s" % time.time() 
1344             
1345         rslt = []
1346         for row in v:
1347             id = str(row.id)
1348             if search_matches is not None:
1349                 if search_matches.has_key(id):
1350                     rslt.append(id)
1351             else:
1352                 rslt.append(id)
1353         return rslt
1354     
1355     def hasnode(self, nodeid):
1356         '''Determine if the given nodeid actually exists
1357         '''
1358         return int(nodeid) < self.maxid
1359     
1360     def labelprop(self, default_to_id=0):
1361         '''Return the property name for a label for the given node.
1363         This method attempts to generate a consistent label for the node.
1364         It tries the following in order:
1366         1. key property
1367         2. "name" property
1368         3. "title" property
1369         4. first property from the sorted property name list
1370         '''
1371         k = self.getkey()
1372         if  k:
1373             return k
1374         props = self.getprops()
1375         if props.has_key('name'):
1376             return 'name'
1377         elif props.has_key('title'):
1378             return 'title'
1379         if default_to_id:
1380             return 'id'
1381         props = props.keys()
1382         props.sort()
1383         return props[0]
1385     def stringFind(self, **requirements):
1386         '''Locate a particular node by matching a set of its String
1387         properties in a caseless search.
1389         If the property is not a String property, a TypeError is raised.
1390         
1391         The return is a list of the id of all nodes that match.
1392         '''
1393         for propname in requirements.keys():
1394             prop = self.properties[propname]
1395             if isinstance(not prop, hyperdb.String):
1396                 raise TypeError, "'%s' not a String property"%propname
1397             requirements[propname] = requirements[propname].lower()
1398         requirements['_isdel'] = 0
1399         
1400         l = []
1401         for row in self.getview().select(requirements):
1402             l.append(str(row.id))
1403         return l
1405     def addjournal(self, nodeid, action, params):
1406         '''Add a journal to the given nodeid,
1407         'action' may be:
1409             'create' or 'set' -- 'params' is a dictionary of property values
1410             'link' or 'unlink' -- 'params' is (classname, nodeid, propname)
1411             'retire' -- 'params' is None
1412         '''
1413         self.db.addjournal(self.classname, nodeid, action, params)
1415     def index(self, nodeid):
1416         ''' Add (or refresh) the node to search indexes '''
1417         # find all the String properties that have indexme
1418         for prop, propclass in self.getprops().items():
1419             if isinstance(propclass, hyperdb.String) and propclass.indexme:
1420                 # index them under (classname, nodeid, property)
1421                 self.db.indexer.add_text((self.classname, nodeid, prop),
1422                                 str(self.get(nodeid, prop)))
1424     def export_list(self, propnames, nodeid):
1425         ''' Export a node - generate a list of CSV-able data in the order
1426             specified by propnames for the given node.
1427         '''
1428         properties = self.getprops()
1429         l = []
1430         for prop in propnames:
1431             proptype = properties[prop]
1432             value = self.get(nodeid, prop)
1433             # "marshal" data where needed
1434             if value is None:
1435                 pass
1436             elif isinstance(proptype, hyperdb.Date):
1437                 value = value.get_tuple()
1438             elif isinstance(proptype, hyperdb.Interval):
1439                 value = value.get_tuple()
1440             elif isinstance(proptype, hyperdb.Password):
1441                 value = str(value)
1442             l.append(repr(value))
1444         # append retired flag
1445         l.append(repr(self.is_retired(nodeid)))
1447         return l
1448         
1449     def import_list(self, propnames, proplist):
1450         ''' Import a node - all information including "id" is present and
1451             should not be sanity checked. Triggers are not triggered. The
1452             journal should be initialised using the "creator" and "creation"
1453             information.
1455             Return the nodeid of the node imported.
1456         '''
1457         if self.db.journaltag is None:
1458             raise hyperdb.DatabaseError, 'Database open read-only'
1459         properties = self.getprops()
1461         d = {}
1462         view = self.getview(READWRITE)
1463         for i in range(len(propnames)):
1464             value = eval(proplist[i])
1465             if not value:
1466                 continue
1468             propname = propnames[i]
1469             if propname == 'id':
1470                 newid = value = int(value)
1471             elif propname == 'is retired':
1472                 # is the item retired?
1473                 if int(value):
1474                     d['_isdel'] = 1
1475                 continue
1476             elif value is None:
1477                 d[propname] = None
1478                 continue
1480             prop = properties[propname]
1481             if isinstance(prop, hyperdb.Date):
1482                 value = int(calendar.timegm(value))
1483             elif isinstance(prop, hyperdb.Interval):
1484                 value = date.Interval(value).serialise()
1485             elif isinstance(prop, hyperdb.Number):
1486                 value = int(value)
1487             elif isinstance(prop, hyperdb.Boolean):
1488                 value = int(value)
1489             elif isinstance(prop, hyperdb.Link) and value:
1490                 value = int(value)
1491             elif isinstance(prop, hyperdb.Multilink):
1492                 # we handle multilinks separately
1493                 continue
1494             d[propname] = value
1496         # possibly make a new node
1497         if not d.has_key('id'):
1498             d['id'] = newid = self.maxid
1499             self.maxid += 1
1501         # save off the node
1502         view.append(d)
1504         # fix up multilinks
1505         ndx = view.find(id=newid)
1506         row = view[ndx]
1507         for i in range(len(propnames)):
1508             value = eval(proplist[i])
1509             propname = propnames[i]
1510             if propname == 'is retired':
1511                 continue
1512             prop = properties[propname]
1513             if not isinstance(prop, hyperdb.Multilink):
1514                 continue
1515             sv = getattr(row, propname)
1516             for entry in value:
1517                 sv.append((int(entry),))
1519         self.db.dirty = 1
1520         creator = d.get('creator', 0)
1521         creation = d.get('creation', 0)
1522         self.db.addjournal(self.classname, str(newid), _CREATE, {}, creator,
1523             creation)
1524         return newid
1526     # --- used by Database
1527     def _commit(self):
1528         ''' called post commit of the DB.
1529             interested subclasses may override '''
1530         self.uncommitted = {}
1531         self.rbactions = []
1532         self.idcache = {}
1533     def _rollback(self):  
1534         ''' called pre rollback of the DB.
1535             interested subclasses may override '''
1536         for action in self.rbactions:
1537             action()
1538         self.rbactions = []
1539         self.uncommitted = {}
1540         self.idcache = {}
1541     def _clear(self):
1542         view = self.getview(READWRITE)
1543         if len(view):
1544             view[:] = []
1545             self.db.dirty = 1
1546         iv = self.getindexview(READWRITE)
1547         if iv:
1548             iv[:] = []
1549     def rollbackaction(self, action):
1550         ''' call this to register a callback called on rollback
1551             callback is removed on end of transaction '''
1552         self.rbactions.append(action)
1553     # --- internal
1554     def __getview(self):
1555         ''' Find the interface for a specific Class in the hyperdb.
1557             This method checks to see whether the schema has changed and
1558             re-works the underlying metakit structure if it has.
1559         '''
1560         db = self.db._db
1561         view = db.view(self.classname)
1562         mkprops = view.structure()
1564         # if we have structure in the database, and the structure hasn't
1565         # changed
1566         # note on view.ordered ->        
1567         # return a metakit view ordered on the id column
1568         # id is always the first column.  This speeds up
1569         # look-ups on the id column.
1570         
1571         if mkprops and self.db.fastopen:
1572             return view.ordered(1)
1574         # is the definition the same?
1575         for nm, rutyp in self.ruprops.items():
1576             for mkprop in mkprops:
1577                 if mkprop.name == nm:
1578                     break
1579             else:
1580                 mkprop = None
1581             if mkprop is None:
1582                 break
1583             if _typmap[rutyp.__class__] != mkprop.type:
1584                 break
1585         else:
1586             
1587             return view.ordered(1)
1588         # The schema has changed.  We need to create or restructure the mk view
1589         # id comes first, so we can use view.ordered(1) so that
1590         # MK will order it for us to allow binary-search quick lookups on
1591         # the id column
1592         self.db.dirty = 1
1593         s = ["%s[id:I" % self.classname]
1595         # these columns will always be added, we can't trample them :)
1596         _columns = {"id":"I", "_isdel":"I", "activity":"I", "creation":"I", "creator":"I"}
1598         for nm, rutyp in self.ruprops.items():
1599             mktyp = _typmap[rutyp.__class__].upper()
1600             if nm in _columns and _columns[nm] != mktyp:
1601                 # oops, two columns with the same name and different properties
1602                raise MKBackendError("column %s for table %sis defined with multiple types"%(nm, self.classname))
1603             _columns[nm] = mktyp
1604             s.append('%s:%s' % (nm, mktyp))
1605             if mktyp == 'V':
1606                 s[-1] += ('[fid:I]')
1608         # XXX FIX ME -> in some tests, creation:I becomes creation:S is this
1609         # okay?  Does this need to be supported?
1610         s.append('_isdel:I,activity:I,creation:I,creator:I]')
1611         view = self.db._db.getas(','.join(s))
1612         self.db.commit()
1613         return view.ordered(1)
1614     def getview(self, RW=0):
1615         # XXX FIX ME -> The RW flag doesn't do anything.
1616         return self.db._db.view(self.classname).ordered(1)
1617     def getindexview(self, RW=0):
1618         # XXX FIX ME -> The RW flag doesn't do anything.
1619         tablename = "_%s.%s"%(self.classname, self.key)
1620         return self.db._db.view("_%s" % tablename).ordered(1)
1622 def _fetchML(sv):
1623     l = []
1624     for row in sv:
1625         if row.fid:
1626             l.append(str(row.fid))
1627     return l
1629 def _fetchPW(s):
1630     ''' Convert to a password.Password unless the password is '' which is
1631         our sentinel for "unset".
1632     '''
1633     if s == '':
1634         return None
1635     p = password.Password()
1636     p.unpack(s)
1637     return p
1639 def _fetchLink(n):
1640     ''' Return None if the link is 0 - otherwise strify it.
1641     '''
1642     return n and str(n) or None
1644 def _fetchDate(n):
1645     ''' Convert the timestamp to a date.Date instance - unless it's 0 which
1646         is our sentinel for "unset".
1647     '''
1648     if n == 0:
1649         return None
1650     return date.Date(time.gmtime(n))
1652 def _fetchInterval(n):
1653     ''' Convert to a date.Interval unless the interval is '' which is our
1654         sentinel for "unset".
1655     '''
1656     if n == '':
1657         return None
1658     return date.Interval(n)
1660 # Converters for boolean and numbers to properly
1661 # return None values.
1662 # These are in conjunction with the setters above
1663 #  look for hyperdb.Boolean and hyperdb.Number
1664 if BACKWARDS_COMPATIBLE:
1665     def getBoolean(bool): return bool
1666     def getNumber(number): return number
1667 else:
1668     def getBoolean(bool):
1669         if not bool: res = None
1670         else: res = bool - 1
1671         return res
1672     
1673     def getNumber(number):
1674         if number == 0: res = None
1675         elif number < 0: res = number
1676         else: res = number - 1
1677         return res
1679 _converters = {
1680     hyperdb.Date   : _fetchDate,
1681     hyperdb.Link   : _fetchLink,
1682     hyperdb.Multilink : _fetchML,
1683     hyperdb.Interval  : _fetchInterval,
1684     hyperdb.Password  : _fetchPW,
1685     hyperdb.Boolean   : getBoolean,
1686     hyperdb.Number    : getNumber,
1687     hyperdb.String    : lambda s: s and str(s) or None,
1688 }                
1690 class FileName(hyperdb.String):
1691     isfilename = 1            
1693 _typmap = {
1694     FileName : 'S',
1695     hyperdb.String : 'S',
1696     hyperdb.Date   : 'I',
1697     hyperdb.Link   : 'I',
1698     hyperdb.Multilink : 'V',
1699     hyperdb.Interval  : 'S',
1700     hyperdb.Password  : 'S',
1701     hyperdb.Boolean   : 'I',
1702     hyperdb.Number    : 'I',
1704 class FileClass(Class, hyperdb.FileClass):
1705     ''' like Class but with a content property
1706     '''
1707     default_mime_type = 'text/plain'
1708     def __init__(self, db, classname, **properties):
1709         properties['content'] = FileName()
1710         if not properties.has_key('type'):
1711             properties['type'] = hyperdb.String()
1712         Class.__init__(self, db, classname, **properties)
1714     def get(self, nodeid, propname, default=_marker, cache=1):
1715         x = Class.get(self, nodeid, propname, default)
1716         poss_msg = 'Possibly an access right configuration problem.'
1717         if propname == 'content':
1718             if x.startswith('file:'):
1719                 fnm = x[5:]
1720                 try:
1721                     f = open(fnm, 'rb')
1722                 except IOError, (strerror):
1723                     # XXX by catching this we donot see an error in the log.
1724                     return 'ERROR reading file: %s%s\n%s\n%s'%(
1725                             self.classname, nodeid, poss_msg, strerror)
1726                 x = f.read()
1727                 f.close()
1728         return x
1730     def create(self, **propvalues):
1731         if not propvalues:
1732             raise ValueError, "Need something to create!"
1733         self.fireAuditors('create', None, propvalues)
1734         content = propvalues['content']
1735         del propvalues['content']
1736         newid = Class.create_inner(self, **propvalues)
1737         if not content:
1738             return newid
1739         nm = bnm = '%s%s' % (self.classname, newid)
1740         sd = str(int(int(newid) / 1000))
1741         d = os.path.join(self.db.config.DATABASE, 'files', self.classname, sd)
1742         if not os.path.exists(d):
1743             os.makedirs(d)
1744         nm = os.path.join(d, nm)
1745         open(nm, 'wb').write(content)
1746         self.set(newid, content = 'file:'+nm)
1747         mimetype = propvalues.get('type', self.default_mime_type)
1748         self.db.indexer.add_text((self.classname, newid, 'content'), content,
1749             mimetype)
1750         def undo(fnm=nm, action1=os.remove, indexer=self.db.indexer):
1751             action1(fnm)
1752         self.rollbackaction(undo)
1753         return newid
1755     def index(self, nodeid):
1756         Class.index(self, nodeid)
1757         mimetype = self.get(nodeid, 'type')
1758         if not mimetype:
1759             mimetype = self.default_mime_type
1760         self.db.indexer.add_text((self.classname, nodeid, 'content'),
1761                     self.get(nodeid, 'content'), mimetype)
1762  
1763 class IssueClass(Class, roundupdb.IssueClass):
1764     ''' The newly-created class automatically includes the "messages",
1765         "files", "nosy", and "superseder" properties.  If the 'properties'
1766         dictionary attempts to specify any of these properties or a
1767         "creation" or "activity" property, a ValueError is raised.
1768     '''
1769     def __init__(self, db, classname, **properties):
1770         if not properties.has_key('title'):
1771             properties['title'] = hyperdb.String(indexme='yes')
1772         if not properties.has_key('messages'):
1773             properties['messages'] = hyperdb.Multilink("msg")
1774         if not properties.has_key('files'):
1775             properties['files'] = hyperdb.Multilink("file")
1776         if not properties.has_key('nosy'):
1777             # note: journalling is turned off as it really just wastes
1778             # space. this behaviour may be overridden in an instance
1779             properties['nosy'] = hyperdb.Multilink("user", do_journal="no")
1780         if not properties.has_key('superseder'):
1781             properties['superseder'] = hyperdb.Multilink(classname)
1782         Class.__init__(self, db, classname, **properties)
1783         
1784 CURVERSION = 2
1786 class Indexer(indexer.Indexer):
1787     disallows = {'THE':1, 'THIS':1, 'ZZZ':1, 'THAT':1, 'WITH':1}
1788     def __init__(self, path, datadb):
1789         self.path = os.path.join(path, 'index.mk4')
1790         self.db = metakit.storage(self.path, 1)
1791         self.datadb = datadb
1792         self.reindex = 0
1793         v = self.db.view('version')
1794         if not v.structure():
1795             v = self.db.getas('version[vers:I]')
1796             self.db.commit()
1797             v.append(vers=CURVERSION)
1798             self.reindex = 1
1799         elif v[0].vers != CURVERSION:
1800             v[0].vers = CURVERSION
1801             self.reindex = 1
1802         if self.reindex:
1803             self.db.getas('ids[tblid:I,nodeid:I,propid:I,ignore:I]')
1804             self.db.getas('index[word:S,hits[pos:I]]')
1805             self.db.commit()
1806             self.reindex = 1
1807         self.changed = 0
1808         self.propcache = {}
1810     def close(self):
1811         '''close the indexing database'''
1812         del self.db
1813         self.db = None
1814   
1815     def force_reindex(self):
1816         '''Force a reindexing of the database.  This essentially
1817         empties the tables ids and index and sets a flag so
1818         that the databases are reindexed'''
1819         v = self.db.view('ids')
1820         v[:] = []
1821         v = self.db.view('index')
1822         v[:] = []
1823         self.db.commit()
1824         self.reindex = 1
1826     def should_reindex(self):
1827         '''returns True if the indexes need to be rebuilt'''
1828         return self.reindex
1830     def _getprops(self, classname):
1831         props = self.propcache.get(classname, None)
1832         if props is None:
1833             props = self.datadb.view(classname).structure()
1834             props = [prop.name for prop in props]
1835             self.propcache[classname] = props
1836         return props
1838     def _getpropid(self, classname, propname):
1839         return self._getprops(classname).index(propname)
1841     def _getpropname(self, classname, propid):
1842         return self._getprops(classname)[propid]
1844     def add_text(self, identifier, text, mime_type='text/plain'):
1845         if mime_type != 'text/plain':
1846             return
1847         classname, nodeid, property = identifier
1848         tbls = self.datadb.view('tables')
1849         tblid = tbls.find(name=classname)
1850         if tblid < 0:
1851             raise KeyError, "unknown class %r"%classname
1852         nodeid = int(nodeid)
1853         propid = self._getpropid(classname, property)
1854         ids = self.db.view('ids')
1855         oldpos = ids.find(tblid=tblid,nodeid=nodeid,propid=propid,ignore=0)
1856         if oldpos > -1:
1857             ids[oldpos].ignore = 1
1858             self.changed = 1
1859         pos = ids.append(tblid=tblid,nodeid=nodeid,propid=propid)
1860         
1861         wordlist = re.findall(r'\b\w{2,25}\b', text.upper())
1862         words = {}
1863         for word in wordlist:
1864             if not self.disallows.has_key(word):
1865                 words[word] = 1
1866         words = words.keys()
1867         
1868         index = self.db.view('index').ordered(1)
1869         for word in words:
1870             ndx = index.find(word=word)
1871             if ndx < 0:
1872                 index.append(word=word)
1873                 ndx = index.find(word=word)
1874             index[ndx].hits.append(pos=pos)
1875             self.changed = 1
1877     def find(self, wordlist):
1878         '''look up all the words in the wordlist.
1879         If none are found return an empty dictionary
1880         * more rules here
1881         '''        
1882         hits = None
1883         index = self.db.view('index').ordered(1)
1884         for word in wordlist:
1885             word = word.upper()
1886             if not 2 < len(word) < 26:
1887                 continue
1888             ndx = index.find(word=word)
1889             if ndx < 0:
1890                 return {}
1891             if hits is None:
1892                 hits = index[ndx].hits
1893             else:
1894                 hits = hits.intersect(index[ndx].hits)
1895             if len(hits) == 0:
1896                 return {}
1897         if hits is None:
1898             return {}
1899         rslt = {}
1900         ids = self.db.view('ids').remapwith(hits)
1901         tbls = self.datadb.view('tables')
1902         for i in range(len(ids)):
1903             hit = ids[i]
1904             if not hit.ignore:
1905                 classname = tbls[hit.tblid].name
1906                 nodeid = str(hit.nodeid)
1907                 property = self._getpropname(classname, hit.propid)
1908                 rslt[i] = (classname, nodeid, property)
1909         return rslt
1911     def save_index(self):
1912         if self.changed:
1913             self.db.commit()
1914         self.changed = 0
1916     def rollback(self):
1917         if self.changed:
1918             self.db.rollback()
1919             self.db = metakit.storage(self.path, 1)
1920         self.changed = 0