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.84 2002-09-23 00:50:32 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
30 from roundup.indexer import Indexer
31 from locking import acquire_lock, release_lock
32 from roundup.hyperdb import String, Password, Date, Interval, Link, \
33 Multilink, DatabaseError, Boolean, Number
35 #
36 # Now the database
37 #
38 class Database(FileStorage, hyperdb.Database, roundupdb.Database):
39 '''A database for storing records containing flexible data types.
41 Transaction stuff TODO:
42 . check the timestamp of the class file and nuke the cache if it's
43 modified. Do some sort of conflict checking on the dirty stuff.
44 . perhaps detect write collisions (related to above)?
46 '''
47 def __init__(self, config, journaltag=None):
48 '''Open a hyperdatabase given a specifier to some storage.
50 The 'storagelocator' is obtained from config.DATABASE.
51 The meaning of 'storagelocator' depends on the particular
52 implementation of the hyperdatabase. It could be a file name,
53 a directory path, a socket descriptor for a connection to a
54 database over the network, etc.
56 The 'journaltag' is a token that will be attached to the journal
57 entries for any edits done on the database. If 'journaltag' is
58 None, the database is opened in read-only mode: the Class.create(),
59 Class.set(), and Class.retire() methods are disabled.
60 '''
61 self.config, self.journaltag = config, journaltag
62 self.dir = config.DATABASE
63 self.classes = {}
64 self.cache = {} # cache of nodes loaded or created
65 self.dirtynodes = {} # keep track of the dirty nodes by class
66 self.newnodes = {} # keep track of the new nodes by class
67 self.destroyednodes = {}# keep track of the destroyed nodes by class
68 self.transactions = []
69 self.indexer = Indexer(self.dir)
70 self.sessions = Sessions(self.config)
71 self.security = security.Security(self)
72 # ensure files are group readable and writable
73 os.umask(0002)
75 def post_init(self):
76 ''' Called once the schema initialisation has finished.
77 '''
78 # reindex the db if necessary
79 if self.indexer.should_reindex():
80 self.reindex()
82 # figure the "curuserid"
83 if self.journaltag is None:
84 self.curuserid = None
85 elif self.journaltag == 'admin':
86 # admin user may not exist, but always has ID 1
87 self.curuserid = '1'
88 else:
89 self.curuserid = self.user.lookup(self.journaltag)
91 def reindex(self):
92 for klass in self.classes.values():
93 for nodeid in klass.list():
94 klass.index(nodeid)
95 self.indexer.save_index()
97 def __repr__(self):
98 return '<back_anydbm instance at %x>'%id(self)
100 #
101 # Classes
102 #
103 def __getattr__(self, classname):
104 '''A convenient way of calling self.getclass(classname).'''
105 if self.classes.has_key(classname):
106 if __debug__:
107 print >>hyperdb.DEBUG, '__getattr__', (self, classname)
108 return self.classes[classname]
109 raise AttributeError, classname
111 def addclass(self, cl):
112 if __debug__:
113 print >>hyperdb.DEBUG, 'addclass', (self, cl)
114 cn = cl.classname
115 if self.classes.has_key(cn):
116 raise ValueError, cn
117 self.classes[cn] = cl
119 def getclasses(self):
120 '''Return a list of the names of all existing classes.'''
121 if __debug__:
122 print >>hyperdb.DEBUG, 'getclasses', (self,)
123 l = self.classes.keys()
124 l.sort()
125 return l
127 def getclass(self, classname):
128 '''Get the Class object representing a particular class.
130 If 'classname' is not a valid class name, a KeyError is raised.
131 '''
132 if __debug__:
133 print >>hyperdb.DEBUG, 'getclass', (self, classname)
134 try:
135 return self.classes[classname]
136 except KeyError:
137 raise KeyError, 'There is no class called "%s"'%classname
139 #
140 # Class DBs
141 #
142 def clear(self):
143 '''Delete all database contents
144 '''
145 if __debug__:
146 print >>hyperdb.DEBUG, 'clear', (self,)
147 for cn in self.classes.keys():
148 for dummy in 'nodes', 'journals':
149 path = os.path.join(self.dir, 'journals.%s'%cn)
150 if os.path.exists(path):
151 os.remove(path)
152 elif os.path.exists(path+'.db'): # dbm appends .db
153 os.remove(path+'.db')
155 def getclassdb(self, classname, mode='r'):
156 ''' grab a connection to the class db that will be used for
157 multiple actions
158 '''
159 if __debug__:
160 print >>hyperdb.DEBUG, 'getclassdb', (self, classname, mode)
161 return self.opendb('nodes.%s'%classname, mode)
163 def determine_db_type(self, path):
164 ''' determine which DB wrote the class file
165 '''
166 db_type = ''
167 if os.path.exists(path):
168 db_type = whichdb.whichdb(path)
169 if not db_type:
170 raise DatabaseError, "Couldn't identify database type"
171 elif os.path.exists(path+'.db'):
172 # if the path ends in '.db', it's a dbm database, whether
173 # anydbm says it's dbhash or not!
174 db_type = 'dbm'
175 return db_type
177 def opendb(self, name, mode):
178 '''Low-level database opener that gets around anydbm/dbm
179 eccentricities.
180 '''
181 if __debug__:
182 print >>hyperdb.DEBUG, 'opendb', (self, name, mode)
184 # figure the class db type
185 path = os.path.join(os.getcwd(), self.dir, name)
186 db_type = self.determine_db_type(path)
188 # new database? let anydbm pick the best dbm
189 if not db_type:
190 if __debug__:
191 print >>hyperdb.DEBUG, "opendb anydbm.open(%r, 'c')"%path
192 return anydbm.open(path, 'c')
194 # open the database with the correct module
195 try:
196 dbm = __import__(db_type)
197 except ImportError:
198 raise DatabaseError, \
199 "Couldn't open database - the required module '%s'"\
200 " is not available"%db_type
201 if __debug__:
202 print >>hyperdb.DEBUG, "opendb %r.open(%r, %r)"%(db_type, path,
203 mode)
204 return dbm.open(path, mode)
206 def lockdb(self, name):
207 ''' Lock a database file
208 '''
209 path = os.path.join(os.getcwd(), self.dir, '%s.lock'%name)
210 return acquire_lock(path)
212 #
213 # Node IDs
214 #
215 def newid(self, classname):
216 ''' Generate a new id for the given class
217 '''
218 # open the ids DB - create if if doesn't exist
219 lock = self.lockdb('_ids')
220 db = self.opendb('_ids', 'c')
221 if db.has_key(classname):
222 newid = db[classname] = str(int(db[classname]) + 1)
223 else:
224 # the count() bit is transitional - older dbs won't start at 1
225 newid = str(self.getclass(classname).count()+1)
226 db[classname] = newid
227 db.close()
228 release_lock(lock)
229 return newid
231 def setid(self, classname, setid):
232 ''' Set the id counter: used during import of database
233 '''
234 # open the ids DB - create if if doesn't exist
235 lock = self.lockdb('_ids')
236 db = self.opendb('_ids', 'c')
237 db[classname] = str(setid)
238 db.close()
239 release_lock(lock)
241 #
242 # Nodes
243 #
244 def addnode(self, classname, nodeid, node):
245 ''' add the specified node to its class's db
246 '''
247 if __debug__:
248 print >>hyperdb.DEBUG, 'addnode', (self, classname, nodeid, node)
250 # we'll be supplied these props if we're doing an import
251 if not node.has_key('creator'):
252 # add in the "calculated" properties (dupe so we don't affect
253 # calling code's node assumptions)
254 node = node.copy()
255 node['creator'] = self.curuserid
256 node['creation'] = node['activity'] = date.Date()
258 self.newnodes.setdefault(classname, {})[nodeid] = 1
259 self.cache.setdefault(classname, {})[nodeid] = node
260 self.savenode(classname, nodeid, node)
262 def setnode(self, classname, nodeid, node):
263 ''' change the specified node
264 '''
265 if __debug__:
266 print >>hyperdb.DEBUG, 'setnode', (self, classname, nodeid, node)
267 self.dirtynodes.setdefault(classname, {})[nodeid] = 1
269 # update the activity time (dupe so we don't affect
270 # calling code's node assumptions)
271 node = node.copy()
272 node['activity'] = date.Date()
274 # can't set without having already loaded the node
275 self.cache[classname][nodeid] = node
276 self.savenode(classname, nodeid, node)
278 def savenode(self, classname, nodeid, node):
279 ''' perform the saving of data specified by the set/addnode
280 '''
281 if __debug__:
282 print >>hyperdb.DEBUG, 'savenode', (self, classname, nodeid, node)
283 self.transactions.append((self.doSaveNode, (classname, nodeid, node)))
285 def getnode(self, classname, nodeid, db=None, cache=1):
286 ''' get a node from the database
287 '''
288 if __debug__:
289 print >>hyperdb.DEBUG, 'getnode', (self, classname, nodeid, db)
290 if cache:
291 # try the cache
292 cache_dict = self.cache.setdefault(classname, {})
293 if cache_dict.has_key(nodeid):
294 if __debug__:
295 print >>hyperdb.TRACE, 'get %s %s cached'%(classname,
296 nodeid)
297 return cache_dict[nodeid]
299 if __debug__:
300 print >>hyperdb.TRACE, 'get %s %s'%(classname, nodeid)
302 # get from the database and save in the cache
303 if db is None:
304 db = self.getclassdb(classname)
305 if not db.has_key(nodeid):
306 raise IndexError, "no such %s %s"%(classname, nodeid)
308 # check the uncommitted, destroyed nodes
309 if (self.destroyednodes.has_key(classname) and
310 self.destroyednodes[classname].has_key(nodeid)):
311 raise IndexError, "no such %s %s"%(classname, nodeid)
313 # decode
314 res = marshal.loads(db[nodeid])
316 # reverse the serialisation
317 res = self.unserialise(classname, res)
319 # store off in the cache dict
320 if cache:
321 cache_dict[nodeid] = res
323 return res
325 def destroynode(self, classname, nodeid):
326 '''Remove a node from the database. Called exclusively by the
327 destroy() method on Class.
328 '''
329 if __debug__:
330 print >>hyperdb.DEBUG, 'destroynode', (self, classname, nodeid)
332 # remove from cache and newnodes if it's there
333 if (self.cache.has_key(classname) and
334 self.cache[classname].has_key(nodeid)):
335 del self.cache[classname][nodeid]
336 if (self.newnodes.has_key(classname) and
337 self.newnodes[classname].has_key(nodeid)):
338 del self.newnodes[classname][nodeid]
340 # see if there's any obvious commit actions that we should get rid of
341 for entry in self.transactions[:]:
342 if entry[1][:2] == (classname, nodeid):
343 self.transactions.remove(entry)
345 # add to the destroyednodes map
346 self.destroyednodes.setdefault(classname, {})[nodeid] = 1
348 # add the destroy commit action
349 self.transactions.append((self.doDestroyNode, (classname, nodeid)))
351 def serialise(self, classname, node):
352 '''Copy the node contents, converting non-marshallable data into
353 marshallable data.
354 '''
355 if __debug__:
356 print >>hyperdb.DEBUG, 'serialise', classname, node
357 properties = self.getclass(classname).getprops()
358 d = {}
359 for k, v in node.items():
360 # if the property doesn't exist, or is the "retired" flag then
361 # it won't be in the properties dict
362 if not properties.has_key(k):
363 d[k] = v
364 continue
366 # get the property spec
367 prop = properties[k]
369 if isinstance(prop, Password):
370 d[k] = str(v)
371 elif isinstance(prop, Date) and v is not None:
372 d[k] = v.serialise()
373 elif isinstance(prop, Interval) and v is not None:
374 d[k] = v.serialise()
375 else:
376 d[k] = v
377 return d
379 def unserialise(self, classname, node):
380 '''Decode the marshalled node data
381 '''
382 if __debug__:
383 print >>hyperdb.DEBUG, 'unserialise', classname, node
384 properties = self.getclass(classname).getprops()
385 d = {}
386 for k, v in node.items():
387 # if the property doesn't exist, or is the "retired" flag then
388 # it won't be in the properties dict
389 if not properties.has_key(k):
390 d[k] = v
391 continue
393 # get the property spec
394 prop = properties[k]
396 if isinstance(prop, Date) and v is not None:
397 d[k] = date.Date(v)
398 elif isinstance(prop, Interval) and v is not None:
399 d[k] = date.Interval(v)
400 elif isinstance(prop, Password):
401 p = password.Password()
402 p.unpack(v)
403 d[k] = p
404 else:
405 d[k] = v
406 return d
408 def hasnode(self, classname, nodeid, db=None):
409 ''' determine if the database has a given node
410 '''
411 if __debug__:
412 print >>hyperdb.DEBUG, 'hasnode', (self, classname, nodeid, db)
414 # try the cache
415 cache = self.cache.setdefault(classname, {})
416 if cache.has_key(nodeid):
417 if __debug__:
418 print >>hyperdb.TRACE, 'has %s %s cached'%(classname, nodeid)
419 return 1
420 if __debug__:
421 print >>hyperdb.TRACE, 'has %s %s'%(classname, nodeid)
423 # not in the cache - check the database
424 if db is None:
425 db = self.getclassdb(classname)
426 res = db.has_key(nodeid)
427 return res
429 def countnodes(self, classname, db=None):
430 if __debug__:
431 print >>hyperdb.DEBUG, 'countnodes', (self, classname, db)
433 count = 0
435 # include the uncommitted nodes
436 if self.newnodes.has_key(classname):
437 count += len(self.newnodes[classname])
438 if self.destroyednodes.has_key(classname):
439 count -= len(self.destroyednodes[classname])
441 # and count those in the DB
442 if db is None:
443 db = self.getclassdb(classname)
444 count = count + len(db.keys())
445 return count
447 def getnodeids(self, classname, db=None):
448 if __debug__:
449 print >>hyperdb.DEBUG, 'getnodeids', (self, classname, db)
451 res = []
453 # start off with the new nodes
454 if self.newnodes.has_key(classname):
455 res += self.newnodes[classname].keys()
457 if db is None:
458 db = self.getclassdb(classname)
459 res = res + db.keys()
461 # remove the uncommitted, destroyed nodes
462 if self.destroyednodes.has_key(classname):
463 for nodeid in self.destroyednodes[classname].keys():
464 if db.has_key(nodeid):
465 res.remove(nodeid)
467 return res
470 #
471 # Files - special node properties
472 # inherited from FileStorage
474 #
475 # Journal
476 #
477 def addjournal(self, classname, nodeid, action, params, creator=None,
478 creation=None):
479 ''' Journal the Action
480 'action' may be:
482 'create' or 'set' -- 'params' is a dictionary of property values
483 'link' or 'unlink' -- 'params' is (classname, nodeid, propname)
484 'retire' -- 'params' is None
485 '''
486 if __debug__:
487 print >>hyperdb.DEBUG, 'addjournal', (self, classname, nodeid,
488 action, params, creator, creation)
489 self.transactions.append((self.doSaveJournal, (classname, nodeid,
490 action, params, creator, creation)))
492 def getjournal(self, classname, nodeid):
493 ''' get the journal for id
495 Raise IndexError if the node doesn't exist (as per history()'s
496 API)
497 '''
498 if __debug__:
499 print >>hyperdb.DEBUG, 'getjournal', (self, classname, nodeid)
500 # attempt to open the journal - in some rare cases, the journal may
501 # not exist
502 try:
503 db = self.opendb('journals.%s'%classname, 'r')
504 except anydbm.error, error:
505 if str(error) == "need 'c' or 'n' flag to open new db":
506 raise IndexError, 'no such %s %s'%(classname, nodeid)
507 elif error.args[0] != 2:
508 raise
509 raise IndexError, 'no such %s %s'%(classname, nodeid)
510 try:
511 journal = marshal.loads(db[nodeid])
512 except KeyError:
513 db.close()
514 raise IndexError, 'no such %s %s'%(classname, nodeid)
515 db.close()
516 res = []
517 for nodeid, date_stamp, user, action, params in journal:
518 res.append((nodeid, date.Date(date_stamp), user, action, params))
519 return res
521 def pack(self, pack_before):
522 ''' Delete all journal entries except "create" before 'pack_before'.
523 '''
524 if __debug__:
525 print >>hyperdb.DEBUG, 'packjournal', (self, pack_before)
527 for classname in self.getclasses():
528 # get the journal db
529 db_name = 'journals.%s'%classname
530 path = os.path.join(os.getcwd(), self.dir, classname)
531 db_type = self.determine_db_type(path)
532 db = self.opendb(db_name, 'w')
534 for key in db.keys():
535 # get the journal for this db entry
536 journal = marshal.loads(db[key])
537 l = []
538 last_set_entry = None
539 for entry in journal:
540 # unpack the entry
541 (nodeid, date_stamp, self.journaltag, action,
542 params) = entry
543 date_stamp = date.Date(date_stamp)
544 # if the entry is after the pack date, _or_ the initial
545 # create entry, then it stays
546 if date_stamp > pack_before or action == 'create':
547 l.append(entry)
548 elif action == 'set':
549 # grab the last set entry to keep information on
550 # activity
551 last_set_entry = entry
552 if last_set_entry:
553 date_stamp = last_set_entry[1]
554 # if the last set entry was made after the pack date
555 # then it is already in the list
556 if date_stamp < pack_before:
557 l.append(last_set_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,)
572 # TODO: lock the DB
574 # keep a handle to all the database files opened
575 self.databases = {}
577 # now, do all the transactions
578 reindex = {}
579 for method, args in self.transactions:
580 reindex[method(*args)] = 1
582 # now close all the database files
583 for db in self.databases.values():
584 db.close()
585 del self.databases
586 # TODO: unlock the DB
588 # reindex the nodes that request it
589 for classname, nodeid in filter(None, reindex.keys()):
590 print >>hyperdb.DEBUG, 'commit.reindex', (classname, nodeid)
591 self.getclass(classname).index(nodeid)
593 # save the indexer state
594 self.indexer.save_index()
596 self.clearCache()
598 def clearCache(self):
599 # all transactions committed, back to normal
600 self.cache = {}
601 self.dirtynodes = {}
602 self.newnodes = {}
603 self.destroyednodes = {}
604 self.transactions = []
606 def getCachedClassDB(self, classname):
607 ''' get the class db, looking in our cache of databases for commit
608 '''
609 # get the database handle
610 db_name = 'nodes.%s'%classname
611 if not self.databases.has_key(db_name):
612 self.databases[db_name] = self.getclassdb(classname, 'c')
613 return self.databases[db_name]
615 def doSaveNode(self, classname, nodeid, node):
616 if __debug__:
617 print >>hyperdb.DEBUG, 'doSaveNode', (self, classname, nodeid,
618 node)
620 db = self.getCachedClassDB(classname)
622 # now save the marshalled data
623 db[nodeid] = marshal.dumps(self.serialise(classname, node))
625 # return the classname, nodeid so we reindex this content
626 return (classname, nodeid)
628 def getCachedJournalDB(self, classname):
629 ''' get the journal db, looking in our cache of databases for commit
630 '''
631 # get the database handle
632 db_name = 'journals.%s'%classname
633 if not self.databases.has_key(db_name):
634 self.databases[db_name] = self.opendb(db_name, 'c')
635 return self.databases[db_name]
637 def doSaveJournal(self, classname, nodeid, action, params, creator,
638 creation):
639 # serialise the parameters now if necessary
640 if isinstance(params, type({})):
641 if action in ('set', 'create'):
642 params = self.serialise(classname, params)
644 # handle supply of the special journalling parameters (usually
645 # supplied on importing an existing database)
646 if creator:
647 journaltag = creator
648 else:
649 journaltag = self.curuserid
650 if creation:
651 journaldate = creation.serialise()
652 else:
653 journaldate = date.Date().serialise()
655 # create the journal entry
656 entry = (nodeid, journaldate, journaltag, action, params)
658 if __debug__:
659 print >>hyperdb.DEBUG, 'doSaveJournal', entry
661 db = self.getCachedJournalDB(classname)
663 # now insert the journal entry
664 if db.has_key(nodeid):
665 # append to existing
666 s = db[nodeid]
667 l = marshal.loads(s)
668 l.append(entry)
669 else:
670 l = [entry]
672 db[nodeid] = marshal.dumps(l)
674 def doDestroyNode(self, classname, nodeid):
675 if __debug__:
676 print >>hyperdb.DEBUG, 'doDestroyNode', (self, classname, nodeid)
678 # delete from the class database
679 db = self.getCachedClassDB(classname)
680 if db.has_key(nodeid):
681 del db[nodeid]
683 # delete from the database
684 db = self.getCachedJournalDB(classname)
685 if db.has_key(nodeid):
686 del db[nodeid]
688 # return the classname, nodeid so we reindex this content
689 return (classname, nodeid)
691 def rollback(self):
692 ''' Reverse all actions from the current transaction.
693 '''
694 if __debug__:
695 print >>hyperdb.DEBUG, 'rollback', (self, )
696 for method, args in self.transactions:
697 # delete temporary files
698 if method == self.doStoreFile:
699 self.rollbackStoreFile(*args)
700 self.cache = {}
701 self.dirtynodes = {}
702 self.newnodes = {}
703 self.destroyednodes = {}
704 self.transactions = []
706 def close(self):
707 ''' Nothing to do
708 '''
709 pass
711 _marker = []
712 class Class(hyperdb.Class):
713 '''The handle to a particular class of nodes in a hyperdatabase.'''
715 def __init__(self, db, classname, **properties):
716 '''Create a new class with a given name and property specification.
718 'classname' must not collide with the name of an existing class,
719 or a ValueError is raised. The keyword arguments in 'properties'
720 must map names to property objects, or a TypeError is raised.
721 '''
722 if (properties.has_key('creation') or properties.has_key('activity')
723 or properties.has_key('creator')):
724 raise ValueError, '"creation", "activity" and "creator" are '\
725 'reserved'
727 self.classname = classname
728 self.properties = properties
729 self.db = weakref.proxy(db) # use a weak ref to avoid circularity
730 self.key = ''
732 # should we journal changes (default yes)
733 self.do_journal = 1
735 # do the db-related init stuff
736 db.addclass(self)
738 self.auditors = {'create': [], 'set': [], 'retire': []}
739 self.reactors = {'create': [], 'set': [], 'retire': []}
741 def enableJournalling(self):
742 '''Turn journalling on for this class
743 '''
744 self.do_journal = 1
746 def disableJournalling(self):
747 '''Turn journalling off for this class
748 '''
749 self.do_journal = 0
751 # Editing nodes:
753 def create(self, **propvalues):
754 '''Create a new node of this class and return its id.
756 The keyword arguments in 'propvalues' map property names to values.
758 The values of arguments must be acceptable for the types of their
759 corresponding properties or a TypeError is raised.
761 If this class has a key property, it must be present and its value
762 must not collide with other key strings or a ValueError is raised.
764 Any other properties on this class that are missing from the
765 'propvalues' dictionary are set to None.
767 If an id in a link or multilink property does not refer to a valid
768 node, an IndexError is raised.
770 These operations trigger detectors and can be vetoed. Attempts
771 to modify the "creation" or "activity" properties cause a KeyError.
772 '''
773 if propvalues.has_key('id'):
774 raise KeyError, '"id" is reserved'
776 if self.db.journaltag is None:
777 raise DatabaseError, 'Database open read-only'
779 if propvalues.has_key('creation') or propvalues.has_key('activity'):
780 raise KeyError, '"creation" and "activity" are reserved'
782 self.fireAuditors('create', None, propvalues)
784 # new node's id
785 newid = self.db.newid(self.classname)
787 # validate propvalues
788 num_re = re.compile('^\d+$')
789 for key, value in propvalues.items():
790 if key == self.key:
791 try:
792 self.lookup(value)
793 except KeyError:
794 pass
795 else:
796 raise ValueError, 'node with key "%s" exists'%value
798 # try to handle this property
799 try:
800 prop = self.properties[key]
801 except KeyError:
802 raise KeyError, '"%s" has no property "%s"'%(self.classname,
803 key)
805 if value is not None and isinstance(prop, Link):
806 if type(value) != type(''):
807 raise ValueError, 'link value must be String'
808 link_class = self.properties[key].classname
809 # if it isn't a number, it's a key
810 if not num_re.match(value):
811 try:
812 value = self.db.classes[link_class].lookup(value)
813 except (TypeError, KeyError):
814 raise IndexError, 'new property "%s": %s not a %s'%(
815 key, value, link_class)
816 elif not self.db.getclass(link_class).hasnode(value):
817 raise IndexError, '%s has no node %s'%(link_class, value)
819 # save off the value
820 propvalues[key] = value
822 # register the link with the newly linked node
823 if self.do_journal and self.properties[key].do_journal:
824 self.db.addjournal(link_class, value, 'link',
825 (self.classname, newid, key))
827 elif isinstance(prop, Multilink):
828 if type(value) != type([]):
829 raise TypeError, 'new property "%s" not a list of ids'%key
831 # clean up and validate the list of links
832 link_class = self.properties[key].classname
833 l = []
834 for entry in value:
835 if type(entry) != type(''):
836 raise ValueError, '"%s" multilink value (%r) '\
837 'must contain Strings'%(key, value)
838 # if it isn't a number, it's a key
839 if not num_re.match(entry):
840 try:
841 entry = self.db.classes[link_class].lookup(entry)
842 except (TypeError, KeyError):
843 raise IndexError, 'new property "%s": %s not a %s'%(
844 key, entry, self.properties[key].classname)
845 l.append(entry)
846 value = l
847 propvalues[key] = value
849 # handle additions
850 for nodeid in value:
851 if not self.db.getclass(link_class).hasnode(nodeid):
852 raise IndexError, '%s has no node %s'%(link_class,
853 nodeid)
854 # register the link with the newly linked node
855 if self.do_journal and self.properties[key].do_journal:
856 self.db.addjournal(link_class, nodeid, 'link',
857 (self.classname, newid, key))
859 elif isinstance(prop, String):
860 if type(value) != type(''):
861 raise TypeError, 'new property "%s" not a string'%key
863 elif isinstance(prop, Password):
864 if not isinstance(value, password.Password):
865 raise TypeError, 'new property "%s" not a Password'%key
867 elif isinstance(prop, Date):
868 if value is not None and not isinstance(value, date.Date):
869 raise TypeError, 'new property "%s" not a Date'%key
871 elif isinstance(prop, Interval):
872 if value is not None and not isinstance(value, date.Interval):
873 raise TypeError, 'new property "%s" not an Interval'%key
875 elif value is not None and isinstance(prop, Number):
876 try:
877 float(value)
878 except ValueError:
879 raise TypeError, 'new property "%s" not numeric'%key
881 elif value is not None and isinstance(prop, Boolean):
882 try:
883 int(value)
884 except ValueError:
885 raise TypeError, 'new property "%s" not boolean'%key
887 # make sure there's data where there needs to be
888 for key, prop in self.properties.items():
889 if propvalues.has_key(key):
890 continue
891 if key == self.key:
892 raise ValueError, 'key property "%s" is required'%key
893 if isinstance(prop, Multilink):
894 propvalues[key] = []
895 else:
896 propvalues[key] = None
898 # done
899 self.db.addnode(self.classname, newid, propvalues)
900 if self.do_journal:
901 self.db.addjournal(self.classname, newid, 'create', propvalues)
903 self.fireReactors('create', newid, None)
905 return newid
907 def export_list(self, propnames, nodeid):
908 ''' Export a node - generate a list of CSV-able data in the order
909 specified by propnames for the given node.
910 '''
911 properties = self.getprops()
912 l = []
913 for prop in propnames:
914 proptype = properties[prop]
915 value = self.get(nodeid, prop)
916 # "marshal" data where needed
917 if value is None:
918 pass
919 elif isinstance(proptype, hyperdb.Date):
920 value = value.get_tuple()
921 elif isinstance(proptype, hyperdb.Interval):
922 value = value.get_tuple()
923 elif isinstance(proptype, hyperdb.Password):
924 value = str(value)
925 l.append(repr(value))
926 return l
928 def import_list(self, propnames, proplist):
929 ''' Import a node - all information including "id" is present and
930 should not be sanity checked. Triggers are not triggered. The
931 journal should be initialised using the "creator" and "created"
932 information.
934 Return the nodeid of the node imported.
935 '''
936 if self.db.journaltag is None:
937 raise DatabaseError, 'Database open read-only'
938 properties = self.getprops()
940 # make the new node's property map
941 d = {}
942 for i in range(len(propnames)):
943 # Use eval to reverse the repr() used to output the CSV
944 value = eval(proplist[i])
946 # Figure the property for this column
947 propname = propnames[i]
948 prop = properties[propname]
950 # "unmarshal" where necessary
951 if propname == 'id':
952 newid = value
953 continue
954 elif value is None:
955 # don't set Nones
956 continue
957 elif isinstance(prop, hyperdb.Date):
958 value = date.Date(value)
959 elif isinstance(prop, hyperdb.Interval):
960 value = date.Interval(value)
961 elif isinstance(prop, hyperdb.Password):
962 pwd = password.Password()
963 pwd.unpack(value)
964 value = pwd
965 d[propname] = value
967 # add the node and journal
968 self.db.addnode(self.classname, newid, d)
970 # extract the journalling stuff and nuke it
971 if d.has_key('creator'):
972 creator = d['creator']
973 del d['creator']
974 else:
975 creator = None
976 if d.has_key('creation'):
977 creation = d['creation']
978 del d['creation']
979 else:
980 creation = None
981 if d.has_key('activity'):
982 del d['activity']
984 self.db.addjournal(self.classname, newid, 'create', d, creator,
985 creation)
986 return newid
988 def get(self, nodeid, propname, default=_marker, cache=1):
989 '''Get the value of a property on an existing node of this class.
991 'nodeid' must be the id of an existing node of this class or an
992 IndexError is raised. 'propname' must be the name of a property
993 of this class or a KeyError is raised.
995 'cache' indicates whether the transaction cache should be queried
996 for the node. If the node has been modified and you need to
997 determine what its values prior to modification are, you need to
998 set cache=0.
1000 Attempts to get the "creation" or "activity" properties should
1001 do the right thing.
1002 '''
1003 if propname == 'id':
1004 return nodeid
1006 # get the node's dict
1007 d = self.db.getnode(self.classname, nodeid, cache=cache)
1009 # check for one of the special props
1010 if propname == 'creation':
1011 if d.has_key('creation'):
1012 return d['creation']
1013 if not self.do_journal:
1014 raise ValueError, 'Journalling is disabled for this class'
1015 journal = self.db.getjournal(self.classname, nodeid)
1016 if journal:
1017 return self.db.getjournal(self.classname, nodeid)[0][1]
1018 else:
1019 # on the strange chance that there's no journal
1020 return date.Date()
1021 if propname == 'activity':
1022 if d.has_key('activity'):
1023 return d['activity']
1024 if not self.do_journal:
1025 raise ValueError, 'Journalling is disabled for this class'
1026 journal = self.db.getjournal(self.classname, nodeid)
1027 if journal:
1028 return self.db.getjournal(self.classname, nodeid)[-1][1]
1029 else:
1030 # on the strange chance that there's no journal
1031 return date.Date()
1032 if propname == 'creator':
1033 if d.has_key('creator'):
1034 return d['creator']
1035 if not self.do_journal:
1036 raise ValueError, 'Journalling is disabled for this class'
1037 journal = self.db.getjournal(self.classname, nodeid)
1038 if journal:
1039 num_re = re.compile('^\d+$')
1040 value = self.db.getjournal(self.classname, nodeid)[0][2]
1041 if num_re.match(value):
1042 return value
1043 else:
1044 # old-style "username" journal tag
1045 try:
1046 return self.db.user.lookup(value)
1047 except KeyError:
1048 # user's been retired, return admin
1049 return '1'
1050 else:
1051 return self.db.curuserid
1053 # get the property (raises KeyErorr if invalid)
1054 prop = self.properties[propname]
1056 if not d.has_key(propname):
1057 if default is _marker:
1058 if isinstance(prop, Multilink):
1059 return []
1060 else:
1061 return None
1062 else:
1063 return default
1065 # return a dupe of the list so code doesn't get confused
1066 if isinstance(prop, Multilink):
1067 return d[propname][:]
1069 return d[propname]
1071 # not in spec
1072 def getnode(self, nodeid, cache=1):
1073 ''' Return a convenience wrapper for the node.
1075 'nodeid' must be the id of an existing node of this class or an
1076 IndexError is raised.
1078 'cache' indicates whether the transaction cache should be queried
1079 for the node. If the node has been modified and you need to
1080 determine what its values prior to modification are, you need to
1081 set cache=0.
1082 '''
1083 return Node(self, nodeid, cache=cache)
1085 def set(self, nodeid, **propvalues):
1086 '''Modify a property on an existing node of this class.
1088 'nodeid' must be the id of an existing node of this class or an
1089 IndexError is raised.
1091 Each key in 'propvalues' must be the name of a property of this
1092 class or a KeyError is raised.
1094 All values in 'propvalues' must be acceptable types for their
1095 corresponding properties or a TypeError is raised.
1097 If the value of the key property is set, it must not collide with
1098 other key strings or a ValueError is raised.
1100 If the value of a Link or Multilink property contains an invalid
1101 node id, a ValueError is raised.
1103 These operations trigger detectors and can be vetoed. Attempts
1104 to modify the "creation" or "activity" properties cause a KeyError.
1105 '''
1106 if not propvalues:
1107 return propvalues
1109 if propvalues.has_key('creation') or propvalues.has_key('activity'):
1110 raise KeyError, '"creation" and "activity" are reserved'
1112 if propvalues.has_key('id'):
1113 raise KeyError, '"id" is reserved'
1115 if self.db.journaltag is None:
1116 raise DatabaseError, 'Database open read-only'
1118 self.fireAuditors('set', nodeid, propvalues)
1119 # Take a copy of the node dict so that the subsequent set
1120 # operation doesn't modify the oldvalues structure.
1121 try:
1122 # try not using the cache initially
1123 oldvalues = copy.deepcopy(self.db.getnode(self.classname, nodeid,
1124 cache=0))
1125 except IndexError:
1126 # this will be needed if somone does a create() and set()
1127 # with no intervening commit()
1128 oldvalues = copy.deepcopy(self.db.getnode(self.classname, nodeid))
1130 node = self.db.getnode(self.classname, nodeid)
1131 if node.has_key(self.db.RETIRED_FLAG):
1132 raise IndexError
1133 num_re = re.compile('^\d+$')
1135 # if the journal value is to be different, store it in here
1136 journalvalues = {}
1138 for propname, value in propvalues.items():
1139 # check to make sure we're not duplicating an existing key
1140 if propname == self.key and node[propname] != value:
1141 try:
1142 self.lookup(value)
1143 except KeyError:
1144 pass
1145 else:
1146 raise ValueError, 'node with key "%s" exists'%value
1148 # this will raise the KeyError if the property isn't valid
1149 # ... we don't use getprops() here because we only care about
1150 # the writeable properties.
1151 try:
1152 prop = self.properties[propname]
1153 except KeyError:
1154 raise KeyError, '"%s" has no property named "%s"'%(
1155 self.classname, propname)
1157 # if the value's the same as the existing value, no sense in
1158 # doing anything
1159 if node.has_key(propname) and value == node[propname]:
1160 del propvalues[propname]
1161 continue
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[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(''):
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 propvalues.update(journalvalues)
1298 self.db.addjournal(self.classname, nodeid, 'set', propvalues)
1300 self.fireReactors('set', nodeid, oldvalues)
1302 return propvalues
1304 def retire(self, nodeid):
1305 '''Retire a node.
1307 The properties on the node remain available from the get() method,
1308 and the node's id is never reused.
1310 Retired nodes are not returned by the find(), list(), or lookup()
1311 methods, and other nodes may reuse the values of their key properties.
1313 These operations trigger detectors and can be vetoed. Attempts
1314 to modify the "creation" or "activity" properties cause a KeyError.
1315 '''
1316 if self.db.journaltag is None:
1317 raise DatabaseError, 'Database open read-only'
1319 self.fireAuditors('retire', nodeid, None)
1321 node = self.db.getnode(self.classname, nodeid)
1322 node[self.db.RETIRED_FLAG] = 1
1323 self.db.setnode(self.classname, nodeid, node)
1324 if self.do_journal:
1325 self.db.addjournal(self.classname, nodeid, 'retired', None)
1327 self.fireReactors('retire', nodeid, None)
1329 def is_retired(self, nodeid):
1330 '''Return true if the node is retired.
1331 '''
1332 node = self.db.getnode(cn, nodeid, cldb)
1333 if node.has_key(self.db.RETIRED_FLAG):
1334 return 1
1335 return 0
1337 def destroy(self, nodeid):
1338 '''Destroy a node.
1340 WARNING: this method should never be used except in extremely rare
1341 situations where there could never be links to the node being
1342 deleted
1343 WARNING: use retire() instead
1344 WARNING: the properties of this node will not be available ever again
1345 WARNING: really, use retire() instead
1347 Well, I think that's enough warnings. This method exists mostly to
1348 support the session storage of the cgi interface.
1349 '''
1350 if self.db.journaltag is None:
1351 raise DatabaseError, 'Database open read-only'
1352 self.db.destroynode(self.classname, nodeid)
1354 def history(self, nodeid):
1355 '''Retrieve the journal of edits on a particular node.
1357 'nodeid' must be the id of an existing node of this class or an
1358 IndexError is raised.
1360 The returned list contains tuples of the form
1362 (date, tag, action, params)
1364 'date' is a Timestamp object specifying the time of the change and
1365 'tag' is the journaltag specified when the database was opened.
1366 '''
1367 if not self.do_journal:
1368 raise ValueError, 'Journalling is disabled for this class'
1369 return self.db.getjournal(self.classname, nodeid)
1371 # Locating nodes:
1372 def hasnode(self, nodeid):
1373 '''Determine if the given nodeid actually exists
1374 '''
1375 return self.db.hasnode(self.classname, nodeid)
1377 def setkey(self, propname):
1378 '''Select a String property of this class to be the key property.
1380 'propname' must be the name of a String property of this class or
1381 None, or a TypeError is raised. The values of the key property on
1382 all existing nodes must be unique or a ValueError is raised. If the
1383 property doesn't exist, KeyError is raised.
1384 '''
1385 prop = self.getprops()[propname]
1386 if not isinstance(prop, String):
1387 raise TypeError, 'key properties must be String'
1388 self.key = propname
1390 def getkey(self):
1391 '''Return the name of the key property for this class or None.'''
1392 return self.key
1394 def labelprop(self, default_to_id=0):
1395 ''' Return the property name for a label for the given node.
1397 This method attempts to generate a consistent label for the node.
1398 It tries the following in order:
1399 1. key property
1400 2. "name" property
1401 3. "title" property
1402 4. first property from the sorted property name list
1403 '''
1404 k = self.getkey()
1405 if k:
1406 return k
1407 props = self.getprops()
1408 if props.has_key('name'):
1409 return 'name'
1410 elif props.has_key('title'):
1411 return 'title'
1412 if default_to_id:
1413 return 'id'
1414 props = props.keys()
1415 props.sort()
1416 return props[0]
1418 # TODO: set up a separate index db file for this? profile?
1419 def lookup(self, keyvalue):
1420 '''Locate a particular node by its key property and return its id.
1422 If this class has no key property, a TypeError is raised. If the
1423 'keyvalue' matches one of the values for the key property among
1424 the nodes in this class, the matching node's id is returned;
1425 otherwise a KeyError is raised.
1426 '''
1427 if not self.key:
1428 raise TypeError, 'No key property set for class %s'%self.classname
1429 cldb = self.db.getclassdb(self.classname)
1430 try:
1431 for nodeid in self.db.getnodeids(self.classname, cldb):
1432 node = self.db.getnode(self.classname, nodeid, cldb)
1433 if node.has_key(self.db.RETIRED_FLAG):
1434 continue
1435 if node[self.key] == keyvalue:
1436 cldb.close()
1437 return nodeid
1438 finally:
1439 cldb.close()
1440 raise KeyError, 'No key (%s) value "%s" for "%s"'%(self.key,
1441 keyvalue, self.classname)
1443 # change from spec - allows multiple props to match
1444 def find(self, **propspec):
1445 '''Get the ids of nodes in this class which link to the given nodes.
1447 'propspec' consists of keyword args propname={nodeid:1,}
1448 'propname' must be the name of a property in this class, or a
1449 KeyError is raised. That property must be a Link or Multilink
1450 property, or a TypeError is raised.
1452 Any node in this class whose 'propname' property links to any of the
1453 nodeids will be returned. Used by the full text indexing, which knows
1454 that "foo" occurs in msg1, msg3 and file7, so we have hits on these issues:
1455 db.issue.find(messages={'1':1,'3':1}, files={'7':1})
1456 '''
1457 propspec = propspec.items()
1458 for propname, nodeids in propspec:
1459 # check the prop is OK
1460 prop = self.properties[propname]
1461 if not isinstance(prop, Link) and not isinstance(prop, Multilink):
1462 raise TypeError, "'%s' not a Link/Multilink property"%propname
1464 # ok, now do the find
1465 cldb = self.db.getclassdb(self.classname)
1466 l = []
1467 try:
1468 for id in self.db.getnodeids(self.classname, db=cldb):
1469 node = self.db.getnode(self.classname, id, db=cldb)
1470 if node.has_key(self.db.RETIRED_FLAG):
1471 continue
1472 for propname, nodeids in propspec:
1473 # can't test if the node doesn't have this property
1474 if not node.has_key(propname):
1475 continue
1476 if type(nodeids) is type(''):
1477 nodeids = {nodeids:1}
1478 prop = self.properties[propname]
1479 value = node[propname]
1480 if isinstance(prop, Link) and nodeids.has_key(value):
1481 l.append(id)
1482 break
1483 elif isinstance(prop, Multilink):
1484 hit = 0
1485 for v in value:
1486 if nodeids.has_key(v):
1487 l.append(id)
1488 hit = 1
1489 break
1490 if hit:
1491 break
1492 finally:
1493 cldb.close()
1494 return l
1496 def stringFind(self, **requirements):
1497 '''Locate a particular node by matching a set of its String
1498 properties in a caseless search.
1500 If the property is not a String property, a TypeError is raised.
1502 The return is a list of the id of all nodes that match.
1503 '''
1504 for propname in requirements.keys():
1505 prop = self.properties[propname]
1506 if isinstance(not prop, String):
1507 raise TypeError, "'%s' not a String property"%propname
1508 requirements[propname] = requirements[propname].lower()
1509 l = []
1510 cldb = self.db.getclassdb(self.classname)
1511 try:
1512 for nodeid in self.db.getnodeids(self.classname, cldb):
1513 node = self.db.getnode(self.classname, nodeid, cldb)
1514 if node.has_key(self.db.RETIRED_FLAG):
1515 continue
1516 for key, value in requirements.items():
1517 if node[key] is None or node[key].lower() != value:
1518 break
1519 else:
1520 l.append(nodeid)
1521 finally:
1522 cldb.close()
1523 return l
1525 def list(self):
1526 ''' Return a list of the ids of the active nodes in this class.
1527 '''
1528 l = []
1529 cn = self.classname
1530 cldb = self.db.getclassdb(cn)
1531 try:
1532 for nodeid in self.db.getnodeids(cn, cldb):
1533 node = self.db.getnode(cn, nodeid, cldb)
1534 if node.has_key(self.db.RETIRED_FLAG):
1535 continue
1536 l.append(nodeid)
1537 finally:
1538 cldb.close()
1539 l.sort()
1540 return l
1542 def filter(self, search_matches, filterspec, sort, group,
1543 num_re = re.compile('^\d+$')):
1544 ''' Return a list of the ids of the active nodes in this class that
1545 match the 'filter' spec, sorted by the group spec and then the
1546 sort spec.
1548 "filterspec" is {propname: value(s)}
1549 "sort" and "group" are (dir, prop) where dir is '+', '-' or None
1550 and prop is a prop name or None
1551 "search_matches" is {nodeid: marker}
1553 The filter must match all properties specificed - but if the
1554 property value to match is a list, any one of the values in the
1555 list may match for that property to match.
1556 '''
1557 cn = self.classname
1559 # optimise filterspec
1560 l = []
1561 props = self.getprops()
1562 LINK = 0
1563 MULTILINK = 1
1564 STRING = 2
1565 OTHER = 6
1566 for k, v in filterspec.items():
1567 propclass = props[k]
1568 if isinstance(propclass, Link):
1569 if type(v) is not type([]):
1570 v = [v]
1571 # replace key values with node ids
1572 u = []
1573 link_class = self.db.classes[propclass.classname]
1574 for entry in v:
1575 if entry == '-1': entry = None
1576 elif not num_re.match(entry):
1577 try:
1578 entry = link_class.lookup(entry)
1579 except (TypeError,KeyError):
1580 raise ValueError, 'property "%s": %s not a %s'%(
1581 k, entry, self.properties[k].classname)
1582 u.append(entry)
1584 l.append((LINK, k, u))
1585 elif isinstance(propclass, Multilink):
1586 if type(v) is not type([]):
1587 v = [v]
1588 # replace key values with node ids
1589 u = []
1590 link_class = self.db.classes[propclass.classname]
1591 for entry in v:
1592 if not num_re.match(entry):
1593 try:
1594 entry = link_class.lookup(entry)
1595 except (TypeError,KeyError):
1596 raise ValueError, 'new property "%s": %s not a %s'%(
1597 k, entry, self.properties[k].classname)
1598 u.append(entry)
1599 l.append((MULTILINK, k, u))
1600 elif isinstance(propclass, String):
1601 # simple glob searching
1602 v = re.sub(r'([\|\{\}\\\.\+\[\]\(\)])', r'\\\1', v)
1603 v = v.replace('?', '.')
1604 v = v.replace('*', '.*?')
1605 l.append((STRING, k, re.compile(v, re.I)))
1606 elif isinstance(propclass, Boolean):
1607 if type(v) is type(''):
1608 bv = v.lower() in ('yes', 'true', 'on', '1')
1609 else:
1610 bv = v
1611 l.append((OTHER, k, bv))
1612 elif isinstance(propclass, Number):
1613 l.append((OTHER, k, int(v)))
1614 else:
1615 l.append((OTHER, k, v))
1616 filterspec = l
1618 # now, find all the nodes that are active and pass filtering
1619 l = []
1620 cldb = self.db.getclassdb(cn)
1621 try:
1622 # TODO: only full-scan once (use items())
1623 for nodeid in self.db.getnodeids(cn, cldb):
1624 node = self.db.getnode(cn, nodeid, cldb)
1625 if node.has_key(self.db.RETIRED_FLAG):
1626 continue
1627 # apply filter
1628 for t, k, v in filterspec:
1629 # make sure the node has the property
1630 if not node.has_key(k):
1631 # this node doesn't have this property, so reject it
1632 break
1634 # now apply the property filter
1635 if t == LINK:
1636 # link - if this node's property doesn't appear in the
1637 # filterspec's nodeid list, skip it
1638 if node[k] not in v:
1639 break
1640 elif t == MULTILINK:
1641 # multilink - if any of the nodeids required by the
1642 # filterspec aren't in this node's property, then skip
1643 # it
1644 have = node[k]
1645 for want in v:
1646 if want not in have:
1647 break
1648 else:
1649 continue
1650 break
1651 elif t == STRING:
1652 # RE search
1653 if node[k] is None or not v.search(node[k]):
1654 break
1655 elif t == OTHER:
1656 # straight value comparison for the other types
1657 if node[k] != v:
1658 break
1659 else:
1660 l.append((nodeid, node))
1661 finally:
1662 cldb.close()
1663 l.sort()
1665 # filter based on full text search
1666 if search_matches is not None:
1667 k = []
1668 for v in l:
1669 if search_matches.has_key(v[0]):
1670 k.append(v)
1671 l = k
1673 # now, sort the result
1674 def sortfun(a, b, sort=sort, group=group, properties=self.getprops(),
1675 db = self.db, cl=self):
1676 a_id, an = a
1677 b_id, bn = b
1678 # sort by group and then sort
1679 for dir, prop in group, sort:
1680 if dir is None or prop is None: continue
1682 # sorting is class-specific
1683 propclass = properties[prop]
1685 # handle the properties that might be "faked"
1686 # also, handle possible missing properties
1687 try:
1688 if not an.has_key(prop):
1689 an[prop] = cl.get(a_id, prop)
1690 av = an[prop]
1691 except KeyError:
1692 # the node doesn't have a value for this property
1693 if isinstance(propclass, Multilink): av = []
1694 else: av = ''
1695 try:
1696 if not bn.has_key(prop):
1697 bn[prop] = cl.get(b_id, prop)
1698 bv = bn[prop]
1699 except KeyError:
1700 # the node doesn't have a value for this property
1701 if isinstance(propclass, Multilink): bv = []
1702 else: bv = ''
1704 # String and Date values are sorted in the natural way
1705 if isinstance(propclass, String):
1706 # clean up the strings
1707 if av and av[0] in string.uppercase:
1708 av = an[prop] = av.lower()
1709 if bv and bv[0] in string.uppercase:
1710 bv = bn[prop] = bv.lower()
1711 if (isinstance(propclass, String) or
1712 isinstance(propclass, Date)):
1713 # it might be a string that's really an integer
1714 try:
1715 av = int(av)
1716 bv = int(bv)
1717 except:
1718 pass
1719 if dir == '+':
1720 r = cmp(av, bv)
1721 if r != 0: return r
1722 elif dir == '-':
1723 r = cmp(bv, av)
1724 if r != 0: return r
1726 # Link properties are sorted according to the value of
1727 # the "order" property on the linked nodes if it is
1728 # present; or otherwise on the key string of the linked
1729 # nodes; or finally on the node ids.
1730 elif isinstance(propclass, Link):
1731 link = db.classes[propclass.classname]
1732 if av is None and bv is not None: return -1
1733 if av is not None and bv is None: return 1
1734 if av is None and bv is None: continue
1735 if link.getprops().has_key('order'):
1736 if dir == '+':
1737 r = cmp(link.get(av, 'order'),
1738 link.get(bv, 'order'))
1739 if r != 0: return r
1740 elif dir == '-':
1741 r = cmp(link.get(bv, 'order'),
1742 link.get(av, 'order'))
1743 if r != 0: return r
1744 elif link.getkey():
1745 key = link.getkey()
1746 if dir == '+':
1747 r = cmp(link.get(av, key), link.get(bv, key))
1748 if r != 0: return r
1749 elif dir == '-':
1750 r = cmp(link.get(bv, key), link.get(av, key))
1751 if r != 0: return r
1752 else:
1753 if dir == '+':
1754 r = cmp(av, bv)
1755 if r != 0: return r
1756 elif dir == '-':
1757 r = cmp(bv, av)
1758 if r != 0: return r
1760 # Multilink properties are sorted according to how many
1761 # links are present.
1762 elif isinstance(propclass, Multilink):
1763 if dir == '+':
1764 r = cmp(len(av), len(bv))
1765 if r != 0: return r
1766 elif dir == '-':
1767 r = cmp(len(bv), len(av))
1768 if r != 0: return r
1769 elif isinstance(propclass, Number) or isinstance(propclass, Boolean):
1770 if dir == '+':
1771 r = cmp(av, bv)
1772 elif dir == '-':
1773 r = cmp(bv, av)
1775 # end for dir, prop in sort, group:
1776 # if all else fails, compare the ids
1777 return cmp(a[0], b[0])
1779 l.sort(sortfun)
1780 return [i[0] for i in l]
1782 def count(self):
1783 '''Get the number of nodes in this class.
1785 If the returned integer is 'numnodes', the ids of all the nodes
1786 in this class run from 1 to numnodes, and numnodes+1 will be the
1787 id of the next node to be created in this class.
1788 '''
1789 return self.db.countnodes(self.classname)
1791 # Manipulating properties:
1793 def getprops(self, protected=1):
1794 '''Return a dictionary mapping property names to property objects.
1795 If the "protected" flag is true, we include protected properties -
1796 those which may not be modified.
1798 In addition to the actual properties on the node, these
1799 methods provide the "creation" and "activity" properties. If the
1800 "protected" flag is true, we include protected properties - those
1801 which may not be modified.
1802 '''
1803 d = self.properties.copy()
1804 if protected:
1805 d['id'] = String()
1806 d['creation'] = hyperdb.Date()
1807 d['activity'] = hyperdb.Date()
1808 d['creator'] = hyperdb.Link('user')
1809 return d
1811 def addprop(self, **properties):
1812 '''Add properties to this class.
1814 The keyword arguments in 'properties' must map names to property
1815 objects, or a TypeError is raised. None of the keys in 'properties'
1816 may collide with the names of existing properties, or a ValueError
1817 is raised before any properties have been added.
1818 '''
1819 for key in properties.keys():
1820 if self.properties.has_key(key):
1821 raise ValueError, key
1822 self.properties.update(properties)
1824 def index(self, nodeid):
1825 '''Add (or refresh) the node to search indexes
1826 '''
1827 # find all the String properties that have indexme
1828 for prop, propclass in self.getprops().items():
1829 if isinstance(propclass, String) and propclass.indexme:
1830 try:
1831 value = str(self.get(nodeid, prop))
1832 except IndexError:
1833 # node no longer exists - entry should be removed
1834 self.db.indexer.purge_entry((self.classname, nodeid, prop))
1835 else:
1836 # and index them under (classname, nodeid, property)
1837 self.db.indexer.add_text((self.classname, nodeid, prop),
1838 value)
1840 #
1841 # Detector interface
1842 #
1843 def audit(self, event, detector):
1844 '''Register a detector
1845 '''
1846 l = self.auditors[event]
1847 if detector not in l:
1848 self.auditors[event].append(detector)
1850 def fireAuditors(self, action, nodeid, newvalues):
1851 '''Fire all registered auditors.
1852 '''
1853 for audit in self.auditors[action]:
1854 audit(self.db, self, nodeid, newvalues)
1856 def react(self, event, detector):
1857 '''Register a detector
1858 '''
1859 l = self.reactors[event]
1860 if detector not in l:
1861 self.reactors[event].append(detector)
1863 def fireReactors(self, action, nodeid, oldvalues):
1864 '''Fire all registered reactors.
1865 '''
1866 for react in self.reactors[action]:
1867 react(self.db, self, nodeid, oldvalues)
1869 class FileClass(Class):
1870 '''This class defines a large chunk of data. To support this, it has a
1871 mandatory String property "content" which is typically saved off
1872 externally to the hyperdb.
1874 The default MIME type of this data is defined by the
1875 "default_mime_type" class attribute, which may be overridden by each
1876 node if the class defines a "type" String property.
1877 '''
1878 default_mime_type = 'text/plain'
1880 def create(self, **propvalues):
1881 ''' snaffle the file propvalue and store in a file
1882 '''
1883 content = propvalues['content']
1884 del propvalues['content']
1885 newid = Class.create(self, **propvalues)
1886 self.db.storefile(self.classname, newid, None, content)
1887 return newid
1889 def import_list(self, propnames, proplist):
1890 ''' Trap the "content" property...
1891 '''
1892 # dupe this list so we don't affect others
1893 propnames = propnames[:]
1895 # extract the "content" property from the proplist
1896 i = propnames.index('content')
1897 content = eval(proplist[i])
1898 del propnames[i]
1899 del proplist[i]
1901 # do the normal import
1902 newid = Class.import_list(self, propnames, proplist)
1904 # save off the "content" file
1905 self.db.storefile(self.classname, newid, None, content)
1906 return newid
1908 def get(self, nodeid, propname, default=_marker, cache=1):
1909 ''' trap the content propname and get it from the file
1910 '''
1912 poss_msg = 'Possibly a access right configuration problem.'
1913 if propname == 'content':
1914 try:
1915 return self.db.getfile(self.classname, nodeid, None)
1916 except IOError, (strerror):
1917 # BUG: by catching this we donot see an error in the log.
1918 return 'ERROR reading file: %s%s\n%s\n%s'%(
1919 self.classname, nodeid, poss_msg, strerror)
1920 if default is not _marker:
1921 return Class.get(self, nodeid, propname, default, cache=cache)
1922 else:
1923 return Class.get(self, nodeid, propname, cache=cache)
1925 def getprops(self, protected=1):
1926 ''' In addition to the actual properties on the node, these methods
1927 provide the "content" property. If the "protected" flag is true,
1928 we include protected properties - those which may not be
1929 modified.
1930 '''
1931 d = Class.getprops(self, protected=protected).copy()
1932 d['content'] = hyperdb.String()
1933 return d
1935 def index(self, nodeid):
1936 ''' Index the node in the search index.
1938 We want to index the content in addition to the normal String
1939 property indexing.
1940 '''
1941 # perform normal indexing
1942 Class.index(self, nodeid)
1944 # get the content to index
1945 content = self.get(nodeid, 'content')
1947 # figure the mime type
1948 if self.properties.has_key('type'):
1949 mime_type = self.get(nodeid, 'type')
1950 else:
1951 mime_type = self.default_mime_type
1953 # and index!
1954 self.db.indexer.add_text((self.classname, nodeid, 'content'), content,
1955 mime_type)
1957 # deviation from spec - was called ItemClass
1958 class IssueClass(Class, roundupdb.IssueClass):
1959 # Overridden methods:
1960 def __init__(self, db, classname, **properties):
1961 '''The newly-created class automatically includes the "messages",
1962 "files", "nosy", and "superseder" properties. If the 'properties'
1963 dictionary attempts to specify any of these properties or a
1964 "creation" or "activity" property, a ValueError is raised.
1965 '''
1966 if not properties.has_key('title'):
1967 properties['title'] = hyperdb.String(indexme='yes')
1968 if not properties.has_key('messages'):
1969 properties['messages'] = hyperdb.Multilink("msg")
1970 if not properties.has_key('files'):
1971 properties['files'] = hyperdb.Multilink("file")
1972 if not properties.has_key('nosy'):
1973 # note: journalling is turned off as it really just wastes
1974 # space. this behaviour may be overridden in an instance
1975 properties['nosy'] = hyperdb.Multilink("user", do_journal="no")
1976 if not properties.has_key('superseder'):
1977 properties['superseder'] = hyperdb.Multilink(classname)
1978 Class.__init__(self, db, classname, **properties)
1980 #