Code

Initial implementaion (half-baked) at new Tracker instance.
[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.124 2003-09-04 00:47:01 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
34 from roundup.date import Range
36 #
37 # Now the database
38 #
39 class Database(FileStorage, hyperdb.Database, roundupdb.Database):
40     '''A database for storing records containing flexible data types.
42     Transaction stuff TODO:
43         . check the timestamp of the class file and nuke the cache if it's
44           modified. Do some sort of conflict checking on the dirty stuff.
45         . perhaps detect write collisions (related to above)?
47     '''
48     def __init__(self, config, journaltag=None):
49         '''Open a hyperdatabase given a specifier to some storage.
51         The 'storagelocator' is obtained from config.DATABASE.
52         The meaning of 'storagelocator' depends on the particular
53         implementation of the hyperdatabase.  It could be a file name,
54         a directory path, a socket descriptor for a connection to a
55         database over the network, etc.
57         The 'journaltag' is a token that will be attached to the journal
58         entries for any edits done on the database.  If 'journaltag' is
59         None, the database is opened in read-only mode: the Class.create(),
60         Class.set(), Class.retire(), and Class.restore() methods are
61         disabled.  
62         '''        
63         self.config, self.journaltag = config, journaltag
64         self.dir = config.DATABASE
65         self.classes = {}
66         self.cache = {}         # cache of nodes loaded or created
67         self.dirtynodes = {}    # keep track of the dirty nodes by class
68         self.newnodes = {}      # keep track of the new nodes by class
69         self.destroyednodes = {}# keep track of the destroyed nodes by class
70         self.transactions = []
71         self.indexer = Indexer(self.dir)
72         self.sessions = Sessions(self.config)
73         self.otks = OneTimeKeys(self.config)
74         self.security = security.Security(self)
75         # ensure files are group readable and writable
76         os.umask(0002)
78         # lock it
79         lockfilenm = os.path.join(self.dir, 'lock')
80         self.lockfile = locking.acquire_lock(lockfilenm)
81         self.lockfile.write(str(os.getpid()))
82         self.lockfile.flush()
84     def post_init(self):
85         ''' Called once the schema initialisation has finished.
86         '''
87         # reindex the db if necessary
88         if self.indexer.should_reindex():
89             self.reindex()
91         # figure the "curuserid"
92         if self.journaltag is None:
93             self.curuserid = None
94         elif self.journaltag == 'admin':
95             # admin user may not exist, but always has ID 1
96             self.curuserid = '1'
97         else:
98             self.curuserid = self.user.lookup(self.journaltag)
100     def reindex(self):
101         for klass in self.classes.values():
102             for nodeid in klass.list():
103                 klass.index(nodeid)
104         self.indexer.save_index()
106     def __repr__(self):
107         return '<back_anydbm instance at %x>'%id(self) 
109     #
110     # Classes
111     #
112     def __getattr__(self, classname):
113         '''A convenient way of calling self.getclass(classname).'''
114         if self.classes.has_key(classname):
115             if __debug__:
116                 print >>hyperdb.DEBUG, '__getattr__', (self, classname)
117             return self.classes[classname]
118         raise AttributeError, classname
120     def addclass(self, cl):
121         if __debug__:
122             print >>hyperdb.DEBUG, 'addclass', (self, cl)
123         cn = cl.classname
124         if self.classes.has_key(cn):
125             raise ValueError, cn
126         self.classes[cn] = cl
128     def getclasses(self):
129         '''Return a list of the names of all existing classes.'''
130         if __debug__:
131             print >>hyperdb.DEBUG, 'getclasses', (self,)
132         l = self.classes.keys()
133         l.sort()
134         return l
136     def getclass(self, classname):
137         '''Get the Class object representing a particular class.
139         If 'classname' is not a valid class name, a KeyError is raised.
140         '''
141         if __debug__:
142             print >>hyperdb.DEBUG, 'getclass', (self, classname)
143         try:
144             return self.classes[classname]
145         except KeyError:
146             raise KeyError, 'There is no class called "%s"'%classname
148     #
149     # Class DBs
150     #
151     def clear(self):
152         '''Delete all database contents
153         '''
154         if __debug__:
155             print >>hyperdb.DEBUG, 'clear', (self,)
156         for cn in self.classes.keys():
157             for dummy in 'nodes', 'journals':
158                 path = os.path.join(self.dir, 'journals.%s'%cn)
159                 if os.path.exists(path):
160                     os.remove(path)
161                 elif os.path.exists(path+'.db'):    # dbm appends .db
162                     os.remove(path+'.db')
164     def getclassdb(self, classname, mode='r'):
165         ''' grab a connection to the class db that will be used for
166             multiple actions
167         '''
168         if __debug__:
169             print >>hyperdb.DEBUG, 'getclassdb', (self, classname, mode)
170         return self.opendb('nodes.%s'%classname, mode)
172     def determine_db_type(self, path):
173         ''' determine which DB wrote the class file
174         '''
175         db_type = ''
176         if os.path.exists(path):
177             db_type = whichdb.whichdb(path)
178             if not db_type:
179                 raise DatabaseError, "Couldn't identify database type"
180         elif os.path.exists(path+'.db'):
181             # if the path ends in '.db', it's a dbm database, whether
182             # anydbm says it's dbhash or not!
183             db_type = 'dbm'
184         return db_type
186     def opendb(self, name, mode):
187         '''Low-level database opener that gets around anydbm/dbm
188            eccentricities.
189         '''
190         if __debug__:
191             print >>hyperdb.DEBUG, 'opendb', (self, name, mode)
193         # figure the class db type
194         path = os.path.join(os.getcwd(), self.dir, name)
195         db_type = self.determine_db_type(path)
197         # new database? let anydbm pick the best dbm
198         if not db_type:
199             if __debug__:
200                 print >>hyperdb.DEBUG, "opendb anydbm.open(%r, 'c')"%path
201             return anydbm.open(path, 'c')
203         # open the database with the correct module
204         try:
205             dbm = __import__(db_type)
206         except ImportError:
207             raise DatabaseError, \
208                 "Couldn't open database - the required module '%s'"\
209                 " is not available"%db_type
210         if __debug__:
211             print >>hyperdb.DEBUG, "opendb %r.open(%r, %r)"%(db_type, path,
212                 mode)
213         return dbm.open(path, mode)
215     #
216     # Node IDs
217     #
218     def newid(self, classname):
219         ''' Generate a new id for the given class
220         '''
221         # open the ids DB - create if if doesn't exist
222         db = self.opendb('_ids', 'c')
223         if db.has_key(classname):
224             newid = db[classname] = str(int(db[classname]) + 1)
225         else:
226             # the count() bit is transitional - older dbs won't start at 1
227             newid = str(self.getclass(classname).count()+1)
228             db[classname] = newid
229         db.close()
230         return newid
232     def setid(self, classname, setid):
233         ''' Set the id counter: used during import of database
234         '''
235         # open the ids DB - create if if doesn't exist
236         db = self.opendb('_ids', 'c')
237         db[classname] = str(setid)
238         db.close()
240     #
241     # Nodes
242     #
243     def addnode(self, classname, nodeid, node):
244         ''' add the specified node to its class's db
245         '''
246         if __debug__:
247             print >>hyperdb.DEBUG, 'addnode', (self, classname, nodeid, node)
249         # we'll be supplied these props if we're doing an import
250         if not node.has_key('creator'):
251             # add in the "calculated" properties (dupe so we don't affect
252             # calling code's node assumptions)
253             node = node.copy()
254             node['creator'] = self.curuserid
255             node['creation'] = node['activity'] = date.Date()
257         self.newnodes.setdefault(classname, {})[nodeid] = 1
258         self.cache.setdefault(classname, {})[nodeid] = node
259         self.savenode(classname, nodeid, node)
261     def setnode(self, classname, nodeid, node):
262         ''' change the specified node
263         '''
264         if __debug__:
265             print >>hyperdb.DEBUG, 'setnode', (self, classname, nodeid, node)
266         self.dirtynodes.setdefault(classname, {})[nodeid] = 1
268         # update the activity time (dupe so we don't affect
269         # calling code's node assumptions)
270         node = node.copy()
271         node['activity'] = date.Date()
273         # can't set without having already loaded the node
274         self.cache[classname][nodeid] = node
275         self.savenode(classname, nodeid, node)
277     def savenode(self, classname, nodeid, node):
278         ''' perform the saving of data specified by the set/addnode
279         '''
280         if __debug__:
281             print >>hyperdb.DEBUG, 'savenode', (self, classname, nodeid, node)
282         self.transactions.append((self.doSaveNode, (classname, nodeid, node)))
284     def getnode(self, classname, nodeid, db=None, cache=1):
285         ''' get a node from the database
287             Note the "cache" parameter is not used, and exists purely for
288             backward compatibility!
289         '''
290         if __debug__:
291             print >>hyperdb.DEBUG, 'getnode', (self, classname, nodeid, db)
293         # try the cache
294         cache_dict = self.cache.setdefault(classname, {})
295         if cache_dict.has_key(nodeid):
296             if __debug__:
297                 print >>hyperdb.TRACE, 'get %s %s cached'%(classname,
298                     nodeid)
299             return cache_dict[nodeid]
301         if __debug__:
302             print >>hyperdb.TRACE, 'get %s %s'%(classname, nodeid)
304         # get from the database and save in the cache
305         if db is None:
306             db = self.getclassdb(classname)
307         if not db.has_key(nodeid):
308             # try the cache - might be a brand-new node
309             cache_dict = self.cache.setdefault(classname, {})
310             if cache_dict.has_key(nodeid):
311                 if __debug__:
312                     print >>hyperdb.TRACE, 'get %s %s cached'%(classname,
313                         nodeid)
314                 return cache_dict[nodeid]
315             raise IndexError, "no such %s %s"%(classname, nodeid)
317         # check the uncommitted, destroyed nodes
318         if (self.destroyednodes.has_key(classname) and
319                 self.destroyednodes[classname].has_key(nodeid)):
320             raise IndexError, "no such %s %s"%(classname, nodeid)
322         # decode
323         res = marshal.loads(db[nodeid])
325         # reverse the serialisation
326         res = self.unserialise(classname, res)
328         # store off in the cache dict
329         if cache:
330             cache_dict[nodeid] = res
332         return res
334     def destroynode(self, classname, nodeid):
335         '''Remove a node from the database. Called exclusively by the
336            destroy() method on Class.
337         '''
338         if __debug__:
339             print >>hyperdb.DEBUG, 'destroynode', (self, classname, nodeid)
341         # remove from cache and newnodes if it's there
342         if (self.cache.has_key(classname) and
343                 self.cache[classname].has_key(nodeid)):
344             del self.cache[classname][nodeid]
345         if (self.newnodes.has_key(classname) and
346                 self.newnodes[classname].has_key(nodeid)):
347             del self.newnodes[classname][nodeid]
349         # see if there's any obvious commit actions that we should get rid of
350         for entry in self.transactions[:]:
351             if entry[1][:2] == (classname, nodeid):
352                 self.transactions.remove(entry)
354         # add to the destroyednodes map
355         self.destroyednodes.setdefault(classname, {})[nodeid] = 1
357         # add the destroy commit action
358         self.transactions.append((self.doDestroyNode, (classname, nodeid)))
360     def serialise(self, classname, node):
361         '''Copy the node contents, converting non-marshallable data into
362            marshallable data.
363         '''
364         if __debug__:
365             print >>hyperdb.DEBUG, 'serialise', classname, node
366         properties = self.getclass(classname).getprops()
367         d = {}
368         for k, v in node.items():
369             # if the property doesn't exist, or is the "retired" flag then
370             # it won't be in the properties dict
371             if not properties.has_key(k):
372                 d[k] = v
373                 continue
375             # get the property spec
376             prop = properties[k]
378             if isinstance(prop, Password) and v is not None:
379                 d[k] = str(v)
380             elif isinstance(prop, Date) and v is not None:
381                 d[k] = v.serialise()
382             elif isinstance(prop, Interval) and v is not None:
383                 d[k] = v.serialise()
384             else:
385                 d[k] = v
386         return d
388     def unserialise(self, classname, node):
389         '''Decode the marshalled node data
390         '''
391         if __debug__:
392             print >>hyperdb.DEBUG, 'unserialise', classname, node
393         properties = self.getclass(classname).getprops()
394         d = {}
395         for k, v in node.items():
396             # if the property doesn't exist, or is the "retired" flag then
397             # it won't be in the properties dict
398             if not properties.has_key(k):
399                 d[k] = v
400                 continue
402             # get the property spec
403             prop = properties[k]
405             if isinstance(prop, Date) and v is not None:
406                 d[k] = date.Date(v)
407             elif isinstance(prop, Interval) and v is not None:
408                 d[k] = date.Interval(v)
409             elif isinstance(prop, Password) and v is not None:
410                 p = password.Password()
411                 p.unpack(v)
412                 d[k] = p
413             else:
414                 d[k] = v
415         return d
417     def hasnode(self, classname, nodeid, db=None):
418         ''' determine if the database has a given node
419         '''
420         if __debug__:
421             print >>hyperdb.DEBUG, 'hasnode', (self, classname, nodeid, db)
423         # try the cache
424         cache = self.cache.setdefault(classname, {})
425         if cache.has_key(nodeid):
426             if __debug__:
427                 print >>hyperdb.TRACE, 'has %s %s cached'%(classname, nodeid)
428             return 1
429         if __debug__:
430             print >>hyperdb.TRACE, 'has %s %s'%(classname, nodeid)
432         # not in the cache - check the database
433         if db is None:
434             db = self.getclassdb(classname)
435         res = db.has_key(nodeid)
436         return res
438     def countnodes(self, classname, db=None):
439         if __debug__:
440             print >>hyperdb.DEBUG, 'countnodes', (self, classname, db)
442         count = 0
444         # include the uncommitted nodes
445         if self.newnodes.has_key(classname):
446             count += len(self.newnodes[classname])
447         if self.destroyednodes.has_key(classname):
448             count -= len(self.destroyednodes[classname])
450         # and count those in the DB
451         if db is None:
452             db = self.getclassdb(classname)
453         count = count + len(db.keys())
454         return count
457     #
458     # Files - special node properties
459     # inherited from FileStorage
461     #
462     # Journal
463     #
464     def addjournal(self, classname, nodeid, action, params, creator=None,
465             creation=None):
466         ''' Journal the Action
467         'action' may be:
469             'create' or 'set' -- 'params' is a dictionary of property values
470             'link' or 'unlink' -- 'params' is (classname, nodeid, propname)
471             'retire' -- 'params' is None
472         '''
473         if __debug__:
474             print >>hyperdb.DEBUG, 'addjournal', (self, classname, nodeid,
475                 action, params, creator, creation)
476         self.transactions.append((self.doSaveJournal, (classname, nodeid,
477             action, params, creator, creation)))
479     def getjournal(self, classname, nodeid):
480         ''' get the journal for id
482             Raise IndexError if the node doesn't exist (as per history()'s
483             API)
484         '''
485         if __debug__:
486             print >>hyperdb.DEBUG, 'getjournal', (self, classname, nodeid)
488         # our journal result
489         res = []
491         # add any journal entries for transactions not committed to the
492         # database
493         for method, args in self.transactions:
494             if method != self.doSaveJournal:
495                 continue
496             (cache_classname, cache_nodeid, cache_action, cache_params,
497                 cache_creator, cache_creation) = args
498             if cache_classname == classname and cache_nodeid == nodeid:
499                 if not cache_creator:
500                     cache_creator = self.curuserid
501                 if not cache_creation:
502                     cache_creation = date.Date()
503                 res.append((cache_nodeid, cache_creation, cache_creator,
504                     cache_action, cache_params))
506         # attempt to open the journal - in some rare cases, the journal may
507         # not exist
508         try:
509             db = self.opendb('journals.%s'%classname, 'r')
510         except anydbm.error, error:
511             if str(error) == "need 'c' or 'n' flag to open new db":
512                 raise IndexError, 'no such %s %s'%(classname, nodeid)
513             elif error.args[0] != 2:
514                 raise
515             raise IndexError, 'no such %s %s'%(classname, nodeid)
516         try:
517             journal = marshal.loads(db[nodeid])
518         except KeyError:
519             db.close()
520             if res:
521                 # we have some unsaved journal entries, be happy!
522                 return res
523             raise IndexError, 'no such %s %s'%(classname, nodeid)
524         db.close()
526         # add all the saved journal entries for this node
527         for nodeid, date_stamp, user, action, params in journal:
528             res.append((nodeid, date.Date(date_stamp), user, action, params))
529         return res
531     def pack(self, pack_before):
532         ''' Delete all journal entries except "create" before 'pack_before'.
533         '''
534         if __debug__:
535             print >>hyperdb.DEBUG, 'packjournal', (self, pack_before)
537         pack_before = pack_before.serialise()
538         for classname in self.getclasses():
539             # get the journal db
540             db_name = 'journals.%s'%classname
541             path = os.path.join(os.getcwd(), self.dir, classname)
542             db_type = self.determine_db_type(path)
543             db = self.opendb(db_name, 'w')
545             for key in db.keys():
546                 # get the journal for this db entry
547                 journal = marshal.loads(db[key])
548                 l = []
549                 last_set_entry = None
550                 for entry in journal:
551                     # unpack the entry
552                     (nodeid, date_stamp, self.journaltag, action, 
553                         params) = entry
554                     # if the entry is after the pack date, _or_ the initial
555                     # create entry, then it stays
556                     if date_stamp > pack_before or action == 'create':
557                         l.append(entry)
558                 db[key] = marshal.dumps(l)
559             if db_type == 'gdbm':
560                 db.reorganize()
561             db.close()
562             
564     #
565     # Basic transaction support
566     #
567     def commit(self):
568         ''' Commit the current transactions.
569         '''
570         if __debug__:
571             print >>hyperdb.DEBUG, 'commit', (self,)
573         # keep a handle to all the database files opened
574         self.databases = {}
576         # now, do all the transactions
577         reindex = {}
578         for method, args in self.transactions:
579             reindex[method(*args)] = 1
581         # now close all the database files
582         for db in self.databases.values():
583             db.close()
584         del self.databases
586         # reindex the nodes that request it
587         for classname, nodeid in filter(None, reindex.keys()):
588             print >>hyperdb.DEBUG, 'commit.reindex', (classname, nodeid)
589             self.getclass(classname).index(nodeid)
591         # save the indexer state
592         self.indexer.save_index()
594         self.clearCache()
596     def clearCache(self):
597         # all transactions committed, back to normal
598         self.cache = {}
599         self.dirtynodes = {}
600         self.newnodes = {}
601         self.destroyednodes = {}
602         self.transactions = []
604     def getCachedClassDB(self, classname):
605         ''' get the class db, looking in our cache of databases for commit
606         '''
607         # get the database handle
608         db_name = 'nodes.%s'%classname
609         if not self.databases.has_key(db_name):
610             self.databases[db_name] = self.getclassdb(classname, 'c')
611         return self.databases[db_name]
613     def doSaveNode(self, classname, nodeid, node):
614         if __debug__:
615             print >>hyperdb.DEBUG, 'doSaveNode', (self, classname, nodeid,
616                 node)
618         db = self.getCachedClassDB(classname)
620         # now save the marshalled data
621         db[nodeid] = marshal.dumps(self.serialise(classname, node))
623         # return the classname, nodeid so we reindex this content
624         return (classname, nodeid)
626     def getCachedJournalDB(self, classname):
627         ''' get the journal db, looking in our cache of databases for commit
628         '''
629         # get the database handle
630         db_name = 'journals.%s'%classname
631         if not self.databases.has_key(db_name):
632             self.databases[db_name] = self.opendb(db_name, 'c')
633         return self.databases[db_name]
635     def doSaveJournal(self, classname, nodeid, action, params, creator,
636             creation):
637         # serialise the parameters now if necessary
638         if isinstance(params, type({})):
639             if action in ('set', 'create'):
640                 params = self.serialise(classname, params)
642         # handle supply of the special journalling parameters (usually
643         # supplied on importing an existing database)
644         if creator:
645             journaltag = creator
646         else:
647             journaltag = self.curuserid
648         if creation:
649             journaldate = creation.serialise()
650         else:
651             journaldate = date.Date().serialise()
653         # create the journal entry
654         entry = (nodeid, journaldate, journaltag, action, params)
656         if __debug__:
657             print >>hyperdb.DEBUG, 'doSaveJournal', entry
659         db = self.getCachedJournalDB(classname)
661         # now insert the journal entry
662         if db.has_key(nodeid):
663             # append to existing
664             s = db[nodeid]
665             l = marshal.loads(s)
666             l.append(entry)
667         else:
668             l = [entry]
670         db[nodeid] = marshal.dumps(l)
672     def doDestroyNode(self, classname, nodeid):
673         if __debug__:
674             print >>hyperdb.DEBUG, 'doDestroyNode', (self, classname, nodeid)
676         # delete from the class database
677         db = self.getCachedClassDB(classname)
678         if db.has_key(nodeid):
679             del db[nodeid]
681         # delete from the database
682         db = self.getCachedJournalDB(classname)
683         if db.has_key(nodeid):
684             del db[nodeid]
686         # return the classname, nodeid so we reindex this content
687         return (classname, nodeid)
689     def rollback(self):
690         ''' Reverse all actions from the current transaction.
691         '''
692         if __debug__:
693             print >>hyperdb.DEBUG, 'rollback', (self, )
694         for method, args in self.transactions:
695             # delete temporary files
696             if method == self.doStoreFile:
697                 self.rollbackStoreFile(*args)
698         self.cache = {}
699         self.dirtynodes = {}
700         self.newnodes = {}
701         self.destroyednodes = {}
702         self.transactions = []
704     def close(self):
705         ''' Nothing to do
706         '''
707         if self.lockfile is not None:
708             locking.release_lock(self.lockfile)
709         if self.lockfile is not None:
710             self.lockfile.close()
711             self.lockfile = None
713 _marker = []
714 class Class(hyperdb.Class):
715     '''The handle to a particular class of nodes in a hyperdatabase.'''
717     def __init__(self, db, classname, **properties):
718         '''Create a new class with a given name and property specification.
720         'classname' must not collide with the name of an existing class,
721         or a ValueError is raised.  The keyword arguments in 'properties'
722         must map names to property objects, or a TypeError is raised.
723         '''
724         if (properties.has_key('creation') or properties.has_key('activity')
725                 or properties.has_key('creator')):
726             raise ValueError, '"creation", "activity" and "creator" are '\
727                 'reserved'
729         self.classname = classname
730         self.properties = properties
731         self.db = weakref.proxy(db)       # use a weak ref to avoid circularity
732         self.key = ''
734         # should we journal changes (default yes)
735         self.do_journal = 1
737         # do the db-related init stuff
738         db.addclass(self)
740         self.auditors = {'create': [], 'set': [], 'retire': [], 'restore': []}
741         self.reactors = {'create': [], 'set': [], 'retire': [], 'restore': []}
743     def enableJournalling(self):
744         '''Turn journalling on for this class
745         '''
746         self.do_journal = 1
748     def disableJournalling(self):
749         '''Turn journalling off for this class
750         '''
751         self.do_journal = 0
753     # Editing nodes:
755     def create(self, **propvalues):
756         '''Create a new node of this class and return its id.
758         The keyword arguments in 'propvalues' map property names to values.
760         The values of arguments must be acceptable for the types of their
761         corresponding properties or a TypeError is raised.
762         
763         If this class has a key property, it must be present and its value
764         must not collide with other key strings or a ValueError is raised.
765         
766         Any other properties on this class that are missing from the
767         'propvalues' dictionary are set to None.
768         
769         If an id in a link or multilink property does not refer to a valid
770         node, an IndexError is raised.
772         These operations trigger detectors and can be vetoed.  Attempts
773         to modify the "creation" or "activity" properties cause a KeyError.
774         '''
775         self.fireAuditors('create', None, propvalues)
776         newid = self.create_inner(**propvalues)
777         self.fireReactors('create', newid, None)
778         return newid
780     def create_inner(self, **propvalues):
781         ''' Called by create, in-between the audit and react calls.
782         '''
783         if propvalues.has_key('id'):
784             raise KeyError, '"id" is reserved'
786         if self.db.journaltag is None:
787             raise DatabaseError, 'Database open read-only'
789         if propvalues.has_key('creation') or propvalues.has_key('activity'):
790             raise KeyError, '"creation" and "activity" are reserved'
791         # new node's id
792         newid = self.db.newid(self.classname)
794         # validate propvalues
795         num_re = re.compile('^\d+$')
796         for key, value in propvalues.items():
797             if key == self.key:
798                 try:
799                     self.lookup(value)
800                 except KeyError:
801                     pass
802                 else:
803                     raise ValueError, 'node with key "%s" exists'%value
805             # try to handle this property
806             try:
807                 prop = self.properties[key]
808             except KeyError:
809                 raise KeyError, '"%s" has no property "%s"'%(self.classname,
810                     key)
812             if value is not None and isinstance(prop, Link):
813                 if type(value) != type(''):
814                     raise ValueError, 'link value must be String'
815                 link_class = self.properties[key].classname
816                 # if it isn't a number, it's a key
817                 if not num_re.match(value):
818                     try:
819                         value = self.db.classes[link_class].lookup(value)
820                     except (TypeError, KeyError):
821                         raise IndexError, 'new property "%s": %s not a %s'%(
822                             key, value, link_class)
823                 elif not self.db.getclass(link_class).hasnode(value):
824                     raise IndexError, '%s has no node %s'%(link_class, value)
826                 # save off the value
827                 propvalues[key] = value
829                 # register the link with the newly linked node
830                 if self.do_journal and self.properties[key].do_journal:
831                     self.db.addjournal(link_class, value, 'link',
832                         (self.classname, newid, key))
834             elif isinstance(prop, Multilink):
835                 if type(value) != type([]):
836                     raise TypeError, 'new property "%s" not a list of ids'%key
838                 # clean up and validate the list of links
839                 link_class = self.properties[key].classname
840                 l = []
841                 for entry in value:
842                     if type(entry) != type(''):
843                         raise ValueError, '"%s" multilink value (%r) '\
844                             'must contain Strings'%(key, value)
845                     # if it isn't a number, it's a key
846                     if not num_re.match(entry):
847                         try:
848                             entry = self.db.classes[link_class].lookup(entry)
849                         except (TypeError, KeyError):
850                             raise IndexError, 'new property "%s": %s not a %s'%(
851                                 key, entry, self.properties[key].classname)
852                     l.append(entry)
853                 value = l
854                 propvalues[key] = value
856                 # handle additions
857                 for nodeid in value:
858                     if not self.db.getclass(link_class).hasnode(nodeid):
859                         raise IndexError, '%s has no node %s'%(link_class,
860                             nodeid)
861                     # register the link with the newly linked node
862                     if self.do_journal and self.properties[key].do_journal:
863                         self.db.addjournal(link_class, nodeid, 'link',
864                             (self.classname, newid, key))
866             elif isinstance(prop, String):
867                 if type(value) != type('') and type(value) != type(u''):
868                     raise TypeError, 'new property "%s" not a string'%key
870             elif isinstance(prop, Password):
871                 if not isinstance(value, password.Password):
872                     raise TypeError, 'new property "%s" not a Password'%key
874             elif isinstance(prop, Date):
875                 if value is not None and not isinstance(value, date.Date):
876                     raise TypeError, 'new property "%s" not a Date'%key
878             elif isinstance(prop, Interval):
879                 if value is not None and not isinstance(value, date.Interval):
880                     raise TypeError, 'new property "%s" not an Interval'%key
882             elif value is not None and isinstance(prop, Number):
883                 try:
884                     float(value)
885                 except ValueError:
886                     raise TypeError, 'new property "%s" not numeric'%key
888             elif value is not None and isinstance(prop, Boolean):
889                 try:
890                     int(value)
891                 except ValueError:
892                     raise TypeError, 'new property "%s" not boolean'%key
894         # make sure there's data where there needs to be
895         for key, prop in self.properties.items():
896             if propvalues.has_key(key):
897                 continue
898             if key == self.key:
899                 raise ValueError, 'key property "%s" is required'%key
900             if isinstance(prop, Multilink):
901                 propvalues[key] = []
902             else:
903                 propvalues[key] = None
905         # done
906         self.db.addnode(self.classname, newid, propvalues)
907         if self.do_journal:
908             self.db.addjournal(self.classname, newid, 'create', {})
910         return newid
912     def export_list(self, propnames, nodeid):
913         ''' Export a node - generate a list of CSV-able data in the order
914             specified by propnames for the given node.
915         '''
916         properties = self.getprops()
917         l = []
918         for prop in propnames:
919             proptype = properties[prop]
920             value = self.get(nodeid, prop)
921             # "marshal" data where needed
922             if value is None:
923                 pass
924             elif isinstance(proptype, hyperdb.Date):
925                 value = value.get_tuple()
926             elif isinstance(proptype, hyperdb.Interval):
927                 value = value.get_tuple()
928             elif isinstance(proptype, hyperdb.Password):
929                 value = str(value)
930             l.append(repr(value))
932         # append retired flag
933         l.append(self.is_retired(nodeid))
935         return l
937     def import_list(self, propnames, proplist):
938         ''' Import a node - all information including "id" is present and
939             should not be sanity checked. Triggers are not triggered. The
940             journal should be initialised using the "creator" and "created"
941             information.
943             Return the nodeid of the node imported.
944         '''
945         if self.db.journaltag is None:
946             raise DatabaseError, 'Database open read-only'
947         properties = self.getprops()
949         # make the new node's property map
950         d = {}
951         newid = None
952         for i in range(len(propnames)):
953             # Figure the property for this column
954             propname = propnames[i]
956             # Use eval to reverse the repr() used to output the CSV
957             value = eval(proplist[i])
959             # "unmarshal" where necessary
960             if propname == 'id':
961                 newid = value
962                 continue
963             elif propname == 'is retired':
964                 # is the item retired?
965                 if int(value):
966                     d[self.db.RETIRED_FLAG] = 1
967                 continue
968             elif value is None:
969                 d[propname] = None
970                 continue
972             prop = properties[propname]
973             if isinstance(prop, hyperdb.Date):
974                 value = date.Date(value)
975             elif isinstance(prop, hyperdb.Interval):
976                 value = date.Interval(value)
977             elif isinstance(prop, hyperdb.Password):
978                 pwd = password.Password()
979                 pwd.unpack(value)
980                 value = pwd
981             d[propname] = value
983         # get a new id if necessary
984         if newid is None:
985             newid = self.db.newid(self.classname)
987         # add the node and journal
988         self.db.addnode(self.classname, newid, d)
990         # extract the journalling stuff and nuke it
991         if d.has_key('creator'):
992             creator = d['creator']
993             del d['creator']
994         else:
995             creator = None
996         if d.has_key('creation'):
997             creation = d['creation']
998             del d['creation']
999         else:
1000             creation = None
1001         if d.has_key('activity'):
1002             del d['activity']
1003         self.db.addjournal(self.classname, newid, 'create', {}, creator,
1004             creation)
1005         return newid
1007     def get(self, nodeid, propname, default=_marker, cache=1):
1008         '''Get the value of a property on an existing node of this class.
1010         'nodeid' must be the id of an existing node of this class or an
1011         IndexError is raised.  'propname' must be the name of a property
1012         of this class or a KeyError is raised.
1014         'cache' exists for backward compatibility, and is not used.
1016         Attempts to get the "creation" or "activity" properties should
1017         do the right thing.
1018         '''
1019         if propname == 'id':
1020             return nodeid
1022         # get the node's dict
1023         d = self.db.getnode(self.classname, nodeid)
1025         # check for one of the special props
1026         if propname == 'creation':
1027             if d.has_key('creation'):
1028                 return d['creation']
1029             if not self.do_journal:
1030                 raise ValueError, 'Journalling is disabled for this class'
1031             journal = self.db.getjournal(self.classname, nodeid)
1032             if journal:
1033                 return self.db.getjournal(self.classname, nodeid)[0][1]
1034             else:
1035                 # on the strange chance that there's no journal
1036                 return date.Date()
1037         if propname == 'activity':
1038             if d.has_key('activity'):
1039                 return d['activity']
1040             if not self.do_journal:
1041                 raise ValueError, 'Journalling is disabled for this class'
1042             journal = self.db.getjournal(self.classname, nodeid)
1043             if journal:
1044                 return self.db.getjournal(self.classname, nodeid)[-1][1]
1045             else:
1046                 # on the strange chance that there's no journal
1047                 return date.Date()
1048         if propname == 'creator':
1049             if d.has_key('creator'):
1050                 return d['creator']
1051             if not self.do_journal:
1052                 raise ValueError, 'Journalling is disabled for this class'
1053             journal = self.db.getjournal(self.classname, nodeid)
1054             if journal:
1055                 num_re = re.compile('^\d+$')
1056                 value = self.db.getjournal(self.classname, nodeid)[0][2]
1057                 if num_re.match(value):
1058                     return value
1059                 else:
1060                     # old-style "username" journal tag
1061                     try:
1062                         return self.db.user.lookup(value)
1063                     except KeyError:
1064                         # user's been retired, return admin
1065                         return '1'
1066             else:
1067                 return self.db.curuserid
1069         # get the property (raises KeyErorr if invalid)
1070         prop = self.properties[propname]
1072         if not d.has_key(propname):
1073             if default is _marker:
1074                 if isinstance(prop, Multilink):
1075                     return []
1076                 else:
1077                     return None
1078             else:
1079                 return default
1081         # return a dupe of the list so code doesn't get confused
1082         if isinstance(prop, Multilink):
1083             return d[propname][:]
1085         return d[propname]
1087     # not in spec
1088     def getnode(self, nodeid, cache=1):
1089         ''' Return a convenience wrapper for the node.
1091         'nodeid' must be the id of an existing node of this class or an
1092         IndexError is raised.
1094         'cache' exists for backwards compatibility, and is not used.
1095         '''
1096         return Node(self, nodeid)
1098     def set(self, nodeid, **propvalues):
1099         '''Modify a property on an existing node of this class.
1100         
1101         'nodeid' must be the id of an existing node of this class or an
1102         IndexError is raised.
1104         Each key in 'propvalues' must be the name of a property of this
1105         class or a KeyError is raised.
1107         All values in 'propvalues' must be acceptable types for their
1108         corresponding properties or a TypeError is raised.
1110         If the value of the key property is set, it must not collide with
1111         other key strings or a ValueError is raised.
1113         If the value of a Link or Multilink property contains an invalid
1114         node id, a ValueError is raised.
1116         These operations trigger detectors and can be vetoed.  Attempts
1117         to modify the "creation" or "activity" properties cause a KeyError.
1118         '''
1119         if not propvalues:
1120             return propvalues
1122         if propvalues.has_key('creation') or propvalues.has_key('activity'):
1123             raise KeyError, '"creation" and "activity" are reserved'
1125         if propvalues.has_key('id'):
1126             raise KeyError, '"id" is reserved'
1128         if self.db.journaltag is None:
1129             raise DatabaseError, 'Database open read-only'
1131         self.fireAuditors('set', nodeid, propvalues)
1132         # Take a copy of the node dict so that the subsequent set
1133         # operation doesn't modify the oldvalues structure.
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 restore(self, nodeid):
1337         '''Restpre a retired node.
1339         Make node available for all operations like it was before retirement.
1340         '''
1341         if self.db.journaltag is None:
1342             raise DatabaseError, 'Database open read-only'
1344         node = self.db.getnode(self.classname, nodeid)
1345         # check if key property was overrided
1346         key = self.getkey()
1347         try:
1348             id = self.lookup(node[key])
1349         except KeyError:
1350             pass
1351         else:
1352             raise KeyError, "Key property (%s) of retired node clashes with \
1353                 existing one (%s)" % (key, node[key])
1354         # Now we can safely restore node
1355         self.fireAuditors('restore', nodeid, None)
1356         del node[self.db.RETIRED_FLAG]
1357         self.db.setnode(self.classname, nodeid, node)
1358         if self.do_journal:
1359             self.db.addjournal(self.classname, nodeid, 'restored', None)
1361         self.fireReactors('restore', nodeid, None)
1363     def is_retired(self, nodeid, cldb=None):
1364         '''Return true if the node is retired.
1365         '''
1366         node = self.db.getnode(self.classname, nodeid, cldb)
1367         if node.has_key(self.db.RETIRED_FLAG):
1368             return 1
1369         return 0
1371     def destroy(self, nodeid):
1372         '''Destroy a node.
1374         WARNING: this method should never be used except in extremely rare
1375                  situations where there could never be links to the node being
1376                  deleted
1377         WARNING: use retire() instead
1378         WARNING: the properties of this node will not be available ever again
1379         WARNING: really, use retire() instead
1381         Well, I think that's enough warnings. This method exists mostly to
1382         support the session storage of the cgi interface.
1383         '''
1384         if self.db.journaltag is None:
1385             raise DatabaseError, 'Database open read-only'
1386         self.db.destroynode(self.classname, nodeid)
1388     def history(self, nodeid):
1389         '''Retrieve the journal of edits on a particular node.
1391         'nodeid' must be the id of an existing node of this class or an
1392         IndexError is raised.
1394         The returned list contains tuples of the form
1396             (nodeid, date, tag, action, params)
1398         'date' is a Timestamp object specifying the time of the change and
1399         'tag' is the journaltag specified when the database was opened.
1400         '''
1401         if not self.do_journal:
1402             raise ValueError, 'Journalling is disabled for this class'
1403         return self.db.getjournal(self.classname, nodeid)
1405     # Locating nodes:
1406     def hasnode(self, nodeid):
1407         '''Determine if the given nodeid actually exists
1408         '''
1409         return self.db.hasnode(self.classname, nodeid)
1411     def setkey(self, propname):
1412         '''Select a String property of this class to be the key property.
1414         'propname' must be the name of a String property of this class or
1415         None, or a TypeError is raised.  The values of the key property on
1416         all existing nodes must be unique or a ValueError is raised. If the
1417         property doesn't exist, KeyError is raised.
1418         '''
1419         prop = self.getprops()[propname]
1420         if not isinstance(prop, String):
1421             raise TypeError, 'key properties must be String'
1422         self.key = propname
1424     def getkey(self):
1425         '''Return the name of the key property for this class or None.'''
1426         return self.key
1428     def labelprop(self, default_to_id=0):
1429         ''' Return the property name for a label for the given node.
1431         This method attempts to generate a consistent label for the node.
1432         It tries the following in order:
1433             1. key property
1434             2. "name" property
1435             3. "title" property
1436             4. first property from the sorted property name list
1437         '''
1438         k = self.getkey()
1439         if  k:
1440             return k
1441         props = self.getprops()
1442         if props.has_key('name'):
1443             return 'name'
1444         elif props.has_key('title'):
1445             return 'title'
1446         if default_to_id:
1447             return 'id'
1448         props = props.keys()
1449         props.sort()
1450         return props[0]
1452     # TODO: set up a separate index db file for this? profile?
1453     def lookup(self, keyvalue):
1454         '''Locate a particular node by its key property and return its id.
1456         If this class has no key property, a TypeError is raised.  If the
1457         'keyvalue' matches one of the values for the key property among
1458         the nodes in this class, the matching node's id is returned;
1459         otherwise a KeyError is raised.
1460         '''
1461         if not self.key:
1462             raise TypeError, 'No key property set for class %s'%self.classname
1463         cldb = self.db.getclassdb(self.classname)
1464         try:
1465             for nodeid in self.getnodeids(cldb):
1466                 node = self.db.getnode(self.classname, nodeid, cldb)
1467                 if node.has_key(self.db.RETIRED_FLAG):
1468                     continue
1469                 if node[self.key] == keyvalue:
1470                     return nodeid
1471         finally:
1472             cldb.close()
1473         raise KeyError, 'No key (%s) value "%s" for "%s"'%(self.key,
1474             keyvalue, self.classname)
1476     # change from spec - allows multiple props to match
1477     def find(self, **propspec):
1478         '''Get the ids of items in this class which link to the given items.
1480         'propspec' consists of keyword args propname=itemid or
1481                    propname={itemid:1, }
1482         'propname' must be the name of a property in this class, or a
1483                    KeyError is raised.  That property must be a Link or
1484                    Multilink property, or a TypeError is raised.
1486         Any item in this class whose 'propname' property links to any of the
1487         itemids will be returned. Used by the full text indexing, which knows
1488         that "foo" occurs in msg1, msg3 and file7, so we have hits on these
1489         issues:
1491             db.issue.find(messages={'1':1,'3':1}, files={'7':1})
1492         '''
1493         propspec = propspec.items()
1494         for propname, itemids in propspec:
1495             # check the prop is OK
1496             prop = self.properties[propname]
1497             if not isinstance(prop, Link) and not isinstance(prop, Multilink):
1498                 raise TypeError, "'%s' not a Link/Multilink property"%propname
1500         # ok, now do the find
1501         cldb = self.db.getclassdb(self.classname)
1502         l = []
1503         try:
1504             for id in self.getnodeids(db=cldb):
1505                 item = self.db.getnode(self.classname, id, db=cldb)
1506                 if item.has_key(self.db.RETIRED_FLAG):
1507                     continue
1508                 for propname, itemids in propspec:
1509                     # can't test if the item doesn't have this property
1510                     if not item.has_key(propname):
1511                         continue
1512                     if type(itemids) is not type({}):
1513                         itemids = {itemids:1}
1515                     # grab the property definition and its value on this item
1516                     prop = self.properties[propname]
1517                     value = item[propname]
1518                     if isinstance(prop, Link) and itemids.has_key(value):
1519                         l.append(id)
1520                         break
1521                     elif isinstance(prop, Multilink):
1522                         hit = 0
1523                         for v in value:
1524                             if itemids.has_key(v):
1525                                 l.append(id)
1526                                 hit = 1
1527                                 break
1528                         if hit:
1529                             break
1530         finally:
1531             cldb.close()
1532         return l
1534     def stringFind(self, **requirements):
1535         '''Locate a particular node by matching a set of its String
1536         properties in a caseless search.
1538         If the property is not a String property, a TypeError is raised.
1539         
1540         The return is a list of the id of all nodes that match.
1541         '''
1542         for propname in requirements.keys():
1543             prop = self.properties[propname]
1544             if isinstance(not prop, String):
1545                 raise TypeError, "'%s' not a String property"%propname
1546             requirements[propname] = requirements[propname].lower()
1547         l = []
1548         cldb = self.db.getclassdb(self.classname)
1549         try:
1550             for nodeid in self.getnodeids(cldb):
1551                 node = self.db.getnode(self.classname, nodeid, cldb)
1552                 if node.has_key(self.db.RETIRED_FLAG):
1553                     continue
1554                 for key, value in requirements.items():
1555                     if not node.has_key(key):
1556                         break
1557                     if node[key] is None or node[key].lower() != value:
1558                         break
1559                 else:
1560                     l.append(nodeid)
1561         finally:
1562             cldb.close()
1563         return l
1565     def list(self):
1566         ''' Return a list of the ids of the active nodes in this class.
1567         '''
1568         l = []
1569         cn = self.classname
1570         cldb = self.db.getclassdb(cn)
1571         try:
1572             for nodeid in self.getnodeids(cldb):
1573                 node = self.db.getnode(cn, nodeid, cldb)
1574                 if node.has_key(self.db.RETIRED_FLAG):
1575                     continue
1576                 l.append(nodeid)
1577         finally:
1578             cldb.close()
1579         l.sort()
1580         return l
1582     def getnodeids(self, db=None):
1583         ''' Return a list of ALL nodeids
1584         '''
1585         if __debug__:
1586             print >>hyperdb.DEBUG, 'getnodeids', (self, self.classname, db)
1588         res = []
1590         # start off with the new nodes
1591         if self.db.newnodes.has_key(self.classname):
1592             res += self.db.newnodes[self.classname].keys()
1594         if db is None:
1595             db = self.db.getclassdb(self.classname)
1596         res = res + db.keys()
1598         # remove the uncommitted, destroyed nodes
1599         if self.db.destroyednodes.has_key(self.classname):
1600             for nodeid in self.db.destroyednodes[self.classname].keys():
1601                 if db.has_key(nodeid):
1602                     res.remove(nodeid)
1604         return res
1606     def filter(self, search_matches, filterspec, sort=(None,None),
1607             group=(None,None), num_re = re.compile('^\d+$')):
1608         ''' Return a list of the ids of the active nodes in this class that
1609             match the 'filter' spec, sorted by the group spec and then the
1610             sort spec.
1612             "filterspec" is {propname: value(s)}
1613             "sort" and "group" are (dir, prop) where dir is '+', '-' or None
1614                                and prop is a prop name or None
1615             "search_matches" is {nodeid: marker}
1617             The filter must match all properties specificed - but if the
1618             property value to match is a list, any one of the values in the
1619             list may match for that property to match. Unless the property
1620             is a Multilink, in which case the item's property list must
1621             match the filterspec list.
1622         '''
1623         cn = self.classname
1625         # optimise filterspec
1626         l = []
1627         props = self.getprops()
1628         LINK = 0
1629         MULTILINK = 1
1630         STRING = 2
1631         DATE = 3
1632         INTERVAL = 4
1633         OTHER = 6
1634         
1635         timezone = self.db.getUserTimezone()
1636         for k, v in filterspec.items():
1637             propclass = props[k]
1638             if isinstance(propclass, Link):
1639                 if type(v) is not type([]):
1640                     v = [v]
1641                 # replace key values with node ids
1642                 u = []
1643                 link_class =  self.db.classes[propclass.classname]
1644                 for entry in v:
1645                     # the value -1 is a special "not set" sentinel
1646                     if entry == '-1':
1647                         entry = None
1648                     elif not num_re.match(entry):
1649                         try:
1650                             entry = link_class.lookup(entry)
1651                         except (TypeError,KeyError):
1652                             raise ValueError, 'property "%s": %s not a %s'%(
1653                                 k, entry, self.properties[k].classname)
1654                     u.append(entry)
1656                 l.append((LINK, k, u))
1657             elif isinstance(propclass, Multilink):
1658                 # the value -1 is a special "not set" sentinel
1659                 if v in ('-1', ['-1']):
1660                     v = []
1661                 elif type(v) is not type([]):
1662                     v = [v]
1664                 # replace key values with node ids
1665                 u = []
1666                 link_class =  self.db.classes[propclass.classname]
1667                 for entry in v:
1668                     if not num_re.match(entry):
1669                         try:
1670                             entry = link_class.lookup(entry)
1671                         except (TypeError,KeyError):
1672                             raise ValueError, 'new property "%s": %s not a %s'%(
1673                                 k, entry, self.properties[k].classname)
1674                     u.append(entry)
1675                 u.sort()
1676                 l.append((MULTILINK, k, u))
1677             elif isinstance(propclass, String) and k != 'id':
1678                 if type(v) is not type([]):
1679                     v = [v]
1680                 m = []
1681                 for v in v:
1682                     # simple glob searching
1683                     v = re.sub(r'([\|\{\}\\\.\+\[\]\(\)])', r'\\\1', v)
1684                     v = v.replace('?', '.')
1685                     v = v.replace('*', '.*?')
1686                     m.append(v)
1687                 m = re.compile('(%s)'%('|'.join(m)), re.I)
1688                 l.append((STRING, k, m))
1689             elif isinstance(propclass, Date):
1690                 try:
1691                     date_rng = Range(v, date.Date, offset=timezone)
1692                     l.append((DATE, k, date_rng))
1693                 except ValueError:
1694                     # If range creation fails - ignore that search parameter
1695                     pass
1696             elif isinstance(propclass, Interval):
1697                 try:
1698                     intv_rng = Range(v, date.Interval)
1699                     l.append((INTERVAL, k, intv_rng))
1700                 except ValueError:
1701                     # If range creation fails - ignore that search parameter
1702                     pass
1703                 
1704             elif isinstance(propclass, Boolean):
1705                 if type(v) is type(''):
1706                     bv = v.lower() in ('yes', 'true', 'on', '1')
1707                 else:
1708                     bv = v
1709                 l.append((OTHER, k, bv))
1710             elif isinstance(propclass, Number):
1711                 l.append((OTHER, k, int(v)))
1712             else:
1713                 l.append((OTHER, k, v))
1714         filterspec = l
1716         # now, find all the nodes that are active and pass filtering
1717         l = []
1718         cldb = self.db.getclassdb(cn)
1719         try:
1720             # TODO: only full-scan once (use items())
1721             for nodeid in self.getnodeids(cldb):
1722                 node = self.db.getnode(cn, nodeid, cldb)
1723                 if node.has_key(self.db.RETIRED_FLAG):
1724                     continue
1725                 # apply filter
1726                 for t, k, v in filterspec:
1727                     # handle the id prop
1728                     if k == 'id' and v == nodeid:
1729                         continue
1731                     # make sure the node has the property
1732                     if not node.has_key(k):
1733                         # this node doesn't have this property, so reject it
1734                         break
1736                     # now apply the property filter
1737                     if t == LINK:
1738                         # link - if this node's property doesn't appear in the
1739                         # filterspec's nodeid list, skip it
1740                         if node[k] not in v:
1741                             break
1742                     elif t == MULTILINK:
1743                         # multilink - if any of the nodeids required by the
1744                         # filterspec aren't in this node's property, then skip
1745                         # it
1746                         have = node[k]
1747                         # check for matching the absence of multilink values
1748                         if not v and have:
1749                             break
1751                         # othewise, make sure this node has each of the
1752                         # required values
1753                         for want in v:
1754                             if want not in have:
1755                                 break
1756                         else:
1757                             continue
1758                         break
1759                     elif t == STRING:
1760                         if node[k] is None:
1761                             break
1762                         # RE search
1763                         if not v.search(node[k]):
1764                             break
1765                     elif t == DATE or t == INTERVAL:
1766                         if node[k] is None:
1767                             break
1768                         if v.to_value:
1769                             if not (v.from_value <= node[k] and v.to_value >= node[k]):
1770                                 break
1771                         else:
1772                             if not (v.from_value <= node[k]):
1773                                 break
1774                     elif t == OTHER:
1775                         # straight value comparison for the other types
1776                         if node[k] != v:
1777                             break
1778                 else:
1779                     l.append((nodeid, node))
1780         finally:
1781             cldb.close()
1782         l.sort()
1784         # filter based on full text search
1785         if search_matches is not None:
1786             k = []
1787             for v in l:
1788                 if search_matches.has_key(v[0]):
1789                     k.append(v)
1790             l = k
1792         # now, sort the result
1793         def sortfun(a, b, sort=sort, group=group, properties=self.getprops(),
1794                 db = self.db, cl=self):
1795             a_id, an = a
1796             b_id, bn = b
1797             # sort by group and then sort
1798             for dir, prop in group, sort:
1799                 if dir is None or prop is None: continue
1801                 # sorting is class-specific
1802                 propclass = properties[prop]
1804                 # handle the properties that might be "faked"
1805                 # also, handle possible missing properties
1806                 try:
1807                     if not an.has_key(prop):
1808                         an[prop] = cl.get(a_id, prop)
1809                     av = an[prop]
1810                 except KeyError:
1811                     # the node doesn't have a value for this property
1812                     if isinstance(propclass, Multilink): av = []
1813                     else: av = ''
1814                 try:
1815                     if not bn.has_key(prop):
1816                         bn[prop] = cl.get(b_id, prop)
1817                     bv = bn[prop]
1818                 except KeyError:
1819                     # the node doesn't have a value for this property
1820                     if isinstance(propclass, Multilink): bv = []
1821                     else: bv = ''
1823                 # String and Date values are sorted in the natural way
1824                 if isinstance(propclass, String):
1825                     # clean up the strings
1826                     if av and av[0] in string.uppercase:
1827                         av = av.lower()
1828                     if bv and bv[0] in string.uppercase:
1829                         bv = bv.lower()
1830                 if (isinstance(propclass, String) or
1831                         isinstance(propclass, Date)):
1832                     # it might be a string that's really an integer
1833                     try:
1834                         av = int(av)
1835                         bv = int(bv)
1836                     except:
1837                         pass
1838                     if dir == '+':
1839                         r = cmp(av, bv)
1840                         if r != 0: return r
1841                     elif dir == '-':
1842                         r = cmp(bv, av)
1843                         if r != 0: return r
1845                 # Link properties are sorted according to the value of
1846                 # the "order" property on the linked nodes if it is
1847                 # present; or otherwise on the key string of the linked
1848                 # nodes; or finally on  the node ids.
1849                 elif isinstance(propclass, Link):
1850                     link = db.classes[propclass.classname]
1851                     if av is None and bv is not None: return -1
1852                     if av is not None and bv is None: return 1
1853                     if av is None and bv is None: continue
1854                     if link.getprops().has_key('order'):
1855                         if dir == '+':
1856                             r = cmp(link.get(av, 'order'),
1857                                 link.get(bv, 'order'))
1858                             if r != 0: return r
1859                         elif dir == '-':
1860                             r = cmp(link.get(bv, 'order'),
1861                                 link.get(av, 'order'))
1862                             if r != 0: return r
1863                     elif link.getkey():
1864                         key = link.getkey()
1865                         if dir == '+':
1866                             r = cmp(link.get(av, key), link.get(bv, key))
1867                             if r != 0: return r
1868                         elif dir == '-':
1869                             r = cmp(link.get(bv, key), link.get(av, key))
1870                             if r != 0: return r
1871                     else:
1872                         if dir == '+':
1873                             r = cmp(av, bv)
1874                             if r != 0: return r
1875                         elif dir == '-':
1876                             r = cmp(bv, av)
1877                             if r != 0: return r
1879                 else:
1880                     # all other types just compare
1881                     if dir == '+':
1882                         r = cmp(av, bv)
1883                     elif dir == '-':
1884                         r = cmp(bv, av)
1885                     if r != 0: return r
1886                     
1887             # end for dir, prop in sort, group:
1888             # if all else fails, compare the ids
1889             return cmp(a[0], b[0])
1891         l.sort(sortfun)
1892         return [i[0] for i in l]
1894     def count(self):
1895         '''Get the number of nodes in this class.
1897         If the returned integer is 'numnodes', the ids of all the nodes
1898         in this class run from 1 to numnodes, and numnodes+1 will be the
1899         id of the next node to be created in this class.
1900         '''
1901         return self.db.countnodes(self.classname)
1903     # Manipulating properties:
1905     def getprops(self, protected=1):
1906         '''Return a dictionary mapping property names to property objects.
1907            If the "protected" flag is true, we include protected properties -
1908            those which may not be modified.
1910            In addition to the actual properties on the node, these
1911            methods provide the "creation" and "activity" properties. If the
1912            "protected" flag is true, we include protected properties - those
1913            which may not be modified.
1914         '''
1915         d = self.properties.copy()
1916         if protected:
1917             d['id'] = String()
1918             d['creation'] = hyperdb.Date()
1919             d['activity'] = hyperdb.Date()
1920             d['creator'] = hyperdb.Link('user')
1921         return d
1923     def addprop(self, **properties):
1924         '''Add properties to this class.
1926         The keyword arguments in 'properties' must map names to property
1927         objects, or a TypeError is raised.  None of the keys in 'properties'
1928         may collide with the names of existing properties, or a ValueError
1929         is raised before any properties have been added.
1930         '''
1931         for key in properties.keys():
1932             if self.properties.has_key(key):
1933                 raise ValueError, key
1934         self.properties.update(properties)
1936     def index(self, nodeid):
1937         '''Add (or refresh) the node to search indexes
1938         '''
1939         # find all the String properties that have indexme
1940         for prop, propclass in self.getprops().items():
1941             if isinstance(propclass, String) and propclass.indexme:
1942                 try:
1943                     value = str(self.get(nodeid, prop))
1944                 except IndexError:
1945                     # node no longer exists - entry should be removed
1946                     self.db.indexer.purge_entry((self.classname, nodeid, prop))
1947                 else:
1948                     # and index them under (classname, nodeid, property)
1949                     self.db.indexer.add_text((self.classname, nodeid, prop),
1950                         value)
1952     #
1953     # Detector interface
1954     #
1955     def audit(self, event, detector):
1956         '''Register a detector
1957         '''
1958         l = self.auditors[event]
1959         if detector not in l:
1960             self.auditors[event].append(detector)
1962     def fireAuditors(self, action, nodeid, newvalues):
1963         '''Fire all registered auditors.
1964         '''
1965         for audit in self.auditors[action]:
1966             audit(self.db, self, nodeid, newvalues)
1968     def react(self, event, detector):
1969         '''Register a detector
1970         '''
1971         l = self.reactors[event]
1972         if detector not in l:
1973             self.reactors[event].append(detector)
1975     def fireReactors(self, action, nodeid, oldvalues):
1976         '''Fire all registered reactors.
1977         '''
1978         for react in self.reactors[action]:
1979             react(self.db, self, nodeid, oldvalues)
1981 class FileClass(Class, hyperdb.FileClass):
1982     '''This class defines a large chunk of data. To support this, it has a
1983        mandatory String property "content" which is typically saved off
1984        externally to the hyperdb.
1986        The default MIME type of this data is defined by the
1987        "default_mime_type" class attribute, which may be overridden by each
1988        node if the class defines a "type" String property.
1989     '''
1990     default_mime_type = 'text/plain'
1992     def create(self, **propvalues):
1993         ''' Snarf the "content" propvalue and store in a file
1994         '''
1995         # we need to fire the auditors now, or the content property won't
1996         # be in propvalues for the auditors to play with
1997         self.fireAuditors('create', None, propvalues)
1999         # now remove the content property so it's not stored in the db
2000         content = propvalues['content']
2001         del propvalues['content']
2003         # do the database create
2004         newid = Class.create_inner(self, **propvalues)
2006         # fire reactors
2007         self.fireReactors('create', newid, None)
2009         # store off the content as a file
2010         self.db.storefile(self.classname, newid, None, content)
2011         return newid
2013     def import_list(self, propnames, proplist):
2014         ''' Trap the "content" property...
2015         '''
2016         # dupe this list so we don't affect others
2017         propnames = propnames[:]
2019         # extract the "content" property from the proplist
2020         i = propnames.index('content')
2021         content = eval(proplist[i])
2022         del propnames[i]
2023         del proplist[i]
2025         # do the normal import
2026         newid = Class.import_list(self, propnames, proplist)
2028         # save off the "content" file
2029         self.db.storefile(self.classname, newid, None, content)
2030         return newid
2032     def get(self, nodeid, propname, default=_marker, cache=1):
2033         ''' Trap the content propname and get it from the file
2035         'cache' exists for backwards compatibility, and is not used.
2036         '''
2037         poss_msg = 'Possibly an access right configuration problem.'
2038         if propname == 'content':
2039             try:
2040                 return self.db.getfile(self.classname, nodeid, None)
2041             except IOError, (strerror):
2042                 # XXX by catching this we donot see an error in the log.
2043                 return 'ERROR reading file: %s%s\n%s\n%s'%(
2044                         self.classname, nodeid, poss_msg, strerror)
2045         if default is not _marker:
2046             return Class.get(self, nodeid, propname, default)
2047         else:
2048             return Class.get(self, nodeid, propname)
2050     def getprops(self, protected=1):
2051         ''' In addition to the actual properties on the node, these methods
2052             provide the "content" property. If the "protected" flag is true,
2053             we include protected properties - those which may not be
2054             modified.
2055         '''
2056         d = Class.getprops(self, protected=protected).copy()
2057         d['content'] = hyperdb.String()
2058         return d
2060     def index(self, nodeid):
2061         ''' Index the node in the search index.
2063             We want to index the content in addition to the normal String
2064             property indexing.
2065         '''
2066         # perform normal indexing
2067         Class.index(self, nodeid)
2069         # get the content to index
2070         content = self.get(nodeid, 'content')
2072         # figure the mime type
2073         if self.properties.has_key('type'):
2074             mime_type = self.get(nodeid, 'type')
2075         else:
2076             mime_type = self.default_mime_type
2078         # and index!
2079         self.db.indexer.add_text((self.classname, nodeid, 'content'), content,
2080             mime_type)
2082 # deviation from spec - was called ItemClass
2083 class IssueClass(Class, roundupdb.IssueClass):
2084     # Overridden methods:
2085     def __init__(self, db, classname, **properties):
2086         '''The newly-created class automatically includes the "messages",
2087         "files", "nosy", and "superseder" properties.  If the 'properties'
2088         dictionary attempts to specify any of these properties or a
2089         "creation" or "activity" property, a ValueError is raised.
2090         '''
2091         if not properties.has_key('title'):
2092             properties['title'] = hyperdb.String(indexme='yes')
2093         if not properties.has_key('messages'):
2094             properties['messages'] = hyperdb.Multilink("msg")
2095         if not properties.has_key('files'):
2096             properties['files'] = hyperdb.Multilink("file")
2097         if not properties.has_key('nosy'):
2098             # note: journalling is turned off as it really just wastes
2099             # space. this behaviour may be overridden in an instance
2100             properties['nosy'] = hyperdb.Multilink("user", do_journal="no")
2101         if not properties.has_key('superseder'):
2102             properties['superseder'] = hyperdb.Multilink(classname)
2103         Class.__init__(self, db, classname, **properties)