Code

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