Code

added support for searching on ranges of dates
[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.110 2003-03-08 20:41:45 kedder 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(), and Class.retire() methods are disabled.
61         '''
62         self.config, self.journaltag = config, journaltag
63         self.dir = config.DATABASE
64         self.classes = {}
65         self.cache = {}         # cache of nodes loaded or created
66         self.dirtynodes = {}    # keep track of the dirty nodes by class
67         self.newnodes = {}      # keep track of the new nodes by class
68         self.destroyednodes = {}# keep track of the destroyed nodes by class
69         self.transactions = []
70         self.indexer = Indexer(self.dir)
71         self.sessions = Sessions(self.config)
72         self.otks = OneTimeKeys(self.config)
73         self.security = security.Security(self)
74         # ensure files are group readable and writable
75         os.umask(0002)
77         # lock it
78         lockfilenm = os.path.join(self.dir, 'lock')
79         self.lockfile = locking.acquire_lock(lockfilenm)
80         self.lockfile.write(str(os.getpid()))
81         self.lockfile.flush()
83     def post_init(self):
84         ''' Called once the schema initialisation has finished.
85         '''
86         # reindex the db if necessary
87         if self.indexer.should_reindex():
88             self.reindex()
90         # figure the "curuserid"
91         if self.journaltag is None:
92             self.curuserid = None
93         elif self.journaltag == 'admin':
94             # admin user may not exist, but always has ID 1
95             self.curuserid = '1'
96         else:
97             self.curuserid = self.user.lookup(self.journaltag)
99     def reindex(self):
100         for klass in self.classes.values():
101             for nodeid in klass.list():
102                 klass.index(nodeid)
103         self.indexer.save_index()
105     def __repr__(self):
106         return '<back_anydbm instance at %x>'%id(self) 
108     #
109     # Classes
110     #
111     def __getattr__(self, classname):
112         '''A convenient way of calling self.getclass(classname).'''
113         if self.classes.has_key(classname):
114             if __debug__:
115                 print >>hyperdb.DEBUG, '__getattr__', (self, classname)
116             return self.classes[classname]
117         raise AttributeError, classname
119     def addclass(self, cl):
120         if __debug__:
121             print >>hyperdb.DEBUG, 'addclass', (self, cl)
122         cn = cl.classname
123         if self.classes.has_key(cn):
124             raise ValueError, cn
125         self.classes[cn] = cl
127     def getclasses(self):
128         '''Return a list of the names of all existing classes.'''
129         if __debug__:
130             print >>hyperdb.DEBUG, 'getclasses', (self,)
131         l = self.classes.keys()
132         l.sort()
133         return l
135     def getclass(self, classname):
136         '''Get the Class object representing a particular class.
138         If 'classname' is not a valid class name, a KeyError is raised.
139         '''
140         if __debug__:
141             print >>hyperdb.DEBUG, 'getclass', (self, classname)
142         try:
143             return self.classes[classname]
144         except KeyError:
145             raise KeyError, 'There is no class called "%s"'%classname
147     #
148     # Class DBs
149     #
150     def clear(self):
151         '''Delete all database contents
152         '''
153         if __debug__:
154             print >>hyperdb.DEBUG, 'clear', (self,)
155         for cn in self.classes.keys():
156             for dummy in 'nodes', 'journals':
157                 path = os.path.join(self.dir, 'journals.%s'%cn)
158                 if os.path.exists(path):
159                     os.remove(path)
160                 elif os.path.exists(path+'.db'):    # dbm appends .db
161                     os.remove(path+'.db')
163     def getclassdb(self, classname, mode='r'):
164         ''' grab a connection to the class db that will be used for
165             multiple actions
166         '''
167         if __debug__:
168             print >>hyperdb.DEBUG, 'getclassdb', (self, classname, mode)
169         return self.opendb('nodes.%s'%classname, mode)
171     def determine_db_type(self, path):
172         ''' determine which DB wrote the class file
173         '''
174         db_type = ''
175         if os.path.exists(path):
176             db_type = whichdb.whichdb(path)
177             if not db_type:
178                 raise DatabaseError, "Couldn't identify database type"
179         elif os.path.exists(path+'.db'):
180             # if the path ends in '.db', it's a dbm database, whether
181             # anydbm says it's dbhash or not!
182             db_type = 'dbm'
183         return db_type
185     def opendb(self, name, mode):
186         '''Low-level database opener that gets around anydbm/dbm
187            eccentricities.
188         '''
189         if __debug__:
190             print >>hyperdb.DEBUG, 'opendb', (self, name, mode)
192         # figure the class db type
193         path = os.path.join(os.getcwd(), self.dir, name)
194         db_type = self.determine_db_type(path)
196         # new database? let anydbm pick the best dbm
197         if not db_type:
198             if __debug__:
199                 print >>hyperdb.DEBUG, "opendb anydbm.open(%r, 'c')"%path
200             return anydbm.open(path, 'c')
202         # open the database with the correct module
203         try:
204             dbm = __import__(db_type)
205         except ImportError:
206             raise DatabaseError, \
207                 "Couldn't open database - the required module '%s'"\
208                 " is not available"%db_type
209         if __debug__:
210             print >>hyperdb.DEBUG, "opendb %r.open(%r, %r)"%(db_type, path,
211                 mode)
212         return dbm.open(path, mode)
214     #
215     # Node IDs
216     #
217     def newid(self, classname):
218         ''' Generate a new id for the given class
219         '''
220         # open the ids DB - create if if doesn't exist
221         db = self.opendb('_ids', 'c')
222         if db.has_key(classname):
223             newid = db[classname] = str(int(db[classname]) + 1)
224         else:
225             # the count() bit is transitional - older dbs won't start at 1
226             newid = str(self.getclass(classname).count()+1)
227             db[classname] = newid
228         db.close()
229         return newid
231     def setid(self, classname, setid):
232         ''' Set the id counter: used during import of database
233         '''
234         # open the ids DB - create if if doesn't exist
235         db = self.opendb('_ids', 'c')
236         db[classname] = str(setid)
237         db.close()
239     #
240     # Nodes
241     #
242     def addnode(self, classname, nodeid, node):
243         ''' add the specified node to its class's db
244         '''
245         if __debug__:
246             print >>hyperdb.DEBUG, 'addnode', (self, classname, nodeid, node)
248         # we'll be supplied these props if we're doing an import
249         if not node.has_key('creator'):
250             # add in the "calculated" properties (dupe so we don't affect
251             # calling code's node assumptions)
252             node = node.copy()
253             node['creator'] = self.curuserid
254             node['creation'] = node['activity'] = date.Date()
256         self.newnodes.setdefault(classname, {})[nodeid] = 1
257         self.cache.setdefault(classname, {})[nodeid] = node
258         self.savenode(classname, nodeid, node)
260     def setnode(self, classname, nodeid, node):
261         ''' change the specified node
262         '''
263         if __debug__:
264             print >>hyperdb.DEBUG, 'setnode', (self, classname, nodeid, node)
265         self.dirtynodes.setdefault(classname, {})[nodeid] = 1
267         # update the activity time (dupe so we don't affect
268         # calling code's node assumptions)
269         node = node.copy()
270         node['activity'] = date.Date()
272         # can't set without having already loaded the node
273         self.cache[classname][nodeid] = node
274         self.savenode(classname, nodeid, node)
276     def savenode(self, classname, nodeid, node):
277         ''' perform the saving of data specified by the set/addnode
278         '''
279         if __debug__:
280             print >>hyperdb.DEBUG, 'savenode', (self, classname, nodeid, node)
281         self.transactions.append((self.doSaveNode, (classname, nodeid, node)))
283     def getnode(self, classname, nodeid, db=None, cache=1):
284         ''' get a node from the database
285         '''
286         if __debug__:
287             print >>hyperdb.DEBUG, 'getnode', (self, classname, nodeid, db)
288         if cache:
289             # try the cache
290             cache_dict = self.cache.setdefault(classname, {})
291             if cache_dict.has_key(nodeid):
292                 if __debug__:
293                     print >>hyperdb.TRACE, 'get %s %s cached'%(classname,
294                         nodeid)
295                 return cache_dict[nodeid]
297         if __debug__:
298             print >>hyperdb.TRACE, 'get %s %s'%(classname, nodeid)
300         # get from the database and save in the cache
301         if db is None:
302             db = self.getclassdb(classname)
303         if not db.has_key(nodeid):
304             # try the cache - might be a brand-new node
305             cache_dict = self.cache.setdefault(classname, {})
306             if cache_dict.has_key(nodeid):
307                 if __debug__:
308                     print >>hyperdb.TRACE, 'get %s %s cached'%(classname,
309                         nodeid)
310                 return cache_dict[nodeid]
311             raise IndexError, "no such %s %s"%(classname, nodeid)
313         # check the uncommitted, destroyed nodes
314         if (self.destroyednodes.has_key(classname) and
315                 self.destroyednodes[classname].has_key(nodeid)):
316             raise IndexError, "no such %s %s"%(classname, nodeid)
318         # decode
319         res = marshal.loads(db[nodeid])
321         # reverse the serialisation
322         res = self.unserialise(classname, res)
324         # store off in the cache dict
325         if cache:
326             cache_dict[nodeid] = res
328         return res
330     def destroynode(self, classname, nodeid):
331         '''Remove a node from the database. Called exclusively by the
332            destroy() method on Class.
333         '''
334         if __debug__:
335             print >>hyperdb.DEBUG, 'destroynode', (self, classname, nodeid)
337         # remove from cache and newnodes if it's there
338         if (self.cache.has_key(classname) and
339                 self.cache[classname].has_key(nodeid)):
340             del self.cache[classname][nodeid]
341         if (self.newnodes.has_key(classname) and
342                 self.newnodes[classname].has_key(nodeid)):
343             del self.newnodes[classname][nodeid]
345         # see if there's any obvious commit actions that we should get rid of
346         for entry in self.transactions[:]:
347             if entry[1][:2] == (classname, nodeid):
348                 self.transactions.remove(entry)
350         # add to the destroyednodes map
351         self.destroyednodes.setdefault(classname, {})[nodeid] = 1
353         # add the destroy commit action
354         self.transactions.append((self.doDestroyNode, (classname, nodeid)))
356     def serialise(self, classname, node):
357         '''Copy the node contents, converting non-marshallable data into
358            marshallable data.
359         '''
360         if __debug__:
361             print >>hyperdb.DEBUG, 'serialise', classname, node
362         properties = self.getclass(classname).getprops()
363         d = {}
364         for k, v in node.items():
365             # if the property doesn't exist, or is the "retired" flag then
366             # it won't be in the properties dict
367             if not properties.has_key(k):
368                 d[k] = v
369                 continue
371             # get the property spec
372             prop = properties[k]
374             if isinstance(prop, Password) and v is not None:
375                 d[k] = str(v)
376             elif isinstance(prop, Date) and v is not None:
377                 d[k] = v.serialise()
378             elif isinstance(prop, Interval) and v is not None:
379                 d[k] = v.serialise()
380             else:
381                 d[k] = v
382         return d
384     def unserialise(self, classname, node):
385         '''Decode the marshalled node data
386         '''
387         if __debug__:
388             print >>hyperdb.DEBUG, 'unserialise', classname, node
389         properties = self.getclass(classname).getprops()
390         d = {}
391         for k, v in node.items():
392             # if the property doesn't exist, or is the "retired" flag then
393             # it won't be in the properties dict
394             if not properties.has_key(k):
395                 d[k] = v
396                 continue
398             # get the property spec
399             prop = properties[k]
401             if isinstance(prop, Date) and v is not None:
402                 d[k] = date.Date(v)
403             elif isinstance(prop, Interval) and v is not None:
404                 d[k] = date.Interval(v)
405             elif isinstance(prop, Password) and v is not None:
406                 p = password.Password()
407                 p.unpack(v)
408                 d[k] = p
409             else:
410                 d[k] = v
411         return d
413     def hasnode(self, classname, nodeid, db=None):
414         ''' determine if the database has a given node
415         '''
416         if __debug__:
417             print >>hyperdb.DEBUG, 'hasnode', (self, classname, nodeid, db)
419         # try the cache
420         cache = self.cache.setdefault(classname, {})
421         if cache.has_key(nodeid):
422             if __debug__:
423                 print >>hyperdb.TRACE, 'has %s %s cached'%(classname, nodeid)
424             return 1
425         if __debug__:
426             print >>hyperdb.TRACE, 'has %s %s'%(classname, nodeid)
428         # not in the cache - check the database
429         if db is None:
430             db = self.getclassdb(classname)
431         res = db.has_key(nodeid)
432         return res
434     def countnodes(self, classname, db=None):
435         if __debug__:
436             print >>hyperdb.DEBUG, 'countnodes', (self, classname, db)
438         count = 0
440         # include the uncommitted nodes
441         if self.newnodes.has_key(classname):
442             count += len(self.newnodes[classname])
443         if self.destroyednodes.has_key(classname):
444             count -= len(self.destroyednodes[classname])
446         # and count those in the DB
447         if db is None:
448             db = self.getclassdb(classname)
449         count = count + len(db.keys())
450         return count
453     #
454     # Files - special node properties
455     # inherited from FileStorage
457     #
458     # Journal
459     #
460     def addjournal(self, classname, nodeid, action, params, creator=None,
461             creation=None):
462         ''' Journal the Action
463         'action' may be:
465             'create' or 'set' -- 'params' is a dictionary of property values
466             'link' or 'unlink' -- 'params' is (classname, nodeid, propname)
467             'retire' -- 'params' is None
468         '''
469         if __debug__:
470             print >>hyperdb.DEBUG, 'addjournal', (self, classname, nodeid,
471                 action, params, creator, creation)
472         self.transactions.append((self.doSaveJournal, (classname, nodeid,
473             action, params, creator, creation)))
475     def getjournal(self, classname, nodeid):
476         ''' get the journal for id
478             Raise IndexError if the node doesn't exist (as per history()'s
479             API)
480         '''
481         if __debug__:
482             print >>hyperdb.DEBUG, 'getjournal', (self, classname, nodeid)
483         # attempt to open the journal - in some rare cases, the journal may
484         # not exist
485         try:
486             db = self.opendb('journals.%s'%classname, 'r')
487         except anydbm.error, error:
488             if str(error) == "need 'c' or 'n' flag to open new db":
489                 raise IndexError, 'no such %s %s'%(classname, nodeid)
490             elif error.args[0] != 2:
491                 raise
492             raise IndexError, 'no such %s %s'%(classname, nodeid)
493         try:
494             journal = marshal.loads(db[nodeid])
495         except KeyError:
496             db.close()
497             raise IndexError, 'no such %s %s'%(classname, nodeid)
498         db.close()
499         res = []
500         for nodeid, date_stamp, user, action, params in journal:
501             res.append((nodeid, date.Date(date_stamp), user, action, params))
502         return res
504     def pack(self, pack_before):
505         ''' Delete all journal entries except "create" before 'pack_before'.
506         '''
507         if __debug__:
508             print >>hyperdb.DEBUG, 'packjournal', (self, pack_before)
510         pack_before = pack_before.serialise()
511         for classname in self.getclasses():
512             # get the journal db
513             db_name = 'journals.%s'%classname
514             path = os.path.join(os.getcwd(), self.dir, classname)
515             db_type = self.determine_db_type(path)
516             db = self.opendb(db_name, 'w')
518             for key in db.keys():
519                 # get the journal for this db entry
520                 journal = marshal.loads(db[key])
521                 l = []
522                 last_set_entry = None
523                 for entry in journal:
524                     # unpack the entry
525                     (nodeid, date_stamp, self.journaltag, action, 
526                         params) = entry
527                     # if the entry is after the pack date, _or_ the initial
528                     # create entry, then it stays
529                     if date_stamp > pack_before or action == 'create':
530                         l.append(entry)
531                 db[key] = marshal.dumps(l)
532             if db_type == 'gdbm':
533                 db.reorganize()
534             db.close()
535             
537     #
538     # Basic transaction support
539     #
540     def commit(self):
541         ''' Commit the current transactions.
542         '''
543         if __debug__:
544             print >>hyperdb.DEBUG, 'commit', (self,)
546         # keep a handle to all the database files opened
547         self.databases = {}
549         # now, do all the transactions
550         reindex = {}
551         for method, args in self.transactions:
552             reindex[method(*args)] = 1
554         # now close all the database files
555         for db in self.databases.values():
556             db.close()
557         del self.databases
559         # reindex the nodes that request it
560         for classname, nodeid in filter(None, reindex.keys()):
561             print >>hyperdb.DEBUG, 'commit.reindex', (classname, nodeid)
562             self.getclass(classname).index(nodeid)
564         # save the indexer state
565         self.indexer.save_index()
567         self.clearCache()
569     def clearCache(self):
570         # all transactions committed, back to normal
571         self.cache = {}
572         self.dirtynodes = {}
573         self.newnodes = {}
574         self.destroyednodes = {}
575         self.transactions = []
577     def getCachedClassDB(self, classname):
578         ''' get the class db, looking in our cache of databases for commit
579         '''
580         # get the database handle
581         db_name = 'nodes.%s'%classname
582         if not self.databases.has_key(db_name):
583             self.databases[db_name] = self.getclassdb(classname, 'c')
584         return self.databases[db_name]
586     def doSaveNode(self, classname, nodeid, node):
587         if __debug__:
588             print >>hyperdb.DEBUG, 'doSaveNode', (self, classname, nodeid,
589                 node)
591         db = self.getCachedClassDB(classname)
593         # now save the marshalled data
594         db[nodeid] = marshal.dumps(self.serialise(classname, node))
596         # return the classname, nodeid so we reindex this content
597         return (classname, nodeid)
599     def getCachedJournalDB(self, classname):
600         ''' get the journal db, looking in our cache of databases for commit
601         '''
602         # get the database handle
603         db_name = 'journals.%s'%classname
604         if not self.databases.has_key(db_name):
605             self.databases[db_name] = self.opendb(db_name, 'c')
606         return self.databases[db_name]
608     def doSaveJournal(self, classname, nodeid, action, params, creator,
609             creation):
610         # serialise the parameters now if necessary
611         if isinstance(params, type({})):
612             if action in ('set', 'create'):
613                 params = self.serialise(classname, params)
615         # handle supply of the special journalling parameters (usually
616         # supplied on importing an existing database)
617         if creator:
618             journaltag = creator
619         else:
620             journaltag = self.curuserid
621         if creation:
622             journaldate = creation.serialise()
623         else:
624             journaldate = date.Date().serialise()
626         # create the journal entry
627         entry = (nodeid, journaldate, journaltag, action, params)
629         if __debug__:
630             print >>hyperdb.DEBUG, 'doSaveJournal', entry
632         db = self.getCachedJournalDB(classname)
634         # now insert the journal entry
635         if db.has_key(nodeid):
636             # append to existing
637             s = db[nodeid]
638             l = marshal.loads(s)
639             l.append(entry)
640         else:
641             l = [entry]
643         db[nodeid] = marshal.dumps(l)
645     def doDestroyNode(self, classname, nodeid):
646         if __debug__:
647             print >>hyperdb.DEBUG, 'doDestroyNode', (self, classname, nodeid)
649         # delete from the class database
650         db = self.getCachedClassDB(classname)
651         if db.has_key(nodeid):
652             del db[nodeid]
654         # delete from the database
655         db = self.getCachedJournalDB(classname)
656         if db.has_key(nodeid):
657             del db[nodeid]
659         # return the classname, nodeid so we reindex this content
660         return (classname, nodeid)
662     def rollback(self):
663         ''' Reverse all actions from the current transaction.
664         '''
665         if __debug__:
666             print >>hyperdb.DEBUG, 'rollback', (self, )
667         for method, args in self.transactions:
668             # delete temporary files
669             if method == self.doStoreFile:
670                 self.rollbackStoreFile(*args)
671         self.cache = {}
672         self.dirtynodes = {}
673         self.newnodes = {}
674         self.destroyednodes = {}
675         self.transactions = []
677     def close(self):
678         ''' Nothing to do
679         '''
680         if self.lockfile is not None:
681             locking.release_lock(self.lockfile)
682         if self.lockfile is not None:
683             self.lockfile.close()
684             self.lockfile = None
686 _marker = []
687 class Class(hyperdb.Class):
688     '''The handle to a particular class of nodes in a hyperdatabase.'''
690     def __init__(self, db, classname, **properties):
691         '''Create a new class with a given name and property specification.
693         'classname' must not collide with the name of an existing class,
694         or a ValueError is raised.  The keyword arguments in 'properties'
695         must map names to property objects, or a TypeError is raised.
696         '''
697         if (properties.has_key('creation') or properties.has_key('activity')
698                 or properties.has_key('creator')):
699             raise ValueError, '"creation", "activity" and "creator" are '\
700                 'reserved'
702         self.classname = classname
703         self.properties = properties
704         self.db = weakref.proxy(db)       # use a weak ref to avoid circularity
705         self.key = ''
707         # should we journal changes (default yes)
708         self.do_journal = 1
710         # do the db-related init stuff
711         db.addclass(self)
713         self.auditors = {'create': [], 'set': [], 'retire': []}
714         self.reactors = {'create': [], 'set': [], 'retire': []}
716     def enableJournalling(self):
717         '''Turn journalling on for this class
718         '''
719         self.do_journal = 1
721     def disableJournalling(self):
722         '''Turn journalling off for this class
723         '''
724         self.do_journal = 0
726     # Editing nodes:
728     def create(self, **propvalues):
729         '''Create a new node of this class and return its id.
731         The keyword arguments in 'propvalues' map property names to values.
733         The values of arguments must be acceptable for the types of their
734         corresponding properties or a TypeError is raised.
735         
736         If this class has a key property, it must be present and its value
737         must not collide with other key strings or a ValueError is raised.
738         
739         Any other properties on this class that are missing from the
740         'propvalues' dictionary are set to None.
741         
742         If an id in a link or multilink property does not refer to a valid
743         node, an IndexError is raised.
745         These operations trigger detectors and can be vetoed.  Attempts
746         to modify the "creation" or "activity" properties cause a KeyError.
747         '''
748         self.fireAuditors('create', None, propvalues)
749         newid = self.create_inner(**propvalues)
750         self.fireReactors('create', newid, None)
751         return newid
753     def create_inner(self, **propvalues):
754         ''' Called by create, in-between the audit and react calls.
755         '''
756         if propvalues.has_key('id'):
757             raise KeyError, '"id" is reserved'
759         if self.db.journaltag is None:
760             raise DatabaseError, 'Database open read-only'
762         if propvalues.has_key('creation') or propvalues.has_key('activity'):
763             raise KeyError, '"creation" and "activity" are reserved'
764         # new node's id
765         newid = self.db.newid(self.classname)
767         # validate propvalues
768         num_re = re.compile('^\d+$')
769         for key, value in propvalues.items():
770             if key == self.key:
771                 try:
772                     self.lookup(value)
773                 except KeyError:
774                     pass
775                 else:
776                     raise ValueError, 'node with key "%s" exists'%value
778             # try to handle this property
779             try:
780                 prop = self.properties[key]
781             except KeyError:
782                 raise KeyError, '"%s" has no property "%s"'%(self.classname,
783                     key)
785             if value is not None and isinstance(prop, Link):
786                 if type(value) != type(''):
787                     raise ValueError, 'link value must be String'
788                 link_class = self.properties[key].classname
789                 # if it isn't a number, it's a key
790                 if not num_re.match(value):
791                     try:
792                         value = self.db.classes[link_class].lookup(value)
793                     except (TypeError, KeyError):
794                         raise IndexError, 'new property "%s": %s not a %s'%(
795                             key, value, link_class)
796                 elif not self.db.getclass(link_class).hasnode(value):
797                     raise IndexError, '%s has no node %s'%(link_class, value)
799                 # save off the value
800                 propvalues[key] = value
802                 # register the link with the newly linked node
803                 if self.do_journal and self.properties[key].do_journal:
804                     self.db.addjournal(link_class, value, 'link',
805                         (self.classname, newid, key))
807             elif isinstance(prop, Multilink):
808                 if type(value) != type([]):
809                     raise TypeError, 'new property "%s" not a list of ids'%key
811                 # clean up and validate the list of links
812                 link_class = self.properties[key].classname
813                 l = []
814                 for entry in value:
815                     if type(entry) != type(''):
816                         raise ValueError, '"%s" multilink value (%r) '\
817                             'must contain Strings'%(key, value)
818                     # if it isn't a number, it's a key
819                     if not num_re.match(entry):
820                         try:
821                             entry = self.db.classes[link_class].lookup(entry)
822                         except (TypeError, KeyError):
823                             raise IndexError, 'new property "%s": %s not a %s'%(
824                                 key, entry, self.properties[key].classname)
825                     l.append(entry)
826                 value = l
827                 propvalues[key] = value
829                 # handle additions
830                 for nodeid in value:
831                     if not self.db.getclass(link_class).hasnode(nodeid):
832                         raise IndexError, '%s has no node %s'%(link_class,
833                             nodeid)
834                     # register the link with the newly linked node
835                     if self.do_journal and self.properties[key].do_journal:
836                         self.db.addjournal(link_class, nodeid, 'link',
837                             (self.classname, newid, key))
839             elif isinstance(prop, String):
840                 if type(value) != type('') and type(value) != type(u''):
841                     raise TypeError, 'new property "%s" not a string'%key
843             elif isinstance(prop, Password):
844                 if not isinstance(value, password.Password):
845                     raise TypeError, 'new property "%s" not a Password'%key
847             elif isinstance(prop, Date):
848                 if value is not None and not isinstance(value, date.Date):
849                     raise TypeError, 'new property "%s" not a Date'%key
851             elif isinstance(prop, Interval):
852                 if value is not None and not isinstance(value, date.Interval):
853                     raise TypeError, 'new property "%s" not an Interval'%key
855             elif value is not None and isinstance(prop, Number):
856                 try:
857                     float(value)
858                 except ValueError:
859                     raise TypeError, 'new property "%s" not numeric'%key
861             elif value is not None and isinstance(prop, Boolean):
862                 try:
863                     int(value)
864                 except ValueError:
865                     raise TypeError, 'new property "%s" not boolean'%key
867         # make sure there's data where there needs to be
868         for key, prop in self.properties.items():
869             if propvalues.has_key(key):
870                 continue
871             if key == self.key:
872                 raise ValueError, 'key property "%s" is required'%key
873             if isinstance(prop, Multilink):
874                 propvalues[key] = []
875             else:
876                 propvalues[key] = None
878         # done
879         self.db.addnode(self.classname, newid, propvalues)
880         if self.do_journal:
881             self.db.addjournal(self.classname, newid, 'create', {})
883         return newid
885     def export_list(self, propnames, nodeid):
886         ''' Export a node - generate a list of CSV-able data in the order
887             specified by propnames for the given node.
888         '''
889         properties = self.getprops()
890         l = []
891         for prop in propnames:
892             proptype = properties[prop]
893             value = self.get(nodeid, prop)
894             # "marshal" data where needed
895             if value is None:
896                 pass
897             elif isinstance(proptype, hyperdb.Date):
898                 value = value.get_tuple()
899             elif isinstance(proptype, hyperdb.Interval):
900                 value = value.get_tuple()
901             elif isinstance(proptype, hyperdb.Password):
902                 value = str(value)
903             l.append(repr(value))
905         # append retired flag
906         l.append(self.is_retired(nodeid))
908         return l
910     def import_list(self, propnames, proplist):
911         ''' Import a node - all information including "id" is present and
912             should not be sanity checked. Triggers are not triggered. The
913             journal should be initialised using the "creator" and "created"
914             information.
916             Return the nodeid of the node imported.
917         '''
918         if self.db.journaltag is None:
919             raise DatabaseError, 'Database open read-only'
920         properties = self.getprops()
922         # make the new node's property map
923         d = {}
924         newid = None
925         for i in range(len(propnames)):
926             # Figure the property for this column
927             propname = propnames[i]
929             # Use eval to reverse the repr() used to output the CSV
930             value = eval(proplist[i])
932             # "unmarshal" where necessary
933             if propname == 'id':
934                 newid = value
935                 continue
936             elif propname == 'is retired':
937                 # is the item retired?
938                 if int(value):
939                     d[self.db.RETIRED_FLAG] = 1
940                 continue
941             elif value is None:
942                 # don't set Nones
943                 continue
945             prop = properties[propname]
946             if isinstance(prop, hyperdb.Date):
947                 value = date.Date(value)
948             elif isinstance(prop, hyperdb.Interval):
949                 value = date.Interval(value)
950             elif isinstance(prop, hyperdb.Password):
951                 pwd = password.Password()
952                 pwd.unpack(value)
953                 value = pwd
954             d[propname] = value
956         # get a new id if necessary
957         if newid is None:
958             newid = self.db.newid(self.classname)
960         # add the node and journal
961         self.db.addnode(self.classname, newid, d)
963         # extract the journalling stuff and nuke it
964         if d.has_key('creator'):
965             creator = d['creator']
966             del d['creator']
967         else:
968             creator = None
969         if d.has_key('creation'):
970             creation = d['creation']
971             del d['creation']
972         else:
973             creation = None
974         if d.has_key('activity'):
975             del d['activity']
976         self.db.addjournal(self.classname, newid, 'create', {}, creator,
977             creation)
978         return newid
980     def get(self, nodeid, propname, default=_marker, cache=1):
981         '''Get the value of a property on an existing node of this class.
983         'nodeid' must be the id of an existing node of this class or an
984         IndexError is raised.  'propname' must be the name of a property
985         of this class or a KeyError is raised.
987         'cache' indicates whether the transaction cache should be queried
988         for the node. If the node has been modified and you need to
989         determine what its values prior to modification are, you need to
990         set cache=0.
992         Attempts to get the "creation" or "activity" properties should
993         do the right thing.
994         '''
995         if propname == 'id':
996             return nodeid
998         # get the node's dict
999         d = self.db.getnode(self.classname, nodeid, cache=cache)
1001         # check for one of the special props
1002         if propname == 'creation':
1003             if d.has_key('creation'):
1004                 return d['creation']
1005             if not self.do_journal:
1006                 raise ValueError, 'Journalling is disabled for this class'
1007             journal = self.db.getjournal(self.classname, nodeid)
1008             if journal:
1009                 return self.db.getjournal(self.classname, nodeid)[0][1]
1010             else:
1011                 # on the strange chance that there's no journal
1012                 return date.Date()
1013         if propname == 'activity':
1014             if d.has_key('activity'):
1015                 return d['activity']
1016             if not self.do_journal:
1017                 raise ValueError, 'Journalling is disabled for this class'
1018             journal = self.db.getjournal(self.classname, nodeid)
1019             if journal:
1020                 return self.db.getjournal(self.classname, nodeid)[-1][1]
1021             else:
1022                 # on the strange chance that there's no journal
1023                 return date.Date()
1024         if propname == 'creator':
1025             if d.has_key('creator'):
1026                 return d['creator']
1027             if not self.do_journal:
1028                 raise ValueError, 'Journalling is disabled for this class'
1029             journal = self.db.getjournal(self.classname, nodeid)
1030             if journal:
1031                 num_re = re.compile('^\d+$')
1032                 value = self.db.getjournal(self.classname, nodeid)[0][2]
1033                 if num_re.match(value):
1034                     return value
1035                 else:
1036                     # old-style "username" journal tag
1037                     try:
1038                         return self.db.user.lookup(value)
1039                     except KeyError:
1040                         # user's been retired, return admin
1041                         return '1'
1042             else:
1043                 return self.db.curuserid
1045         # get the property (raises KeyErorr if invalid)
1046         prop = self.properties[propname]
1048         if not d.has_key(propname):
1049             if default is _marker:
1050                 if isinstance(prop, Multilink):
1051                     return []
1052                 else:
1053                     return None
1054             else:
1055                 return default
1057         # return a dupe of the list so code doesn't get confused
1058         if isinstance(prop, Multilink):
1059             return d[propname][:]
1061         return d[propname]
1063     # not in spec
1064     def getnode(self, nodeid, cache=1):
1065         ''' Return a convenience wrapper for the node.
1067         'nodeid' must be the id of an existing node of this class or an
1068         IndexError is raised.
1070         'cache' indicates whether the transaction cache should be queried
1071         for the node. If the node has been modified and you need to
1072         determine what its values prior to modification are, you need to
1073         set cache=0.
1074         '''
1075         return Node(self, nodeid, cache=cache)
1077     def set(self, nodeid, **propvalues):
1078         '''Modify a property on an existing node of this class.
1079         
1080         'nodeid' must be the id of an existing node of this class or an
1081         IndexError is raised.
1083         Each key in 'propvalues' must be the name of a property of this
1084         class or a KeyError is raised.
1086         All values in 'propvalues' must be acceptable types for their
1087         corresponding properties or a TypeError is raised.
1089         If the value of the key property is set, it must not collide with
1090         other key strings or a ValueError is raised.
1092         If the value of a Link or Multilink property contains an invalid
1093         node id, a ValueError is raised.
1095         These operations trigger detectors and can be vetoed.  Attempts
1096         to modify the "creation" or "activity" properties cause a KeyError.
1097         '''
1098         if not propvalues:
1099             return propvalues
1101         if propvalues.has_key('creation') or propvalues.has_key('activity'):
1102             raise KeyError, '"creation" and "activity" are reserved'
1104         if propvalues.has_key('id'):
1105             raise KeyError, '"id" is reserved'
1107         if self.db.journaltag is None:
1108             raise DatabaseError, 'Database open read-only'
1110         self.fireAuditors('set', nodeid, propvalues)
1111         # Take a copy of the node dict so that the subsequent set
1112         # operation doesn't modify the oldvalues structure.
1113         try:
1114             # try not using the cache initially
1115             oldvalues = copy.deepcopy(self.db.getnode(self.classname, nodeid,
1116                 cache=0))
1117         except IndexError:
1118             # this will be needed if somone does a create() and set()
1119             # with no intervening commit()
1120             oldvalues = copy.deepcopy(self.db.getnode(self.classname, nodeid))
1122         node = self.db.getnode(self.classname, nodeid)
1123         if node.has_key(self.db.RETIRED_FLAG):
1124             raise IndexError
1125         num_re = re.compile('^\d+$')
1127         # if the journal value is to be different, store it in here
1128         journalvalues = {}
1130         for propname, value in propvalues.items():
1131             # check to make sure we're not duplicating an existing key
1132             if propname == self.key and node[propname] != value:
1133                 try:
1134                     self.lookup(value)
1135                 except KeyError:
1136                     pass
1137                 else:
1138                     raise ValueError, 'node with key "%s" exists'%value
1140             # this will raise the KeyError if the property isn't valid
1141             # ... we don't use getprops() here because we only care about
1142             # the writeable properties.
1143             try:
1144                 prop = self.properties[propname]
1145             except KeyError:
1146                 raise KeyError, '"%s" has no property named "%s"'%(
1147                     self.classname, propname)
1149             # if the value's the same as the existing value, no sense in
1150             # doing anything
1151             current = node.get(propname, None)
1152             if value == current:
1153                 del propvalues[propname]
1154                 continue
1155             journalvalues[propname] = current
1157             # do stuff based on the prop type
1158             if isinstance(prop, Link):
1159                 link_class = prop.classname
1160                 # if it isn't a number, it's a key
1161                 if value is not None and not isinstance(value, type('')):
1162                     raise ValueError, 'property "%s" link value be a string'%(
1163                         propname)
1164                 if isinstance(value, type('')) and not num_re.match(value):
1165                     try:
1166                         value = self.db.classes[link_class].lookup(value)
1167                     except (TypeError, KeyError):
1168                         raise IndexError, 'new property "%s": %s not a %s'%(
1169                             propname, value, prop.classname)
1171                 if (value is not None and
1172                         not self.db.getclass(link_class).hasnode(value)):
1173                     raise IndexError, '%s has no node %s'%(link_class, value)
1175                 if self.do_journal and prop.do_journal:
1176                     # register the unlink with the old linked node
1177                     if node.has_key(propname) and node[propname] is not None:
1178                         self.db.addjournal(link_class, node[propname], 'unlink',
1179                             (self.classname, nodeid, propname))
1181                     # register the link with the newly linked node
1182                     if value is not None:
1183                         self.db.addjournal(link_class, value, 'link',
1184                             (self.classname, nodeid, propname))
1186             elif isinstance(prop, Multilink):
1187                 if type(value) != type([]):
1188                     raise TypeError, 'new property "%s" not a list of'\
1189                         ' ids'%propname
1190                 link_class = self.properties[propname].classname
1191                 l = []
1192                 for entry in value:
1193                     # if it isn't a number, it's a key
1194                     if type(entry) != type(''):
1195                         raise ValueError, 'new property "%s" link value ' \
1196                             'must be a string'%propname
1197                     if not num_re.match(entry):
1198                         try:
1199                             entry = self.db.classes[link_class].lookup(entry)
1200                         except (TypeError, KeyError):
1201                             raise IndexError, 'new property "%s": %s not a %s'%(
1202                                 propname, entry,
1203                                 self.properties[propname].classname)
1204                     l.append(entry)
1205                 value = l
1206                 propvalues[propname] = value
1208                 # figure the journal entry for this property
1209                 add = []
1210                 remove = []
1212                 # handle removals
1213                 if node.has_key(propname):
1214                     l = node[propname]
1215                 else:
1216                     l = []
1217                 for id in l[:]:
1218                     if id in value:
1219                         continue
1220                     # register the unlink with the old linked node
1221                     if self.do_journal and self.properties[propname].do_journal:
1222                         self.db.addjournal(link_class, id, 'unlink',
1223                             (self.classname, nodeid, propname))
1224                     l.remove(id)
1225                     remove.append(id)
1227                 # handle additions
1228                 for id in value:
1229                     if not self.db.getclass(link_class).hasnode(id):
1230                         raise IndexError, '%s has no node %s'%(link_class, id)
1231                     if id in l:
1232                         continue
1233                     # register the link with the newly linked node
1234                     if self.do_journal and self.properties[propname].do_journal:
1235                         self.db.addjournal(link_class, id, 'link',
1236                             (self.classname, nodeid, propname))
1237                     l.append(id)
1238                     add.append(id)
1240                 # figure the journal entry
1241                 l = []
1242                 if add:
1243                     l.append(('+', add))
1244                 if remove:
1245                     l.append(('-', remove))
1246                 if l:
1247                     journalvalues[propname] = tuple(l)
1249             elif isinstance(prop, String):
1250                 if value is not None and type(value) != type('') and type(value) != type(u''):
1251                     raise TypeError, 'new property "%s" not a string'%propname
1253             elif isinstance(prop, Password):
1254                 if not isinstance(value, password.Password):
1255                     raise TypeError, 'new property "%s" not a Password'%propname
1256                 propvalues[propname] = value
1258             elif value is not None and isinstance(prop, Date):
1259                 if not isinstance(value, date.Date):
1260                     raise TypeError, 'new property "%s" not a Date'% propname
1261                 propvalues[propname] = value
1263             elif value is not None and isinstance(prop, Interval):
1264                 if not isinstance(value, date.Interval):
1265                     raise TypeError, 'new property "%s" not an '\
1266                         'Interval'%propname
1267                 propvalues[propname] = value
1269             elif value is not None and isinstance(prop, Number):
1270                 try:
1271                     float(value)
1272                 except ValueError:
1273                     raise TypeError, 'new property "%s" not numeric'%propname
1275             elif value is not None and isinstance(prop, Boolean):
1276                 try:
1277                     int(value)
1278                 except ValueError:
1279                     raise TypeError, 'new property "%s" not boolean'%propname
1281             node[propname] = value
1283         # nothing to do?
1284         if not propvalues:
1285             return propvalues
1287         # do the set, and journal it
1288         self.db.setnode(self.classname, nodeid, node)
1290         if self.do_journal:
1291             self.db.addjournal(self.classname, nodeid, 'set', journalvalues)
1293         self.fireReactors('set', nodeid, oldvalues)
1295         return propvalues        
1297     def retire(self, nodeid):
1298         '''Retire a node.
1299         
1300         The properties on the node remain available from the get() method,
1301         and the node's id is never reused.
1302         
1303         Retired nodes are not returned by the find(), list(), or lookup()
1304         methods, and other nodes may reuse the values of their key properties.
1306         These operations trigger detectors and can be vetoed.  Attempts
1307         to modify the "creation" or "activity" properties cause a KeyError.
1308         '''
1309         if self.db.journaltag is None:
1310             raise DatabaseError, 'Database open read-only'
1312         self.fireAuditors('retire', nodeid, None)
1314         node = self.db.getnode(self.classname, nodeid)
1315         node[self.db.RETIRED_FLAG] = 1
1316         self.db.setnode(self.classname, nodeid, node)
1317         if self.do_journal:
1318             self.db.addjournal(self.classname, nodeid, 'retired', None)
1320         self.fireReactors('retire', nodeid, None)
1322     def is_retired(self, nodeid, cldb=None):
1323         '''Return true if the node is retired.
1324         '''
1325         node = self.db.getnode(self.classname, nodeid, cldb)
1326         if node.has_key(self.db.RETIRED_FLAG):
1327             return 1
1328         return 0
1330     def destroy(self, nodeid):
1331         '''Destroy a node.
1333         WARNING: this method should never be used except in extremely rare
1334                  situations where there could never be links to the node being
1335                  deleted
1336         WARNING: use retire() instead
1337         WARNING: the properties of this node will not be available ever again
1338         WARNING: really, use retire() instead
1340         Well, I think that's enough warnings. This method exists mostly to
1341         support the session storage of the cgi interface.
1342         '''
1343         if self.db.journaltag is None:
1344             raise DatabaseError, 'Database open read-only'
1345         self.db.destroynode(self.classname, nodeid)
1347     def history(self, nodeid):
1348         '''Retrieve the journal of edits on a particular node.
1350         'nodeid' must be the id of an existing node of this class or an
1351         IndexError is raised.
1353         The returned list contains tuples of the form
1355             (nodeid, date, tag, action, params)
1357         'date' is a Timestamp object specifying the time of the change and
1358         'tag' is the journaltag specified when the database was opened.
1359         '''
1360         if not self.do_journal:
1361             raise ValueError, 'Journalling is disabled for this class'
1362         return self.db.getjournal(self.classname, nodeid)
1364     # Locating nodes:
1365     def hasnode(self, nodeid):
1366         '''Determine if the given nodeid actually exists
1367         '''
1368         return self.db.hasnode(self.classname, nodeid)
1370     def setkey(self, propname):
1371         '''Select a String property of this class to be the key property.
1373         'propname' must be the name of a String property of this class or
1374         None, or a TypeError is raised.  The values of the key property on
1375         all existing nodes must be unique or a ValueError is raised. If the
1376         property doesn't exist, KeyError is raised.
1377         '''
1378         prop = self.getprops()[propname]
1379         if not isinstance(prop, String):
1380             raise TypeError, 'key properties must be String'
1381         self.key = propname
1383     def getkey(self):
1384         '''Return the name of the key property for this class or None.'''
1385         return self.key
1387     def labelprop(self, default_to_id=0):
1388         ''' Return the property name for a label for the given node.
1390         This method attempts to generate a consistent label for the node.
1391         It tries the following in order:
1392             1. key property
1393             2. "name" property
1394             3. "title" property
1395             4. first property from the sorted property name list
1396         '''
1397         k = self.getkey()
1398         if  k:
1399             return k
1400         props = self.getprops()
1401         if props.has_key('name'):
1402             return 'name'
1403         elif props.has_key('title'):
1404             return 'title'
1405         if default_to_id:
1406             return 'id'
1407         props = props.keys()
1408         props.sort()
1409         return props[0]
1411     # TODO: set up a separate index db file for this? profile?
1412     def lookup(self, keyvalue):
1413         '''Locate a particular node by its key property and return its id.
1415         If this class has no key property, a TypeError is raised.  If the
1416         'keyvalue' matches one of the values for the key property among
1417         the nodes in this class, the matching node's id is returned;
1418         otherwise a KeyError is raised.
1419         '''
1420         if not self.key:
1421             raise TypeError, 'No key property set for class %s'%self.classname
1422         cldb = self.db.getclassdb(self.classname)
1423         try:
1424             for nodeid in self.getnodeids(cldb):
1425                 node = self.db.getnode(self.classname, nodeid, cldb)
1426                 if node.has_key(self.db.RETIRED_FLAG):
1427                     continue
1428                 if node[self.key] == keyvalue:
1429                     return nodeid
1430         finally:
1431             cldb.close()
1432         raise KeyError, 'No key (%s) value "%s" for "%s"'%(self.key,
1433             keyvalue, self.classname)
1435     # change from spec - allows multiple props to match
1436     def find(self, **propspec):
1437         '''Get the ids of nodes in this class which link to the given nodes.
1439         'propspec' consists of keyword args propname=nodeid or
1440                    propname={nodeid:1, }
1441         'propname' must be the name of a property in this class, or a
1442                    KeyError is raised.  That property must be a Link or
1443                    Multilink property, or a TypeError is raised.
1445         Any node in this class whose 'propname' property links to any of the
1446         nodeids will be returned. Used by the full text indexing, which knows
1447         that "foo" occurs in msg1, msg3 and file7, so we have hits on these
1448         issues:
1450             db.issue.find(messages={'1':1,'3':1}, files={'7':1})
1451         '''
1452         propspec = propspec.items()
1453         for propname, nodeids in propspec:
1454             # check the prop is OK
1455             prop = self.properties[propname]
1456             if not isinstance(prop, Link) and not isinstance(prop, Multilink):
1457                 raise TypeError, "'%s' not a Link/Multilink property"%propname
1459         # ok, now do the find
1460         cldb = self.db.getclassdb(self.classname)
1461         l = []
1462         try:
1463             for id in self.getnodeids(db=cldb):
1464                 node = self.db.getnode(self.classname, id, db=cldb)
1465                 if node.has_key(self.db.RETIRED_FLAG):
1466                     continue
1467                 for propname, nodeids in propspec:
1468                     # can't test if the node doesn't have this property
1469                     if not node.has_key(propname):
1470                         continue
1471                     if type(nodeids) is type(''):
1472                         nodeids = {nodeids:1}
1473                     prop = self.properties[propname]
1474                     value = node[propname]
1475                     if isinstance(prop, Link) and nodeids.has_key(value):
1476                         l.append(id)
1477                         break
1478                     elif isinstance(prop, Multilink):
1479                         hit = 0
1480                         for v in value:
1481                             if nodeids.has_key(v):
1482                                 l.append(id)
1483                                 hit = 1
1484                                 break
1485                         if hit:
1486                             break
1487         finally:
1488             cldb.close()
1489         return l
1491     def stringFind(self, **requirements):
1492         '''Locate a particular node by matching a set of its String
1493         properties in a caseless search.
1495         If the property is not a String property, a TypeError is raised.
1496         
1497         The return is a list of the id of all nodes that match.
1498         '''
1499         for propname in requirements.keys():
1500             prop = self.properties[propname]
1501             if isinstance(not prop, String):
1502                 raise TypeError, "'%s' not a String property"%propname
1503             requirements[propname] = requirements[propname].lower()
1504         l = []
1505         cldb = self.db.getclassdb(self.classname)
1506         try:
1507             for nodeid in self.getnodeids(cldb):
1508                 node = self.db.getnode(self.classname, nodeid, cldb)
1509                 if node.has_key(self.db.RETIRED_FLAG):
1510                     continue
1511                 for key, value in requirements.items():
1512                     if not node.has_key(key):
1513                         break
1514                     if node[key] is None or node[key].lower() != value:
1515                         break
1516                 else:
1517                     l.append(nodeid)
1518         finally:
1519             cldb.close()
1520         return l
1522     def list(self):
1523         ''' Return a list of the ids of the active nodes in this class.
1524         '''
1525         l = []
1526         cn = self.classname
1527         cldb = self.db.getclassdb(cn)
1528         try:
1529             for nodeid in self.getnodeids(cldb):
1530                 node = self.db.getnode(cn, nodeid, cldb)
1531                 if node.has_key(self.db.RETIRED_FLAG):
1532                     continue
1533                 l.append(nodeid)
1534         finally:
1535             cldb.close()
1536         l.sort()
1537         return l
1539     def getnodeids(self, db=None):
1540         ''' Return a list of ALL nodeids
1541         '''
1542         if __debug__:
1543             print >>hyperdb.DEBUG, 'getnodeids', (self, self.classname, db)
1545         res = []
1547         # start off with the new nodes
1548         if self.db.newnodes.has_key(self.classname):
1549             res += self.db.newnodes[self.classname].keys()
1551         if db is None:
1552             db = self.db.getclassdb(self.classname)
1553         res = res + db.keys()
1555         # remove the uncommitted, destroyed nodes
1556         if self.db.destroyednodes.has_key(self.classname):
1557             for nodeid in self.db.destroyednodes[self.classname].keys():
1558                 if db.has_key(nodeid):
1559                     res.remove(nodeid)
1561         return res
1563     def filter(self, search_matches, filterspec, sort=(None,None),
1564             group=(None,None), num_re = re.compile('^\d+$')):
1565         ''' Return a list of the ids of the active nodes in this class that
1566             match the 'filter' spec, sorted by the group spec and then the
1567             sort spec.
1569             "filterspec" is {propname: value(s)}
1570             "sort" and "group" are (dir, prop) where dir is '+', '-' or None
1571                                and prop is a prop name or None
1572             "search_matches" is {nodeid: marker}
1574             The filter must match all properties specificed - but if the
1575             property value to match is a list, any one of the values in the
1576             list may match for that property to match.
1577         '''
1578         cn = self.classname
1580         # optimise filterspec
1581         l = []
1582         props = self.getprops()
1583         LINK = 0
1584         MULTILINK = 1
1585         STRING = 2
1586         DATE = 3
1587         OTHER = 6
1588         
1589         timezone = self.db.getUserTimezone()
1590         for k, v in filterspec.items():
1591             propclass = props[k]
1592             if isinstance(propclass, Link):
1593                 if type(v) is not type([]):
1594                     v = [v]
1595                 # replace key values with node ids
1596                 u = []
1597                 link_class =  self.db.classes[propclass.classname]
1598                 for entry in v:
1599                     if entry == '-1': entry = None
1600                     elif not num_re.match(entry):
1601                         try:
1602                             entry = link_class.lookup(entry)
1603                         except (TypeError,KeyError):
1604                             raise ValueError, 'property "%s": %s not a %s'%(
1605                                 k, entry, self.properties[k].classname)
1606                     u.append(entry)
1608                 l.append((LINK, k, u))
1609             elif isinstance(propclass, Multilink):
1610                 if type(v) is not type([]):
1611                     v = [v]
1612                 # replace key values with node ids
1613                 u = []
1614                 link_class =  self.db.classes[propclass.classname]
1615                 for entry in v:
1616                     if not num_re.match(entry):
1617                         try:
1618                             entry = link_class.lookup(entry)
1619                         except (TypeError,KeyError):
1620                             raise ValueError, 'new property "%s": %s not a %s'%(
1621                                 k, entry, self.properties[k].classname)
1622                     u.append(entry)
1623                 l.append((MULTILINK, k, u))
1624             elif isinstance(propclass, String) and k != 'id':
1625                 # simple glob searching
1626                 v = re.sub(r'([\|\{\}\\\.\+\[\]\(\)])', r'\\\1', v)
1627                 v = v.replace('?', '.')
1628                 v = v.replace('*', '.*?')
1629                 l.append((STRING, k, re.compile(v, re.I)))
1630             elif isinstance(propclass, Date):
1631                 try:
1632                     date_rng = Range(v, date.Date, offset=timezone)
1633                     l.append((DATE, k, date_rng))
1634                 except ValueError:
1635                     # If range creation fails - ignore that search parameter
1636                     pass                            
1637             elif isinstance(propclass, Boolean):
1638                 if type(v) is type(''):
1639                     bv = v.lower() in ('yes', 'true', 'on', '1')
1640                 else:
1641                     bv = v
1642                 l.append((OTHER, k, bv))
1643             # kedder: dates are filtered by ranges
1644             #elif isinstance(propclass, Date):
1645             #    l.append((OTHER, k, date.Date(v)))
1646             elif isinstance(propclass, Interval):
1647                 l.append((OTHER, k, date.Interval(v)))
1648             elif isinstance(propclass, Number):
1649                 l.append((OTHER, k, int(v)))
1650             else:
1651                 l.append((OTHER, k, v))
1652         filterspec = l
1654         # now, find all the nodes that are active and pass filtering
1655         l = []
1656         cldb = self.db.getclassdb(cn)
1657         try:
1658             # TODO: only full-scan once (use items())
1659             for nodeid in self.getnodeids(cldb):
1660                 node = self.db.getnode(cn, nodeid, cldb)
1661                 if node.has_key(self.db.RETIRED_FLAG):
1662                     continue
1663                 # apply filter
1664                 for t, k, v in filterspec:
1665                     # handle the id prop
1666                     if k == 'id' and v == nodeid:
1667                         continue
1669                     # make sure the node has the property
1670                     if not node.has_key(k):
1671                         # this node doesn't have this property, so reject it
1672                         break
1674                     # now apply the property filter
1675                     if t == LINK:
1676                         # link - if this node's property doesn't appear in the
1677                         # filterspec's nodeid list, skip it
1678                         if node[k] not in v:
1679                             break
1680                     elif t == MULTILINK:
1681                         # multilink - if any of the nodeids required by the
1682                         # filterspec aren't in this node's property, then skip
1683                         # it
1684                         have = node[k]
1685                         for want in v:
1686                             if want not in have:
1687                                 break
1688                         else:
1689                             continue
1690                         break
1691                     elif t == STRING:
1692                         # RE search
1693                         if node[k] is None or not v.search(node[k]):
1694                             break
1695                     elif t == DATE:
1696                         if node[k] is None: break
1697                         if v.to_value:
1698                             if not (v.from_value < node[k] and v.to_value > node[k]):
1699                                 break
1700                         else:
1701                             if not (v.from_value < node[k]):
1702                                 break
1703                     elif t == OTHER:
1704                         # straight value comparison for the other types
1705                         if node[k] != v:
1706                             break
1707                 else:
1708                     l.append((nodeid, node))
1709         finally:
1710             cldb.close()
1711         l.sort()
1713         # filter based on full text search
1714         if search_matches is not None:
1715             k = []
1716             for v in l:
1717                 if search_matches.has_key(v[0]):
1718                     k.append(v)
1719             l = k
1721         # now, sort the result
1722         def sortfun(a, b, sort=sort, group=group, properties=self.getprops(),
1723                 db = self.db, cl=self):
1724             a_id, an = a
1725             b_id, bn = b
1726             # sort by group and then sort
1727             for dir, prop in group, sort:
1728                 if dir is None or prop is None: continue
1730                 # sorting is class-specific
1731                 propclass = properties[prop]
1733                 # handle the properties that might be "faked"
1734                 # also, handle possible missing properties
1735                 try:
1736                     if not an.has_key(prop):
1737                         an[prop] = cl.get(a_id, prop)
1738                     av = an[prop]
1739                 except KeyError:
1740                     # the node doesn't have a value for this property
1741                     if isinstance(propclass, Multilink): av = []
1742                     else: av = ''
1743                 try:
1744                     if not bn.has_key(prop):
1745                         bn[prop] = cl.get(b_id, prop)
1746                     bv = bn[prop]
1747                 except KeyError:
1748                     # the node doesn't have a value for this property
1749                     if isinstance(propclass, Multilink): bv = []
1750                     else: bv = ''
1752                 # String and Date values are sorted in the natural way
1753                 if isinstance(propclass, String):
1754                     # clean up the strings
1755                     if av and av[0] in string.uppercase:
1756                         av = av.lower()
1757                     if bv and bv[0] in string.uppercase:
1758                         bv = bv.lower()
1759                 if (isinstance(propclass, String) or
1760                         isinstance(propclass, Date)):
1761                     # it might be a string that's really an integer
1762                     try:
1763                         av = int(av)
1764                         bv = int(bv)
1765                     except:
1766                         pass
1767                     if dir == '+':
1768                         r = cmp(av, bv)
1769                         if r != 0: return r
1770                     elif dir == '-':
1771                         r = cmp(bv, av)
1772                         if r != 0: return r
1774                 # Link properties are sorted according to the value of
1775                 # the "order" property on the linked nodes if it is
1776                 # present; or otherwise on the key string of the linked
1777                 # nodes; or finally on  the node ids.
1778                 elif isinstance(propclass, Link):
1779                     link = db.classes[propclass.classname]
1780                     if av is None and bv is not None: return -1
1781                     if av is not None and bv is None: return 1
1782                     if av is None and bv is None: continue
1783                     if link.getprops().has_key('order'):
1784                         if dir == '+':
1785                             r = cmp(link.get(av, 'order'),
1786                                 link.get(bv, 'order'))
1787                             if r != 0: return r
1788                         elif dir == '-':
1789                             r = cmp(link.get(bv, 'order'),
1790                                 link.get(av, 'order'))
1791                             if r != 0: return r
1792                     elif link.getkey():
1793                         key = link.getkey()
1794                         if dir == '+':
1795                             r = cmp(link.get(av, key), link.get(bv, key))
1796                             if r != 0: return r
1797                         elif dir == '-':
1798                             r = cmp(link.get(bv, key), link.get(av, key))
1799                             if r != 0: return r
1800                     else:
1801                         if dir == '+':
1802                             r = cmp(av, bv)
1803                             if r != 0: return r
1804                         elif dir == '-':
1805                             r = cmp(bv, av)
1806                             if r != 0: return r
1808                 # Multilink properties are sorted according to how many
1809                 # links are present.
1810                 elif isinstance(propclass, Multilink):
1811                     r = cmp(len(av), len(bv))
1812                     if r == 0:
1813                         # Compare contents of multilink property if lenghts is
1814                         # equal
1815                         r = cmp ('.'.join(av), '.'.join(bv))
1816                     if dir == '+':
1817                         return r
1818                     elif dir == '-':
1819                         return -r
1820                 elif isinstance(propclass, Number) or isinstance(propclass, Boolean):
1821                     if dir == '+':
1822                         r = cmp(av, bv)
1823                     elif dir == '-':
1824                         r = cmp(bv, av)
1825                     
1826             # end for dir, prop in sort, group:
1827             # if all else fails, compare the ids
1828             return cmp(a[0], b[0])
1830         l.sort(sortfun)
1831         return [i[0] for i in l]
1833     def count(self):
1834         '''Get the number of nodes in this class.
1836         If the returned integer is 'numnodes', the ids of all the nodes
1837         in this class run from 1 to numnodes, and numnodes+1 will be the
1838         id of the next node to be created in this class.
1839         '''
1840         return self.db.countnodes(self.classname)
1842     # Manipulating properties:
1844     def getprops(self, protected=1):
1845         '''Return a dictionary mapping property names to property objects.
1846            If the "protected" flag is true, we include protected properties -
1847            those which may not be modified.
1849            In addition to the actual properties on the node, these
1850            methods provide the "creation" and "activity" properties. If the
1851            "protected" flag is true, we include protected properties - those
1852            which may not be modified.
1853         '''
1854         d = self.properties.copy()
1855         if protected:
1856             d['id'] = String()
1857             d['creation'] = hyperdb.Date()
1858             d['activity'] = hyperdb.Date()
1859             d['creator'] = hyperdb.Link('user')
1860         return d
1862     def addprop(self, **properties):
1863         '''Add properties to this class.
1865         The keyword arguments in 'properties' must map names to property
1866         objects, or a TypeError is raised.  None of the keys in 'properties'
1867         may collide with the names of existing properties, or a ValueError
1868         is raised before any properties have been added.
1869         '''
1870         for key in properties.keys():
1871             if self.properties.has_key(key):
1872                 raise ValueError, key
1873         self.properties.update(properties)
1875     def index(self, nodeid):
1876         '''Add (or refresh) the node to search indexes
1877         '''
1878         # find all the String properties that have indexme
1879         for prop, propclass in self.getprops().items():
1880             if isinstance(propclass, String) and propclass.indexme:
1881                 try:
1882                     value = str(self.get(nodeid, prop))
1883                 except IndexError:
1884                     # node no longer exists - entry should be removed
1885                     self.db.indexer.purge_entry((self.classname, nodeid, prop))
1886                 else:
1887                     # and index them under (classname, nodeid, property)
1888                     self.db.indexer.add_text((self.classname, nodeid, prop),
1889                         value)
1891     #
1892     # Detector interface
1893     #
1894     def audit(self, event, detector):
1895         '''Register a detector
1896         '''
1897         l = self.auditors[event]
1898         if detector not in l:
1899             self.auditors[event].append(detector)
1901     def fireAuditors(self, action, nodeid, newvalues):
1902         '''Fire all registered auditors.
1903         '''
1904         for audit in self.auditors[action]:
1905             audit(self.db, self, nodeid, newvalues)
1907     def react(self, event, detector):
1908         '''Register a detector
1909         '''
1910         l = self.reactors[event]
1911         if detector not in l:
1912             self.reactors[event].append(detector)
1914     def fireReactors(self, action, nodeid, oldvalues):
1915         '''Fire all registered reactors.
1916         '''
1917         for react in self.reactors[action]:
1918             react(self.db, self, nodeid, oldvalues)
1920 class FileClass(Class, hyperdb.FileClass):
1921     '''This class defines a large chunk of data. To support this, it has a
1922        mandatory String property "content" which is typically saved off
1923        externally to the hyperdb.
1925        The default MIME type of this data is defined by the
1926        "default_mime_type" class attribute, which may be overridden by each
1927        node if the class defines a "type" String property.
1928     '''
1929     default_mime_type = 'text/plain'
1931     def create(self, **propvalues):
1932         ''' Snarf the "content" propvalue and store in a file
1933         '''
1934         # we need to fire the auditors now, or the content property won't
1935         # be in propvalues for the auditors to play with
1936         self.fireAuditors('create', None, propvalues)
1938         # now remove the content property so it's not stored in the db
1939         content = propvalues['content']
1940         del propvalues['content']
1942         # do the database create
1943         newid = Class.create_inner(self, **propvalues)
1945         # fire reactors
1946         self.fireReactors('create', newid, None)
1948         # store off the content as a file
1949         self.db.storefile(self.classname, newid, None, content)
1950         return newid
1952     def import_list(self, propnames, proplist):
1953         ''' Trap the "content" property...
1954         '''
1955         # dupe this list so we don't affect others
1956         propnames = propnames[:]
1958         # extract the "content" property from the proplist
1959         i = propnames.index('content')
1960         content = eval(proplist[i])
1961         del propnames[i]
1962         del proplist[i]
1964         # do the normal import
1965         newid = Class.import_list(self, propnames, proplist)
1967         # save off the "content" file
1968         self.db.storefile(self.classname, newid, None, content)
1969         return newid
1971     def get(self, nodeid, propname, default=_marker, cache=1):
1972         ''' trap the content propname and get it from the file
1973         '''
1974         poss_msg = 'Possibly an access right configuration problem.'
1975         if propname == 'content':
1976             try:
1977                 return self.db.getfile(self.classname, nodeid, None)
1978             except IOError, (strerror):
1979                 # XXX by catching this we donot see an error in the log.
1980                 return 'ERROR reading file: %s%s\n%s\n%s'%(
1981                         self.classname, nodeid, poss_msg, strerror)
1982         if default is not _marker:
1983             return Class.get(self, nodeid, propname, default, cache=cache)
1984         else:
1985             return Class.get(self, nodeid, propname, cache=cache)
1987     def getprops(self, protected=1):
1988         ''' In addition to the actual properties on the node, these methods
1989             provide the "content" property. If the "protected" flag is true,
1990             we include protected properties - those which may not be
1991             modified.
1992         '''
1993         d = Class.getprops(self, protected=protected).copy()
1994         d['content'] = hyperdb.String()
1995         return d
1997     def index(self, nodeid):
1998         ''' Index the node in the search index.
2000             We want to index the content in addition to the normal String
2001             property indexing.
2002         '''
2003         # perform normal indexing
2004         Class.index(self, nodeid)
2006         # get the content to index
2007         content = self.get(nodeid, 'content')
2009         # figure the mime type
2010         if self.properties.has_key('type'):
2011             mime_type = self.get(nodeid, 'type')
2012         else:
2013             mime_type = self.default_mime_type
2015         # and index!
2016         self.db.indexer.add_text((self.classname, nodeid, 'content'), content,
2017             mime_type)
2019 # deviation from spec - was called ItemClass
2020 class IssueClass(Class, roundupdb.IssueClass):
2021     # Overridden methods:
2022     def __init__(self, db, classname, **properties):
2023         '''The newly-created class automatically includes the "messages",
2024         "files", "nosy", and "superseder" properties.  If the 'properties'
2025         dictionary attempts to specify any of these properties or a
2026         "creation" or "activity" property, a ValueError is raised.
2027         '''
2028         if not properties.has_key('title'):
2029             properties['title'] = hyperdb.String(indexme='yes')
2030         if not properties.has_key('messages'):
2031             properties['messages'] = hyperdb.Multilink("msg")
2032         if not properties.has_key('files'):
2033             properties['files'] = hyperdb.Multilink("file")
2034         if not properties.has_key('nosy'):
2035             # note: journalling is turned off as it really just wastes
2036             # space. this behaviour may be overridden in an instance
2037             properties['nosy'] = hyperdb.Multilink("user", do_journal="no")
2038         if not properties.has_key('superseder'):
2039             properties['superseder'] = hyperdb.Multilink(classname)
2040         Class.__init__(self, db, classname, **properties)