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.131 2003-11-14 00:11:18 richard Exp $
19 '''
20 This module defines a backend that saves the hyperdatabase in a database
21 chosen by anydbm. It is guaranteed to always be available in python
22 versions >2.1.1 (the dumbdbm fallback in 2.1.1 and earlier has several
23 serious bugs, and is not available)
24 '''
26 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 refresh_database(self):
102 "Rebuild the database"
103 self.reindex()
105 def reindex(self):
106 for klass in self.classes.values():
107 for nodeid in klass.list():
108 klass.index(nodeid)
109 self.indexer.save_index()
111 def __repr__(self):
112 return '<back_anydbm instance at %x>'%id(self)
114 #
115 # Classes
116 #
117 def __getattr__(self, classname):
118 '''A convenient way of calling self.getclass(classname).'''
119 if self.classes.has_key(classname):
120 if __debug__:
121 print >>hyperdb.DEBUG, '__getattr__', (self, classname)
122 return self.classes[classname]
123 raise AttributeError, classname
125 def addclass(self, cl):
126 if __debug__:
127 print >>hyperdb.DEBUG, 'addclass', (self, cl)
128 cn = cl.classname
129 if self.classes.has_key(cn):
130 raise ValueError, cn
131 self.classes[cn] = cl
133 def getclasses(self):
134 '''Return a list of the names of all existing classes.'''
135 if __debug__:
136 print >>hyperdb.DEBUG, 'getclasses', (self,)
137 l = self.classes.keys()
138 l.sort()
139 return l
141 def getclass(self, classname):
142 '''Get the Class object representing a particular class.
144 If 'classname' is not a valid class name, a KeyError is raised.
145 '''
146 if __debug__:
147 print >>hyperdb.DEBUG, 'getclass', (self, classname)
148 try:
149 return self.classes[classname]
150 except KeyError:
151 raise KeyError, 'There is no class called "%s"'%classname
153 #
154 # Class DBs
155 #
156 def clear(self):
157 '''Delete all database contents
158 '''
159 if __debug__:
160 print >>hyperdb.DEBUG, 'clear', (self,)
161 for cn in self.classes.keys():
162 for dummy in 'nodes', 'journals':
163 path = os.path.join(self.dir, 'journals.%s'%cn)
164 if os.path.exists(path):
165 os.remove(path)
166 elif os.path.exists(path+'.db'): # dbm appends .db
167 os.remove(path+'.db')
169 def getclassdb(self, classname, mode='r'):
170 ''' grab a connection to the class db that will be used for
171 multiple actions
172 '''
173 if __debug__:
174 print >>hyperdb.DEBUG, 'getclassdb', (self, classname, mode)
175 return self.opendb('nodes.%s'%classname, mode)
177 def determine_db_type(self, path):
178 ''' determine which DB wrote the class file
179 '''
180 db_type = ''
181 if os.path.exists(path):
182 db_type = whichdb.whichdb(path)
183 if not db_type:
184 raise DatabaseError, "Couldn't identify database type"
185 elif os.path.exists(path+'.db'):
186 # if the path ends in '.db', it's a dbm database, whether
187 # anydbm says it's dbhash or not!
188 db_type = 'dbm'
189 return db_type
191 def opendb(self, name, mode):
192 '''Low-level database opener that gets around anydbm/dbm
193 eccentricities.
194 '''
195 if __debug__:
196 print >>hyperdb.DEBUG, 'opendb', (self, name, mode)
198 # figure the class db type
199 path = os.path.join(os.getcwd(), self.dir, name)
200 db_type = self.determine_db_type(path)
202 # new database? let anydbm pick the best dbm
203 if not db_type:
204 if __debug__:
205 print >>hyperdb.DEBUG, "opendb anydbm.open(%r, 'c')"%path
206 return anydbm.open(path, 'c')
208 # open the database with the correct module
209 try:
210 dbm = __import__(db_type)
211 except ImportError:
212 raise DatabaseError, \
213 "Couldn't open database - the required module '%s'"\
214 " is not available"%db_type
215 if __debug__:
216 print >>hyperdb.DEBUG, "opendb %r.open(%r, %r)"%(db_type, path,
217 mode)
218 return dbm.open(path, mode)
220 #
221 # Node IDs
222 #
223 def newid(self, classname):
224 ''' Generate a new id for the given class
225 '''
226 # open the ids DB - create if if doesn't exist
227 db = self.opendb('_ids', 'c')
228 if db.has_key(classname):
229 newid = db[classname] = str(int(db[classname]) + 1)
230 else:
231 # the count() bit is transitional - older dbs won't start at 1
232 newid = str(self.getclass(classname).count()+1)
233 db[classname] = newid
234 db.close()
235 return newid
237 def setid(self, classname, setid):
238 ''' Set the id counter: used during import of database
239 '''
240 # open the ids DB - create if if doesn't exist
241 db = self.opendb('_ids', 'c')
242 db[classname] = str(setid)
243 db.close()
245 #
246 # Nodes
247 #
248 def addnode(self, classname, nodeid, node):
249 ''' add the specified node to its class's db
250 '''
251 if __debug__:
252 print >>hyperdb.DEBUG, 'addnode', (self, classname, nodeid, node)
254 # we'll be supplied these props if we're doing an import
255 if not node.has_key('creator'):
256 # add in the "calculated" properties (dupe so we don't affect
257 # calling code's node assumptions)
258 node = node.copy()
259 node['creator'] = self.getuid()
260 node['creation'] = node['activity'] = date.Date()
262 self.newnodes.setdefault(classname, {})[nodeid] = 1
263 self.cache.setdefault(classname, {})[nodeid] = node
264 self.savenode(classname, nodeid, node)
266 def setnode(self, classname, nodeid, node):
267 ''' change the specified node
268 '''
269 if __debug__:
270 print >>hyperdb.DEBUG, 'setnode', (self, classname, nodeid, node)
271 self.dirtynodes.setdefault(classname, {})[nodeid] = 1
273 # update the activity time (dupe so we don't affect
274 # calling code's node assumptions)
275 node = node.copy()
276 node['activity'] = date.Date()
278 # can't set without having already loaded the node
279 self.cache[classname][nodeid] = node
280 self.savenode(classname, nodeid, node)
282 def savenode(self, classname, nodeid, node):
283 ''' perform the saving of data specified by the set/addnode
284 '''
285 if __debug__:
286 print >>hyperdb.DEBUG, 'savenode', (self, classname, nodeid, node)
287 self.transactions.append((self.doSaveNode, (classname, nodeid, node)))
289 def getnode(self, classname, nodeid, db=None, cache=1):
290 ''' get a node from the database
292 Note the "cache" parameter is not used, and exists purely for
293 backward compatibility!
294 '''
295 if __debug__:
296 print >>hyperdb.DEBUG, 'getnode', (self, classname, nodeid, db)
298 # try the cache
299 cache_dict = self.cache.setdefault(classname, {})
300 if cache_dict.has_key(nodeid):
301 if __debug__:
302 print >>hyperdb.TRACE, 'get %s %s cached'%(classname,
303 nodeid)
304 return cache_dict[nodeid]
306 if __debug__:
307 print >>hyperdb.TRACE, 'get %s %s'%(classname, nodeid)
309 # get from the database and save in the cache
310 if db is None:
311 db = self.getclassdb(classname)
312 if not db.has_key(nodeid):
313 # try the cache - might be a brand-new node
314 cache_dict = self.cache.setdefault(classname, {})
315 if cache_dict.has_key(nodeid):
316 if __debug__:
317 print >>hyperdb.TRACE, 'get %s %s cached'%(classname,
318 nodeid)
319 return cache_dict[nodeid]
320 raise IndexError, "no such %s %s"%(classname, nodeid)
322 # check the uncommitted, destroyed nodes
323 if (self.destroyednodes.has_key(classname) and
324 self.destroyednodes[classname].has_key(nodeid)):
325 raise IndexError, "no such %s %s"%(classname, nodeid)
327 # decode
328 res = marshal.loads(db[nodeid])
330 # reverse the serialisation
331 res = self.unserialise(classname, res)
333 # store off in the cache dict
334 if cache:
335 cache_dict[nodeid] = res
337 return res
339 def destroynode(self, classname, nodeid):
340 '''Remove a node from the database. Called exclusively by the
341 destroy() method on Class.
342 '''
343 if __debug__:
344 print >>hyperdb.DEBUG, 'destroynode', (self, classname, nodeid)
346 # remove from cache and newnodes if it's there
347 if (self.cache.has_key(classname) and
348 self.cache[classname].has_key(nodeid)):
349 del self.cache[classname][nodeid]
350 if (self.newnodes.has_key(classname) and
351 self.newnodes[classname].has_key(nodeid)):
352 del self.newnodes[classname][nodeid]
354 # see if there's any obvious commit actions that we should get rid of
355 for entry in self.transactions[:]:
356 if entry[1][:2] == (classname, nodeid):
357 self.transactions.remove(entry)
359 # add to the destroyednodes map
360 self.destroyednodes.setdefault(classname, {})[nodeid] = 1
362 # add the destroy commit action
363 self.transactions.append((self.doDestroyNode, (classname, nodeid)))
365 def serialise(self, classname, node):
366 '''Copy the node contents, converting non-marshallable data into
367 marshallable data.
368 '''
369 if __debug__:
370 print >>hyperdb.DEBUG, 'serialise', classname, node
371 properties = self.getclass(classname).getprops()
372 d = {}
373 for k, v in node.items():
374 # if the property doesn't exist, or is the "retired" flag then
375 # it won't be in the properties dict
376 if not properties.has_key(k):
377 d[k] = v
378 continue
380 # get the property spec
381 prop = properties[k]
383 if isinstance(prop, Password) and v is not None:
384 d[k] = str(v)
385 elif isinstance(prop, Date) and v is not None:
386 d[k] = v.serialise()
387 elif isinstance(prop, Interval) and v is not None:
388 d[k] = v.serialise()
389 else:
390 d[k] = v
391 return d
393 def unserialise(self, classname, node):
394 '''Decode the marshalled node data
395 '''
396 if __debug__:
397 print >>hyperdb.DEBUG, 'unserialise', classname, node
398 properties = self.getclass(classname).getprops()
399 d = {}
400 for k, v in node.items():
401 # if the property doesn't exist, or is the "retired" flag then
402 # it won't be in the properties dict
403 if not properties.has_key(k):
404 d[k] = v
405 continue
407 # get the property spec
408 prop = properties[k]
410 if isinstance(prop, Date) and v is not None:
411 d[k] = date.Date(v)
412 elif isinstance(prop, Interval) and v is not None:
413 d[k] = date.Interval(v)
414 elif isinstance(prop, Password) and v is not None:
415 p = password.Password()
416 p.unpack(v)
417 d[k] = p
418 else:
419 d[k] = v
420 return d
422 def hasnode(self, classname, nodeid, db=None):
423 ''' determine if the database has a given node
424 '''
425 if __debug__:
426 print >>hyperdb.DEBUG, 'hasnode', (self, classname, nodeid, db)
428 # try the cache
429 cache = self.cache.setdefault(classname, {})
430 if cache.has_key(nodeid):
431 if __debug__:
432 print >>hyperdb.TRACE, 'has %s %s cached'%(classname, nodeid)
433 return 1
434 if __debug__:
435 print >>hyperdb.TRACE, 'has %s %s'%(classname, nodeid)
437 # not in the cache - check the database
438 if db is None:
439 db = self.getclassdb(classname)
440 res = db.has_key(nodeid)
441 return res
443 def countnodes(self, classname, db=None):
444 if __debug__:
445 print >>hyperdb.DEBUG, 'countnodes', (self, classname, db)
447 count = 0
449 # include the uncommitted nodes
450 if self.newnodes.has_key(classname):
451 count += len(self.newnodes[classname])
452 if self.destroyednodes.has_key(classname):
453 count -= len(self.destroyednodes[classname])
455 # and count those in the DB
456 if db is None:
457 db = self.getclassdb(classname)
458 count = count + len(db.keys())
459 return count
462 #
463 # Files - special node properties
464 # inherited from FileStorage
466 #
467 # Journal
468 #
469 def addjournal(self, classname, nodeid, action, params, creator=None,
470 creation=None):
471 ''' Journal the Action
472 'action' may be:
474 'create' or 'set' -- 'params' is a dictionary of property values
475 'link' or 'unlink' -- 'params' is (classname, nodeid, propname)
476 'retire' -- 'params' is None
477 '''
478 if __debug__:
479 print >>hyperdb.DEBUG, 'addjournal', (self, classname, nodeid,
480 action, params, creator, creation)
481 self.transactions.append((self.doSaveJournal, (classname, nodeid,
482 action, params, creator, creation)))
484 def getjournal(self, classname, nodeid):
485 ''' get the journal for id
487 Raise IndexError if the node doesn't exist (as per history()'s
488 API)
489 '''
490 if __debug__:
491 print >>hyperdb.DEBUG, 'getjournal', (self, classname, nodeid)
493 # our journal result
494 res = []
496 # add any journal entries for transactions not committed to the
497 # database
498 for method, args in self.transactions:
499 if method != self.doSaveJournal:
500 continue
501 (cache_classname, cache_nodeid, cache_action, cache_params,
502 cache_creator, cache_creation) = args
503 if cache_classname == classname and cache_nodeid == nodeid:
504 if not cache_creator:
505 cache_creator = self.getuid()
506 if not cache_creation:
507 cache_creation = date.Date()
508 res.append((cache_nodeid, cache_creation, cache_creator,
509 cache_action, cache_params))
511 # attempt to open the journal - in some rare cases, the journal may
512 # not exist
513 try:
514 db = self.opendb('journals.%s'%classname, 'r')
515 except anydbm.error, error:
516 if str(error) == "need 'c' or 'n' flag to open new db":
517 raise IndexError, 'no such %s %s'%(classname, nodeid)
518 elif error.args[0] != 2:
519 # this isn't a "not found" error, be alarmed!
520 raise
521 if res:
522 # we have unsaved journal entries, return them
523 return res
524 raise IndexError, 'no such %s %s'%(classname, nodeid)
525 try:
526 journal = marshal.loads(db[nodeid])
527 except KeyError:
528 db.close()
529 if res:
530 # we have some unsaved journal entries, be happy!
531 return res
532 raise IndexError, 'no such %s %s'%(classname, nodeid)
533 db.close()
535 # add all the saved journal entries for this node
536 for nodeid, date_stamp, user, action, params in journal:
537 res.append((nodeid, date.Date(date_stamp), user, action, params))
538 return res
540 def pack(self, pack_before):
541 ''' Delete all journal entries except "create" before 'pack_before'.
542 '''
543 if __debug__:
544 print >>hyperdb.DEBUG, 'packjournal', (self, pack_before)
546 pack_before = pack_before.serialise()
547 for classname in self.getclasses():
548 # get the journal db
549 db_name = 'journals.%s'%classname
550 path = os.path.join(os.getcwd(), self.dir, classname)
551 db_type = self.determine_db_type(path)
552 db = self.opendb(db_name, 'w')
554 for key in db.keys():
555 # get the journal for this db entry
556 journal = marshal.loads(db[key])
557 l = []
558 last_set_entry = None
559 for entry in journal:
560 # unpack the entry
561 (nodeid, date_stamp, self.journaltag, action,
562 params) = entry
563 # if the entry is after the pack date, _or_ the initial
564 # create entry, then it stays
565 if date_stamp > pack_before or action == 'create':
566 l.append(entry)
567 db[key] = marshal.dumps(l)
568 if db_type == 'gdbm':
569 db.reorganize()
570 db.close()
573 #
574 # Basic transaction support
575 #
576 def commit(self):
577 ''' Commit the current transactions.
578 '''
579 if __debug__:
580 print >>hyperdb.DEBUG, 'commit', (self,)
582 # keep a handle to all the database files opened
583 self.databases = {}
585 try:
586 # now, do all the transactions
587 reindex = {}
588 for method, args in self.transactions:
589 reindex[method(*args)] = 1
590 finally:
591 # make sure we close all the database files
592 for db in self.databases.values():
593 db.close()
594 del self.databases
596 # reindex the nodes that request it
597 for classname, nodeid in filter(None, reindex.keys()):
598 print >>hyperdb.DEBUG, 'commit.reindex', (classname, nodeid)
599 self.getclass(classname).index(nodeid)
601 # save the indexer state
602 self.indexer.save_index()
604 self.clearCache()
606 def clearCache(self):
607 # all transactions committed, back to normal
608 self.cache = {}
609 self.dirtynodes = {}
610 self.newnodes = {}
611 self.destroyednodes = {}
612 self.transactions = []
614 def getCachedClassDB(self, classname):
615 ''' get the class db, looking in our cache of databases for commit
616 '''
617 # get the database handle
618 db_name = 'nodes.%s'%classname
619 if not self.databases.has_key(db_name):
620 self.databases[db_name] = self.getclassdb(classname, 'c')
621 return self.databases[db_name]
623 def doSaveNode(self, classname, nodeid, node):
624 if __debug__:
625 print >>hyperdb.DEBUG, 'doSaveNode', (self, classname, nodeid,
626 node)
628 db = self.getCachedClassDB(classname)
630 # now save the marshalled data
631 db[nodeid] = marshal.dumps(self.serialise(classname, node))
633 # return the classname, nodeid so we reindex this content
634 return (classname, nodeid)
636 def getCachedJournalDB(self, classname):
637 ''' get the journal db, looking in our cache of databases for commit
638 '''
639 # get the database handle
640 db_name = 'journals.%s'%classname
641 if not self.databases.has_key(db_name):
642 self.databases[db_name] = self.opendb(db_name, 'c')
643 return self.databases[db_name]
645 def doSaveJournal(self, classname, nodeid, action, params, creator,
646 creation):
647 # serialise the parameters now if necessary
648 if isinstance(params, type({})):
649 if action in ('set', 'create'):
650 params = self.serialise(classname, params)
652 # handle supply of the special journalling parameters (usually
653 # supplied on importing an existing database)
654 if creator:
655 journaltag = creator
656 else:
657 journaltag = self.getuid()
658 if creation:
659 journaldate = creation.serialise()
660 else:
661 journaldate = date.Date().serialise()
663 # create the journal entry
664 entry = (nodeid, journaldate, journaltag, action, params)
666 if __debug__:
667 print >>hyperdb.DEBUG, 'doSaveJournal', entry
669 db = self.getCachedJournalDB(classname)
671 # now insert the journal entry
672 if db.has_key(nodeid):
673 # append to existing
674 s = db[nodeid]
675 l = marshal.loads(s)
676 l.append(entry)
677 else:
678 l = [entry]
680 db[nodeid] = marshal.dumps(l)
682 def doDestroyNode(self, classname, nodeid):
683 if __debug__:
684 print >>hyperdb.DEBUG, 'doDestroyNode', (self, classname, nodeid)
686 # delete from the class database
687 db = self.getCachedClassDB(classname)
688 if db.has_key(nodeid):
689 del db[nodeid]
691 # delete from the database
692 db = self.getCachedJournalDB(classname)
693 if db.has_key(nodeid):
694 del db[nodeid]
696 # return the classname, nodeid so we reindex this content
697 return (classname, nodeid)
699 def rollback(self):
700 ''' Reverse all actions from the current transaction.
701 '''
702 if __debug__:
703 print >>hyperdb.DEBUG, 'rollback', (self, )
704 for method, args in self.transactions:
705 # delete temporary files
706 if method == self.doStoreFile:
707 self.rollbackStoreFile(*args)
708 self.cache = {}
709 self.dirtynodes = {}
710 self.newnodes = {}
711 self.destroyednodes = {}
712 self.transactions = []
714 def close(self):
715 ''' Nothing to do
716 '''
717 if self.lockfile is not None:
718 locking.release_lock(self.lockfile)
719 if self.lockfile is not None:
720 self.lockfile.close()
721 self.lockfile = None
723 _marker = []
724 class Class(hyperdb.Class):
725 '''The handle to a particular class of nodes in a hyperdatabase.'''
727 def __init__(self, db, classname, **properties):
728 '''Create a new class with a given name and property specification.
730 'classname' must not collide with the name of an existing class,
731 or a ValueError is raised. The keyword arguments in 'properties'
732 must map names to property objects, or a TypeError is raised.
733 '''
734 if (properties.has_key('creation') or properties.has_key('activity')
735 or properties.has_key('creator')):
736 raise ValueError, '"creation", "activity" and "creator" are '\
737 'reserved'
739 self.classname = classname
740 self.properties = properties
741 self.db = weakref.proxy(db) # use a weak ref to avoid circularity
742 self.key = ''
744 # should we journal changes (default yes)
745 self.do_journal = 1
747 # do the db-related init stuff
748 db.addclass(self)
750 self.auditors = {'create': [], 'set': [], 'retire': [], 'restore': []}
751 self.reactors = {'create': [], 'set': [], 'retire': [], 'restore': []}
753 def enableJournalling(self):
754 '''Turn journalling on for this class
755 '''
756 self.do_journal = 1
758 def disableJournalling(self):
759 '''Turn journalling off for this class
760 '''
761 self.do_journal = 0
763 # Editing nodes:
765 def create(self, **propvalues):
766 '''Create a new node of this class and return its id.
768 The keyword arguments in 'propvalues' map property names to values.
770 The values of arguments must be acceptable for the types of their
771 corresponding properties or a TypeError is raised.
773 If this class has a key property, it must be present and its value
774 must not collide with other key strings or a ValueError is raised.
776 Any other properties on this class that are missing from the
777 'propvalues' dictionary are set to None.
779 If an id in a link or multilink property does not refer to a valid
780 node, an IndexError is raised.
782 These operations trigger detectors and can be vetoed. Attempts
783 to modify the "creation" or "activity" properties cause a KeyError.
784 '''
785 self.fireAuditors('create', None, propvalues)
786 newid = self.create_inner(**propvalues)
787 self.fireReactors('create', newid, None)
788 return newid
790 def create_inner(self, **propvalues):
791 ''' Called by create, in-between the audit and react calls.
792 '''
793 if propvalues.has_key('id'):
794 raise KeyError, '"id" is reserved'
796 if self.db.journaltag is None:
797 raise DatabaseError, 'Database open read-only'
799 if propvalues.has_key('creation') or propvalues.has_key('activity'):
800 raise KeyError, '"creation" and "activity" are reserved'
801 # new node's id
802 newid = self.db.newid(self.classname)
804 # validate propvalues
805 num_re = re.compile('^\d+$')
806 for key, value in propvalues.items():
807 if key == self.key:
808 try:
809 self.lookup(value)
810 except KeyError:
811 pass
812 else:
813 raise ValueError, 'node with key "%s" exists'%value
815 # try to handle this property
816 try:
817 prop = self.properties[key]
818 except KeyError:
819 raise KeyError, '"%s" has no property "%s"'%(self.classname,
820 key)
822 if value is not None and isinstance(prop, Link):
823 if type(value) != type(''):
824 raise ValueError, 'link value must be String'
825 link_class = self.properties[key].classname
826 # if it isn't a number, it's a key
827 if not num_re.match(value):
828 try:
829 value = self.db.classes[link_class].lookup(value)
830 except (TypeError, KeyError):
831 raise IndexError, 'new property "%s": %s not a %s'%(
832 key, value, link_class)
833 elif not self.db.getclass(link_class).hasnode(value):
834 raise IndexError, '%s has no node %s'%(link_class, value)
836 # save off the value
837 propvalues[key] = value
839 # register the link with the newly linked node
840 if self.do_journal and self.properties[key].do_journal:
841 self.db.addjournal(link_class, value, 'link',
842 (self.classname, newid, key))
844 elif isinstance(prop, Multilink):
845 if type(value) != type([]):
846 raise TypeError, 'new property "%s" not a list of ids'%key
848 # clean up and validate the list of links
849 link_class = self.properties[key].classname
850 l = []
851 for entry in value:
852 if type(entry) != type(''):
853 raise ValueError, '"%s" multilink value (%r) '\
854 'must contain Strings'%(key, value)
855 # if it isn't a number, it's a key
856 if not num_re.match(entry):
857 try:
858 entry = self.db.classes[link_class].lookup(entry)
859 except (TypeError, KeyError):
860 raise IndexError, 'new property "%s": %s not a %s'%(
861 key, entry, self.properties[key].classname)
862 l.append(entry)
863 value = l
864 propvalues[key] = value
866 # handle additions
867 for nodeid in value:
868 if not self.db.getclass(link_class).hasnode(nodeid):
869 raise IndexError, '%s has no node %s'%(link_class,
870 nodeid)
871 # register the link with the newly linked node
872 if self.do_journal and self.properties[key].do_journal:
873 self.db.addjournal(link_class, nodeid, 'link',
874 (self.classname, newid, key))
876 elif isinstance(prop, String):
877 if type(value) != type('') and type(value) != type(u''):
878 raise TypeError, 'new property "%s" not a string'%key
880 elif isinstance(prop, Password):
881 if not isinstance(value, password.Password):
882 raise TypeError, 'new property "%s" not a Password'%key
884 elif isinstance(prop, Date):
885 if value is not None and not isinstance(value, date.Date):
886 raise TypeError, 'new property "%s" not a Date'%key
888 elif isinstance(prop, Interval):
889 if value is not None and not isinstance(value, date.Interval):
890 raise TypeError, 'new property "%s" not an Interval'%key
892 elif value is not None and isinstance(prop, Number):
893 try:
894 float(value)
895 except ValueError:
896 raise TypeError, 'new property "%s" not numeric'%key
898 elif value is not None and isinstance(prop, Boolean):
899 try:
900 int(value)
901 except ValueError:
902 raise TypeError, 'new property "%s" not boolean'%key
904 # make sure there's data where there needs to be
905 for key, prop in self.properties.items():
906 if propvalues.has_key(key):
907 continue
908 if key == self.key:
909 raise ValueError, 'key property "%s" is required'%key
910 if isinstance(prop, Multilink):
911 propvalues[key] = []
912 else:
913 propvalues[key] = None
915 # done
916 self.db.addnode(self.classname, newid, propvalues)
917 if self.do_journal:
918 self.db.addjournal(self.classname, newid, 'create', {})
920 return newid
922 def export_list(self, propnames, nodeid):
923 ''' Export a node - generate a list of CSV-able data in the order
924 specified by propnames for the given node.
925 '''
926 properties = self.getprops()
927 l = []
928 for prop in propnames:
929 proptype = properties[prop]
930 value = self.get(nodeid, prop)
931 # "marshal" data where needed
932 if value is None:
933 pass
934 elif isinstance(proptype, hyperdb.Date):
935 value = value.get_tuple()
936 elif isinstance(proptype, hyperdb.Interval):
937 value = value.get_tuple()
938 elif isinstance(proptype, hyperdb.Password):
939 value = str(value)
940 l.append(repr(value))
942 # append retired flag
943 l.append(repr(self.is_retired(nodeid)))
945 return l
947 def import_list(self, propnames, proplist):
948 ''' Import a node - all information including "id" is present and
949 should not be sanity checked. Triggers are not triggered. The
950 journal should be initialised using the "creator" and "created"
951 information.
953 Return the nodeid of the node imported.
954 '''
955 if self.db.journaltag is None:
956 raise DatabaseError, 'Database open read-only'
957 properties = self.getprops()
959 # make the new node's property map
960 d = {}
961 newid = None
962 for i in range(len(propnames)):
963 # Figure the property for this column
964 propname = propnames[i]
966 # Use eval to reverse the repr() used to output the CSV
967 value = eval(proplist[i])
969 # "unmarshal" where necessary
970 if propname == 'id':
971 newid = value
972 continue
973 elif propname == 'is retired':
974 # is the item retired?
975 if int(value):
976 d[self.db.RETIRED_FLAG] = 1
977 continue
978 elif value is None:
979 d[propname] = None
980 continue
982 prop = properties[propname]
983 if isinstance(prop, hyperdb.Date):
984 value = date.Date(value)
985 elif isinstance(prop, hyperdb.Interval):
986 value = date.Interval(value)
987 elif isinstance(prop, hyperdb.Password):
988 pwd = password.Password()
989 pwd.unpack(value)
990 value = pwd
991 d[propname] = value
993 # get a new id if necessary
994 if newid is None:
995 newid = self.db.newid(self.classname)
997 # add the node and journal
998 self.db.addnode(self.classname, newid, d)
1000 # extract the journalling stuff and nuke it
1001 if d.has_key('creator'):
1002 creator = d['creator']
1003 del d['creator']
1004 else:
1005 creator = None
1006 if d.has_key('creation'):
1007 creation = d['creation']
1008 del d['creation']
1009 else:
1010 creation = None
1011 if d.has_key('activity'):
1012 del d['activity']
1013 self.db.addjournal(self.classname, newid, 'create', {}, creator,
1014 creation)
1015 return newid
1017 def get(self, nodeid, propname, default=_marker, cache=1):
1018 '''Get the value of a property on an existing node of this class.
1020 'nodeid' must be the id of an existing node of this class or an
1021 IndexError is raised. 'propname' must be the name of a property
1022 of this class or a KeyError is raised.
1024 'cache' exists for backward compatibility, and is not used.
1026 Attempts to get the "creation" or "activity" properties should
1027 do the right thing.
1028 '''
1029 if propname == 'id':
1030 return nodeid
1032 # get the node's dict
1033 d = self.db.getnode(self.classname, nodeid)
1035 # check for one of the special props
1036 if propname == 'creation':
1037 if d.has_key('creation'):
1038 return d['creation']
1039 if not self.do_journal:
1040 raise ValueError, 'Journalling is disabled for this class'
1041 journal = self.db.getjournal(self.classname, nodeid)
1042 if journal:
1043 return self.db.getjournal(self.classname, nodeid)[0][1]
1044 else:
1045 # on the strange chance that there's no journal
1046 return date.Date()
1047 if propname == 'activity':
1048 if d.has_key('activity'):
1049 return d['activity']
1050 if not self.do_journal:
1051 raise ValueError, 'Journalling is disabled for this class'
1052 journal = self.db.getjournal(self.classname, nodeid)
1053 if journal:
1054 return self.db.getjournal(self.classname, nodeid)[-1][1]
1055 else:
1056 # on the strange chance that there's no journal
1057 return date.Date()
1058 if propname == 'creator':
1059 if d.has_key('creator'):
1060 return d['creator']
1061 if not self.do_journal:
1062 raise ValueError, 'Journalling is disabled for this class'
1063 journal = self.db.getjournal(self.classname, nodeid)
1064 if journal:
1065 num_re = re.compile('^\d+$')
1066 value = self.db.getjournal(self.classname, nodeid)[0][2]
1067 if num_re.match(value):
1068 return value
1069 else:
1070 # old-style "username" journal tag
1071 try:
1072 return self.db.user.lookup(value)
1073 except KeyError:
1074 # user's been retired, return admin
1075 return '1'
1076 else:
1077 return self.db.getuid()
1079 # get the property (raises KeyErorr if invalid)
1080 prop = self.properties[propname]
1082 if not d.has_key(propname):
1083 if default is _marker:
1084 if isinstance(prop, Multilink):
1085 return []
1086 else:
1087 return None
1088 else:
1089 return default
1091 # return a dupe of the list so code doesn't get confused
1092 if isinstance(prop, Multilink):
1093 return d[propname][:]
1095 return d[propname]
1097 # not in spec
1098 def getnode(self, nodeid, cache=1):
1099 ''' Return a convenience wrapper for the node.
1101 'nodeid' must be the id of an existing node of this class or an
1102 IndexError is raised.
1104 'cache' exists for backwards compatibility, and is not used.
1105 '''
1106 return Node(self, nodeid)
1108 def set(self, nodeid, **propvalues):
1109 '''Modify a property on an existing node of this class.
1111 'nodeid' must be the id of an existing node of this class or an
1112 IndexError is raised.
1114 Each key in 'propvalues' must be the name of a property of this
1115 class or a KeyError is raised.
1117 All values in 'propvalues' must be acceptable types for their
1118 corresponding properties or a TypeError is raised.
1120 If the value of the key property is set, it must not collide with
1121 other key strings or a ValueError is raised.
1123 If the value of a Link or Multilink property contains an invalid
1124 node id, a ValueError is raised.
1126 These operations trigger detectors and can be vetoed. Attempts
1127 to modify the "creation" or "activity" properties cause a KeyError.
1128 '''
1129 if not propvalues:
1130 return propvalues
1132 if propvalues.has_key('creation') or propvalues.has_key('activity'):
1133 raise KeyError, '"creation" and "activity" are reserved'
1135 if propvalues.has_key('id'):
1136 raise KeyError, '"id" is reserved'
1138 if self.db.journaltag is None:
1139 raise DatabaseError, 'Database open read-only'
1141 self.fireAuditors('set', nodeid, propvalues)
1142 # Take a copy of the node dict so that the subsequent set
1143 # operation doesn't modify the oldvalues structure.
1144 oldvalues = copy.deepcopy(self.db.getnode(self.classname, nodeid))
1146 node = self.db.getnode(self.classname, nodeid)
1147 if node.has_key(self.db.RETIRED_FLAG):
1148 raise IndexError
1149 num_re = re.compile('^\d+$')
1151 # if the journal value is to be different, store it in here
1152 journalvalues = {}
1154 for propname, value in propvalues.items():
1155 # check to make sure we're not duplicating an existing key
1156 if propname == self.key and node[propname] != value:
1157 try:
1158 self.lookup(value)
1159 except KeyError:
1160 pass
1161 else:
1162 raise ValueError, 'node with key "%s" exists'%value
1164 # this will raise the KeyError if the property isn't valid
1165 # ... we don't use getprops() here because we only care about
1166 # the writeable properties.
1167 try:
1168 prop = self.properties[propname]
1169 except KeyError:
1170 raise KeyError, '"%s" has no property named "%s"'%(
1171 self.classname, propname)
1173 # if the value's the same as the existing value, no sense in
1174 # doing anything
1175 current = node.get(propname, None)
1176 if value == current:
1177 del propvalues[propname]
1178 continue
1179 journalvalues[propname] = current
1181 # do stuff based on the prop type
1182 if isinstance(prop, Link):
1183 link_class = prop.classname
1184 # if it isn't a number, it's a key
1185 if value is not None and not isinstance(value, type('')):
1186 raise ValueError, 'property "%s" link value be a string'%(
1187 propname)
1188 if isinstance(value, type('')) and not num_re.match(value):
1189 try:
1190 value = self.db.classes[link_class].lookup(value)
1191 except (TypeError, KeyError):
1192 raise IndexError, 'new property "%s": %s not a %s'%(
1193 propname, value, prop.classname)
1195 if (value is not None and
1196 not self.db.getclass(link_class).hasnode(value)):
1197 raise IndexError, '%s has no node %s'%(link_class, value)
1199 if self.do_journal and prop.do_journal:
1200 # register the unlink with the old linked node
1201 if node.has_key(propname) and node[propname] is not None:
1202 self.db.addjournal(link_class, node[propname], 'unlink',
1203 (self.classname, nodeid, propname))
1205 # register the link with the newly linked node
1206 if value is not None:
1207 self.db.addjournal(link_class, value, 'link',
1208 (self.classname, nodeid, propname))
1210 elif isinstance(prop, Multilink):
1211 if type(value) != type([]):
1212 raise TypeError, 'new property "%s" not a list of'\
1213 ' ids'%propname
1214 link_class = self.properties[propname].classname
1215 l = []
1216 for entry in value:
1217 # if it isn't a number, it's a key
1218 if type(entry) != type(''):
1219 raise ValueError, 'new property "%s" link value ' \
1220 'must be a string'%propname
1221 if not num_re.match(entry):
1222 try:
1223 entry = self.db.classes[link_class].lookup(entry)
1224 except (TypeError, KeyError):
1225 raise IndexError, 'new property "%s": %s not a %s'%(
1226 propname, entry,
1227 self.properties[propname].classname)
1228 l.append(entry)
1229 value = l
1230 propvalues[propname] = value
1232 # figure the journal entry for this property
1233 add = []
1234 remove = []
1236 # handle removals
1237 if node.has_key(propname):
1238 l = node[propname]
1239 else:
1240 l = []
1241 for id in l[:]:
1242 if id in value:
1243 continue
1244 # register the unlink with the old linked node
1245 if self.do_journal and self.properties[propname].do_journal:
1246 self.db.addjournal(link_class, id, 'unlink',
1247 (self.classname, nodeid, propname))
1248 l.remove(id)
1249 remove.append(id)
1251 # handle additions
1252 for id in value:
1253 if not self.db.getclass(link_class).hasnode(id):
1254 raise IndexError, '%s has no node %s'%(link_class, id)
1255 if id in l:
1256 continue
1257 # register the link with the newly linked node
1258 if self.do_journal and self.properties[propname].do_journal:
1259 self.db.addjournal(link_class, id, 'link',
1260 (self.classname, nodeid, propname))
1261 l.append(id)
1262 add.append(id)
1264 # figure the journal entry
1265 l = []
1266 if add:
1267 l.append(('+', add))
1268 if remove:
1269 l.append(('-', remove))
1270 if l:
1271 journalvalues[propname] = tuple(l)
1273 elif isinstance(prop, String):
1274 if value is not None and type(value) != type('') and type(value) != type(u''):
1275 raise TypeError, 'new property "%s" not a string'%propname
1277 elif isinstance(prop, Password):
1278 if not isinstance(value, password.Password):
1279 raise TypeError, 'new property "%s" not a Password'%propname
1280 propvalues[propname] = value
1282 elif value is not None and isinstance(prop, Date):
1283 if not isinstance(value, date.Date):
1284 raise TypeError, 'new property "%s" not a Date'% propname
1285 propvalues[propname] = value
1287 elif value is not None and isinstance(prop, Interval):
1288 if not isinstance(value, date.Interval):
1289 raise TypeError, 'new property "%s" not an '\
1290 'Interval'%propname
1291 propvalues[propname] = value
1293 elif value is not None and isinstance(prop, Number):
1294 try:
1295 float(value)
1296 except ValueError:
1297 raise TypeError, 'new property "%s" not numeric'%propname
1299 elif value is not None and isinstance(prop, Boolean):
1300 try:
1301 int(value)
1302 except ValueError:
1303 raise TypeError, 'new property "%s" not boolean'%propname
1305 node[propname] = value
1307 # nothing to do?
1308 if not propvalues:
1309 return propvalues
1311 # do the set, and journal it
1312 self.db.setnode(self.classname, nodeid, node)
1314 if self.do_journal:
1315 self.db.addjournal(self.classname, nodeid, 'set', journalvalues)
1317 self.fireReactors('set', nodeid, oldvalues)
1319 return propvalues
1321 def retire(self, nodeid):
1322 '''Retire a node.
1324 The properties on the node remain available from the get() method,
1325 and the node's id is never reused.
1327 Retired nodes are not returned by the find(), list(), or lookup()
1328 methods, and other nodes may reuse the values of their key properties.
1330 These operations trigger detectors and can be vetoed. Attempts
1331 to modify the "creation" or "activity" properties cause a KeyError.
1332 '''
1333 if self.db.journaltag is None:
1334 raise DatabaseError, 'Database open read-only'
1336 self.fireAuditors('retire', nodeid, None)
1338 node = self.db.getnode(self.classname, nodeid)
1339 node[self.db.RETIRED_FLAG] = 1
1340 self.db.setnode(self.classname, nodeid, node)
1341 if self.do_journal:
1342 self.db.addjournal(self.classname, nodeid, 'retired', None)
1344 self.fireReactors('retire', nodeid, None)
1346 def restore(self, nodeid):
1347 '''Restpre a retired node.
1349 Make node available for all operations like it was before retirement.
1350 '''
1351 if self.db.journaltag is None:
1352 raise DatabaseError, 'Database open read-only'
1354 node = self.db.getnode(self.classname, nodeid)
1355 # check if key property was overrided
1356 key = self.getkey()
1357 try:
1358 id = self.lookup(node[key])
1359 except KeyError:
1360 pass
1361 else:
1362 raise KeyError, "Key property (%s) of retired node clashes with \
1363 existing one (%s)" % (key, node[key])
1364 # Now we can safely restore node
1365 self.fireAuditors('restore', nodeid, None)
1366 del node[self.db.RETIRED_FLAG]
1367 self.db.setnode(self.classname, nodeid, node)
1368 if self.do_journal:
1369 self.db.addjournal(self.classname, nodeid, 'restored', None)
1371 self.fireReactors('restore', nodeid, None)
1373 def is_retired(self, nodeid, cldb=None):
1374 '''Return true if the node is retired.
1375 '''
1376 node = self.db.getnode(self.classname, nodeid, cldb)
1377 if node.has_key(self.db.RETIRED_FLAG):
1378 return 1
1379 return 0
1381 def destroy(self, nodeid):
1382 '''Destroy a node.
1384 WARNING: this method should never be used except in extremely rare
1385 situations where there could never be links to the node being
1386 deleted
1387 WARNING: use retire() instead
1388 WARNING: the properties of this node will not be available ever again
1389 WARNING: really, use retire() instead
1391 Well, I think that's enough warnings. This method exists mostly to
1392 support the session storage of the cgi interface.
1393 '''
1394 if self.db.journaltag is None:
1395 raise DatabaseError, 'Database open read-only'
1396 self.db.destroynode(self.classname, nodeid)
1398 def history(self, nodeid):
1399 '''Retrieve the journal of edits on a particular node.
1401 'nodeid' must be the id of an existing node of this class or an
1402 IndexError is raised.
1404 The returned list contains tuples of the form
1406 (nodeid, date, tag, action, params)
1408 'date' is a Timestamp object specifying the time of the change and
1409 'tag' is the journaltag specified when the database was opened.
1410 '''
1411 if not self.do_journal:
1412 raise ValueError, 'Journalling is disabled for this class'
1413 return self.db.getjournal(self.classname, nodeid)
1415 # Locating nodes:
1416 def hasnode(self, nodeid):
1417 '''Determine if the given nodeid actually exists
1418 '''
1419 return self.db.hasnode(self.classname, nodeid)
1421 def setkey(self, propname):
1422 '''Select a String property of this class to be the key property.
1424 'propname' must be the name of a String property of this class or
1425 None, or a TypeError is raised. The values of the key property on
1426 all existing nodes must be unique or a ValueError is raised. If the
1427 property doesn't exist, KeyError is raised.
1428 '''
1429 prop = self.getprops()[propname]
1430 if not isinstance(prop, String):
1431 raise TypeError, 'key properties must be String'
1432 self.key = propname
1434 def getkey(self):
1435 '''Return the name of the key property for this class or None.'''
1436 return self.key
1438 def labelprop(self, default_to_id=0):
1439 ''' Return the property name for a label for the given node.
1441 This method attempts to generate a consistent label for the node.
1442 It tries the following in order:
1443 1. key property
1444 2. "name" property
1445 3. "title" property
1446 4. first property from the sorted property name list
1447 '''
1448 k = self.getkey()
1449 if k:
1450 return k
1451 props = self.getprops()
1452 if props.has_key('name'):
1453 return 'name'
1454 elif props.has_key('title'):
1455 return 'title'
1456 if default_to_id:
1457 return 'id'
1458 props = props.keys()
1459 props.sort()
1460 return props[0]
1462 # TODO: set up a separate index db file for this? profile?
1463 def lookup(self, keyvalue):
1464 '''Locate a particular node by its key property and return its id.
1466 If this class has no key property, a TypeError is raised. If the
1467 'keyvalue' matches one of the values for the key property among
1468 the nodes in this class, the matching node's id is returned;
1469 otherwise a KeyError is raised.
1470 '''
1471 if not self.key:
1472 raise TypeError, 'No key property set for class %s'%self.classname
1473 cldb = self.db.getclassdb(self.classname)
1474 try:
1475 for nodeid in self.getnodeids(cldb):
1476 node = self.db.getnode(self.classname, nodeid, cldb)
1477 if node.has_key(self.db.RETIRED_FLAG):
1478 continue
1479 if node[self.key] == keyvalue:
1480 return nodeid
1481 finally:
1482 cldb.close()
1483 raise KeyError, 'No key (%s) value "%s" for "%s"'%(self.key,
1484 keyvalue, self.classname)
1486 # change from spec - allows multiple props to match
1487 def find(self, **propspec):
1488 '''Get the ids of items in this class which link to the given items.
1490 'propspec' consists of keyword args propname=itemid or
1491 propname={itemid:1, }
1492 'propname' must be the name of a property in this class, or a
1493 KeyError is raised. That property must be a Link or
1494 Multilink property, or a TypeError is raised.
1496 Any item in this class whose 'propname' property links to any of the
1497 itemids will be returned. Used by the full text indexing, which knows
1498 that "foo" occurs in msg1, msg3 and file7, so we have hits on these
1499 issues:
1501 db.issue.find(messages={'1':1,'3':1}, files={'7':1})
1502 '''
1503 propspec = propspec.items()
1504 for propname, itemids in propspec:
1505 # check the prop is OK
1506 prop = self.properties[propname]
1507 if not isinstance(prop, Link) and not isinstance(prop, Multilink):
1508 raise TypeError, "'%s' not a Link/Multilink property"%propname
1510 # ok, now do the find
1511 cldb = self.db.getclassdb(self.classname)
1512 l = []
1513 try:
1514 for id in self.getnodeids(db=cldb):
1515 item = self.db.getnode(self.classname, id, db=cldb)
1516 if item.has_key(self.db.RETIRED_FLAG):
1517 continue
1518 for propname, itemids in propspec:
1519 # can't test if the item doesn't have this property
1520 if not item.has_key(propname):
1521 continue
1522 if type(itemids) is not type({}):
1523 itemids = {itemids:1}
1525 # grab the property definition and its value on this item
1526 prop = self.properties[propname]
1527 value = item[propname]
1528 if isinstance(prop, Link) and itemids.has_key(value):
1529 l.append(id)
1530 break
1531 elif isinstance(prop, Multilink):
1532 hit = 0
1533 for v in value:
1534 if itemids.has_key(v):
1535 l.append(id)
1536 hit = 1
1537 break
1538 if hit:
1539 break
1540 finally:
1541 cldb.close()
1542 return l
1544 def stringFind(self, **requirements):
1545 '''Locate a particular node by matching a set of its String
1546 properties in a caseless search.
1548 If the property is not a String property, a TypeError is raised.
1550 The return is a list of the id of all nodes that match.
1551 '''
1552 for propname in requirements.keys():
1553 prop = self.properties[propname]
1554 if isinstance(not prop, String):
1555 raise TypeError, "'%s' not a String property"%propname
1556 requirements[propname] = requirements[propname].lower()
1557 l = []
1558 cldb = self.db.getclassdb(self.classname)
1559 try:
1560 for nodeid in self.getnodeids(cldb):
1561 node = self.db.getnode(self.classname, nodeid, cldb)
1562 if node.has_key(self.db.RETIRED_FLAG):
1563 continue
1564 for key, value in requirements.items():
1565 if not node.has_key(key):
1566 break
1567 if node[key] is None or node[key].lower() != value:
1568 break
1569 else:
1570 l.append(nodeid)
1571 finally:
1572 cldb.close()
1573 return l
1575 def list(self):
1576 ''' Return a list of the ids of the active nodes in this class.
1577 '''
1578 l = []
1579 cn = self.classname
1580 cldb = self.db.getclassdb(cn)
1581 try:
1582 for nodeid in self.getnodeids(cldb):
1583 node = self.db.getnode(cn, nodeid, cldb)
1584 if node.has_key(self.db.RETIRED_FLAG):
1585 continue
1586 l.append(nodeid)
1587 finally:
1588 cldb.close()
1589 l.sort()
1590 return l
1592 def getnodeids(self, db=None):
1593 ''' Return a list of ALL nodeids
1594 '''
1595 if __debug__:
1596 print >>hyperdb.DEBUG, 'getnodeids', (self, self.classname, db)
1598 res = []
1600 # start off with the new nodes
1601 if self.db.newnodes.has_key(self.classname):
1602 res += self.db.newnodes[self.classname].keys()
1604 if db is None:
1605 db = self.db.getclassdb(self.classname)
1606 res = res + db.keys()
1608 # remove the uncommitted, destroyed nodes
1609 if self.db.destroyednodes.has_key(self.classname):
1610 for nodeid in self.db.destroyednodes[self.classname].keys():
1611 if db.has_key(nodeid):
1612 res.remove(nodeid)
1614 return res
1616 def filter(self, search_matches, filterspec, sort=(None,None),
1617 group=(None,None), num_re = re.compile('^\d+$')):
1618 ''' Return a list of the ids of the active nodes in this class that
1619 match the 'filter' spec, sorted by the group spec and then the
1620 sort spec.
1622 "filterspec" is {propname: value(s)}
1623 "sort" and "group" are (dir, prop) where dir is '+', '-' or None
1624 and prop is a prop name or None
1625 "search_matches" is {nodeid: marker}
1627 The filter must match all properties specificed - but if the
1628 property value to match is a list, any one of the values in the
1629 list may match for that property to match. Unless the property
1630 is a Multilink, in which case the item's property list must
1631 match the filterspec list.
1632 '''
1633 cn = self.classname
1635 # optimise filterspec
1636 l = []
1637 props = self.getprops()
1638 LINK = 0
1639 MULTILINK = 1
1640 STRING = 2
1641 DATE = 3
1642 INTERVAL = 4
1643 OTHER = 6
1645 timezone = self.db.getUserTimezone()
1646 for k, v in filterspec.items():
1647 propclass = props[k]
1648 if isinstance(propclass, Link):
1649 if type(v) is not type([]):
1650 v = [v]
1651 # replace key values with node ids
1652 u = []
1653 link_class = self.db.classes[propclass.classname]
1654 for entry in v:
1655 # the value -1 is a special "not set" sentinel
1656 if entry == '-1':
1657 entry = None
1658 elif not num_re.match(entry):
1659 try:
1660 entry = link_class.lookup(entry)
1661 except (TypeError,KeyError):
1662 raise ValueError, 'property "%s": %s not a %s'%(
1663 k, entry, self.properties[k].classname)
1664 u.append(entry)
1666 l.append((LINK, k, u))
1667 elif isinstance(propclass, Multilink):
1668 # the value -1 is a special "not set" sentinel
1669 if v in ('-1', ['-1']):
1670 v = []
1671 elif type(v) is not type([]):
1672 v = [v]
1674 # replace key values with node ids
1675 u = []
1676 link_class = self.db.classes[propclass.classname]
1677 for entry in v:
1678 if not num_re.match(entry):
1679 try:
1680 entry = link_class.lookup(entry)
1681 except (TypeError,KeyError):
1682 raise ValueError, 'new property "%s": %s not a %s'%(
1683 k, entry, self.properties[k].classname)
1684 u.append(entry)
1685 u.sort()
1686 l.append((MULTILINK, k, u))
1687 elif isinstance(propclass, String) and k != 'id':
1688 if type(v) is not type([]):
1689 v = [v]
1690 m = []
1691 for v in v:
1692 # simple glob searching
1693 v = re.sub(r'([\|\{\}\\\.\+\[\]\(\)])', r'\\\1', v)
1694 v = v.replace('?', '.')
1695 v = v.replace('*', '.*?')
1696 m.append(v)
1697 m = re.compile('(%s)'%('|'.join(m)), re.I)
1698 l.append((STRING, k, m))
1699 elif isinstance(propclass, Date):
1700 try:
1701 date_rng = Range(v, date.Date, offset=timezone)
1702 l.append((DATE, k, date_rng))
1703 except ValueError:
1704 # If range creation fails - ignore that search parameter
1705 pass
1706 elif isinstance(propclass, Interval):
1707 try:
1708 intv_rng = Range(v, date.Interval)
1709 l.append((INTERVAL, k, intv_rng))
1710 except ValueError:
1711 # If range creation fails - ignore that search parameter
1712 pass
1714 elif isinstance(propclass, Boolean):
1715 if type(v) is type(''):
1716 bv = v.lower() in ('yes', 'true', 'on', '1')
1717 else:
1718 bv = v
1719 l.append((OTHER, k, bv))
1720 elif isinstance(propclass, Number):
1721 l.append((OTHER, k, int(v)))
1722 else:
1723 l.append((OTHER, k, v))
1724 filterspec = l
1726 # now, find all the nodes that are active and pass filtering
1727 l = []
1728 cldb = self.db.getclassdb(cn)
1729 try:
1730 # TODO: only full-scan once (use items())
1731 for nodeid in self.getnodeids(cldb):
1732 node = self.db.getnode(cn, nodeid, cldb)
1733 if node.has_key(self.db.RETIRED_FLAG):
1734 continue
1735 # apply filter
1736 for t, k, v in filterspec:
1737 # handle the id prop
1738 if k == 'id' and v == nodeid:
1739 continue
1741 # make sure the node has the property
1742 if not node.has_key(k):
1743 # this node doesn't have this property, so reject it
1744 break
1746 # now apply the property filter
1747 if t == LINK:
1748 # link - if this node's property doesn't appear in the
1749 # filterspec's nodeid list, skip it
1750 if node[k] not in v:
1751 break
1752 elif t == MULTILINK:
1753 # multilink - if any of the nodeids required by the
1754 # filterspec aren't in this node's property, then skip
1755 # it
1756 have = node[k]
1757 # check for matching the absence of multilink values
1758 if not v and have:
1759 break
1761 # othewise, make sure this node has each of the
1762 # required values
1763 for want in v:
1764 if want not in have:
1765 break
1766 else:
1767 continue
1768 break
1769 elif t == STRING:
1770 if node[k] is None:
1771 break
1772 # RE search
1773 if not v.search(node[k]):
1774 break
1775 elif t == DATE or t == INTERVAL:
1776 if node[k] is None:
1777 break
1778 if v.to_value:
1779 if not (v.from_value <= node[k] and v.to_value >= node[k]):
1780 break
1781 else:
1782 if not (v.from_value <= node[k]):
1783 break
1784 elif t == OTHER:
1785 # straight value comparison for the other types
1786 if node[k] != v:
1787 break
1788 else:
1789 l.append((nodeid, node))
1790 finally:
1791 cldb.close()
1792 l.sort()
1794 # filter based on full text search
1795 if search_matches is not None:
1796 k = []
1797 for v in l:
1798 if search_matches.has_key(v[0]):
1799 k.append(v)
1800 l = k
1802 # now, sort the result
1803 def sortfun(a, b, sort=sort, group=group, properties=self.getprops(),
1804 db = self.db, cl=self):
1805 a_id, an = a
1806 b_id, bn = b
1807 # sort by group and then sort
1808 for dir, prop in group, sort:
1809 if dir is None or prop is None: continue
1811 # sorting is class-specific
1812 propclass = properties[prop]
1814 # handle the properties that might be "faked"
1815 # also, handle possible missing properties
1816 try:
1817 if not an.has_key(prop):
1818 an[prop] = cl.get(a_id, prop)
1819 av = an[prop]
1820 except KeyError:
1821 # the node doesn't have a value for this property
1822 if isinstance(propclass, Multilink): av = []
1823 else: av = ''
1824 try:
1825 if not bn.has_key(prop):
1826 bn[prop] = cl.get(b_id, prop)
1827 bv = bn[prop]
1828 except KeyError:
1829 # the node doesn't have a value for this property
1830 if isinstance(propclass, Multilink): bv = []
1831 else: bv = ''
1833 # String and Date values are sorted in the natural way
1834 if isinstance(propclass, String):
1835 # clean up the strings
1836 if av and av[0] in string.uppercase:
1837 av = av.lower()
1838 if bv and bv[0] in string.uppercase:
1839 bv = bv.lower()
1840 if (isinstance(propclass, String) or
1841 isinstance(propclass, Date)):
1842 # it might be a string that's really an integer
1843 try:
1844 av = int(av)
1845 bv = int(bv)
1846 except:
1847 pass
1848 if dir == '+':
1849 r = cmp(av, bv)
1850 if r != 0: return r
1851 elif dir == '-':
1852 r = cmp(bv, av)
1853 if r != 0: return r
1855 # Link properties are sorted according to the value of
1856 # the "order" property on the linked nodes if it is
1857 # present; or otherwise on the key string of the linked
1858 # nodes; or finally on the node ids.
1859 elif isinstance(propclass, Link):
1860 link = db.classes[propclass.classname]
1861 if av is None and bv is not None: return -1
1862 if av is not None and bv is None: return 1
1863 if av is None and bv is None: continue
1864 if link.getprops().has_key('order'):
1865 if dir == '+':
1866 r = cmp(link.get(av, 'order'),
1867 link.get(bv, 'order'))
1868 if r != 0: return r
1869 elif dir == '-':
1870 r = cmp(link.get(bv, 'order'),
1871 link.get(av, 'order'))
1872 if r != 0: return r
1873 elif link.getkey():
1874 key = link.getkey()
1875 if dir == '+':
1876 r = cmp(link.get(av, key), link.get(bv, key))
1877 if r != 0: return r
1878 elif dir == '-':
1879 r = cmp(link.get(bv, key), link.get(av, key))
1880 if r != 0: return r
1881 else:
1882 if dir == '+':
1883 r = cmp(av, bv)
1884 if r != 0: return r
1885 elif dir == '-':
1886 r = cmp(bv, av)
1887 if r != 0: return r
1889 else:
1890 # all other types just compare
1891 if dir == '+':
1892 r = cmp(av, bv)
1893 elif dir == '-':
1894 r = cmp(bv, av)
1895 if r != 0: return r
1897 # end for dir, prop in sort, group:
1898 # if all else fails, compare the ids
1899 return cmp(a[0], b[0])
1901 l.sort(sortfun)
1902 return [i[0] for i in l]
1904 def count(self):
1905 '''Get the number of nodes in this class.
1907 If the returned integer is 'numnodes', the ids of all the nodes
1908 in this class run from 1 to numnodes, and numnodes+1 will be the
1909 id of the next node to be created in this class.
1910 '''
1911 return self.db.countnodes(self.classname)
1913 # Manipulating properties:
1915 def getprops(self, protected=1):
1916 '''Return a dictionary mapping property names to property objects.
1917 If the "protected" flag is true, we include protected properties -
1918 those which may not be modified.
1920 In addition to the actual properties on the node, these
1921 methods provide the "creation" and "activity" properties. If the
1922 "protected" flag is true, we include protected properties - those
1923 which may not be modified.
1924 '''
1925 d = self.properties.copy()
1926 if protected:
1927 d['id'] = String()
1928 d['creation'] = hyperdb.Date()
1929 d['activity'] = hyperdb.Date()
1930 d['creator'] = hyperdb.Link('user')
1931 return d
1933 def addprop(self, **properties):
1934 '''Add properties to this class.
1936 The keyword arguments in 'properties' must map names to property
1937 objects, or a TypeError is raised. None of the keys in 'properties'
1938 may collide with the names of existing properties, or a ValueError
1939 is raised before any properties have been added.
1940 '''
1941 for key in properties.keys():
1942 if self.properties.has_key(key):
1943 raise ValueError, key
1944 self.properties.update(properties)
1946 def index(self, nodeid):
1947 '''Add (or refresh) the node to search indexes
1948 '''
1949 # find all the String properties that have indexme
1950 for prop, propclass in self.getprops().items():
1951 if isinstance(propclass, String) and propclass.indexme:
1952 try:
1953 value = str(self.get(nodeid, prop))
1954 except IndexError:
1955 # node no longer exists - entry should be removed
1956 self.db.indexer.purge_entry((self.classname, nodeid, prop))
1957 else:
1958 # and index them under (classname, nodeid, property)
1959 self.db.indexer.add_text((self.classname, nodeid, prop),
1960 value)
1962 #
1963 # Detector interface
1964 #
1965 def audit(self, event, detector):
1966 '''Register a detector
1967 '''
1968 l = self.auditors[event]
1969 if detector not in l:
1970 self.auditors[event].append(detector)
1972 def fireAuditors(self, action, nodeid, newvalues):
1973 '''Fire all registered auditors.
1974 '''
1975 for audit in self.auditors[action]:
1976 audit(self.db, self, nodeid, newvalues)
1978 def react(self, event, detector):
1979 '''Register a detector
1980 '''
1981 l = self.reactors[event]
1982 if detector not in l:
1983 self.reactors[event].append(detector)
1985 def fireReactors(self, action, nodeid, oldvalues):
1986 '''Fire all registered reactors.
1987 '''
1988 for react in self.reactors[action]:
1989 react(self.db, self, nodeid, oldvalues)
1991 class FileClass(Class, hyperdb.FileClass):
1992 '''This class defines a large chunk of data. To support this, it has a
1993 mandatory String property "content" which is typically saved off
1994 externally to the hyperdb.
1996 The default MIME type of this data is defined by the
1997 "default_mime_type" class attribute, which may be overridden by each
1998 node if the class defines a "type" String property.
1999 '''
2000 default_mime_type = 'text/plain'
2002 def create(self, **propvalues):
2003 ''' Snarf the "content" propvalue and store in a file
2004 '''
2005 # we need to fire the auditors now, or the content property won't
2006 # be in propvalues for the auditors to play with
2007 self.fireAuditors('create', None, propvalues)
2009 # now remove the content property so it's not stored in the db
2010 content = propvalues['content']
2011 del propvalues['content']
2013 # do the database create
2014 newid = Class.create_inner(self, **propvalues)
2016 # fire reactors
2017 self.fireReactors('create', newid, None)
2019 # store off the content as a file
2020 self.db.storefile(self.classname, newid, None, content)
2021 return newid
2023 def import_list(self, propnames, proplist):
2024 ''' Trap the "content" property...
2025 '''
2026 # dupe this list so we don't affect others
2027 propnames = propnames[:]
2029 # extract the "content" property from the proplist
2030 i = propnames.index('content')
2031 content = eval(proplist[i])
2032 del propnames[i]
2033 del proplist[i]
2035 # do the normal import
2036 newid = Class.import_list(self, propnames, proplist)
2038 # save off the "content" file
2039 self.db.storefile(self.classname, newid, None, content)
2040 return newid
2042 def get(self, nodeid, propname, default=_marker, cache=1):
2043 ''' Trap the content propname and get it from the file
2045 'cache' exists for backwards compatibility, and is not used.
2046 '''
2047 poss_msg = 'Possibly an access right configuration problem.'
2048 if propname == 'content':
2049 try:
2050 return self.db.getfile(self.classname, nodeid, None)
2051 except IOError, (strerror):
2052 # XXX by catching this we donot see an error in the log.
2053 return 'ERROR reading file: %s%s\n%s\n%s'%(
2054 self.classname, nodeid, poss_msg, strerror)
2055 if default is not _marker:
2056 return Class.get(self, nodeid, propname, default)
2057 else:
2058 return Class.get(self, nodeid, propname)
2060 def getprops(self, protected=1):
2061 ''' In addition to the actual properties on the node, these methods
2062 provide the "content" property. If the "protected" flag is true,
2063 we include protected properties - those which may not be
2064 modified.
2065 '''
2066 d = Class.getprops(self, protected=protected).copy()
2067 d['content'] = hyperdb.String()
2068 return d
2070 def index(self, nodeid):
2071 ''' Index the node in the search index.
2073 We want to index the content in addition to the normal String
2074 property indexing.
2075 '''
2076 # perform normal indexing
2077 Class.index(self, nodeid)
2079 # get the content to index
2080 content = self.get(nodeid, 'content')
2082 # figure the mime type
2083 if self.properties.has_key('type'):
2084 mime_type = self.get(nodeid, 'type')
2085 else:
2086 mime_type = self.default_mime_type
2088 # and index!
2089 self.db.indexer.add_text((self.classname, nodeid, 'content'), content,
2090 mime_type)
2092 # deviation from spec - was called ItemClass
2093 class IssueClass(Class, roundupdb.IssueClass):
2094 # Overridden methods:
2095 def __init__(self, db, classname, **properties):
2096 '''The newly-created class automatically includes the "messages",
2097 "files", "nosy", and "superseder" properties. If the 'properties'
2098 dictionary attempts to specify any of these properties or a
2099 "creation" or "activity" property, a ValueError is raised.
2100 '''
2101 if not properties.has_key('title'):
2102 properties['title'] = hyperdb.String(indexme='yes')
2103 if not properties.has_key('messages'):
2104 properties['messages'] = hyperdb.Multilink("msg")
2105 if not properties.has_key('files'):
2106 properties['files'] = hyperdb.Multilink("file")
2107 if not properties.has_key('nosy'):
2108 # note: journalling is turned off as it really just wastes
2109 # space. this behaviour may be overridden in an instance
2110 properties['nosy'] = hyperdb.Multilink("user", do_journal="no")
2111 if not properties.has_key('superseder'):
2112 properties['superseder'] = hyperdb.Multilink(classname)
2113 Class.__init__(self, db, classname, **properties)
2115 #