Code

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