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