Code

Move up figure_curuserid(), it should be available for all backends.
[roundup.git] / roundup / backends / back_anydbm.py
1 #
2 # Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/)
3 # This module is free software, and you may redistribute it and/or modify
4 # under the same terms as Python, so long as this copyright message and
5 # disclaimer are retained in their original form.
6 #
7 # IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR
8 # DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING
9 # OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE
10 # POSSIBILITY OF SUCH DAMAGE.
11 #
12 # BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING,
13 # BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
14 # FOR A PARTICULAR PURPOSE.  THE CODE PROVIDED HEREUNDER IS ON AN "AS IS"
15 # BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
16 # SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
17
18 #$Id: back_anydbm.py,v 1.126 2003-09-06 20:01:10 jlgijsbers Exp $
19 '''
20 This module defines a backend that saves the hyperdatabase in a database
21 chosen by anydbm. It is guaranteed to always be available in python
22 versions >2.1.1 (the dumbdbm fallback in 2.1.1 and earlier has several
23 serious bugs, and is not available)
24 '''
26 import whichdb, anydbm, os, marshal, re, weakref, string, copy
27 from roundup import hyperdb, date, password, roundupdb, security
28 from blobfiles import FileStorage
29 from sessions import Sessions, OneTimeKeys
30 from roundup.indexer import Indexer
31 from roundup.backends import locking
32 from roundup.hyperdb import String, Password, Date, Interval, Link, \
33     Multilink, DatabaseError, Boolean, Number, Node
34 from roundup.date import Range
36 #
37 # Now the database
38 #
39 class Database(FileStorage, hyperdb.Database, roundupdb.Database):
40     '''A database for storing records containing flexible data types.
42     Transaction stuff TODO:
43         . check the timestamp of the class file and nuke the cache if it's
44           modified. Do some sort of conflict checking on the dirty stuff.
45         . perhaps detect write collisions (related to above)?
47     '''
48     def __init__(self, config, journaltag=None):
49         '''Open a hyperdatabase given a specifier to some storage.
51         The 'storagelocator' is obtained from config.DATABASE.
52         The meaning of 'storagelocator' depends on the particular
53         implementation of the hyperdatabase.  It could be a file name,
54         a directory path, a socket descriptor for a connection to a
55         database over the network, etc.
57         The 'journaltag' is a token that will be attached to the journal
58         entries for any edits done on the database.  If 'journaltag' is
59         None, the database is opened in read-only mode: the Class.create(),
60         Class.set(), Class.retire(), and Class.restore() methods are
61         disabled.  
62         '''        
63         self.config, self.journaltag = config, journaltag
64         self.dir = config.DATABASE
65         self.classes = {}
66         self.cache = {}         # cache of nodes loaded or created
67         self.dirtynodes = {}    # keep track of the dirty nodes by class
68         self.newnodes = {}      # keep track of the new nodes by class
69         self.destroyednodes = {}# keep track of the destroyed nodes by class
70         self.transactions = []
71         self.indexer = Indexer(self.dir)
72         self.sessions = Sessions(self.config)
73         self.otks = OneTimeKeys(self.config)
74         self.security = security.Security(self)
75         # ensure files are group readable and writable
76         os.umask(0002)
78         # lock it
79         lockfilenm = os.path.join(self.dir, 'lock')
80         self.lockfile = locking.acquire_lock(lockfilenm)
81         self.lockfile.write(str(os.getpid()))
82         self.lockfile.flush()
84     def post_init(self):
85         ''' Called once the schema initialisation has finished.
86         '''
87         # reindex the db if necessary
88         if self.indexer.should_reindex():
89             self.reindex()
90         self.figure_curuserid()
92     def reindex(self):
93         for klass in self.classes.values():
94             for nodeid in klass.list():
95                 klass.index(nodeid)
96         self.indexer.save_index()
98     def __repr__(self):
99         return '<back_anydbm instance at %x>'%id(self) 
101     #
102     # Classes
103     #
104     def __getattr__(self, classname):
105         '''A convenient way of calling self.getclass(classname).'''
106         if self.classes.has_key(classname):
107             if __debug__:
108                 print >>hyperdb.DEBUG, '__getattr__', (self, classname)
109             return self.classes[classname]
110         raise AttributeError, classname
112     def addclass(self, cl):
113         if __debug__:
114             print >>hyperdb.DEBUG, 'addclass', (self, cl)
115         cn = cl.classname
116         if self.classes.has_key(cn):
117             raise ValueError, cn
118         self.classes[cn] = cl
120     def getclasses(self):
121         '''Return a list of the names of all existing classes.'''
122         if __debug__:
123             print >>hyperdb.DEBUG, 'getclasses', (self,)
124         l = self.classes.keys()
125         l.sort()
126         return l
128     def getclass(self, classname):
129         '''Get the Class object representing a particular class.
131         If 'classname' is not a valid class name, a KeyError is raised.
132         '''
133         if __debug__:
134             print >>hyperdb.DEBUG, 'getclass', (self, classname)
135         try:
136             return self.classes[classname]
137         except KeyError:
138             raise KeyError, 'There is no class called "%s"'%classname
140     #
141     # Class DBs
142     #
143     def clear(self):
144         '''Delete all database contents
145         '''
146         if __debug__:
147             print >>hyperdb.DEBUG, 'clear', (self,)
148         for cn in self.classes.keys():
149             for dummy in 'nodes', 'journals':
150                 path = os.path.join(self.dir, 'journals.%s'%cn)
151                 if os.path.exists(path):
152                     os.remove(path)
153                 elif os.path.exists(path+'.db'):    # dbm appends .db
154                     os.remove(path+'.db')
156     def getclassdb(self, classname, mode='r'):
157         ''' grab a connection to the class db that will be used for
158             multiple actions
159         '''
160         if __debug__:
161             print >>hyperdb.DEBUG, 'getclassdb', (self, classname, mode)
162         return self.opendb('nodes.%s'%classname, mode)
164     def determine_db_type(self, path):
165         ''' determine which DB wrote the class file
166         '''
167         db_type = ''
168         if os.path.exists(path):
169             db_type = whichdb.whichdb(path)
170             if not db_type:
171                 raise DatabaseError, "Couldn't identify database type"
172         elif os.path.exists(path+'.db'):
173             # if the path ends in '.db', it's a dbm database, whether
174             # anydbm says it's dbhash or not!
175             db_type = 'dbm'
176         return db_type
178     def opendb(self, name, mode):
179         '''Low-level database opener that gets around anydbm/dbm
180            eccentricities.
181         '''
182         if __debug__:
183             print >>hyperdb.DEBUG, 'opendb', (self, name, mode)
185         # figure the class db type
186         path = os.path.join(os.getcwd(), self.dir, name)
187         db_type = self.determine_db_type(path)
189         # new database? let anydbm pick the best dbm
190         if not db_type:
191             if __debug__:
192                 print >>hyperdb.DEBUG, "opendb anydbm.open(%r, 'c')"%path
193             return anydbm.open(path, 'c')
195         # open the database with the correct module
196         try:
197             dbm = __import__(db_type)
198         except ImportError:
199             raise DatabaseError, \
200                 "Couldn't open database - the required module '%s'"\
201                 " is not available"%db_type
202         if __debug__:
203             print >>hyperdb.DEBUG, "opendb %r.open(%r, %r)"%(db_type, path,
204                 mode)
205         return dbm.open(path, mode)
207     #
208     # Node IDs
209     #
210     def newid(self, classname):
211         ''' Generate a new id for the given class
212         '''
213         # open the ids DB - create if if doesn't exist
214         db = self.opendb('_ids', 'c')
215         if db.has_key(classname):
216             newid = db[classname] = str(int(db[classname]) + 1)
217         else:
218             # the count() bit is transitional - older dbs won't start at 1
219             newid = str(self.getclass(classname).count()+1)
220             db[classname] = newid
221         db.close()
222         return newid
224     def setid(self, classname, setid):
225         ''' Set the id counter: used during import of database
226         '''
227         # open the ids DB - create if if doesn't exist
228         db = self.opendb('_ids', 'c')
229         db[classname] = str(setid)
230         db.close()
232     #
233     # Nodes
234     #
235     def addnode(self, classname, nodeid, node):
236         ''' add the specified node to its class's db
237         '''
238         if __debug__:
239             print >>hyperdb.DEBUG, 'addnode', (self, classname, nodeid, node)
241         # we'll be supplied these props if we're doing an import
242         if not node.has_key('creator'):
243             # add in the "calculated" properties (dupe so we don't affect
244             # calling code's node assumptions)
245             node = node.copy()
246             node['creator'] = self.curuserid
247             node['creation'] = node['activity'] = date.Date()
249         self.newnodes.setdefault(classname, {})[nodeid] = 1
250         self.cache.setdefault(classname, {})[nodeid] = node
251         self.savenode(classname, nodeid, node)
253     def setnode(self, classname, nodeid, node):
254         ''' change the specified node
255         '''
256         if __debug__:
257             print >>hyperdb.DEBUG, 'setnode', (self, classname, nodeid, node)
258         self.dirtynodes.setdefault(classname, {})[nodeid] = 1
260         # update the activity time (dupe so we don't affect
261         # calling code's node assumptions)
262         node = node.copy()
263         node['activity'] = date.Date()
265         # can't set without having already loaded the node
266         self.cache[classname][nodeid] = node
267         self.savenode(classname, nodeid, node)
269     def savenode(self, classname, nodeid, node):
270         ''' perform the saving of data specified by the set/addnode
271         '''
272         if __debug__:
273             print >>hyperdb.DEBUG, 'savenode', (self, classname, nodeid, node)
274         self.transactions.append((self.doSaveNode, (classname, nodeid, node)))
276     def getnode(self, classname, nodeid, db=None, cache=1):
277         ''' get a node from the database
279             Note the "cache" parameter is not used, and exists purely for
280             backward compatibility!
281         '''
282         if __debug__:
283             print >>hyperdb.DEBUG, 'getnode', (self, classname, nodeid, db)
285         # try the cache
286         cache_dict = self.cache.setdefault(classname, {})
287         if cache_dict.has_key(nodeid):
288             if __debug__:
289                 print >>hyperdb.TRACE, 'get %s %s cached'%(classname,
290                     nodeid)
291             return cache_dict[nodeid]
293         if __debug__:
294             print >>hyperdb.TRACE, 'get %s %s'%(classname, nodeid)
296         # get from the database and save in the cache
297         if db is None:
298             db = self.getclassdb(classname)
299         if not db.has_key(nodeid):
300             # try the cache - might be a brand-new node
301             cache_dict = self.cache.setdefault(classname, {})
302             if cache_dict.has_key(nodeid):
303                 if __debug__:
304                     print >>hyperdb.TRACE, 'get %s %s cached'%(classname,
305                         nodeid)
306                 return cache_dict[nodeid]
307             raise IndexError, "no such %s %s"%(classname, nodeid)
309         # check the uncommitted, destroyed nodes
310         if (self.destroyednodes.has_key(classname) and
311                 self.destroyednodes[classname].has_key(nodeid)):
312             raise IndexError, "no such %s %s"%(classname, nodeid)
314         # decode
315         res = marshal.loads(db[nodeid])
317         # reverse the serialisation
318         res = self.unserialise(classname, res)
320         # store off in the cache dict
321         if cache:
322             cache_dict[nodeid] = res
324         return res
326     def destroynode(self, classname, nodeid):
327         '''Remove a node from the database. Called exclusively by the
328            destroy() method on Class.
329         '''
330         if __debug__:
331             print >>hyperdb.DEBUG, 'destroynode', (self, classname, nodeid)
333         # remove from cache and newnodes if it's there
334         if (self.cache.has_key(classname) and
335                 self.cache[classname].has_key(nodeid)):
336             del self.cache[classname][nodeid]
337         if (self.newnodes.has_key(classname) and
338                 self.newnodes[classname].has_key(nodeid)):
339             del self.newnodes[classname][nodeid]
341         # see if there's any obvious commit actions that we should get rid of
342         for entry in self.transactions[:]:
343             if entry[1][:2] == (classname, nodeid):
344                 self.transactions.remove(entry)
346         # add to the destroyednodes map
347         self.destroyednodes.setdefault(classname, {})[nodeid] = 1
349         # add the destroy commit action
350         self.transactions.append((self.doDestroyNode, (classname, nodeid)))
352     def serialise(self, classname, node):
353         '''Copy the node contents, converting non-marshallable data into
354            marshallable data.
355         '''
356         if __debug__:
357             print >>hyperdb.DEBUG, 'serialise', classname, node
358         properties = self.getclass(classname).getprops()
359         d = {}
360         for k, v in node.items():
361             # if the property doesn't exist, or is the "retired" flag then
362             # it won't be in the properties dict
363             if not properties.has_key(k):
364                 d[k] = v
365                 continue
367             # get the property spec
368             prop = properties[k]
370             if isinstance(prop, Password) and v is not None:
371                 d[k] = str(v)
372             elif isinstance(prop, Date) and v is not None:
373                 d[k] = v.serialise()
374             elif isinstance(prop, Interval) and v is not None:
375                 d[k] = v.serialise()
376             else:
377                 d[k] = v
378         return d
380     def unserialise(self, classname, node):
381         '''Decode the marshalled node data
382         '''
383         if __debug__:
384             print >>hyperdb.DEBUG, 'unserialise', classname, node
385         properties = self.getclass(classname).getprops()
386         d = {}
387         for k, v in node.items():
388             # if the property doesn't exist, or is the "retired" flag then
389             # it won't be in the properties dict
390             if not properties.has_key(k):
391                 d[k] = v
392                 continue
394             # get the property spec
395             prop = properties[k]
397             if isinstance(prop, Date) and v is not None:
398                 d[k] = date.Date(v)
399             elif isinstance(prop, Interval) and v is not None:
400                 d[k] = date.Interval(v)
401             elif isinstance(prop, Password) and v is not None:
402                 p = password.Password()
403                 p.unpack(v)
404                 d[k] = p
405             else:
406                 d[k] = v
407         return d
409     def hasnode(self, classname, nodeid, db=None):
410         ''' determine if the database has a given node
411         '''
412         if __debug__:
413             print >>hyperdb.DEBUG, 'hasnode', (self, classname, nodeid, db)
415         # try the cache
416         cache = self.cache.setdefault(classname, {})
417         if cache.has_key(nodeid):
418             if __debug__:
419                 print >>hyperdb.TRACE, 'has %s %s cached'%(classname, nodeid)
420             return 1
421         if __debug__:
422             print >>hyperdb.TRACE, 'has %s %s'%(classname, nodeid)
424         # not in the cache - check the database
425         if db is None:
426             db = self.getclassdb(classname)
427         res = db.has_key(nodeid)
428         return res
430     def countnodes(self, classname, db=None):
431         if __debug__:
432             print >>hyperdb.DEBUG, 'countnodes', (self, classname, db)
434         count = 0
436         # include the uncommitted nodes
437         if self.newnodes.has_key(classname):
438             count += len(self.newnodes[classname])
439         if self.destroyednodes.has_key(classname):
440             count -= len(self.destroyednodes[classname])
442         # and count those in the DB
443         if db is None:
444             db = self.getclassdb(classname)
445         count = count + len(db.keys())
446         return count
449     #
450     # Files - special node properties
451     # inherited from FileStorage
453     #
454     # Journal
455     #
456     def addjournal(self, classname, nodeid, action, params, creator=None,
457             creation=None):
458         ''' Journal the Action
459         'action' may be:
461             'create' or 'set' -- 'params' is a dictionary of property values
462             'link' or 'unlink' -- 'params' is (classname, nodeid, propname)
463             'retire' -- 'params' is None
464         '''
465         if __debug__:
466             print >>hyperdb.DEBUG, 'addjournal', (self, classname, nodeid,
467                 action, params, creator, creation)
468         self.transactions.append((self.doSaveJournal, (classname, nodeid,
469             action, params, creator, creation)))
471     def getjournal(self, classname, nodeid):
472         ''' get the journal for id
474             Raise IndexError if the node doesn't exist (as per history()'s
475             API)
476         '''
477         if __debug__:
478             print >>hyperdb.DEBUG, 'getjournal', (self, classname, nodeid)
480         # our journal result
481         res = []
483         # add any journal entries for transactions not committed to the
484         # database
485         for method, args in self.transactions:
486             if method != self.doSaveJournal:
487                 continue
488             (cache_classname, cache_nodeid, cache_action, cache_params,
489                 cache_creator, cache_creation) = args
490             if cache_classname == classname and cache_nodeid == nodeid:
491                 if not cache_creator:
492                     cache_creator = self.curuserid
493                 if not cache_creation:
494                     cache_creation = date.Date()
495                 res.append((cache_nodeid, cache_creation, cache_creator,
496                     cache_action, cache_params))
498         # attempt to open the journal - in some rare cases, the journal may
499         # not exist
500         try:
501             db = self.opendb('journals.%s'%classname, 'r')
502         except anydbm.error, error:
503             if str(error) == "need 'c' or 'n' flag to open new db":
504                 raise IndexError, 'no such %s %s'%(classname, nodeid)
505             elif error.args[0] != 2:
506                 raise
507             raise IndexError, 'no such %s %s'%(classname, nodeid)
508         try:
509             journal = marshal.loads(db[nodeid])
510         except KeyError:
511             db.close()
512             if res:
513                 # we have some unsaved journal entries, be happy!
514                 return res
515             raise IndexError, 'no such %s %s'%(classname, nodeid)
516         db.close()
518         # add all the saved journal entries for this node
519         for nodeid, date_stamp, user, action, params in journal:
520             res.append((nodeid, date.Date(date_stamp), user, action, params))
521         return res
523     def pack(self, pack_before):
524         ''' Delete all journal entries except "create" before 'pack_before'.
525         '''
526         if __debug__:
527             print >>hyperdb.DEBUG, 'packjournal', (self, pack_before)
529         pack_before = pack_before.serialise()
530         for classname in self.getclasses():
531             # get the journal db
532             db_name = 'journals.%s'%classname
533             path = os.path.join(os.getcwd(), self.dir, classname)
534             db_type = self.determine_db_type(path)
535             db = self.opendb(db_name, 'w')
537             for key in db.keys():
538                 # get the journal for this db entry
539                 journal = marshal.loads(db[key])
540                 l = []
541                 last_set_entry = None
542                 for entry in journal:
543                     # unpack the entry
544                     (nodeid, date_stamp, self.journaltag, action, 
545                         params) = entry
546                     # if the entry is after the pack date, _or_ the initial
547                     # create entry, then it stays
548                     if date_stamp > pack_before or action == 'create':
549                         l.append(entry)
550                 db[key] = marshal.dumps(l)
551             if db_type == 'gdbm':
552                 db.reorganize()
553             db.close()
554             
556     #
557     # Basic transaction support
558     #
559     def commit(self):
560         ''' Commit the current transactions.
561         '''
562         if __debug__:
563             print >>hyperdb.DEBUG, 'commit', (self,)
565         # keep a handle to all the database files opened
566         self.databases = {}
568         # now, do all the transactions
569         reindex = {}
570         for method, args in self.transactions:
571             reindex[method(*args)] = 1
573         # now close all the database files
574         for db in self.databases.values():
575             db.close()
576         del self.databases
578         # reindex the nodes that request it
579         for classname, nodeid in filter(None, reindex.keys()):
580             print >>hyperdb.DEBUG, 'commit.reindex', (classname, nodeid)
581             self.getclass(classname).index(nodeid)
583         # save the indexer state
584         self.indexer.save_index()
586         self.clearCache()
588     def clearCache(self):
589         # all transactions committed, back to normal
590         self.cache = {}
591         self.dirtynodes = {}
592         self.newnodes = {}
593         self.destroyednodes = {}
594         self.transactions = []
596     def getCachedClassDB(self, classname):
597         ''' get the class db, looking in our cache of databases for commit
598         '''
599         # get the database handle
600         db_name = 'nodes.%s'%classname
601         if not self.databases.has_key(db_name):
602             self.databases[db_name] = self.getclassdb(classname, 'c')
603         return self.databases[db_name]
605     def doSaveNode(self, classname, nodeid, node):
606         if __debug__:
607             print >>hyperdb.DEBUG, 'doSaveNode', (self, classname, nodeid,
608                 node)
610         db = self.getCachedClassDB(classname)
612         # now save the marshalled data
613         db[nodeid] = marshal.dumps(self.serialise(classname, node))
615         # return the classname, nodeid so we reindex this content
616         return (classname, nodeid)
618     def getCachedJournalDB(self, classname):
619         ''' get the journal db, looking in our cache of databases for commit
620         '''
621         # get the database handle
622         db_name = 'journals.%s'%classname
623         if not self.databases.has_key(db_name):
624             self.databases[db_name] = self.opendb(db_name, 'c')
625         return self.databases[db_name]
627     def doSaveJournal(self, classname, nodeid, action, params, creator,
628             creation):
629         # serialise the parameters now if necessary
630         if isinstance(params, type({})):
631             if action in ('set', 'create'):
632                 params = self.serialise(classname, params)
634         # handle supply of the special journalling parameters (usually
635         # supplied on importing an existing database)
636         if creator:
637             journaltag = creator
638         else:
639             journaltag = self.curuserid
640         if creation:
641             journaldate = creation.serialise()
642         else:
643             journaldate = date.Date().serialise()
645         # create the journal entry
646         entry = (nodeid, journaldate, journaltag, action, params)
648         if __debug__:
649             print >>hyperdb.DEBUG, 'doSaveJournal', entry
651         db = self.getCachedJournalDB(classname)
653         # now insert the journal entry
654         if db.has_key(nodeid):
655             # append to existing
656             s = db[nodeid]
657             l = marshal.loads(s)
658             l.append(entry)
659         else:
660             l = [entry]
662         db[nodeid] = marshal.dumps(l)
664     def doDestroyNode(self, classname, nodeid):
665         if __debug__:
666             print >>hyperdb.DEBUG, 'doDestroyNode', (self, classname, nodeid)
668         # delete from the class database
669         db = self.getCachedClassDB(classname)
670         if db.has_key(nodeid):
671             del db[nodeid]
673         # delete from the database
674         db = self.getCachedJournalDB(classname)
675         if db.has_key(nodeid):
676             del db[nodeid]
678         # return the classname, nodeid so we reindex this content
679         return (classname, nodeid)
681     def rollback(self):
682         ''' Reverse all actions from the current transaction.
683         '''
684         if __debug__:
685             print >>hyperdb.DEBUG, 'rollback', (self, )
686         for method, args in self.transactions:
687             # delete temporary files
688             if method == self.doStoreFile:
689                 self.rollbackStoreFile(*args)
690         self.cache = {}
691         self.dirtynodes = {}
692         self.newnodes = {}
693         self.destroyednodes = {}
694         self.transactions = []
696     def close(self):
697         ''' Nothing to do
698         '''
699         if self.lockfile is not None:
700             locking.release_lock(self.lockfile)
701         if self.lockfile is not None:
702             self.lockfile.close()
703             self.lockfile = None
705 _marker = []
706 class Class(hyperdb.Class):
707     '''The handle to a particular class of nodes in a hyperdatabase.'''
709     def __init__(self, db, classname, **properties):
710         '''Create a new class with a given name and property specification.
712         'classname' must not collide with the name of an existing class,
713         or a ValueError is raised.  The keyword arguments in 'properties'
714         must map names to property objects, or a TypeError is raised.
715         '''
716         if (properties.has_key('creation') or properties.has_key('activity')
717                 or properties.has_key('creator')):
718             raise ValueError, '"creation", "activity" and "creator" are '\
719                 'reserved'
721         self.classname = classname
722         self.properties = properties
723         self.db = weakref.proxy(db)       # use a weak ref to avoid circularity
724         self.key = ''
726         # should we journal changes (default yes)
727         self.do_journal = 1
729         # do the db-related init stuff
730         db.addclass(self)
732         self.auditors = {'create': [], 'set': [], 'retire': [], 'restore': []}
733         self.reactors = {'create': [], 'set': [], 'retire': [], 'restore': []}
735     def enableJournalling(self):
736         '''Turn journalling on for this class
737         '''
738         self.do_journal = 1
740     def disableJournalling(self):
741         '''Turn journalling off for this class
742         '''
743         self.do_journal = 0
745     # Editing nodes:
747     def create(self, **propvalues):
748         '''Create a new node of this class and return its id.
750         The keyword arguments in 'propvalues' map property names to values.
752         The values of arguments must be acceptable for the types of their
753         corresponding properties or a TypeError is raised.
754         
755         If this class has a key property, it must be present and its value
756         must not collide with other key strings or a ValueError is raised.
757         
758         Any other properties on this class that are missing from the
759         'propvalues' dictionary are set to None.
760         
761         If an id in a link or multilink property does not refer to a valid
762         node, an IndexError is raised.
764         These operations trigger detectors and can be vetoed.  Attempts
765         to modify the "creation" or "activity" properties cause a KeyError.
766         '''
767         self.fireAuditors('create', None, propvalues)
768         newid = self.create_inner(**propvalues)
769         self.fireReactors('create', newid, None)
770         return newid
772     def create_inner(self, **propvalues):
773         ''' Called by create, in-between the audit and react calls.
774         '''
775         if propvalues.has_key('id'):
776             raise KeyError, '"id" is reserved'
778         if self.db.journaltag is None:
779             raise DatabaseError, 'Database open read-only'
781         if propvalues.has_key('creation') or propvalues.has_key('activity'):
782             raise KeyError, '"creation" and "activity" are reserved'
783         # new node's id
784         newid = self.db.newid(self.classname)
786         # validate propvalues
787         num_re = re.compile('^\d+$')
788         for key, value in propvalues.items():
789             if key == self.key:
790                 try:
791                     self.lookup(value)
792                 except KeyError:
793                     pass
794                 else:
795                     raise ValueError, 'node with key "%s" exists'%value
797             # try to handle this property
798             try:
799                 prop = self.properties[key]
800             except KeyError:
801                 raise KeyError, '"%s" has no property "%s"'%(self.classname,
802                     key)
804             if value is not None and isinstance(prop, Link):
805                 if type(value) != type(''):
806                     raise ValueError, 'link value must be String'
807                 link_class = self.properties[key].classname
808                 # if it isn't a number, it's a key
809                 if not num_re.match(value):
810                     try:
811                         value = self.db.classes[link_class].lookup(value)
812                     except (TypeError, KeyError):
813                         raise IndexError, 'new property "%s": %s not a %s'%(
814                             key, value, link_class)
815                 elif not self.db.getclass(link_class).hasnode(value):
816                     raise IndexError, '%s has no node %s'%(link_class, value)
818                 # save off the value
819                 propvalues[key] = value
821                 # register the link with the newly linked node
822                 if self.do_journal and self.properties[key].do_journal:
823                     self.db.addjournal(link_class, value, 'link',
824                         (self.classname, newid, key))
826             elif isinstance(prop, Multilink):
827                 if type(value) != type([]):
828                     raise TypeError, 'new property "%s" not a list of ids'%key
830                 # clean up and validate the list of links
831                 link_class = self.properties[key].classname
832                 l = []
833                 for entry in value:
834                     if type(entry) != type(''):
835                         raise ValueError, '"%s" multilink value (%r) '\
836                             'must contain Strings'%(key, value)
837                     # if it isn't a number, it's a key
838                     if not num_re.match(entry):
839                         try:
840                             entry = self.db.classes[link_class].lookup(entry)
841                         except (TypeError, KeyError):
842                             raise IndexError, 'new property "%s": %s not a %s'%(
843                                 key, entry, self.properties[key].classname)
844                     l.append(entry)
845                 value = l
846                 propvalues[key] = value
848                 # handle additions
849                 for nodeid in value:
850                     if not self.db.getclass(link_class).hasnode(nodeid):
851                         raise IndexError, '%s has no node %s'%(link_class,
852                             nodeid)
853                     # register the link with the newly linked node
854                     if self.do_journal and self.properties[key].do_journal:
855                         self.db.addjournal(link_class, nodeid, 'link',
856                             (self.classname, newid, key))
858             elif isinstance(prop, String):
859                 if type(value) != type('') and type(value) != type(u''):
860                     raise TypeError, 'new property "%s" not a string'%key
862             elif isinstance(prop, Password):
863                 if not isinstance(value, password.Password):
864                     raise TypeError, 'new property "%s" not a Password'%key
866             elif isinstance(prop, Date):
867                 if value is not None and not isinstance(value, date.Date):
868                     raise TypeError, 'new property "%s" not a Date'%key
870             elif isinstance(prop, Interval):
871                 if value is not None and not isinstance(value, date.Interval):
872                     raise TypeError, 'new property "%s" not an Interval'%key
874             elif value is not None and isinstance(prop, Number):
875                 try:
876                     float(value)
877                 except ValueError:
878                     raise TypeError, 'new property "%s" not numeric'%key
880             elif value is not None and isinstance(prop, Boolean):
881                 try:
882                     int(value)
883                 except ValueError:
884                     raise TypeError, 'new property "%s" not boolean'%key
886         # make sure there's data where there needs to be
887         for key, prop in self.properties.items():
888             if propvalues.has_key(key):
889                 continue
890             if key == self.key:
891                 raise ValueError, 'key property "%s" is required'%key
892             if isinstance(prop, Multilink):
893                 propvalues[key] = []
894             else:
895                 propvalues[key] = None
897         # done
898         self.db.addnode(self.classname, newid, propvalues)
899         if self.do_journal:
900             self.db.addjournal(self.classname, newid, 'create', {})
902         return newid
904     def export_list(self, propnames, nodeid):
905         ''' Export a node - generate a list of CSV-able data in the order
906             specified by propnames for the given node.
907         '''
908         properties = self.getprops()
909         l = []
910         for prop in propnames:
911             proptype = properties[prop]
912             value = self.get(nodeid, prop)
913             # "marshal" data where needed
914             if value is None:
915                 pass
916             elif isinstance(proptype, hyperdb.Date):
917                 value = value.get_tuple()
918             elif isinstance(proptype, hyperdb.Interval):
919                 value = value.get_tuple()
920             elif isinstance(proptype, hyperdb.Password):
921                 value = str(value)
922             l.append(repr(value))
924         # append retired flag
925         l.append(self.is_retired(nodeid))
927         return l
929     def import_list(self, propnames, proplist):
930         ''' Import a node - all information including "id" is present and
931             should not be sanity checked. Triggers are not triggered. The
932             journal should be initialised using the "creator" and "created"
933             information.
935             Return the nodeid of the node imported.
936         '''
937         if self.db.journaltag is None:
938             raise DatabaseError, 'Database open read-only'
939         properties = self.getprops()
941         # make the new node's property map
942         d = {}
943         newid = None
944         for i in range(len(propnames)):
945             # Figure the property for this column
946             propname = propnames[i]
948             # Use eval to reverse the repr() used to output the CSV
949             value = eval(proplist[i])
951             # "unmarshal" where necessary
952             if propname == 'id':
953                 newid = value
954                 continue
955             elif propname == 'is retired':
956                 # is the item retired?
957                 if int(value):
958                     d[self.db.RETIRED_FLAG] = 1
959                 continue
960             elif value is None:
961                 d[propname] = None
962                 continue
964             prop = properties[propname]
965             if isinstance(prop, hyperdb.Date):
966                 value = date.Date(value)
967             elif isinstance(prop, hyperdb.Interval):
968                 value = date.Interval(value)
969             elif isinstance(prop, hyperdb.Password):
970                 pwd = password.Password()
971                 pwd.unpack(value)
972                 value = pwd
973             d[propname] = value
975         # get a new id if necessary
976         if newid is None:
977             newid = self.db.newid(self.classname)
979         # add the node and journal
980         self.db.addnode(self.classname, newid, d)
982         # extract the journalling stuff and nuke it
983         if d.has_key('creator'):
984             creator = d['creator']
985             del d['creator']
986         else:
987             creator = None
988         if d.has_key('creation'):
989             creation = d['creation']
990             del d['creation']
991         else:
992             creation = None
993         if d.has_key('activity'):
994             del d['activity']
995         self.db.addjournal(self.classname, newid, 'create', {}, creator,
996             creation)
997         return newid
999     def get(self, nodeid, propname, default=_marker, cache=1):
1000         '''Get the value of a property on an existing node of this class.
1002         'nodeid' must be the id of an existing node of this class or an
1003         IndexError is raised.  'propname' must be the name of a property
1004         of this class or a KeyError is raised.
1006         'cache' exists for backward compatibility, and is not used.
1008         Attempts to get the "creation" or "activity" properties should
1009         do the right thing.
1010         '''
1011         if propname == 'id':
1012             return nodeid
1014         # get the node's dict
1015         d = self.db.getnode(self.classname, nodeid)
1017         # check for one of the special props
1018         if propname == 'creation':
1019             if d.has_key('creation'):
1020                 return d['creation']
1021             if not self.do_journal:
1022                 raise ValueError, 'Journalling is disabled for this class'
1023             journal = self.db.getjournal(self.classname, nodeid)
1024             if journal:
1025                 return self.db.getjournal(self.classname, nodeid)[0][1]
1026             else:
1027                 # on the strange chance that there's no journal
1028                 return date.Date()
1029         if propname == 'activity':
1030             if d.has_key('activity'):
1031                 return d['activity']
1032             if not self.do_journal:
1033                 raise ValueError, 'Journalling is disabled for this class'
1034             journal = self.db.getjournal(self.classname, nodeid)
1035             if journal:
1036                 return self.db.getjournal(self.classname, nodeid)[-1][1]
1037             else:
1038                 # on the strange chance that there's no journal
1039                 return date.Date()
1040         if propname == 'creator':
1041             if d.has_key('creator'):
1042                 return d['creator']
1043             if not self.do_journal:
1044                 raise ValueError, 'Journalling is disabled for this class'
1045             journal = self.db.getjournal(self.classname, nodeid)
1046             if journal:
1047                 num_re = re.compile('^\d+$')
1048                 value = self.db.getjournal(self.classname, nodeid)[0][2]
1049                 if num_re.match(value):
1050                     return value
1051                 else:
1052                     # old-style "username" journal tag
1053                     try:
1054                         return self.db.user.lookup(value)
1055                     except KeyError:
1056                         # user's been retired, return admin
1057                         return '1'
1058             else:
1059                 return self.db.curuserid
1061         # get the property (raises KeyErorr if invalid)
1062         prop = self.properties[propname]
1064         if not d.has_key(propname):
1065             if default is _marker:
1066                 if isinstance(prop, Multilink):
1067                     return []
1068                 else:
1069                     return None
1070             else:
1071                 return default
1073         # return a dupe of the list so code doesn't get confused
1074         if isinstance(prop, Multilink):
1075             return d[propname][:]
1077         return d[propname]
1079     # not in spec
1080     def getnode(self, nodeid, cache=1):
1081         ''' Return a convenience wrapper for the node.
1083         'nodeid' must be the id of an existing node of this class or an
1084         IndexError is raised.
1086         'cache' exists for backwards compatibility, and is not used.
1087         '''
1088         return Node(self, nodeid)
1090     def set(self, nodeid, **propvalues):
1091         '''Modify a property on an existing node of this class.
1092         
1093         'nodeid' must be the id of an existing node of this class or an
1094         IndexError is raised.
1096         Each key in 'propvalues' must be the name of a property of this
1097         class or a KeyError is raised.
1099         All values in 'propvalues' must be acceptable types for their
1100         corresponding properties or a TypeError is raised.
1102         If the value of the key property is set, it must not collide with
1103         other key strings or a ValueError is raised.
1105         If the value of a Link or Multilink property contains an invalid
1106         node id, a ValueError is raised.
1108         These operations trigger detectors and can be vetoed.  Attempts
1109         to modify the "creation" or "activity" properties cause a KeyError.
1110         '''
1111         if not propvalues:
1112             return propvalues
1114         if propvalues.has_key('creation') or propvalues.has_key('activity'):
1115             raise KeyError, '"creation" and "activity" are reserved'
1117         if propvalues.has_key('id'):
1118             raise KeyError, '"id" is reserved'
1120         if self.db.journaltag is None:
1121             raise DatabaseError, 'Database open read-only'
1123         self.fireAuditors('set', nodeid, propvalues)
1124         # Take a copy of the node dict so that the subsequent set
1125         # operation doesn't modify the oldvalues structure.
1126         oldvalues = copy.deepcopy(self.db.getnode(self.classname, nodeid))
1128         node = self.db.getnode(self.classname, nodeid)
1129         if node.has_key(self.db.RETIRED_FLAG):
1130             raise IndexError
1131         num_re = re.compile('^\d+$')
1133         # if the journal value is to be different, store it in here
1134         journalvalues = {}
1136         for propname, value in propvalues.items():
1137             # check to make sure we're not duplicating an existing key
1138             if propname == self.key and node[propname] != value:
1139                 try:
1140                     self.lookup(value)
1141                 except KeyError:
1142                     pass
1143                 else:
1144                     raise ValueError, 'node with key "%s" exists'%value
1146             # this will raise the KeyError if the property isn't valid
1147             # ... we don't use getprops() here because we only care about
1148             # the writeable properties.
1149             try:
1150                 prop = self.properties[propname]
1151             except KeyError:
1152                 raise KeyError, '"%s" has no property named "%s"'%(
1153                     self.classname, propname)
1155             # if the value's the same as the existing value, no sense in
1156             # doing anything
1157             current = node.get(propname, None)
1158             if value == current:
1159                 del propvalues[propname]
1160                 continue
1161             journalvalues[propname] = current
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.has_key(propname) and 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('') and type(value) != type(u''):
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             self.db.addjournal(self.classname, nodeid, 'set', journalvalues)
1299         self.fireReactors('set', nodeid, oldvalues)
1301         return propvalues        
1303     def retire(self, nodeid):
1304         '''Retire a node.
1305         
1306         The properties on the node remain available from the get() method,
1307         and the node's id is never reused.
1308         
1309         Retired nodes are not returned by the find(), list(), or lookup()
1310         methods, and other nodes may reuse the values of their key properties.
1312         These operations trigger detectors and can be vetoed.  Attempts
1313         to modify the "creation" or "activity" properties cause a KeyError.
1314         '''
1315         if self.db.journaltag is None:
1316             raise DatabaseError, 'Database open read-only'
1318         self.fireAuditors('retire', nodeid, None)
1320         node = self.db.getnode(self.classname, nodeid)
1321         node[self.db.RETIRED_FLAG] = 1
1322         self.db.setnode(self.classname, nodeid, node)
1323         if self.do_journal:
1324             self.db.addjournal(self.classname, nodeid, 'retired', None)
1326         self.fireReactors('retire', nodeid, None)
1328     def restore(self, nodeid):
1329         '''Restpre a retired node.
1331         Make node available for all operations like it was before retirement.
1332         '''
1333         if self.db.journaltag is None:
1334             raise DatabaseError, 'Database open read-only'
1336         node = self.db.getnode(self.classname, nodeid)
1337         # check if key property was overrided
1338         key = self.getkey()
1339         try:
1340             id = self.lookup(node[key])
1341         except KeyError:
1342             pass
1343         else:
1344             raise KeyError, "Key property (%s) of retired node clashes with \
1345                 existing one (%s)" % (key, node[key])
1346         # Now we can safely restore node
1347         self.fireAuditors('restore', nodeid, None)
1348         del node[self.db.RETIRED_FLAG]
1349         self.db.setnode(self.classname, nodeid, node)
1350         if self.do_journal:
1351             self.db.addjournal(self.classname, nodeid, 'restored', None)
1353         self.fireReactors('restore', nodeid, None)
1355     def is_retired(self, nodeid, cldb=None):
1356         '''Return true if the node is retired.
1357         '''
1358         node = self.db.getnode(self.classname, nodeid, cldb)
1359         if node.has_key(self.db.RETIRED_FLAG):
1360             return 1
1361         return 0
1363     def destroy(self, nodeid):
1364         '''Destroy a node.
1366         WARNING: this method should never be used except in extremely rare
1367                  situations where there could never be links to the node being
1368                  deleted
1369         WARNING: use retire() instead
1370         WARNING: the properties of this node will not be available ever again
1371         WARNING: really, use retire() instead
1373         Well, I think that's enough warnings. This method exists mostly to
1374         support the session storage of the cgi interface.
1375         '''
1376         if self.db.journaltag is None:
1377             raise DatabaseError, 'Database open read-only'
1378         self.db.destroynode(self.classname, nodeid)
1380     def history(self, nodeid):
1381         '''Retrieve the journal of edits on a particular node.
1383         'nodeid' must be the id of an existing node of this class or an
1384         IndexError is raised.
1386         The returned list contains tuples of the form
1388             (nodeid, date, tag, action, params)
1390         'date' is a Timestamp object specifying the time of the change and
1391         'tag' is the journaltag specified when the database was opened.
1392         '''
1393         if not self.do_journal:
1394             raise ValueError, 'Journalling is disabled for this class'
1395         return self.db.getjournal(self.classname, nodeid)
1397     # Locating nodes:
1398     def hasnode(self, nodeid):
1399         '''Determine if the given nodeid actually exists
1400         '''
1401         return self.db.hasnode(self.classname, nodeid)
1403     def setkey(self, propname):
1404         '''Select a String property of this class to be the key property.
1406         'propname' must be the name of a String property of this class or
1407         None, or a TypeError is raised.  The values of the key property on
1408         all existing nodes must be unique or a ValueError is raised. If the
1409         property doesn't exist, KeyError is raised.
1410         '''
1411         prop = self.getprops()[propname]
1412         if not isinstance(prop, String):
1413             raise TypeError, 'key properties must be String'
1414         self.key = propname
1416     def getkey(self):
1417         '''Return the name of the key property for this class or None.'''
1418         return self.key
1420     def labelprop(self, default_to_id=0):
1421         ''' Return the property name for a label for the given node.
1423         This method attempts to generate a consistent label for the node.
1424         It tries the following in order:
1425             1. key property
1426             2. "name" property
1427             3. "title" property
1428             4. first property from the sorted property name list
1429         '''
1430         k = self.getkey()
1431         if  k:
1432             return k
1433         props = self.getprops()
1434         if props.has_key('name'):
1435             return 'name'
1436         elif props.has_key('title'):
1437             return 'title'
1438         if default_to_id:
1439             return 'id'
1440         props = props.keys()
1441         props.sort()
1442         return props[0]
1444     # TODO: set up a separate index db file for this? profile?
1445     def lookup(self, keyvalue):
1446         '''Locate a particular node by its key property and return its id.
1448         If this class has no key property, a TypeError is raised.  If the
1449         'keyvalue' matches one of the values for the key property among
1450         the nodes in this class, the matching node's id is returned;
1451         otherwise a KeyError is raised.
1452         '''
1453         if not self.key:
1454             raise TypeError, 'No key property set for class %s'%self.classname
1455         cldb = self.db.getclassdb(self.classname)
1456         try:
1457             for nodeid in self.getnodeids(cldb):
1458                 node = self.db.getnode(self.classname, nodeid, cldb)
1459                 if node.has_key(self.db.RETIRED_FLAG):
1460                     continue
1461                 if node[self.key] == keyvalue:
1462                     return nodeid
1463         finally:
1464             cldb.close()
1465         raise KeyError, 'No key (%s) value "%s" for "%s"'%(self.key,
1466             keyvalue, self.classname)
1468     # change from spec - allows multiple props to match
1469     def find(self, **propspec):
1470         '''Get the ids of items in this class which link to the given items.
1472         'propspec' consists of keyword args propname=itemid or
1473                    propname={itemid:1, }
1474         'propname' must be the name of a property in this class, or a
1475                    KeyError is raised.  That property must be a Link or
1476                    Multilink property, or a TypeError is raised.
1478         Any item in this class whose 'propname' property links to any of the
1479         itemids will be returned. Used by the full text indexing, which knows
1480         that "foo" occurs in msg1, msg3 and file7, so we have hits on these
1481         issues:
1483             db.issue.find(messages={'1':1,'3':1}, files={'7':1})
1484         '''
1485         propspec = propspec.items()
1486         for propname, itemids in propspec:
1487             # check the prop is OK
1488             prop = self.properties[propname]
1489             if not isinstance(prop, Link) and not isinstance(prop, Multilink):
1490                 raise TypeError, "'%s' not a Link/Multilink property"%propname
1492         # ok, now do the find
1493         cldb = self.db.getclassdb(self.classname)
1494         l = []
1495         try:
1496             for id in self.getnodeids(db=cldb):
1497                 item = self.db.getnode(self.classname, id, db=cldb)
1498                 if item.has_key(self.db.RETIRED_FLAG):
1499                     continue
1500                 for propname, itemids in propspec:
1501                     # can't test if the item doesn't have this property
1502                     if not item.has_key(propname):
1503                         continue
1504                     if type(itemids) is not type({}):
1505                         itemids = {itemids:1}
1507                     # grab the property definition and its value on this item
1508                     prop = self.properties[propname]
1509                     value = item[propname]
1510                     if isinstance(prop, Link) and itemids.has_key(value):
1511                         l.append(id)
1512                         break
1513                     elif isinstance(prop, Multilink):
1514                         hit = 0
1515                         for v in value:
1516                             if itemids.has_key(v):
1517                                 l.append(id)
1518                                 hit = 1
1519                                 break
1520                         if hit:
1521                             break
1522         finally:
1523             cldb.close()
1524         return l
1526     def stringFind(self, **requirements):
1527         '''Locate a particular node by matching a set of its String
1528         properties in a caseless search.
1530         If the property is not a String property, a TypeError is raised.
1531         
1532         The return is a list of the id of all nodes that match.
1533         '''
1534         for propname in requirements.keys():
1535             prop = self.properties[propname]
1536             if isinstance(not prop, String):
1537                 raise TypeError, "'%s' not a String property"%propname
1538             requirements[propname] = requirements[propname].lower()
1539         l = []
1540         cldb = self.db.getclassdb(self.classname)
1541         try:
1542             for nodeid in self.getnodeids(cldb):
1543                 node = self.db.getnode(self.classname, nodeid, cldb)
1544                 if node.has_key(self.db.RETIRED_FLAG):
1545                     continue
1546                 for key, value in requirements.items():
1547                     if not node.has_key(key):
1548                         break
1549                     if node[key] is None or node[key].lower() != value:
1550                         break
1551                 else:
1552                     l.append(nodeid)
1553         finally:
1554             cldb.close()
1555         return l
1557     def list(self):
1558         ''' Return a list of the ids of the active nodes in this class.
1559         '''
1560         l = []
1561         cn = self.classname
1562         cldb = self.db.getclassdb(cn)
1563         try:
1564             for nodeid in self.getnodeids(cldb):
1565                 node = self.db.getnode(cn, nodeid, cldb)
1566                 if node.has_key(self.db.RETIRED_FLAG):
1567                     continue
1568                 l.append(nodeid)
1569         finally:
1570             cldb.close()
1571         l.sort()
1572         return l
1574     def getnodeids(self, db=None):
1575         ''' Return a list of ALL nodeids
1576         '''
1577         if __debug__:
1578             print >>hyperdb.DEBUG, 'getnodeids', (self, self.classname, db)
1580         res = []
1582         # start off with the new nodes
1583         if self.db.newnodes.has_key(self.classname):
1584             res += self.db.newnodes[self.classname].keys()
1586         if db is None:
1587             db = self.db.getclassdb(self.classname)
1588         res = res + db.keys()
1590         # remove the uncommitted, destroyed nodes
1591         if self.db.destroyednodes.has_key(self.classname):
1592             for nodeid in self.db.destroyednodes[self.classname].keys():
1593                 if db.has_key(nodeid):
1594                     res.remove(nodeid)
1596         return res
1598     def filter(self, search_matches, filterspec, sort=(None,None),
1599             group=(None,None), num_re = re.compile('^\d+$')):
1600         ''' Return a list of the ids of the active nodes in this class that
1601             match the 'filter' spec, sorted by the group spec and then the
1602             sort spec.
1604             "filterspec" is {propname: value(s)}
1605             "sort" and "group" are (dir, prop) where dir is '+', '-' or None
1606                                and prop is a prop name or None
1607             "search_matches" is {nodeid: marker}
1609             The filter must match all properties specificed - but if the
1610             property value to match is a list, any one of the values in the
1611             list may match for that property to match. Unless the property
1612             is a Multilink, in which case the item's property list must
1613             match the filterspec list.
1614         '''
1615         cn = self.classname
1617         # optimise filterspec
1618         l = []
1619         props = self.getprops()
1620         LINK = 0
1621         MULTILINK = 1
1622         STRING = 2
1623         DATE = 3
1624         INTERVAL = 4
1625         OTHER = 6
1626         
1627         timezone = self.db.getUserTimezone()
1628         for k, v in filterspec.items():
1629             propclass = props[k]
1630             if isinstance(propclass, Link):
1631                 if type(v) is not type([]):
1632                     v = [v]
1633                 # replace key values with node ids
1634                 u = []
1635                 link_class =  self.db.classes[propclass.classname]
1636                 for entry in v:
1637                     # the value -1 is a special "not set" sentinel
1638                     if entry == '-1':
1639                         entry = None
1640                     elif not num_re.match(entry):
1641                         try:
1642                             entry = link_class.lookup(entry)
1643                         except (TypeError,KeyError):
1644                             raise ValueError, 'property "%s": %s not a %s'%(
1645                                 k, entry, self.properties[k].classname)
1646                     u.append(entry)
1648                 l.append((LINK, k, u))
1649             elif isinstance(propclass, Multilink):
1650                 # the value -1 is a special "not set" sentinel
1651                 if v in ('-1', ['-1']):
1652                     v = []
1653                 elif type(v) is not type([]):
1654                     v = [v]
1656                 # replace key values with node ids
1657                 u = []
1658                 link_class =  self.db.classes[propclass.classname]
1659                 for entry in v:
1660                     if not num_re.match(entry):
1661                         try:
1662                             entry = link_class.lookup(entry)
1663                         except (TypeError,KeyError):
1664                             raise ValueError, 'new property "%s": %s not a %s'%(
1665                                 k, entry, self.properties[k].classname)
1666                     u.append(entry)
1667                 u.sort()
1668                 l.append((MULTILINK, k, u))
1669             elif isinstance(propclass, String) and k != 'id':
1670                 if type(v) is not type([]):
1671                     v = [v]
1672                 m = []
1673                 for v in v:
1674                     # simple glob searching
1675                     v = re.sub(r'([\|\{\}\\\.\+\[\]\(\)])', r'\\\1', v)
1676                     v = v.replace('?', '.')
1677                     v = v.replace('*', '.*?')
1678                     m.append(v)
1679                 m = re.compile('(%s)'%('|'.join(m)), re.I)
1680                 l.append((STRING, k, m))
1681             elif isinstance(propclass, Date):
1682                 try:
1683                     date_rng = Range(v, date.Date, offset=timezone)
1684                     l.append((DATE, k, date_rng))
1685                 except ValueError:
1686                     # If range creation fails - ignore that search parameter
1687                     pass
1688             elif isinstance(propclass, Interval):
1689                 try:
1690                     intv_rng = Range(v, date.Interval)
1691                     l.append((INTERVAL, k, intv_rng))
1692                 except ValueError:
1693                     # If range creation fails - ignore that search parameter
1694                     pass
1695                 
1696             elif isinstance(propclass, Boolean):
1697                 if type(v) is type(''):
1698                     bv = v.lower() in ('yes', 'true', 'on', '1')
1699                 else:
1700                     bv = v
1701                 l.append((OTHER, k, bv))
1702             elif isinstance(propclass, Number):
1703                 l.append((OTHER, k, int(v)))
1704             else:
1705                 l.append((OTHER, k, v))
1706         filterspec = l
1708         # now, find all the nodes that are active and pass filtering
1709         l = []
1710         cldb = self.db.getclassdb(cn)
1711         try:
1712             # TODO: only full-scan once (use items())
1713             for nodeid in self.getnodeids(cldb):
1714                 node = self.db.getnode(cn, nodeid, cldb)
1715                 if node.has_key(self.db.RETIRED_FLAG):
1716                     continue
1717                 # apply filter
1718                 for t, k, v in filterspec:
1719                     # handle the id prop
1720                     if k == 'id' and v == nodeid:
1721                         continue
1723                     # make sure the node has the property
1724                     if not node.has_key(k):
1725                         # this node doesn't have this property, so reject it
1726                         break
1728                     # now apply the property filter
1729                     if t == LINK:
1730                         # link - if this node's property doesn't appear in the
1731                         # filterspec's nodeid list, skip it
1732                         if node[k] not in v:
1733                             break
1734                     elif t == MULTILINK:
1735                         # multilink - if any of the nodeids required by the
1736                         # filterspec aren't in this node's property, then skip
1737                         # it
1738                         have = node[k]
1739                         # check for matching the absence of multilink values
1740                         if not v and have:
1741                             break
1743                         # othewise, make sure this node has each of the
1744                         # required values
1745                         for want in v:
1746                             if want not in have:
1747                                 break
1748                         else:
1749                             continue
1750                         break
1751                     elif t == STRING:
1752                         if node[k] is None:
1753                             break
1754                         # RE search
1755                         if not v.search(node[k]):
1756                             break
1757                     elif t == DATE or t == INTERVAL:
1758                         if node[k] is None:
1759                             break
1760                         if v.to_value:
1761                             if not (v.from_value <= node[k] and v.to_value >= node[k]):
1762                                 break
1763                         else:
1764                             if not (v.from_value <= node[k]):
1765                                 break
1766                     elif t == OTHER:
1767                         # straight value comparison for the other types
1768                         if node[k] != v:
1769                             break
1770                 else:
1771                     l.append((nodeid, node))
1772         finally:
1773             cldb.close()
1774         l.sort()
1776         # filter based on full text search
1777         if search_matches is not None:
1778             k = []
1779             for v in l:
1780                 if search_matches.has_key(v[0]):
1781                     k.append(v)
1782             l = k
1784         # now, sort the result
1785         def sortfun(a, b, sort=sort, group=group, properties=self.getprops(),
1786                 db = self.db, cl=self):
1787             a_id, an = a
1788             b_id, bn = b
1789             # sort by group and then sort
1790             for dir, prop in group, sort:
1791                 if dir is None or prop is None: continue
1793                 # sorting is class-specific
1794                 propclass = properties[prop]
1796                 # handle the properties that might be "faked"
1797                 # also, handle possible missing properties
1798                 try:
1799                     if not an.has_key(prop):
1800                         an[prop] = cl.get(a_id, prop)
1801                     av = an[prop]
1802                 except KeyError:
1803                     # the node doesn't have a value for this property
1804                     if isinstance(propclass, Multilink): av = []
1805                     else: av = ''
1806                 try:
1807                     if not bn.has_key(prop):
1808                         bn[prop] = cl.get(b_id, prop)
1809                     bv = bn[prop]
1810                 except KeyError:
1811                     # the node doesn't have a value for this property
1812                     if isinstance(propclass, Multilink): bv = []
1813                     else: bv = ''
1815                 # String and Date values are sorted in the natural way
1816                 if isinstance(propclass, String):
1817                     # clean up the strings
1818                     if av and av[0] in string.uppercase:
1819                         av = av.lower()
1820                     if bv and bv[0] in string.uppercase:
1821                         bv = bv.lower()
1822                 if (isinstance(propclass, String) or
1823                         isinstance(propclass, Date)):
1824                     # it might be a string that's really an integer
1825                     try:
1826                         av = int(av)
1827                         bv = int(bv)
1828                     except:
1829                         pass
1830                     if dir == '+':
1831                         r = cmp(av, bv)
1832                         if r != 0: return r
1833                     elif dir == '-':
1834                         r = cmp(bv, av)
1835                         if r != 0: return r
1837                 # Link properties are sorted according to the value of
1838                 # the "order" property on the linked nodes if it is
1839                 # present; or otherwise on the key string of the linked
1840                 # nodes; or finally on  the node ids.
1841                 elif isinstance(propclass, Link):
1842                     link = db.classes[propclass.classname]
1843                     if av is None and bv is not None: return -1
1844                     if av is not None and bv is None: return 1
1845                     if av is None and bv is None: continue
1846                     if link.getprops().has_key('order'):
1847                         if dir == '+':
1848                             r = cmp(link.get(av, 'order'),
1849                                 link.get(bv, 'order'))
1850                             if r != 0: return r
1851                         elif dir == '-':
1852                             r = cmp(link.get(bv, 'order'),
1853                                 link.get(av, 'order'))
1854                             if r != 0: return r
1855                     elif link.getkey():
1856                         key = link.getkey()
1857                         if dir == '+':
1858                             r = cmp(link.get(av, key), link.get(bv, key))
1859                             if r != 0: return r
1860                         elif dir == '-':
1861                             r = cmp(link.get(bv, key), link.get(av, key))
1862                             if r != 0: return r
1863                     else:
1864                         if dir == '+':
1865                             r = cmp(av, bv)
1866                             if r != 0: return r
1867                         elif dir == '-':
1868                             r = cmp(bv, av)
1869                             if r != 0: return r
1871                 else:
1872                     # all other types just compare
1873                     if dir == '+':
1874                         r = cmp(av, bv)
1875                     elif dir == '-':
1876                         r = cmp(bv, av)
1877                     if r != 0: return r
1878                     
1879             # end for dir, prop in sort, group:
1880             # if all else fails, compare the ids
1881             return cmp(a[0], b[0])
1883         l.sort(sortfun)
1884         return [i[0] for i in l]
1886     def count(self):
1887         '''Get the number of nodes in this class.
1889         If the returned integer is 'numnodes', the ids of all the nodes
1890         in this class run from 1 to numnodes, and numnodes+1 will be the
1891         id of the next node to be created in this class.
1892         '''
1893         return self.db.countnodes(self.classname)
1895     # Manipulating properties:
1897     def getprops(self, protected=1):
1898         '''Return a dictionary mapping property names to property objects.
1899            If the "protected" flag is true, we include protected properties -
1900            those which may not be modified.
1902            In addition to the actual properties on the node, these
1903            methods provide the "creation" and "activity" properties. If the
1904            "protected" flag is true, we include protected properties - those
1905            which may not be modified.
1906         '''
1907         d = self.properties.copy()
1908         if protected:
1909             d['id'] = String()
1910             d['creation'] = hyperdb.Date()
1911             d['activity'] = hyperdb.Date()
1912             d['creator'] = hyperdb.Link('user')
1913         return d
1915     def addprop(self, **properties):
1916         '''Add properties to this class.
1918         The keyword arguments in 'properties' must map names to property
1919         objects, or a TypeError is raised.  None of the keys in 'properties'
1920         may collide with the names of existing properties, or a ValueError
1921         is raised before any properties have been added.
1922         '''
1923         for key in properties.keys():
1924             if self.properties.has_key(key):
1925                 raise ValueError, key
1926         self.properties.update(properties)
1928     def index(self, nodeid):
1929         '''Add (or refresh) the node to search indexes
1930         '''
1931         # find all the String properties that have indexme
1932         for prop, propclass in self.getprops().items():
1933             if isinstance(propclass, String) and propclass.indexme:
1934                 try:
1935                     value = str(self.get(nodeid, prop))
1936                 except IndexError:
1937                     # node no longer exists - entry should be removed
1938                     self.db.indexer.purge_entry((self.classname, nodeid, prop))
1939                 else:
1940                     # and index them under (classname, nodeid, property)
1941                     self.db.indexer.add_text((self.classname, nodeid, prop),
1942                         value)
1944     #
1945     # Detector interface
1946     #
1947     def audit(self, event, detector):
1948         '''Register a detector
1949         '''
1950         l = self.auditors[event]
1951         if detector not in l:
1952             self.auditors[event].append(detector)
1954     def fireAuditors(self, action, nodeid, newvalues):
1955         '''Fire all registered auditors.
1956         '''
1957         for audit in self.auditors[action]:
1958             audit(self.db, self, nodeid, newvalues)
1960     def react(self, event, detector):
1961         '''Register a detector
1962         '''
1963         l = self.reactors[event]
1964         if detector not in l:
1965             self.reactors[event].append(detector)
1967     def fireReactors(self, action, nodeid, oldvalues):
1968         '''Fire all registered reactors.
1969         '''
1970         for react in self.reactors[action]:
1971             react(self.db, self, nodeid, oldvalues)
1973 class FileClass(Class, hyperdb.FileClass):
1974     '''This class defines a large chunk of data. To support this, it has a
1975        mandatory String property "content" which is typically saved off
1976        externally to the hyperdb.
1978        The default MIME type of this data is defined by the
1979        "default_mime_type" class attribute, which may be overridden by each
1980        node if the class defines a "type" String property.
1981     '''
1982     default_mime_type = 'text/plain'
1984     def create(self, **propvalues):
1985         ''' Snarf the "content" propvalue and store in a file
1986         '''
1987         # we need to fire the auditors now, or the content property won't
1988         # be in propvalues for the auditors to play with
1989         self.fireAuditors('create', None, propvalues)
1991         # now remove the content property so it's not stored in the db
1992         content = propvalues['content']
1993         del propvalues['content']
1995         # do the database create
1996         newid = Class.create_inner(self, **propvalues)
1998         # fire reactors
1999         self.fireReactors('create', newid, None)
2001         # store off the content as a file
2002         self.db.storefile(self.classname, newid, None, content)
2003         return newid
2005     def import_list(self, propnames, proplist):
2006         ''' Trap the "content" property...
2007         '''
2008         # dupe this list so we don't affect others
2009         propnames = propnames[:]
2011         # extract the "content" property from the proplist
2012         i = propnames.index('content')
2013         content = eval(proplist[i])
2014         del propnames[i]
2015         del proplist[i]
2017         # do the normal import
2018         newid = Class.import_list(self, propnames, proplist)
2020         # save off the "content" file
2021         self.db.storefile(self.classname, newid, None, content)
2022         return newid
2024     def get(self, nodeid, propname, default=_marker, cache=1):
2025         ''' Trap the content propname and get it from the file
2027         'cache' exists for backwards compatibility, and is not used.
2028         '''
2029         poss_msg = 'Possibly an access right configuration problem.'
2030         if propname == 'content':
2031             try:
2032                 return self.db.getfile(self.classname, nodeid, None)
2033             except IOError, (strerror):
2034                 # XXX by catching this we donot see an error in the log.
2035                 return 'ERROR reading file: %s%s\n%s\n%s'%(
2036                         self.classname, nodeid, poss_msg, strerror)
2037         if default is not _marker:
2038             return Class.get(self, nodeid, propname, default)
2039         else:
2040             return Class.get(self, nodeid, propname)
2042     def getprops(self, protected=1):
2043         ''' In addition to the actual properties on the node, these methods
2044             provide the "content" property. If the "protected" flag is true,
2045             we include protected properties - those which may not be
2046             modified.
2047         '''
2048         d = Class.getprops(self, protected=protected).copy()
2049         d['content'] = hyperdb.String()
2050         return d
2052     def index(self, nodeid):
2053         ''' Index the node in the search index.
2055             We want to index the content in addition to the normal String
2056             property indexing.
2057         '''
2058         # perform normal indexing
2059         Class.index(self, nodeid)
2061         # get the content to index
2062         content = self.get(nodeid, 'content')
2064         # figure the mime type
2065         if self.properties.has_key('type'):
2066             mime_type = self.get(nodeid, 'type')
2067         else:
2068             mime_type = self.default_mime_type
2070         # and index!
2071         self.db.indexer.add_text((self.classname, nodeid, 'content'), content,
2072             mime_type)
2074 # deviation from spec - was called ItemClass
2075 class IssueClass(Class, roundupdb.IssueClass):
2076     # Overridden methods:
2077     def __init__(self, db, classname, **properties):
2078         '''The newly-created class automatically includes the "messages",
2079         "files", "nosy", and "superseder" properties.  If the 'properties'
2080         dictionary attempts to specify any of these properties or a
2081         "creation" or "activity" property, a ValueError is raised.
2082         '''
2083         if not properties.has_key('title'):
2084             properties['title'] = hyperdb.String(indexme='yes')
2085         if not properties.has_key('messages'):
2086             properties['messages'] = hyperdb.Multilink("msg")
2087         if not properties.has_key('files'):
2088             properties['files'] = hyperdb.Multilink("file")
2089         if not properties.has_key('nosy'):
2090             # note: journalling is turned off as it really just wastes
2091             # space. this behaviour may be overridden in an instance
2092             properties['nosy'] = hyperdb.Multilink("user", do_journal="no")
2093         if not properties.has_key('superseder'):
2094             properties['superseder'] = hyperdb.Multilink(classname)
2095         Class.__init__(self, db, classname, **properties)