Code

- Extended getuid() to replace figure_curuserid().
[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.127 2003-09-08 20:39:18 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()
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     #
207     # Node IDs
208     #
209     def newid(self, classname):
210         ''' Generate a new id for the given class
211         '''
212         # open the ids DB - create if if doesn't exist
213         db = self.opendb('_ids', 'c')
214         if db.has_key(classname):
215             newid = db[classname] = str(int(db[classname]) + 1)
216         else:
217             # the count() bit is transitional - older dbs won't start at 1
218             newid = str(self.getclass(classname).count()+1)
219             db[classname] = newid
220         db.close()
221         return newid
223     def setid(self, classname, setid):
224         ''' Set the id counter: used during import of database
225         '''
226         # open the ids DB - create if if doesn't exist
227         db = self.opendb('_ids', 'c')
228         db[classname] = str(setid)
229         db.close()
231     #
232     # Nodes
233     #
234     def addnode(self, classname, nodeid, node):
235         ''' add the specified node to its class's db
236         '''
237         if __debug__:
238             print >>hyperdb.DEBUG, 'addnode', (self, classname, nodeid, node)
240         # we'll be supplied these props if we're doing an import
241         if not node.has_key('creator'):
242             # add in the "calculated" properties (dupe so we don't affect
243             # calling code's node assumptions)
244             node = node.copy()
245             node['creator'] = self.getuid()
246             node['creation'] = node['activity'] = date.Date()
248         self.newnodes.setdefault(classname, {})[nodeid] = 1
249         self.cache.setdefault(classname, {})[nodeid] = node
250         self.savenode(classname, nodeid, node)
252     def setnode(self, classname, nodeid, node):
253         ''' change the specified node
254         '''
255         if __debug__:
256             print >>hyperdb.DEBUG, 'setnode', (self, classname, nodeid, node)
257         self.dirtynodes.setdefault(classname, {})[nodeid] = 1
259         # update the activity time (dupe so we don't affect
260         # calling code's node assumptions)
261         node = node.copy()
262         node['activity'] = date.Date()
264         # can't set without having already loaded the node
265         self.cache[classname][nodeid] = node
266         self.savenode(classname, nodeid, node)
268     def savenode(self, classname, nodeid, node):
269         ''' perform the saving of data specified by the set/addnode
270         '''
271         if __debug__:
272             print >>hyperdb.DEBUG, 'savenode', (self, classname, nodeid, node)
273         self.transactions.append((self.doSaveNode, (classname, nodeid, node)))
275     def getnode(self, classname, nodeid, db=None, cache=1):
276         ''' get a node from the database
278             Note the "cache" parameter is not used, and exists purely for
279             backward compatibility!
280         '''
281         if __debug__:
282             print >>hyperdb.DEBUG, 'getnode', (self, classname, nodeid, db)
284         # try the cache
285         cache_dict = self.cache.setdefault(classname, {})
286         if cache_dict.has_key(nodeid):
287             if __debug__:
288                 print >>hyperdb.TRACE, 'get %s %s cached'%(classname,
289                     nodeid)
290             return cache_dict[nodeid]
292         if __debug__:
293             print >>hyperdb.TRACE, 'get %s %s'%(classname, nodeid)
295         # get from the database and save in the cache
296         if db is None:
297             db = self.getclassdb(classname)
298         if not db.has_key(nodeid):
299             # try the cache - might be a brand-new node
300             cache_dict = self.cache.setdefault(classname, {})
301             if cache_dict.has_key(nodeid):
302                 if __debug__:
303                     print >>hyperdb.TRACE, 'get %s %s cached'%(classname,
304                         nodeid)
305                 return cache_dict[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) and v is not None:
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) and v is not None:
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
448     #
449     # Files - special node properties
450     # inherited from FileStorage
452     #
453     # Journal
454     #
455     def addjournal(self, classname, nodeid, action, params, creator=None,
456             creation=None):
457         ''' Journal the Action
458         'action' may be:
460             'create' or 'set' -- 'params' is a dictionary of property values
461             'link' or 'unlink' -- 'params' is (classname, nodeid, propname)
462             'retire' -- 'params' is None
463         '''
464         if __debug__:
465             print >>hyperdb.DEBUG, 'addjournal', (self, classname, nodeid,
466                 action, params, creator, creation)
467         self.transactions.append((self.doSaveJournal, (classname, nodeid,
468             action, params, creator, creation)))
470     def getjournal(self, classname, nodeid):
471         ''' get the journal for id
473             Raise IndexError if the node doesn't exist (as per history()'s
474             API)
475         '''
476         if __debug__:
477             print >>hyperdb.DEBUG, 'getjournal', (self, classname, nodeid)
479         # our journal result
480         res = []
482         # add any journal entries for transactions not committed to the
483         # database
484         for method, args in self.transactions:
485             if method != self.doSaveJournal:
486                 continue
487             (cache_classname, cache_nodeid, cache_action, cache_params,
488                 cache_creator, cache_creation) = args
489             if cache_classname == classname and cache_nodeid == nodeid:
490                 if not cache_creator:
491                     cache_creator = self.getuid()
492                 if not cache_creation:
493                     cache_creation = date.Date()
494                 res.append((cache_nodeid, cache_creation, cache_creator,
495                     cache_action, cache_params))
497         # attempt to open the journal - in some rare cases, the journal may
498         # not exist
499         try:
500             db = self.opendb('journals.%s'%classname, 'r')
501         except anydbm.error, error:
502             if str(error) == "need 'c' or 'n' flag to open new db":
503                 raise IndexError, 'no such %s %s'%(classname, nodeid)
504             elif error.args[0] != 2:
505                 raise
506             raise IndexError, 'no such %s %s'%(classname, nodeid)
507         try:
508             journal = marshal.loads(db[nodeid])
509         except KeyError:
510             db.close()
511             if res:
512                 # we have some unsaved journal entries, be happy!
513                 return res
514             raise IndexError, 'no such %s %s'%(classname, nodeid)
515         db.close()
517         # add all the saved journal entries for this node
518         for nodeid, date_stamp, user, action, params in journal:
519             res.append((nodeid, date.Date(date_stamp), user, action, params))
520         return res
522     def pack(self, pack_before):
523         ''' Delete all journal entries except "create" before 'pack_before'.
524         '''
525         if __debug__:
526             print >>hyperdb.DEBUG, 'packjournal', (self, pack_before)
528         pack_before = pack_before.serialise()
529         for classname in self.getclasses():
530             # get the journal db
531             db_name = 'journals.%s'%classname
532             path = os.path.join(os.getcwd(), self.dir, classname)
533             db_type = self.determine_db_type(path)
534             db = self.opendb(db_name, 'w')
536             for key in db.keys():
537                 # get the journal for this db entry
538                 journal = marshal.loads(db[key])
539                 l = []
540                 last_set_entry = None
541                 for entry in journal:
542                     # unpack the entry
543                     (nodeid, date_stamp, self.journaltag, action, 
544                         params) = entry
545                     # if the entry is after the pack date, _or_ the initial
546                     # create entry, then it stays
547                     if date_stamp > pack_before or action == 'create':
548                         l.append(entry)
549                 db[key] = marshal.dumps(l)
550             if db_type == 'gdbm':
551                 db.reorganize()
552             db.close()
553             
555     #
556     # Basic transaction support
557     #
558     def commit(self):
559         ''' Commit the current transactions.
560         '''
561         if __debug__:
562             print >>hyperdb.DEBUG, 'commit', (self,)
564         # keep a handle to all the database files opened
565         self.databases = {}
567         # now, do all the transactions
568         reindex = {}
569         for method, args in self.transactions:
570             reindex[method(*args)] = 1
572         # now close all the database files
573         for db in self.databases.values():
574             db.close()
575         del self.databases
577         # reindex the nodes that request it
578         for classname, nodeid in filter(None, reindex.keys()):
579             print >>hyperdb.DEBUG, 'commit.reindex', (classname, nodeid)
580             self.getclass(classname).index(nodeid)
582         # save the indexer state
583         self.indexer.save_index()
585         self.clearCache()
587     def clearCache(self):
588         # all transactions committed, back to normal
589         self.cache = {}
590         self.dirtynodes = {}
591         self.newnodes = {}
592         self.destroyednodes = {}
593         self.transactions = []
595     def getCachedClassDB(self, classname):
596         ''' get the class db, looking in our cache of databases for commit
597         '''
598         # get the database handle
599         db_name = 'nodes.%s'%classname
600         if not self.databases.has_key(db_name):
601             self.databases[db_name] = self.getclassdb(classname, 'c')
602         return self.databases[db_name]
604     def doSaveNode(self, classname, nodeid, node):
605         if __debug__:
606             print >>hyperdb.DEBUG, 'doSaveNode', (self, classname, nodeid,
607                 node)
609         db = self.getCachedClassDB(classname)
611         # now save the marshalled data
612         db[nodeid] = marshal.dumps(self.serialise(classname, node))
614         # return the classname, nodeid so we reindex this content
615         return (classname, nodeid)
617     def getCachedJournalDB(self, classname):
618         ''' get the journal db, looking in our cache of databases for commit
619         '''
620         # get the database handle
621         db_name = 'journals.%s'%classname
622         if not self.databases.has_key(db_name):
623             self.databases[db_name] = self.opendb(db_name, 'c')
624         return self.databases[db_name]
626     def doSaveJournal(self, classname, nodeid, action, params, creator,
627             creation):
628         # serialise the parameters now if necessary
629         if isinstance(params, type({})):
630             if action in ('set', 'create'):
631                 params = self.serialise(classname, params)
633         # handle supply of the special journalling parameters (usually
634         # supplied on importing an existing database)
635         if creator:
636             journaltag = creator
637         else:
638             journaltag = self.getuid()
639         if creation:
640             journaldate = creation.serialise()
641         else:
642             journaldate = date.Date().serialise()
644         # create the journal entry
645         entry = (nodeid, journaldate, journaltag, action, params)
647         if __debug__:
648             print >>hyperdb.DEBUG, 'doSaveJournal', entry
650         db = self.getCachedJournalDB(classname)
652         # now insert the journal entry
653         if db.has_key(nodeid):
654             # append to existing
655             s = db[nodeid]
656             l = marshal.loads(s)
657             l.append(entry)
658         else:
659             l = [entry]
661         db[nodeid] = marshal.dumps(l)
663     def doDestroyNode(self, classname, nodeid):
664         if __debug__:
665             print >>hyperdb.DEBUG, 'doDestroyNode', (self, classname, nodeid)
667         # delete from the class database
668         db = self.getCachedClassDB(classname)
669         if db.has_key(nodeid):
670             del db[nodeid]
672         # delete from the database
673         db = self.getCachedJournalDB(classname)
674         if db.has_key(nodeid):
675             del db[nodeid]
677         # return the classname, nodeid so we reindex this content
678         return (classname, nodeid)
680     def rollback(self):
681         ''' Reverse all actions from the current transaction.
682         '''
683         if __debug__:
684             print >>hyperdb.DEBUG, 'rollback', (self, )
685         for method, args in self.transactions:
686             # delete temporary files
687             if method == self.doStoreFile:
688                 self.rollbackStoreFile(*args)
689         self.cache = {}
690         self.dirtynodes = {}
691         self.newnodes = {}
692         self.destroyednodes = {}
693         self.transactions = []
695     def close(self):
696         ''' Nothing to do
697         '''
698         if self.lockfile is not None:
699             locking.release_lock(self.lockfile)
700         if self.lockfile is not None:
701             self.lockfile.close()
702             self.lockfile = None
704 _marker = []
705 class Class(hyperdb.Class):
706     '''The handle to a particular class of nodes in a hyperdatabase.'''
708     def __init__(self, db, classname, **properties):
709         '''Create a new class with a given name and property specification.
711         'classname' must not collide with the name of an existing class,
712         or a ValueError is raised.  The keyword arguments in 'properties'
713         must map names to property objects, or a TypeError is raised.
714         '''
715         if (properties.has_key('creation') or properties.has_key('activity')
716                 or properties.has_key('creator')):
717             raise ValueError, '"creation", "activity" and "creator" are '\
718                 'reserved'
720         self.classname = classname
721         self.properties = properties
722         self.db = weakref.proxy(db)       # use a weak ref to avoid circularity
723         self.key = ''
725         # should we journal changes (default yes)
726         self.do_journal = 1
728         # do the db-related init stuff
729         db.addclass(self)
731         self.auditors = {'create': [], 'set': [], 'retire': [], 'restore': []}
732         self.reactors = {'create': [], 'set': [], 'retire': [], 'restore': []}
734     def enableJournalling(self):
735         '''Turn journalling on for this class
736         '''
737         self.do_journal = 1
739     def disableJournalling(self):
740         '''Turn journalling off for this class
741         '''
742         self.do_journal = 0
744     # Editing nodes:
746     def create(self, **propvalues):
747         '''Create a new node of this class and return its id.
749         The keyword arguments in 'propvalues' map property names to values.
751         The values of arguments must be acceptable for the types of their
752         corresponding properties or a TypeError is raised.
753         
754         If this class has a key property, it must be present and its value
755         must not collide with other key strings or a ValueError is raised.
756         
757         Any other properties on this class that are missing from the
758         'propvalues' dictionary are set to None.
759         
760         If an id in a link or multilink property does not refer to a valid
761         node, an IndexError is raised.
763         These operations trigger detectors and can be vetoed.  Attempts
764         to modify the "creation" or "activity" properties cause a KeyError.
765         '''
766         self.fireAuditors('create', None, propvalues)
767         newid = self.create_inner(**propvalues)
768         self.fireReactors('create', newid, None)
769         return newid
771     def create_inner(self, **propvalues):
772         ''' Called by create, in-between the audit and react calls.
773         '''
774         if propvalues.has_key('id'):
775             raise KeyError, '"id" is reserved'
777         if self.db.journaltag is None:
778             raise DatabaseError, 'Database open read-only'
780         if propvalues.has_key('creation') or propvalues.has_key('activity'):
781             raise KeyError, '"creation" and "activity" are reserved'
782         # new node's id
783         newid = self.db.newid(self.classname)
785         # validate propvalues
786         num_re = re.compile('^\d+$')
787         for key, value in propvalues.items():
788             if key == self.key:
789                 try:
790                     self.lookup(value)
791                 except KeyError:
792                     pass
793                 else:
794                     raise ValueError, 'node with key "%s" exists'%value
796             # try to handle this property
797             try:
798                 prop = self.properties[key]
799             except KeyError:
800                 raise KeyError, '"%s" has no property "%s"'%(self.classname,
801                     key)
803             if value is not None and isinstance(prop, Link):
804                 if type(value) != type(''):
805                     raise ValueError, 'link value must be String'
806                 link_class = self.properties[key].classname
807                 # if it isn't a number, it's a key
808                 if not num_re.match(value):
809                     try:
810                         value = self.db.classes[link_class].lookup(value)
811                     except (TypeError, KeyError):
812                         raise IndexError, 'new property "%s": %s not a %s'%(
813                             key, value, link_class)
814                 elif not self.db.getclass(link_class).hasnode(value):
815                     raise IndexError, '%s has no node %s'%(link_class, value)
817                 # save off the value
818                 propvalues[key] = value
820                 # register the link with the newly linked node
821                 if self.do_journal and self.properties[key].do_journal:
822                     self.db.addjournal(link_class, value, 'link',
823                         (self.classname, newid, key))
825             elif isinstance(prop, Multilink):
826                 if type(value) != type([]):
827                     raise TypeError, 'new property "%s" not a list of ids'%key
829                 # clean up and validate the list of links
830                 link_class = self.properties[key].classname
831                 l = []
832                 for entry in value:
833                     if type(entry) != type(''):
834                         raise ValueError, '"%s" multilink value (%r) '\
835                             'must contain Strings'%(key, value)
836                     # if it isn't a number, it's a key
837                     if not num_re.match(entry):
838                         try:
839                             entry = self.db.classes[link_class].lookup(entry)
840                         except (TypeError, KeyError):
841                             raise IndexError, 'new property "%s": %s not a %s'%(
842                                 key, entry, self.properties[key].classname)
843                     l.append(entry)
844                 value = l
845                 propvalues[key] = value
847                 # handle additions
848                 for nodeid in value:
849                     if not self.db.getclass(link_class).hasnode(nodeid):
850                         raise IndexError, '%s has no node %s'%(link_class,
851                             nodeid)
852                     # register the link with the newly linked node
853                     if self.do_journal and self.properties[key].do_journal:
854                         self.db.addjournal(link_class, nodeid, 'link',
855                             (self.classname, newid, key))
857             elif isinstance(prop, String):
858                 if type(value) != type('') and type(value) != type(u''):
859                     raise TypeError, 'new property "%s" not a string'%key
861             elif isinstance(prop, Password):
862                 if not isinstance(value, password.Password):
863                     raise TypeError, 'new property "%s" not a Password'%key
865             elif isinstance(prop, Date):
866                 if value is not None and not isinstance(value, date.Date):
867                     raise TypeError, 'new property "%s" not a Date'%key
869             elif isinstance(prop, Interval):
870                 if value is not None and not isinstance(value, date.Interval):
871                     raise TypeError, 'new property "%s" not an Interval'%key
873             elif value is not None and isinstance(prop, Number):
874                 try:
875                     float(value)
876                 except ValueError:
877                     raise TypeError, 'new property "%s" not numeric'%key
879             elif value is not None and isinstance(prop, Boolean):
880                 try:
881                     int(value)
882                 except ValueError:
883                     raise TypeError, 'new property "%s" not boolean'%key
885         # make sure there's data where there needs to be
886         for key, prop in self.properties.items():
887             if propvalues.has_key(key):
888                 continue
889             if key == self.key:
890                 raise ValueError, 'key property "%s" is required'%key
891             if isinstance(prop, Multilink):
892                 propvalues[key] = []
893             else:
894                 propvalues[key] = None
896         # done
897         self.db.addnode(self.classname, newid, propvalues)
898         if self.do_journal:
899             self.db.addjournal(self.classname, newid, 'create', {})
901         return newid
903     def export_list(self, propnames, nodeid):
904         ''' Export a node - generate a list of CSV-able data in the order
905             specified by propnames for the given node.
906         '''
907         properties = self.getprops()
908         l = []
909         for prop in propnames:
910             proptype = properties[prop]
911             value = self.get(nodeid, prop)
912             # "marshal" data where needed
913             if value is None:
914                 pass
915             elif isinstance(proptype, hyperdb.Date):
916                 value = value.get_tuple()
917             elif isinstance(proptype, hyperdb.Interval):
918                 value = value.get_tuple()
919             elif isinstance(proptype, hyperdb.Password):
920                 value = str(value)
921             l.append(repr(value))
923         # append retired flag
924         l.append(self.is_retired(nodeid))
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         newid = None
943         for i in range(len(propnames)):
944             # Figure the property for this column
945             propname = propnames[i]
947             # Use eval to reverse the repr() used to output the CSV
948             value = eval(proplist[i])
950             # "unmarshal" where necessary
951             if propname == 'id':
952                 newid = value
953                 continue
954             elif propname == 'is retired':
955                 # is the item retired?
956                 if int(value):
957                     d[self.db.RETIRED_FLAG] = 1
958                 continue
959             elif value is None:
960                 d[propname] = None
961                 continue
963             prop = properties[propname]
964             if isinstance(prop, hyperdb.Date):
965                 value = date.Date(value)
966             elif isinstance(prop, hyperdb.Interval):
967                 value = date.Interval(value)
968             elif isinstance(prop, hyperdb.Password):
969                 pwd = password.Password()
970                 pwd.unpack(value)
971                 value = pwd
972             d[propname] = value
974         # get a new id if necessary
975         if newid is None:
976             newid = self.db.newid(self.classname)
978         # add the node and journal
979         self.db.addnode(self.classname, newid, d)
981         # extract the journalling stuff and nuke it
982         if d.has_key('creator'):
983             creator = d['creator']
984             del d['creator']
985         else:
986             creator = None
987         if d.has_key('creation'):
988             creation = d['creation']
989             del d['creation']
990         else:
991             creation = None
992         if d.has_key('activity'):
993             del d['activity']
994         self.db.addjournal(self.classname, newid, 'create', {}, creator,
995             creation)
996         return newid
998     def get(self, nodeid, propname, default=_marker, cache=1):
999         '''Get the value of a property on an existing node of this class.
1001         'nodeid' must be the id of an existing node of this class or an
1002         IndexError is raised.  'propname' must be the name of a property
1003         of this class or a KeyError is raised.
1005         'cache' exists for backward compatibility, and is not used.
1007         Attempts to get the "creation" or "activity" properties should
1008         do the right thing.
1009         '''
1010         if propname == 'id':
1011             return nodeid
1013         # get the node's dict
1014         d = self.db.getnode(self.classname, nodeid)
1016         # check for one of the special props
1017         if propname == 'creation':
1018             if d.has_key('creation'):
1019                 return d['creation']
1020             if not self.do_journal:
1021                 raise ValueError, 'Journalling is disabled for this class'
1022             journal = self.db.getjournal(self.classname, nodeid)
1023             if journal:
1024                 return self.db.getjournal(self.classname, nodeid)[0][1]
1025             else:
1026                 # on the strange chance that there's no journal
1027                 return date.Date()
1028         if propname == 'activity':
1029             if d.has_key('activity'):
1030                 return d['activity']
1031             if not self.do_journal:
1032                 raise ValueError, 'Journalling is disabled for this class'
1033             journal = self.db.getjournal(self.classname, nodeid)
1034             if journal:
1035                 return self.db.getjournal(self.classname, nodeid)[-1][1]
1036             else:
1037                 # on the strange chance that there's no journal
1038                 return date.Date()
1039         if propname == 'creator':
1040             if d.has_key('creator'):
1041                 return d['creator']
1042             if not self.do_journal:
1043                 raise ValueError, 'Journalling is disabled for this class'
1044             journal = self.db.getjournal(self.classname, nodeid)
1045             if journal:
1046                 num_re = re.compile('^\d+$')
1047                 value = self.db.getjournal(self.classname, nodeid)[0][2]
1048                 if num_re.match(value):
1049                     return value
1050                 else:
1051                     # old-style "username" journal tag
1052                     try:
1053                         return self.db.user.lookup(value)
1054                     except KeyError:
1055                         # user's been retired, return admin
1056                         return '1'
1057             else:
1058                 return self.db.getuid()
1060         # get the property (raises KeyErorr if invalid)
1061         prop = self.properties[propname]
1063         if not d.has_key(propname):
1064             if default is _marker:
1065                 if isinstance(prop, Multilink):
1066                     return []
1067                 else:
1068                     return None
1069             else:
1070                 return default
1072         # return a dupe of the list so code doesn't get confused
1073         if isinstance(prop, Multilink):
1074             return d[propname][:]
1076         return d[propname]
1078     # not in spec
1079     def getnode(self, nodeid, cache=1):
1080         ''' Return a convenience wrapper for the node.
1082         'nodeid' must be the id of an existing node of this class or an
1083         IndexError is raised.
1085         'cache' exists for backwards compatibility, and is not used.
1086         '''
1087         return Node(self, nodeid)
1089     def set(self, nodeid, **propvalues):
1090         '''Modify a property on an existing node of this class.
1091         
1092         'nodeid' must be the id of an existing node of this class or an
1093         IndexError is raised.
1095         Each key in 'propvalues' must be the name of a property of this
1096         class or a KeyError is raised.
1098         All values in 'propvalues' must be acceptable types for their
1099         corresponding properties or a TypeError is raised.
1101         If the value of the key property is set, it must not collide with
1102         other key strings or a ValueError is raised.
1104         If the value of a Link or Multilink property contains an invalid
1105         node id, a ValueError is raised.
1107         These operations trigger detectors and can be vetoed.  Attempts
1108         to modify the "creation" or "activity" properties cause a KeyError.
1109         '''
1110         if not propvalues:
1111             return propvalues
1113         if propvalues.has_key('creation') or propvalues.has_key('activity'):
1114             raise KeyError, '"creation" and "activity" are reserved'
1116         if propvalues.has_key('id'):
1117             raise KeyError, '"id" is reserved'
1119         if self.db.journaltag is None:
1120             raise DatabaseError, 'Database open read-only'
1122         self.fireAuditors('set', nodeid, propvalues)
1123         # Take a copy of the node dict so that the subsequent set
1124         # operation doesn't modify the oldvalues structure.
1125         oldvalues = copy.deepcopy(self.db.getnode(self.classname, nodeid))
1127         node = self.db.getnode(self.classname, nodeid)
1128         if node.has_key(self.db.RETIRED_FLAG):
1129             raise IndexError
1130         num_re = re.compile('^\d+$')
1132         # if the journal value is to be different, store it in here
1133         journalvalues = {}
1135         for propname, value in propvalues.items():
1136             # check to make sure we're not duplicating an existing key
1137             if propname == self.key and node[propname] != value:
1138                 try:
1139                     self.lookup(value)
1140                 except KeyError:
1141                     pass
1142                 else:
1143                     raise ValueError, 'node with key "%s" exists'%value
1145             # this will raise the KeyError if the property isn't valid
1146             # ... we don't use getprops() here because we only care about
1147             # the writeable properties.
1148             try:
1149                 prop = self.properties[propname]
1150             except KeyError:
1151                 raise KeyError, '"%s" has no property named "%s"'%(
1152                     self.classname, propname)
1154             # if the value's the same as the existing value, no sense in
1155             # doing anything
1156             current = node.get(propname, None)
1157             if value == current:
1158                 del propvalues[propname]
1159                 continue
1160             journalvalues[propname] = current
1162             # do stuff based on the prop type
1163             if isinstance(prop, Link):
1164                 link_class = prop.classname
1165                 # if it isn't a number, it's a key
1166                 if value is not None and not isinstance(value, type('')):
1167                     raise ValueError, 'property "%s" link value be a string'%(
1168                         propname)
1169                 if isinstance(value, type('')) and not num_re.match(value):
1170                     try:
1171                         value = self.db.classes[link_class].lookup(value)
1172                     except (TypeError, KeyError):
1173                         raise IndexError, 'new property "%s": %s not a %s'%(
1174                             propname, value, prop.classname)
1176                 if (value is not None and
1177                         not self.db.getclass(link_class).hasnode(value)):
1178                     raise IndexError, '%s has no node %s'%(link_class, value)
1180                 if self.do_journal and prop.do_journal:
1181                     # register the unlink with the old linked node
1182                     if node.has_key(propname) and node[propname] is not None:
1183                         self.db.addjournal(link_class, node[propname], 'unlink',
1184                             (self.classname, nodeid, propname))
1186                     # register the link with the newly linked node
1187                     if value is not None:
1188                         self.db.addjournal(link_class, value, 'link',
1189                             (self.classname, nodeid, propname))
1191             elif isinstance(prop, Multilink):
1192                 if type(value) != type([]):
1193                     raise TypeError, 'new property "%s" not a list of'\
1194                         ' ids'%propname
1195                 link_class = self.properties[propname].classname
1196                 l = []
1197                 for entry in value:
1198                     # if it isn't a number, it's a key
1199                     if type(entry) != type(''):
1200                         raise ValueError, 'new property "%s" link value ' \
1201                             'must be a string'%propname
1202                     if not num_re.match(entry):
1203                         try:
1204                             entry = self.db.classes[link_class].lookup(entry)
1205                         except (TypeError, KeyError):
1206                             raise IndexError, 'new property "%s": %s not a %s'%(
1207                                 propname, entry,
1208                                 self.properties[propname].classname)
1209                     l.append(entry)
1210                 value = l
1211                 propvalues[propname] = value
1213                 # figure the journal entry for this property
1214                 add = []
1215                 remove = []
1217                 # handle removals
1218                 if node.has_key(propname):
1219                     l = node[propname]
1220                 else:
1221                     l = []
1222                 for id in l[:]:
1223                     if id in value:
1224                         continue
1225                     # register the unlink with the old linked node
1226                     if self.do_journal and self.properties[propname].do_journal:
1227                         self.db.addjournal(link_class, id, 'unlink',
1228                             (self.classname, nodeid, propname))
1229                     l.remove(id)
1230                     remove.append(id)
1232                 # handle additions
1233                 for id in value:
1234                     if not self.db.getclass(link_class).hasnode(id):
1235                         raise IndexError, '%s has no node %s'%(link_class, id)
1236                     if id in l:
1237                         continue
1238                     # register the link with the newly linked node
1239                     if self.do_journal and self.properties[propname].do_journal:
1240                         self.db.addjournal(link_class, id, 'link',
1241                             (self.classname, nodeid, propname))
1242                     l.append(id)
1243                     add.append(id)
1245                 # figure the journal entry
1246                 l = []
1247                 if add:
1248                     l.append(('+', add))
1249                 if remove:
1250                     l.append(('-', remove))
1251                 if l:
1252                     journalvalues[propname] = tuple(l)
1254             elif isinstance(prop, String):
1255                 if value is not None and type(value) != type('') and type(value) != type(u''):
1256                     raise TypeError, 'new property "%s" not a string'%propname
1258             elif isinstance(prop, Password):
1259                 if not isinstance(value, password.Password):
1260                     raise TypeError, 'new property "%s" not a Password'%propname
1261                 propvalues[propname] = value
1263             elif value is not None and isinstance(prop, Date):
1264                 if not isinstance(value, date.Date):
1265                     raise TypeError, 'new property "%s" not a Date'% propname
1266                 propvalues[propname] = value
1268             elif value is not None and isinstance(prop, Interval):
1269                 if not isinstance(value, date.Interval):
1270                     raise TypeError, 'new property "%s" not an '\
1271                         'Interval'%propname
1272                 propvalues[propname] = value
1274             elif value is not None and isinstance(prop, Number):
1275                 try:
1276                     float(value)
1277                 except ValueError:
1278                     raise TypeError, 'new property "%s" not numeric'%propname
1280             elif value is not None and isinstance(prop, Boolean):
1281                 try:
1282                     int(value)
1283                 except ValueError:
1284                     raise TypeError, 'new property "%s" not boolean'%propname
1286             node[propname] = value
1288         # nothing to do?
1289         if not propvalues:
1290             return propvalues
1292         # do the set, and journal it
1293         self.db.setnode(self.classname, nodeid, node)
1295         if self.do_journal:
1296             self.db.addjournal(self.classname, nodeid, 'set', journalvalues)
1298         self.fireReactors('set', nodeid, oldvalues)
1300         return propvalues        
1302     def retire(self, nodeid):
1303         '''Retire a node.
1304         
1305         The properties on the node remain available from the get() method,
1306         and the node's id is never reused.
1307         
1308         Retired nodes are not returned by the find(), list(), or lookup()
1309         methods, and other nodes may reuse the values of their key properties.
1311         These operations trigger detectors and can be vetoed.  Attempts
1312         to modify the "creation" or "activity" properties cause a KeyError.
1313         '''
1314         if self.db.journaltag is None:
1315             raise DatabaseError, 'Database open read-only'
1317         self.fireAuditors('retire', nodeid, None)
1319         node = self.db.getnode(self.classname, nodeid)
1320         node[self.db.RETIRED_FLAG] = 1
1321         self.db.setnode(self.classname, nodeid, node)
1322         if self.do_journal:
1323             self.db.addjournal(self.classname, nodeid, 'retired', None)
1325         self.fireReactors('retire', nodeid, None)
1327     def restore(self, nodeid):
1328         '''Restpre a retired node.
1330         Make node available for all operations like it was before retirement.
1331         '''
1332         if self.db.journaltag is None:
1333             raise DatabaseError, 'Database open read-only'
1335         node = self.db.getnode(self.classname, nodeid)
1336         # check if key property was overrided
1337         key = self.getkey()
1338         try:
1339             id = self.lookup(node[key])
1340         except KeyError:
1341             pass
1342         else:
1343             raise KeyError, "Key property (%s) of retired node clashes with \
1344                 existing one (%s)" % (key, node[key])
1345         # Now we can safely restore node
1346         self.fireAuditors('restore', nodeid, None)
1347         del node[self.db.RETIRED_FLAG]
1348         self.db.setnode(self.classname, nodeid, node)
1349         if self.do_journal:
1350             self.db.addjournal(self.classname, nodeid, 'restored', None)
1352         self.fireReactors('restore', nodeid, None)
1354     def is_retired(self, nodeid, cldb=None):
1355         '''Return true if the node is retired.
1356         '''
1357         node = self.db.getnode(self.classname, nodeid, cldb)
1358         if node.has_key(self.db.RETIRED_FLAG):
1359             return 1
1360         return 0
1362     def destroy(self, nodeid):
1363         '''Destroy a node.
1365         WARNING: this method should never be used except in extremely rare
1366                  situations where there could never be links to the node being
1367                  deleted
1368         WARNING: use retire() instead
1369         WARNING: the properties of this node will not be available ever again
1370         WARNING: really, use retire() instead
1372         Well, I think that's enough warnings. This method exists mostly to
1373         support the session storage of the cgi interface.
1374         '''
1375         if self.db.journaltag is None:
1376             raise DatabaseError, 'Database open read-only'
1377         self.db.destroynode(self.classname, nodeid)
1379     def history(self, nodeid):
1380         '''Retrieve the journal of edits on a particular node.
1382         'nodeid' must be the id of an existing node of this class or an
1383         IndexError is raised.
1385         The returned list contains tuples of the form
1387             (nodeid, date, tag, action, params)
1389         'date' is a Timestamp object specifying the time of the change and
1390         'tag' is the journaltag specified when the database was opened.
1391         '''
1392         if not self.do_journal:
1393             raise ValueError, 'Journalling is disabled for this class'
1394         return self.db.getjournal(self.classname, nodeid)
1396     # Locating nodes:
1397     def hasnode(self, nodeid):
1398         '''Determine if the given nodeid actually exists
1399         '''
1400         return self.db.hasnode(self.classname, nodeid)
1402     def setkey(self, propname):
1403         '''Select a String property of this class to be the key property.
1405         'propname' must be the name of a String property of this class or
1406         None, or a TypeError is raised.  The values of the key property on
1407         all existing nodes must be unique or a ValueError is raised. If the
1408         property doesn't exist, KeyError is raised.
1409         '''
1410         prop = self.getprops()[propname]
1411         if not isinstance(prop, String):
1412             raise TypeError, 'key properties must be String'
1413         self.key = propname
1415     def getkey(self):
1416         '''Return the name of the key property for this class or None.'''
1417         return self.key
1419     def labelprop(self, default_to_id=0):
1420         ''' Return the property name for a label for the given node.
1422         This method attempts to generate a consistent label for the node.
1423         It tries the following in order:
1424             1. key property
1425             2. "name" property
1426             3. "title" property
1427             4. first property from the sorted property name list
1428         '''
1429         k = self.getkey()
1430         if  k:
1431             return k
1432         props = self.getprops()
1433         if props.has_key('name'):
1434             return 'name'
1435         elif props.has_key('title'):
1436             return 'title'
1437         if default_to_id:
1438             return 'id'
1439         props = props.keys()
1440         props.sort()
1441         return props[0]
1443     # TODO: set up a separate index db file for this? profile?
1444     def lookup(self, keyvalue):
1445         '''Locate a particular node by its key property and return its id.
1447         If this class has no key property, a TypeError is raised.  If the
1448         'keyvalue' matches one of the values for the key property among
1449         the nodes in this class, the matching node's id is returned;
1450         otherwise a KeyError is raised.
1451         '''
1452         if not self.key:
1453             raise TypeError, 'No key property set for class %s'%self.classname
1454         cldb = self.db.getclassdb(self.classname)
1455         try:
1456             for nodeid in self.getnodeids(cldb):
1457                 node = self.db.getnode(self.classname, nodeid, cldb)
1458                 if node.has_key(self.db.RETIRED_FLAG):
1459                     continue
1460                 if node[self.key] == keyvalue:
1461                     return nodeid
1462         finally:
1463             cldb.close()
1464         raise KeyError, 'No key (%s) value "%s" for "%s"'%(self.key,
1465             keyvalue, self.classname)
1467     # change from spec - allows multiple props to match
1468     def find(self, **propspec):
1469         '''Get the ids of items in this class which link to the given items.
1471         'propspec' consists of keyword args propname=itemid or
1472                    propname={itemid:1, }
1473         'propname' must be the name of a property in this class, or a
1474                    KeyError is raised.  That property must be a Link or
1475                    Multilink property, or a TypeError is raised.
1477         Any item in this class whose 'propname' property links to any of the
1478         itemids will be returned. Used by the full text indexing, which knows
1479         that "foo" occurs in msg1, msg3 and file7, so we have hits on these
1480         issues:
1482             db.issue.find(messages={'1':1,'3':1}, files={'7':1})
1483         '''
1484         propspec = propspec.items()
1485         for propname, itemids in propspec:
1486             # check the prop is OK
1487             prop = self.properties[propname]
1488             if not isinstance(prop, Link) and not isinstance(prop, Multilink):
1489                 raise TypeError, "'%s' not a Link/Multilink property"%propname
1491         # ok, now do the find
1492         cldb = self.db.getclassdb(self.classname)
1493         l = []
1494         try:
1495             for id in self.getnodeids(db=cldb):
1496                 item = self.db.getnode(self.classname, id, db=cldb)
1497                 if item.has_key(self.db.RETIRED_FLAG):
1498                     continue
1499                 for propname, itemids in propspec:
1500                     # can't test if the item doesn't have this property
1501                     if not item.has_key(propname):
1502                         continue
1503                     if type(itemids) is not type({}):
1504                         itemids = {itemids:1}
1506                     # grab the property definition and its value on this item
1507                     prop = self.properties[propname]
1508                     value = item[propname]
1509                     if isinstance(prop, Link) and itemids.has_key(value):
1510                         l.append(id)
1511                         break
1512                     elif isinstance(prop, Multilink):
1513                         hit = 0
1514                         for v in value:
1515                             if itemids.has_key(v):
1516                                 l.append(id)
1517                                 hit = 1
1518                                 break
1519                         if hit:
1520                             break
1521         finally:
1522             cldb.close()
1523         return l
1525     def stringFind(self, **requirements):
1526         '''Locate a particular node by matching a set of its String
1527         properties in a caseless search.
1529         If the property is not a String property, a TypeError is raised.
1530         
1531         The return is a list of the id of all nodes that match.
1532         '''
1533         for propname in requirements.keys():
1534             prop = self.properties[propname]
1535             if isinstance(not prop, String):
1536                 raise TypeError, "'%s' not a String property"%propname
1537             requirements[propname] = requirements[propname].lower()
1538         l = []
1539         cldb = self.db.getclassdb(self.classname)
1540         try:
1541             for nodeid in self.getnodeids(cldb):
1542                 node = self.db.getnode(self.classname, nodeid, cldb)
1543                 if node.has_key(self.db.RETIRED_FLAG):
1544                     continue
1545                 for key, value in requirements.items():
1546                     if not node.has_key(key):
1547                         break
1548                     if node[key] is None or node[key].lower() != value:
1549                         break
1550                 else:
1551                     l.append(nodeid)
1552         finally:
1553             cldb.close()
1554         return l
1556     def list(self):
1557         ''' Return a list of the ids of the active nodes in this class.
1558         '''
1559         l = []
1560         cn = self.classname
1561         cldb = self.db.getclassdb(cn)
1562         try:
1563             for nodeid in self.getnodeids(cldb):
1564                 node = self.db.getnode(cn, nodeid, cldb)
1565                 if node.has_key(self.db.RETIRED_FLAG):
1566                     continue
1567                 l.append(nodeid)
1568         finally:
1569             cldb.close()
1570         l.sort()
1571         return l
1573     def getnodeids(self, db=None):
1574         ''' Return a list of ALL nodeids
1575         '''
1576         if __debug__:
1577             print >>hyperdb.DEBUG, 'getnodeids', (self, self.classname, db)
1579         res = []
1581         # start off with the new nodes
1582         if self.db.newnodes.has_key(self.classname):
1583             res += self.db.newnodes[self.classname].keys()
1585         if db is None:
1586             db = self.db.getclassdb(self.classname)
1587         res = res + db.keys()
1589         # remove the uncommitted, destroyed nodes
1590         if self.db.destroyednodes.has_key(self.classname):
1591             for nodeid in self.db.destroyednodes[self.classname].keys():
1592                 if db.has_key(nodeid):
1593                     res.remove(nodeid)
1595         return res
1597     def filter(self, search_matches, filterspec, sort=(None,None),
1598             group=(None,None), num_re = re.compile('^\d+$')):
1599         ''' Return a list of the ids of the active nodes in this class that
1600             match the 'filter' spec, sorted by the group spec and then the
1601             sort spec.
1603             "filterspec" is {propname: value(s)}
1604             "sort" and "group" are (dir, prop) where dir is '+', '-' or None
1605                                and prop is a prop name or None
1606             "search_matches" is {nodeid: marker}
1608             The filter must match all properties specificed - but if the
1609             property value to match is a list, any one of the values in the
1610             list may match for that property to match. Unless the property
1611             is a Multilink, in which case the item's property list must
1612             match the filterspec list.
1613         '''
1614         cn = self.classname
1616         # optimise filterspec
1617         l = []
1618         props = self.getprops()
1619         LINK = 0
1620         MULTILINK = 1
1621         STRING = 2
1622         DATE = 3
1623         INTERVAL = 4
1624         OTHER = 6
1625         
1626         timezone = self.db.getUserTimezone()
1627         for k, v in filterspec.items():
1628             propclass = props[k]
1629             if isinstance(propclass, Link):
1630                 if type(v) is not type([]):
1631                     v = [v]
1632                 # replace key values with node ids
1633                 u = []
1634                 link_class =  self.db.classes[propclass.classname]
1635                 for entry in v:
1636                     # the value -1 is a special "not set" sentinel
1637                     if entry == '-1':
1638                         entry = None
1639                     elif not num_re.match(entry):
1640                         try:
1641                             entry = link_class.lookup(entry)
1642                         except (TypeError,KeyError):
1643                             raise ValueError, 'property "%s": %s not a %s'%(
1644                                 k, entry, self.properties[k].classname)
1645                     u.append(entry)
1647                 l.append((LINK, k, u))
1648             elif isinstance(propclass, Multilink):
1649                 # the value -1 is a special "not set" sentinel
1650                 if v in ('-1', ['-1']):
1651                     v = []
1652                 elif type(v) is not type([]):
1653                     v = [v]
1655                 # replace key values with node ids
1656                 u = []
1657                 link_class =  self.db.classes[propclass.classname]
1658                 for entry in v:
1659                     if not num_re.match(entry):
1660                         try:
1661                             entry = link_class.lookup(entry)
1662                         except (TypeError,KeyError):
1663                             raise ValueError, 'new property "%s": %s not a %s'%(
1664                                 k, entry, self.properties[k].classname)
1665                     u.append(entry)
1666                 u.sort()
1667                 l.append((MULTILINK, k, u))
1668             elif isinstance(propclass, String) and k != 'id':
1669                 if type(v) is not type([]):
1670                     v = [v]
1671                 m = []
1672                 for v in v:
1673                     # simple glob searching
1674                     v = re.sub(r'([\|\{\}\\\.\+\[\]\(\)])', r'\\\1', v)
1675                     v = v.replace('?', '.')
1676                     v = v.replace('*', '.*?')
1677                     m.append(v)
1678                 m = re.compile('(%s)'%('|'.join(m)), re.I)
1679                 l.append((STRING, k, m))
1680             elif isinstance(propclass, Date):
1681                 try:
1682                     date_rng = Range(v, date.Date, offset=timezone)
1683                     l.append((DATE, k, date_rng))
1684                 except ValueError:
1685                     # If range creation fails - ignore that search parameter
1686                     pass
1687             elif isinstance(propclass, Interval):
1688                 try:
1689                     intv_rng = Range(v, date.Interval)
1690                     l.append((INTERVAL, k, intv_rng))
1691                 except ValueError:
1692                     # If range creation fails - ignore that search parameter
1693                     pass
1694                 
1695             elif isinstance(propclass, Boolean):
1696                 if type(v) is type(''):
1697                     bv = v.lower() in ('yes', 'true', 'on', '1')
1698                 else:
1699                     bv = v
1700                 l.append((OTHER, k, bv))
1701             elif isinstance(propclass, Number):
1702                 l.append((OTHER, k, int(v)))
1703             else:
1704                 l.append((OTHER, k, v))
1705         filterspec = l
1707         # now, find all the nodes that are active and pass filtering
1708         l = []
1709         cldb = self.db.getclassdb(cn)
1710         try:
1711             # TODO: only full-scan once (use items())
1712             for nodeid in self.getnodeids(cldb):
1713                 node = self.db.getnode(cn, nodeid, cldb)
1714                 if node.has_key(self.db.RETIRED_FLAG):
1715                     continue
1716                 # apply filter
1717                 for t, k, v in filterspec:
1718                     # handle the id prop
1719                     if k == 'id' and v == nodeid:
1720                         continue
1722                     # make sure the node has the property
1723                     if not node.has_key(k):
1724                         # this node doesn't have this property, so reject it
1725                         break
1727                     # now apply the property filter
1728                     if t == LINK:
1729                         # link - if this node's property doesn't appear in the
1730                         # filterspec's nodeid list, skip it
1731                         if node[k] not in v:
1732                             break
1733                     elif t == MULTILINK:
1734                         # multilink - if any of the nodeids required by the
1735                         # filterspec aren't in this node's property, then skip
1736                         # it
1737                         have = node[k]
1738                         # check for matching the absence of multilink values
1739                         if not v and have:
1740                             break
1742                         # othewise, make sure this node has each of the
1743                         # required values
1744                         for want in v:
1745                             if want not in have:
1746                                 break
1747                         else:
1748                             continue
1749                         break
1750                     elif t == STRING:
1751                         if node[k] is None:
1752                             break
1753                         # RE search
1754                         if not v.search(node[k]):
1755                             break
1756                     elif t == DATE or t == INTERVAL:
1757                         if node[k] is None:
1758                             break
1759                         if v.to_value:
1760                             if not (v.from_value <= node[k] and v.to_value >= node[k]):
1761                                 break
1762                         else:
1763                             if not (v.from_value <= node[k]):
1764                                 break
1765                     elif t == OTHER:
1766                         # straight value comparison for the other types
1767                         if node[k] != v:
1768                             break
1769                 else:
1770                     l.append((nodeid, node))
1771         finally:
1772             cldb.close()
1773         l.sort()
1775         # filter based on full text search
1776         if search_matches is not None:
1777             k = []
1778             for v in l:
1779                 if search_matches.has_key(v[0]):
1780                     k.append(v)
1781             l = k
1783         # now, sort the result
1784         def sortfun(a, b, sort=sort, group=group, properties=self.getprops(),
1785                 db = self.db, cl=self):
1786             a_id, an = a
1787             b_id, bn = b
1788             # sort by group and then sort
1789             for dir, prop in group, sort:
1790                 if dir is None or prop is None: continue
1792                 # sorting is class-specific
1793                 propclass = properties[prop]
1795                 # handle the properties that might be "faked"
1796                 # also, handle possible missing properties
1797                 try:
1798                     if not an.has_key(prop):
1799                         an[prop] = cl.get(a_id, prop)
1800                     av = an[prop]
1801                 except KeyError:
1802                     # the node doesn't have a value for this property
1803                     if isinstance(propclass, Multilink): av = []
1804                     else: av = ''
1805                 try:
1806                     if not bn.has_key(prop):
1807                         bn[prop] = cl.get(b_id, prop)
1808                     bv = bn[prop]
1809                 except KeyError:
1810                     # the node doesn't have a value for this property
1811                     if isinstance(propclass, Multilink): bv = []
1812                     else: bv = ''
1814                 # String and Date values are sorted in the natural way
1815                 if isinstance(propclass, String):
1816                     # clean up the strings
1817                     if av and av[0] in string.uppercase:
1818                         av = av.lower()
1819                     if bv and bv[0] in string.uppercase:
1820                         bv = bv.lower()
1821                 if (isinstance(propclass, String) or
1822                         isinstance(propclass, Date)):
1823                     # it might be a string that's really an integer
1824                     try:
1825                         av = int(av)
1826                         bv = int(bv)
1827                     except:
1828                         pass
1829                     if dir == '+':
1830                         r = cmp(av, bv)
1831                         if r != 0: return r
1832                     elif dir == '-':
1833                         r = cmp(bv, av)
1834                         if r != 0: return r
1836                 # Link properties are sorted according to the value of
1837                 # the "order" property on the linked nodes if it is
1838                 # present; or otherwise on the key string of the linked
1839                 # nodes; or finally on  the node ids.
1840                 elif isinstance(propclass, Link):
1841                     link = db.classes[propclass.classname]
1842                     if av is None and bv is not None: return -1
1843                     if av is not None and bv is None: return 1
1844                     if av is None and bv is None: continue
1845                     if link.getprops().has_key('order'):
1846                         if dir == '+':
1847                             r = cmp(link.get(av, 'order'),
1848                                 link.get(bv, 'order'))
1849                             if r != 0: return r
1850                         elif dir == '-':
1851                             r = cmp(link.get(bv, 'order'),
1852                                 link.get(av, 'order'))
1853                             if r != 0: return r
1854                     elif link.getkey():
1855                         key = link.getkey()
1856                         if dir == '+':
1857                             r = cmp(link.get(av, key), link.get(bv, key))
1858                             if r != 0: return r
1859                         elif dir == '-':
1860                             r = cmp(link.get(bv, key), link.get(av, key))
1861                             if r != 0: return r
1862                     else:
1863                         if dir == '+':
1864                             r = cmp(av, bv)
1865                             if r != 0: return r
1866                         elif dir == '-':
1867                             r = cmp(bv, av)
1868                             if r != 0: return r
1870                 else:
1871                     # all other types just compare
1872                     if dir == '+':
1873                         r = cmp(av, bv)
1874                     elif dir == '-':
1875                         r = cmp(bv, av)
1876                     if r != 0: return r
1877                     
1878             # end for dir, prop in sort, group:
1879             # if all else fails, compare the ids
1880             return cmp(a[0], b[0])
1882         l.sort(sortfun)
1883         return [i[0] for i in l]
1885     def count(self):
1886         '''Get the number of nodes in this class.
1888         If the returned integer is 'numnodes', the ids of all the nodes
1889         in this class run from 1 to numnodes, and numnodes+1 will be the
1890         id of the next node to be created in this class.
1891         '''
1892         return self.db.countnodes(self.classname)
1894     # Manipulating properties:
1896     def getprops(self, protected=1):
1897         '''Return a dictionary mapping property names to property objects.
1898            If the "protected" flag is true, we include protected properties -
1899            those which may not be modified.
1901            In addition to the actual properties on the node, these
1902            methods provide the "creation" and "activity" properties. If the
1903            "protected" flag is true, we include protected properties - those
1904            which may not be modified.
1905         '''
1906         d = self.properties.copy()
1907         if protected:
1908             d['id'] = String()
1909             d['creation'] = hyperdb.Date()
1910             d['activity'] = hyperdb.Date()
1911             d['creator'] = hyperdb.Link('user')
1912         return d
1914     def addprop(self, **properties):
1915         '''Add properties to this class.
1917         The keyword arguments in 'properties' must map names to property
1918         objects, or a TypeError is raised.  None of the keys in 'properties'
1919         may collide with the names of existing properties, or a ValueError
1920         is raised before any properties have been added.
1921         '''
1922         for key in properties.keys():
1923             if self.properties.has_key(key):
1924                 raise ValueError, key
1925         self.properties.update(properties)
1927     def index(self, nodeid):
1928         '''Add (or refresh) the node to search indexes
1929         '''
1930         # find all the String properties that have indexme
1931         for prop, propclass in self.getprops().items():
1932             if isinstance(propclass, String) and propclass.indexme:
1933                 try:
1934                     value = str(self.get(nodeid, prop))
1935                 except IndexError:
1936                     # node no longer exists - entry should be removed
1937                     self.db.indexer.purge_entry((self.classname, nodeid, prop))
1938                 else:
1939                     # and index them under (classname, nodeid, property)
1940                     self.db.indexer.add_text((self.classname, nodeid, prop),
1941                         value)
1943     #
1944     # Detector interface
1945     #
1946     def audit(self, event, detector):
1947         '''Register a detector
1948         '''
1949         l = self.auditors[event]
1950         if detector not in l:
1951             self.auditors[event].append(detector)
1953     def fireAuditors(self, action, nodeid, newvalues):
1954         '''Fire all registered auditors.
1955         '''
1956         for audit in self.auditors[action]:
1957             audit(self.db, self, nodeid, newvalues)
1959     def react(self, event, detector):
1960         '''Register a detector
1961         '''
1962         l = self.reactors[event]
1963         if detector not in l:
1964             self.reactors[event].append(detector)
1966     def fireReactors(self, action, nodeid, oldvalues):
1967         '''Fire all registered reactors.
1968         '''
1969         for react in self.reactors[action]:
1970             react(self.db, self, nodeid, oldvalues)
1972 class FileClass(Class, hyperdb.FileClass):
1973     '''This class defines a large chunk of data. To support this, it has a
1974        mandatory String property "content" which is typically saved off
1975        externally to the hyperdb.
1977        The default MIME type of this data is defined by the
1978        "default_mime_type" class attribute, which may be overridden by each
1979        node if the class defines a "type" String property.
1980     '''
1981     default_mime_type = 'text/plain'
1983     def create(self, **propvalues):
1984         ''' Snarf the "content" propvalue and store in a file
1985         '''
1986         # we need to fire the auditors now, or the content property won't
1987         # be in propvalues for the auditors to play with
1988         self.fireAuditors('create', None, propvalues)
1990         # now remove the content property so it's not stored in the db
1991         content = propvalues['content']
1992         del propvalues['content']
1994         # do the database create
1995         newid = Class.create_inner(self, **propvalues)
1997         # fire reactors
1998         self.fireReactors('create', newid, None)
2000         # store off the content as a file
2001         self.db.storefile(self.classname, newid, None, content)
2002         return newid
2004     def import_list(self, propnames, proplist):
2005         ''' Trap the "content" property...
2006         '''
2007         # dupe this list so we don't affect others
2008         propnames = propnames[:]
2010         # extract the "content" property from the proplist
2011         i = propnames.index('content')
2012         content = eval(proplist[i])
2013         del propnames[i]
2014         del proplist[i]
2016         # do the normal import
2017         newid = Class.import_list(self, propnames, proplist)
2019         # save off the "content" file
2020         self.db.storefile(self.classname, newid, None, content)
2021         return newid
2023     def get(self, nodeid, propname, default=_marker, cache=1):
2024         ''' Trap the content propname and get it from the file
2026         'cache' exists for backwards compatibility, and is not used.
2027         '''
2028         poss_msg = 'Possibly an access right configuration problem.'
2029         if propname == 'content':
2030             try:
2031                 return self.db.getfile(self.classname, nodeid, None)
2032             except IOError, (strerror):
2033                 # XXX by catching this we donot see an error in the log.
2034                 return 'ERROR reading file: %s%s\n%s\n%s'%(
2035                         self.classname, nodeid, poss_msg, strerror)
2036         if default is not _marker:
2037             return Class.get(self, nodeid, propname, default)
2038         else:
2039             return Class.get(self, nodeid, propname)
2041     def getprops(self, protected=1):
2042         ''' In addition to the actual properties on the node, these methods
2043             provide the "content" property. If the "protected" flag is true,
2044             we include protected properties - those which may not be
2045             modified.
2046         '''
2047         d = Class.getprops(self, protected=protected).copy()
2048         d['content'] = hyperdb.String()
2049         return d
2051     def index(self, nodeid):
2052         ''' Index the node in the search index.
2054             We want to index the content in addition to the normal String
2055             property indexing.
2056         '''
2057         # perform normal indexing
2058         Class.index(self, nodeid)
2060         # get the content to index
2061         content = self.get(nodeid, 'content')
2063         # figure the mime type
2064         if self.properties.has_key('type'):
2065             mime_type = self.get(nodeid, 'type')
2066         else:
2067             mime_type = self.default_mime_type
2069         # and index!
2070         self.db.indexer.add_text((self.classname, nodeid, 'content'), content,
2071             mime_type)
2073 # deviation from spec - was called ItemClass
2074 class IssueClass(Class, roundupdb.IssueClass):
2075     # Overridden methods:
2076     def __init__(self, db, classname, **properties):
2077         '''The newly-created class automatically includes the "messages",
2078         "files", "nosy", and "superseder" properties.  If the 'properties'
2079         dictionary attempts to specify any of these properties or a
2080         "creation" or "activity" property, a ValueError is raised.
2081         '''
2082         if not properties.has_key('title'):
2083             properties['title'] = hyperdb.String(indexme='yes')
2084         if not properties.has_key('messages'):
2085             properties['messages'] = hyperdb.Multilink("msg")
2086         if not properties.has_key('files'):
2087             properties['files'] = hyperdb.Multilink("file")
2088         if not properties.has_key('nosy'):
2089             # note: journalling is turned off as it really just wastes
2090             # space. this behaviour may be overridden in an instance
2091             properties['nosy'] = hyperdb.Multilink("user", do_journal="no")
2092         if not properties.has_key('superseder'):
2093             properties['superseder'] = hyperdb.Multilink(classname)
2094         Class.__init__(self, db, classname, **properties)