Code

help for winzip users
[roundup.git] / roundup / backends / back_metakit.py
1 # $Id: back_metakit.py,v 1.70 2004-04-02 05: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 = 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))
215     def setjournal(self, tablenm, nodeid, journal):
216         '''Set the journal to the "journal" list.'''
217         tblid = self.tables.find(name=tablenm)
218         if tblid == -1:
219             tblid = self.tables.append(name=tablenm)
220         for nodeid, date, user, action, params in journal:
221             # tableid:I,nodeid:I,date:I,user:I,action:I,params:B
222             self.hist.append(tableid=tblid,
223                              nodeid=int(nodeid),
224                              date=date,
225                              action=action,
226                              user=user,
227                              params=marshal.dumps(params))
229     def getjournal(self, tablenm, nodeid):
230         ''' get the journal for id
231         '''
232         rslt = []
233         tblid = self.tables.find(name=tablenm)
234         if tblid == -1:
235             return rslt
236         q = self.hist.select(tableid=tblid, nodeid=int(nodeid))
237         if len(q) == 0:
238             raise IndexError, "no history for id %s in %s" % (nodeid, tablenm)
239         i = 0
240         #userclass = self.getclass('user')
241         for row in q:
242             try:
243                 params = marshal.loads(row.params)
244             except ValueError:
245                 print "history couldn't unmarshal %r" % row.params
246                 params = {}
247             #usernm = userclass.get(str(row.user), 'username')
248             dt = date.Date(time.gmtime(row.date))
249             #rslt.append((nodeid, dt, usernm, _actionnames[row.action], params))
250             rslt.append((nodeid, dt, str(row.user), _actionnames[row.action],
251                 params))
252         return rslt
254     def destroyjournal(self, tablenm, nodeid):
255         nodeid = int(nodeid)
256         tblid = self.tables.find(name=tablenm)
257         if tblid == -1:
258             return 
259         i = 0
260         hist = self.hist
261         while i < len(hist):
262             if hist[i].tableid == tblid and hist[i].nodeid == nodeid:
263                 hist.delete(i)
264             else:
265                 i = i + 1
266         self.dirty = 1
267         
268     def close(self):
269         ''' Close off the connection.
270         '''
271         # de-reference count the metakit databases,
272         #  as this is the only way they will be closed
273         for cl in self.classes.values():
274             cl.db = None
275         self._db = None
276         if self.lockfile is not None:
277             locking.release_lock(self.lockfile)
278         if _dbs.has_key(self.config.DATABASE):
279             del _dbs[self.config.DATABASE]
280         if self.lockfile is not None:
281             self.lockfile.close()
282             self.lockfile = None
283         self.classes = {}
285         # force the indexer to close
286         self.indexer.close()
287         self.indexer = None
289     # --- internal
290     def __open(self):
291         ''' Open the metakit database
292         '''
293         # make the database dir if it doesn't exist
294         if not os.path.exists(self.config.DATABASE):
295             os.makedirs(self.config.DATABASE)
297         # figure the file names
298         self.dbnm = db = os.path.join(self.config.DATABASE, 'tracker.mk4')
299         lockfilenm = db[:-3]+'lck'
301         # get the database lock
302         self.lockfile = locking.acquire_lock(lockfilenm)
303         self.lockfile.write(str(os.getpid()))
304         self.lockfile.flush()
306         # see if the schema has changed since last db access
307         self.fastopen = 0
308         if os.path.exists(db):
309             dbtm = os.path.getmtime(db)
310             pkgnm = self.config.__name__.split('.')[0]
311             schemamod = sys.modules.get(pkgnm+'.dbinit', None)
312             if schemamod:
313                 if os.path.exists(schemamod.__file__):
314                     schematm = os.path.getmtime(schemamod.__file__)
315                     if schematm < dbtm:
316                         # found schema mod - it's older than the db
317                         self.fastopen = 1
318                 else:
319                      # can't find schemamod - must be frozen
320                     self.fastopen = 1
322         # open the db
323         db = metakit.storage(db, 1)
324         hist = db.view('history')
325         tables = db.view('tables')
326         if not self.fastopen:
327             # create the database if it's brand new
328             if not hist.structure():
329                 hist = db.getas('history[tableid:I,nodeid:I,date:I,user:I,action:I,params:B]')
330             if not tables.structure():
331                 tables = db.getas('tables[name:S]')
332             db.commit()
334         # we now have an open, initialised database
335         self.tables = tables
336         self.hist = hist
337         return db
339     def setid(self, classname, maxid):
340         ''' No-op in metakit
341         '''
342         pass
344     def numfiles(self):
345         '''Get number of files in storage, even across subdirectories.
346         '''
347         files_dir = os.path.join(self.config.DATABASE, 'files')
348         return files_in_dir(files_dir)
349         
350 _STRINGTYPE = type('')
351 _LISTTYPE = type([])
352 _CREATE, _SET, _RETIRE, _LINK, _UNLINK, _RESTORE = range(6)
354 _actionnames = {
355     _CREATE : 'create',
356     _SET : 'set',
357     _RETIRE : 'retire',
358     _RESTORE : 'restore',
359     _LINK : 'link',
360     _UNLINK : 'unlink',
363 _marker = []
365 _ALLOWSETTINGPRIVATEPROPS = 0
367 class Class(hyperdb.Class):
368     ''' The handle to a particular class of nodes in a hyperdatabase.
369         
370         All methods except __repr__ and getnode must be implemented by a
371         concrete backend Class of which this is one.
372     '''
374     privateprops = None
375     def __init__(self, db, classname, **properties):
376         if (properties.has_key('creation') or properties.has_key('activity')
377             or properties.has_key('creator') or properties.has_key('actor')):
378             raise ValueError, '"creation", "activity" and "creator" are '\
379                   'reserved'
380         if hasattr(db, classname):
381             raise ValueError, "Class %s already exists"%classname
383         self.db = db
384         self.classname = classname
385         self.key = None
386         self.ruprops = properties
387         self.privateprops = { 'id' : hyperdb.String(),
388                               'activity' : hyperdb.Date(),
389                               'actor' : hyperdb.Link('user'),
390                               'creation' : hyperdb.Date(),
391                               'creator'  : hyperdb.Link('user') }
393         # event -> list of callables
394         self.auditors = {'create': [], 'set': [], 'retire': [], 'restore': []}
395         self.reactors = {'create': [], 'set': [], 'retire': [], 'restore': []}
397         view = self.__getview()
398         self.maxid = 1
399         if view:
400             self.maxid = view[-1].id + 1
401         self.uncommitted = {}
402         self.comactions = []
403         self.rbactions = []
405         # people reach inside!!
406         self.properties = self.ruprops
407         self.db.addclass(self)
408         self.idcache = {}
410         # default is to journal changes
411         self.do_journal = 1
413     def enableJournalling(self):
414         '''Turn journalling on for this class
415         '''
416         self.do_journal = 1
418     def disableJournalling(self):
419         '''Turn journalling off for this class
420         '''
421         self.do_journal = 0
422         
423     #
424     # Detector/reactor interface
425     #
426     def audit(self, event, detector):
427         '''Register a detector
428         '''
429         l = self.auditors[event]
430         if detector not in l:
431             self.auditors[event].append(detector)
433     def fireAuditors(self, action, nodeid, newvalues):
434        '''Fire all registered auditors.
435         '''
436        for audit in self.auditors[action]:
437             audit(self.db, self, nodeid, newvalues)
439     def react(self, event, detector):
440        '''Register a reactor
441        '''
442        l = self.reactors[event]
443        if detector not in l:
444            self.reactors[event].append(detector)
446     def fireReactors(self, action, nodeid, oldvalues):
447         '''Fire all registered reactors.
448         '''
449         for react in self.reactors[action]:
450             react(self.db, self, nodeid, oldvalues)
451             
452     # --- the hyperdb.Class methods
453     def create(self, **propvalues):
454         ''' Create a new node of this class and return its id.
456         The keyword arguments in 'propvalues' map property names to values.
458         The values of arguments must be acceptable for the types of their
459         corresponding properties or a TypeError is raised.
460         
461         If this class has a key property, it must be present and its value
462         must not collide with other key strings or a ValueError is raised.
463         
464         Any other properties on this class that are missing from the
465         'propvalues' dictionary are set to None.
466         
467         If an id in a link or multilink property does not refer to a valid
468         node, an IndexError is raised.
469         '''
470         if not propvalues:
471             raise ValueError, "Need something to create!"
472         self.fireAuditors('create', None, propvalues)
473         newid = self.create_inner(**propvalues)
474         self.fireReactors('create', newid, None)
475         return newid
477     def create_inner(self, **propvalues):
478        ''' Called by create, in-between the audit and react calls.
479        '''
480        rowdict = {}
481        rowdict['id'] = newid = self.maxid
482        self.maxid += 1
483        ndx = self.getview(READWRITE).append(rowdict)
484        propvalues['#ISNEW'] = 1
485        try:
486            self.set(str(newid), **propvalues)
487        except Exception:
488            self.maxid -= 1
489            raise
490        return str(newid)
491     
492     def get(self, nodeid, propname, default=_marker, cache=1):
493         '''Get the value of a property on an existing node of this class.
495         'nodeid' must be the id of an existing node of this class or an
496         IndexError is raised.  'propname' must be the name of a property
497         of this class or a KeyError is raised.
499         'cache' exists for backwards compatibility, and is not used.
500         '''
501         view = self.getview()
502         id = int(nodeid)
503         if cache == 0:
504             oldnode = self.uncommitted.get(id, None)
505             if oldnode and oldnode.has_key(propname):
506                 raw = oldnode[propname]
507                 converter = _converters.get(rutyp.__class__, None)
508                 if converter:
509                     return converter(raw)
510                 return raw
511         ndx = self.idcache.get(id, None)
513         if ndx is None:
514             ndx = view.find(id=id)
515             if ndx < 0:
516                 raise IndexError, "%s has no node %s" % (self.classname, nodeid)
517             self.idcache[id] = ndx
518         try:
519             raw = getattr(view[ndx], propname)
520         except AttributeError:
521             raise KeyError, propname
522         rutyp = self.ruprops.get(propname, None)
524         if rutyp is None:
525             rutyp = self.privateprops[propname]
527         converter = _converters.get(rutyp.__class__, None)
528         if converter:
529             raw = converter(raw)
530         return raw
531         
532     def set(self, nodeid, **propvalues):
533         '''Modify a property on an existing node of this class.
534         
535         'nodeid' must be the id of an existing node of this class or an
536         IndexError is raised.
538         Each key in 'propvalues' must be the name of a property of this
539         class or a KeyError is raised.
541         All values in 'propvalues' must be acceptable types for their
542         corresponding properties or a TypeError is raised.
544         If the value of the key property is set, it must not collide with
545         other key strings or a ValueError is raised.
547         If the value of a Link or Multilink property contains an invalid
548         node id, a ValueError is raised.
549         '''
550         self.fireAuditors('set', nodeid, propvalues)
551         propvalues, oldnode = self.set_inner(nodeid, **propvalues)
552         self.fireReactors('set', nodeid, oldnode)
554     def set_inner(self, nodeid, **propvalues):
555         '''Called outside of auditors'''
556         isnew = 0
557         if propvalues.has_key('#ISNEW'):
558             isnew = 1
559             del propvalues['#ISNEW']
561         if propvalues.has_key('id'):
562             raise KeyError, '"id" is reserved'
563         if self.db.journaltag is None:
564             raise hyperdb.DatabaseError, 'Database open read-only'
565         view = self.getview(READWRITE)
567         # node must exist & not be retired
568         id = int(nodeid)
569         ndx = view.find(id=id)
570         if ndx < 0:
571             raise IndexError, "%s has no node %s" % (self.classname, nodeid)
572         row = view[ndx]
573         if row._isdel:
574             raise IndexError, "%s has no node %s" % (self.classname, nodeid)
575         oldnode = self.uncommitted.setdefault(id, {})
576         changes = {}
578         for key, value in propvalues.items():
579             # this will raise the KeyError if the property isn't valid
580             # ... we don't use getprops() here because we only care about
581             # the writeable properties.
582             if _ALLOWSETTINGPRIVATEPROPS:
583                 prop = self.ruprops.get(key, None)
584                 if not prop:
585                     prop = self.privateprops[key]
586             else:
587                 prop = self.ruprops[key]
588             converter = _converters.get(prop.__class__, lambda v: v)
589             # if the value's the same as the existing value, no sense in
590             # doing anything
591             oldvalue = converter(getattr(row, key))
592             if  value == oldvalue:
593                 del propvalues[key]
594                 continue
595             
596             # check to make sure we're not duplicating an existing key
597             if key == self.key:
598                 iv = self.getindexview(READWRITE)
599                 ndx = iv.find(k=value)
600                 if ndx == -1:
601                     iv.append(k=value, i=row.id)
602                     if not isnew:
603                         ndx = iv.find(k=oldvalue)
604                         if ndx > -1:
605                             iv.delete(ndx)
606                 else:
607                     raise ValueError, 'node with key "%s" exists'%value
609             # do stuff based on the prop type
610             if isinstance(prop, hyperdb.Link):
611                 link_class = prop.classname
612                 # must be a string or None
613                 if value is not None and not isinstance(value, type('')):
614                     raise ValueError, 'property "%s" link value be a string'%(
615                         key)
616                 # Roundup sets to "unselected" by passing None
617                 if value is None:
618                     value = 0   
619                 # if it isn't a number, it's a key
620                 try:
621                     int(value)
622                 except ValueError:
623                     try:
624                         value = self.db.getclass(link_class).lookup(value)
625                     except (TypeError, KeyError):
626                         raise IndexError, 'new property "%s": %s not a %s'%(
627                             key, value, prop.classname)
629                 if (value is not None and
630                         not self.db.getclass(link_class).hasnode(value)):
631                     raise IndexError, '%s has no node %s'%(link_class, value)
633                 setattr(row, key, int(value))
634                 changes[key] = oldvalue
635                 
636                 if self.do_journal and prop.do_journal:
637                     # register the unlink with the old linked node
638                     if oldvalue:
639                         self.db.addjournal(link_class, oldvalue, _UNLINK,
640                             (self.classname, str(row.id), key))
642                     # register the link with the newly linked node
643                     if value:
644                         self.db.addjournal(link_class, value, _LINK,
645                             (self.classname, str(row.id), key))
647             elif isinstance(prop, hyperdb.Multilink):
648                 if value is not None and type(value) != _LISTTYPE:
649                     raise TypeError, 'new property "%s" not a list of ids'%key
650                 link_class = prop.classname
651                 l = []
652                 if value is None:
653                     value = []
654                 for entry in value:
655                     if type(entry) != _STRINGTYPE:
656                         raise ValueError, 'new property "%s" link value ' \
657                             'must be a string'%key
658                     # if it isn't a number, it's a key
659                     try:
660                         int(entry)
661                     except ValueError:
662                         try:
663                             entry = self.db.getclass(link_class).lookup(entry)
664                         except (TypeError, KeyError):
665                             raise IndexError, 'new property "%s": %s not a %s'%(
666                                 key, entry, prop.classname)
667                     l.append(entry)
668                 propvalues[key] = value = l
670                 # handle removals
671                 rmvd = []
672                 for id in oldvalue:
673                     if id not in value:
674                         rmvd.append(id)
675                         # register the unlink with the old linked node
676                         if self.do_journal and prop.do_journal:
677                             self.db.addjournal(link_class, id, _UNLINK,
678                                 (self.classname, str(row.id), key))
680                 # handle additions
681                 adds = []
682                 for id in value:
683                     if id not in oldvalue:
684                         if not self.db.getclass(link_class).hasnode(id):
685                             raise IndexError, '%s has no node %s'%(
686                                 link_class, id)
687                         adds.append(id)
688                         # register the link with the newly linked node
689                         if self.do_journal and prop.do_journal:
690                             self.db.addjournal(link_class, id, _LINK,
691                                 (self.classname, str(row.id), key))
693                 # perform the modifications on the actual property value
694                 sv = getattr(row, key)
695                 i = 0
696                 while i < len(sv):
697                     if str(sv[i].fid) in rmvd:
698                         sv.delete(i)
699                     else:
700                         i += 1
701                 for id in adds:
702                     sv.append(fid=int(id))
704                 # figure the journal entry
705                 l = []
706                 if adds:
707                     l.append(('+', adds))
708                 if rmvd:
709                     l.append(('-', rmvd))
710                 if l:
711                     changes[key] = tuple(l)
712                 #changes[key] = oldvalue
714                 if not rmvd and not adds:
715                     del propvalues[key]
717             elif isinstance(prop, hyperdb.String):
718                 if value is not None and type(value) != _STRINGTYPE:
719                     raise TypeError, 'new property "%s" not a string'%key
720                 if value is None:
721                     value = ''
722                 setattr(row, key, value)
723                 changes[key] = oldvalue
724                 if hasattr(prop, 'isfilename') and prop.isfilename:
725                     propvalues[key] = os.path.basename(value)
726                 if prop.indexme:
727                     self.db.indexer.add_text((self.classname, nodeid, key),
728                         value, 'text/plain')
730             elif isinstance(prop, hyperdb.Password):
731                 if value is not None and not isinstance(value, password.Password):
732                     raise TypeError, 'new property "%s" not a Password'% key
733                 if value is None:
734                     value = ''
735                 setattr(row, key, str(value))
736                 changes[key] = str(oldvalue)
737                 propvalues[key] = str(value)
739             elif isinstance(prop, hyperdb.Date):
740                 if value is not None and not isinstance(value, date.Date):
741                     raise TypeError, 'new property "%s" not a Date'% key
742                 if value is None:
743                     setattr(row, key, 0)
744                 else:
745                     setattr(row, key, int(calendar.timegm(value.get_tuple())))
746                 changes[key] = str(oldvalue)
747                 propvalues[key] = str(value)
749             elif isinstance(prop, hyperdb.Interval):
750                 if value is not None and not isinstance(value, date.Interval):
751                     raise TypeError, 'new property "%s" not an Interval'% key
752                 if value is None:
753                     setattr(row, key, '')
754                 else:
755                     # kedder: we should store interval values serialized
756                     setattr(row, key, value.serialise())
757                 changes[key] = str(oldvalue)
758                 propvalues[key] = str(value)
759  
760             elif isinstance(prop, hyperdb.Number):
761                 if value is None:
762                     v = 0
763                 else:
764                     try:
765                         v = int(value)
766                     except ValueError:
767                         raise TypeError, "%s (%s) is not numeric"%(key, repr(value))
768                     if not BACKWARDS_COMPATIBLE:
769                         if v >=0:
770                             v = v + 1
771                 setattr(row, key, v)
772                 changes[key] = oldvalue
773                 propvalues[key] = value
775             elif isinstance(prop, hyperdb.Boolean):
776                 if value is None:
777                     bv = 0
778                 elif value not in (0,1):
779                     raise TypeError, "%s (%s) is not boolean"%(key, repr(value))
780                 else:
781                     bv = value
782                     if not BACKWARDS_COMPATIBLE:
783                         bv += 1
784                 setattr(row, key, bv)
785                 changes[key] = oldvalue
786                 propvalues[key] = value
788             oldnode[key] = oldvalue
790         # nothing to do?
791         if not propvalues:
792             return propvalues, oldnode
793         if not propvalues.has_key('activity'):
794             row.activity = int(time.time())
795         if not propvalues.has_key('actor'):
796             row.actor = int(self.db.getuid())
797         if isnew:
798             if not row.creation:
799                 row.creation = int(time.time())
800             if not row.creator:
801                 row.creator = int(self.db.getuid())
803         self.db.dirty = 1
805         if self.do_journal:
806             if isnew:
807                 self.db.addjournal(self.classname, nodeid, _CREATE, {})
808             else:
809                 self.db.addjournal(self.classname, nodeid, _SET, changes)
811         return propvalues, oldnode
812     
813     def retire(self, nodeid):
814         '''Retire a node.
815         
816         The properties on the node remain available from the get() method,
817         and the node's id is never reused.
818         
819         Retired nodes are not returned by the find(), list(), or lookup()
820         methods, and other nodes may reuse the values of their key properties.
821         '''
822         if self.db.journaltag is None:
823             raise hyperdb.DatabaseError, 'Database open read-only'
824         self.fireAuditors('retire', nodeid, None)
825         view = self.getview(READWRITE)
826         ndx = view.find(id=int(nodeid))
827         if ndx < 0:
828             raise KeyError, "nodeid %s not found" % nodeid
830         row = view[ndx]
831         oldvalues = self.uncommitted.setdefault(row.id, {})
832         oldval = oldvalues['_isdel'] = row._isdel
833         row._isdel = 1
835         if self.do_journal:
836             self.db.addjournal(self.classname, nodeid, _RETIRE, {})
837         if self.key:
838             iv = self.getindexview(READWRITE)
839             ndx = iv.find(k=getattr(row, self.key))
840             # find is broken with multiple attribute lookups
841             # on ordered views
842             #ndx = iv.find(k=getattr(row, self.key),i=row.id)
843             if ndx > -1 and iv[ndx].i == row.id:
844                 iv.delete(ndx)
846         self.db.dirty = 1
847         self.fireReactors('retire', nodeid, None)
849     def restore(self, nodeid):
850         '''Restore a retired node.
852         Make node available for all operations like it was before retirement.
853         '''
854         if self.db.journaltag is None:
855             raise hyperdb.DatabaseError, 'Database open read-only'
857         # check if key property was overrided
858         key = self.getkey()
859         keyvalue = self.get(nodeid, key)
860         
861         try:
862             id = self.lookup(keyvalue)
863         except KeyError:
864             pass
865         else:
866             raise KeyError, "Key property (%s) of retired node clashes with \
867                 existing one (%s)" % (key, keyvalue)
868         # Now we can safely restore node
869         self.fireAuditors('restore', nodeid, None)
870         view = self.getview(READWRITE)
871         ndx = view.find(id=int(nodeid))
872         if ndx < 0:
873             raise KeyError, "nodeid %s not found" % nodeid
875         row = view[ndx]
876         oldvalues = self.uncommitted.setdefault(row.id, {})
877         oldval = oldvalues['_isdel'] = row._isdel
878         row._isdel = 0
880         if self.do_journal:
881             self.db.addjournal(self.classname, nodeid, _RESTORE, {})
882         if self.key:
883             iv = self.getindexview(READWRITE)
884             ndx = iv.find(k=getattr(row, self.key),i=row.id)
885             if ndx > -1:
886                 iv.delete(ndx)
887         self.db.dirty = 1
888         self.fireReactors('restore', nodeid, None)
890     def is_retired(self, nodeid):
891         '''Return true if the node is retired
892         '''
893         view = self.getview(READWRITE)
894         # node must exist & not be retired
895         id = int(nodeid)
896         ndx = view.find(id=id)
897         if ndx < 0:
898             raise IndexError, "%s has no node %s" % (self.classname, nodeid)
899         row = view[ndx]
900         return row._isdel
902     def history(self, nodeid):
903         '''Retrieve the journal of edits on a particular node.
905         'nodeid' must be the id of an existing node of this class or an
906         IndexError is raised.
908         The returned list contains tuples of the form
910             (nodeid, date, tag, action, params)
912         'date' is a Timestamp object specifying the time of the change and
913         'tag' is the journaltag specified when the database was opened.
914         '''        
915         if not self.do_journal:
916             raise ValueError, 'Journalling is disabled for this class'
917         return self.db.getjournal(self.classname, nodeid)
919     def setkey(self, propname):
920         '''Select a String property of this class to be the key property.
922         'propname' must be the name of a String property of this class or
923         None, or a TypeError is raised.  The values of the key property on
924         all existing nodes must be unique or a ValueError is raised.
925         '''
926         if self.key:
927             if propname == self.key:
928                 return
929             else:
930                 # drop the old key table
931                 tablename = "_%s.%s"%(self.classname, self.key)
932                 self.db._db.getas(tablename)
933                 
934             #raise ValueError, "%s already indexed on %s"%(self.classname,
935             #    self.key)
937         prop = self.properties.get(propname, None)
938         if prop is None:
939             prop = self.privateprops.get(propname, None)
940         if prop is None:
941             raise KeyError, "no property %s" % propname
942         if not isinstance(prop, hyperdb.String):
943             raise TypeError, "%s is not a String" % propname
945         # the way he index on properties is by creating a
946         # table named _%(classname)s.%(key)s, if this table
947         # exists then everything is okay.  If this table
948         # doesn't exist, then generate a new table on the
949         # key value.
950         
951         # first setkey for this run or key has been changed
952         self.key = propname
953         tablename = "_%s.%s"%(self.classname, self.key)
954         
955         iv = self.db._db.view(tablename)
956         if self.db.fastopen and iv.structure():
957             return
959         # very first setkey ever or the key has changed
960         self.db.dirty = 1
961         iv = self.db._db.getas('_%s[k:S,i:I]' % tablename)
962         iv = iv.ordered(1)
963         for row in self.getview():
964             iv.append(k=getattr(row, propname), i=row.id)
965         self.db.commit()
967     def getkey(self):
968        '''Return the name of the key property for this class or None.'''
969        return self.key
971     def lookup(self, keyvalue):
972         '''Locate a particular node by its key property and return its id.
974         If this class has no key property, a TypeError is raised.  If the
975         keyvalue matches one of the values for the key property among
976         the nodes in this class, the matching node's id is returned;
977         otherwise a KeyError is raised.
978         '''
979         if not self.key:
980             raise TypeError, 'No key property set for class %s'%self.classname
981         
982         if type(keyvalue) is not _STRINGTYPE:
983             raise TypeError, '%r is not a string'%keyvalue
985         # XXX FIX ME -> this is a bit convoluted
986         # First we search the index view to get the id
987         # which is a quicker look up.
988         # Then we lookup the row with id=id
989         # if the _isdel property of the row is 0, return the
990         # string version of the id. (Why string version???)
991         #
992         # Otherwise, just lookup the non-indexed key
993         # in the non-index table and check the _isdel property
994         iv = self.getindexview()
995         if iv:
996             # look up the index view for the id,
997             # then instead of looking up the keyvalue, lookup the
998             # quicker id
999             ndx = iv.find(k=keyvalue)
1000             if ndx > -1:
1001                 view = self.getview()
1002                 ndx = view.find(id=iv[ndx].i)
1003                 if ndx > -1:
1004                     row = view[ndx]
1005                     if not row._isdel:
1006                         return str(row.id)
1007         else:
1008             # perform the slower query
1009             view = self.getview()
1010             ndx = view.find({self.key:keyvalue})
1011             if ndx > -1:
1012                 row = view[ndx]
1013                 if not row._isdel:
1014                     return str(row.id)
1016         raise KeyError, keyvalue
1018     def destroy(self, id):
1019         '''Destroy a node.
1020         
1021         WARNING: this method should never be used except in extremely rare
1022                  situations where there could never be links to the node being
1023                  deleted
1025         WARNING: use retire() instead
1027         WARNING: the properties of this node will not be available ever again
1029         WARNING: really, use retire() instead
1031         Well, I think that's enough warnings. This method exists mostly to
1032         support the session storage of the cgi interface.
1034         The node is completely removed from the hyperdb, including all journal
1035         entries. It will no longer be available, and will generally break code
1036         if there are any references to the node.
1037         '''
1038         view = self.getview(READWRITE)
1039         ndx = view.find(id=int(id))
1040         if ndx > -1:
1041             if self.key:
1042                 keyvalue = getattr(view[ndx], self.key)
1043                 iv = self.getindexview(READWRITE)
1044                 if iv:
1045                     ivndx = iv.find(k=keyvalue)
1046                     if ivndx > -1:
1047                         iv.delete(ivndx)
1048             view.delete(ndx)
1049             self.db.destroyjournal(self.classname, id)
1050             self.db.dirty = 1
1051         
1052     def find(self, **propspec):
1053         '''Get the ids of nodes in this class which link to the given nodes.
1055         'propspec'
1056              consists of keyword args propname={nodeid:1,}   
1057         'propname'
1058              must be the name of a property in this class, or a
1059              KeyError is raised.  That property must be a Link or
1060              Multilink property, or a TypeError is raised.
1062         Any node in this class whose propname property links to any of the
1063         nodeids will be returned. Used by the full text indexing, which knows
1064         that "foo" occurs in msg1, msg3 and file7; so we have hits on these
1065         issues::
1067             db.issue.find(messages={'1':1,'3':1}, files={'7':1})
1068         '''
1069         propspec = propspec.items()
1070         for propname, nodeid in propspec:
1071             # check the prop is OK
1072             prop = self.ruprops[propname]
1073             if (not isinstance(prop, hyperdb.Link) and
1074                     not isinstance(prop, hyperdb.Multilink)):
1075                 raise TypeError, "'%s' not a Link/Multilink property"%propname
1077         vws = []
1078         for propname, ids in propspec:
1079             if type(ids) is _STRINGTYPE:
1080                 ids = {int(ids):1}
1081             elif ids is None:
1082                 ids = {0:1}
1083             else:
1084                 d = {}
1085                 for id in ids.keys():
1086                     if id is None:
1087                         d[0] = 1
1088                     else:
1089                         d[int(id)] = 1
1090                 ids = d
1091             prop = self.ruprops[propname]
1092             view = self.getview()
1093             if isinstance(prop, hyperdb.Multilink):
1094                 def ff(row, nm=propname, ids=ids):
1095                     if not row._isdel:
1096                         sv = getattr(row, nm)
1097                         for sr in sv:
1098                             if ids.has_key(sr.fid):
1099                                 return 1
1100                     return 0
1101             else:
1102                 def ff(row, nm=propname, ids=ids):
1103                     return not row._isdel and ids.has_key(getattr(row, nm))
1104             ndxview = view.filter(ff)
1105             vws.append(ndxview.unique())
1107         # handle the empty match case
1108         if not vws:
1109             return []
1111         ndxview = vws[0]
1112         for v in vws[1:]:
1113             ndxview = ndxview.union(v)
1114         view = self.getview().remapwith(ndxview)
1115         rslt = []
1116         for row in view:
1117             rslt.append(str(row.id))
1118         return rslt
1119             
1121     def list(self):
1122         ''' Return a list of the ids of the active nodes in this class.
1123         '''
1124         l = []
1125         for row in self.getview().select(_isdel=0):
1126             l.append(str(row.id))
1127         return l
1129     def getnodeids(self):
1130         ''' Retrieve all the ids of the nodes for a particular Class.
1132             Set retired=None to get all nodes. Otherwise it'll get all the 
1133             retired or non-retired nodes, depending on the flag.
1134         '''
1135         l = []
1136         for row in self.getview():
1137             l.append(str(row.id))
1138         return l
1140     def count(self):
1141         return len(self.getview())
1143     def getprops(self, protected=1):
1144         # protected is not in ping's spec
1145         allprops = self.ruprops.copy()
1146         if protected and self.privateprops is not None:
1147             allprops.update(self.privateprops)
1148         return allprops
1150     def addprop(self, **properties):
1151         for key in properties.keys():
1152             if self.ruprops.has_key(key):
1153                 raise ValueError, "%s is already a property of %s"%(key,
1154                     self.classname)
1155         self.ruprops.update(properties)
1156         # Class structure has changed
1157         self.db.fastopen = 0
1158         view = self.__getview()
1159         self.db.commit()
1160     # ---- end of ping's spec
1162     def filter(self, search_matches, filterspec, sort=(None,None),
1163             group=(None,None)):
1164         '''Return a list of the ids of the active nodes in this class that
1165         match the 'filter' spec, sorted by the group spec and then the
1166         sort spec
1168         "filterspec" is {propname: value(s)}
1170         "sort" and "group" are (dir, prop) where dir is '+', '-' or None
1171         and prop is a prop name or None
1173         "search_matches" is {nodeid: marker}
1175         The filter must match all properties specificed - but if the
1176         property value to match is a list, any one of the values in the
1177         list may match for that property to match.
1178         '''        
1179         # search_matches is None or a set (dict of {nodeid: {propname:[nodeid,...]}})
1180         # filterspec is a dict {propname:value}
1181         # sort and group are (dir, prop) where dir is '+', '-' or None
1182         #                    and prop is a prop name or None
1184         timezone = self.db.getUserTimezone()
1186         where = {'_isdel':0}
1187         wherehigh = {}
1188         mlcriteria = {}
1189         regexes = {}
1190         orcriteria = {}
1191         for propname, value in filterspec.items():
1192             prop = self.ruprops.get(propname, None)
1193             if prop is None:
1194                 prop = self.privateprops[propname]
1195             if isinstance(prop, hyperdb.Multilink):
1196                 if value in ('-1', ['-1']):
1197                     value = []
1198                 elif type(value) is not _LISTTYPE:
1199                     value = [value]
1200                 # transform keys to ids
1201                 u = []
1202                 for item in value:
1203                     try:
1204                         item = int(item)
1205                     except (TypeError, ValueError):
1206                         item = int(self.db.getclass(prop.classname).lookup(item))
1207                     if item == -1:
1208                         item = 0
1209                     u.append(item)
1210                 mlcriteria[propname] = u
1211             elif isinstance(prop, hyperdb.Link):
1212                 if type(value) is not _LISTTYPE:
1213                     value = [value]
1214                 # transform keys to ids
1215                 u = []
1216                 for item in value:
1217                     try:
1218                         item = int(item)
1219                     except (TypeError, ValueError):
1220                         item = int(self.db.getclass(prop.classname).lookup(item))
1221                     if item == -1:
1222                         item = 0
1223                     u.append(item)
1224                 if len(u) == 1:
1225                     where[propname] = u[0]
1226                 else:
1227                     orcriteria[propname] = u
1228             elif isinstance(prop, hyperdb.String):
1229                 if type(value) is not type([]):
1230                     value = [value]
1231                 m = []
1232                 for v in value:
1233                     # simple glob searching
1234                     v = re.sub(r'([\|\{\}\\\.\+\[\]\(\)])', r'\\\1', v)
1235                     v = v.replace('?', '.')
1236                     v = v.replace('*', '.*?')
1237                     m.append(v)
1238                 regexes[propname] = re.compile('(%s)'%('|'.join(m)), re.I)
1239             elif propname == 'id':
1240                 where[propname] = int(value)
1241             elif isinstance(prop, hyperdb.Boolean):
1242                 if type(value) is _STRINGTYPE:
1243                     bv = value.lower() in ('yes', 'true', 'on', '1')
1244                 else:
1245                     bv = value
1246                 where[propname] = bv
1247             elif isinstance(prop, hyperdb.Date):
1248                 try:
1249                     # Try to filter on range of dates
1250                     date_rng = Range(value, date.Date, offset=timezone)
1251                     if date_rng.from_value:
1252                         t = date_rng.from_value.get_tuple()
1253                         where[propname] = int(calendar.timegm(t))
1254                     else:
1255                         # use minimum possible value to exclude items without
1256                         # 'prop' property
1257                         where[propname] = 0
1258                     if date_rng.to_value:
1259                         t = date_rng.to_value.get_tuple()
1260                         wherehigh[propname] = int(calendar.timegm(t))
1261                     else:
1262                         wherehigh[propname] = None
1263                 except ValueError:
1264                     # If range creation fails - ignore that search parameter
1265                     pass                        
1266             elif isinstance(prop, hyperdb.Interval):
1267                 try:
1268                     # Try to filter on range of intervals
1269                     date_rng = Range(value, date.Interval)
1270                     if date_rng.from_value:
1271                         #t = date_rng.from_value.get_tuple()
1272                         where[propname] = date_rng.from_value.serialise()
1273                     else:
1274                         # use minimum possible value to exclude items without
1275                         # 'prop' property
1276                         where[propname] = '-99999999999999'
1277                     if date_rng.to_value:
1278                         #t = date_rng.to_value.get_tuple()
1279                         wherehigh[propname] = date_rng.to_value.serialise()
1280                     else:
1281                         wherehigh[propname] = None
1282                 except ValueError:
1283                     # If range creation fails - ignore that search parameter
1284                     pass                        
1285             elif isinstance(prop, hyperdb.Number):
1286                 where[propname] = int(value)
1287             else:
1288                 where[propname] = str(value)
1289         v = self.getview()
1290         #print "filter start at  %s" % time.time() 
1291         if where:
1292             where_higherbound = where.copy()
1293             where_higherbound.update(wherehigh)
1294             v = v.select(where, where_higherbound)
1295         #print "filter where at  %s" % time.time() 
1297         if mlcriteria:
1298             # multilink - if any of the nodeids required by the
1299             # filterspec aren't in this node's property, then skip it
1300             def ff(row, ml=mlcriteria):
1301                 for propname, values in ml.items():
1302                     sv = getattr(row, propname)
1303                     if not values and sv:
1304                         return 0
1305                     for id in values:
1306                         if sv.find(fid=id) == -1:
1307                             return 0
1308                 return 1
1309             iv = v.filter(ff)
1310             v = v.remapwith(iv)
1312         #print "filter mlcrit at %s" % time.time() 
1313         
1314         if orcriteria:
1315             def ff(row, crit=orcriteria):
1316                 for propname, allowed in crit.items():
1317                     val = getattr(row, propname)
1318                     if val not in allowed:
1319                         return 0
1320                 return 1
1321             
1322             iv = v.filter(ff)
1323             v = v.remapwith(iv)
1324         
1325         #print "filter orcrit at %s" % time.time() 
1326         if regexes:
1327             def ff(row, r=regexes):
1328                 for propname, regex in r.items():
1329                     val = str(getattr(row, propname))
1330                     if not regex.search(val):
1331                         return 0
1332                 return 1
1333             
1334             iv = v.filter(ff)
1335             v = v.remapwith(iv)
1336         #print "filter regexs at %s" % time.time() 
1337         
1338         if sort or group:
1339             sortspec = []
1340             rev = []
1341             for dir, propname in group, sort:
1342                 if propname is None: continue
1343                 isreversed = 0
1344                 if dir == '-':
1345                     isreversed = 1
1346                 try:
1347                     prop = getattr(v, propname)
1348                 except AttributeError:
1349                     print "MK has no property %s" % propname
1350                     continue
1351                 propclass = self.ruprops.get(propname, None)
1352                 if propclass is None:
1353                     propclass = self.privateprops.get(propname, None)
1354                     if propclass is None:
1355                         print "Schema has no property %s" % propname
1356                         continue
1357                 if isinstance(propclass, hyperdb.Link):
1358                     linkclass = self.db.getclass(propclass.classname)
1359                     lv = linkclass.getview()
1360                     lv = lv.rename('id', propname)
1361                     v = v.join(lv, prop, 1)
1362                     if linkclass.getprops().has_key('order'):
1363                         propname = 'order'
1364                     else:
1365                         propname = linkclass.labelprop()
1366                     prop = getattr(v, propname)
1367                 if isreversed:
1368                     rev.append(prop)
1369                 sortspec.append(prop)
1370             v = v.sortrev(sortspec, rev)[:] #XXX Metakit bug
1371         #print "filter sort   at %s" % time.time() 
1372             
1373         rslt = []
1374         for row in v:
1375             id = str(row.id)
1376             if search_matches is not None:
1377                 if search_matches.has_key(id):
1378                     rslt.append(id)
1379             else:
1380                 rslt.append(id)
1381         return rslt
1382     
1383     def hasnode(self, nodeid):
1384         '''Determine if the given nodeid actually exists
1385         '''
1386         return int(nodeid) < self.maxid
1387     
1388     def labelprop(self, default_to_id=0):
1389         '''Return the property name for a label for the given node.
1391         This method attempts to generate a consistent label for the node.
1392         It tries the following in order:
1394         1. key property
1395         2. "name" property
1396         3. "title" property
1397         4. first property from the sorted property name list
1398         '''
1399         k = self.getkey()
1400         if  k:
1401             return k
1402         props = self.getprops()
1403         if props.has_key('name'):
1404             return 'name'
1405         elif props.has_key('title'):
1406             return 'title'
1407         if default_to_id:
1408             return 'id'
1409         props = props.keys()
1410         props.sort()
1411         return props[0]
1413     def stringFind(self, **requirements):
1414         '''Locate a particular node by matching a set of its String
1415         properties in a caseless search.
1417         If the property is not a String property, a TypeError is raised.
1418         
1419         The return is a list of the id of all nodes that match.
1420         '''
1421         for propname in requirements.keys():
1422             prop = self.properties[propname]
1423             if isinstance(not prop, hyperdb.String):
1424                 raise TypeError, "'%s' not a String property"%propname
1425             requirements[propname] = requirements[propname].lower()
1426         requirements['_isdel'] = 0
1427         
1428         l = []
1429         for row in self.getview().select(requirements):
1430             l.append(str(row.id))
1431         return l
1433     def addjournal(self, nodeid, action, params):
1434         '''Add a journal to the given nodeid,
1435         'action' may be:
1437             'create' or 'set' -- 'params' is a dictionary of property values
1438             'link' or 'unlink' -- 'params' is (classname, nodeid, propname)
1439             'retire' -- 'params' is None
1440         '''
1441         self.db.addjournal(self.classname, nodeid, action, params)
1443     def index(self, nodeid):
1444         ''' Add (or refresh) the node to search indexes '''
1445         # find all the String properties that have indexme
1446         for prop, propclass in self.getprops().items():
1447             if isinstance(propclass, hyperdb.String) and propclass.indexme:
1448                 # index them under (classname, nodeid, property)
1449                 self.db.indexer.add_text((self.classname, nodeid, prop),
1450                                 str(self.get(nodeid, prop)))
1452     # --- used by Database
1453     def _commit(self):
1454         ''' called post commit of the DB.
1455             interested subclasses may override '''
1456         self.uncommitted = {}
1457         for action in self.comactions:
1458             action()
1459         self.comactions = []
1460         self.rbactions = []
1461         self.idcache = {}
1462     def _rollback(self):  
1463         ''' called pre rollback of the DB.
1464             interested subclasses may override '''
1465         self.comactions = []
1466         for action in self.rbactions:
1467             action()
1468         self.rbactions = []
1469         self.uncommitted = {}
1470         self.idcache = {}
1471     def _clear(self):
1472         view = self.getview(READWRITE)
1473         if len(view):
1474             view[:] = []
1475             self.db.dirty = 1
1476         iv = self.getindexview(READWRITE)
1477         if iv:
1478             iv[:] = []
1479     def commitaction(self, action):
1480         ''' call this to register a callback called on commit
1481             callback is removed on end of transaction '''
1482         self.comactions.append(action)
1483     def rollbackaction(self, action):
1484         ''' call this to register a callback called on rollback
1485             callback is removed on end of transaction '''
1486         self.rbactions.append(action)
1487     # --- internal
1488     def __getview(self):
1489         ''' Find the interface for a specific Class in the hyperdb.
1491             This method checks to see whether the schema has changed and
1492             re-works the underlying metakit structure if it has.
1493         '''
1494         db = self.db._db
1495         view = db.view(self.classname)
1496         mkprops = view.structure()
1498         # if we have structure in the database, and the structure hasn't
1499         # changed
1500         # note on view.ordered ->        
1501         # return a metakit view ordered on the id column
1502         # id is always the first column.  This speeds up
1503         # look-ups on the id column.
1504         
1505         if mkprops and self.db.fastopen:
1506             return view.ordered(1)
1508         # is the definition the same?
1509         for nm, rutyp in self.ruprops.items():
1510             for mkprop in mkprops:
1511                 if mkprop.name == nm:
1512                     break
1513             else:
1514                 mkprop = None
1515             if mkprop is None:
1516                 break
1517             if _typmap[rutyp.__class__] != mkprop.type:
1518                 break
1519         else:
1520             # make sure we have the 'actor' property too
1521             for mkprop in mkprops:
1522                 if mkprop.name == 'actor':
1523                     return view.ordered(1)
1525         # The schema has changed.  We need to create or restructure the mk view
1526         # id comes first, so we can use view.ordered(1) so that
1527         # MK will order it for us to allow binary-search quick lookups on
1528         # the id column
1529         self.db.dirty = 1
1530         s = ["%s[id:I" % self.classname]
1532         # these columns will always be added, we can't trample them :)
1533         _columns = {"id":"I", "_isdel":"I", "activity":"I", "actor": "I",
1534             "creation":"I", "creator":"I"}
1536         for nm, rutyp in self.ruprops.items():
1537             mktyp = _typmap[rutyp.__class__].upper()
1538             if nm in _columns and _columns[nm] != mktyp:
1539                 # oops, two columns with the same name and different properties
1540                raise MKBackendError("column %s for table %sis defined with multiple types"%(nm, self.classname))
1541             _columns[nm] = mktyp
1542             s.append('%s:%s' % (nm, mktyp))
1543             if mktyp == 'V':
1544                 s[-1] += ('[fid:I]')
1546         # XXX FIX ME -> in some tests, creation:I becomes creation:S is this
1547         # okay?  Does this need to be supported?
1548         s.append('_isdel:I,activity:I,actor:I,creation:I,creator:I]')
1549         view = self.db._db.getas(','.join(s))
1550         self.db.commit()
1551         return view.ordered(1)
1552     def getview(self, RW=0):
1553         # XXX FIX ME -> The RW flag doesn't do anything.
1554         return self.db._db.view(self.classname).ordered(1)
1555     def getindexview(self, RW=0):
1556         # XXX FIX ME -> The RW flag doesn't do anything.
1557         tablename = "_%s.%s"%(self.classname, self.key)
1558         return self.db._db.view("_%s" % tablename).ordered(1)
1560     #
1561     # import / export
1562     #
1563     def export_list(self, propnames, nodeid):
1564         ''' Export a node - generate a list of CSV-able data in the order
1565             specified by propnames for the given node.
1566         '''
1567         properties = self.getprops()
1568         l = []
1569         for prop in propnames:
1570             proptype = properties[prop]
1571             value = self.get(nodeid, prop)
1572             # "marshal" data where needed
1573             if value is None:
1574                 pass
1575             elif isinstance(proptype, hyperdb.Date):
1576                 value = value.get_tuple()
1577             elif isinstance(proptype, hyperdb.Interval):
1578                 value = value.get_tuple()
1579             elif isinstance(proptype, hyperdb.Password):
1580                 value = str(value)
1581             l.append(repr(value))
1583         # append retired flag
1584         l.append(repr(self.is_retired(nodeid)))
1586         return l
1587         
1588     def import_list(self, propnames, proplist):
1589         ''' Import a node - all information including "id" is present and
1590             should not be sanity checked. Triggers are not triggered. The
1591             journal should be initialised using the "creator" and "creation"
1592             information.
1594             Return the nodeid of the node imported.
1595         '''
1596         if self.db.journaltag is None:
1597             raise hyperdb.DatabaseError, 'Database open read-only'
1598         properties = self.getprops()
1600         d = {}
1601         view = self.getview(READWRITE)
1602         for i in range(len(propnames)):
1603             value = eval(proplist[i])
1604             if not value:
1605                 continue
1607             propname = propnames[i]
1608             if propname == 'id':
1609                 newid = value = int(value)
1610             elif propname == 'is retired':
1611                 # is the item retired?
1612                 if int(value):
1613                     d['_isdel'] = 1
1614                 continue
1615             elif value is None:
1616                 d[propname] = None
1617                 continue
1619             prop = properties[propname]
1620             if isinstance(prop, hyperdb.Date):
1621                 value = int(calendar.timegm(value))
1622             elif isinstance(prop, hyperdb.Interval):
1623                 value = date.Interval(value).serialise()
1624             elif isinstance(prop, hyperdb.Number):
1625                 value = int(value)
1626             elif isinstance(prop, hyperdb.Boolean):
1627                 value = int(value)
1628             elif isinstance(prop, hyperdb.Link) and value:
1629                 value = int(value)
1630             elif isinstance(prop, hyperdb.Multilink):
1631                 # we handle multilinks separately
1632                 continue
1633             d[propname] = value
1635         # possibly make a new node
1636         if not d.has_key('id'):
1637             d['id'] = newid = self.maxid
1638             self.maxid += 1
1640         # save off the node
1641         view.append(d)
1643         # fix up multilinks
1644         ndx = view.find(id=newid)
1645         row = view[ndx]
1646         for i in range(len(propnames)):
1647             value = eval(proplist[i])
1648             propname = propnames[i]
1649             if propname == 'is retired':
1650                 continue
1651             prop = properties[propname]
1652             if not isinstance(prop, hyperdb.Multilink):
1653                 continue
1654             sv = getattr(row, propname)
1655             for entry in value:
1656                 sv.append((int(entry),))
1658         self.db.dirty = 1
1659         return newid
1661     def export_journals(self):
1662         '''Export a class's journal - generate a list of lists of
1663         CSV-able data:
1665             nodeid, date, user, action, params
1667         No heading here - the columns are fixed.
1668         '''
1669         properties = self.getprops()
1670         r = []
1671         for nodeid in self.getnodeids():
1672             for nodeid, date, user, action, params in self.history(nodeid):
1673                 date = date.get_tuple()
1674                 if action == 'set':
1675                     for propname, value in params.items():
1676                         prop = properties[propname]
1677                         # make sure the params are eval()'able
1678                         if value is None:
1679                             pass
1680                         elif isinstance(prop, Date):
1681                             value = value.get_tuple()
1682                         elif isinstance(prop, Interval):
1683                             value = value.get_tuple()
1684                         elif isinstance(prop, Password):
1685                             value = str(value)
1686                         params[propname] = value
1687                 l = [nodeid, date, user, action, params]
1688                 r.append(map(repr, l))
1689         return r
1691     def import_journals(self, entries):
1692         '''Import a class's journal.
1693         
1694         Uses setjournal() to set the journal for each item.'''
1695         properties = self.getprops()
1696         d = {}
1697         for l in entries:
1698             l = map(eval, l)
1699             nodeid, date, user, action, params = l
1700             r = d.setdefault(nodeid, [])
1701             if action == 'set':
1702                 for propname, value in params.items():
1703                     prop = properties[propname]
1704                     if value is None:
1705                         pass
1706                     elif isinstance(prop, Date):
1707                         value = date.Date(value)
1708                     elif isinstance(prop, Interval):
1709                         value = date.Interval(value)
1710                     elif isinstance(prop, Password):
1711                         pwd = password.Password()
1712                         pwd.unpack(value)
1713                         value = pwd
1714                     params[propname] = value
1715             r.append((nodeid, date.Date(date), user, action, params))
1717         for nodeid, l in d.items():
1718             self.db.setjournal(self.classname, nodeid, l)
1720 def _fetchML(sv):
1721     l = []
1722     for row in sv:
1723         if row.fid:
1724             l.append(str(row.fid))
1725     return l
1727 def _fetchPW(s):
1728     ''' Convert to a password.Password unless the password is '' which is
1729         our sentinel for "unset".
1730     '''
1731     if s == '':
1732         return None
1733     p = password.Password()
1734     p.unpack(s)
1735     return p
1737 def _fetchLink(n):
1738     ''' Return None if the link is 0 - otherwise strify it.
1739     '''
1740     return n and str(n) or None
1742 def _fetchDate(n):
1743     ''' Convert the timestamp to a date.Date instance - unless it's 0 which
1744         is our sentinel for "unset".
1745     '''
1746     if n == 0:
1747         return None
1748     return date.Date(time.gmtime(n))
1750 def _fetchInterval(n):
1751     ''' Convert to a date.Interval unless the interval is '' which is our
1752         sentinel for "unset".
1753     '''
1754     if n == '':
1755         return None
1756     return date.Interval(n)
1758 # Converters for boolean and numbers to properly
1759 # return None values.
1760 # These are in conjunction with the setters above
1761 #  look for hyperdb.Boolean and hyperdb.Number
1762 if BACKWARDS_COMPATIBLE:
1763     def getBoolean(bool): return bool
1764     def getNumber(number): return number
1765 else:
1766     def getBoolean(bool):
1767         if not bool: res = None
1768         else: res = bool - 1
1769         return res
1770     
1771     def getNumber(number):
1772         if number == 0: res = None
1773         elif number < 0: res = number
1774         else: res = number - 1
1775         return res
1777 _converters = {
1778     hyperdb.Date   : _fetchDate,
1779     hyperdb.Link   : _fetchLink,
1780     hyperdb.Multilink : _fetchML,
1781     hyperdb.Interval  : _fetchInterval,
1782     hyperdb.Password  : _fetchPW,
1783     hyperdb.Boolean   : getBoolean,
1784     hyperdb.Number    : getNumber,
1785     hyperdb.String    : lambda s: s and str(s) or None,
1786 }                
1788 class FileName(hyperdb.String):
1789     isfilename = 1            
1791 _typmap = {
1792     FileName : 'S',
1793     hyperdb.String : 'S',
1794     hyperdb.Date   : 'I',
1795     hyperdb.Link   : 'I',
1796     hyperdb.Multilink : 'V',
1797     hyperdb.Interval  : 'S',
1798     hyperdb.Password  : 'S',
1799     hyperdb.Boolean   : 'I',
1800     hyperdb.Number    : 'I',
1802 class FileClass(Class, hyperdb.FileClass):
1803     ''' like Class but with a content property
1804     '''
1805     default_mime_type = 'text/plain'
1806     def __init__(self, db, classname, **properties):
1807         properties['content'] = FileName()
1808         if not properties.has_key('type'):
1809             properties['type'] = hyperdb.String()
1810         Class.__init__(self, db, classname, **properties)
1812     def gen_filename(self, nodeid):
1813         nm = '%s%s' % (self.classname, nodeid)
1814         sd = str(int(int(nodeid) / 1000))
1815         d = os.path.join(self.db.config.DATABASE, 'files', self.classname, sd)
1816         if not os.path.exists(d):
1817             os.makedirs(d)
1818         return os.path.join(d, nm)
1820     def get(self, nodeid, propname, default=_marker, cache=1):
1821         if propname == 'content':
1822             poss_msg = 'Possibly an access right configuration problem.'
1823             fnm = self.gen_filename(nodeid)
1824             if not os.path.exists(fnm):
1825                 fnm = fnm + '.tmp'
1826             try:
1827                 f = open(fnm, 'rb')
1828             except IOError, (strerror):
1829                 # XXX by catching this we donot see an error in the log.
1830                 return 'ERROR reading file: %s%s\n%s\n%s'%(
1831                         self.classname, nodeid, poss_msg, strerror)
1832             x = f.read()
1833             f.close()
1834         else:
1835             x = Class.get(self, nodeid, propname, default)
1836         return x
1838     def create(self, **propvalues):
1839         if not propvalues:
1840             raise ValueError, "Need something to create!"
1841         self.fireAuditors('create', None, propvalues)
1843         content = propvalues['content']
1844         del propvalues['content']
1846         newid = Class.create_inner(self, **propvalues)
1847         if not content:
1848             return newid
1850         # figure a filename
1851         nm = self.gen_filename(newid)
1852         f = open(nm + '.tmp', 'wb')
1853         f.write(content)
1854         f.close()
1856         mimetype = propvalues.get('type', self.default_mime_type)
1857         self.db.indexer.add_text((self.classname, newid, 'content'), content,
1858             mimetype)
1860         # register commit and rollback actions
1861         def commit(fnm=nm):
1862             os.rename(fnm + '.tmp', fnm)
1863         self.commitaction(commit)
1864         def undo(fnm=nm):
1865             os.remove(fnm + '.tmp')
1866         self.rollbackaction(undo)
1867         return newid
1869     def set(self, itemid, **propvalues):
1870         if not propvalues:
1871             return
1872         self.fireAuditors('set', None, propvalues)
1874         content = propvalues.get('content', None)
1875         if content is not None:
1876             del propvalues['content']
1878         propvalues, oldnode = Class.set_inner(self, itemid, **propvalues)
1880         # figure a filename
1881         if content is not None:
1882             nm = self.gen_filename(itemid)
1883             f = open(nm + '.tmp', 'wb')
1884             f.write(content)
1885             f.close()
1886             mimetype = propvalues.get('type', self.default_mime_type)
1887             self.db.indexer.add_text((self.classname, itemid, 'content'),
1888                 content, mimetype)
1890             # register commit and rollback actions
1891             def commit(fnm=nm):
1892                 if os.path.exists(fnm):
1893                     os.remove(fnm)
1894                 os.rename(fnm + '.tmp', fnm)
1895             self.commitaction(commit)
1896             def undo(fnm=nm):
1897                 os.remove(fnm + '.tmp')
1898             self.rollbackaction(undo)
1900         self.fireReactors('set', oldnode, propvalues)
1902     def index(self, nodeid):
1903         Class.index(self, nodeid)
1904         mimetype = self.get(nodeid, 'type')
1905         if not mimetype:
1906             mimetype = self.default_mime_type
1907         self.db.indexer.add_text((self.classname, nodeid, 'content'),
1908                     self.get(nodeid, 'content'), mimetype)
1909  
1910 class IssueClass(Class, roundupdb.IssueClass):
1911     ''' The newly-created class automatically includes the "messages",
1912         "files", "nosy", and "superseder" properties.  If the 'properties'
1913         dictionary attempts to specify any of these properties or a
1914         "creation" or "activity" property, a ValueError is raised.
1915     '''
1916     def __init__(self, db, classname, **properties):
1917         if not properties.has_key('title'):
1918             properties['title'] = hyperdb.String(indexme='yes')
1919         if not properties.has_key('messages'):
1920             properties['messages'] = hyperdb.Multilink("msg")
1921         if not properties.has_key('files'):
1922             properties['files'] = hyperdb.Multilink("file")
1923         if not properties.has_key('nosy'):
1924             # note: journalling is turned off as it really just wastes
1925             # space. this behaviour may be overridden in an instance
1926             properties['nosy'] = hyperdb.Multilink("user", do_journal="no")
1927         if not properties.has_key('superseder'):
1928             properties['superseder'] = hyperdb.Multilink(classname)
1929         Class.__init__(self, db, classname, **properties)
1930         
1931 CURVERSION = 2
1933 class Indexer(Indexer):
1934     disallows = {'THE':1, 'THIS':1, 'ZZZ':1, 'THAT':1, 'WITH':1}
1935     def __init__(self, path, datadb):
1936         self.path = os.path.join(path, 'index.mk4')
1937         self.db = metakit.storage(self.path, 1)
1938         self.datadb = datadb
1939         self.reindex = 0
1940         v = self.db.view('version')
1941         if not v.structure():
1942             v = self.db.getas('version[vers:I]')
1943             self.db.commit()
1944             v.append(vers=CURVERSION)
1945             self.reindex = 1
1946         elif v[0].vers != CURVERSION:
1947             v[0].vers = CURVERSION
1948             self.reindex = 1
1949         if self.reindex:
1950             self.db.getas('ids[tblid:I,nodeid:I,propid:I,ignore:I]')
1951             self.db.getas('index[word:S,hits[pos:I]]')
1952             self.db.commit()
1953             self.reindex = 1
1954         self.changed = 0
1955         self.propcache = {}
1957     def close(self):
1958         '''close the indexing database'''
1959         del self.db
1960         self.db = None
1961   
1962     def force_reindex(self):
1963         '''Force a reindexing of the database.  This essentially
1964         empties the tables ids and index and sets a flag so
1965         that the databases are reindexed'''
1966         v = self.db.view('ids')
1967         v[:] = []
1968         v = self.db.view('index')
1969         v[:] = []
1970         self.db.commit()
1971         self.reindex = 1
1973     def should_reindex(self):
1974         '''returns True if the indexes need to be rebuilt'''
1975         return self.reindex
1977     def _getprops(self, classname):
1978         props = self.propcache.get(classname, None)
1979         if props is None:
1980             props = self.datadb.view(classname).structure()
1981             props = [prop.name for prop in props]
1982             self.propcache[classname] = props
1983         return props
1985     def _getpropid(self, classname, propname):
1986         return self._getprops(classname).index(propname)
1988     def _getpropname(self, classname, propid):
1989         return self._getprops(classname)[propid]
1991     def add_text(self, identifier, text, mime_type='text/plain'):
1992         if mime_type != 'text/plain':
1993             return
1994         classname, nodeid, property = identifier
1995         tbls = self.datadb.view('tables')
1996         tblid = tbls.find(name=classname)
1997         if tblid < 0:
1998             raise KeyError, "unknown class %r"%classname
1999         nodeid = int(nodeid)
2000         propid = self._getpropid(classname, property)
2001         ids = self.db.view('ids')
2002         oldpos = ids.find(tblid=tblid,nodeid=nodeid,propid=propid,ignore=0)
2003         if oldpos > -1:
2004             ids[oldpos].ignore = 1
2005             self.changed = 1
2006         pos = ids.append(tblid=tblid,nodeid=nodeid,propid=propid)
2008         wordlist = re.findall(r'\b\w{2,25}\b', text.upper())
2009         words = {}
2010         for word in wordlist:
2011             if not self.disallows.has_key(word):
2012                 words[word] = 1
2013         words = words.keys()
2014         
2015         index = self.db.view('index').ordered(1)
2016         for word in words:
2017             ndx = index.find(word=word)
2018             if ndx < 0:
2019                 index.append(word=word)
2020                 ndx = index.find(word=word)
2021             index[ndx].hits.append(pos=pos)
2022             self.changed = 1
2024     def find(self, wordlist):
2025         '''look up all the words in the wordlist.
2026         If none are found return an empty dictionary
2027         * more rules here
2028         '''        
2029         hits = None
2030         index = self.db.view('index').ordered(1)
2031         for word in wordlist:
2032             word = word.upper()
2033             if not 2 < len(word) < 26:
2034                 continue
2035             ndx = index.find(word=word)
2036             if ndx < 0:
2037                 return {}
2038             if hits is None:
2039                 hits = index[ndx].hits
2040             else:
2041                 hits = hits.intersect(index[ndx].hits)
2042             if len(hits) == 0:
2043                 return {}
2044         if hits is None:
2045             return {}
2046         rslt = {}
2047         ids = self.db.view('ids').remapwith(hits)
2048         tbls = self.datadb.view('tables')
2049         for i in range(len(ids)):
2050             hit = ids[i]
2051             if not hit.ignore:
2052                 classname = tbls[hit.tblid].name
2053                 nodeid = str(hit.nodeid)
2054                 property = self._getpropname(classname, hit.propid)
2055                 rslt[i] = (classname, nodeid, property)
2056         return rslt
2058     def save_index(self):
2059         if self.changed:
2060             self.db.commit()
2061         self.changed = 0
2063     def rollback(self):
2064         if self.changed:
2065             self.db.rollback()
2066             self.db = metakit.storage(self.path, 1)
2067         self.changed = 0