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