Code

documentation cleanup
[roundup.git] / roundup / backends / back_anydbm.py
1 #
2 # Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/)
3 # This module is free software, and you may redistribute it and/or modify
4 # under the same terms as Python, so long as this copyright message and
5 # disclaimer are retained in their original form.
6 #
7 # IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR
8 # DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING
9 # OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE
10 # POSSIBILITY OF SUCH DAMAGE.
11 #
12 # BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING,
13 # BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
14 # FOR A PARTICULAR PURPOSE.  THE CODE PROVIDED HEREUNDER IS ON AN "AS IS"
15 # BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
16 # SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
17
18 #$Id: back_anydbm.py,v 1.135 2004-02-11 23:55:08 richard Exp $
19 '''This module defines a backend that saves the hyperdatabase in a
20 database chosen by anydbm. It is guaranteed to always be available in python
21 versions >2.1.1 (the dumbdbm fallback in 2.1.1 and earlier has several
22 serious bugs, and is not available)
23 '''
24 __docformat__ = 'restructuredtext'
26 try:
27     import anydbm, sys
28     # dumbdbm only works in python 2.1.2+
29     if sys.version_info < (2,1,2):
30         import dumbdbm
31         assert anydbm._defaultmod != dumbdbm
32         del dumbdbm
33 except AssertionError:
34     print "WARNING: you should upgrade to python 2.1.3"
36 import whichdb, os, marshal, re, weakref, string, copy
37 from roundup import hyperdb, date, password, roundupdb, security
38 from blobfiles import FileStorage
39 from sessions import Sessions, OneTimeKeys
40 from roundup.indexer import Indexer
41 from roundup.backends import locking
42 from roundup.hyperdb import String, Password, Date, Interval, Link, \
43     Multilink, DatabaseError, Boolean, Number, Node
44 from roundup.date import Range
46 #
47 # Now the database
48 #
49 class Database(FileStorage, hyperdb.Database, roundupdb.Database):
50     '''A database for storing records containing flexible data types.
52     Transaction stuff TODO:
53     
54     - check the timestamp of the class file and nuke the cache if it's
55       modified. Do some sort of conflict checking on the dirty stuff.
56     - perhaps detect write collisions (related to above)?
57     '''
58     def __init__(self, config, journaltag=None):
59         '''Open a hyperdatabase given a specifier to some storage.
61         The 'storagelocator' is obtained from config.DATABASE.
62         The meaning of 'storagelocator' depends on the particular
63         implementation of the hyperdatabase.  It could be a file name,
64         a directory path, a socket descriptor for a connection to a
65         database over the network, etc.
67         The 'journaltag' is a token that will be attached to the journal
68         entries for any edits done on the database.  If 'journaltag' is
69         None, the database is opened in read-only mode: the Class.create(),
70         Class.set(), Class.retire(), and Class.restore() methods are
71         disabled.  
72         '''        
73         self.config, self.journaltag = config, journaltag
74         self.dir = config.DATABASE
75         self.classes = {}
76         self.cache = {}         # cache of nodes loaded or created
77         self.dirtynodes = {}    # keep track of the dirty nodes by class
78         self.newnodes = {}      # keep track of the new nodes by class
79         self.destroyednodes = {}# keep track of the destroyed nodes by class
80         self.transactions = []
81         self.indexer = Indexer(self.dir)
82         self.sessions = Sessions(self.config)
83         self.otks = OneTimeKeys(self.config)
84         self.security = security.Security(self)
85         # ensure files are group readable and writable
86         os.umask(0002)
88         # lock it
89         lockfilenm = os.path.join(self.dir, 'lock')
90         self.lockfile = locking.acquire_lock(lockfilenm)
91         self.lockfile.write(str(os.getpid()))
92         self.lockfile.flush()
94     def post_init(self):
95         '''Called once the schema initialisation has finished.
96         '''
97         # reindex the db if necessary
98         if self.indexer.should_reindex():
99             self.reindex()
101     def refresh_database(self):
102         """Rebuild the database
103         """
104         self.reindex()
106     def reindex(self):
107         for klass in self.classes.values():
108             for nodeid in klass.list():
109                 klass.index(nodeid)
110         self.indexer.save_index()
112     def __repr__(self):
113         return '<back_anydbm instance at %x>'%id(self) 
115     #
116     # Classes
117     #
118     def __getattr__(self, classname):
119         '''A convenient way of calling self.getclass(classname).'''
120         if self.classes.has_key(classname):
121             if __debug__:
122                 print >>hyperdb.DEBUG, '__getattr__', (self, classname)
123             return self.classes[classname]
124         raise AttributeError, classname
126     def addclass(self, cl):
127         if __debug__:
128             print >>hyperdb.DEBUG, 'addclass', (self, cl)
129         cn = cl.classname
130         if self.classes.has_key(cn):
131             raise ValueError, cn
132         self.classes[cn] = cl
134     def getclasses(self):
135         '''Return a list of the names of all existing classes.'''
136         if __debug__:
137             print >>hyperdb.DEBUG, 'getclasses', (self,)
138         l = self.classes.keys()
139         l.sort()
140         return l
142     def getclass(self, classname):
143         '''Get the Class object representing a particular class.
145         If 'classname' is not a valid class name, a KeyError is raised.
146         '''
147         if __debug__:
148             print >>hyperdb.DEBUG, 'getclass', (self, classname)
149         try:
150             return self.classes[classname]
151         except KeyError:
152             raise KeyError, 'There is no class called "%s"'%classname
154     #
155     # Class DBs
156     #
157     def clear(self):
158         '''Delete all database contents
159         '''
160         if __debug__:
161             print >>hyperdb.DEBUG, 'clear', (self,)
162         for cn in self.classes.keys():
163             for dummy in 'nodes', 'journals':
164                 path = os.path.join(self.dir, 'journals.%s'%cn)
165                 if os.path.exists(path):
166                     os.remove(path)
167                 elif os.path.exists(path+'.db'):    # dbm appends .db
168                     os.remove(path+'.db')
170     def getclassdb(self, classname, mode='r'):
171         ''' grab a connection to the class db that will be used for
172             multiple actions
173         '''
174         if __debug__:
175             print >>hyperdb.DEBUG, 'getclassdb', (self, classname, mode)
176         return self.opendb('nodes.%s'%classname, mode)
178     def determine_db_type(self, path):
179         ''' determine which DB wrote the class file
180         '''
181         db_type = ''
182         if os.path.exists(path):
183             db_type = whichdb.whichdb(path)
184             if not db_type:
185                 raise DatabaseError, "Couldn't identify database type"
186         elif os.path.exists(path+'.db'):
187             # if the path ends in '.db', it's a dbm database, whether
188             # anydbm says it's dbhash or not!
189             db_type = 'dbm'
190         return db_type
192     def opendb(self, name, mode):
193         '''Low-level database opener that gets around anydbm/dbm
194            eccentricities.
195         '''
196         if __debug__:
197             print >>hyperdb.DEBUG, 'opendb', (self, name, mode)
199         # figure the class db type
200         path = os.path.join(os.getcwd(), self.dir, name)
201         db_type = self.determine_db_type(path)
203         # new database? let anydbm pick the best dbm
204         if not db_type:
205             if __debug__:
206                 print >>hyperdb.DEBUG, "opendb anydbm.open(%r, 'c')"%path
207             return anydbm.open(path, 'c')
209         # open the database with the correct module
210         try:
211             dbm = __import__(db_type)
212         except ImportError:
213             raise DatabaseError, \
214                 "Couldn't open database - the required module '%s'"\
215                 " is not available"%db_type
216         if __debug__:
217             print >>hyperdb.DEBUG, "opendb %r.open(%r, %r)"%(db_type, path,
218                 mode)
219         return dbm.open(path, mode)
221     #
222     # Node IDs
223     #
224     def newid(self, classname):
225         ''' Generate a new id for the given class
226         '''
227         # open the ids DB - create if if doesn't exist
228         db = self.opendb('_ids', 'c')
229         if db.has_key(classname):
230             newid = db[classname] = str(int(db[classname]) + 1)
231         else:
232             # the count() bit is transitional - older dbs won't start at 1
233             newid = str(self.getclass(classname).count()+1)
234             db[classname] = newid
235         db.close()
236         return newid
238     def setid(self, classname, setid):
239         ''' Set the id counter: used during import of database
240         '''
241         # open the ids DB - create if if doesn't exist
242         db = self.opendb('_ids', 'c')
243         db[classname] = str(setid)
244         db.close()
246     #
247     # Nodes
248     #
249     def addnode(self, classname, nodeid, node):
250         ''' add the specified node to its class's db
251         '''
252         if __debug__:
253             print >>hyperdb.DEBUG, 'addnode', (self, classname, nodeid, node)
255         # we'll be supplied these props if we're doing an import
256         if not node.has_key('creator'):
257             # add in the "calculated" properties (dupe so we don't affect
258             # calling code's node assumptions)
259             node = node.copy()
260             node['creator'] = self.getuid()
261             node['creation'] = node['activity'] = date.Date()
263         self.newnodes.setdefault(classname, {})[nodeid] = 1
264         self.cache.setdefault(classname, {})[nodeid] = node
265         self.savenode(classname, nodeid, node)
267     def setnode(self, classname, nodeid, node):
268         ''' change the specified node
269         '''
270         if __debug__:
271             print >>hyperdb.DEBUG, 'setnode', (self, classname, nodeid, node)
272         self.dirtynodes.setdefault(classname, {})[nodeid] = 1
274         # update the activity time (dupe so we don't affect
275         # calling code's node assumptions)
276         node = node.copy()
277         node['activity'] = date.Date()
279         # can't set without having already loaded the node
280         self.cache[classname][nodeid] = node
281         self.savenode(classname, nodeid, node)
283     def savenode(self, classname, nodeid, node):
284         ''' perform the saving of data specified by the set/addnode
285         '''
286         if __debug__:
287             print >>hyperdb.DEBUG, 'savenode', (self, classname, nodeid, node)
288         self.transactions.append((self.doSaveNode, (classname, nodeid, node)))
290     def getnode(self, classname, nodeid, db=None, cache=1):
291         ''' get a node from the database
293             Note the "cache" parameter is not used, and exists purely for
294             backward compatibility!
295         '''
296         if __debug__:
297             print >>hyperdb.DEBUG, 'getnode', (self, classname, nodeid, db)
299         # try the cache
300         cache_dict = self.cache.setdefault(classname, {})
301         if cache_dict.has_key(nodeid):
302             if __debug__:
303                 print >>hyperdb.TRACE, 'get %s %s cached'%(classname,
304                     nodeid)
305             return cache_dict[nodeid]
307         if __debug__:
308             print >>hyperdb.TRACE, 'get %s %s'%(classname, nodeid)
310         # get from the database and save in the cache
311         if db is None:
312             db = self.getclassdb(classname)
313         if not db.has_key(nodeid):
314             raise IndexError, "no such %s %s"%(classname, nodeid)
316         # check the uncommitted, destroyed nodes
317         if (self.destroyednodes.has_key(classname) and
318                 self.destroyednodes[classname].has_key(nodeid)):
319             raise IndexError, "no such %s %s"%(classname, nodeid)
321         # decode
322         res = marshal.loads(db[nodeid])
324         # reverse the serialisation
325         res = self.unserialise(classname, res)
327         # store off in the cache dict
328         if cache:
329             cache_dict[nodeid] = res
331         return res
333     def destroynode(self, classname, nodeid):
334         '''Remove a node from the database. Called exclusively by the
335            destroy() method on Class.
336         '''
337         if __debug__:
338             print >>hyperdb.DEBUG, 'destroynode', (self, classname, nodeid)
340         # remove from cache and newnodes if it's there
341         if (self.cache.has_key(classname) and
342                 self.cache[classname].has_key(nodeid)):
343             del self.cache[classname][nodeid]
344         if (self.newnodes.has_key(classname) and
345                 self.newnodes[classname].has_key(nodeid)):
346             del self.newnodes[classname][nodeid]
348         # see if there's any obvious commit actions that we should get rid of
349         for entry in self.transactions[:]:
350             if entry[1][:2] == (classname, nodeid):
351                 self.transactions.remove(entry)
353         # add to the destroyednodes map
354         self.destroyednodes.setdefault(classname, {})[nodeid] = 1
356         # add the destroy commit action
357         self.transactions.append((self.doDestroyNode, (classname, nodeid)))
359     def serialise(self, classname, node):
360         '''Copy the node contents, converting non-marshallable data into
361            marshallable data.
362         '''
363         if __debug__:
364             print >>hyperdb.DEBUG, 'serialise', classname, node
365         properties = self.getclass(classname).getprops()
366         d = {}
367         for k, v in node.items():
368             # if the property doesn't exist, or is the "retired" flag then
369             # it won't be in the properties dict
370             if not properties.has_key(k):
371                 d[k] = v
372                 continue
374             # get the property spec
375             prop = properties[k]
377             if isinstance(prop, Password) and v is not None:
378                 d[k] = str(v)
379             elif isinstance(prop, Date) and v is not None:
380                 d[k] = v.serialise()
381             elif isinstance(prop, Interval) and v is not None:
382                 d[k] = v.serialise()
383             else:
384                 d[k] = v
385         return d
387     def unserialise(self, classname, node):
388         '''Decode the marshalled node data
389         '''
390         if __debug__:
391             print >>hyperdb.DEBUG, 'unserialise', classname, node
392         properties = self.getclass(classname).getprops()
393         d = {}
394         for k, v in node.items():
395             # if the property doesn't exist, or is the "retired" flag then
396             # it won't be in the properties dict
397             if not properties.has_key(k):
398                 d[k] = v
399                 continue
401             # get the property spec
402             prop = properties[k]
404             if isinstance(prop, Date) and v is not None:
405                 d[k] = date.Date(v)
406             elif isinstance(prop, Interval) and v is not None:
407                 d[k] = date.Interval(v)
408             elif isinstance(prop, Password) and v is not None:
409                 p = password.Password()
410                 p.unpack(v)
411                 d[k] = p
412             else:
413                 d[k] = v
414         return d
416     def hasnode(self, classname, nodeid, db=None):
417         ''' determine if the database has a given node
418         '''
419         if __debug__:
420             print >>hyperdb.DEBUG, 'hasnode', (self, classname, nodeid, db)
422         # try the cache
423         cache = self.cache.setdefault(classname, {})
424         if cache.has_key(nodeid):
425             if __debug__:
426                 print >>hyperdb.TRACE, 'has %s %s cached'%(classname, nodeid)
427             return 1
428         if __debug__:
429             print >>hyperdb.TRACE, 'has %s %s'%(classname, nodeid)
431         # not in the cache - check the database
432         if db is None:
433             db = self.getclassdb(classname)
434         res = db.has_key(nodeid)
435         return res
437     def countnodes(self, classname, db=None):
438         if __debug__:
439             print >>hyperdb.DEBUG, 'countnodes', (self, classname, db)
441         count = 0
443         # include the uncommitted nodes
444         if self.newnodes.has_key(classname):
445             count += len(self.newnodes[classname])
446         if self.destroyednodes.has_key(classname):
447             count -= len(self.destroyednodes[classname])
449         # and count those in the DB
450         if db is None:
451             db = self.getclassdb(classname)
452         count = count + len(db.keys())
453         return count
456     #
457     # Files - special node properties
458     # inherited from FileStorage
460     #
461     # Journal
462     #
463     def addjournal(self, classname, nodeid, action, params, creator=None,
464             creation=None):
465         ''' Journal the Action
466         'action' may be:
468             'create' or 'set' -- 'params' is a dictionary of property values
469             'link' or 'unlink' -- 'params' is (classname, nodeid, propname)
470             'retire' -- 'params' is None
471         '''
472         if __debug__:
473             print >>hyperdb.DEBUG, 'addjournal', (self, classname, nodeid,
474                 action, params, creator, creation)
475         self.transactions.append((self.doSaveJournal, (classname, nodeid,
476             action, params, creator, creation)))
478     def getjournal(self, classname, nodeid):
479         ''' get the journal for id
481             Raise IndexError if the node doesn't exist (as per history()'s
482             API)
483         '''
484         if __debug__:
485             print >>hyperdb.DEBUG, 'getjournal', (self, classname, nodeid)
487         # our journal result
488         res = []
490         # add any journal entries for transactions not committed to the
491         # database
492         for method, args in self.transactions:
493             if method != self.doSaveJournal:
494                 continue
495             (cache_classname, cache_nodeid, cache_action, cache_params,
496                 cache_creator, cache_creation) = args
497             if cache_classname == classname and cache_nodeid == nodeid:
498                 if not cache_creator:
499                     cache_creator = self.getuid()
500                 if not cache_creation:
501                     cache_creation = date.Date()
502                 res.append((cache_nodeid, cache_creation, cache_creator,
503                     cache_action, cache_params))
505         # attempt to open the journal - in some rare cases, the journal may
506         # not exist
507         try:
508             db = self.opendb('journals.%s'%classname, 'r')
509         except anydbm.error, error:
510             if str(error) == "need 'c' or 'n' flag to open new db":
511                 raise IndexError, 'no such %s %s'%(classname, nodeid)
512             elif error.args[0] != 2:
513                 # this isn't a "not found" error, be alarmed!
514                 raise
515             if res:
516                 # we have unsaved journal entries, return them
517                 return res
518             raise IndexError, 'no such %s %s'%(classname, nodeid)
519         try:
520             journal = marshal.loads(db[nodeid])
521         except KeyError:
522             db.close()
523             if res:
524                 # we have some unsaved journal entries, be happy!
525                 return res
526             raise IndexError, 'no such %s %s'%(classname, nodeid)
527         db.close()
529         # add all the saved journal entries for this node
530         for nodeid, date_stamp, user, action, params in journal:
531             res.append((nodeid, date.Date(date_stamp), user, action, params))
532         return res
534     def pack(self, pack_before):
535         ''' Delete all journal entries except "create" before 'pack_before'.
536         '''
537         if __debug__:
538             print >>hyperdb.DEBUG, 'packjournal', (self, pack_before)
540         pack_before = pack_before.serialise()
541         for classname in self.getclasses():
542             # get the journal db
543             db_name = 'journals.%s'%classname
544             path = os.path.join(os.getcwd(), self.dir, classname)
545             db_type = self.determine_db_type(path)
546             db = self.opendb(db_name, 'w')
548             for key in db.keys():
549                 # get the journal for this db entry
550                 journal = marshal.loads(db[key])
551                 l = []
552                 last_set_entry = None
553                 for entry in journal:
554                     # unpack the entry
555                     (nodeid, date_stamp, self.journaltag, action, 
556                         params) = entry
557                     # if the entry is after the pack date, _or_ the initial
558                     # create entry, then it stays
559                     if date_stamp > pack_before or action == 'create':
560                         l.append(entry)
561                 db[key] = marshal.dumps(l)
562             if db_type == 'gdbm':
563                 db.reorganize()
564             db.close()
565             
567     #
568     # Basic transaction support
569     #
570     def commit(self):
571         ''' Commit the current transactions.
572         '''
573         if __debug__:
574             print >>hyperdb.DEBUG, 'commit', (self,)
576         # keep a handle to all the database files opened
577         self.databases = {}
579         try:
580             # now, do all the transactions
581             reindex = {}
582             for method, args in self.transactions:
583                 reindex[method(*args)] = 1
584         finally:
585             # make sure we close all the database files
586             for db in self.databases.values():
587                 db.close()
588             del self.databases
590         # reindex the nodes that request it
591         for classname, nodeid in filter(None, reindex.keys()):
592             print >>hyperdb.DEBUG, 'commit.reindex', (classname, nodeid)
593             self.getclass(classname).index(nodeid)
595         # save the indexer state
596         self.indexer.save_index()
598         self.clearCache()
600     def clearCache(self):
601         # all transactions committed, back to normal
602         self.cache = {}
603         self.dirtynodes = {}
604         self.newnodes = {}
605         self.destroyednodes = {}
606         self.transactions = []
608     def getCachedClassDB(self, classname):
609         ''' get the class db, looking in our cache of databases for commit
610         '''
611         # get the database handle
612         db_name = 'nodes.%s'%classname
613         if not self.databases.has_key(db_name):
614             self.databases[db_name] = self.getclassdb(classname, 'c')
615         return self.databases[db_name]
617     def doSaveNode(self, classname, nodeid, node):
618         if __debug__:
619             print >>hyperdb.DEBUG, 'doSaveNode', (self, classname, nodeid,
620                 node)
622         db = self.getCachedClassDB(classname)
624         # now save the marshalled data
625         db[nodeid] = marshal.dumps(self.serialise(classname, node))
627         # return the classname, nodeid so we reindex this content
628         return (classname, nodeid)
630     def getCachedJournalDB(self, classname):
631         ''' get the journal db, looking in our cache of databases for commit
632         '''
633         # get the database handle
634         db_name = 'journals.%s'%classname
635         if not self.databases.has_key(db_name):
636             self.databases[db_name] = self.opendb(db_name, 'c')
637         return self.databases[db_name]
639     def doSaveJournal(self, classname, nodeid, action, params, creator,
640             creation):
641         # serialise the parameters now if necessary
642         if isinstance(params, type({})):
643             if action in ('set', 'create'):
644                 params = self.serialise(classname, params)
646         # handle supply of the special journalling parameters (usually
647         # supplied on importing an existing database)
648         if creator:
649             journaltag = creator
650         else:
651             journaltag = self.getuid()
652         if creation:
653             journaldate = creation.serialise()
654         else:
655             journaldate = date.Date().serialise()
657         # create the journal entry
658         entry = (nodeid, journaldate, journaltag, action, params)
660         if __debug__:
661             print >>hyperdb.DEBUG, 'doSaveJournal', entry
663         db = self.getCachedJournalDB(classname)
665         # now insert the journal entry
666         if db.has_key(nodeid):
667             # append to existing
668             s = db[nodeid]
669             l = marshal.loads(s)
670             l.append(entry)
671         else:
672             l = [entry]
674         db[nodeid] = marshal.dumps(l)
676     def doDestroyNode(self, classname, nodeid):
677         if __debug__:
678             print >>hyperdb.DEBUG, 'doDestroyNode', (self, classname, nodeid)
680         # delete from the class database
681         db = self.getCachedClassDB(classname)
682         if db.has_key(nodeid):
683             del db[nodeid]
685         # delete from the database
686         db = self.getCachedJournalDB(classname)
687         if db.has_key(nodeid):
688             del db[nodeid]
690         # return the classname, nodeid so we reindex this content
691         return (classname, nodeid)
693     def rollback(self):
694         ''' Reverse all actions from the current transaction.
695         '''
696         if __debug__:
697             print >>hyperdb.DEBUG, 'rollback', (self, )
698         for method, args in self.transactions:
699             # delete temporary files
700             if method == self.doStoreFile:
701                 self.rollbackStoreFile(*args)
702         self.cache = {}
703         self.dirtynodes = {}
704         self.newnodes = {}
705         self.destroyednodes = {}
706         self.transactions = []
708     def close(self):
709         ''' Nothing to do
710         '''
711         if self.lockfile is not None:
712             locking.release_lock(self.lockfile)
713         if self.lockfile is not None:
714             self.lockfile.close()
715             self.lockfile = None
717 _marker = []
718 class Class(hyperdb.Class):
719     '''The handle to a particular class of nodes in a hyperdatabase.'''
721     def __init__(self, db, classname, **properties):
722         '''Create a new class with a given name and property specification.
724         'classname' must not collide with the name of an existing class,
725         or a ValueError is raised.  The keyword arguments in 'properties'
726         must map names to property objects, or a TypeError is raised.
727         '''
728         if (properties.has_key('creation') or properties.has_key('activity')
729                 or properties.has_key('creator')):
730             raise ValueError, '"creation", "activity" and "creator" are '\
731                 'reserved'
733         self.classname = classname
734         self.properties = properties
735         self.db = weakref.proxy(db)       # use a weak ref to avoid circularity
736         self.key = ''
738         # should we journal changes (default yes)
739         self.do_journal = 1
741         # do the db-related init stuff
742         db.addclass(self)
744         self.auditors = {'create': [], 'set': [], 'retire': [], 'restore': []}
745         self.reactors = {'create': [], 'set': [], 'retire': [], 'restore': []}
747     def enableJournalling(self):
748         '''Turn journalling on for this class
749         '''
750         self.do_journal = 1
752     def disableJournalling(self):
753         '''Turn journalling off for this class
754         '''
755         self.do_journal = 0
757     # Editing nodes:
759     def create(self, **propvalues):
760         '''Create a new node of this class and return its id.
762         The keyword arguments in 'propvalues' map property names to values.
764         The values of arguments must be acceptable for the types of their
765         corresponding properties or a TypeError is raised.
766         
767         If this class has a key property, it must be present and its value
768         must not collide with other key strings or a ValueError is raised.
769         
770         Any other properties on this class that are missing from the
771         'propvalues' dictionary are set to None.
772         
773         If an id in a link or multilink property does not refer to a valid
774         node, an IndexError is raised.
776         These operations trigger detectors and can be vetoed.  Attempts
777         to modify the "creation" or "activity" properties cause a KeyError.
778         '''
779         self.fireAuditors('create', None, propvalues)
780         newid = self.create_inner(**propvalues)
781         self.fireReactors('create', newid, None)
782         return newid
784     def create_inner(self, **propvalues):
785         ''' Called by create, in-between the audit and react calls.
786         '''
787         if propvalues.has_key('id'):
788             raise KeyError, '"id" is reserved'
790         if self.db.journaltag is None:
791             raise DatabaseError, 'Database open read-only'
793         if propvalues.has_key('creation') or propvalues.has_key('activity'):
794             raise KeyError, '"creation" and "activity" are reserved'
795         # new node's id
796         newid = self.db.newid(self.classname)
798         # validate propvalues
799         num_re = re.compile('^\d+$')
800         for key, value in propvalues.items():
801             if key == self.key:
802                 try:
803                     self.lookup(value)
804                 except KeyError:
805                     pass
806                 else:
807                     raise ValueError, 'node with key "%s" exists'%value
809             # try to handle this property
810             try:
811                 prop = self.properties[key]
812             except KeyError:
813                 raise KeyError, '"%s" has no property "%s"'%(self.classname,
814                     key)
816             if value is not None and isinstance(prop, Link):
817                 if type(value) != type(''):
818                     raise ValueError, 'link value must be String'
819                 link_class = self.properties[key].classname
820                 # if it isn't a number, it's a key
821                 if not num_re.match(value):
822                     try:
823                         value = self.db.classes[link_class].lookup(value)
824                     except (TypeError, KeyError):
825                         raise IndexError, 'new property "%s": %s not a %s'%(
826                             key, value, link_class)
827                 elif not self.db.getclass(link_class).hasnode(value):
828                     raise IndexError, '%s has no node %s'%(link_class, value)
830                 # save off the value
831                 propvalues[key] = value
833                 # register the link with the newly linked node
834                 if self.do_journal and self.properties[key].do_journal:
835                     self.db.addjournal(link_class, value, 'link',
836                         (self.classname, newid, key))
838             elif isinstance(prop, Multilink):
839                 if type(value) != type([]):
840                     raise TypeError, 'new property "%s" not a list of ids'%key
842                 # clean up and validate the list of links
843                 link_class = self.properties[key].classname
844                 l = []
845                 for entry in value:
846                     if type(entry) != type(''):
847                         raise ValueError, '"%s" multilink value (%r) '\
848                             'must contain Strings'%(key, value)
849                     # if it isn't a number, it's a key
850                     if not num_re.match(entry):
851                         try:
852                             entry = self.db.classes[link_class].lookup(entry)
853                         except (TypeError, KeyError):
854                             raise IndexError, 'new property "%s": %s not a %s'%(
855                                 key, entry, self.properties[key].classname)
856                     l.append(entry)
857                 value = l
858                 propvalues[key] = value
860                 # handle additions
861                 for nodeid in value:
862                     if not self.db.getclass(link_class).hasnode(nodeid):
863                         raise IndexError, '%s has no node %s'%(link_class,
864                             nodeid)
865                     # register the link with the newly linked node
866                     if self.do_journal and self.properties[key].do_journal:
867                         self.db.addjournal(link_class, nodeid, 'link',
868                             (self.classname, newid, key))
870             elif isinstance(prop, String):
871                 if type(value) != type('') and type(value) != type(u''):
872                     raise TypeError, 'new property "%s" not a string'%key
874             elif isinstance(prop, Password):
875                 if not isinstance(value, password.Password):
876                     raise TypeError, 'new property "%s" not a Password'%key
878             elif isinstance(prop, Date):
879                 if value is not None and not isinstance(value, date.Date):
880                     raise TypeError, 'new property "%s" not a Date'%key
882             elif isinstance(prop, Interval):
883                 if value is not None and not isinstance(value, date.Interval):
884                     raise TypeError, 'new property "%s" not an Interval'%key
886             elif value is not None and isinstance(prop, Number):
887                 try:
888                     float(value)
889                 except ValueError:
890                     raise TypeError, 'new property "%s" not numeric'%key
892             elif value is not None and isinstance(prop, Boolean):
893                 try:
894                     int(value)
895                 except ValueError:
896                     raise TypeError, 'new property "%s" not boolean'%key
898         # make sure there's data where there needs to be
899         for key, prop in self.properties.items():
900             if propvalues.has_key(key):
901                 continue
902             if key == self.key:
903                 raise ValueError, 'key property "%s" is required'%key
904             if isinstance(prop, Multilink):
905                 propvalues[key] = []
906             else:
907                 propvalues[key] = None
909         # done
910         self.db.addnode(self.classname, newid, propvalues)
911         if self.do_journal:
912             self.db.addjournal(self.classname, newid, 'create', {})
914         return newid
916     def export_list(self, propnames, nodeid):
917         ''' Export a node - generate a list of CSV-able data in the order
918             specified by propnames for the given node.
919         '''
920         properties = self.getprops()
921         l = []
922         for prop in propnames:
923             proptype = properties[prop]
924             value = self.get(nodeid, prop)
925             # "marshal" data where needed
926             if value is None:
927                 pass
928             elif isinstance(proptype, hyperdb.Date):
929                 value = value.get_tuple()
930             elif isinstance(proptype, hyperdb.Interval):
931                 value = value.get_tuple()
932             elif isinstance(proptype, hyperdb.Password):
933                 value = str(value)
934             l.append(repr(value))
936         # append retired flag
937         l.append(repr(self.is_retired(nodeid)))
939         return l
941     def import_list(self, propnames, proplist):
942         ''' Import a node - all information including "id" is present and
943             should not be sanity checked. Triggers are not triggered. The
944             journal should be initialised using the "creator" and "created"
945             information.
947             Return the nodeid of the node imported.
948         '''
949         if self.db.journaltag is None:
950             raise DatabaseError, 'Database open read-only'
951         properties = self.getprops()
953         # make the new node's property map
954         d = {}
955         newid = None
956         for i in range(len(propnames)):
957             # Figure the property for this column
958             propname = propnames[i]
960             # Use eval to reverse the repr() used to output the CSV
961             value = eval(proplist[i])
963             # "unmarshal" where necessary
964             if propname == 'id':
965                 newid = value
966                 continue
967             elif propname == 'is retired':
968                 # is the item retired?
969                 if int(value):
970                     d[self.db.RETIRED_FLAG] = 1
971                 continue
972             elif value is None:
973                 d[propname] = None
974                 continue
976             prop = properties[propname]
977             if isinstance(prop, hyperdb.Date):
978                 value = date.Date(value)
979             elif isinstance(prop, hyperdb.Interval):
980                 value = date.Interval(value)
981             elif isinstance(prop, hyperdb.Password):
982                 pwd = password.Password()
983                 pwd.unpack(value)
984                 value = pwd
985             d[propname] = value
987         # get a new id if necessary
988         if newid is None:
989             newid = self.db.newid(self.classname)
991         # add the node and journal
992         self.db.addnode(self.classname, newid, d)
994         # extract the journalling stuff and nuke it
995         if d.has_key('creator'):
996             creator = d['creator']
997             del d['creator']
998         else:
999             creator = None
1000         if d.has_key('creation'):
1001             creation = d['creation']
1002             del d['creation']
1003         else:
1004             creation = None
1005         if d.has_key('activity'):
1006             del d['activity']
1007         self.db.addjournal(self.classname, newid, 'create', {}, creator,
1008             creation)
1009         return newid
1011     def get(self, nodeid, propname, default=_marker, cache=1):
1012         '''Get the value of a property on an existing node of this class.
1014         'nodeid' must be the id of an existing node of this class or an
1015         IndexError is raised.  'propname' must be the name of a property
1016         of this class or a KeyError is raised.
1018         'cache' exists for backward compatibility, and is not used.
1020         Attempts to get the "creation" or "activity" properties should
1021         do the right thing.
1022         '''
1023         if propname == 'id':
1024             return nodeid
1026         # get the node's dict
1027         d = self.db.getnode(self.classname, nodeid)
1029         # check for one of the special props
1030         if propname == 'creation':
1031             if d.has_key('creation'):
1032                 return d['creation']
1033             if not self.do_journal:
1034                 raise ValueError, 'Journalling is disabled for this class'
1035             journal = self.db.getjournal(self.classname, nodeid)
1036             if journal:
1037                 return self.db.getjournal(self.classname, nodeid)[0][1]
1038             else:
1039                 # on the strange chance that there's no journal
1040                 return date.Date()
1041         if propname == 'activity':
1042             if d.has_key('activity'):
1043                 return d['activity']
1044             if not self.do_journal:
1045                 raise ValueError, 'Journalling is disabled for this class'
1046             journal = self.db.getjournal(self.classname, nodeid)
1047             if journal:
1048                 return self.db.getjournal(self.classname, nodeid)[-1][1]
1049             else:
1050                 # on the strange chance that there's no journal
1051                 return date.Date()
1052         if propname == 'creator':
1053             if d.has_key('creator'):
1054                 return d['creator']
1055             if not self.do_journal:
1056                 raise ValueError, 'Journalling is disabled for this class'
1057             journal = self.db.getjournal(self.classname, nodeid)
1058             if journal:
1059                 num_re = re.compile('^\d+$')
1060                 value = self.db.getjournal(self.classname, nodeid)[0][2]
1061                 if num_re.match(value):
1062                     return value
1063                 else:
1064                     # old-style "username" journal tag
1065                     try:
1066                         return self.db.user.lookup(value)
1067                     except KeyError:
1068                         # user's been retired, return admin
1069                         return '1'
1070             else:
1071                 return self.db.getuid()
1073         # get the property (raises KeyErorr if invalid)
1074         prop = self.properties[propname]
1076         if not d.has_key(propname):
1077             if default is _marker:
1078                 if isinstance(prop, Multilink):
1079                     return []
1080                 else:
1081                     return None
1082             else:
1083                 return default
1085         # return a dupe of the list so code doesn't get confused
1086         if isinstance(prop, Multilink):
1087             return d[propname][:]
1089         return d[propname]
1091     def set(self, nodeid, **propvalues):
1092         '''Modify a property on an existing node of this class.
1093         
1094         'nodeid' must be the id of an existing node of this class or an
1095         IndexError is raised.
1097         Each key in 'propvalues' must be the name of a property of this
1098         class or a KeyError is raised.
1100         All values in 'propvalues' must be acceptable types for their
1101         corresponding properties or a TypeError is raised.
1103         If the value of the key property is set, it must not collide with
1104         other key strings or a ValueError is raised.
1106         If the value of a Link or Multilink property contains an invalid
1107         node id, a ValueError is raised.
1109         These operations trigger detectors and can be vetoed.  Attempts
1110         to modify the "creation" or "activity" properties cause a KeyError.
1111         '''
1112         if not propvalues:
1113             return propvalues
1115         if propvalues.has_key('creation') or propvalues.has_key('activity'):
1116             raise KeyError, '"creation" and "activity" are reserved'
1118         if propvalues.has_key('id'):
1119             raise KeyError, '"id" is reserved'
1121         if self.db.journaltag is None:
1122             raise DatabaseError, 'Database open read-only'
1124         self.fireAuditors('set', nodeid, propvalues)
1125         # Take a copy of the node dict so that the subsequent set
1126         # operation doesn't modify the oldvalues structure.
1127         oldvalues = copy.deepcopy(self.db.getnode(self.classname, nodeid))
1129         node = self.db.getnode(self.classname, nodeid)
1130         if node.has_key(self.db.RETIRED_FLAG):
1131             raise IndexError
1132         num_re = re.compile('^\d+$')
1134         # if the journal value is to be different, store it in here
1135         journalvalues = {}
1137         for propname, value in propvalues.items():
1138             # check to make sure we're not duplicating an existing key
1139             if propname == self.key and node[propname] != value:
1140                 try:
1141                     self.lookup(value)
1142                 except KeyError:
1143                     pass
1144                 else:
1145                     raise ValueError, 'node with key "%s" exists'%value
1147             # this will raise the KeyError if the property isn't valid
1148             # ... we don't use getprops() here because we only care about
1149             # the writeable properties.
1150             try:
1151                 prop = self.properties[propname]
1152             except KeyError:
1153                 raise KeyError, '"%s" has no property named "%s"'%(
1154                     self.classname, propname)
1156             # if the value's the same as the existing value, no sense in
1157             # doing anything
1158             current = node.get(propname, None)
1159             if value == current:
1160                 del propvalues[propname]
1161                 continue
1162             journalvalues[propname] = current
1164             # do stuff based on the prop type
1165             if isinstance(prop, Link):
1166                 link_class = prop.classname
1167                 # if it isn't a number, it's a key
1168                 if value is not None and not isinstance(value, type('')):
1169                     raise ValueError, 'property "%s" link value be a string'%(
1170                         propname)
1171                 if isinstance(value, type('')) and not num_re.match(value):
1172                     try:
1173                         value = self.db.classes[link_class].lookup(value)
1174                     except (TypeError, KeyError):
1175                         raise IndexError, 'new property "%s": %s not a %s'%(
1176                             propname, value, prop.classname)
1178                 if (value is not None and
1179                         not self.db.getclass(link_class).hasnode(value)):
1180                     raise IndexError, '%s has no node %s'%(link_class, value)
1182                 if self.do_journal and prop.do_journal:
1183                     # register the unlink with the old linked node
1184                     if node.has_key(propname) and node[propname] is not None:
1185                         self.db.addjournal(link_class, node[propname], 'unlink',
1186                             (self.classname, nodeid, propname))
1188                     # register the link with the newly linked node
1189                     if value is not None:
1190                         self.db.addjournal(link_class, value, 'link',
1191                             (self.classname, nodeid, propname))
1193             elif isinstance(prop, Multilink):
1194                 if type(value) != type([]):
1195                     raise TypeError, 'new property "%s" not a list of'\
1196                         ' ids'%propname
1197                 link_class = self.properties[propname].classname
1198                 l = []
1199                 for entry in value:
1200                     # if it isn't a number, it's a key
1201                     if type(entry) != type(''):
1202                         raise ValueError, 'new property "%s" link value ' \
1203                             'must be a string'%propname
1204                     if not num_re.match(entry):
1205                         try:
1206                             entry = self.db.classes[link_class].lookup(entry)
1207                         except (TypeError, KeyError):
1208                             raise IndexError, 'new property "%s": %s not a %s'%(
1209                                 propname, entry,
1210                                 self.properties[propname].classname)
1211                     l.append(entry)
1212                 value = l
1213                 propvalues[propname] = value
1215                 # figure the journal entry for this property
1216                 add = []
1217                 remove = []
1219                 # handle removals
1220                 if node.has_key(propname):
1221                     l = node[propname]
1222                 else:
1223                     l = []
1224                 for id in l[:]:
1225                     if id in value:
1226                         continue
1227                     # register the unlink with the old linked node
1228                     if self.do_journal and self.properties[propname].do_journal:
1229                         self.db.addjournal(link_class, id, 'unlink',
1230                             (self.classname, nodeid, propname))
1231                     l.remove(id)
1232                     remove.append(id)
1234                 # handle additions
1235                 for id in value:
1236                     if not self.db.getclass(link_class).hasnode(id):
1237                         raise IndexError, '%s has no node %s'%(link_class, id)
1238                     if id in l:
1239                         continue
1240                     # register the link with the newly linked node
1241                     if self.do_journal and self.properties[propname].do_journal:
1242                         self.db.addjournal(link_class, id, 'link',
1243                             (self.classname, nodeid, propname))
1244                     l.append(id)
1245                     add.append(id)
1247                 # figure the journal entry
1248                 l = []
1249                 if add:
1250                     l.append(('+', add))
1251                 if remove:
1252                     l.append(('-', remove))
1253                 if l:
1254                     journalvalues[propname] = tuple(l)
1256             elif isinstance(prop, String):
1257                 if value is not None and type(value) != type('') and type(value) != type(u''):
1258                     raise TypeError, 'new property "%s" not a string'%propname
1260             elif isinstance(prop, Password):
1261                 if not isinstance(value, password.Password):
1262                     raise TypeError, 'new property "%s" not a Password'%propname
1263                 propvalues[propname] = value
1265             elif value is not None and isinstance(prop, Date):
1266                 if not isinstance(value, date.Date):
1267                     raise TypeError, 'new property "%s" not a Date'% propname
1268                 propvalues[propname] = value
1270             elif value is not None and isinstance(prop, Interval):
1271                 if not isinstance(value, date.Interval):
1272                     raise TypeError, 'new property "%s" not an '\
1273                         'Interval'%propname
1274                 propvalues[propname] = value
1276             elif value is not None and isinstance(prop, Number):
1277                 try:
1278                     float(value)
1279                 except ValueError:
1280                     raise TypeError, 'new property "%s" not numeric'%propname
1282             elif value is not None and isinstance(prop, Boolean):
1283                 try:
1284                     int(value)
1285                 except ValueError:
1286                     raise TypeError, 'new property "%s" not boolean'%propname
1288             node[propname] = value
1290         # nothing to do?
1291         if not propvalues:
1292             return propvalues
1294         # do the set, and journal it
1295         self.db.setnode(self.classname, nodeid, node)
1297         if self.do_journal:
1298             self.db.addjournal(self.classname, nodeid, 'set', journalvalues)
1300         self.fireReactors('set', nodeid, oldvalues)
1302         return propvalues        
1304     def retire(self, nodeid):
1305         '''Retire a node.
1306         
1307         The properties on the node remain available from the get() method,
1308         and the node's id is never reused.
1309         
1310         Retired nodes are not returned by the find(), list(), or lookup()
1311         methods, and other nodes may reuse the values of their key properties.
1313         These operations trigger detectors and can be vetoed.  Attempts
1314         to modify the "creation" or "activity" properties cause a KeyError.
1315         '''
1316         if self.db.journaltag is None:
1317             raise DatabaseError, 'Database open read-only'
1319         self.fireAuditors('retire', nodeid, None)
1321         node = self.db.getnode(self.classname, nodeid)
1322         node[self.db.RETIRED_FLAG] = 1
1323         self.db.setnode(self.classname, nodeid, node)
1324         if self.do_journal:
1325             self.db.addjournal(self.classname, nodeid, 'retired', None)
1327         self.fireReactors('retire', nodeid, None)
1329     def restore(self, nodeid):
1330         '''Restpre a retired node.
1332         Make node available for all operations like it was before retirement.
1333         '''
1334         if self.db.journaltag is None:
1335             raise DatabaseError, 'Database open read-only'
1337         node = self.db.getnode(self.classname, nodeid)
1338         # check if key property was overrided
1339         key = self.getkey()
1340         try:
1341             id = self.lookup(node[key])
1342         except KeyError:
1343             pass
1344         else:
1345             raise KeyError, "Key property (%s) of retired node clashes with \
1346                 existing one (%s)" % (key, node[key])
1347         # Now we can safely restore node
1348         self.fireAuditors('restore', nodeid, None)
1349         del node[self.db.RETIRED_FLAG]
1350         self.db.setnode(self.classname, nodeid, node)
1351         if self.do_journal:
1352             self.db.addjournal(self.classname, nodeid, 'restored', None)
1354         self.fireReactors('restore', nodeid, None)
1356     def is_retired(self, nodeid, cldb=None):
1357         '''Return true if the node is retired.
1358         '''
1359         node = self.db.getnode(self.classname, nodeid, cldb)
1360         if node.has_key(self.db.RETIRED_FLAG):
1361             return 1
1362         return 0
1364     def destroy(self, nodeid):
1365         '''Destroy a node.
1367         WARNING: this method should never be used except in extremely rare
1368                  situations where there could never be links to the node being
1369                  deleted
1371         WARNING: use retire() instead
1373         WARNING: the properties of this node will not be available ever again
1375         WARNING: really, use retire() instead
1377         Well, I think that's enough warnings. This method exists mostly to
1378         support the session storage of the cgi interface.
1379         '''
1380         if self.db.journaltag is None:
1381             raise DatabaseError, 'Database open read-only'
1382         self.db.destroynode(self.classname, nodeid)
1384     def history(self, nodeid):
1385         '''Retrieve the journal of edits on a particular node.
1387         'nodeid' must be the id of an existing node of this class or an
1388         IndexError is raised.
1390         The returned list contains tuples of the form
1392             (nodeid, date, tag, action, params)
1394         'date' is a Timestamp object specifying the time of the change and
1395         'tag' is the journaltag specified when the database was opened.
1396         '''
1397         if not self.do_journal:
1398             raise ValueError, 'Journalling is disabled for this class'
1399         return self.db.getjournal(self.classname, nodeid)
1401     # Locating nodes:
1402     def hasnode(self, nodeid):
1403         '''Determine if the given nodeid actually exists
1404         '''
1405         return self.db.hasnode(self.classname, nodeid)
1407     def setkey(self, propname):
1408         '''Select a String property of this class to be the key property.
1410         'propname' must be the name of a String property of this class or
1411         None, or a TypeError is raised.  The values of the key property on
1412         all existing nodes must be unique or a ValueError is raised. If the
1413         property doesn't exist, KeyError is raised.
1414         '''
1415         prop = self.getprops()[propname]
1416         if not isinstance(prop, String):
1417             raise TypeError, 'key properties must be String'
1418         self.key = propname
1420     def getkey(self):
1421         '''Return the name of the key property for this class or None.'''
1422         return self.key
1424     def labelprop(self, default_to_id=0):
1425         '''Return the property name for a label for the given node.
1427         This method attempts to generate a consistent label for the node.
1428         It tries the following in order:
1430         1. key property
1431         2. "name" property
1432         3. "title" property
1433         4. first property from the sorted property name list
1434         '''
1435         k = self.getkey()
1436         if  k:
1437             return k
1438         props = self.getprops()
1439         if props.has_key('name'):
1440             return 'name'
1441         elif props.has_key('title'):
1442             return 'title'
1443         if default_to_id:
1444             return 'id'
1445         props = props.keys()
1446         props.sort()
1447         return props[0]
1449     # TODO: set up a separate index db file for this? profile?
1450     def lookup(self, keyvalue):
1451         '''Locate a particular node by its key property and return its id.
1453         If this class has no key property, a TypeError is raised.  If the
1454         'keyvalue' matches one of the values for the key property among
1455         the nodes in this class, the matching node's id is returned;
1456         otherwise a KeyError is raised.
1457         '''
1458         if not self.key:
1459             raise TypeError, 'No key property set for class %s'%self.classname
1460         cldb = self.db.getclassdb(self.classname)
1461         try:
1462             for nodeid in self.getnodeids(cldb):
1463                 node = self.db.getnode(self.classname, nodeid, cldb)
1464                 if node.has_key(self.db.RETIRED_FLAG):
1465                     continue
1466                 if node[self.key] == keyvalue:
1467                     return nodeid
1468         finally:
1469             cldb.close()
1470         raise KeyError, 'No key (%s) value "%s" for "%s"'%(self.key,
1471             keyvalue, self.classname)
1473     # change from spec - allows multiple props to match
1474     def find(self, **propspec):
1475         '''Get the ids of items in this class which link to the given items.
1477         'propspec' consists of keyword args propname=itemid or
1478                    propname={itemid:1, }
1479         'propname' must be the name of a property in this class, or a
1480                    KeyError is raised.  That property must be a Link or
1481                    Multilink property, or a TypeError is raised.
1483         Any item in this class whose 'propname' property links to any of the
1484         itemids will be returned. Used by the full text indexing, which knows
1485         that "foo" occurs in msg1, msg3 and file7, so we have hits on these
1486         issues:
1488             db.issue.find(messages={'1':1,'3':1}, files={'7':1})
1489         '''
1490         propspec = propspec.items()
1491         for propname, itemids in propspec:
1492             # check the prop is OK
1493             prop = self.properties[propname]
1494             if not isinstance(prop, Link) and not isinstance(prop, Multilink):
1495                 raise TypeError, "'%s' not a Link/Multilink property"%propname
1497         # ok, now do the find
1498         cldb = self.db.getclassdb(self.classname)
1499         l = []
1500         try:
1501             for id in self.getnodeids(db=cldb):
1502                 item = self.db.getnode(self.classname, id, db=cldb)
1503                 if item.has_key(self.db.RETIRED_FLAG):
1504                     continue
1505                 for propname, itemids in propspec:
1506                     # can't test if the item doesn't have this property
1507                     if not item.has_key(propname):
1508                         continue
1509                     if type(itemids) is not type({}):
1510                         itemids = {itemids:1}
1512                     # grab the property definition and its value on this item
1513                     prop = self.properties[propname]
1514                     value = item[propname]
1515                     if isinstance(prop, Link) and itemids.has_key(value):
1516                         l.append(id)
1517                         break
1518                     elif isinstance(prop, Multilink):
1519                         hit = 0
1520                         for v in value:
1521                             if itemids.has_key(v):
1522                                 l.append(id)
1523                                 hit = 1
1524                                 break
1525                         if hit:
1526                             break
1527         finally:
1528             cldb.close()
1529         return l
1531     def stringFind(self, **requirements):
1532         '''Locate a particular node by matching a set of its String
1533         properties in a caseless search.
1535         If the property is not a String property, a TypeError is raised.
1536         
1537         The return is a list of the id of all nodes that match.
1538         '''
1539         for propname in requirements.keys():
1540             prop = self.properties[propname]
1541             if not isinstance(prop, String):
1542                 raise TypeError, "'%s' not a String property"%propname
1543             requirements[propname] = requirements[propname].lower()
1544         l = []
1545         cldb = self.db.getclassdb(self.classname)
1546         try:
1547             for nodeid in self.getnodeids(cldb):
1548                 node = self.db.getnode(self.classname, nodeid, cldb)
1549                 if node.has_key(self.db.RETIRED_FLAG):
1550                     continue
1551                 for key, value in requirements.items():
1552                     if not node.has_key(key):
1553                         break
1554                     if node[key] is None or node[key].lower() != value:
1555                         break
1556                 else:
1557                     l.append(nodeid)
1558         finally:
1559             cldb.close()
1560         return l
1562     def list(self):
1563         ''' Return a list of the ids of the active nodes in this class.
1564         '''
1565         l = []
1566         cn = self.classname
1567         cldb = self.db.getclassdb(cn)
1568         try:
1569             for nodeid in self.getnodeids(cldb):
1570                 node = self.db.getnode(cn, nodeid, cldb)
1571                 if node.has_key(self.db.RETIRED_FLAG):
1572                     continue
1573                 l.append(nodeid)
1574         finally:
1575             cldb.close()
1576         l.sort()
1577         return l
1579     def getnodeids(self, db=None):
1580         ''' Return a list of ALL nodeids
1581         '''
1582         if __debug__:
1583             print >>hyperdb.DEBUG, 'getnodeids', (self, self.classname, db)
1585         res = []
1587         # start off with the new nodes
1588         if self.db.newnodes.has_key(self.classname):
1589             res += self.db.newnodes[self.classname].keys()
1591         if db is None:
1592             db = self.db.getclassdb(self.classname)
1593         res = res + db.keys()
1595         # remove the uncommitted, destroyed nodes
1596         if self.db.destroyednodes.has_key(self.classname):
1597             for nodeid in self.db.destroyednodes[self.classname].keys():
1598                 if db.has_key(nodeid):
1599                     res.remove(nodeid)
1601         return res
1603     def filter(self, search_matches, filterspec, sort=(None,None),
1604             group=(None,None), num_re = re.compile('^\d+$')):
1605         """Return a list of the ids of the active nodes in this class that
1606         match the 'filter' spec, sorted by the group spec and then the
1607         sort spec.
1609         "filterspec" is {propname: value(s)}
1611         "sort" and "group" are (dir, prop) where dir is '+', '-' or None
1612         and prop is a prop name or None
1614         "search_matches" is {nodeid: marker}
1616         The filter must match all properties specificed - but if the
1617         property value to match is a list, any one of the values in the
1618         list may match for that property to match. Unless the property
1619         is a Multilink, in which case the item's property list must
1620         match the filterspec list.
1621         """
1622         cn = self.classname
1624         # optimise filterspec
1625         l = []
1626         props = self.getprops()
1627         LINK = 0
1628         MULTILINK = 1
1629         STRING = 2
1630         DATE = 3
1631         INTERVAL = 4
1632         OTHER = 6
1633         
1634         timezone = self.db.getUserTimezone()
1635         for k, v in filterspec.items():
1636             propclass = props[k]
1637             if isinstance(propclass, Link):
1638                 if type(v) is not type([]):
1639                     v = [v]
1640                 u = []
1641                 for entry in v:
1642                     # the value -1 is a special "not set" sentinel
1643                     if entry == '-1':
1644                         entry = None
1645                     u.append(entry)
1646                 l.append((LINK, k, u))
1647             elif isinstance(propclass, Multilink):
1648                 # the value -1 is a special "not set" sentinel
1649                 if v in ('-1', ['-1']):
1650                     v = []
1651                 elif type(v) is not type([]):
1652                     v = [v]
1653                 l.append((MULTILINK, k, v))
1654             elif isinstance(propclass, String) and k != 'id':
1655                 if type(v) is not type([]):
1656                     v = [v]
1657                 m = []
1658                 for v in v:
1659                     # simple glob searching
1660                     v = re.sub(r'([\|\{\}\\\.\+\[\]\(\)])', r'\\\1', v)
1661                     v = v.replace('?', '.')
1662                     v = v.replace('*', '.*?')
1663                     m.append(v)
1664                 m = re.compile('(%s)'%('|'.join(m)), re.I)
1665                 l.append((STRING, k, m))
1666             elif isinstance(propclass, Date):
1667                 try:
1668                     date_rng = Range(v, date.Date, offset=timezone)
1669                     l.append((DATE, k, date_rng))
1670                 except ValueError:
1671                     # If range creation fails - ignore that search parameter
1672                     pass
1673             elif isinstance(propclass, Interval):
1674                 try:
1675                     intv_rng = Range(v, date.Interval)
1676                     l.append((INTERVAL, k, intv_rng))
1677                 except ValueError:
1678                     # If range creation fails - ignore that search parameter
1679                     pass
1680                 
1681             elif isinstance(propclass, Boolean):
1682                 if type(v) is type(''):
1683                     bv = v.lower() in ('yes', 'true', 'on', '1')
1684                 else:
1685                     bv = v
1686                 l.append((OTHER, k, bv))
1687             elif isinstance(propclass, Number):
1688                 l.append((OTHER, k, int(v)))
1689             else:
1690                 l.append((OTHER, k, v))
1691         filterspec = l
1693         # now, find all the nodes that are active and pass filtering
1694         l = []
1695         cldb = self.db.getclassdb(cn)
1696         try:
1697             # TODO: only full-scan once (use items())
1698             for nodeid in self.getnodeids(cldb):
1699                 node = self.db.getnode(cn, nodeid, cldb)
1700                 if node.has_key(self.db.RETIRED_FLAG):
1701                     continue
1702                 # apply filter
1703                 for t, k, v in filterspec:
1704                     # handle the id prop
1705                     if k == 'id' and v == nodeid:
1706                         continue
1708                     # make sure the node has the property
1709                     if not node.has_key(k):
1710                         # this node doesn't have this property, so reject it
1711                         break
1713                     # now apply the property filter
1714                     if t == LINK:
1715                         # link - if this node's property doesn't appear in the
1716                         # filterspec's nodeid list, skip it
1717                         if node[k] not in v:
1718                             break
1719                     elif t == MULTILINK:
1720                         # multilink - if any of the nodeids required by the
1721                         # filterspec aren't in this node's property, then skip
1722                         # it
1723                         have = node[k]
1724                         # check for matching the absence of multilink values
1725                         if not v and have:
1726                             break
1728                         # othewise, make sure this node has each of the
1729                         # required values
1730                         for want in v:
1731                             if want not in have:
1732                                 break
1733                         else:
1734                             continue
1735                         break
1736                     elif t == STRING:
1737                         if node[k] is None:
1738                             break
1739                         # RE search
1740                         if not v.search(node[k]):
1741                             break
1742                     elif t == DATE or t == INTERVAL:
1743                         if node[k] is None:
1744                             break
1745                         if v.to_value:
1746                             if not (v.from_value <= node[k] and v.to_value >= node[k]):
1747                                 break
1748                         else:
1749                             if not (v.from_value <= node[k]):
1750                                 break
1751                     elif t == OTHER:
1752                         # straight value comparison for the other types
1753                         if node[k] != v:
1754                             break
1755                 else:
1756                     l.append((nodeid, node))
1757         finally:
1758             cldb.close()
1759         l.sort()
1761         # filter based on full text search
1762         if search_matches is not None:
1763             k = []
1764             for v in l:
1765                 if search_matches.has_key(v[0]):
1766                     k.append(v)
1767             l = k
1769         # now, sort the result
1770         def sortfun(a, b, sort=sort, group=group, properties=self.getprops(),
1771                 db = self.db, cl=self):
1772             a_id, an = a
1773             b_id, bn = b
1774             # sort by group and then sort
1775             for dir, prop in group, sort:
1776                 if dir is None or prop is None: continue
1778                 # sorting is class-specific
1779                 propclass = properties[prop]
1781                 # handle the properties that might be "faked"
1782                 # also, handle possible missing properties
1783                 try:
1784                     if not an.has_key(prop):
1785                         an[prop] = cl.get(a_id, prop)
1786                     av = an[prop]
1787                 except KeyError:
1788                     # the node doesn't have a value for this property
1789                     if isinstance(propclass, Multilink): av = []
1790                     else: av = ''
1791                 try:
1792                     if not bn.has_key(prop):
1793                         bn[prop] = cl.get(b_id, prop)
1794                     bv = bn[prop]
1795                 except KeyError:
1796                     # the node doesn't have a value for this property
1797                     if isinstance(propclass, Multilink): bv = []
1798                     else: bv = ''
1800                 # String and Date values are sorted in the natural way
1801                 if isinstance(propclass, String):
1802                     # clean up the strings
1803                     if av and av[0] in string.uppercase:
1804                         av = av.lower()
1805                     if bv and bv[0] in string.uppercase:
1806                         bv = bv.lower()
1807                 if (isinstance(propclass, String) or
1808                         isinstance(propclass, Date)):
1809                     # it might be a string that's really an integer
1810                     try:
1811                         av = int(av)
1812                         bv = int(bv)
1813                     except:
1814                         pass
1815                     if dir == '+':
1816                         r = cmp(av, bv)
1817                         if r != 0: return r
1818                     elif dir == '-':
1819                         r = cmp(bv, av)
1820                         if r != 0: return r
1822                 # Link properties are sorted according to the value of
1823                 # the "order" property on the linked nodes if it is
1824                 # present; or otherwise on the key string of the linked
1825                 # nodes; or finally on  the node ids.
1826                 elif isinstance(propclass, Link):
1827                     link = db.classes[propclass.classname]
1828                     if av is None and bv is not None: return -1
1829                     if av is not None and bv is None: return 1
1830                     if av is None and bv is None: continue
1831                     if link.getprops().has_key('order'):
1832                         if dir == '+':
1833                             r = cmp(link.get(av, 'order'),
1834                                 link.get(bv, 'order'))
1835                             if r != 0: return r
1836                         elif dir == '-':
1837                             r = cmp(link.get(bv, 'order'),
1838                                 link.get(av, 'order'))
1839                             if r != 0: return r
1840                     elif link.getkey():
1841                         key = link.getkey()
1842                         if dir == '+':
1843                             r = cmp(link.get(av, key), link.get(bv, key))
1844                             if r != 0: return r
1845                         elif dir == '-':
1846                             r = cmp(link.get(bv, key), link.get(av, key))
1847                             if r != 0: return r
1848                     else:
1849                         if dir == '+':
1850                             r = cmp(av, bv)
1851                             if r != 0: return r
1852                         elif dir == '-':
1853                             r = cmp(bv, av)
1854                             if r != 0: return r
1856                 else:
1857                     # all other types just compare
1858                     if dir == '+':
1859                         r = cmp(av, bv)
1860                     elif dir == '-':
1861                         r = cmp(bv, av)
1862                     if r != 0: return r
1863                     
1864             # end for dir, prop in sort, group:
1865             # if all else fails, compare the ids
1866             return cmp(a[0], b[0])
1868         l.sort(sortfun)
1869         return [i[0] for i in l]
1871     def count(self):
1872         '''Get the number of nodes in this class.
1874         If the returned integer is 'numnodes', the ids of all the nodes
1875         in this class run from 1 to numnodes, and numnodes+1 will be the
1876         id of the next node to be created in this class.
1877         '''
1878         return self.db.countnodes(self.classname)
1880     # Manipulating properties:
1882     def getprops(self, protected=1):
1883         '''Return a dictionary mapping property names to property objects.
1884            If the "protected" flag is true, we include protected properties -
1885            those which may not be modified.
1887            In addition to the actual properties on the node, these
1888            methods provide the "creation" and "activity" properties. If the
1889            "protected" flag is true, we include protected properties - those
1890            which may not be modified.
1891         '''
1892         d = self.properties.copy()
1893         if protected:
1894             d['id'] = String()
1895             d['creation'] = hyperdb.Date()
1896             d['activity'] = hyperdb.Date()
1897             d['creator'] = hyperdb.Link('user')
1898         return d
1900     def addprop(self, **properties):
1901         '''Add properties to this class.
1903         The keyword arguments in 'properties' must map names to property
1904         objects, or a TypeError is raised.  None of the keys in 'properties'
1905         may collide with the names of existing properties, or a ValueError
1906         is raised before any properties have been added.
1907         '''
1908         for key in properties.keys():
1909             if self.properties.has_key(key):
1910                 raise ValueError, key
1911         self.properties.update(properties)
1913     def index(self, nodeid):
1914         '''Add (or refresh) the node to search indexes
1915         '''
1916         # find all the String properties that have indexme
1917         for prop, propclass in self.getprops().items():
1918             if isinstance(propclass, String) and propclass.indexme:
1919                 try:
1920                     value = str(self.get(nodeid, prop))
1921                 except IndexError:
1922                     # node no longer exists - entry should be removed
1923                     self.db.indexer.purge_entry((self.classname, nodeid, prop))
1924                 else:
1925                     # and index them under (classname, nodeid, property)
1926                     self.db.indexer.add_text((self.classname, nodeid, prop),
1927                         value)
1929     #
1930     # Detector interface
1931     #
1932     def audit(self, event, detector):
1933         '''Register a detector
1934         '''
1935         l = self.auditors[event]
1936         if detector not in l:
1937             self.auditors[event].append(detector)
1939     def fireAuditors(self, action, nodeid, newvalues):
1940         '''Fire all registered auditors.
1941         '''
1942         for audit in self.auditors[action]:
1943             audit(self.db, self, nodeid, newvalues)
1945     def react(self, event, detector):
1946         '''Register a detector
1947         '''
1948         l = self.reactors[event]
1949         if detector not in l:
1950             self.reactors[event].append(detector)
1952     def fireReactors(self, action, nodeid, oldvalues):
1953         '''Fire all registered reactors.
1954         '''
1955         for react in self.reactors[action]:
1956             react(self.db, self, nodeid, oldvalues)
1958 class FileClass(Class, hyperdb.FileClass):
1959     '''This class defines a large chunk of data. To support this, it has a
1960        mandatory String property "content" which is typically saved off
1961        externally to the hyperdb.
1963        The default MIME type of this data is defined by the
1964        "default_mime_type" class attribute, which may be overridden by each
1965        node if the class defines a "type" String property.
1966     '''
1967     default_mime_type = 'text/plain'
1969     def create(self, **propvalues):
1970         ''' Snarf the "content" propvalue and store in a file
1971         '''
1972         # we need to fire the auditors now, or the content property won't
1973         # be in propvalues for the auditors to play with
1974         self.fireAuditors('create', None, propvalues)
1976         # now remove the content property so it's not stored in the db
1977         content = propvalues['content']
1978         del propvalues['content']
1980         # do the database create
1981         newid = Class.create_inner(self, **propvalues)
1983         # fire reactors
1984         self.fireReactors('create', newid, None)
1986         # store off the content as a file
1987         self.db.storefile(self.classname, newid, None, content)
1988         return newid
1990     def import_list(self, propnames, proplist):
1991         ''' Trap the "content" property...
1992         '''
1993         # dupe this list so we don't affect others
1994         propnames = propnames[:]
1996         # extract the "content" property from the proplist
1997         i = propnames.index('content')
1998         content = eval(proplist[i])
1999         del propnames[i]
2000         del proplist[i]
2002         # do the normal import
2003         newid = Class.import_list(self, propnames, proplist)
2005         # save off the "content" file
2006         self.db.storefile(self.classname, newid, None, content)
2007         return newid
2009     def get(self, nodeid, propname, default=_marker, cache=1):
2010         ''' Trap the content propname and get it from the file
2012         'cache' exists for backwards compatibility, and is not used.
2013         '''
2014         poss_msg = 'Possibly an access right configuration problem.'
2015         if propname == 'content':
2016             try:
2017                 return self.db.getfile(self.classname, nodeid, None)
2018             except IOError, (strerror):
2019                 # XXX by catching this we donot see an error in the log.
2020                 return 'ERROR reading file: %s%s\n%s\n%s'%(
2021                         self.classname, nodeid, poss_msg, strerror)
2022         if default is not _marker:
2023             return Class.get(self, nodeid, propname, default)
2024         else:
2025             return Class.get(self, nodeid, propname)
2027     def getprops(self, protected=1):
2028         ''' In addition to the actual properties on the node, these methods
2029             provide the "content" property. If the "protected" flag is true,
2030             we include protected properties - those which may not be
2031             modified.
2032         '''
2033         d = Class.getprops(self, protected=protected).copy()
2034         d['content'] = hyperdb.String()
2035         return d
2037     def index(self, nodeid):
2038         ''' Index the node in the search index.
2040             We want to index the content in addition to the normal String
2041             property indexing.
2042         '''
2043         # perform normal indexing
2044         Class.index(self, nodeid)
2046         # get the content to index
2047         content = self.get(nodeid, 'content')
2049         # figure the mime type
2050         if self.properties.has_key('type'):
2051             mime_type = self.get(nodeid, 'type')
2052         else:
2053             mime_type = self.default_mime_type
2055         # and index!
2056         self.db.indexer.add_text((self.classname, nodeid, 'content'), content,
2057             mime_type)
2059 # deviation from spec - was called ItemClass
2060 class IssueClass(Class, roundupdb.IssueClass):
2061     # Overridden methods:
2062     def __init__(self, db, classname, **properties):
2063         '''The newly-created class automatically includes the "messages",
2064         "files", "nosy", and "superseder" properties.  If the 'properties'
2065         dictionary attempts to specify any of these properties or a
2066         "creation" or "activity" property, a ValueError is raised.
2067         '''
2068         if not properties.has_key('title'):
2069             properties['title'] = hyperdb.String(indexme='yes')
2070         if not properties.has_key('messages'):
2071             properties['messages'] = hyperdb.Multilink("msg")
2072         if not properties.has_key('files'):
2073             properties['files'] = hyperdb.Multilink("file")
2074         if not properties.has_key('nosy'):
2075             # note: journalling is turned off as it really just wastes
2076             # space. this behaviour may be overridden in an instance
2077             properties['nosy'] = hyperdb.Multilink("user", do_journal="no")
2078         if not properties.has_key('superseder'):
2079             properties['superseder'] = hyperdb.Multilink(classname)
2080         Class.__init__(self, db, classname, **properties)