Code

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