Code

Simplify backend importing, by moving the imports into the backend modules.
[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.128 2003-09-14 18:55:37 jlgijsbers Exp $
19 '''
20 This module defines a backend that saves the hyperdatabase in a database
21 chosen by anydbm. It is guaranteed to always be available in python
22 versions >2.1.1 (the dumbdbm fallback in 2.1.1 and earlier has several
23 serious bugs, and is not available)
24 '''
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         . check the timestamp of the class file and nuke the cache if it's
54           modified. Do some sort of conflict checking on the dirty stuff.
55         . 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 reindex(self):
102         for klass in self.classes.values():
103             for nodeid in klass.list():
104                 klass.index(nodeid)
105         self.indexer.save_index()
107     def __repr__(self):
108         return '<back_anydbm instance at %x>'%id(self) 
110     #
111     # Classes
112     #
113     def __getattr__(self, classname):
114         '''A convenient way of calling self.getclass(classname).'''
115         if self.classes.has_key(classname):
116             if __debug__:
117                 print >>hyperdb.DEBUG, '__getattr__', (self, classname)
118             return self.classes[classname]
119         raise AttributeError, classname
121     def addclass(self, cl):
122         if __debug__:
123             print >>hyperdb.DEBUG, 'addclass', (self, cl)
124         cn = cl.classname
125         if self.classes.has_key(cn):
126             raise ValueError, cn
127         self.classes[cn] = cl
129     def getclasses(self):
130         '''Return a list of the names of all existing classes.'''
131         if __debug__:
132             print >>hyperdb.DEBUG, 'getclasses', (self,)
133         l = self.classes.keys()
134         l.sort()
135         return l
137     def getclass(self, classname):
138         '''Get the Class object representing a particular class.
140         If 'classname' is not a valid class name, a KeyError is raised.
141         '''
142         if __debug__:
143             print >>hyperdb.DEBUG, 'getclass', (self, classname)
144         try:
145             return self.classes[classname]
146         except KeyError:
147             raise KeyError, 'There is no class called "%s"'%classname
149     #
150     # Class DBs
151     #
152     def clear(self):
153         '''Delete all database contents
154         '''
155         if __debug__:
156             print >>hyperdb.DEBUG, 'clear', (self,)
157         for cn in self.classes.keys():
158             for dummy in 'nodes', 'journals':
159                 path = os.path.join(self.dir, 'journals.%s'%cn)
160                 if os.path.exists(path):
161                     os.remove(path)
162                 elif os.path.exists(path+'.db'):    # dbm appends .db
163                     os.remove(path+'.db')
165     def getclassdb(self, classname, mode='r'):
166         ''' grab a connection to the class db that will be used for
167             multiple actions
168         '''
169         if __debug__:
170             print >>hyperdb.DEBUG, 'getclassdb', (self, classname, mode)
171         return self.opendb('nodes.%s'%classname, mode)
173     def determine_db_type(self, path):
174         ''' determine which DB wrote the class file
175         '''
176         db_type = ''
177         if os.path.exists(path):
178             db_type = whichdb.whichdb(path)
179             if not db_type:
180                 raise DatabaseError, "Couldn't identify database type"
181         elif os.path.exists(path+'.db'):
182             # if the path ends in '.db', it's a dbm database, whether
183             # anydbm says it's dbhash or not!
184             db_type = 'dbm'
185         return db_type
187     def opendb(self, name, mode):
188         '''Low-level database opener that gets around anydbm/dbm
189            eccentricities.
190         '''
191         if __debug__:
192             print >>hyperdb.DEBUG, 'opendb', (self, name, mode)
194         # figure the class db type
195         path = os.path.join(os.getcwd(), self.dir, name)
196         db_type = self.determine_db_type(path)
198         # new database? let anydbm pick the best dbm
199         if not db_type:
200             if __debug__:
201                 print >>hyperdb.DEBUG, "opendb anydbm.open(%r, 'c')"%path
202             return anydbm.open(path, 'c')
204         # open the database with the correct module
205         try:
206             dbm = __import__(db_type)
207         except ImportError:
208             raise DatabaseError, \
209                 "Couldn't open database - the required module '%s'"\
210                 " is not available"%db_type
211         if __debug__:
212             print >>hyperdb.DEBUG, "opendb %r.open(%r, %r)"%(db_type, path,
213                 mode)
214         return dbm.open(path, mode)
216     #
217     # Node IDs
218     #
219     def newid(self, classname):
220         ''' Generate a new id for the given class
221         '''
222         # open the ids DB - create if if doesn't exist
223         db = self.opendb('_ids', 'c')
224         if db.has_key(classname):
225             newid = db[classname] = str(int(db[classname]) + 1)
226         else:
227             # the count() bit is transitional - older dbs won't start at 1
228             newid = str(self.getclass(classname).count()+1)
229             db[classname] = newid
230         db.close()
231         return newid
233     def setid(self, classname, setid):
234         ''' Set the id counter: used during import of database
235         '''
236         # open the ids DB - create if if doesn't exist
237         db = self.opendb('_ids', 'c')
238         db[classname] = str(setid)
239         db.close()
241     #
242     # Nodes
243     #
244     def addnode(self, classname, nodeid, node):
245         ''' add the specified node to its class's db
246         '''
247         if __debug__:
248             print >>hyperdb.DEBUG, 'addnode', (self, classname, nodeid, node)
250         # we'll be supplied these props if we're doing an import
251         if not node.has_key('creator'):
252             # add in the "calculated" properties (dupe so we don't affect
253             # calling code's node assumptions)
254             node = node.copy()
255             node['creator'] = self.getuid()
256             node['creation'] = node['activity'] = date.Date()
258         self.newnodes.setdefault(classname, {})[nodeid] = 1
259         self.cache.setdefault(classname, {})[nodeid] = node
260         self.savenode(classname, nodeid, node)
262     def setnode(self, classname, nodeid, node):
263         ''' change the specified node
264         '''
265         if __debug__:
266             print >>hyperdb.DEBUG, 'setnode', (self, classname, nodeid, node)
267         self.dirtynodes.setdefault(classname, {})[nodeid] = 1
269         # update the activity time (dupe so we don't affect
270         # calling code's node assumptions)
271         node = node.copy()
272         node['activity'] = date.Date()
274         # can't set without having already loaded the node
275         self.cache[classname][nodeid] = node
276         self.savenode(classname, nodeid, node)
278     def savenode(self, classname, nodeid, node):
279         ''' perform the saving of data specified by the set/addnode
280         '''
281         if __debug__:
282             print >>hyperdb.DEBUG, 'savenode', (self, classname, nodeid, node)
283         self.transactions.append((self.doSaveNode, (classname, nodeid, node)))
285     def getnode(self, classname, nodeid, db=None, cache=1):
286         ''' get a node from the database
288             Note the "cache" parameter is not used, and exists purely for
289             backward compatibility!
290         '''
291         if __debug__:
292             print >>hyperdb.DEBUG, 'getnode', (self, classname, nodeid, db)
294         # try the cache
295         cache_dict = self.cache.setdefault(classname, {})
296         if cache_dict.has_key(nodeid):
297             if __debug__:
298                 print >>hyperdb.TRACE, 'get %s %s cached'%(classname,
299                     nodeid)
300             return cache_dict[nodeid]
302         if __debug__:
303             print >>hyperdb.TRACE, 'get %s %s'%(classname, nodeid)
305         # get from the database and save in the cache
306         if db is None:
307             db = self.getclassdb(classname)
308         if not db.has_key(nodeid):
309             # try the cache - might be a brand-new node
310             cache_dict = self.cache.setdefault(classname, {})
311             if cache_dict.has_key(nodeid):
312                 if __debug__:
313                     print >>hyperdb.TRACE, 'get %s %s cached'%(classname,
314                         nodeid)
315                 return cache_dict[nodeid]
316             raise IndexError, "no such %s %s"%(classname, nodeid)
318         # check the uncommitted, destroyed nodes
319         if (self.destroyednodes.has_key(classname) and
320                 self.destroyednodes[classname].has_key(nodeid)):
321             raise IndexError, "no such %s %s"%(classname, nodeid)
323         # decode
324         res = marshal.loads(db[nodeid])
326         # reverse the serialisation
327         res = self.unserialise(classname, res)
329         # store off in the cache dict
330         if cache:
331             cache_dict[nodeid] = res
333         return res
335     def destroynode(self, classname, nodeid):
336         '''Remove a node from the database. Called exclusively by the
337            destroy() method on Class.
338         '''
339         if __debug__:
340             print >>hyperdb.DEBUG, 'destroynode', (self, classname, nodeid)
342         # remove from cache and newnodes if it's there
343         if (self.cache.has_key(classname) and
344                 self.cache[classname].has_key(nodeid)):
345             del self.cache[classname][nodeid]
346         if (self.newnodes.has_key(classname) and
347                 self.newnodes[classname].has_key(nodeid)):
348             del self.newnodes[classname][nodeid]
350         # see if there's any obvious commit actions that we should get rid of
351         for entry in self.transactions[:]:
352             if entry[1][:2] == (classname, nodeid):
353                 self.transactions.remove(entry)
355         # add to the destroyednodes map
356         self.destroyednodes.setdefault(classname, {})[nodeid] = 1
358         # add the destroy commit action
359         self.transactions.append((self.doDestroyNode, (classname, nodeid)))
361     def serialise(self, classname, node):
362         '''Copy the node contents, converting non-marshallable data into
363            marshallable data.
364         '''
365         if __debug__:
366             print >>hyperdb.DEBUG, 'serialise', classname, node
367         properties = self.getclass(classname).getprops()
368         d = {}
369         for k, v in node.items():
370             # if the property doesn't exist, or is the "retired" flag then
371             # it won't be in the properties dict
372             if not properties.has_key(k):
373                 d[k] = v
374                 continue
376             # get the property spec
377             prop = properties[k]
379             if isinstance(prop, Password) and v is not None:
380                 d[k] = str(v)
381             elif isinstance(prop, Date) and v is not None:
382                 d[k] = v.serialise()
383             elif isinstance(prop, Interval) and v is not None:
384                 d[k] = v.serialise()
385             else:
386                 d[k] = v
387         return d
389     def unserialise(self, classname, node):
390         '''Decode the marshalled node data
391         '''
392         if __debug__:
393             print >>hyperdb.DEBUG, 'unserialise', classname, node
394         properties = self.getclass(classname).getprops()
395         d = {}
396         for k, v in node.items():
397             # if the property doesn't exist, or is the "retired" flag then
398             # it won't be in the properties dict
399             if not properties.has_key(k):
400                 d[k] = v
401                 continue
403             # get the property spec
404             prop = properties[k]
406             if isinstance(prop, Date) and v is not None:
407                 d[k] = date.Date(v)
408             elif isinstance(prop, Interval) and v is not None:
409                 d[k] = date.Interval(v)
410             elif isinstance(prop, Password) and v is not None:
411                 p = password.Password()
412                 p.unpack(v)
413                 d[k] = p
414             else:
415                 d[k] = v
416         return d
418     def hasnode(self, classname, nodeid, db=None):
419         ''' determine if the database has a given node
420         '''
421         if __debug__:
422             print >>hyperdb.DEBUG, 'hasnode', (self, classname, nodeid, db)
424         # try the cache
425         cache = self.cache.setdefault(classname, {})
426         if cache.has_key(nodeid):
427             if __debug__:
428                 print >>hyperdb.TRACE, 'has %s %s cached'%(classname, nodeid)
429             return 1
430         if __debug__:
431             print >>hyperdb.TRACE, 'has %s %s'%(classname, nodeid)
433         # not in the cache - check the database
434         if db is None:
435             db = self.getclassdb(classname)
436         res = db.has_key(nodeid)
437         return res
439     def countnodes(self, classname, db=None):
440         if __debug__:
441             print >>hyperdb.DEBUG, 'countnodes', (self, classname, db)
443         count = 0
445         # include the uncommitted nodes
446         if self.newnodes.has_key(classname):
447             count += len(self.newnodes[classname])
448         if self.destroyednodes.has_key(classname):
449             count -= len(self.destroyednodes[classname])
451         # and count those in the DB
452         if db is None:
453             db = self.getclassdb(classname)
454         count = count + len(db.keys())
455         return count
458     #
459     # Files - special node properties
460     # inherited from FileStorage
462     #
463     # Journal
464     #
465     def addjournal(self, classname, nodeid, action, params, creator=None,
466             creation=None):
467         ''' Journal the Action
468         'action' may be:
470             'create' or 'set' -- 'params' is a dictionary of property values
471             'link' or 'unlink' -- 'params' is (classname, nodeid, propname)
472             'retire' -- 'params' is None
473         '''
474         if __debug__:
475             print >>hyperdb.DEBUG, 'addjournal', (self, classname, nodeid,
476                 action, params, creator, creation)
477         self.transactions.append((self.doSaveJournal, (classname, nodeid,
478             action, params, creator, creation)))
480     def getjournal(self, classname, nodeid):
481         ''' get the journal for id
483             Raise IndexError if the node doesn't exist (as per history()'s
484             API)
485         '''
486         if __debug__:
487             print >>hyperdb.DEBUG, 'getjournal', (self, classname, nodeid)
489         # our journal result
490         res = []
492         # add any journal entries for transactions not committed to the
493         # database
494         for method, args in self.transactions:
495             if method != self.doSaveJournal:
496                 continue
497             (cache_classname, cache_nodeid, cache_action, cache_params,
498                 cache_creator, cache_creation) = args
499             if cache_classname == classname and cache_nodeid == nodeid:
500                 if not cache_creator:
501                     cache_creator = self.getuid()
502                 if not cache_creation:
503                     cache_creation = date.Date()
504                 res.append((cache_nodeid, cache_creation, cache_creator,
505                     cache_action, cache_params))
507         # attempt to open the journal - in some rare cases, the journal may
508         # not exist
509         try:
510             db = self.opendb('journals.%s'%classname, 'r')
511         except anydbm.error, error:
512             if str(error) == "need 'c' or 'n' flag to open new db":
513                 raise IndexError, 'no such %s %s'%(classname, nodeid)
514             elif error.args[0] != 2:
515                 raise
516             raise IndexError, 'no such %s %s'%(classname, nodeid)
517         try:
518             journal = marshal.loads(db[nodeid])
519         except KeyError:
520             db.close()
521             if res:
522                 # we have some unsaved journal entries, be happy!
523                 return res
524             raise IndexError, 'no such %s %s'%(classname, nodeid)
525         db.close()
527         # add all the saved journal entries for this node
528         for nodeid, date_stamp, user, action, params in journal:
529             res.append((nodeid, date.Date(date_stamp), user, action, params))
530         return res
532     def pack(self, pack_before):
533         ''' Delete all journal entries except "create" before 'pack_before'.
534         '''
535         if __debug__:
536             print >>hyperdb.DEBUG, 'packjournal', (self, pack_before)
538         pack_before = pack_before.serialise()
539         for classname in self.getclasses():
540             # get the journal db
541             db_name = 'journals.%s'%classname
542             path = os.path.join(os.getcwd(), self.dir, classname)
543             db_type = self.determine_db_type(path)
544             db = self.opendb(db_name, 'w')
546             for key in db.keys():
547                 # get the journal for this db entry
548                 journal = marshal.loads(db[key])
549                 l = []
550                 last_set_entry = None
551                 for entry in journal:
552                     # unpack the entry
553                     (nodeid, date_stamp, self.journaltag, action, 
554                         params) = entry
555                     # if the entry is after the pack date, _or_ the initial
556                     # create entry, then it stays
557                     if date_stamp > pack_before or action == 'create':
558                         l.append(entry)
559                 db[key] = marshal.dumps(l)
560             if db_type == 'gdbm':
561                 db.reorganize()
562             db.close()
563             
565     #
566     # Basic transaction support
567     #
568     def commit(self):
569         ''' Commit the current transactions.
570         '''
571         if __debug__:
572             print >>hyperdb.DEBUG, 'commit', (self,)
574         # keep a handle to all the database files opened
575         self.databases = {}
577         # now, do all the transactions
578         reindex = {}
579         for method, args in self.transactions:
580             reindex[method(*args)] = 1
582         # now close all the database files
583         for db in self.databases.values():
584             db.close()
585         del self.databases
587         # reindex the nodes that request it
588         for classname, nodeid in filter(None, reindex.keys()):
589             print >>hyperdb.DEBUG, 'commit.reindex', (classname, nodeid)
590             self.getclass(classname).index(nodeid)
592         # save the indexer state
593         self.indexer.save_index()
595         self.clearCache()
597     def clearCache(self):
598         # all transactions committed, back to normal
599         self.cache = {}
600         self.dirtynodes = {}
601         self.newnodes = {}
602         self.destroyednodes = {}
603         self.transactions = []
605     def getCachedClassDB(self, classname):
606         ''' get the class db, looking in our cache of databases for commit
607         '''
608         # get the database handle
609         db_name = 'nodes.%s'%classname
610         if not self.databases.has_key(db_name):
611             self.databases[db_name] = self.getclassdb(classname, 'c')
612         return self.databases[db_name]
614     def doSaveNode(self, classname, nodeid, node):
615         if __debug__:
616             print >>hyperdb.DEBUG, 'doSaveNode', (self, classname, nodeid,
617                 node)
619         db = self.getCachedClassDB(classname)
621         # now save the marshalled data
622         db[nodeid] = marshal.dumps(self.serialise(classname, node))
624         # return the classname, nodeid so we reindex this content
625         return (classname, nodeid)
627     def getCachedJournalDB(self, classname):
628         ''' get the journal db, looking in our cache of databases for commit
629         '''
630         # get the database handle
631         db_name = 'journals.%s'%classname
632         if not self.databases.has_key(db_name):
633             self.databases[db_name] = self.opendb(db_name, 'c')
634         return self.databases[db_name]
636     def doSaveJournal(self, classname, nodeid, action, params, creator,
637             creation):
638         # serialise the parameters now if necessary
639         if isinstance(params, type({})):
640             if action in ('set', 'create'):
641                 params = self.serialise(classname, params)
643         # handle supply of the special journalling parameters (usually
644         # supplied on importing an existing database)
645         if creator:
646             journaltag = creator
647         else:
648             journaltag = self.getuid()
649         if creation:
650             journaldate = creation.serialise()
651         else:
652             journaldate = date.Date().serialise()
654         # create the journal entry
655         entry = (nodeid, journaldate, journaltag, action, params)
657         if __debug__:
658             print >>hyperdb.DEBUG, 'doSaveJournal', entry
660         db = self.getCachedJournalDB(classname)
662         # now insert the journal entry
663         if db.has_key(nodeid):
664             # append to existing
665             s = db[nodeid]
666             l = marshal.loads(s)
667             l.append(entry)
668         else:
669             l = [entry]
671         db[nodeid] = marshal.dumps(l)
673     def doDestroyNode(self, classname, nodeid):
674         if __debug__:
675             print >>hyperdb.DEBUG, 'doDestroyNode', (self, classname, nodeid)
677         # delete from the class database
678         db = self.getCachedClassDB(classname)
679         if db.has_key(nodeid):
680             del db[nodeid]
682         # delete from the database
683         db = self.getCachedJournalDB(classname)
684         if db.has_key(nodeid):
685             del db[nodeid]
687         # return the classname, nodeid so we reindex this content
688         return (classname, nodeid)
690     def rollback(self):
691         ''' Reverse all actions from the current transaction.
692         '''
693         if __debug__:
694             print >>hyperdb.DEBUG, 'rollback', (self, )
695         for method, args in self.transactions:
696             # delete temporary files
697             if method == self.doStoreFile:
698                 self.rollbackStoreFile(*args)
699         self.cache = {}
700         self.dirtynodes = {}
701         self.newnodes = {}
702         self.destroyednodes = {}
703         self.transactions = []
705     def close(self):
706         ''' Nothing to do
707         '''
708         if self.lockfile is not None:
709             locking.release_lock(self.lockfile)
710         if self.lockfile is not None:
711             self.lockfile.close()
712             self.lockfile = None
714 _marker = []
715 class Class(hyperdb.Class):
716     '''The handle to a particular class of nodes in a hyperdatabase.'''
718     def __init__(self, db, classname, **properties):
719         '''Create a new class with a given name and property specification.
721         'classname' must not collide with the name of an existing class,
722         or a ValueError is raised.  The keyword arguments in 'properties'
723         must map names to property objects, or a TypeError is raised.
724         '''
725         if (properties.has_key('creation') or properties.has_key('activity')
726                 or properties.has_key('creator')):
727             raise ValueError, '"creation", "activity" and "creator" are '\
728                 'reserved'
730         self.classname = classname
731         self.properties = properties
732         self.db = weakref.proxy(db)       # use a weak ref to avoid circularity
733         self.key = ''
735         # should we journal changes (default yes)
736         self.do_journal = 1
738         # do the db-related init stuff
739         db.addclass(self)
741         self.auditors = {'create': [], 'set': [], 'retire': [], 'restore': []}
742         self.reactors = {'create': [], 'set': [], 'retire': [], 'restore': []}
744     def enableJournalling(self):
745         '''Turn journalling on for this class
746         '''
747         self.do_journal = 1
749     def disableJournalling(self):
750         '''Turn journalling off for this class
751         '''
752         self.do_journal = 0
754     # Editing nodes:
756     def create(self, **propvalues):
757         '''Create a new node of this class and return its id.
759         The keyword arguments in 'propvalues' map property names to values.
761         The values of arguments must be acceptable for the types of their
762         corresponding properties or a TypeError is raised.
763         
764         If this class has a key property, it must be present and its value
765         must not collide with other key strings or a ValueError is raised.
766         
767         Any other properties on this class that are missing from the
768         'propvalues' dictionary are set to None.
769         
770         If an id in a link or multilink property does not refer to a valid
771         node, an IndexError is raised.
773         These operations trigger detectors and can be vetoed.  Attempts
774         to modify the "creation" or "activity" properties cause a KeyError.
775         '''
776         self.fireAuditors('create', None, propvalues)
777         newid = self.create_inner(**propvalues)
778         self.fireReactors('create', newid, None)
779         return newid
781     def create_inner(self, **propvalues):
782         ''' Called by create, in-between the audit and react calls.
783         '''
784         if propvalues.has_key('id'):
785             raise KeyError, '"id" is reserved'
787         if self.db.journaltag is None:
788             raise DatabaseError, 'Database open read-only'
790         if propvalues.has_key('creation') or propvalues.has_key('activity'):
791             raise KeyError, '"creation" and "activity" are reserved'
792         # new node's id
793         newid = self.db.newid(self.classname)
795         # validate propvalues
796         num_re = re.compile('^\d+$')
797         for key, value in propvalues.items():
798             if key == self.key:
799                 try:
800                     self.lookup(value)
801                 except KeyError:
802                     pass
803                 else:
804                     raise ValueError, 'node with key "%s" exists'%value
806             # try to handle this property
807             try:
808                 prop = self.properties[key]
809             except KeyError:
810                 raise KeyError, '"%s" has no property "%s"'%(self.classname,
811                     key)
813             if value is not None and isinstance(prop, Link):
814                 if type(value) != type(''):
815                     raise ValueError, 'link value must be String'
816                 link_class = self.properties[key].classname
817                 # if it isn't a number, it's a key
818                 if not num_re.match(value):
819                     try:
820                         value = self.db.classes[link_class].lookup(value)
821                     except (TypeError, KeyError):
822                         raise IndexError, 'new property "%s": %s not a %s'%(
823                             key, value, link_class)
824                 elif not self.db.getclass(link_class).hasnode(value):
825                     raise IndexError, '%s has no node %s'%(link_class, value)
827                 # save off the value
828                 propvalues[key] = value
830                 # register the link with the newly linked node
831                 if self.do_journal and self.properties[key].do_journal:
832                     self.db.addjournal(link_class, value, 'link',
833                         (self.classname, newid, key))
835             elif isinstance(prop, Multilink):
836                 if type(value) != type([]):
837                     raise TypeError, 'new property "%s" not a list of ids'%key
839                 # clean up and validate the list of links
840                 link_class = self.properties[key].classname
841                 l = []
842                 for entry in value:
843                     if type(entry) != type(''):
844                         raise ValueError, '"%s" multilink value (%r) '\
845                             'must contain Strings'%(key, value)
846                     # if it isn't a number, it's a key
847                     if not num_re.match(entry):
848                         try:
849                             entry = self.db.classes[link_class].lookup(entry)
850                         except (TypeError, KeyError):
851                             raise IndexError, 'new property "%s": %s not a %s'%(
852                                 key, entry, self.properties[key].classname)
853                     l.append(entry)
854                 value = l
855                 propvalues[key] = value
857                 # handle additions
858                 for nodeid in value:
859                     if not self.db.getclass(link_class).hasnode(nodeid):
860                         raise IndexError, '%s has no node %s'%(link_class,
861                             nodeid)
862                     # register the link with the newly linked node
863                     if self.do_journal and self.properties[key].do_journal:
864                         self.db.addjournal(link_class, nodeid, 'link',
865                             (self.classname, newid, key))
867             elif isinstance(prop, String):
868                 if type(value) != type('') and type(value) != type(u''):
869                     raise TypeError, 'new property "%s" not a string'%key
871             elif isinstance(prop, Password):
872                 if not isinstance(value, password.Password):
873                     raise TypeError, 'new property "%s" not a Password'%key
875             elif isinstance(prop, Date):
876                 if value is not None and not isinstance(value, date.Date):
877                     raise TypeError, 'new property "%s" not a Date'%key
879             elif isinstance(prop, Interval):
880                 if value is not None and not isinstance(value, date.Interval):
881                     raise TypeError, 'new property "%s" not an Interval'%key
883             elif value is not None and isinstance(prop, Number):
884                 try:
885                     float(value)
886                 except ValueError:
887                     raise TypeError, 'new property "%s" not numeric'%key
889             elif value is not None and isinstance(prop, Boolean):
890                 try:
891                     int(value)
892                 except ValueError:
893                     raise TypeError, 'new property "%s" not boolean'%key
895         # make sure there's data where there needs to be
896         for key, prop in self.properties.items():
897             if propvalues.has_key(key):
898                 continue
899             if key == self.key:
900                 raise ValueError, 'key property "%s" is required'%key
901             if isinstance(prop, Multilink):
902                 propvalues[key] = []
903             else:
904                 propvalues[key] = None
906         # done
907         self.db.addnode(self.classname, newid, propvalues)
908         if self.do_journal:
909             self.db.addjournal(self.classname, newid, 'create', {})
911         return newid
913     def export_list(self, propnames, nodeid):
914         ''' Export a node - generate a list of CSV-able data in the order
915             specified by propnames for the given node.
916         '''
917         properties = self.getprops()
918         l = []
919         for prop in propnames:
920             proptype = properties[prop]
921             value = self.get(nodeid, prop)
922             # "marshal" data where needed
923             if value is None:
924                 pass
925             elif isinstance(proptype, hyperdb.Date):
926                 value = value.get_tuple()
927             elif isinstance(proptype, hyperdb.Interval):
928                 value = value.get_tuple()
929             elif isinstance(proptype, hyperdb.Password):
930                 value = str(value)
931             l.append(repr(value))
933         # append retired flag
934         l.append(self.is_retired(nodeid))
936         return l
938     def import_list(self, propnames, proplist):
939         ''' Import a node - all information including "id" is present and
940             should not be sanity checked. Triggers are not triggered. The
941             journal should be initialised using the "creator" and "created"
942             information.
944             Return the nodeid of the node imported.
945         '''
946         if self.db.journaltag is None:
947             raise DatabaseError, 'Database open read-only'
948         properties = self.getprops()
950         # make the new node's property map
951         d = {}
952         newid = None
953         for i in range(len(propnames)):
954             # Figure the property for this column
955             propname = propnames[i]
957             # Use eval to reverse the repr() used to output the CSV
958             value = eval(proplist[i])
960             # "unmarshal" where necessary
961             if propname == 'id':
962                 newid = value
963                 continue
964             elif propname == 'is retired':
965                 # is the item retired?
966                 if int(value):
967                     d[self.db.RETIRED_FLAG] = 1
968                 continue
969             elif value is None:
970                 d[propname] = None
971                 continue
973             prop = properties[propname]
974             if isinstance(prop, hyperdb.Date):
975                 value = date.Date(value)
976             elif isinstance(prop, hyperdb.Interval):
977                 value = date.Interval(value)
978             elif isinstance(prop, hyperdb.Password):
979                 pwd = password.Password()
980                 pwd.unpack(value)
981                 value = pwd
982             d[propname] = value
984         # get a new id if necessary
985         if newid is None:
986             newid = self.db.newid(self.classname)
988         # add the node and journal
989         self.db.addnode(self.classname, newid, d)
991         # extract the journalling stuff and nuke it
992         if d.has_key('creator'):
993             creator = d['creator']
994             del d['creator']
995         else:
996             creator = None
997         if d.has_key('creation'):
998             creation = d['creation']
999             del d['creation']
1000         else:
1001             creation = None
1002         if d.has_key('activity'):
1003             del d['activity']
1004         self.db.addjournal(self.classname, newid, 'create', {}, creator,
1005             creation)
1006         return newid
1008     def get(self, nodeid, propname, default=_marker, cache=1):
1009         '''Get the value of a property on an existing node of this class.
1011         'nodeid' must be the id of an existing node of this class or an
1012         IndexError is raised.  'propname' must be the name of a property
1013         of this class or a KeyError is raised.
1015         'cache' exists for backward compatibility, and is not used.
1017         Attempts to get the "creation" or "activity" properties should
1018         do the right thing.
1019         '''
1020         if propname == 'id':
1021             return nodeid
1023         # get the node's dict
1024         d = self.db.getnode(self.classname, nodeid)
1026         # check for one of the special props
1027         if propname == 'creation':
1028             if d.has_key('creation'):
1029                 return d['creation']
1030             if not self.do_journal:
1031                 raise ValueError, 'Journalling is disabled for this class'
1032             journal = self.db.getjournal(self.classname, nodeid)
1033             if journal:
1034                 return self.db.getjournal(self.classname, nodeid)[0][1]
1035             else:
1036                 # on the strange chance that there's no journal
1037                 return date.Date()
1038         if propname == 'activity':
1039             if d.has_key('activity'):
1040                 return d['activity']
1041             if not self.do_journal:
1042                 raise ValueError, 'Journalling is disabled for this class'
1043             journal = self.db.getjournal(self.classname, nodeid)
1044             if journal:
1045                 return self.db.getjournal(self.classname, nodeid)[-1][1]
1046             else:
1047                 # on the strange chance that there's no journal
1048                 return date.Date()
1049         if propname == 'creator':
1050             if d.has_key('creator'):
1051                 return d['creator']
1052             if not self.do_journal:
1053                 raise ValueError, 'Journalling is disabled for this class'
1054             journal = self.db.getjournal(self.classname, nodeid)
1055             if journal:
1056                 num_re = re.compile('^\d+$')
1057                 value = self.db.getjournal(self.classname, nodeid)[0][2]
1058                 if num_re.match(value):
1059                     return value
1060                 else:
1061                     # old-style "username" journal tag
1062                     try:
1063                         return self.db.user.lookup(value)
1064                     except KeyError:
1065                         # user's been retired, return admin
1066                         return '1'
1067             else:
1068                 return self.db.getuid()
1070         # get the property (raises KeyErorr if invalid)
1071         prop = self.properties[propname]
1073         if not d.has_key(propname):
1074             if default is _marker:
1075                 if isinstance(prop, Multilink):
1076                     return []
1077                 else:
1078                     return None
1079             else:
1080                 return default
1082         # return a dupe of the list so code doesn't get confused
1083         if isinstance(prop, Multilink):
1084             return d[propname][:]
1086         return d[propname]
1088     # not in spec
1089     def getnode(self, nodeid, cache=1):
1090         ''' Return a convenience wrapper for the node.
1092         'nodeid' must be the id of an existing node of this class or an
1093         IndexError is raised.
1095         'cache' exists for backwards compatibility, and is not used.
1096         '''
1097         return Node(self, nodeid)
1099     def set(self, nodeid, **propvalues):
1100         '''Modify a property on an existing node of this class.
1101         
1102         'nodeid' must be the id of an existing node of this class or an
1103         IndexError is raised.
1105         Each key in 'propvalues' must be the name of a property of this
1106         class or a KeyError is raised.
1108         All values in 'propvalues' must be acceptable types for their
1109         corresponding properties or a TypeError is raised.
1111         If the value of the key property is set, it must not collide with
1112         other key strings or a ValueError is raised.
1114         If the value of a Link or Multilink property contains an invalid
1115         node id, a ValueError is raised.
1117         These operations trigger detectors and can be vetoed.  Attempts
1118         to modify the "creation" or "activity" properties cause a KeyError.
1119         '''
1120         if not propvalues:
1121             return propvalues
1123         if propvalues.has_key('creation') or propvalues.has_key('activity'):
1124             raise KeyError, '"creation" and "activity" are reserved'
1126         if propvalues.has_key('id'):
1127             raise KeyError, '"id" is reserved'
1129         if self.db.journaltag is None:
1130             raise DatabaseError, 'Database open read-only'
1132         self.fireAuditors('set', nodeid, propvalues)
1133         # Take a copy of the node dict so that the subsequent set
1134         # operation doesn't modify the oldvalues structure.
1135         oldvalues = copy.deepcopy(self.db.getnode(self.classname, nodeid))
1137         node = self.db.getnode(self.classname, nodeid)
1138         if node.has_key(self.db.RETIRED_FLAG):
1139             raise IndexError
1140         num_re = re.compile('^\d+$')
1142         # if the journal value is to be different, store it in here
1143         journalvalues = {}
1145         for propname, value in propvalues.items():
1146             # check to make sure we're not duplicating an existing key
1147             if propname == self.key and node[propname] != value:
1148                 try:
1149                     self.lookup(value)
1150                 except KeyError:
1151                     pass
1152                 else:
1153                     raise ValueError, 'node with key "%s" exists'%value
1155             # this will raise the KeyError if the property isn't valid
1156             # ... we don't use getprops() here because we only care about
1157             # the writeable properties.
1158             try:
1159                 prop = self.properties[propname]
1160             except KeyError:
1161                 raise KeyError, '"%s" has no property named "%s"'%(
1162                     self.classname, propname)
1164             # if the value's the same as the existing value, no sense in
1165             # doing anything
1166             current = node.get(propname, None)
1167             if value == current:
1168                 del propvalues[propname]
1169                 continue
1170             journalvalues[propname] = current
1172             # do stuff based on the prop type
1173             if isinstance(prop, Link):
1174                 link_class = prop.classname
1175                 # if it isn't a number, it's a key
1176                 if value is not None and not isinstance(value, type('')):
1177                     raise ValueError, 'property "%s" link value be a string'%(
1178                         propname)
1179                 if isinstance(value, type('')) and not num_re.match(value):
1180                     try:
1181                         value = self.db.classes[link_class].lookup(value)
1182                     except (TypeError, KeyError):
1183                         raise IndexError, 'new property "%s": %s not a %s'%(
1184                             propname, value, prop.classname)
1186                 if (value is not None and
1187                         not self.db.getclass(link_class).hasnode(value)):
1188                     raise IndexError, '%s has no node %s'%(link_class, value)
1190                 if self.do_journal and prop.do_journal:
1191                     # register the unlink with the old linked node
1192                     if node.has_key(propname) and node[propname] is not None:
1193                         self.db.addjournal(link_class, node[propname], 'unlink',
1194                             (self.classname, nodeid, propname))
1196                     # register the link with the newly linked node
1197                     if value is not None:
1198                         self.db.addjournal(link_class, value, 'link',
1199                             (self.classname, nodeid, propname))
1201             elif isinstance(prop, Multilink):
1202                 if type(value) != type([]):
1203                     raise TypeError, 'new property "%s" not a list of'\
1204                         ' ids'%propname
1205                 link_class = self.properties[propname].classname
1206                 l = []
1207                 for entry in value:
1208                     # if it isn't a number, it's a key
1209                     if type(entry) != type(''):
1210                         raise ValueError, 'new property "%s" link value ' \
1211                             'must be a string'%propname
1212                     if not num_re.match(entry):
1213                         try:
1214                             entry = self.db.classes[link_class].lookup(entry)
1215                         except (TypeError, KeyError):
1216                             raise IndexError, 'new property "%s": %s not a %s'%(
1217                                 propname, entry,
1218                                 self.properties[propname].classname)
1219                     l.append(entry)
1220                 value = l
1221                 propvalues[propname] = value
1223                 # figure the journal entry for this property
1224                 add = []
1225                 remove = []
1227                 # handle removals
1228                 if node.has_key(propname):
1229                     l = node[propname]
1230                 else:
1231                     l = []
1232                 for id in l[:]:
1233                     if id in value:
1234                         continue
1235                     # register the unlink with the old linked node
1236                     if self.do_journal and self.properties[propname].do_journal:
1237                         self.db.addjournal(link_class, id, 'unlink',
1238                             (self.classname, nodeid, propname))
1239                     l.remove(id)
1240                     remove.append(id)
1242                 # handle additions
1243                 for id in value:
1244                     if not self.db.getclass(link_class).hasnode(id):
1245                         raise IndexError, '%s has no node %s'%(link_class, id)
1246                     if id in l:
1247                         continue
1248                     # register the link with the newly linked node
1249                     if self.do_journal and self.properties[propname].do_journal:
1250                         self.db.addjournal(link_class, id, 'link',
1251                             (self.classname, nodeid, propname))
1252                     l.append(id)
1253                     add.append(id)
1255                 # figure the journal entry
1256                 l = []
1257                 if add:
1258                     l.append(('+', add))
1259                 if remove:
1260                     l.append(('-', remove))
1261                 if l:
1262                     journalvalues[propname] = tuple(l)
1264             elif isinstance(prop, String):
1265                 if value is not None and type(value) != type('') and type(value) != type(u''):
1266                     raise TypeError, 'new property "%s" not a string'%propname
1268             elif isinstance(prop, Password):
1269                 if not isinstance(value, password.Password):
1270                     raise TypeError, 'new property "%s" not a Password'%propname
1271                 propvalues[propname] = value
1273             elif value is not None and isinstance(prop, Date):
1274                 if not isinstance(value, date.Date):
1275                     raise TypeError, 'new property "%s" not a Date'% propname
1276                 propvalues[propname] = value
1278             elif value is not None and isinstance(prop, Interval):
1279                 if not isinstance(value, date.Interval):
1280                     raise TypeError, 'new property "%s" not an '\
1281                         'Interval'%propname
1282                 propvalues[propname] = value
1284             elif value is not None and isinstance(prop, Number):
1285                 try:
1286                     float(value)
1287                 except ValueError:
1288                     raise TypeError, 'new property "%s" not numeric'%propname
1290             elif value is not None and isinstance(prop, Boolean):
1291                 try:
1292                     int(value)
1293                 except ValueError:
1294                     raise TypeError, 'new property "%s" not boolean'%propname
1296             node[propname] = value
1298         # nothing to do?
1299         if not propvalues:
1300             return propvalues
1302         # do the set, and journal it
1303         self.db.setnode(self.classname, nodeid, node)
1305         if self.do_journal:
1306             self.db.addjournal(self.classname, nodeid, 'set', journalvalues)
1308         self.fireReactors('set', nodeid, oldvalues)
1310         return propvalues        
1312     def retire(self, nodeid):
1313         '''Retire a node.
1314         
1315         The properties on the node remain available from the get() method,
1316         and the node's id is never reused.
1317         
1318         Retired nodes are not returned by the find(), list(), or lookup()
1319         methods, and other nodes may reuse the values of their key properties.
1321         These operations trigger detectors and can be vetoed.  Attempts
1322         to modify the "creation" or "activity" properties cause a KeyError.
1323         '''
1324         if self.db.journaltag is None:
1325             raise DatabaseError, 'Database open read-only'
1327         self.fireAuditors('retire', nodeid, None)
1329         node = self.db.getnode(self.classname, nodeid)
1330         node[self.db.RETIRED_FLAG] = 1
1331         self.db.setnode(self.classname, nodeid, node)
1332         if self.do_journal:
1333             self.db.addjournal(self.classname, nodeid, 'retired', None)
1335         self.fireReactors('retire', nodeid, None)
1337     def restore(self, nodeid):
1338         '''Restpre a retired node.
1340         Make node available for all operations like it was before retirement.
1341         '''
1342         if self.db.journaltag is None:
1343             raise DatabaseError, 'Database open read-only'
1345         node = self.db.getnode(self.classname, nodeid)
1346         # check if key property was overrided
1347         key = self.getkey()
1348         try:
1349             id = self.lookup(node[key])
1350         except KeyError:
1351             pass
1352         else:
1353             raise KeyError, "Key property (%s) of retired node clashes with \
1354                 existing one (%s)" % (key, node[key])
1355         # Now we can safely restore node
1356         self.fireAuditors('restore', nodeid, None)
1357         del node[self.db.RETIRED_FLAG]
1358         self.db.setnode(self.classname, nodeid, node)
1359         if self.do_journal:
1360             self.db.addjournal(self.classname, nodeid, 'restored', None)
1362         self.fireReactors('restore', nodeid, None)
1364     def is_retired(self, nodeid, cldb=None):
1365         '''Return true if the node is retired.
1366         '''
1367         node = self.db.getnode(self.classname, nodeid, cldb)
1368         if node.has_key(self.db.RETIRED_FLAG):
1369             return 1
1370         return 0
1372     def destroy(self, nodeid):
1373         '''Destroy a node.
1375         WARNING: this method should never be used except in extremely rare
1376                  situations where there could never be links to the node being
1377                  deleted
1378         WARNING: use retire() instead
1379         WARNING: the properties of this node will not be available ever again
1380         WARNING: really, use retire() instead
1382         Well, I think that's enough warnings. This method exists mostly to
1383         support the session storage of the cgi interface.
1384         '''
1385         if self.db.journaltag is None:
1386             raise DatabaseError, 'Database open read-only'
1387         self.db.destroynode(self.classname, nodeid)
1389     def history(self, nodeid):
1390         '''Retrieve the journal of edits on a particular node.
1392         'nodeid' must be the id of an existing node of this class or an
1393         IndexError is raised.
1395         The returned list contains tuples of the form
1397             (nodeid, date, tag, action, params)
1399         'date' is a Timestamp object specifying the time of the change and
1400         'tag' is the journaltag specified when the database was opened.
1401         '''
1402         if not self.do_journal:
1403             raise ValueError, 'Journalling is disabled for this class'
1404         return self.db.getjournal(self.classname, nodeid)
1406     # Locating nodes:
1407     def hasnode(self, nodeid):
1408         '''Determine if the given nodeid actually exists
1409         '''
1410         return self.db.hasnode(self.classname, nodeid)
1412     def setkey(self, propname):
1413         '''Select a String property of this class to be the key property.
1415         'propname' must be the name of a String property of this class or
1416         None, or a TypeError is raised.  The values of the key property on
1417         all existing nodes must be unique or a ValueError is raised. If the
1418         property doesn't exist, KeyError is raised.
1419         '''
1420         prop = self.getprops()[propname]
1421         if not isinstance(prop, String):
1422             raise TypeError, 'key properties must be String'
1423         self.key = propname
1425     def getkey(self):
1426         '''Return the name of the key property for this class or None.'''
1427         return self.key
1429     def labelprop(self, default_to_id=0):
1430         ''' Return the property name for a label for the given node.
1432         This method attempts to generate a consistent label for the node.
1433         It tries the following in order:
1434             1. key property
1435             2. "name" property
1436             3. "title" property
1437             4. first property from the sorted property name list
1438         '''
1439         k = self.getkey()
1440         if  k:
1441             return k
1442         props = self.getprops()
1443         if props.has_key('name'):
1444             return 'name'
1445         elif props.has_key('title'):
1446             return 'title'
1447         if default_to_id:
1448             return 'id'
1449         props = props.keys()
1450         props.sort()
1451         return props[0]
1453     # TODO: set up a separate index db file for this? profile?
1454     def lookup(self, keyvalue):
1455         '''Locate a particular node by its key property and return its id.
1457         If this class has no key property, a TypeError is raised.  If the
1458         'keyvalue' matches one of the values for the key property among
1459         the nodes in this class, the matching node's id is returned;
1460         otherwise a KeyError is raised.
1461         '''
1462         if not self.key:
1463             raise TypeError, 'No key property set for class %s'%self.classname
1464         cldb = self.db.getclassdb(self.classname)
1465         try:
1466             for nodeid in self.getnodeids(cldb):
1467                 node = self.db.getnode(self.classname, nodeid, cldb)
1468                 if node.has_key(self.db.RETIRED_FLAG):
1469                     continue
1470                 if node[self.key] == keyvalue:
1471                     return nodeid
1472         finally:
1473             cldb.close()
1474         raise KeyError, 'No key (%s) value "%s" for "%s"'%(self.key,
1475             keyvalue, self.classname)
1477     # change from spec - allows multiple props to match
1478     def find(self, **propspec):
1479         '''Get the ids of items in this class which link to the given items.
1481         'propspec' consists of keyword args propname=itemid or
1482                    propname={itemid:1, }
1483         'propname' must be the name of a property in this class, or a
1484                    KeyError is raised.  That property must be a Link or
1485                    Multilink property, or a TypeError is raised.
1487         Any item in this class whose 'propname' property links to any of the
1488         itemids will be returned. Used by the full text indexing, which knows
1489         that "foo" occurs in msg1, msg3 and file7, so we have hits on these
1490         issues:
1492             db.issue.find(messages={'1':1,'3':1}, files={'7':1})
1493         '''
1494         propspec = propspec.items()
1495         for propname, itemids in propspec:
1496             # check the prop is OK
1497             prop = self.properties[propname]
1498             if not isinstance(prop, Link) and not isinstance(prop, Multilink):
1499                 raise TypeError, "'%s' not a Link/Multilink property"%propname
1501         # ok, now do the find
1502         cldb = self.db.getclassdb(self.classname)
1503         l = []
1504         try:
1505             for id in self.getnodeids(db=cldb):
1506                 item = self.db.getnode(self.classname, id, db=cldb)
1507                 if item.has_key(self.db.RETIRED_FLAG):
1508                     continue
1509                 for propname, itemids in propspec:
1510                     # can't test if the item doesn't have this property
1511                     if not item.has_key(propname):
1512                         continue
1513                     if type(itemids) is not type({}):
1514                         itemids = {itemids:1}
1516                     # grab the property definition and its value on this item
1517                     prop = self.properties[propname]
1518                     value = item[propname]
1519                     if isinstance(prop, Link) and itemids.has_key(value):
1520                         l.append(id)
1521                         break
1522                     elif isinstance(prop, Multilink):
1523                         hit = 0
1524                         for v in value:
1525                             if itemids.has_key(v):
1526                                 l.append(id)
1527                                 hit = 1
1528                                 break
1529                         if hit:
1530                             break
1531         finally:
1532             cldb.close()
1533         return l
1535     def stringFind(self, **requirements):
1536         '''Locate a particular node by matching a set of its String
1537         properties in a caseless search.
1539         If the property is not a String property, a TypeError is raised.
1540         
1541         The return is a list of the id of all nodes that match.
1542         '''
1543         for propname in requirements.keys():
1544             prop = self.properties[propname]
1545             if isinstance(not prop, String):
1546                 raise TypeError, "'%s' not a String property"%propname
1547             requirements[propname] = requirements[propname].lower()
1548         l = []
1549         cldb = self.db.getclassdb(self.classname)
1550         try:
1551             for nodeid in self.getnodeids(cldb):
1552                 node = self.db.getnode(self.classname, nodeid, cldb)
1553                 if node.has_key(self.db.RETIRED_FLAG):
1554                     continue
1555                 for key, value in requirements.items():
1556                     if not node.has_key(key):
1557                         break
1558                     if node[key] is None or node[key].lower() != value:
1559                         break
1560                 else:
1561                     l.append(nodeid)
1562         finally:
1563             cldb.close()
1564         return l
1566     def list(self):
1567         ''' Return a list of the ids of the active nodes in this class.
1568         '''
1569         l = []
1570         cn = self.classname
1571         cldb = self.db.getclassdb(cn)
1572         try:
1573             for nodeid in self.getnodeids(cldb):
1574                 node = self.db.getnode(cn, nodeid, cldb)
1575                 if node.has_key(self.db.RETIRED_FLAG):
1576                     continue
1577                 l.append(nodeid)
1578         finally:
1579             cldb.close()
1580         l.sort()
1581         return l
1583     def getnodeids(self, db=None):
1584         ''' Return a list of ALL nodeids
1585         '''
1586         if __debug__:
1587             print >>hyperdb.DEBUG, 'getnodeids', (self, self.classname, db)
1589         res = []
1591         # start off with the new nodes
1592         if self.db.newnodes.has_key(self.classname):
1593             res += self.db.newnodes[self.classname].keys()
1595         if db is None:
1596             db = self.db.getclassdb(self.classname)
1597         res = res + db.keys()
1599         # remove the uncommitted, destroyed nodes
1600         if self.db.destroyednodes.has_key(self.classname):
1601             for nodeid in self.db.destroyednodes[self.classname].keys():
1602                 if db.has_key(nodeid):
1603                     res.remove(nodeid)
1605         return res
1607     def filter(self, search_matches, filterspec, sort=(None,None),
1608             group=(None,None), num_re = re.compile('^\d+$')):
1609         ''' Return a list of the ids of the active nodes in this class that
1610             match the 'filter' spec, sorted by the group spec and then the
1611             sort spec.
1613             "filterspec" is {propname: value(s)}
1614             "sort" and "group" are (dir, prop) where dir is '+', '-' or None
1615                                and prop is a prop name or None
1616             "search_matches" is {nodeid: marker}
1618             The filter must match all properties specificed - but if the
1619             property value to match is a list, any one of the values in the
1620             list may match for that property to match. Unless the property
1621             is a Multilink, in which case the item's property list must
1622             match the filterspec list.
1623         '''
1624         cn = self.classname
1626         # optimise filterspec
1627         l = []
1628         props = self.getprops()
1629         LINK = 0
1630         MULTILINK = 1
1631         STRING = 2
1632         DATE = 3
1633         INTERVAL = 4
1634         OTHER = 6
1635         
1636         timezone = self.db.getUserTimezone()
1637         for k, v in filterspec.items():
1638             propclass = props[k]
1639             if isinstance(propclass, Link):
1640                 if type(v) is not type([]):
1641                     v = [v]
1642                 # replace key values with node ids
1643                 u = []
1644                 link_class =  self.db.classes[propclass.classname]
1645                 for entry in v:
1646                     # the value -1 is a special "not set" sentinel
1647                     if entry == '-1':
1648                         entry = None
1649                     elif not num_re.match(entry):
1650                         try:
1651                             entry = link_class.lookup(entry)
1652                         except (TypeError,KeyError):
1653                             raise ValueError, 'property "%s": %s not a %s'%(
1654                                 k, entry, self.properties[k].classname)
1655                     u.append(entry)
1657                 l.append((LINK, k, u))
1658             elif isinstance(propclass, Multilink):
1659                 # the value -1 is a special "not set" sentinel
1660                 if v in ('-1', ['-1']):
1661                     v = []
1662                 elif type(v) is not type([]):
1663                     v = [v]
1665                 # replace key values with node ids
1666                 u = []
1667                 link_class =  self.db.classes[propclass.classname]
1668                 for entry in v:
1669                     if not num_re.match(entry):
1670                         try:
1671                             entry = link_class.lookup(entry)
1672                         except (TypeError,KeyError):
1673                             raise ValueError, 'new property "%s": %s not a %s'%(
1674                                 k, entry, self.properties[k].classname)
1675                     u.append(entry)
1676                 u.sort()
1677                 l.append((MULTILINK, k, u))
1678             elif isinstance(propclass, String) and k != 'id':
1679                 if type(v) is not type([]):
1680                     v = [v]
1681                 m = []
1682                 for v in v:
1683                     # simple glob searching
1684                     v = re.sub(r'([\|\{\}\\\.\+\[\]\(\)])', r'\\\1', v)
1685                     v = v.replace('?', '.')
1686                     v = v.replace('*', '.*?')
1687                     m.append(v)
1688                 m = re.compile('(%s)'%('|'.join(m)), re.I)
1689                 l.append((STRING, k, m))
1690             elif isinstance(propclass, Date):
1691                 try:
1692                     date_rng = Range(v, date.Date, offset=timezone)
1693                     l.append((DATE, k, date_rng))
1694                 except ValueError:
1695                     # If range creation fails - ignore that search parameter
1696                     pass
1697             elif isinstance(propclass, Interval):
1698                 try:
1699                     intv_rng = Range(v, date.Interval)
1700                     l.append((INTERVAL, k, intv_rng))
1701                 except ValueError:
1702                     # If range creation fails - ignore that search parameter
1703                     pass
1704                 
1705             elif isinstance(propclass, Boolean):
1706                 if type(v) is type(''):
1707                     bv = v.lower() in ('yes', 'true', 'on', '1')
1708                 else:
1709                     bv = v
1710                 l.append((OTHER, k, bv))
1711             elif isinstance(propclass, Number):
1712                 l.append((OTHER, k, int(v)))
1713             else:
1714                 l.append((OTHER, k, v))
1715         filterspec = l
1717         # now, find all the nodes that are active and pass filtering
1718         l = []
1719         cldb = self.db.getclassdb(cn)
1720         try:
1721             # TODO: only full-scan once (use items())
1722             for nodeid in self.getnodeids(cldb):
1723                 node = self.db.getnode(cn, nodeid, cldb)
1724                 if node.has_key(self.db.RETIRED_FLAG):
1725                     continue
1726                 # apply filter
1727                 for t, k, v in filterspec:
1728                     # handle the id prop
1729                     if k == 'id' and v == nodeid:
1730                         continue
1732                     # make sure the node has the property
1733                     if not node.has_key(k):
1734                         # this node doesn't have this property, so reject it
1735                         break
1737                     # now apply the property filter
1738                     if t == LINK:
1739                         # link - if this node's property doesn't appear in the
1740                         # filterspec's nodeid list, skip it
1741                         if node[k] not in v:
1742                             break
1743                     elif t == MULTILINK:
1744                         # multilink - if any of the nodeids required by the
1745                         # filterspec aren't in this node's property, then skip
1746                         # it
1747                         have = node[k]
1748                         # check for matching the absence of multilink values
1749                         if not v and have:
1750                             break
1752                         # othewise, make sure this node has each of the
1753                         # required values
1754                         for want in v:
1755                             if want not in have:
1756                                 break
1757                         else:
1758                             continue
1759                         break
1760                     elif t == STRING:
1761                         if node[k] is None:
1762                             break
1763                         # RE search
1764                         if not v.search(node[k]):
1765                             break
1766                     elif t == DATE or t == INTERVAL:
1767                         if node[k] is None:
1768                             break
1769                         if v.to_value:
1770                             if not (v.from_value <= node[k] and v.to_value >= node[k]):
1771                                 break
1772                         else:
1773                             if not (v.from_value <= node[k]):
1774                                 break
1775                     elif t == OTHER:
1776                         # straight value comparison for the other types
1777                         if node[k] != v:
1778                             break
1779                 else:
1780                     l.append((nodeid, node))
1781         finally:
1782             cldb.close()
1783         l.sort()
1785         # filter based on full text search
1786         if search_matches is not None:
1787             k = []
1788             for v in l:
1789                 if search_matches.has_key(v[0]):
1790                     k.append(v)
1791             l = k
1793         # now, sort the result
1794         def sortfun(a, b, sort=sort, group=group, properties=self.getprops(),
1795                 db = self.db, cl=self):
1796             a_id, an = a
1797             b_id, bn = b
1798             # sort by group and then sort
1799             for dir, prop in group, sort:
1800                 if dir is None or prop is None: continue
1802                 # sorting is class-specific
1803                 propclass = properties[prop]
1805                 # handle the properties that might be "faked"
1806                 # also, handle possible missing properties
1807                 try:
1808                     if not an.has_key(prop):
1809                         an[prop] = cl.get(a_id, prop)
1810                     av = an[prop]
1811                 except KeyError:
1812                     # the node doesn't have a value for this property
1813                     if isinstance(propclass, Multilink): av = []
1814                     else: av = ''
1815                 try:
1816                     if not bn.has_key(prop):
1817                         bn[prop] = cl.get(b_id, prop)
1818                     bv = bn[prop]
1819                 except KeyError:
1820                     # the node doesn't have a value for this property
1821                     if isinstance(propclass, Multilink): bv = []
1822                     else: bv = ''
1824                 # String and Date values are sorted in the natural way
1825                 if isinstance(propclass, String):
1826                     # clean up the strings
1827                     if av and av[0] in string.uppercase:
1828                         av = av.lower()
1829                     if bv and bv[0] in string.uppercase:
1830                         bv = bv.lower()
1831                 if (isinstance(propclass, String) or
1832                         isinstance(propclass, Date)):
1833                     # it might be a string that's really an integer
1834                     try:
1835                         av = int(av)
1836                         bv = int(bv)
1837                     except:
1838                         pass
1839                     if dir == '+':
1840                         r = cmp(av, bv)
1841                         if r != 0: return r
1842                     elif dir == '-':
1843                         r = cmp(bv, av)
1844                         if r != 0: return r
1846                 # Link properties are sorted according to the value of
1847                 # the "order" property on the linked nodes if it is
1848                 # present; or otherwise on the key string of the linked
1849                 # nodes; or finally on  the node ids.
1850                 elif isinstance(propclass, Link):
1851                     link = db.classes[propclass.classname]
1852                     if av is None and bv is not None: return -1
1853                     if av is not None and bv is None: return 1
1854                     if av is None and bv is None: continue
1855                     if link.getprops().has_key('order'):
1856                         if dir == '+':
1857                             r = cmp(link.get(av, 'order'),
1858                                 link.get(bv, 'order'))
1859                             if r != 0: return r
1860                         elif dir == '-':
1861                             r = cmp(link.get(bv, 'order'),
1862                                 link.get(av, 'order'))
1863                             if r != 0: return r
1864                     elif link.getkey():
1865                         key = link.getkey()
1866                         if dir == '+':
1867                             r = cmp(link.get(av, key), link.get(bv, key))
1868                             if r != 0: return r
1869                         elif dir == '-':
1870                             r = cmp(link.get(bv, key), link.get(av, key))
1871                             if r != 0: return r
1872                     else:
1873                         if dir == '+':
1874                             r = cmp(av, bv)
1875                             if r != 0: return r
1876                         elif dir == '-':
1877                             r = cmp(bv, av)
1878                             if r != 0: return r
1880                 else:
1881                     # all other types just compare
1882                     if dir == '+':
1883                         r = cmp(av, bv)
1884                     elif dir == '-':
1885                         r = cmp(bv, av)
1886                     if r != 0: return r
1887                     
1888             # end for dir, prop in sort, group:
1889             # if all else fails, compare the ids
1890             return cmp(a[0], b[0])
1892         l.sort(sortfun)
1893         return [i[0] for i in l]
1895     def count(self):
1896         '''Get the number of nodes in this class.
1898         If the returned integer is 'numnodes', the ids of all the nodes
1899         in this class run from 1 to numnodes, and numnodes+1 will be the
1900         id of the next node to be created in this class.
1901         '''
1902         return self.db.countnodes(self.classname)
1904     # Manipulating properties:
1906     def getprops(self, protected=1):
1907         '''Return a dictionary mapping property names to property objects.
1908            If the "protected" flag is true, we include protected properties -
1909            those which may not be modified.
1911            In addition to the actual properties on the node, these
1912            methods provide the "creation" and "activity" properties. If the
1913            "protected" flag is true, we include protected properties - those
1914            which may not be modified.
1915         '''
1916         d = self.properties.copy()
1917         if protected:
1918             d['id'] = String()
1919             d['creation'] = hyperdb.Date()
1920             d['activity'] = hyperdb.Date()
1921             d['creator'] = hyperdb.Link('user')
1922         return d
1924     def addprop(self, **properties):
1925         '''Add properties to this class.
1927         The keyword arguments in 'properties' must map names to property
1928         objects, or a TypeError is raised.  None of the keys in 'properties'
1929         may collide with the names of existing properties, or a ValueError
1930         is raised before any properties have been added.
1931         '''
1932         for key in properties.keys():
1933             if self.properties.has_key(key):
1934                 raise ValueError, key
1935         self.properties.update(properties)
1937     def index(self, nodeid):
1938         '''Add (or refresh) the node to search indexes
1939         '''
1940         # find all the String properties that have indexme
1941         for prop, propclass in self.getprops().items():
1942             if isinstance(propclass, String) and propclass.indexme:
1943                 try:
1944                     value = str(self.get(nodeid, prop))
1945                 except IndexError:
1946                     # node no longer exists - entry should be removed
1947                     self.db.indexer.purge_entry((self.classname, nodeid, prop))
1948                 else:
1949                     # and index them under (classname, nodeid, property)
1950                     self.db.indexer.add_text((self.classname, nodeid, prop),
1951                         value)
1953     #
1954     # Detector interface
1955     #
1956     def audit(self, event, detector):
1957         '''Register a detector
1958         '''
1959         l = self.auditors[event]
1960         if detector not in l:
1961             self.auditors[event].append(detector)
1963     def fireAuditors(self, action, nodeid, newvalues):
1964         '''Fire all registered auditors.
1965         '''
1966         for audit in self.auditors[action]:
1967             audit(self.db, self, nodeid, newvalues)
1969     def react(self, event, detector):
1970         '''Register a detector
1971         '''
1972         l = self.reactors[event]
1973         if detector not in l:
1974             self.reactors[event].append(detector)
1976     def fireReactors(self, action, nodeid, oldvalues):
1977         '''Fire all registered reactors.
1978         '''
1979         for react in self.reactors[action]:
1980             react(self.db, self, nodeid, oldvalues)
1982 class FileClass(Class, hyperdb.FileClass):
1983     '''This class defines a large chunk of data. To support this, it has a
1984        mandatory String property "content" which is typically saved off
1985        externally to the hyperdb.
1987        The default MIME type of this data is defined by the
1988        "default_mime_type" class attribute, which may be overridden by each
1989        node if the class defines a "type" String property.
1990     '''
1991     default_mime_type = 'text/plain'
1993     def create(self, **propvalues):
1994         ''' Snarf the "content" propvalue and store in a file
1995         '''
1996         # we need to fire the auditors now, or the content property won't
1997         # be in propvalues for the auditors to play with
1998         self.fireAuditors('create', None, propvalues)
2000         # now remove the content property so it's not stored in the db
2001         content = propvalues['content']
2002         del propvalues['content']
2004         # do the database create
2005         newid = Class.create_inner(self, **propvalues)
2007         # fire reactors
2008         self.fireReactors('create', newid, None)
2010         # store off the content as a file
2011         self.db.storefile(self.classname, newid, None, content)
2012         return newid
2014     def import_list(self, propnames, proplist):
2015         ''' Trap the "content" property...
2016         '''
2017         # dupe this list so we don't affect others
2018         propnames = propnames[:]
2020         # extract the "content" property from the proplist
2021         i = propnames.index('content')
2022         content = eval(proplist[i])
2023         del propnames[i]
2024         del proplist[i]
2026         # do the normal import
2027         newid = Class.import_list(self, propnames, proplist)
2029         # save off the "content" file
2030         self.db.storefile(self.classname, newid, None, content)
2031         return newid
2033     def get(self, nodeid, propname, default=_marker, cache=1):
2034         ''' Trap the content propname and get it from the file
2036         'cache' exists for backwards compatibility, and is not used.
2037         '''
2038         poss_msg = 'Possibly an access right configuration problem.'
2039         if propname == 'content':
2040             try:
2041                 return self.db.getfile(self.classname, nodeid, None)
2042             except IOError, (strerror):
2043                 # XXX by catching this we donot see an error in the log.
2044                 return 'ERROR reading file: %s%s\n%s\n%s'%(
2045                         self.classname, nodeid, poss_msg, strerror)
2046         if default is not _marker:
2047             return Class.get(self, nodeid, propname, default)
2048         else:
2049             return Class.get(self, nodeid, propname)
2051     def getprops(self, protected=1):
2052         ''' In addition to the actual properties on the node, these methods
2053             provide the "content" property. If the "protected" flag is true,
2054             we include protected properties - those which may not be
2055             modified.
2056         '''
2057         d = Class.getprops(self, protected=protected).copy()
2058         d['content'] = hyperdb.String()
2059         return d
2061     def index(self, nodeid):
2062         ''' Index the node in the search index.
2064             We want to index the content in addition to the normal String
2065             property indexing.
2066         '''
2067         # perform normal indexing
2068         Class.index(self, nodeid)
2070         # get the content to index
2071         content = self.get(nodeid, 'content')
2073         # figure the mime type
2074         if self.properties.has_key('type'):
2075             mime_type = self.get(nodeid, 'type')
2076         else:
2077             mime_type = self.default_mime_type
2079         # and index!
2080         self.db.indexer.add_text((self.classname, nodeid, 'content'), content,
2081             mime_type)
2083 # deviation from spec - was called ItemClass
2084 class IssueClass(Class, roundupdb.IssueClass):
2085     # Overridden methods:
2086     def __init__(self, db, classname, **properties):
2087         '''The newly-created class automatically includes the "messages",
2088         "files", "nosy", and "superseder" properties.  If the 'properties'
2089         dictionary attempts to specify any of these properties or a
2090         "creation" or "activity" property, a ValueError is raised.
2091         '''
2092         if not properties.has_key('title'):
2093             properties['title'] = hyperdb.String(indexme='yes')
2094         if not properties.has_key('messages'):
2095             properties['messages'] = hyperdb.Multilink("msg")
2096         if not properties.has_key('files'):
2097             properties['files'] = hyperdb.Multilink("file")
2098         if not properties.has_key('nosy'):
2099             # note: journalling is turned off as it really just wastes
2100             # space. this behaviour may be overridden in an instance
2101             properties['nosy'] = hyperdb.Multilink("user", do_journal="no")
2102         if not properties.has_key('superseder'):
2103             properties['superseder'] = hyperdb.Multilink(classname)
2104         Class.__init__(self, db, classname, **properties)