Code

- Fixed retirement of items in rdbms imports (sf bug 841355)
[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.131 2003-11-14 00:11:18 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 try:
27     import anydbm, sys
28     # dumbdbm only works in python 2.1.2+
29     if sys.version_info < (2,1,2):
30         import dumbdbm
31         assert anydbm._defaultmod != dumbdbm
32         del dumbdbm
33 except AssertionError:
34     print "WARNING: you should upgrade to python 2.1.3"
36 import whichdb, os, marshal, re, weakref, string, copy
37 from roundup import hyperdb, date, password, roundupdb, security
38 from blobfiles import FileStorage
39 from sessions import Sessions, OneTimeKeys
40 from roundup.indexer import Indexer
41 from roundup.backends import locking
42 from roundup.hyperdb import String, Password, Date, Interval, Link, \
43     Multilink, DatabaseError, Boolean, Number, Node
44 from roundup.date import Range
46 #
47 # Now the database
48 #
49 class Database(FileStorage, hyperdb.Database, roundupdb.Database):
50     '''A database for storing records containing flexible data types.
52     Transaction stuff TODO:
53         . check the timestamp of the class file and nuke the cache if it's
54           modified. Do some sort of conflict checking on the dirty stuff.
55         . perhaps detect write collisions (related to above)?
57     '''
58     def __init__(self, config, journaltag=None):
59         '''Open a hyperdatabase given a specifier to some storage.
61         The 'storagelocator' is obtained from config.DATABASE.
62         The meaning of 'storagelocator' depends on the particular
63         implementation of the hyperdatabase.  It could be a file name,
64         a directory path, a socket descriptor for a connection to a
65         database over the network, etc.
67         The 'journaltag' is a token that will be attached to the journal
68         entries for any edits done on the database.  If 'journaltag' is
69         None, the database is opened in read-only mode: the Class.create(),
70         Class.set(), Class.retire(), and Class.restore() methods are
71         disabled.  
72         '''        
73         self.config, self.journaltag = config, journaltag
74         self.dir = config.DATABASE
75         self.classes = {}
76         self.cache = {}         # cache of nodes loaded or created
77         self.dirtynodes = {}    # keep track of the dirty nodes by class
78         self.newnodes = {}      # keep track of the new nodes by class
79         self.destroyednodes = {}# keep track of the destroyed nodes by class
80         self.transactions = []
81         self.indexer = Indexer(self.dir)
82         self.sessions = Sessions(self.config)
83         self.otks = OneTimeKeys(self.config)
84         self.security = security.Security(self)
85         # ensure files are group readable and writable
86         os.umask(0002)
88         # lock it
89         lockfilenm = os.path.join(self.dir, 'lock')
90         self.lockfile = locking.acquire_lock(lockfilenm)
91         self.lockfile.write(str(os.getpid()))
92         self.lockfile.flush()
94     def post_init(self):
95         ''' Called once the schema initialisation has finished.
96         '''
97         # reindex the db if necessary
98         if self.indexer.should_reindex():
99             self.reindex()
101     def refresh_database(self):
102         "Rebuild the database"
103         self.reindex()
105     def reindex(self):
106         for klass in self.classes.values():
107             for nodeid in klass.list():
108                 klass.index(nodeid)
109         self.indexer.save_index()
111     def __repr__(self):
112         return '<back_anydbm instance at %x>'%id(self) 
114     #
115     # Classes
116     #
117     def __getattr__(self, classname):
118         '''A convenient way of calling self.getclass(classname).'''
119         if self.classes.has_key(classname):
120             if __debug__:
121                 print >>hyperdb.DEBUG, '__getattr__', (self, classname)
122             return self.classes[classname]
123         raise AttributeError, classname
125     def addclass(self, cl):
126         if __debug__:
127             print >>hyperdb.DEBUG, 'addclass', (self, cl)
128         cn = cl.classname
129         if self.classes.has_key(cn):
130             raise ValueError, cn
131         self.classes[cn] = cl
133     def getclasses(self):
134         '''Return a list of the names of all existing classes.'''
135         if __debug__:
136             print >>hyperdb.DEBUG, 'getclasses', (self,)
137         l = self.classes.keys()
138         l.sort()
139         return l
141     def getclass(self, classname):
142         '''Get the Class object representing a particular class.
144         If 'classname' is not a valid class name, a KeyError is raised.
145         '''
146         if __debug__:
147             print >>hyperdb.DEBUG, 'getclass', (self, classname)
148         try:
149             return self.classes[classname]
150         except KeyError:
151             raise KeyError, 'There is no class called "%s"'%classname
153     #
154     # Class DBs
155     #
156     def clear(self):
157         '''Delete all database contents
158         '''
159         if __debug__:
160             print >>hyperdb.DEBUG, 'clear', (self,)
161         for cn in self.classes.keys():
162             for dummy in 'nodes', 'journals':
163                 path = os.path.join(self.dir, 'journals.%s'%cn)
164                 if os.path.exists(path):
165                     os.remove(path)
166                 elif os.path.exists(path+'.db'):    # dbm appends .db
167                     os.remove(path+'.db')
169     def getclassdb(self, classname, mode='r'):
170         ''' grab a connection to the class db that will be used for
171             multiple actions
172         '''
173         if __debug__:
174             print >>hyperdb.DEBUG, 'getclassdb', (self, classname, mode)
175         return self.opendb('nodes.%s'%classname, mode)
177     def determine_db_type(self, path):
178         ''' determine which DB wrote the class file
179         '''
180         db_type = ''
181         if os.path.exists(path):
182             db_type = whichdb.whichdb(path)
183             if not db_type:
184                 raise DatabaseError, "Couldn't identify database type"
185         elif os.path.exists(path+'.db'):
186             # if the path ends in '.db', it's a dbm database, whether
187             # anydbm says it's dbhash or not!
188             db_type = 'dbm'
189         return db_type
191     def opendb(self, name, mode):
192         '''Low-level database opener that gets around anydbm/dbm
193            eccentricities.
194         '''
195         if __debug__:
196             print >>hyperdb.DEBUG, 'opendb', (self, name, mode)
198         # figure the class db type
199         path = os.path.join(os.getcwd(), self.dir, name)
200         db_type = self.determine_db_type(path)
202         # new database? let anydbm pick the best dbm
203         if not db_type:
204             if __debug__:
205                 print >>hyperdb.DEBUG, "opendb anydbm.open(%r, 'c')"%path
206             return anydbm.open(path, 'c')
208         # open the database with the correct module
209         try:
210             dbm = __import__(db_type)
211         except ImportError:
212             raise DatabaseError, \
213                 "Couldn't open database - the required module '%s'"\
214                 " is not available"%db_type
215         if __debug__:
216             print >>hyperdb.DEBUG, "opendb %r.open(%r, %r)"%(db_type, path,
217                 mode)
218         return dbm.open(path, mode)
220     #
221     # Node IDs
222     #
223     def newid(self, classname):
224         ''' Generate a new id for the given class
225         '''
226         # open the ids DB - create if if doesn't exist
227         db = self.opendb('_ids', 'c')
228         if db.has_key(classname):
229             newid = db[classname] = str(int(db[classname]) + 1)
230         else:
231             # the count() bit is transitional - older dbs won't start at 1
232             newid = str(self.getclass(classname).count()+1)
233             db[classname] = newid
234         db.close()
235         return newid
237     def setid(self, classname, setid):
238         ''' Set the id counter: used during import of database
239         '''
240         # open the ids DB - create if if doesn't exist
241         db = self.opendb('_ids', 'c')
242         db[classname] = str(setid)
243         db.close()
245     #
246     # Nodes
247     #
248     def addnode(self, classname, nodeid, node):
249         ''' add the specified node to its class's db
250         '''
251         if __debug__:
252             print >>hyperdb.DEBUG, 'addnode', (self, classname, nodeid, node)
254         # we'll be supplied these props if we're doing an import
255         if not node.has_key('creator'):
256             # add in the "calculated" properties (dupe so we don't affect
257             # calling code's node assumptions)
258             node = node.copy()
259             node['creator'] = self.getuid()
260             node['creation'] = node['activity'] = date.Date()
262         self.newnodes.setdefault(classname, {})[nodeid] = 1
263         self.cache.setdefault(classname, {})[nodeid] = node
264         self.savenode(classname, nodeid, node)
266     def setnode(self, classname, nodeid, node):
267         ''' change the specified node
268         '''
269         if __debug__:
270             print >>hyperdb.DEBUG, 'setnode', (self, classname, nodeid, node)
271         self.dirtynodes.setdefault(classname, {})[nodeid] = 1
273         # update the activity time (dupe so we don't affect
274         # calling code's node assumptions)
275         node = node.copy()
276         node['activity'] = date.Date()
278         # can't set without having already loaded the node
279         self.cache[classname][nodeid] = node
280         self.savenode(classname, nodeid, node)
282     def savenode(self, classname, nodeid, node):
283         ''' perform the saving of data specified by the set/addnode
284         '''
285         if __debug__:
286             print >>hyperdb.DEBUG, 'savenode', (self, classname, nodeid, node)
287         self.transactions.append((self.doSaveNode, (classname, nodeid, node)))
289     def getnode(self, classname, nodeid, db=None, cache=1):
290         ''' get a node from the database
292             Note the "cache" parameter is not used, and exists purely for
293             backward compatibility!
294         '''
295         if __debug__:
296             print >>hyperdb.DEBUG, 'getnode', (self, classname, nodeid, db)
298         # try the cache
299         cache_dict = self.cache.setdefault(classname, {})
300         if cache_dict.has_key(nodeid):
301             if __debug__:
302                 print >>hyperdb.TRACE, 'get %s %s cached'%(classname,
303                     nodeid)
304             return cache_dict[nodeid]
306         if __debug__:
307             print >>hyperdb.TRACE, 'get %s %s'%(classname, nodeid)
309         # get from the database and save in the cache
310         if db is None:
311             db = self.getclassdb(classname)
312         if not db.has_key(nodeid):
313             # try the cache - might be a brand-new node
314             cache_dict = self.cache.setdefault(classname, {})
315             if cache_dict.has_key(nodeid):
316                 if __debug__:
317                     print >>hyperdb.TRACE, 'get %s %s cached'%(classname,
318                         nodeid)
319                 return cache_dict[nodeid]
320             raise IndexError, "no such %s %s"%(classname, nodeid)
322         # check the uncommitted, destroyed nodes
323         if (self.destroyednodes.has_key(classname) and
324                 self.destroyednodes[classname].has_key(nodeid)):
325             raise IndexError, "no such %s %s"%(classname, nodeid)
327         # decode
328         res = marshal.loads(db[nodeid])
330         # reverse the serialisation
331         res = self.unserialise(classname, res)
333         # store off in the cache dict
334         if cache:
335             cache_dict[nodeid] = res
337         return res
339     def destroynode(self, classname, nodeid):
340         '''Remove a node from the database. Called exclusively by the
341            destroy() method on Class.
342         '''
343         if __debug__:
344             print >>hyperdb.DEBUG, 'destroynode', (self, classname, nodeid)
346         # remove from cache and newnodes if it's there
347         if (self.cache.has_key(classname) and
348                 self.cache[classname].has_key(nodeid)):
349             del self.cache[classname][nodeid]
350         if (self.newnodes.has_key(classname) and
351                 self.newnodes[classname].has_key(nodeid)):
352             del self.newnodes[classname][nodeid]
354         # see if there's any obvious commit actions that we should get rid of
355         for entry in self.transactions[:]:
356             if entry[1][:2] == (classname, nodeid):
357                 self.transactions.remove(entry)
359         # add to the destroyednodes map
360         self.destroyednodes.setdefault(classname, {})[nodeid] = 1
362         # add the destroy commit action
363         self.transactions.append((self.doDestroyNode, (classname, nodeid)))
365     def serialise(self, classname, node):
366         '''Copy the node contents, converting non-marshallable data into
367            marshallable data.
368         '''
369         if __debug__:
370             print >>hyperdb.DEBUG, 'serialise', classname, node
371         properties = self.getclass(classname).getprops()
372         d = {}
373         for k, v in node.items():
374             # if the property doesn't exist, or is the "retired" flag then
375             # it won't be in the properties dict
376             if not properties.has_key(k):
377                 d[k] = v
378                 continue
380             # get the property spec
381             prop = properties[k]
383             if isinstance(prop, Password) and v is not None:
384                 d[k] = str(v)
385             elif isinstance(prop, Date) and v is not None:
386                 d[k] = v.serialise()
387             elif isinstance(prop, Interval) and v is not None:
388                 d[k] = v.serialise()
389             else:
390                 d[k] = v
391         return d
393     def unserialise(self, classname, node):
394         '''Decode the marshalled node data
395         '''
396         if __debug__:
397             print >>hyperdb.DEBUG, 'unserialise', classname, node
398         properties = self.getclass(classname).getprops()
399         d = {}
400         for k, v in node.items():
401             # if the property doesn't exist, or is the "retired" flag then
402             # it won't be in the properties dict
403             if not properties.has_key(k):
404                 d[k] = v
405                 continue
407             # get the property spec
408             prop = properties[k]
410             if isinstance(prop, Date) and v is not None:
411                 d[k] = date.Date(v)
412             elif isinstance(prop, Interval) and v is not None:
413                 d[k] = date.Interval(v)
414             elif isinstance(prop, Password) and v is not None:
415                 p = password.Password()
416                 p.unpack(v)
417                 d[k] = p
418             else:
419                 d[k] = v
420         return d
422     def hasnode(self, classname, nodeid, db=None):
423         ''' determine if the database has a given node
424         '''
425         if __debug__:
426             print >>hyperdb.DEBUG, 'hasnode', (self, classname, nodeid, db)
428         # try the cache
429         cache = self.cache.setdefault(classname, {})
430         if cache.has_key(nodeid):
431             if __debug__:
432                 print >>hyperdb.TRACE, 'has %s %s cached'%(classname, nodeid)
433             return 1
434         if __debug__:
435             print >>hyperdb.TRACE, 'has %s %s'%(classname, nodeid)
437         # not in the cache - check the database
438         if db is None:
439             db = self.getclassdb(classname)
440         res = db.has_key(nodeid)
441         return res
443     def countnodes(self, classname, db=None):
444         if __debug__:
445             print >>hyperdb.DEBUG, 'countnodes', (self, classname, db)
447         count = 0
449         # include the uncommitted nodes
450         if self.newnodes.has_key(classname):
451             count += len(self.newnodes[classname])
452         if self.destroyednodes.has_key(classname):
453             count -= len(self.destroyednodes[classname])
455         # and count those in the DB
456         if db is None:
457             db = self.getclassdb(classname)
458         count = count + len(db.keys())
459         return count
462     #
463     # Files - special node properties
464     # inherited from FileStorage
466     #
467     # Journal
468     #
469     def addjournal(self, classname, nodeid, action, params, creator=None,
470             creation=None):
471         ''' Journal the Action
472         'action' may be:
474             'create' or 'set' -- 'params' is a dictionary of property values
475             'link' or 'unlink' -- 'params' is (classname, nodeid, propname)
476             'retire' -- 'params' is None
477         '''
478         if __debug__:
479             print >>hyperdb.DEBUG, 'addjournal', (self, classname, nodeid,
480                 action, params, creator, creation)
481         self.transactions.append((self.doSaveJournal, (classname, nodeid,
482             action, params, creator, creation)))
484     def getjournal(self, classname, nodeid):
485         ''' get the journal for id
487             Raise IndexError if the node doesn't exist (as per history()'s
488             API)
489         '''
490         if __debug__:
491             print >>hyperdb.DEBUG, 'getjournal', (self, classname, nodeid)
493         # our journal result
494         res = []
496         # add any journal entries for transactions not committed to the
497         # database
498         for method, args in self.transactions:
499             if method != self.doSaveJournal:
500                 continue
501             (cache_classname, cache_nodeid, cache_action, cache_params,
502                 cache_creator, cache_creation) = args
503             if cache_classname == classname and cache_nodeid == nodeid:
504                 if not cache_creator:
505                     cache_creator = self.getuid()
506                 if not cache_creation:
507                     cache_creation = date.Date()
508                 res.append((cache_nodeid, cache_creation, cache_creator,
509                     cache_action, cache_params))
511         # attempt to open the journal - in some rare cases, the journal may
512         # not exist
513         try:
514             db = self.opendb('journals.%s'%classname, 'r')
515         except anydbm.error, error:
516             if str(error) == "need 'c' or 'n' flag to open new db":
517                 raise IndexError, 'no such %s %s'%(classname, nodeid)
518             elif error.args[0] != 2:
519                 # this isn't a "not found" error, be alarmed!
520                 raise
521             if res:
522                 # we have unsaved journal entries, return them
523                 return res
524             raise IndexError, 'no such %s %s'%(classname, nodeid)
525         try:
526             journal = marshal.loads(db[nodeid])
527         except KeyError:
528             db.close()
529             if res:
530                 # we have some unsaved journal entries, be happy!
531                 return res
532             raise IndexError, 'no such %s %s'%(classname, nodeid)
533         db.close()
535         # add all the saved journal entries for this node
536         for nodeid, date_stamp, user, action, params in journal:
537             res.append((nodeid, date.Date(date_stamp), user, action, params))
538         return res
540     def pack(self, pack_before):
541         ''' Delete all journal entries except "create" before 'pack_before'.
542         '''
543         if __debug__:
544             print >>hyperdb.DEBUG, 'packjournal', (self, pack_before)
546         pack_before = pack_before.serialise()
547         for classname in self.getclasses():
548             # get the journal db
549             db_name = 'journals.%s'%classname
550             path = os.path.join(os.getcwd(), self.dir, classname)
551             db_type = self.determine_db_type(path)
552             db = self.opendb(db_name, 'w')
554             for key in db.keys():
555                 # get the journal for this db entry
556                 journal = marshal.loads(db[key])
557                 l = []
558                 last_set_entry = None
559                 for entry in journal:
560                     # unpack the entry
561                     (nodeid, date_stamp, self.journaltag, action, 
562                         params) = entry
563                     # if the entry is after the pack date, _or_ the initial
564                     # create entry, then it stays
565                     if date_stamp > pack_before or action == 'create':
566                         l.append(entry)
567                 db[key] = marshal.dumps(l)
568             if db_type == 'gdbm':
569                 db.reorganize()
570             db.close()
571             
573     #
574     # Basic transaction support
575     #
576     def commit(self):
577         ''' Commit the current transactions.
578         '''
579         if __debug__:
580             print >>hyperdb.DEBUG, 'commit', (self,)
582         # keep a handle to all the database files opened
583         self.databases = {}
585         try:
586             # now, do all the transactions
587             reindex = {}
588             for method, args in self.transactions:
589                 reindex[method(*args)] = 1
590         finally:
591             # make sure we close all the database files
592             for db in self.databases.values():
593                 db.close()
594             del self.databases
596         # reindex the nodes that request it
597         for classname, nodeid in filter(None, reindex.keys()):
598             print >>hyperdb.DEBUG, 'commit.reindex', (classname, nodeid)
599             self.getclass(classname).index(nodeid)
601         # save the indexer state
602         self.indexer.save_index()
604         self.clearCache()
606     def clearCache(self):
607         # all transactions committed, back to normal
608         self.cache = {}
609         self.dirtynodes = {}
610         self.newnodes = {}
611         self.destroyednodes = {}
612         self.transactions = []
614     def getCachedClassDB(self, classname):
615         ''' get the class db, looking in our cache of databases for commit
616         '''
617         # get the database handle
618         db_name = 'nodes.%s'%classname
619         if not self.databases.has_key(db_name):
620             self.databases[db_name] = self.getclassdb(classname, 'c')
621         return self.databases[db_name]
623     def doSaveNode(self, classname, nodeid, node):
624         if __debug__:
625             print >>hyperdb.DEBUG, 'doSaveNode', (self, classname, nodeid,
626                 node)
628         db = self.getCachedClassDB(classname)
630         # now save the marshalled data
631         db[nodeid] = marshal.dumps(self.serialise(classname, node))
633         # return the classname, nodeid so we reindex this content
634         return (classname, nodeid)
636     def getCachedJournalDB(self, classname):
637         ''' get the journal db, looking in our cache of databases for commit
638         '''
639         # get the database handle
640         db_name = 'journals.%s'%classname
641         if not self.databases.has_key(db_name):
642             self.databases[db_name] = self.opendb(db_name, 'c')
643         return self.databases[db_name]
645     def doSaveJournal(self, classname, nodeid, action, params, creator,
646             creation):
647         # serialise the parameters now if necessary
648         if isinstance(params, type({})):
649             if action in ('set', 'create'):
650                 params = self.serialise(classname, params)
652         # handle supply of the special journalling parameters (usually
653         # supplied on importing an existing database)
654         if creator:
655             journaltag = creator
656         else:
657             journaltag = self.getuid()
658         if creation:
659             journaldate = creation.serialise()
660         else:
661             journaldate = date.Date().serialise()
663         # create the journal entry
664         entry = (nodeid, journaldate, journaltag, action, params)
666         if __debug__:
667             print >>hyperdb.DEBUG, 'doSaveJournal', entry
669         db = self.getCachedJournalDB(classname)
671         # now insert the journal entry
672         if db.has_key(nodeid):
673             # append to existing
674             s = db[nodeid]
675             l = marshal.loads(s)
676             l.append(entry)
677         else:
678             l = [entry]
680         db[nodeid] = marshal.dumps(l)
682     def doDestroyNode(self, classname, nodeid):
683         if __debug__:
684             print >>hyperdb.DEBUG, 'doDestroyNode', (self, classname, nodeid)
686         # delete from the class database
687         db = self.getCachedClassDB(classname)
688         if db.has_key(nodeid):
689             del db[nodeid]
691         # delete from the database
692         db = self.getCachedJournalDB(classname)
693         if db.has_key(nodeid):
694             del db[nodeid]
696         # return the classname, nodeid so we reindex this content
697         return (classname, nodeid)
699     def rollback(self):
700         ''' Reverse all actions from the current transaction.
701         '''
702         if __debug__:
703             print >>hyperdb.DEBUG, 'rollback', (self, )
704         for method, args in self.transactions:
705             # delete temporary files
706             if method == self.doStoreFile:
707                 self.rollbackStoreFile(*args)
708         self.cache = {}
709         self.dirtynodes = {}
710         self.newnodes = {}
711         self.destroyednodes = {}
712         self.transactions = []
714     def close(self):
715         ''' Nothing to do
716         '''
717         if self.lockfile is not None:
718             locking.release_lock(self.lockfile)
719         if self.lockfile is not None:
720             self.lockfile.close()
721             self.lockfile = None
723 _marker = []
724 class Class(hyperdb.Class):
725     '''The handle to a particular class of nodes in a hyperdatabase.'''
727     def __init__(self, db, classname, **properties):
728         '''Create a new class with a given name and property specification.
730         'classname' must not collide with the name of an existing class,
731         or a ValueError is raised.  The keyword arguments in 'properties'
732         must map names to property objects, or a TypeError is raised.
733         '''
734         if (properties.has_key('creation') or properties.has_key('activity')
735                 or properties.has_key('creator')):
736             raise ValueError, '"creation", "activity" and "creator" are '\
737                 'reserved'
739         self.classname = classname
740         self.properties = properties
741         self.db = weakref.proxy(db)       # use a weak ref to avoid circularity
742         self.key = ''
744         # should we journal changes (default yes)
745         self.do_journal = 1
747         # do the db-related init stuff
748         db.addclass(self)
750         self.auditors = {'create': [], 'set': [], 'retire': [], 'restore': []}
751         self.reactors = {'create': [], 'set': [], 'retire': [], 'restore': []}
753     def enableJournalling(self):
754         '''Turn journalling on for this class
755         '''
756         self.do_journal = 1
758     def disableJournalling(self):
759         '''Turn journalling off for this class
760         '''
761         self.do_journal = 0
763     # Editing nodes:
765     def create(self, **propvalues):
766         '''Create a new node of this class and return its id.
768         The keyword arguments in 'propvalues' map property names to values.
770         The values of arguments must be acceptable for the types of their
771         corresponding properties or a TypeError is raised.
772         
773         If this class has a key property, it must be present and its value
774         must not collide with other key strings or a ValueError is raised.
775         
776         Any other properties on this class that are missing from the
777         'propvalues' dictionary are set to None.
778         
779         If an id in a link or multilink property does not refer to a valid
780         node, an IndexError is raised.
782         These operations trigger detectors and can be vetoed.  Attempts
783         to modify the "creation" or "activity" properties cause a KeyError.
784         '''
785         self.fireAuditors('create', None, propvalues)
786         newid = self.create_inner(**propvalues)
787         self.fireReactors('create', newid, None)
788         return newid
790     def create_inner(self, **propvalues):
791         ''' Called by create, in-between the audit and react calls.
792         '''
793         if propvalues.has_key('id'):
794             raise KeyError, '"id" is reserved'
796         if self.db.journaltag is None:
797             raise DatabaseError, 'Database open read-only'
799         if propvalues.has_key('creation') or propvalues.has_key('activity'):
800             raise KeyError, '"creation" and "activity" are reserved'
801         # new node's id
802         newid = self.db.newid(self.classname)
804         # validate propvalues
805         num_re = re.compile('^\d+$')
806         for key, value in propvalues.items():
807             if key == self.key:
808                 try:
809                     self.lookup(value)
810                 except KeyError:
811                     pass
812                 else:
813                     raise ValueError, 'node with key "%s" exists'%value
815             # try to handle this property
816             try:
817                 prop = self.properties[key]
818             except KeyError:
819                 raise KeyError, '"%s" has no property "%s"'%(self.classname,
820                     key)
822             if value is not None and isinstance(prop, Link):
823                 if type(value) != type(''):
824                     raise ValueError, 'link value must be String'
825                 link_class = self.properties[key].classname
826                 # if it isn't a number, it's a key
827                 if not num_re.match(value):
828                     try:
829                         value = self.db.classes[link_class].lookup(value)
830                     except (TypeError, KeyError):
831                         raise IndexError, 'new property "%s": %s not a %s'%(
832                             key, value, link_class)
833                 elif not self.db.getclass(link_class).hasnode(value):
834                     raise IndexError, '%s has no node %s'%(link_class, value)
836                 # save off the value
837                 propvalues[key] = value
839                 # register the link with the newly linked node
840                 if self.do_journal and self.properties[key].do_journal:
841                     self.db.addjournal(link_class, value, 'link',
842                         (self.classname, newid, key))
844             elif isinstance(prop, Multilink):
845                 if type(value) != type([]):
846                     raise TypeError, 'new property "%s" not a list of ids'%key
848                 # clean up and validate the list of links
849                 link_class = self.properties[key].classname
850                 l = []
851                 for entry in value:
852                     if type(entry) != type(''):
853                         raise ValueError, '"%s" multilink value (%r) '\
854                             'must contain Strings'%(key, value)
855                     # if it isn't a number, it's a key
856                     if not num_re.match(entry):
857                         try:
858                             entry = self.db.classes[link_class].lookup(entry)
859                         except (TypeError, KeyError):
860                             raise IndexError, 'new property "%s": %s not a %s'%(
861                                 key, entry, self.properties[key].classname)
862                     l.append(entry)
863                 value = l
864                 propvalues[key] = value
866                 # handle additions
867                 for nodeid in value:
868                     if not self.db.getclass(link_class).hasnode(nodeid):
869                         raise IndexError, '%s has no node %s'%(link_class,
870                             nodeid)
871                     # register the link with the newly linked node
872                     if self.do_journal and self.properties[key].do_journal:
873                         self.db.addjournal(link_class, nodeid, 'link',
874                             (self.classname, newid, key))
876             elif isinstance(prop, String):
877                 if type(value) != type('') and type(value) != type(u''):
878                     raise TypeError, 'new property "%s" not a string'%key
880             elif isinstance(prop, Password):
881                 if not isinstance(value, password.Password):
882                     raise TypeError, 'new property "%s" not a Password'%key
884             elif isinstance(prop, Date):
885                 if value is not None and not isinstance(value, date.Date):
886                     raise TypeError, 'new property "%s" not a Date'%key
888             elif isinstance(prop, Interval):
889                 if value is not None and not isinstance(value, date.Interval):
890                     raise TypeError, 'new property "%s" not an Interval'%key
892             elif value is not None and isinstance(prop, Number):
893                 try:
894                     float(value)
895                 except ValueError:
896                     raise TypeError, 'new property "%s" not numeric'%key
898             elif value is not None and isinstance(prop, Boolean):
899                 try:
900                     int(value)
901                 except ValueError:
902                     raise TypeError, 'new property "%s" not boolean'%key
904         # make sure there's data where there needs to be
905         for key, prop in self.properties.items():
906             if propvalues.has_key(key):
907                 continue
908             if key == self.key:
909                 raise ValueError, 'key property "%s" is required'%key
910             if isinstance(prop, Multilink):
911                 propvalues[key] = []
912             else:
913                 propvalues[key] = None
915         # done
916         self.db.addnode(self.classname, newid, propvalues)
917         if self.do_journal:
918             self.db.addjournal(self.classname, newid, 'create', {})
920         return newid
922     def export_list(self, propnames, nodeid):
923         ''' Export a node - generate a list of CSV-able data in the order
924             specified by propnames for the given node.
925         '''
926         properties = self.getprops()
927         l = []
928         for prop in propnames:
929             proptype = properties[prop]
930             value = self.get(nodeid, prop)
931             # "marshal" data where needed
932             if value is None:
933                 pass
934             elif isinstance(proptype, hyperdb.Date):
935                 value = value.get_tuple()
936             elif isinstance(proptype, hyperdb.Interval):
937                 value = value.get_tuple()
938             elif isinstance(proptype, hyperdb.Password):
939                 value = str(value)
940             l.append(repr(value))
942         # append retired flag
943         l.append(repr(self.is_retired(nodeid)))
945         return l
947     def import_list(self, propnames, proplist):
948         ''' Import a node - all information including "id" is present and
949             should not be sanity checked. Triggers are not triggered. The
950             journal should be initialised using the "creator" and "created"
951             information.
953             Return the nodeid of the node imported.
954         '''
955         if self.db.journaltag is None:
956             raise DatabaseError, 'Database open read-only'
957         properties = self.getprops()
959         # make the new node's property map
960         d = {}
961         newid = None
962         for i in range(len(propnames)):
963             # Figure the property for this column
964             propname = propnames[i]
966             # Use eval to reverse the repr() used to output the CSV
967             value = eval(proplist[i])
969             # "unmarshal" where necessary
970             if propname == 'id':
971                 newid = value
972                 continue
973             elif propname == 'is retired':
974                 # is the item retired?
975                 if int(value):
976                     d[self.db.RETIRED_FLAG] = 1
977                 continue
978             elif value is None:
979                 d[propname] = None
980                 continue
982             prop = properties[propname]
983             if isinstance(prop, hyperdb.Date):
984                 value = date.Date(value)
985             elif isinstance(prop, hyperdb.Interval):
986                 value = date.Interval(value)
987             elif isinstance(prop, hyperdb.Password):
988                 pwd = password.Password()
989                 pwd.unpack(value)
990                 value = pwd
991             d[propname] = value
993         # get a new id if necessary
994         if newid is None:
995             newid = self.db.newid(self.classname)
997         # add the node and journal
998         self.db.addnode(self.classname, newid, d)
1000         # extract the journalling stuff and nuke it
1001         if d.has_key('creator'):
1002             creator = d['creator']
1003             del d['creator']
1004         else:
1005             creator = None
1006         if d.has_key('creation'):
1007             creation = d['creation']
1008             del d['creation']
1009         else:
1010             creation = None
1011         if d.has_key('activity'):
1012             del d['activity']
1013         self.db.addjournal(self.classname, newid, 'create', {}, creator,
1014             creation)
1015         return newid
1017     def get(self, nodeid, propname, default=_marker, cache=1):
1018         '''Get the value of a property on an existing node of this class.
1020         'nodeid' must be the id of an existing node of this class or an
1021         IndexError is raised.  'propname' must be the name of a property
1022         of this class or a KeyError is raised.
1024         'cache' exists for backward compatibility, and is not used.
1026         Attempts to get the "creation" or "activity" properties should
1027         do the right thing.
1028         '''
1029         if propname == 'id':
1030             return nodeid
1032         # get the node's dict
1033         d = self.db.getnode(self.classname, nodeid)
1035         # check for one of the special props
1036         if propname == 'creation':
1037             if d.has_key('creation'):
1038                 return d['creation']
1039             if not self.do_journal:
1040                 raise ValueError, 'Journalling is disabled for this class'
1041             journal = self.db.getjournal(self.classname, nodeid)
1042             if journal:
1043                 return self.db.getjournal(self.classname, nodeid)[0][1]
1044             else:
1045                 # on the strange chance that there's no journal
1046                 return date.Date()
1047         if propname == 'activity':
1048             if d.has_key('activity'):
1049                 return d['activity']
1050             if not self.do_journal:
1051                 raise ValueError, 'Journalling is disabled for this class'
1052             journal = self.db.getjournal(self.classname, nodeid)
1053             if journal:
1054                 return self.db.getjournal(self.classname, nodeid)[-1][1]
1055             else:
1056                 # on the strange chance that there's no journal
1057                 return date.Date()
1058         if propname == 'creator':
1059             if d.has_key('creator'):
1060                 return d['creator']
1061             if not self.do_journal:
1062                 raise ValueError, 'Journalling is disabled for this class'
1063             journal = self.db.getjournal(self.classname, nodeid)
1064             if journal:
1065                 num_re = re.compile('^\d+$')
1066                 value = self.db.getjournal(self.classname, nodeid)[0][2]
1067                 if num_re.match(value):
1068                     return value
1069                 else:
1070                     # old-style "username" journal tag
1071                     try:
1072                         return self.db.user.lookup(value)
1073                     except KeyError:
1074                         # user's been retired, return admin
1075                         return '1'
1076             else:
1077                 return self.db.getuid()
1079         # get the property (raises KeyErorr if invalid)
1080         prop = self.properties[propname]
1082         if not d.has_key(propname):
1083             if default is _marker:
1084                 if isinstance(prop, Multilink):
1085                     return []
1086                 else:
1087                     return None
1088             else:
1089                 return default
1091         # return a dupe of the list so code doesn't get confused
1092         if isinstance(prop, Multilink):
1093             return d[propname][:]
1095         return d[propname]
1097     # not in spec
1098     def getnode(self, nodeid, cache=1):
1099         ''' Return a convenience wrapper for the node.
1101         'nodeid' must be the id of an existing node of this class or an
1102         IndexError is raised.
1104         'cache' exists for backwards compatibility, and is not used.
1105         '''
1106         return Node(self, nodeid)
1108     def set(self, nodeid, **propvalues):
1109         '''Modify a property on an existing node of this class.
1110         
1111         'nodeid' must be the id of an existing node of this class or an
1112         IndexError is raised.
1114         Each key in 'propvalues' must be the name of a property of this
1115         class or a KeyError is raised.
1117         All values in 'propvalues' must be acceptable types for their
1118         corresponding properties or a TypeError is raised.
1120         If the value of the key property is set, it must not collide with
1121         other key strings or a ValueError is raised.
1123         If the value of a Link or Multilink property contains an invalid
1124         node id, a ValueError is raised.
1126         These operations trigger detectors and can be vetoed.  Attempts
1127         to modify the "creation" or "activity" properties cause a KeyError.
1128         '''
1129         if not propvalues:
1130             return propvalues
1132         if propvalues.has_key('creation') or propvalues.has_key('activity'):
1133             raise KeyError, '"creation" and "activity" are reserved'
1135         if propvalues.has_key('id'):
1136             raise KeyError, '"id" is reserved'
1138         if self.db.journaltag is None:
1139             raise DatabaseError, 'Database open read-only'
1141         self.fireAuditors('set', nodeid, propvalues)
1142         # Take a copy of the node dict so that the subsequent set
1143         # operation doesn't modify the oldvalues structure.
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
2045         'cache' exists for backwards compatibility, and is not used.
2046         '''
2047         poss_msg = 'Possibly an access right configuration problem.'
2048         if propname == 'content':
2049             try:
2050                 return self.db.getfile(self.classname, nodeid, None)
2051             except IOError, (strerror):
2052                 # XXX by catching this we donot see an error in the log.
2053                 return 'ERROR reading file: %s%s\n%s\n%s'%(
2054                         self.classname, nodeid, poss_msg, strerror)
2055         if default is not _marker:
2056             return Class.get(self, nodeid, propname, default)
2057         else:
2058             return Class.get(self, nodeid, propname)
2060     def getprops(self, protected=1):
2061         ''' In addition to the actual properties on the node, these methods
2062             provide the "content" property. If the "protected" flag is true,
2063             we include protected properties - those which may not be
2064             modified.
2065         '''
2066         d = Class.getprops(self, protected=protected).copy()
2067         d['content'] = hyperdb.String()
2068         return d
2070     def index(self, nodeid):
2071         ''' Index the node in the search index.
2073             We want to index the content in addition to the normal String
2074             property indexing.
2075         '''
2076         # perform normal indexing
2077         Class.index(self, nodeid)
2079         # get the content to index
2080         content = self.get(nodeid, 'content')
2082         # figure the mime type
2083         if self.properties.has_key('type'):
2084             mime_type = self.get(nodeid, 'type')
2085         else:
2086             mime_type = self.default_mime_type
2088         # and index!
2089         self.db.indexer.add_text((self.classname, nodeid, 'content'), content,
2090             mime_type)
2092 # deviation from spec - was called ItemClass
2093 class IssueClass(Class, roundupdb.IssueClass):
2094     # Overridden methods:
2095     def __init__(self, db, classname, **properties):
2096         '''The newly-created class automatically includes the "messages",
2097         "files", "nosy", and "superseder" properties.  If the 'properties'
2098         dictionary attempts to specify any of these properties or a
2099         "creation" or "activity" property, a ValueError is raised.
2100         '''
2101         if not properties.has_key('title'):
2102             properties['title'] = hyperdb.String(indexme='yes')
2103         if not properties.has_key('messages'):
2104             properties['messages'] = hyperdb.Multilink("msg")
2105         if not properties.has_key('files'):
2106             properties['files'] = hyperdb.Multilink("file")
2107         if not properties.has_key('nosy'):
2108             # note: journalling is turned off as it really just wastes
2109             # space. this behaviour may be overridden in an instance
2110             properties['nosy'] = hyperdb.Multilink("user", do_journal="no")
2111         if not properties.has_key('superseder'):
2112             properties['superseder'] = hyperdb.Multilink(classname)
2113         Class.__init__(self, db, classname, **properties)