Code

fixed export/import of retired nodes (sf bug 685273)
[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.106 2003-02-26 23:42:50 richard 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 import whichdb, anydbm, os, marshal, re, weakref, string, copy
27 from roundup import hyperdb, date, password, roundupdb, security
28 from blobfiles import FileStorage
29 from sessions import Sessions, OneTimeKeys
30 from roundup.indexer import Indexer
31 from roundup.backends import locking
32 from roundup.hyperdb import String, Password, Date, Interval, Link, \
33     Multilink, DatabaseError, Boolean, Number, Node
35 #
36 # Now the database
37 #
38 class Database(FileStorage, hyperdb.Database, roundupdb.Database):
39     '''A database for storing records containing flexible data types.
41     Transaction stuff TODO:
42         . check the timestamp of the class file and nuke the cache if it's
43           modified. Do some sort of conflict checking on the dirty stuff.
44         . perhaps detect write collisions (related to above)?
46     '''
47     def __init__(self, config, journaltag=None):
48         '''Open a hyperdatabase given a specifier to some storage.
50         The 'storagelocator' is obtained from config.DATABASE.
51         The meaning of 'storagelocator' depends on the particular
52         implementation of the hyperdatabase.  It could be a file name,
53         a directory path, a socket descriptor for a connection to a
54         database over the network, etc.
56         The 'journaltag' is a token that will be attached to the journal
57         entries for any edits done on the database.  If 'journaltag' is
58         None, the database is opened in read-only mode: the Class.create(),
59         Class.set(), and Class.retire() methods are disabled.
60         '''
61         self.config, self.journaltag = config, journaltag
62         self.dir = config.DATABASE
63         self.classes = {}
64         self.cache = {}         # cache of nodes loaded or created
65         self.dirtynodes = {}    # keep track of the dirty nodes by class
66         self.newnodes = {}      # keep track of the new nodes by class
67         self.destroyednodes = {}# keep track of the destroyed nodes by class
68         self.transactions = []
69         self.indexer = Indexer(self.dir)
70         self.sessions = Sessions(self.config)
71         self.otks = OneTimeKeys(self.config)
72         self.security = security.Security(self)
73         # ensure files are group readable and writable
74         os.umask(0002)
76         # lock it
77         lockfilenm = os.path.join(self.dir, 'lock')
78         self.lockfile = locking.acquire_lock(lockfilenm)
79         self.lockfile.write(str(os.getpid()))
80         self.lockfile.flush()
82     def post_init(self):
83         ''' Called once the schema initialisation has finished.
84         '''
85         # reindex the db if necessary
86         if self.indexer.should_reindex():
87             self.reindex()
89         # figure the "curuserid"
90         if self.journaltag is None:
91             self.curuserid = None
92         elif self.journaltag == 'admin':
93             # admin user may not exist, but always has ID 1
94             self.curuserid = '1'
95         else:
96             self.curuserid = self.user.lookup(self.journaltag)
98     def reindex(self):
99         for klass in self.classes.values():
100             for nodeid in klass.list():
101                 klass.index(nodeid)
102         self.indexer.save_index()
104     def __repr__(self):
105         return '<back_anydbm instance at %x>'%id(self) 
107     #
108     # Classes
109     #
110     def __getattr__(self, classname):
111         '''A convenient way of calling self.getclass(classname).'''
112         if self.classes.has_key(classname):
113             if __debug__:
114                 print >>hyperdb.DEBUG, '__getattr__', (self, classname)
115             return self.classes[classname]
116         raise AttributeError, classname
118     def addclass(self, cl):
119         if __debug__:
120             print >>hyperdb.DEBUG, 'addclass', (self, cl)
121         cn = cl.classname
122         if self.classes.has_key(cn):
123             raise ValueError, cn
124         self.classes[cn] = cl
126     def getclasses(self):
127         '''Return a list of the names of all existing classes.'''
128         if __debug__:
129             print >>hyperdb.DEBUG, 'getclasses', (self,)
130         l = self.classes.keys()
131         l.sort()
132         return l
134     def getclass(self, classname):
135         '''Get the Class object representing a particular class.
137         If 'classname' is not a valid class name, a KeyError is raised.
138         '''
139         if __debug__:
140             print >>hyperdb.DEBUG, 'getclass', (self, classname)
141         try:
142             return self.classes[classname]
143         except KeyError:
144             raise KeyError, 'There is no class called "%s"'%classname
146     #
147     # Class DBs
148     #
149     def clear(self):
150         '''Delete all database contents
151         '''
152         if __debug__:
153             print >>hyperdb.DEBUG, 'clear', (self,)
154         for cn in self.classes.keys():
155             for dummy in 'nodes', 'journals':
156                 path = os.path.join(self.dir, 'journals.%s'%cn)
157                 if os.path.exists(path):
158                     os.remove(path)
159                 elif os.path.exists(path+'.db'):    # dbm appends .db
160                     os.remove(path+'.db')
162     def getclassdb(self, classname, mode='r'):
163         ''' grab a connection to the class db that will be used for
164             multiple actions
165         '''
166         if __debug__:
167             print >>hyperdb.DEBUG, 'getclassdb', (self, classname, mode)
168         return self.opendb('nodes.%s'%classname, mode)
170     def determine_db_type(self, path):
171         ''' determine which DB wrote the class file
172         '''
173         db_type = ''
174         if os.path.exists(path):
175             db_type = whichdb.whichdb(path)
176             if not db_type:
177                 raise DatabaseError, "Couldn't identify database type"
178         elif os.path.exists(path+'.db'):
179             # if the path ends in '.db', it's a dbm database, whether
180             # anydbm says it's dbhash or not!
181             db_type = 'dbm'
182         return db_type
184     def opendb(self, name, mode):
185         '''Low-level database opener that gets around anydbm/dbm
186            eccentricities.
187         '''
188         if __debug__:
189             print >>hyperdb.DEBUG, 'opendb', (self, name, mode)
191         # figure the class db type
192         path = os.path.join(os.getcwd(), self.dir, name)
193         db_type = self.determine_db_type(path)
195         # new database? let anydbm pick the best dbm
196         if not db_type:
197             if __debug__:
198                 print >>hyperdb.DEBUG, "opendb anydbm.open(%r, 'c')"%path
199             return anydbm.open(path, 'c')
201         # open the database with the correct module
202         try:
203             dbm = __import__(db_type)
204         except ImportError:
205             raise DatabaseError, \
206                 "Couldn't open database - the required module '%s'"\
207                 " is not available"%db_type
208         if __debug__:
209             print >>hyperdb.DEBUG, "opendb %r.open(%r, %r)"%(db_type, path,
210                 mode)
211         return dbm.open(path, mode)
213     #
214     # Node IDs
215     #
216     def newid(self, classname):
217         ''' Generate a new id for the given class
218         '''
219         # open the ids DB - create if if doesn't exist
220         db = self.opendb('_ids', 'c')
221         if db.has_key(classname):
222             newid = db[classname] = str(int(db[classname]) + 1)
223         else:
224             # the count() bit is transitional - older dbs won't start at 1
225             newid = str(self.getclass(classname).count()+1)
226             db[classname] = newid
227         db.close()
228         return newid
230     def setid(self, classname, setid):
231         ''' Set the id counter: used during import of database
232         '''
233         # open the ids DB - create if if doesn't exist
234         db = self.opendb('_ids', 'c')
235         db[classname] = str(setid)
236         db.close()
238     #
239     # Nodes
240     #
241     def addnode(self, classname, nodeid, node):
242         ''' add the specified node to its class's db
243         '''
244         if __debug__:
245             print >>hyperdb.DEBUG, 'addnode', (self, classname, nodeid, node)
247         # we'll be supplied these props if we're doing an import
248         if not node.has_key('creator'):
249             # add in the "calculated" properties (dupe so we don't affect
250             # calling code's node assumptions)
251             node = node.copy()
252             node['creator'] = self.curuserid
253             node['creation'] = node['activity'] = date.Date()
255         self.newnodes.setdefault(classname, {})[nodeid] = 1
256         self.cache.setdefault(classname, {})[nodeid] = node
257         self.savenode(classname, nodeid, node)
259     def setnode(self, classname, nodeid, node):
260         ''' change the specified node
261         '''
262         if __debug__:
263             print >>hyperdb.DEBUG, 'setnode', (self, classname, nodeid, node)
264         self.dirtynodes.setdefault(classname, {})[nodeid] = 1
266         # update the activity time (dupe so we don't affect
267         # calling code's node assumptions)
268         node = node.copy()
269         node['activity'] = date.Date()
271         # can't set without having already loaded the node
272         self.cache[classname][nodeid] = node
273         self.savenode(classname, nodeid, node)
275     def savenode(self, classname, nodeid, node):
276         ''' perform the saving of data specified by the set/addnode
277         '''
278         if __debug__:
279             print >>hyperdb.DEBUG, 'savenode', (self, classname, nodeid, node)
280         self.transactions.append((self.doSaveNode, (classname, nodeid, node)))
282     def getnode(self, classname, nodeid, db=None, cache=1):
283         ''' get a node from the database
284         '''
285         if __debug__:
286             print >>hyperdb.DEBUG, 'getnode', (self, classname, nodeid, db)
287         if cache:
288             # try the cache
289             cache_dict = self.cache.setdefault(classname, {})
290             if cache_dict.has_key(nodeid):
291                 if __debug__:
292                     print >>hyperdb.TRACE, 'get %s %s cached'%(classname,
293                         nodeid)
294                 return cache_dict[nodeid]
296         if __debug__:
297             print >>hyperdb.TRACE, 'get %s %s'%(classname, nodeid)
299         # get from the database and save in the cache
300         if db is None:
301             db = self.getclassdb(classname)
302         if not db.has_key(nodeid):
303             # try the cache - might be a brand-new node
304             cache_dict = self.cache.setdefault(classname, {})
305             if cache_dict.has_key(nodeid):
306                 if __debug__:
307                     print >>hyperdb.TRACE, 'get %s %s cached'%(classname,
308                         nodeid)
309                 return cache_dict[nodeid]
310             raise IndexError, "no such %s %s"%(classname, nodeid)
312         # check the uncommitted, destroyed nodes
313         if (self.destroyednodes.has_key(classname) and
314                 self.destroyednodes[classname].has_key(nodeid)):
315             raise IndexError, "no such %s %s"%(classname, nodeid)
317         # decode
318         res = marshal.loads(db[nodeid])
320         # reverse the serialisation
321         res = self.unserialise(classname, res)
323         # store off in the cache dict
324         if cache:
325             cache_dict[nodeid] = res
327         return res
329     def destroynode(self, classname, nodeid):
330         '''Remove a node from the database. Called exclusively by the
331            destroy() method on Class.
332         '''
333         if __debug__:
334             print >>hyperdb.DEBUG, 'destroynode', (self, classname, nodeid)
336         # remove from cache and newnodes if it's there
337         if (self.cache.has_key(classname) and
338                 self.cache[classname].has_key(nodeid)):
339             del self.cache[classname][nodeid]
340         if (self.newnodes.has_key(classname) and
341                 self.newnodes[classname].has_key(nodeid)):
342             del self.newnodes[classname][nodeid]
344         # see if there's any obvious commit actions that we should get rid of
345         for entry in self.transactions[:]:
346             if entry[1][:2] == (classname, nodeid):
347                 self.transactions.remove(entry)
349         # add to the destroyednodes map
350         self.destroyednodes.setdefault(classname, {})[nodeid] = 1
352         # add the destroy commit action
353         self.transactions.append((self.doDestroyNode, (classname, nodeid)))
355     def serialise(self, classname, node):
356         '''Copy the node contents, converting non-marshallable data into
357            marshallable data.
358         '''
359         if __debug__:
360             print >>hyperdb.DEBUG, 'serialise', classname, node
361         properties = self.getclass(classname).getprops()
362         d = {}
363         for k, v in node.items():
364             # if the property doesn't exist, or is the "retired" flag then
365             # it won't be in the properties dict
366             if not properties.has_key(k):
367                 d[k] = v
368                 continue
370             # get the property spec
371             prop = properties[k]
373             if isinstance(prop, Password) and v is not None:
374                 d[k] = str(v)
375             elif isinstance(prop, Date) and v is not None:
376                 d[k] = v.serialise()
377             elif isinstance(prop, Interval) and v is not None:
378                 d[k] = v.serialise()
379             else:
380                 d[k] = v
381         return d
383     def unserialise(self, classname, node):
384         '''Decode the marshalled node data
385         '''
386         if __debug__:
387             print >>hyperdb.DEBUG, 'unserialise', classname, node
388         properties = self.getclass(classname).getprops()
389         d = {}
390         for k, v in node.items():
391             # if the property doesn't exist, or is the "retired" flag then
392             # it won't be in the properties dict
393             if not properties.has_key(k):
394                 d[k] = v
395                 continue
397             # get the property spec
398             prop = properties[k]
400             if isinstance(prop, Date) and v is not None:
401                 d[k] = date.Date(v)
402             elif isinstance(prop, Interval) and v is not None:
403                 d[k] = date.Interval(v)
404             elif isinstance(prop, Password) and v is not None:
405                 p = password.Password()
406                 p.unpack(v)
407                 d[k] = p
408             else:
409                 d[k] = v
410         return d
412     def hasnode(self, classname, nodeid, db=None):
413         ''' determine if the database has a given node
414         '''
415         if __debug__:
416             print >>hyperdb.DEBUG, 'hasnode', (self, classname, nodeid, db)
418         # try the cache
419         cache = self.cache.setdefault(classname, {})
420         if cache.has_key(nodeid):
421             if __debug__:
422                 print >>hyperdb.TRACE, 'has %s %s cached'%(classname, nodeid)
423             return 1
424         if __debug__:
425             print >>hyperdb.TRACE, 'has %s %s'%(classname, nodeid)
427         # not in the cache - check the database
428         if db is None:
429             db = self.getclassdb(classname)
430         res = db.has_key(nodeid)
431         return res
433     def countnodes(self, classname, db=None):
434         if __debug__:
435             print >>hyperdb.DEBUG, 'countnodes', (self, classname, db)
437         count = 0
439         # include the uncommitted nodes
440         if self.newnodes.has_key(classname):
441             count += len(self.newnodes[classname])
442         if self.destroyednodes.has_key(classname):
443             count -= len(self.destroyednodes[classname])
445         # and count those in the DB
446         if db is None:
447             db = self.getclassdb(classname)
448         count = count + len(db.keys())
449         return count
451     def getnodeids(self, classname, db=None):
452         if __debug__:
453             print >>hyperdb.DEBUG, 'getnodeids', (self, classname, db)
455         res = []
457         # start off with the new nodes
458         if self.newnodes.has_key(classname):
459             res += self.newnodes[classname].keys()
461         if db is None:
462             db = self.getclassdb(classname)
463         res = res + db.keys()
465         # remove the uncommitted, destroyed nodes
466         if self.destroyednodes.has_key(classname):
467             for nodeid in self.destroyednodes[classname].keys():
468                 if db.has_key(nodeid):
469                     res.remove(nodeid)
471         return res
474     #
475     # Files - special node properties
476     # inherited from FileStorage
478     #
479     # Journal
480     #
481     def addjournal(self, classname, nodeid, action, params, creator=None,
482             creation=None):
483         ''' Journal the Action
484         'action' may be:
486             'create' or 'set' -- 'params' is a dictionary of property values
487             'link' or 'unlink' -- 'params' is (classname, nodeid, propname)
488             'retire' -- 'params' is None
489         '''
490         if __debug__:
491             print >>hyperdb.DEBUG, 'addjournal', (self, classname, nodeid,
492                 action, params, creator, creation)
493         self.transactions.append((self.doSaveJournal, (classname, nodeid,
494             action, params, creator, creation)))
496     def getjournal(self, classname, nodeid):
497         ''' get the journal for id
499             Raise IndexError if the node doesn't exist (as per history()'s
500             API)
501         '''
502         if __debug__:
503             print >>hyperdb.DEBUG, 'getjournal', (self, classname, nodeid)
504         # attempt to open the journal - in some rare cases, the journal may
505         # not exist
506         try:
507             db = self.opendb('journals.%s'%classname, 'r')
508         except anydbm.error, error:
509             if str(error) == "need 'c' or 'n' flag to open new db":
510                 raise IndexError, 'no such %s %s'%(classname, nodeid)
511             elif error.args[0] != 2:
512                 raise
513             raise IndexError, 'no such %s %s'%(classname, nodeid)
514         try:
515             journal = marshal.loads(db[nodeid])
516         except KeyError:
517             db.close()
518             raise IndexError, 'no such %s %s'%(classname, nodeid)
519         db.close()
520         res = []
521         for nodeid, date_stamp, user, action, params in journal:
522             res.append((nodeid, date.Date(date_stamp), user, action, params))
523         return res
525     def pack(self, pack_before):
526         ''' Delete all journal entries except "create" before 'pack_before'.
527         '''
528         if __debug__:
529             print >>hyperdb.DEBUG, 'packjournal', (self, pack_before)
531         pack_before = pack_before.serialise()
532         for classname in self.getclasses():
533             # get the journal db
534             db_name = 'journals.%s'%classname
535             path = os.path.join(os.getcwd(), self.dir, classname)
536             db_type = self.determine_db_type(path)
537             db = self.opendb(db_name, 'w')
539             for key in db.keys():
540                 # get the journal for this db entry
541                 journal = marshal.loads(db[key])
542                 l = []
543                 last_set_entry = None
544                 for entry in journal:
545                     # unpack the entry
546                     (nodeid, date_stamp, self.journaltag, action, 
547                         params) = entry
548                     # if the entry is after the pack date, _or_ the initial
549                     # create entry, then it stays
550                     if date_stamp > pack_before or action == 'create':
551                         l.append(entry)
552                 db[key] = marshal.dumps(l)
553             if db_type == 'gdbm':
554                 db.reorganize()
555             db.close()
556             
558     #
559     # Basic transaction support
560     #
561     def commit(self):
562         ''' Commit the current transactions.
563         '''
564         if __debug__:
565             print >>hyperdb.DEBUG, 'commit', (self,)
567         # keep a handle to all the database files opened
568         self.databases = {}
570         # now, do all the transactions
571         reindex = {}
572         for method, args in self.transactions:
573             reindex[method(*args)] = 1
575         # now close all the database files
576         for db in self.databases.values():
577             db.close()
578         del self.databases
580         # reindex the nodes that request it
581         for classname, nodeid in filter(None, reindex.keys()):
582             print >>hyperdb.DEBUG, 'commit.reindex', (classname, nodeid)
583             self.getclass(classname).index(nodeid)
585         # save the indexer state
586         self.indexer.save_index()
588         self.clearCache()
590     def clearCache(self):
591         # all transactions committed, back to normal
592         self.cache = {}
593         self.dirtynodes = {}
594         self.newnodes = {}
595         self.destroyednodes = {}
596         self.transactions = []
598     def getCachedClassDB(self, classname):
599         ''' get the class db, looking in our cache of databases for commit
600         '''
601         # get the database handle
602         db_name = 'nodes.%s'%classname
603         if not self.databases.has_key(db_name):
604             self.databases[db_name] = self.getclassdb(classname, 'c')
605         return self.databases[db_name]
607     def doSaveNode(self, classname, nodeid, node):
608         if __debug__:
609             print >>hyperdb.DEBUG, 'doSaveNode', (self, classname, nodeid,
610                 node)
612         db = self.getCachedClassDB(classname)
614         # now save the marshalled data
615         db[nodeid] = marshal.dumps(self.serialise(classname, node))
617         # return the classname, nodeid so we reindex this content
618         return (classname, nodeid)
620     def getCachedJournalDB(self, classname):
621         ''' get the journal db, looking in our cache of databases for commit
622         '''
623         # get the database handle
624         db_name = 'journals.%s'%classname
625         if not self.databases.has_key(db_name):
626             self.databases[db_name] = self.opendb(db_name, 'c')
627         return self.databases[db_name]
629     def doSaveJournal(self, classname, nodeid, action, params, creator,
630             creation):
631         # serialise the parameters now if necessary
632         if isinstance(params, type({})):
633             if action in ('set', 'create'):
634                 params = self.serialise(classname, params)
636         # handle supply of the special journalling parameters (usually
637         # supplied on importing an existing database)
638         if creator:
639             journaltag = creator
640         else:
641             journaltag = self.curuserid
642         if creation:
643             journaldate = creation.serialise()
644         else:
645             journaldate = date.Date().serialise()
647         # create the journal entry
648         entry = (nodeid, journaldate, journaltag, action, params)
650         if __debug__:
651             print >>hyperdb.DEBUG, 'doSaveJournal', entry
653         db = self.getCachedJournalDB(classname)
655         # now insert the journal entry
656         if db.has_key(nodeid):
657             # append to existing
658             s = db[nodeid]
659             l = marshal.loads(s)
660             l.append(entry)
661         else:
662             l = [entry]
664         db[nodeid] = marshal.dumps(l)
666     def doDestroyNode(self, classname, nodeid):
667         if __debug__:
668             print >>hyperdb.DEBUG, 'doDestroyNode', (self, classname, nodeid)
670         # delete from the class database
671         db = self.getCachedClassDB(classname)
672         if db.has_key(nodeid):
673             del db[nodeid]
675         # delete from the database
676         db = self.getCachedJournalDB(classname)
677         if db.has_key(nodeid):
678             del db[nodeid]
680         # return the classname, nodeid so we reindex this content
681         return (classname, nodeid)
683     def rollback(self):
684         ''' Reverse all actions from the current transaction.
685         '''
686         if __debug__:
687             print >>hyperdb.DEBUG, 'rollback', (self, )
688         for method, args in self.transactions:
689             # delete temporary files
690             if method == self.doStoreFile:
691                 self.rollbackStoreFile(*args)
692         self.cache = {}
693         self.dirtynodes = {}
694         self.newnodes = {}
695         self.destroyednodes = {}
696         self.transactions = []
698     def close(self):
699         ''' Nothing to do
700         '''
701         if self.lockfile is not None:
702             locking.release_lock(self.lockfile)
703         if self.lockfile is not None:
704             self.lockfile.close()
705             self.lockfile = None
707 _marker = []
708 class Class(hyperdb.Class):
709     '''The handle to a particular class of nodes in a hyperdatabase.'''
711     def __init__(self, db, classname, **properties):
712         '''Create a new class with a given name and property specification.
714         'classname' must not collide with the name of an existing class,
715         or a ValueError is raised.  The keyword arguments in 'properties'
716         must map names to property objects, or a TypeError is raised.
717         '''
718         if (properties.has_key('creation') or properties.has_key('activity')
719                 or properties.has_key('creator')):
720             raise ValueError, '"creation", "activity" and "creator" are '\
721                 'reserved'
723         self.classname = classname
724         self.properties = properties
725         self.db = weakref.proxy(db)       # use a weak ref to avoid circularity
726         self.key = ''
728         # should we journal changes (default yes)
729         self.do_journal = 1
731         # do the db-related init stuff
732         db.addclass(self)
734         self.auditors = {'create': [], 'set': [], 'retire': []}
735         self.reactors = {'create': [], 'set': [], 'retire': []}
737     def enableJournalling(self):
738         '''Turn journalling on for this class
739         '''
740         self.do_journal = 1
742     def disableJournalling(self):
743         '''Turn journalling off for this class
744         '''
745         self.do_journal = 0
747     # Editing nodes:
749     def create(self, **propvalues):
750         '''Create a new node of this class and return its id.
752         The keyword arguments in 'propvalues' map property names to values.
754         The values of arguments must be acceptable for the types of their
755         corresponding properties or a TypeError is raised.
756         
757         If this class has a key property, it must be present and its value
758         must not collide with other key strings or a ValueError is raised.
759         
760         Any other properties on this class that are missing from the
761         'propvalues' dictionary are set to None.
762         
763         If an id in a link or multilink property does not refer to a valid
764         node, an IndexError is raised.
766         These operations trigger detectors and can be vetoed.  Attempts
767         to modify the "creation" or "activity" properties cause a KeyError.
768         '''
769         self.fireAuditors('create', None, propvalues)
770         newid = self.create_inner(**propvalues)
771         self.fireReactors('create', newid, None)
772         return newid
774     def create_inner(self, **propvalues):
775         ''' Called by create, in-between the audit and react calls.
776         '''
777         if propvalues.has_key('id'):
778             raise KeyError, '"id" is reserved'
780         if self.db.journaltag is None:
781             raise DatabaseError, 'Database open read-only'
783         if propvalues.has_key('creation') or propvalues.has_key('activity'):
784             raise KeyError, '"creation" and "activity" are reserved'
785         # new node's id
786         newid = self.db.newid(self.classname)
788         # validate propvalues
789         num_re = re.compile('^\d+$')
790         for key, value in propvalues.items():
791             if key == self.key:
792                 try:
793                     self.lookup(value)
794                 except KeyError:
795                     pass
796                 else:
797                     raise ValueError, 'node with key "%s" exists'%value
799             # try to handle this property
800             try:
801                 prop = self.properties[key]
802             except KeyError:
803                 raise KeyError, '"%s" has no property "%s"'%(self.classname,
804                     key)
806             if value is not None and isinstance(prop, Link):
807                 if type(value) != type(''):
808                     raise ValueError, 'link value must be String'
809                 link_class = self.properties[key].classname
810                 # if it isn't a number, it's a key
811                 if not num_re.match(value):
812                     try:
813                         value = self.db.classes[link_class].lookup(value)
814                     except (TypeError, KeyError):
815                         raise IndexError, 'new property "%s": %s not a %s'%(
816                             key, value, link_class)
817                 elif not self.db.getclass(link_class).hasnode(value):
818                     raise IndexError, '%s has no node %s'%(link_class, value)
820                 # save off the value
821                 propvalues[key] = value
823                 # register the link with the newly linked node
824                 if self.do_journal and self.properties[key].do_journal:
825                     self.db.addjournal(link_class, value, 'link',
826                         (self.classname, newid, key))
828             elif isinstance(prop, Multilink):
829                 if type(value) != type([]):
830                     raise TypeError, 'new property "%s" not a list of ids'%key
832                 # clean up and validate the list of links
833                 link_class = self.properties[key].classname
834                 l = []
835                 for entry in value:
836                     if type(entry) != type(''):
837                         raise ValueError, '"%s" multilink value (%r) '\
838                             'must contain Strings'%(key, value)
839                     # if it isn't a number, it's a key
840                     if not num_re.match(entry):
841                         try:
842                             entry = self.db.classes[link_class].lookup(entry)
843                         except (TypeError, KeyError):
844                             raise IndexError, 'new property "%s": %s not a %s'%(
845                                 key, entry, self.properties[key].classname)
846                     l.append(entry)
847                 value = l
848                 propvalues[key] = value
850                 # handle additions
851                 for nodeid in value:
852                     if not self.db.getclass(link_class).hasnode(nodeid):
853                         raise IndexError, '%s has no node %s'%(link_class,
854                             nodeid)
855                     # register the link with the newly linked node
856                     if self.do_journal and self.properties[key].do_journal:
857                         self.db.addjournal(link_class, nodeid, 'link',
858                             (self.classname, newid, key))
860             elif isinstance(prop, String):
861                 if type(value) != type('') and type(value) != type(u''):
862                     raise TypeError, 'new property "%s" not a string'%key
864             elif isinstance(prop, Password):
865                 if not isinstance(value, password.Password):
866                     raise TypeError, 'new property "%s" not a Password'%key
868             elif isinstance(prop, Date):
869                 if value is not None and not isinstance(value, date.Date):
870                     raise TypeError, 'new property "%s" not a Date'%key
872             elif isinstance(prop, Interval):
873                 if value is not None and not isinstance(value, date.Interval):
874                     raise TypeError, 'new property "%s" not an Interval'%key
876             elif value is not None and isinstance(prop, Number):
877                 try:
878                     float(value)
879                 except ValueError:
880                     raise TypeError, 'new property "%s" not numeric'%key
882             elif value is not None and isinstance(prop, Boolean):
883                 try:
884                     int(value)
885                 except ValueError:
886                     raise TypeError, 'new property "%s" not boolean'%key
888         # make sure there's data where there needs to be
889         for key, prop in self.properties.items():
890             if propvalues.has_key(key):
891                 continue
892             if key == self.key:
893                 raise ValueError, 'key property "%s" is required'%key
894             if isinstance(prop, Multilink):
895                 propvalues[key] = []
896             else:
897                 propvalues[key] = None
899         # done
900         self.db.addnode(self.classname, newid, propvalues)
901         if self.do_journal:
902             self.db.addjournal(self.classname, newid, 'create', {})
904         return newid
906     def export_list(self, propnames, nodeid):
907         ''' Export a node - generate a list of CSV-able data in the order
908             specified by propnames for the given node.
909         '''
910         properties = self.getprops()
911         l = []
912         for prop in propnames:
913             proptype = properties[prop]
914             value = self.get(nodeid, prop)
915             # "marshal" data where needed
916             if value is None:
917                 pass
918             elif isinstance(proptype, hyperdb.Date):
919                 value = value.get_tuple()
920             elif isinstance(proptype, hyperdb.Interval):
921                 value = value.get_tuple()
922             elif isinstance(proptype, hyperdb.Password):
923                 value = str(value)
924             l.append(repr(value))
926         # append retired flag
927         l.append(self.is_retired(nodeid))
929         return l
931     def import_list(self, propnames, proplist):
932         ''' Import a node - all information including "id" is present and
933             should not be sanity checked. Triggers are not triggered. The
934             journal should be initialised using the "creator" and "created"
935             information.
937             Return the nodeid of the node imported.
938         '''
939         if self.db.journaltag is None:
940             raise DatabaseError, 'Database open read-only'
941         properties = self.getprops()
943         # make the new node's property map
944         d = {}
945         for i in range(len(propnames)):
946             # Use eval to reverse the repr() used to output the CSV
947             value = eval(proplist[i])
949             # Figure the property for this column
950             propname = propnames[i]
951             prop = properties[propname]
953             # "unmarshal" where necessary
954             if propname == 'id':
955                 newid = value
956                 continue
957             elif value is None:
958                 # don't set Nones
959                 continue
960             elif isinstance(prop, hyperdb.Date):
961                 value = date.Date(value)
962             elif isinstance(prop, hyperdb.Interval):
963                 value = date.Interval(value)
964             elif isinstance(prop, hyperdb.Password):
965                 pwd = password.Password()
966                 pwd.unpack(value)
967                 value = pwd
968             d[propname] = value
970         # check retired flag
971         if int(proplist[-1]):
972             d[self.db.RETIRED_FLAG] = 1
974         # add the node and journal
975         self.db.addnode(self.classname, newid, d)
977         # extract the journalling stuff and nuke it
978         if d.has_key('creator'):
979             creator = d['creator']
980             del d['creator']
981         else:
982             creator = None
983         if d.has_key('creation'):
984             creation = d['creation']
985             del d['creation']
986         else:
987             creation = None
988         if d.has_key('activity'):
989             del d['activity']
990         self.db.addjournal(self.classname, newid, 'create', {}, creator,
991             creation)
992         return newid
994     def get(self, nodeid, propname, default=_marker, cache=1):
995         '''Get the value of a property on an existing node of this class.
997         'nodeid' must be the id of an existing node of this class or an
998         IndexError is raised.  'propname' must be the name of a property
999         of this class or a KeyError is raised.
1001         'cache' indicates whether the transaction cache should be queried
1002         for the node. If the node has been modified and you need to
1003         determine what its values prior to modification are, you need to
1004         set cache=0.
1006         Attempts to get the "creation" or "activity" properties should
1007         do the right thing.
1008         '''
1009         if propname == 'id':
1010             return nodeid
1012         # get the node's dict
1013         d = self.db.getnode(self.classname, nodeid, cache=cache)
1015         # check for one of the special props
1016         if propname == 'creation':
1017             if d.has_key('creation'):
1018                 return d['creation']
1019             if not self.do_journal:
1020                 raise ValueError, 'Journalling is disabled for this class'
1021             journal = self.db.getjournal(self.classname, nodeid)
1022             if journal:
1023                 return self.db.getjournal(self.classname, nodeid)[0][1]
1024             else:
1025                 # on the strange chance that there's no journal
1026                 return date.Date()
1027         if propname == 'activity':
1028             if d.has_key('activity'):
1029                 return d['activity']
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)[-1][1]
1035             else:
1036                 # on the strange chance that there's no journal
1037                 return date.Date()
1038         if propname == 'creator':
1039             if d.has_key('creator'):
1040                 return d['creator']
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                 num_re = re.compile('^\d+$')
1046                 value = self.db.getjournal(self.classname, nodeid)[0][2]
1047                 if num_re.match(value):
1048                     return value
1049                 else:
1050                     # old-style "username" journal tag
1051                     try:
1052                         return self.db.user.lookup(value)
1053                     except KeyError:
1054                         # user's been retired, return admin
1055                         return '1'
1056             else:
1057                 return self.db.curuserid
1059         # get the property (raises KeyErorr if invalid)
1060         prop = self.properties[propname]
1062         if not d.has_key(propname):
1063             if default is _marker:
1064                 if isinstance(prop, Multilink):
1065                     return []
1066                 else:
1067                     return None
1068             else:
1069                 return default
1071         # return a dupe of the list so code doesn't get confused
1072         if isinstance(prop, Multilink):
1073             return d[propname][:]
1075         return d[propname]
1077     # not in spec
1078     def getnode(self, nodeid, cache=1):
1079         ''' Return a convenience wrapper for the node.
1081         'nodeid' must be the id of an existing node of this class or an
1082         IndexError is raised.
1084         'cache' indicates whether the transaction cache should be queried
1085         for the node. If the node has been modified and you need to
1086         determine what its values prior to modification are, you need to
1087         set cache=0.
1088         '''
1089         return Node(self, nodeid, cache=cache)
1091     def set(self, nodeid, **propvalues):
1092         '''Modify a property on an existing node of this class.
1093         
1094         'nodeid' must be the id of an existing node of this class or an
1095         IndexError is raised.
1097         Each key in 'propvalues' must be the name of a property of this
1098         class or a KeyError is raised.
1100         All values in 'propvalues' must be acceptable types for their
1101         corresponding properties or a TypeError is raised.
1103         If the value of the key property is set, it must not collide with
1104         other key strings or a ValueError is raised.
1106         If the value of a Link or Multilink property contains an invalid
1107         node id, a ValueError is raised.
1109         These operations trigger detectors and can be vetoed.  Attempts
1110         to modify the "creation" or "activity" properties cause a KeyError.
1111         '''
1112         if not propvalues:
1113             return propvalues
1115         if propvalues.has_key('creation') or propvalues.has_key('activity'):
1116             raise KeyError, '"creation" and "activity" are reserved'
1118         if propvalues.has_key('id'):
1119             raise KeyError, '"id" is reserved'
1121         if self.db.journaltag is None:
1122             raise DatabaseError, 'Database open read-only'
1124         self.fireAuditors('set', nodeid, propvalues)
1125         # Take a copy of the node dict so that the subsequent set
1126         # operation doesn't modify the oldvalues structure.
1127         try:
1128             # try not using the cache initially
1129             oldvalues = copy.deepcopy(self.db.getnode(self.classname, nodeid,
1130                 cache=0))
1131         except IndexError:
1132             # this will be needed if somone does a create() and set()
1133             # with no intervening commit()
1134             oldvalues = copy.deepcopy(self.db.getnode(self.classname, nodeid))
1136         node = self.db.getnode(self.classname, nodeid)
1137         if node.has_key(self.db.RETIRED_FLAG):
1138             raise IndexError
1139         num_re = re.compile('^\d+$')
1141         # if the journal value is to be different, store it in here
1142         journalvalues = {}
1144         for propname, value in propvalues.items():
1145             # check to make sure we're not duplicating an existing key
1146             if propname == self.key and node[propname] != value:
1147                 try:
1148                     self.lookup(value)
1149                 except KeyError:
1150                     pass
1151                 else:
1152                     raise ValueError, 'node with key "%s" exists'%value
1154             # this will raise the KeyError if the property isn't valid
1155             # ... we don't use getprops() here because we only care about
1156             # the writeable properties.
1157             try:
1158                 prop = self.properties[propname]
1159             except KeyError:
1160                 raise KeyError, '"%s" has no property named "%s"'%(
1161                     self.classname, propname)
1163             # if the value's the same as the existing value, no sense in
1164             # doing anything
1165             current = node.get(propname, None)
1166             if value == current:
1167                 del propvalues[propname]
1168                 continue
1169             journalvalues[propname] = current
1171             # do stuff based on the prop type
1172             if isinstance(prop, Link):
1173                 link_class = prop.classname
1174                 # if it isn't a number, it's a key
1175                 if value is not None and not isinstance(value, type('')):
1176                     raise ValueError, 'property "%s" link value be a string'%(
1177                         propname)
1178                 if isinstance(value, type('')) and not num_re.match(value):
1179                     try:
1180                         value = self.db.classes[link_class].lookup(value)
1181                     except (TypeError, KeyError):
1182                         raise IndexError, 'new property "%s": %s not a %s'%(
1183                             propname, value, prop.classname)
1185                 if (value is not None and
1186                         not self.db.getclass(link_class).hasnode(value)):
1187                     raise IndexError, '%s has no node %s'%(link_class, value)
1189                 if self.do_journal and prop.do_journal:
1190                     # register the unlink with the old linked node
1191                     if node.has_key(propname) and node[propname] is not None:
1192                         self.db.addjournal(link_class, node[propname], 'unlink',
1193                             (self.classname, nodeid, propname))
1195                     # register the link with the newly linked node
1196                     if value is not None:
1197                         self.db.addjournal(link_class, value, 'link',
1198                             (self.classname, nodeid, propname))
1200             elif isinstance(prop, Multilink):
1201                 if type(value) != type([]):
1202                     raise TypeError, 'new property "%s" not a list of'\
1203                         ' ids'%propname
1204                 link_class = self.properties[propname].classname
1205                 l = []
1206                 for entry in value:
1207                     # if it isn't a number, it's a key
1208                     if type(entry) != type(''):
1209                         raise ValueError, 'new property "%s" link value ' \
1210                             'must be a string'%propname
1211                     if not num_re.match(entry):
1212                         try:
1213                             entry = self.db.classes[link_class].lookup(entry)
1214                         except (TypeError, KeyError):
1215                             raise IndexError, 'new property "%s": %s not a %s'%(
1216                                 propname, entry,
1217                                 self.properties[propname].classname)
1218                     l.append(entry)
1219                 value = l
1220                 propvalues[propname] = value
1222                 # figure the journal entry for this property
1223                 add = []
1224                 remove = []
1226                 # handle removals
1227                 if node.has_key(propname):
1228                     l = node[propname]
1229                 else:
1230                     l = []
1231                 for id in l[:]:
1232                     if id in value:
1233                         continue
1234                     # register the unlink with the old linked node
1235                     if self.do_journal and self.properties[propname].do_journal:
1236                         self.db.addjournal(link_class, id, 'unlink',
1237                             (self.classname, nodeid, propname))
1238                     l.remove(id)
1239                     remove.append(id)
1241                 # handle additions
1242                 for id in value:
1243                     if not self.db.getclass(link_class).hasnode(id):
1244                         raise IndexError, '%s has no node %s'%(link_class, id)
1245                     if id in l:
1246                         continue
1247                     # register the link with the newly linked node
1248                     if self.do_journal and self.properties[propname].do_journal:
1249                         self.db.addjournal(link_class, id, 'link',
1250                             (self.classname, nodeid, propname))
1251                     l.append(id)
1252                     add.append(id)
1254                 # figure the journal entry
1255                 l = []
1256                 if add:
1257                     l.append(('+', add))
1258                 if remove:
1259                     l.append(('-', remove))
1260                 if l:
1261                     journalvalues[propname] = tuple(l)
1263             elif isinstance(prop, String):
1264                 if value is not None and type(value) != type('') and type(value) != type(u''):
1265                     raise TypeError, 'new property "%s" not a string'%propname
1267             elif isinstance(prop, Password):
1268                 if not isinstance(value, password.Password):
1269                     raise TypeError, 'new property "%s" not a Password'%propname
1270                 propvalues[propname] = value
1272             elif value is not None and isinstance(prop, Date):
1273                 if not isinstance(value, date.Date):
1274                     raise TypeError, 'new property "%s" not a Date'% propname
1275                 propvalues[propname] = value
1277             elif value is not None and isinstance(prop, Interval):
1278                 if not isinstance(value, date.Interval):
1279                     raise TypeError, 'new property "%s" not an '\
1280                         'Interval'%propname
1281                 propvalues[propname] = value
1283             elif value is not None and isinstance(prop, Number):
1284                 try:
1285                     float(value)
1286                 except ValueError:
1287                     raise TypeError, 'new property "%s" not numeric'%propname
1289             elif value is not None and isinstance(prop, Boolean):
1290                 try:
1291                     int(value)
1292                 except ValueError:
1293                     raise TypeError, 'new property "%s" not boolean'%propname
1295             node[propname] = value
1297         # nothing to do?
1298         if not propvalues:
1299             return propvalues
1301         # do the set, and journal it
1302         self.db.setnode(self.classname, nodeid, node)
1304         if self.do_journal:
1305             self.db.addjournal(self.classname, nodeid, 'set', journalvalues)
1307         self.fireReactors('set', nodeid, oldvalues)
1309         return propvalues        
1311     def retire(self, nodeid):
1312         '''Retire a node.
1313         
1314         The properties on the node remain available from the get() method,
1315         and the node's id is never reused.
1316         
1317         Retired nodes are not returned by the find(), list(), or lookup()
1318         methods, and other nodes may reuse the values of their key properties.
1320         These operations trigger detectors and can be vetoed.  Attempts
1321         to modify the "creation" or "activity" properties cause a KeyError.
1322         '''
1323         if self.db.journaltag is None:
1324             raise DatabaseError, 'Database open read-only'
1326         self.fireAuditors('retire', nodeid, None)
1328         node = self.db.getnode(self.classname, nodeid)
1329         node[self.db.RETIRED_FLAG] = 1
1330         self.db.setnode(self.classname, nodeid, node)
1331         if self.do_journal:
1332             self.db.addjournal(self.classname, nodeid, 'retired', None)
1334         self.fireReactors('retire', nodeid, None)
1336     def is_retired(self, nodeid, cldb=None):
1337         '''Return true if the node is retired.
1338         '''
1339         node = self.db.getnode(self.classname, nodeid, cldb)
1340         if node.has_key(self.db.RETIRED_FLAG):
1341             return 1
1342         return 0
1344     def destroy(self, nodeid):
1345         '''Destroy a node.
1347         WARNING: this method should never be used except in extremely rare
1348                  situations where there could never be links to the node being
1349                  deleted
1350         WARNING: use retire() instead
1351         WARNING: the properties of this node will not be available ever again
1352         WARNING: really, use retire() instead
1354         Well, I think that's enough warnings. This method exists mostly to
1355         support the session storage of the cgi interface.
1356         '''
1357         if self.db.journaltag is None:
1358             raise DatabaseError, 'Database open read-only'
1359         self.db.destroynode(self.classname, nodeid)
1361     def history(self, nodeid):
1362         '''Retrieve the journal of edits on a particular node.
1364         'nodeid' must be the id of an existing node of this class or an
1365         IndexError is raised.
1367         The returned list contains tuples of the form
1369             (nodeid, date, tag, action, params)
1371         'date' is a Timestamp object specifying the time of the change and
1372         'tag' is the journaltag specified when the database was opened.
1373         '''
1374         if not self.do_journal:
1375             raise ValueError, 'Journalling is disabled for this class'
1376         return self.db.getjournal(self.classname, nodeid)
1378     # Locating nodes:
1379     def hasnode(self, nodeid):
1380         '''Determine if the given nodeid actually exists
1381         '''
1382         return self.db.hasnode(self.classname, nodeid)
1384     def setkey(self, propname):
1385         '''Select a String property of this class to be the key property.
1387         'propname' must be the name of a String property of this class or
1388         None, or a TypeError is raised.  The values of the key property on
1389         all existing nodes must be unique or a ValueError is raised. If the
1390         property doesn't exist, KeyError is raised.
1391         '''
1392         prop = self.getprops()[propname]
1393         if not isinstance(prop, String):
1394             raise TypeError, 'key properties must be String'
1395         self.key = propname
1397     def getkey(self):
1398         '''Return the name of the key property for this class or None.'''
1399         return self.key
1401     def labelprop(self, default_to_id=0):
1402         ''' Return the property name for a label for the given node.
1404         This method attempts to generate a consistent label for the node.
1405         It tries the following in order:
1406             1. key property
1407             2. "name" property
1408             3. "title" property
1409             4. first property from the sorted property name list
1410         '''
1411         k = self.getkey()
1412         if  k:
1413             return k
1414         props = self.getprops()
1415         if props.has_key('name'):
1416             return 'name'
1417         elif props.has_key('title'):
1418             return 'title'
1419         if default_to_id:
1420             return 'id'
1421         props = props.keys()
1422         props.sort()
1423         return props[0]
1425     # TODO: set up a separate index db file for this? profile?
1426     def lookup(self, keyvalue):
1427         '''Locate a particular node by its key property and return its id.
1429         If this class has no key property, a TypeError is raised.  If the
1430         'keyvalue' matches one of the values for the key property among
1431         the nodes in this class, the matching node's id is returned;
1432         otherwise a KeyError is raised.
1433         '''
1434         if not self.key:
1435             raise TypeError, 'No key property set for class %s'%self.classname
1436         cldb = self.db.getclassdb(self.classname)
1437         try:
1438             for nodeid in self.db.getnodeids(self.classname, cldb):
1439                 node = self.db.getnode(self.classname, nodeid, cldb)
1440                 if node.has_key(self.db.RETIRED_FLAG):
1441                     continue
1442                 if node[self.key] == keyvalue:
1443                     return nodeid
1444         finally:
1445             cldb.close()
1446         raise KeyError, 'No key (%s) value "%s" for "%s"'%(self.key,
1447             keyvalue, self.classname)
1449     # change from spec - allows multiple props to match
1450     def find(self, **propspec):
1451         '''Get the ids of nodes in this class which link to the given nodes.
1453         'propspec' consists of keyword args propname=nodeid or
1454                    propname={nodeid:1, }
1455         'propname' must be the name of a property in this class, or a
1456                    KeyError is raised.  That property must be a Link or
1457                    Multilink property, or a TypeError is raised.
1459         Any node in this class whose 'propname' property links to any of the
1460         nodeids will be returned. Used by the full text indexing, which knows
1461         that "foo" occurs in msg1, msg3 and file7, so we have hits on these
1462         issues:
1464             db.issue.find(messages={'1':1,'3':1}, files={'7':1})
1465         '''
1466         propspec = propspec.items()
1467         for propname, nodeids in propspec:
1468             # check the prop is OK
1469             prop = self.properties[propname]
1470             if not isinstance(prop, Link) and not isinstance(prop, Multilink):
1471                 raise TypeError, "'%s' not a Link/Multilink property"%propname
1473         # ok, now do the find
1474         cldb = self.db.getclassdb(self.classname)
1475         l = []
1476         try:
1477             for id in self.db.getnodeids(self.classname, db=cldb):
1478                 node = self.db.getnode(self.classname, id, db=cldb)
1479                 if node.has_key(self.db.RETIRED_FLAG):
1480                     continue
1481                 for propname, nodeids in propspec:
1482                     # can't test if the node doesn't have this property
1483                     if not node.has_key(propname):
1484                         continue
1485                     if type(nodeids) is type(''):
1486                         nodeids = {nodeids:1}
1487                     prop = self.properties[propname]
1488                     value = node[propname]
1489                     if isinstance(prop, Link) and nodeids.has_key(value):
1490                         l.append(id)
1491                         break
1492                     elif isinstance(prop, Multilink):
1493                         hit = 0
1494                         for v in value:
1495                             if nodeids.has_key(v):
1496                                 l.append(id)
1497                                 hit = 1
1498                                 break
1499                         if hit:
1500                             break
1501         finally:
1502             cldb.close()
1503         return l
1505     def stringFind(self, **requirements):
1506         '''Locate a particular node by matching a set of its String
1507         properties in a caseless search.
1509         If the property is not a String property, a TypeError is raised.
1510         
1511         The return is a list of the id of all nodes that match.
1512         '''
1513         for propname in requirements.keys():
1514             prop = self.properties[propname]
1515             if isinstance(not prop, String):
1516                 raise TypeError, "'%s' not a String property"%propname
1517             requirements[propname] = requirements[propname].lower()
1518         l = []
1519         cldb = self.db.getclassdb(self.classname)
1520         try:
1521             for nodeid in self.db.getnodeids(self.classname, cldb):
1522                 node = self.db.getnode(self.classname, nodeid, cldb)
1523                 if node.has_key(self.db.RETIRED_FLAG):
1524                     continue
1525                 for key, value in requirements.items():
1526                     if not node.has_key(key):
1527                         break
1528                     if node[key] is None or node[key].lower() != value:
1529                         break
1530                 else:
1531                     l.append(nodeid)
1532         finally:
1533             cldb.close()
1534         return l
1536     def list(self):
1537         ''' Return a list of the ids of the active nodes in this class.
1538         '''
1539         l = []
1540         cn = self.classname
1541         cldb = self.db.getclassdb(cn)
1542         try:
1543             for nodeid in self.db.getnodeids(cn, cldb):
1544                 node = self.db.getnode(cn, nodeid, cldb)
1545                 if node.has_key(self.db.RETIRED_FLAG):
1546                     continue
1547                 l.append(nodeid)
1548         finally:
1549             cldb.close()
1550         l.sort()
1551         return l
1553     def filter(self, search_matches, filterspec, sort=(None,None),
1554             group=(None,None), num_re = re.compile('^\d+$')):
1555         ''' Return a list of the ids of the active nodes in this class that
1556             match the 'filter' spec, sorted by the group spec and then the
1557             sort spec.
1559             "filterspec" is {propname: value(s)}
1560             "sort" and "group" are (dir, prop) where dir is '+', '-' or None
1561                                and prop is a prop name or None
1562             "search_matches" is {nodeid: marker}
1564             The filter must match all properties specificed - but if the
1565             property value to match is a list, any one of the values in the
1566             list may match for that property to match.
1567         '''
1568         cn = self.classname
1570         # optimise filterspec
1571         l = []
1572         props = self.getprops()
1573         LINK = 0
1574         MULTILINK = 1
1575         STRING = 2
1576         OTHER = 6
1577         for k, v in filterspec.items():
1578             propclass = props[k]
1579             if isinstance(propclass, Link):
1580                 if type(v) is not type([]):
1581                     v = [v]
1582                 # replace key values with node ids
1583                 u = []
1584                 link_class =  self.db.classes[propclass.classname]
1585                 for entry in v:
1586                     if entry == '-1': entry = None
1587                     elif not num_re.match(entry):
1588                         try:
1589                             entry = link_class.lookup(entry)
1590                         except (TypeError,KeyError):
1591                             raise ValueError, 'property "%s": %s not a %s'%(
1592                                 k, entry, self.properties[k].classname)
1593                     u.append(entry)
1595                 l.append((LINK, k, u))
1596             elif isinstance(propclass, Multilink):
1597                 if type(v) is not type([]):
1598                     v = [v]
1599                 # replace key values with node ids
1600                 u = []
1601                 link_class =  self.db.classes[propclass.classname]
1602                 for entry in v:
1603                     if not num_re.match(entry):
1604                         try:
1605                             entry = link_class.lookup(entry)
1606                         except (TypeError,KeyError):
1607                             raise ValueError, 'new property "%s": %s not a %s'%(
1608                                 k, entry, self.properties[k].classname)
1609                     u.append(entry)
1610                 l.append((MULTILINK, k, u))
1611             elif isinstance(propclass, String) and k != 'id':
1612                 # simple glob searching
1613                 v = re.sub(r'([\|\{\}\\\.\+\[\]\(\)])', r'\\\1', v)
1614                 v = v.replace('?', '.')
1615                 v = v.replace('*', '.*?')
1616                 l.append((STRING, k, re.compile(v, re.I)))
1617             elif isinstance(propclass, Boolean):
1618                 if type(v) is type(''):
1619                     bv = v.lower() in ('yes', 'true', 'on', '1')
1620                 else:
1621                     bv = v
1622                 l.append((OTHER, k, bv))
1623             elif isinstance(propclass, Date):
1624                 l.append((OTHER, k, date.Date(v)))
1625             elif isinstance(propclass, Interval):
1626                 l.append((OTHER, k, date.Interval(v)))
1627             elif isinstance(propclass, Number):
1628                 l.append((OTHER, k, int(v)))
1629             else:
1630                 l.append((OTHER, k, v))
1631         filterspec = l
1633         # now, find all the nodes that are active and pass filtering
1634         l = []
1635         cldb = self.db.getclassdb(cn)
1636         try:
1637             # TODO: only full-scan once (use items())
1638             for nodeid in self.db.getnodeids(cn, cldb):
1639                 node = self.db.getnode(cn, nodeid, cldb)
1640                 if node.has_key(self.db.RETIRED_FLAG):
1641                     continue
1642                 # apply filter
1643                 for t, k, v in filterspec:
1644                     # handle the id prop
1645                     if k == 'id' and v == nodeid:
1646                         continue
1648                     # make sure the node has the property
1649                     if not node.has_key(k):
1650                         # this node doesn't have this property, so reject it
1651                         break
1653                     # now apply the property filter
1654                     if t == LINK:
1655                         # link - if this node's property doesn't appear in the
1656                         # filterspec's nodeid list, skip it
1657                         if node[k] not in v:
1658                             break
1659                     elif t == MULTILINK:
1660                         # multilink - if any of the nodeids required by the
1661                         # filterspec aren't in this node's property, then skip
1662                         # it
1663                         have = node[k]
1664                         for want in v:
1665                             if want not in have:
1666                                 break
1667                         else:
1668                             continue
1669                         break
1670                     elif t == STRING:
1671                         # RE search
1672                         if node[k] is None or not v.search(node[k]):
1673                             break
1674                     elif t == OTHER:
1675                         # straight value comparison for the other types
1676                         if node[k] != v:
1677                             break
1678                 else:
1679                     l.append((nodeid, node))
1680         finally:
1681             cldb.close()
1682         l.sort()
1684         # filter based on full text search
1685         if search_matches is not None:
1686             k = []
1687             for v in l:
1688                 if search_matches.has_key(v[0]):
1689                     k.append(v)
1690             l = k
1692         # now, sort the result
1693         def sortfun(a, b, sort=sort, group=group, properties=self.getprops(),
1694                 db = self.db, cl=self):
1695             a_id, an = a
1696             b_id, bn = b
1697             # sort by group and then sort
1698             for dir, prop in group, sort:
1699                 if dir is None or prop is None: continue
1701                 # sorting is class-specific
1702                 propclass = properties[prop]
1704                 # handle the properties that might be "faked"
1705                 # also, handle possible missing properties
1706                 try:
1707                     if not an.has_key(prop):
1708                         an[prop] = cl.get(a_id, prop)
1709                     av = an[prop]
1710                 except KeyError:
1711                     # the node doesn't have a value for this property
1712                     if isinstance(propclass, Multilink): av = []
1713                     else: av = ''
1714                 try:
1715                     if not bn.has_key(prop):
1716                         bn[prop] = cl.get(b_id, prop)
1717                     bv = bn[prop]
1718                 except KeyError:
1719                     # the node doesn't have a value for this property
1720                     if isinstance(propclass, Multilink): bv = []
1721                     else: bv = ''
1723                 # String and Date values are sorted in the natural way
1724                 if isinstance(propclass, String):
1725                     # clean up the strings
1726                     if av and av[0] in string.uppercase:
1727                         av = av.lower()
1728                     if bv and bv[0] in string.uppercase:
1729                         bv = bv.lower()
1730                 if (isinstance(propclass, String) or
1731                         isinstance(propclass, Date)):
1732                     # it might be a string that's really an integer
1733                     try:
1734                         av = int(av)
1735                         bv = int(bv)
1736                     except:
1737                         pass
1738                     if dir == '+':
1739                         r = cmp(av, bv)
1740                         if r != 0: return r
1741                     elif dir == '-':
1742                         r = cmp(bv, av)
1743                         if r != 0: return r
1745                 # Link properties are sorted according to the value of
1746                 # the "order" property on the linked nodes if it is
1747                 # present; or otherwise on the key string of the linked
1748                 # nodes; or finally on  the node ids.
1749                 elif isinstance(propclass, Link):
1750                     link = db.classes[propclass.classname]
1751                     if av is None and bv is not None: return -1
1752                     if av is not None and bv is None: return 1
1753                     if av is None and bv is None: continue
1754                     if link.getprops().has_key('order'):
1755                         if dir == '+':
1756                             r = cmp(link.get(av, 'order'),
1757                                 link.get(bv, 'order'))
1758                             if r != 0: return r
1759                         elif dir == '-':
1760                             r = cmp(link.get(bv, 'order'),
1761                                 link.get(av, 'order'))
1762                             if r != 0: return r
1763                     elif link.getkey():
1764                         key = link.getkey()
1765                         if dir == '+':
1766                             r = cmp(link.get(av, key), link.get(bv, key))
1767                             if r != 0: return r
1768                         elif dir == '-':
1769                             r = cmp(link.get(bv, key), link.get(av, key))
1770                             if r != 0: return r
1771                     else:
1772                         if dir == '+':
1773                             r = cmp(av, bv)
1774                             if r != 0: return r
1775                         elif dir == '-':
1776                             r = cmp(bv, av)
1777                             if r != 0: return r
1779                 # Multilink properties are sorted according to how many
1780                 # links are present.
1781                 elif isinstance(propclass, Multilink):
1782                     r = cmp(len(av), len(bv))
1783                     if r == 0:
1784                         # Compare contents of multilink property if lenghts is
1785                         # equal
1786                         r = cmp ('.'.join(av), '.'.join(bv))
1787                     if dir == '+':
1788                         return r
1789                     elif dir == '-':
1790                         return -r
1791                 elif isinstance(propclass, Number) or isinstance(propclass, Boolean):
1792                     if dir == '+':
1793                         r = cmp(av, bv)
1794                     elif dir == '-':
1795                         r = cmp(bv, av)
1796                     
1797             # end for dir, prop in sort, group:
1798             # if all else fails, compare the ids
1799             return cmp(a[0], b[0])
1801         l.sort(sortfun)
1802         return [i[0] for i in l]
1804     def count(self):
1805         '''Get the number of nodes in this class.
1807         If the returned integer is 'numnodes', the ids of all the nodes
1808         in this class run from 1 to numnodes, and numnodes+1 will be the
1809         id of the next node to be created in this class.
1810         '''
1811         return self.db.countnodes(self.classname)
1813     # Manipulating properties:
1815     def getprops(self, protected=1):
1816         '''Return a dictionary mapping property names to property objects.
1817            If the "protected" flag is true, we include protected properties -
1818            those which may not be modified.
1820            In addition to the actual properties on the node, these
1821            methods provide the "creation" and "activity" properties. If the
1822            "protected" flag is true, we include protected properties - those
1823            which may not be modified.
1824         '''
1825         d = self.properties.copy()
1826         if protected:
1827             d['id'] = String()
1828             d['creation'] = hyperdb.Date()
1829             d['activity'] = hyperdb.Date()
1830             d['creator'] = hyperdb.Link('user')
1831         return d
1833     def addprop(self, **properties):
1834         '''Add properties to this class.
1836         The keyword arguments in 'properties' must map names to property
1837         objects, or a TypeError is raised.  None of the keys in 'properties'
1838         may collide with the names of existing properties, or a ValueError
1839         is raised before any properties have been added.
1840         '''
1841         for key in properties.keys():
1842             if self.properties.has_key(key):
1843                 raise ValueError, key
1844         self.properties.update(properties)
1846     def index(self, nodeid):
1847         '''Add (or refresh) the node to search indexes
1848         '''
1849         # find all the String properties that have indexme
1850         for prop, propclass in self.getprops().items():
1851             if isinstance(propclass, String) and propclass.indexme:
1852                 try:
1853                     value = str(self.get(nodeid, prop))
1854                 except IndexError:
1855                     # node no longer exists - entry should be removed
1856                     self.db.indexer.purge_entry((self.classname, nodeid, prop))
1857                 else:
1858                     # and index them under (classname, nodeid, property)
1859                     self.db.indexer.add_text((self.classname, nodeid, prop),
1860                         value)
1862     #
1863     # Detector interface
1864     #
1865     def audit(self, event, detector):
1866         '''Register a detector
1867         '''
1868         l = self.auditors[event]
1869         if detector not in l:
1870             self.auditors[event].append(detector)
1872     def fireAuditors(self, action, nodeid, newvalues):
1873         '''Fire all registered auditors.
1874         '''
1875         for audit in self.auditors[action]:
1876             audit(self.db, self, nodeid, newvalues)
1878     def react(self, event, detector):
1879         '''Register a detector
1880         '''
1881         l = self.reactors[event]
1882         if detector not in l:
1883             self.reactors[event].append(detector)
1885     def fireReactors(self, action, nodeid, oldvalues):
1886         '''Fire all registered reactors.
1887         '''
1888         for react in self.reactors[action]:
1889             react(self.db, self, nodeid, oldvalues)
1891 class FileClass(Class, hyperdb.FileClass):
1892     '''This class defines a large chunk of data. To support this, it has a
1893        mandatory String property "content" which is typically saved off
1894        externally to the hyperdb.
1896        The default MIME type of this data is defined by the
1897        "default_mime_type" class attribute, which may be overridden by each
1898        node if the class defines a "type" String property.
1899     '''
1900     default_mime_type = 'text/plain'
1902     def create(self, **propvalues):
1903         ''' Snarf the "content" propvalue and store in a file
1904         '''
1905         # we need to fire the auditors now, or the content property won't
1906         # be in propvalues for the auditors to play with
1907         self.fireAuditors('create', None, propvalues)
1909         # now remove the content property so it's not stored in the db
1910         content = propvalues['content']
1911         del propvalues['content']
1913         # do the database create
1914         newid = Class.create_inner(self, **propvalues)
1916         # fire reactors
1917         self.fireReactors('create', newid, None)
1919         # store off the content as a file
1920         self.db.storefile(self.classname, newid, None, content)
1921         return newid
1923     def import_list(self, propnames, proplist):
1924         ''' Trap the "content" property...
1925         '''
1926         # dupe this list so we don't affect others
1927         propnames = propnames[:]
1929         # extract the "content" property from the proplist
1930         i = propnames.index('content')
1931         content = eval(proplist[i])
1932         del propnames[i]
1933         del proplist[i]
1935         # do the normal import
1936         newid = Class.import_list(self, propnames, proplist)
1938         # save off the "content" file
1939         self.db.storefile(self.classname, newid, None, content)
1940         return newid
1942     def get(self, nodeid, propname, default=_marker, cache=1):
1943         ''' trap the content propname and get it from the file
1944         '''
1945         poss_msg = 'Possibly an access right configuration problem.'
1946         if propname == 'content':
1947             try:
1948                 return self.db.getfile(self.classname, nodeid, None)
1949             except IOError, (strerror):
1950                 # XXX by catching this we donot see an error in the log.
1951                 return 'ERROR reading file: %s%s\n%s\n%s'%(
1952                         self.classname, nodeid, poss_msg, strerror)
1953         if default is not _marker:
1954             return Class.get(self, nodeid, propname, default, cache=cache)
1955         else:
1956             return Class.get(self, nodeid, propname, cache=cache)
1958     def getprops(self, protected=1):
1959         ''' In addition to the actual properties on the node, these methods
1960             provide the "content" property. If the "protected" flag is true,
1961             we include protected properties - those which may not be
1962             modified.
1963         '''
1964         d = Class.getprops(self, protected=protected).copy()
1965         d['content'] = hyperdb.String()
1966         return d
1968     def index(self, nodeid):
1969         ''' Index the node in the search index.
1971             We want to index the content in addition to the normal String
1972             property indexing.
1973         '''
1974         # perform normal indexing
1975         Class.index(self, nodeid)
1977         # get the content to index
1978         content = self.get(nodeid, 'content')
1980         # figure the mime type
1981         if self.properties.has_key('type'):
1982             mime_type = self.get(nodeid, 'type')
1983         else:
1984             mime_type = self.default_mime_type
1986         # and index!
1987         self.db.indexer.add_text((self.classname, nodeid, 'content'), content,
1988             mime_type)
1990 # deviation from spec - was called ItemClass
1991 class IssueClass(Class, roundupdb.IssueClass):
1992     # Overridden methods:
1993     def __init__(self, db, classname, **properties):
1994         '''The newly-created class automatically includes the "messages",
1995         "files", "nosy", and "superseder" properties.  If the 'properties'
1996         dictionary attempts to specify any of these properties or a
1997         "creation" or "activity" property, a ValueError is raised.
1998         '''
1999         if not properties.has_key('title'):
2000             properties['title'] = hyperdb.String(indexme='yes')
2001         if not properties.has_key('messages'):
2002             properties['messages'] = hyperdb.Multilink("msg")
2003         if not properties.has_key('files'):
2004             properties['files'] = hyperdb.Multilink("file")
2005         if not properties.has_key('nosy'):
2006             # note: journalling is turned off as it really just wastes
2007             # space. this behaviour may be overridden in an instance
2008             properties['nosy'] = hyperdb.Multilink("user", do_journal="no")
2009         if not properties.has_key('superseder'):
2010             properties['superseder'] = hyperdb.Multilink(classname)
2011         Class.__init__(self, db, classname, **properties)