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