Code

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