Code

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