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