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.106 2003-02-26 23:42:50 richard Exp $
19 '''
20 This module defines a backend that saves the hyperdatabase in a database
21 chosen by anydbm. It is guaranteed to always be available in python
22 versions >2.1.1 (the dumbdbm fallback in 2.1.1 and earlier has several
23 serious bugs, and is not available)
24 '''
26 import whichdb, anydbm, os, marshal, re, weakref, string, copy
27 from roundup import hyperdb, date, password, roundupdb, security
28 from blobfiles import FileStorage
29 from sessions import Sessions, OneTimeKeys
30 from roundup.indexer import Indexer
31 from roundup.backends import locking
32 from roundup.hyperdb import String, Password, Date, Interval, Link, \
33 Multilink, DatabaseError, Boolean, Number, Node
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.otks = OneTimeKeys(self.config)
72 self.security = security.Security(self)
73 # ensure files are group readable and writable
74 os.umask(0002)
76 # lock it
77 lockfilenm = os.path.join(self.dir, 'lock')
78 self.lockfile = locking.acquire_lock(lockfilenm)
79 self.lockfile.write(str(os.getpid()))
80 self.lockfile.flush()
82 def post_init(self):
83 ''' Called once the schema initialisation has finished.
84 '''
85 # reindex the db if necessary
86 if self.indexer.should_reindex():
87 self.reindex()
89 # figure the "curuserid"
90 if self.journaltag is None:
91 self.curuserid = None
92 elif self.journaltag == 'admin':
93 # admin user may not exist, but always has ID 1
94 self.curuserid = '1'
95 else:
96 self.curuserid = self.user.lookup(self.journaltag)
98 def reindex(self):
99 for klass in self.classes.values():
100 for nodeid in klass.list():
101 klass.index(nodeid)
102 self.indexer.save_index()
104 def __repr__(self):
105 return '<back_anydbm instance at %x>'%id(self)
107 #
108 # Classes
109 #
110 def __getattr__(self, classname):
111 '''A convenient way of calling self.getclass(classname).'''
112 if self.classes.has_key(classname):
113 if __debug__:
114 print >>hyperdb.DEBUG, '__getattr__', (self, classname)
115 return self.classes[classname]
116 raise AttributeError, classname
118 def addclass(self, cl):
119 if __debug__:
120 print >>hyperdb.DEBUG, 'addclass', (self, cl)
121 cn = cl.classname
122 if self.classes.has_key(cn):
123 raise ValueError, cn
124 self.classes[cn] = cl
126 def getclasses(self):
127 '''Return a list of the names of all existing classes.'''
128 if __debug__:
129 print >>hyperdb.DEBUG, 'getclasses', (self,)
130 l = self.classes.keys()
131 l.sort()
132 return l
134 def getclass(self, classname):
135 '''Get the Class object representing a particular class.
137 If 'classname' is not a valid class name, a KeyError is raised.
138 '''
139 if __debug__:
140 print >>hyperdb.DEBUG, 'getclass', (self, classname)
141 try:
142 return self.classes[classname]
143 except KeyError:
144 raise KeyError, 'There is no class called "%s"'%classname
146 #
147 # Class DBs
148 #
149 def clear(self):
150 '''Delete all database contents
151 '''
152 if __debug__:
153 print >>hyperdb.DEBUG, 'clear', (self,)
154 for cn in self.classes.keys():
155 for dummy in 'nodes', 'journals':
156 path = os.path.join(self.dir, 'journals.%s'%cn)
157 if os.path.exists(path):
158 os.remove(path)
159 elif os.path.exists(path+'.db'): # dbm appends .db
160 os.remove(path+'.db')
162 def getclassdb(self, classname, mode='r'):
163 ''' grab a connection to the class db that will be used for
164 multiple actions
165 '''
166 if __debug__:
167 print >>hyperdb.DEBUG, 'getclassdb', (self, classname, mode)
168 return self.opendb('nodes.%s'%classname, mode)
170 def determine_db_type(self, path):
171 ''' determine which DB wrote the class file
172 '''
173 db_type = ''
174 if os.path.exists(path):
175 db_type = whichdb.whichdb(path)
176 if not db_type:
177 raise DatabaseError, "Couldn't identify database type"
178 elif os.path.exists(path+'.db'):
179 # if the path ends in '.db', it's a dbm database, whether
180 # anydbm says it's dbhash or not!
181 db_type = 'dbm'
182 return db_type
184 def opendb(self, name, mode):
185 '''Low-level database opener that gets around anydbm/dbm
186 eccentricities.
187 '''
188 if __debug__:
189 print >>hyperdb.DEBUG, 'opendb', (self, name, mode)
191 # figure the class db type
192 path = os.path.join(os.getcwd(), self.dir, name)
193 db_type = self.determine_db_type(path)
195 # new database? let anydbm pick the best dbm
196 if not db_type:
197 if __debug__:
198 print >>hyperdb.DEBUG, "opendb anydbm.open(%r, 'c')"%path
199 return anydbm.open(path, 'c')
201 # open the database with the correct module
202 try:
203 dbm = __import__(db_type)
204 except ImportError:
205 raise DatabaseError, \
206 "Couldn't open database - the required module '%s'"\
207 " is not available"%db_type
208 if __debug__:
209 print >>hyperdb.DEBUG, "opendb %r.open(%r, %r)"%(db_type, path,
210 mode)
211 return dbm.open(path, mode)
213 #
214 # Node IDs
215 #
216 def newid(self, classname):
217 ''' Generate a new id for the given class
218 '''
219 # open the ids DB - create if if doesn't exist
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 return newid
230 def setid(self, classname, setid):
231 ''' Set the id counter: used during import of database
232 '''
233 # open the ids DB - create if if doesn't exist
234 db = self.opendb('_ids', 'c')
235 db[classname] = str(setid)
236 db.close()
238 #
239 # Nodes
240 #
241 def addnode(self, classname, nodeid, node):
242 ''' add the specified node to its class's db
243 '''
244 if __debug__:
245 print >>hyperdb.DEBUG, 'addnode', (self, classname, nodeid, node)
247 # we'll be supplied these props if we're doing an import
248 if not node.has_key('creator'):
249 # add in the "calculated" properties (dupe so we don't affect
250 # calling code's node assumptions)
251 node = node.copy()
252 node['creator'] = self.curuserid
253 node['creation'] = node['activity'] = date.Date()
255 self.newnodes.setdefault(classname, {})[nodeid] = 1
256 self.cache.setdefault(classname, {})[nodeid] = node
257 self.savenode(classname, nodeid, node)
259 def setnode(self, classname, nodeid, node):
260 ''' change the specified node
261 '''
262 if __debug__:
263 print >>hyperdb.DEBUG, 'setnode', (self, classname, nodeid, node)
264 self.dirtynodes.setdefault(classname, {})[nodeid] = 1
266 # update the activity time (dupe so we don't affect
267 # calling code's node assumptions)
268 node = node.copy()
269 node['activity'] = date.Date()
271 # can't set without having already loaded the node
272 self.cache[classname][nodeid] = node
273 self.savenode(classname, nodeid, node)
275 def savenode(self, classname, nodeid, node):
276 ''' perform the saving of data specified by the set/addnode
277 '''
278 if __debug__:
279 print >>hyperdb.DEBUG, 'savenode', (self, classname, nodeid, node)
280 self.transactions.append((self.doSaveNode, (classname, nodeid, node)))
282 def getnode(self, classname, nodeid, db=None, cache=1):
283 ''' get a node from the database
284 '''
285 if __debug__:
286 print >>hyperdb.DEBUG, 'getnode', (self, classname, nodeid, db)
287 if cache:
288 # try the cache
289 cache_dict = self.cache.setdefault(classname, {})
290 if cache_dict.has_key(nodeid):
291 if __debug__:
292 print >>hyperdb.TRACE, 'get %s %s cached'%(classname,
293 nodeid)
294 return cache_dict[nodeid]
296 if __debug__:
297 print >>hyperdb.TRACE, 'get %s %s'%(classname, nodeid)
299 # get from the database and save in the cache
300 if db is None:
301 db = self.getclassdb(classname)
302 if not db.has_key(nodeid):
303 # try the cache - might be a brand-new node
304 cache_dict = self.cache.setdefault(classname, {})
305 if cache_dict.has_key(nodeid):
306 if __debug__:
307 print >>hyperdb.TRACE, 'get %s %s cached'%(classname,
308 nodeid)
309 return cache_dict[nodeid]
310 raise IndexError, "no such %s %s"%(classname, nodeid)
312 # check the uncommitted, destroyed nodes
313 if (self.destroyednodes.has_key(classname) and
314 self.destroyednodes[classname].has_key(nodeid)):
315 raise IndexError, "no such %s %s"%(classname, nodeid)
317 # decode
318 res = marshal.loads(db[nodeid])
320 # reverse the serialisation
321 res = self.unserialise(classname, res)
323 # store off in the cache dict
324 if cache:
325 cache_dict[nodeid] = res
327 return res
329 def destroynode(self, classname, nodeid):
330 '''Remove a node from the database. Called exclusively by the
331 destroy() method on Class.
332 '''
333 if __debug__:
334 print >>hyperdb.DEBUG, 'destroynode', (self, classname, nodeid)
336 # remove from cache and newnodes if it's there
337 if (self.cache.has_key(classname) and
338 self.cache[classname].has_key(nodeid)):
339 del self.cache[classname][nodeid]
340 if (self.newnodes.has_key(classname) and
341 self.newnodes[classname].has_key(nodeid)):
342 del self.newnodes[classname][nodeid]
344 # see if there's any obvious commit actions that we should get rid of
345 for entry in self.transactions[:]:
346 if entry[1][:2] == (classname, nodeid):
347 self.transactions.remove(entry)
349 # add to the destroyednodes map
350 self.destroyednodes.setdefault(classname, {})[nodeid] = 1
352 # add the destroy commit action
353 self.transactions.append((self.doDestroyNode, (classname, nodeid)))
355 def serialise(self, classname, node):
356 '''Copy the node contents, converting non-marshallable data into
357 marshallable data.
358 '''
359 if __debug__:
360 print >>hyperdb.DEBUG, 'serialise', classname, node
361 properties = self.getclass(classname).getprops()
362 d = {}
363 for k, v in node.items():
364 # if the property doesn't exist, or is the "retired" flag then
365 # it won't be in the properties dict
366 if not properties.has_key(k):
367 d[k] = v
368 continue
370 # get the property spec
371 prop = properties[k]
373 if isinstance(prop, Password) and v is not None:
374 d[k] = str(v)
375 elif isinstance(prop, Date) and v is not None:
376 d[k] = v.serialise()
377 elif isinstance(prop, Interval) and v is not None:
378 d[k] = v.serialise()
379 else:
380 d[k] = v
381 return d
383 def unserialise(self, classname, node):
384 '''Decode the marshalled node data
385 '''
386 if __debug__:
387 print >>hyperdb.DEBUG, 'unserialise', classname, node
388 properties = self.getclass(classname).getprops()
389 d = {}
390 for k, v in node.items():
391 # if the property doesn't exist, or is the "retired" flag then
392 # it won't be in the properties dict
393 if not properties.has_key(k):
394 d[k] = v
395 continue
397 # get the property spec
398 prop = properties[k]
400 if isinstance(prop, Date) and v is not None:
401 d[k] = date.Date(v)
402 elif isinstance(prop, Interval) and v is not None:
403 d[k] = date.Interval(v)
404 elif isinstance(prop, Password) and v is not None:
405 p = password.Password()
406 p.unpack(v)
407 d[k] = p
408 else:
409 d[k] = v
410 return d
412 def hasnode(self, classname, nodeid, db=None):
413 ''' determine if the database has a given node
414 '''
415 if __debug__:
416 print >>hyperdb.DEBUG, 'hasnode', (self, classname, nodeid, db)
418 # try the cache
419 cache = self.cache.setdefault(classname, {})
420 if cache.has_key(nodeid):
421 if __debug__:
422 print >>hyperdb.TRACE, 'has %s %s cached'%(classname, nodeid)
423 return 1
424 if __debug__:
425 print >>hyperdb.TRACE, 'has %s %s'%(classname, nodeid)
427 # not in the cache - check the database
428 if db is None:
429 db = self.getclassdb(classname)
430 res = db.has_key(nodeid)
431 return res
433 def countnodes(self, classname, db=None):
434 if __debug__:
435 print >>hyperdb.DEBUG, 'countnodes', (self, classname, db)
437 count = 0
439 # include the uncommitted nodes
440 if self.newnodes.has_key(classname):
441 count += len(self.newnodes[classname])
442 if self.destroyednodes.has_key(classname):
443 count -= len(self.destroyednodes[classname])
445 # and count those in the DB
446 if db is None:
447 db = self.getclassdb(classname)
448 count = count + len(db.keys())
449 return count
451 def getnodeids(self, classname, db=None):
452 if __debug__:
453 print >>hyperdb.DEBUG, 'getnodeids', (self, classname, db)
455 res = []
457 # start off with the new nodes
458 if self.newnodes.has_key(classname):
459 res += self.newnodes[classname].keys()
461 if db is None:
462 db = self.getclassdb(classname)
463 res = res + db.keys()
465 # remove the uncommitted, destroyed nodes
466 if self.destroyednodes.has_key(classname):
467 for nodeid in self.destroyednodes[classname].keys():
468 if db.has_key(nodeid):
469 res.remove(nodeid)
471 return res
474 #
475 # Files - special node properties
476 # inherited from FileStorage
478 #
479 # Journal
480 #
481 def addjournal(self, classname, nodeid, action, params, creator=None,
482 creation=None):
483 ''' Journal the Action
484 'action' may be:
486 'create' or 'set' -- 'params' is a dictionary of property values
487 'link' or 'unlink' -- 'params' is (classname, nodeid, propname)
488 'retire' -- 'params' is None
489 '''
490 if __debug__:
491 print >>hyperdb.DEBUG, 'addjournal', (self, classname, nodeid,
492 action, params, creator, creation)
493 self.transactions.append((self.doSaveJournal, (classname, nodeid,
494 action, params, creator, creation)))
496 def getjournal(self, classname, nodeid):
497 ''' get the journal for id
499 Raise IndexError if the node doesn't exist (as per history()'s
500 API)
501 '''
502 if __debug__:
503 print >>hyperdb.DEBUG, 'getjournal', (self, classname, nodeid)
504 # attempt to open the journal - in some rare cases, the journal may
505 # not exist
506 try:
507 db = self.opendb('journals.%s'%classname, 'r')
508 except anydbm.error, error:
509 if str(error) == "need 'c' or 'n' flag to open new db":
510 raise IndexError, 'no such %s %s'%(classname, nodeid)
511 elif error.args[0] != 2:
512 raise
513 raise IndexError, 'no such %s %s'%(classname, nodeid)
514 try:
515 journal = marshal.loads(db[nodeid])
516 except KeyError:
517 db.close()
518 raise IndexError, 'no such %s %s'%(classname, nodeid)
519 db.close()
520 res = []
521 for nodeid, date_stamp, user, action, params in journal:
522 res.append((nodeid, date.Date(date_stamp), user, action, params))
523 return res
525 def pack(self, pack_before):
526 ''' Delete all journal entries except "create" before 'pack_before'.
527 '''
528 if __debug__:
529 print >>hyperdb.DEBUG, 'packjournal', (self, pack_before)
531 pack_before = pack_before.serialise()
532 for classname in self.getclasses():
533 # get the journal db
534 db_name = 'journals.%s'%classname
535 path = os.path.join(os.getcwd(), self.dir, classname)
536 db_type = self.determine_db_type(path)
537 db = self.opendb(db_name, 'w')
539 for key in db.keys():
540 # get the journal for this db entry
541 journal = marshal.loads(db[key])
542 l = []
543 last_set_entry = None
544 for entry in journal:
545 # unpack the entry
546 (nodeid, date_stamp, self.journaltag, action,
547 params) = entry
548 # if the entry is after the pack date, _or_ the initial
549 # create entry, then it stays
550 if date_stamp > pack_before or action == 'create':
551 l.append(entry)
552 db[key] = marshal.dumps(l)
553 if db_type == 'gdbm':
554 db.reorganize()
555 db.close()
558 #
559 # Basic transaction support
560 #
561 def commit(self):
562 ''' Commit the current transactions.
563 '''
564 if __debug__:
565 print >>hyperdb.DEBUG, 'commit', (self,)
567 # keep a handle to all the database files opened
568 self.databases = {}
570 # now, do all the transactions
571 reindex = {}
572 for method, args in self.transactions:
573 reindex[method(*args)] = 1
575 # now close all the database files
576 for db in self.databases.values():
577 db.close()
578 del self.databases
580 # reindex the nodes that request it
581 for classname, nodeid in filter(None, reindex.keys()):
582 print >>hyperdb.DEBUG, 'commit.reindex', (classname, nodeid)
583 self.getclass(classname).index(nodeid)
585 # save the indexer state
586 self.indexer.save_index()
588 self.clearCache()
590 def clearCache(self):
591 # all transactions committed, back to normal
592 self.cache = {}
593 self.dirtynodes = {}
594 self.newnodes = {}
595 self.destroyednodes = {}
596 self.transactions = []
598 def getCachedClassDB(self, classname):
599 ''' get the class db, looking in our cache of databases for commit
600 '''
601 # get the database handle
602 db_name = 'nodes.%s'%classname
603 if not self.databases.has_key(db_name):
604 self.databases[db_name] = self.getclassdb(classname, 'c')
605 return self.databases[db_name]
607 def doSaveNode(self, classname, nodeid, node):
608 if __debug__:
609 print >>hyperdb.DEBUG, 'doSaveNode', (self, classname, nodeid,
610 node)
612 db = self.getCachedClassDB(classname)
614 # now save the marshalled data
615 db[nodeid] = marshal.dumps(self.serialise(classname, node))
617 # return the classname, nodeid so we reindex this content
618 return (classname, nodeid)
620 def getCachedJournalDB(self, classname):
621 ''' get the journal db, looking in our cache of databases for commit
622 '''
623 # get the database handle
624 db_name = 'journals.%s'%classname
625 if not self.databases.has_key(db_name):
626 self.databases[db_name] = self.opendb(db_name, 'c')
627 return self.databases[db_name]
629 def doSaveJournal(self, classname, nodeid, action, params, creator,
630 creation):
631 # serialise the parameters now if necessary
632 if isinstance(params, type({})):
633 if action in ('set', 'create'):
634 params = self.serialise(classname, params)
636 # handle supply of the special journalling parameters (usually
637 # supplied on importing an existing database)
638 if creator:
639 journaltag = creator
640 else:
641 journaltag = self.curuserid
642 if creation:
643 journaldate = creation.serialise()
644 else:
645 journaldate = date.Date().serialise()
647 # create the journal entry
648 entry = (nodeid, journaldate, journaltag, action, params)
650 if __debug__:
651 print >>hyperdb.DEBUG, 'doSaveJournal', entry
653 db = self.getCachedJournalDB(classname)
655 # now insert the journal entry
656 if db.has_key(nodeid):
657 # append to existing
658 s = db[nodeid]
659 l = marshal.loads(s)
660 l.append(entry)
661 else:
662 l = [entry]
664 db[nodeid] = marshal.dumps(l)
666 def doDestroyNode(self, classname, nodeid):
667 if __debug__:
668 print >>hyperdb.DEBUG, 'doDestroyNode', (self, classname, nodeid)
670 # delete from the class database
671 db = self.getCachedClassDB(classname)
672 if db.has_key(nodeid):
673 del db[nodeid]
675 # delete from the database
676 db = self.getCachedJournalDB(classname)
677 if db.has_key(nodeid):
678 del db[nodeid]
680 # return the classname, nodeid so we reindex this content
681 return (classname, nodeid)
683 def rollback(self):
684 ''' Reverse all actions from the current transaction.
685 '''
686 if __debug__:
687 print >>hyperdb.DEBUG, 'rollback', (self, )
688 for method, args in self.transactions:
689 # delete temporary files
690 if method == self.doStoreFile:
691 self.rollbackStoreFile(*args)
692 self.cache = {}
693 self.dirtynodes = {}
694 self.newnodes = {}
695 self.destroyednodes = {}
696 self.transactions = []
698 def close(self):
699 ''' Nothing to do
700 '''
701 if self.lockfile is not None:
702 locking.release_lock(self.lockfile)
703 if self.lockfile is not None:
704 self.lockfile.close()
705 self.lockfile = None
707 _marker = []
708 class Class(hyperdb.Class):
709 '''The handle to a particular class of nodes in a hyperdatabase.'''
711 def __init__(self, db, classname, **properties):
712 '''Create a new class with a given name and property specification.
714 'classname' must not collide with the name of an existing class,
715 or a ValueError is raised. The keyword arguments in 'properties'
716 must map names to property objects, or a TypeError is raised.
717 '''
718 if (properties.has_key('creation') or properties.has_key('activity')
719 or properties.has_key('creator')):
720 raise ValueError, '"creation", "activity" and "creator" are '\
721 'reserved'
723 self.classname = classname
724 self.properties = properties
725 self.db = weakref.proxy(db) # use a weak ref to avoid circularity
726 self.key = ''
728 # should we journal changes (default yes)
729 self.do_journal = 1
731 # do the db-related init stuff
732 db.addclass(self)
734 self.auditors = {'create': [], 'set': [], 'retire': []}
735 self.reactors = {'create': [], 'set': [], 'retire': []}
737 def enableJournalling(self):
738 '''Turn journalling on for this class
739 '''
740 self.do_journal = 1
742 def disableJournalling(self):
743 '''Turn journalling off for this class
744 '''
745 self.do_journal = 0
747 # Editing nodes:
749 def create(self, **propvalues):
750 '''Create a new node of this class and return its id.
752 The keyword arguments in 'propvalues' map property names to values.
754 The values of arguments must be acceptable for the types of their
755 corresponding properties or a TypeError is raised.
757 If this class has a key property, it must be present and its value
758 must not collide with other key strings or a ValueError is raised.
760 Any other properties on this class that are missing from the
761 'propvalues' dictionary are set to None.
763 If an id in a link or multilink property does not refer to a valid
764 node, an IndexError is raised.
766 These operations trigger detectors and can be vetoed. Attempts
767 to modify the "creation" or "activity" properties cause a KeyError.
768 '''
769 self.fireAuditors('create', None, propvalues)
770 newid = self.create_inner(**propvalues)
771 self.fireReactors('create', newid, None)
772 return newid
774 def create_inner(self, **propvalues):
775 ''' Called by create, in-between the audit and react calls.
776 '''
777 if propvalues.has_key('id'):
778 raise KeyError, '"id" is reserved'
780 if self.db.journaltag is None:
781 raise DatabaseError, 'Database open read-only'
783 if propvalues.has_key('creation') or propvalues.has_key('activity'):
784 raise KeyError, '"creation" and "activity" are reserved'
785 # new node's id
786 newid = self.db.newid(self.classname)
788 # validate propvalues
789 num_re = re.compile('^\d+$')
790 for key, value in propvalues.items():
791 if key == self.key:
792 try:
793 self.lookup(value)
794 except KeyError:
795 pass
796 else:
797 raise ValueError, 'node with key "%s" exists'%value
799 # try to handle this property
800 try:
801 prop = self.properties[key]
802 except KeyError:
803 raise KeyError, '"%s" has no property "%s"'%(self.classname,
804 key)
806 if value is not None and isinstance(prop, Link):
807 if type(value) != type(''):
808 raise ValueError, 'link value must be String'
809 link_class = self.properties[key].classname
810 # if it isn't a number, it's a key
811 if not num_re.match(value):
812 try:
813 value = self.db.classes[link_class].lookup(value)
814 except (TypeError, KeyError):
815 raise IndexError, 'new property "%s": %s not a %s'%(
816 key, value, link_class)
817 elif not self.db.getclass(link_class).hasnode(value):
818 raise IndexError, '%s has no node %s'%(link_class, value)
820 # save off the value
821 propvalues[key] = value
823 # register the link with the newly linked node
824 if self.do_journal and self.properties[key].do_journal:
825 self.db.addjournal(link_class, value, 'link',
826 (self.classname, newid, key))
828 elif isinstance(prop, Multilink):
829 if type(value) != type([]):
830 raise TypeError, 'new property "%s" not a list of ids'%key
832 # clean up and validate the list of links
833 link_class = self.properties[key].classname
834 l = []
835 for entry in value:
836 if type(entry) != type(''):
837 raise ValueError, '"%s" multilink value (%r) '\
838 'must contain Strings'%(key, value)
839 # if it isn't a number, it's a key
840 if not num_re.match(entry):
841 try:
842 entry = self.db.classes[link_class].lookup(entry)
843 except (TypeError, KeyError):
844 raise IndexError, 'new property "%s": %s not a %s'%(
845 key, entry, self.properties[key].classname)
846 l.append(entry)
847 value = l
848 propvalues[key] = value
850 # handle additions
851 for nodeid in value:
852 if not self.db.getclass(link_class).hasnode(nodeid):
853 raise IndexError, '%s has no node %s'%(link_class,
854 nodeid)
855 # register the link with the newly linked node
856 if self.do_journal and self.properties[key].do_journal:
857 self.db.addjournal(link_class, nodeid, 'link',
858 (self.classname, newid, key))
860 elif isinstance(prop, String):
861 if type(value) != type('') and type(value) != type(u''):
862 raise TypeError, 'new property "%s" not a string'%key
864 elif isinstance(prop, Password):
865 if not isinstance(value, password.Password):
866 raise TypeError, 'new property "%s" not a Password'%key
868 elif isinstance(prop, Date):
869 if value is not None and not isinstance(value, date.Date):
870 raise TypeError, 'new property "%s" not a Date'%key
872 elif isinstance(prop, Interval):
873 if value is not None and not isinstance(value, date.Interval):
874 raise TypeError, 'new property "%s" not an Interval'%key
876 elif value is not None and isinstance(prop, Number):
877 try:
878 float(value)
879 except ValueError:
880 raise TypeError, 'new property "%s" not numeric'%key
882 elif value is not None and isinstance(prop, Boolean):
883 try:
884 int(value)
885 except ValueError:
886 raise TypeError, 'new property "%s" not boolean'%key
888 # make sure there's data where there needs to be
889 for key, prop in self.properties.items():
890 if propvalues.has_key(key):
891 continue
892 if key == self.key:
893 raise ValueError, 'key property "%s" is required'%key
894 if isinstance(prop, Multilink):
895 propvalues[key] = []
896 else:
897 propvalues[key] = None
899 # done
900 self.db.addnode(self.classname, newid, propvalues)
901 if self.do_journal:
902 self.db.addjournal(self.classname, newid, 'create', {})
904 return newid
906 def export_list(self, propnames, nodeid):
907 ''' Export a node - generate a list of CSV-able data in the order
908 specified by propnames for the given node.
909 '''
910 properties = self.getprops()
911 l = []
912 for prop in propnames:
913 proptype = properties[prop]
914 value = self.get(nodeid, prop)
915 # "marshal" data where needed
916 if value is None:
917 pass
918 elif isinstance(proptype, hyperdb.Date):
919 value = value.get_tuple()
920 elif isinstance(proptype, hyperdb.Interval):
921 value = value.get_tuple()
922 elif isinstance(proptype, hyperdb.Password):
923 value = str(value)
924 l.append(repr(value))
926 # append retired flag
927 l.append(self.is_retired(nodeid))
929 return l
931 def import_list(self, propnames, proplist):
932 ''' Import a node - all information including "id" is present and
933 should not be sanity checked. Triggers are not triggered. The
934 journal should be initialised using the "creator" and "created"
935 information.
937 Return the nodeid of the node imported.
938 '''
939 if self.db.journaltag is None:
940 raise DatabaseError, 'Database open read-only'
941 properties = self.getprops()
943 # make the new node's property map
944 d = {}
945 for i in range(len(propnames)):
946 # Use eval to reverse the repr() used to output the CSV
947 value = eval(proplist[i])
949 # Figure the property for this column
950 propname = propnames[i]
951 prop = properties[propname]
953 # "unmarshal" where necessary
954 if propname == 'id':
955 newid = value
956 continue
957 elif value is None:
958 # don't set Nones
959 continue
960 elif isinstance(prop, hyperdb.Date):
961 value = date.Date(value)
962 elif isinstance(prop, hyperdb.Interval):
963 value = date.Interval(value)
964 elif isinstance(prop, hyperdb.Password):
965 pwd = password.Password()
966 pwd.unpack(value)
967 value = pwd
968 d[propname] = value
970 # check retired flag
971 if int(proplist[-1]):
972 d[self.db.RETIRED_FLAG] = 1
974 # add the node and journal
975 self.db.addnode(self.classname, newid, d)
977 # extract the journalling stuff and nuke it
978 if d.has_key('creator'):
979 creator = d['creator']
980 del d['creator']
981 else:
982 creator = None
983 if d.has_key('creation'):
984 creation = d['creation']
985 del d['creation']
986 else:
987 creation = None
988 if d.has_key('activity'):
989 del d['activity']
990 self.db.addjournal(self.classname, newid, 'create', {}, creator,
991 creation)
992 return newid
994 def get(self, nodeid, propname, default=_marker, cache=1):
995 '''Get the value of a property on an existing node of this class.
997 'nodeid' must be the id of an existing node of this class or an
998 IndexError is raised. 'propname' must be the name of a property
999 of this class or a KeyError is raised.
1001 'cache' indicates whether the transaction cache should be queried
1002 for the node. If the node has been modified and you need to
1003 determine what its values prior to modification are, you need to
1004 set cache=0.
1006 Attempts to get the "creation" or "activity" properties should
1007 do the right thing.
1008 '''
1009 if propname == 'id':
1010 return nodeid
1012 # get the node's dict
1013 d = self.db.getnode(self.classname, nodeid, cache=cache)
1015 # check for one of the special props
1016 if propname == 'creation':
1017 if d.has_key('creation'):
1018 return d['creation']
1019 if not self.do_journal:
1020 raise ValueError, 'Journalling is disabled for this class'
1021 journal = self.db.getjournal(self.classname, nodeid)
1022 if journal:
1023 return self.db.getjournal(self.classname, nodeid)[0][1]
1024 else:
1025 # on the strange chance that there's no journal
1026 return date.Date()
1027 if propname == 'activity':
1028 if d.has_key('activity'):
1029 return d['activity']
1030 if not self.do_journal:
1031 raise ValueError, 'Journalling is disabled for this class'
1032 journal = self.db.getjournal(self.classname, nodeid)
1033 if journal:
1034 return self.db.getjournal(self.classname, nodeid)[-1][1]
1035 else:
1036 # on the strange chance that there's no journal
1037 return date.Date()
1038 if propname == 'creator':
1039 if d.has_key('creator'):
1040 return d['creator']
1041 if not self.do_journal:
1042 raise ValueError, 'Journalling is disabled for this class'
1043 journal = self.db.getjournal(self.classname, nodeid)
1044 if journal:
1045 num_re = re.compile('^\d+$')
1046 value = self.db.getjournal(self.classname, nodeid)[0][2]
1047 if num_re.match(value):
1048 return value
1049 else:
1050 # old-style "username" journal tag
1051 try:
1052 return self.db.user.lookup(value)
1053 except KeyError:
1054 # user's been retired, return admin
1055 return '1'
1056 else:
1057 return self.db.curuserid
1059 # get the property (raises KeyErorr if invalid)
1060 prop = self.properties[propname]
1062 if not d.has_key(propname):
1063 if default is _marker:
1064 if isinstance(prop, Multilink):
1065 return []
1066 else:
1067 return None
1068 else:
1069 return default
1071 # return a dupe of the list so code doesn't get confused
1072 if isinstance(prop, Multilink):
1073 return d[propname][:]
1075 return d[propname]
1077 # not in spec
1078 def getnode(self, nodeid, cache=1):
1079 ''' Return a convenience wrapper for the node.
1081 'nodeid' must be the id of an existing node of this class or an
1082 IndexError is raised.
1084 'cache' indicates whether the transaction cache should be queried
1085 for the node. If the node has been modified and you need to
1086 determine what its values prior to modification are, you need to
1087 set cache=0.
1088 '''
1089 return Node(self, nodeid, cache=cache)
1091 def set(self, nodeid, **propvalues):
1092 '''Modify a property on an existing node of this class.
1094 'nodeid' must be the id of an existing node of this class or an
1095 IndexError is raised.
1097 Each key in 'propvalues' must be the name of a property of this
1098 class or a KeyError is raised.
1100 All values in 'propvalues' must be acceptable types for their
1101 corresponding properties or a TypeError is raised.
1103 If the value of the key property is set, it must not collide with
1104 other key strings or a ValueError is raised.
1106 If the value of a Link or Multilink property contains an invalid
1107 node id, a ValueError is raised.
1109 These operations trigger detectors and can be vetoed. Attempts
1110 to modify the "creation" or "activity" properties cause a KeyError.
1111 '''
1112 if not propvalues:
1113 return propvalues
1115 if propvalues.has_key('creation') or propvalues.has_key('activity'):
1116 raise KeyError, '"creation" and "activity" are reserved'
1118 if propvalues.has_key('id'):
1119 raise KeyError, '"id" is reserved'
1121 if self.db.journaltag is None:
1122 raise DatabaseError, 'Database open read-only'
1124 self.fireAuditors('set', nodeid, propvalues)
1125 # Take a copy of the node dict so that the subsequent set
1126 # operation doesn't modify the oldvalues structure.
1127 try:
1128 # try not using the cache initially
1129 oldvalues = copy.deepcopy(self.db.getnode(self.classname, nodeid,
1130 cache=0))
1131 except IndexError:
1132 # this will be needed if somone does a create() and set()
1133 # with no intervening commit()
1134 oldvalues = copy.deepcopy(self.db.getnode(self.classname, nodeid))
1136 node = self.db.getnode(self.classname, nodeid)
1137 if node.has_key(self.db.RETIRED_FLAG):
1138 raise IndexError
1139 num_re = re.compile('^\d+$')
1141 # if the journal value is to be different, store it in here
1142 journalvalues = {}
1144 for propname, value in propvalues.items():
1145 # check to make sure we're not duplicating an existing key
1146 if propname == self.key and node[propname] != value:
1147 try:
1148 self.lookup(value)
1149 except KeyError:
1150 pass
1151 else:
1152 raise ValueError, 'node with key "%s" exists'%value
1154 # this will raise the KeyError if the property isn't valid
1155 # ... we don't use getprops() here because we only care about
1156 # the writeable properties.
1157 try:
1158 prop = self.properties[propname]
1159 except KeyError:
1160 raise KeyError, '"%s" has no property named "%s"'%(
1161 self.classname, propname)
1163 # if the value's the same as the existing value, no sense in
1164 # doing anything
1165 current = node.get(propname, None)
1166 if value == current:
1167 del propvalues[propname]
1168 continue
1169 journalvalues[propname] = current
1171 # do stuff based on the prop type
1172 if isinstance(prop, Link):
1173 link_class = prop.classname
1174 # if it isn't a number, it's a key
1175 if value is not None and not isinstance(value, type('')):
1176 raise ValueError, 'property "%s" link value be a string'%(
1177 propname)
1178 if isinstance(value, type('')) and not num_re.match(value):
1179 try:
1180 value = self.db.classes[link_class].lookup(value)
1181 except (TypeError, KeyError):
1182 raise IndexError, 'new property "%s": %s not a %s'%(
1183 propname, value, prop.classname)
1185 if (value is not None and
1186 not self.db.getclass(link_class).hasnode(value)):
1187 raise IndexError, '%s has no node %s'%(link_class, value)
1189 if self.do_journal and prop.do_journal:
1190 # register the unlink with the old linked node
1191 if node.has_key(propname) and node[propname] is not None:
1192 self.db.addjournal(link_class, node[propname], 'unlink',
1193 (self.classname, nodeid, propname))
1195 # register the link with the newly linked node
1196 if value is not None:
1197 self.db.addjournal(link_class, value, 'link',
1198 (self.classname, nodeid, propname))
1200 elif isinstance(prop, Multilink):
1201 if type(value) != type([]):
1202 raise TypeError, 'new property "%s" not a list of'\
1203 ' ids'%propname
1204 link_class = self.properties[propname].classname
1205 l = []
1206 for entry in value:
1207 # if it isn't a number, it's a key
1208 if type(entry) != type(''):
1209 raise ValueError, 'new property "%s" link value ' \
1210 'must be a string'%propname
1211 if not num_re.match(entry):
1212 try:
1213 entry = self.db.classes[link_class].lookup(entry)
1214 except (TypeError, KeyError):
1215 raise IndexError, 'new property "%s": %s not a %s'%(
1216 propname, entry,
1217 self.properties[propname].classname)
1218 l.append(entry)
1219 value = l
1220 propvalues[propname] = value
1222 # figure the journal entry for this property
1223 add = []
1224 remove = []
1226 # handle removals
1227 if node.has_key(propname):
1228 l = node[propname]
1229 else:
1230 l = []
1231 for id in l[:]:
1232 if id in value:
1233 continue
1234 # register the unlink with the old linked node
1235 if self.do_journal and self.properties[propname].do_journal:
1236 self.db.addjournal(link_class, id, 'unlink',
1237 (self.classname, nodeid, propname))
1238 l.remove(id)
1239 remove.append(id)
1241 # handle additions
1242 for id in value:
1243 if not self.db.getclass(link_class).hasnode(id):
1244 raise IndexError, '%s has no node %s'%(link_class, id)
1245 if id in l:
1246 continue
1247 # register the link with the newly linked node
1248 if self.do_journal and self.properties[propname].do_journal:
1249 self.db.addjournal(link_class, id, 'link',
1250 (self.classname, nodeid, propname))
1251 l.append(id)
1252 add.append(id)
1254 # figure the journal entry
1255 l = []
1256 if add:
1257 l.append(('+', add))
1258 if remove:
1259 l.append(('-', remove))
1260 if l:
1261 journalvalues[propname] = tuple(l)
1263 elif isinstance(prop, String):
1264 if value is not None and type(value) != type('') and type(value) != type(u''):
1265 raise TypeError, 'new property "%s" not a string'%propname
1267 elif isinstance(prop, Password):
1268 if not isinstance(value, password.Password):
1269 raise TypeError, 'new property "%s" not a Password'%propname
1270 propvalues[propname] = value
1272 elif value is not None and isinstance(prop, Date):
1273 if not isinstance(value, date.Date):
1274 raise TypeError, 'new property "%s" not a Date'% propname
1275 propvalues[propname] = value
1277 elif value is not None and isinstance(prop, Interval):
1278 if not isinstance(value, date.Interval):
1279 raise TypeError, 'new property "%s" not an '\
1280 'Interval'%propname
1281 propvalues[propname] = value
1283 elif value is not None and isinstance(prop, Number):
1284 try:
1285 float(value)
1286 except ValueError:
1287 raise TypeError, 'new property "%s" not numeric'%propname
1289 elif value is not None and isinstance(prop, Boolean):
1290 try:
1291 int(value)
1292 except ValueError:
1293 raise TypeError, 'new property "%s" not boolean'%propname
1295 node[propname] = value
1297 # nothing to do?
1298 if not propvalues:
1299 return propvalues
1301 # do the set, and journal it
1302 self.db.setnode(self.classname, nodeid, node)
1304 if self.do_journal:
1305 self.db.addjournal(self.classname, nodeid, 'set', journalvalues)
1307 self.fireReactors('set', nodeid, oldvalues)
1309 return propvalues
1311 def retire(self, nodeid):
1312 '''Retire a node.
1314 The properties on the node remain available from the get() method,
1315 and the node's id is never reused.
1317 Retired nodes are not returned by the find(), list(), or lookup()
1318 methods, and other nodes may reuse the values of their key properties.
1320 These operations trigger detectors and can be vetoed. Attempts
1321 to modify the "creation" or "activity" properties cause a KeyError.
1322 '''
1323 if self.db.journaltag is None:
1324 raise DatabaseError, 'Database open read-only'
1326 self.fireAuditors('retire', nodeid, None)
1328 node = self.db.getnode(self.classname, nodeid)
1329 node[self.db.RETIRED_FLAG] = 1
1330 self.db.setnode(self.classname, nodeid, node)
1331 if self.do_journal:
1332 self.db.addjournal(self.classname, nodeid, 'retired', None)
1334 self.fireReactors('retire', nodeid, None)
1336 def is_retired(self, nodeid, cldb=None):
1337 '''Return true if the node is retired.
1338 '''
1339 node = self.db.getnode(self.classname, nodeid, cldb)
1340 if node.has_key(self.db.RETIRED_FLAG):
1341 return 1
1342 return 0
1344 def destroy(self, nodeid):
1345 '''Destroy a node.
1347 WARNING: this method should never be used except in extremely rare
1348 situations where there could never be links to the node being
1349 deleted
1350 WARNING: use retire() instead
1351 WARNING: the properties of this node will not be available ever again
1352 WARNING: really, use retire() instead
1354 Well, I think that's enough warnings. This method exists mostly to
1355 support the session storage of the cgi interface.
1356 '''
1357 if self.db.journaltag is None:
1358 raise DatabaseError, 'Database open read-only'
1359 self.db.destroynode(self.classname, nodeid)
1361 def history(self, nodeid):
1362 '''Retrieve the journal of edits on a particular node.
1364 'nodeid' must be the id of an existing node of this class or an
1365 IndexError is raised.
1367 The returned list contains tuples of the form
1369 (nodeid, date, tag, action, params)
1371 'date' is a Timestamp object specifying the time of the change and
1372 'tag' is the journaltag specified when the database was opened.
1373 '''
1374 if not self.do_journal:
1375 raise ValueError, 'Journalling is disabled for this class'
1376 return self.db.getjournal(self.classname, nodeid)
1378 # Locating nodes:
1379 def hasnode(self, nodeid):
1380 '''Determine if the given nodeid actually exists
1381 '''
1382 return self.db.hasnode(self.classname, nodeid)
1384 def setkey(self, propname):
1385 '''Select a String property of this class to be the key property.
1387 'propname' must be the name of a String property of this class or
1388 None, or a TypeError is raised. The values of the key property on
1389 all existing nodes must be unique or a ValueError is raised. If the
1390 property doesn't exist, KeyError is raised.
1391 '''
1392 prop = self.getprops()[propname]
1393 if not isinstance(prop, String):
1394 raise TypeError, 'key properties must be String'
1395 self.key = propname
1397 def getkey(self):
1398 '''Return the name of the key property for this class or None.'''
1399 return self.key
1401 def labelprop(self, default_to_id=0):
1402 ''' Return the property name for a label for the given node.
1404 This method attempts to generate a consistent label for the node.
1405 It tries the following in order:
1406 1. key property
1407 2. "name" property
1408 3. "title" property
1409 4. first property from the sorted property name list
1410 '''
1411 k = self.getkey()
1412 if k:
1413 return k
1414 props = self.getprops()
1415 if props.has_key('name'):
1416 return 'name'
1417 elif props.has_key('title'):
1418 return 'title'
1419 if default_to_id:
1420 return 'id'
1421 props = props.keys()
1422 props.sort()
1423 return props[0]
1425 # TODO: set up a separate index db file for this? profile?
1426 def lookup(self, keyvalue):
1427 '''Locate a particular node by its key property and return its id.
1429 If this class has no key property, a TypeError is raised. If the
1430 'keyvalue' matches one of the values for the key property among
1431 the nodes in this class, the matching node's id is returned;
1432 otherwise a KeyError is raised.
1433 '''
1434 if not self.key:
1435 raise TypeError, 'No key property set for class %s'%self.classname
1436 cldb = self.db.getclassdb(self.classname)
1437 try:
1438 for nodeid in self.db.getnodeids(self.classname, cldb):
1439 node = self.db.getnode(self.classname, nodeid, cldb)
1440 if node.has_key(self.db.RETIRED_FLAG):
1441 continue
1442 if node[self.key] == keyvalue:
1443 return nodeid
1444 finally:
1445 cldb.close()
1446 raise KeyError, 'No key (%s) value "%s" for "%s"'%(self.key,
1447 keyvalue, self.classname)
1449 # change from spec - allows multiple props to match
1450 def find(self, **propspec):
1451 '''Get the ids of nodes in this class which link to the given nodes.
1453 'propspec' consists of keyword args propname=nodeid or
1454 propname={nodeid:1, }
1455 'propname' must be the name of a property in this class, or a
1456 KeyError is raised. That property must be a Link or
1457 Multilink property, or a TypeError is raised.
1459 Any node in this class whose 'propname' property links to any of the
1460 nodeids will be returned. Used by the full text indexing, which knows
1461 that "foo" occurs in msg1, msg3 and file7, so we have hits on these
1462 issues:
1464 db.issue.find(messages={'1':1,'3':1}, files={'7':1})
1465 '''
1466 propspec = propspec.items()
1467 for propname, nodeids in propspec:
1468 # check the prop is OK
1469 prop = self.properties[propname]
1470 if not isinstance(prop, Link) and not isinstance(prop, Multilink):
1471 raise TypeError, "'%s' not a Link/Multilink property"%propname
1473 # ok, now do the find
1474 cldb = self.db.getclassdb(self.classname)
1475 l = []
1476 try:
1477 for id in self.db.getnodeids(self.classname, db=cldb):
1478 node = self.db.getnode(self.classname, id, db=cldb)
1479 if node.has_key(self.db.RETIRED_FLAG):
1480 continue
1481 for propname, nodeids in propspec:
1482 # can't test if the node doesn't have this property
1483 if not node.has_key(propname):
1484 continue
1485 if type(nodeids) is type(''):
1486 nodeids = {nodeids:1}
1487 prop = self.properties[propname]
1488 value = node[propname]
1489 if isinstance(prop, Link) and nodeids.has_key(value):
1490 l.append(id)
1491 break
1492 elif isinstance(prop, Multilink):
1493 hit = 0
1494 for v in value:
1495 if nodeids.has_key(v):
1496 l.append(id)
1497 hit = 1
1498 break
1499 if hit:
1500 break
1501 finally:
1502 cldb.close()
1503 return l
1505 def stringFind(self, **requirements):
1506 '''Locate a particular node by matching a set of its String
1507 properties in a caseless search.
1509 If the property is not a String property, a TypeError is raised.
1511 The return is a list of the id of all nodes that match.
1512 '''
1513 for propname in requirements.keys():
1514 prop = self.properties[propname]
1515 if isinstance(not prop, String):
1516 raise TypeError, "'%s' not a String property"%propname
1517 requirements[propname] = requirements[propname].lower()
1518 l = []
1519 cldb = self.db.getclassdb(self.classname)
1520 try:
1521 for nodeid in self.db.getnodeids(self.classname, cldb):
1522 node = self.db.getnode(self.classname, nodeid, cldb)
1523 if node.has_key(self.db.RETIRED_FLAG):
1524 continue
1525 for key, value in requirements.items():
1526 if not node.has_key(key):
1527 break
1528 if node[key] is None or node[key].lower() != value:
1529 break
1530 else:
1531 l.append(nodeid)
1532 finally:
1533 cldb.close()
1534 return l
1536 def list(self):
1537 ''' Return a list of the ids of the active nodes in this class.
1538 '''
1539 l = []
1540 cn = self.classname
1541 cldb = self.db.getclassdb(cn)
1542 try:
1543 for nodeid in self.db.getnodeids(cn, cldb):
1544 node = self.db.getnode(cn, nodeid, cldb)
1545 if node.has_key(self.db.RETIRED_FLAG):
1546 continue
1547 l.append(nodeid)
1548 finally:
1549 cldb.close()
1550 l.sort()
1551 return l
1553 def filter(self, search_matches, filterspec, sort=(None,None),
1554 group=(None,None), num_re = re.compile('^\d+$')):
1555 ''' Return a list of the ids of the active nodes in this class that
1556 match the 'filter' spec, sorted by the group spec and then the
1557 sort spec.
1559 "filterspec" is {propname: value(s)}
1560 "sort" and "group" are (dir, prop) where dir is '+', '-' or None
1561 and prop is a prop name or None
1562 "search_matches" is {nodeid: marker}
1564 The filter must match all properties specificed - but if the
1565 property value to match is a list, any one of the values in the
1566 list may match for that property to match.
1567 '''
1568 cn = self.classname
1570 # optimise filterspec
1571 l = []
1572 props = self.getprops()
1573 LINK = 0
1574 MULTILINK = 1
1575 STRING = 2
1576 OTHER = 6
1577 for k, v in filterspec.items():
1578 propclass = props[k]
1579 if isinstance(propclass, Link):
1580 if type(v) is not type([]):
1581 v = [v]
1582 # replace key values with node ids
1583 u = []
1584 link_class = self.db.classes[propclass.classname]
1585 for entry in v:
1586 if entry == '-1': entry = None
1587 elif not num_re.match(entry):
1588 try:
1589 entry = link_class.lookup(entry)
1590 except (TypeError,KeyError):
1591 raise ValueError, 'property "%s": %s not a %s'%(
1592 k, entry, self.properties[k].classname)
1593 u.append(entry)
1595 l.append((LINK, k, u))
1596 elif isinstance(propclass, Multilink):
1597 if type(v) is not type([]):
1598 v = [v]
1599 # replace key values with node ids
1600 u = []
1601 link_class = self.db.classes[propclass.classname]
1602 for entry in v:
1603 if not num_re.match(entry):
1604 try:
1605 entry = link_class.lookup(entry)
1606 except (TypeError,KeyError):
1607 raise ValueError, 'new property "%s": %s not a %s'%(
1608 k, entry, self.properties[k].classname)
1609 u.append(entry)
1610 l.append((MULTILINK, k, u))
1611 elif isinstance(propclass, String) and k != 'id':
1612 # simple glob searching
1613 v = re.sub(r'([\|\{\}\\\.\+\[\]\(\)])', r'\\\1', v)
1614 v = v.replace('?', '.')
1615 v = v.replace('*', '.*?')
1616 l.append((STRING, k, re.compile(v, re.I)))
1617 elif isinstance(propclass, Boolean):
1618 if type(v) is type(''):
1619 bv = v.lower() in ('yes', 'true', 'on', '1')
1620 else:
1621 bv = v
1622 l.append((OTHER, k, bv))
1623 elif isinstance(propclass, Date):
1624 l.append((OTHER, k, date.Date(v)))
1625 elif isinstance(propclass, Interval):
1626 l.append((OTHER, k, date.Interval(v)))
1627 elif isinstance(propclass, Number):
1628 l.append((OTHER, k, int(v)))
1629 else:
1630 l.append((OTHER, k, v))
1631 filterspec = l
1633 # now, find all the nodes that are active and pass filtering
1634 l = []
1635 cldb = self.db.getclassdb(cn)
1636 try:
1637 # TODO: only full-scan once (use items())
1638 for nodeid in self.db.getnodeids(cn, cldb):
1639 node = self.db.getnode(cn, nodeid, cldb)
1640 if node.has_key(self.db.RETIRED_FLAG):
1641 continue
1642 # apply filter
1643 for t, k, v in filterspec:
1644 # handle the id prop
1645 if k == 'id' and v == nodeid:
1646 continue
1648 # make sure the node has the property
1649 if not node.has_key(k):
1650 # this node doesn't have this property, so reject it
1651 break
1653 # now apply the property filter
1654 if t == LINK:
1655 # link - if this node's property doesn't appear in the
1656 # filterspec's nodeid list, skip it
1657 if node[k] not in v:
1658 break
1659 elif t == MULTILINK:
1660 # multilink - if any of the nodeids required by the
1661 # filterspec aren't in this node's property, then skip
1662 # it
1663 have = node[k]
1664 for want in v:
1665 if want not in have:
1666 break
1667 else:
1668 continue
1669 break
1670 elif t == STRING:
1671 # RE search
1672 if node[k] is None or not v.search(node[k]):
1673 break
1674 elif t == OTHER:
1675 # straight value comparison for the other types
1676 if node[k] != v:
1677 break
1678 else:
1679 l.append((nodeid, node))
1680 finally:
1681 cldb.close()
1682 l.sort()
1684 # filter based on full text search
1685 if search_matches is not None:
1686 k = []
1687 for v in l:
1688 if search_matches.has_key(v[0]):
1689 k.append(v)
1690 l = k
1692 # now, sort the result
1693 def sortfun(a, b, sort=sort, group=group, properties=self.getprops(),
1694 db = self.db, cl=self):
1695 a_id, an = a
1696 b_id, bn = b
1697 # sort by group and then sort
1698 for dir, prop in group, sort:
1699 if dir is None or prop is None: continue
1701 # sorting is class-specific
1702 propclass = properties[prop]
1704 # handle the properties that might be "faked"
1705 # also, handle possible missing properties
1706 try:
1707 if not an.has_key(prop):
1708 an[prop] = cl.get(a_id, prop)
1709 av = an[prop]
1710 except KeyError:
1711 # the node doesn't have a value for this property
1712 if isinstance(propclass, Multilink): av = []
1713 else: av = ''
1714 try:
1715 if not bn.has_key(prop):
1716 bn[prop] = cl.get(b_id, prop)
1717 bv = bn[prop]
1718 except KeyError:
1719 # the node doesn't have a value for this property
1720 if isinstance(propclass, Multilink): bv = []
1721 else: bv = ''
1723 # String and Date values are sorted in the natural way
1724 if isinstance(propclass, String):
1725 # clean up the strings
1726 if av and av[0] in string.uppercase:
1727 av = av.lower()
1728 if bv and bv[0] in string.uppercase:
1729 bv = bv.lower()
1730 if (isinstance(propclass, String) or
1731 isinstance(propclass, Date)):
1732 # it might be a string that's really an integer
1733 try:
1734 av = int(av)
1735 bv = int(bv)
1736 except:
1737 pass
1738 if dir == '+':
1739 r = cmp(av, bv)
1740 if r != 0: return r
1741 elif dir == '-':
1742 r = cmp(bv, av)
1743 if r != 0: return r
1745 # Link properties are sorted according to the value of
1746 # the "order" property on the linked nodes if it is
1747 # present; or otherwise on the key string of the linked
1748 # nodes; or finally on the node ids.
1749 elif isinstance(propclass, Link):
1750 link = db.classes[propclass.classname]
1751 if av is None and bv is not None: return -1
1752 if av is not None and bv is None: return 1
1753 if av is None and bv is None: continue
1754 if link.getprops().has_key('order'):
1755 if dir == '+':
1756 r = cmp(link.get(av, 'order'),
1757 link.get(bv, 'order'))
1758 if r != 0: return r
1759 elif dir == '-':
1760 r = cmp(link.get(bv, 'order'),
1761 link.get(av, 'order'))
1762 if r != 0: return r
1763 elif link.getkey():
1764 key = link.getkey()
1765 if dir == '+':
1766 r = cmp(link.get(av, key), link.get(bv, key))
1767 if r != 0: return r
1768 elif dir == '-':
1769 r = cmp(link.get(bv, key), link.get(av, key))
1770 if r != 0: return r
1771 else:
1772 if dir == '+':
1773 r = cmp(av, bv)
1774 if r != 0: return r
1775 elif dir == '-':
1776 r = cmp(bv, av)
1777 if r != 0: return r
1779 # Multilink properties are sorted according to how many
1780 # links are present.
1781 elif isinstance(propclass, Multilink):
1782 r = cmp(len(av), len(bv))
1783 if r == 0:
1784 # Compare contents of multilink property if lenghts is
1785 # equal
1786 r = cmp ('.'.join(av), '.'.join(bv))
1787 if dir == '+':
1788 return r
1789 elif dir == '-':
1790 return -r
1791 elif isinstance(propclass, Number) or isinstance(propclass, Boolean):
1792 if dir == '+':
1793 r = cmp(av, bv)
1794 elif dir == '-':
1795 r = cmp(bv, av)
1797 # end for dir, prop in sort, group:
1798 # if all else fails, compare the ids
1799 return cmp(a[0], b[0])
1801 l.sort(sortfun)
1802 return [i[0] for i in l]
1804 def count(self):
1805 '''Get the number of nodes in this class.
1807 If the returned integer is 'numnodes', the ids of all the nodes
1808 in this class run from 1 to numnodes, and numnodes+1 will be the
1809 id of the next node to be created in this class.
1810 '''
1811 return self.db.countnodes(self.classname)
1813 # Manipulating properties:
1815 def getprops(self, protected=1):
1816 '''Return a dictionary mapping property names to property objects.
1817 If the "protected" flag is true, we include protected properties -
1818 those which may not be modified.
1820 In addition to the actual properties on the node, these
1821 methods provide the "creation" and "activity" properties. If the
1822 "protected" flag is true, we include protected properties - those
1823 which may not be modified.
1824 '''
1825 d = self.properties.copy()
1826 if protected:
1827 d['id'] = String()
1828 d['creation'] = hyperdb.Date()
1829 d['activity'] = hyperdb.Date()
1830 d['creator'] = hyperdb.Link('user')
1831 return d
1833 def addprop(self, **properties):
1834 '''Add properties to this class.
1836 The keyword arguments in 'properties' must map names to property
1837 objects, or a TypeError is raised. None of the keys in 'properties'
1838 may collide with the names of existing properties, or a ValueError
1839 is raised before any properties have been added.
1840 '''
1841 for key in properties.keys():
1842 if self.properties.has_key(key):
1843 raise ValueError, key
1844 self.properties.update(properties)
1846 def index(self, nodeid):
1847 '''Add (or refresh) the node to search indexes
1848 '''
1849 # find all the String properties that have indexme
1850 for prop, propclass in self.getprops().items():
1851 if isinstance(propclass, String) and propclass.indexme:
1852 try:
1853 value = str(self.get(nodeid, prop))
1854 except IndexError:
1855 # node no longer exists - entry should be removed
1856 self.db.indexer.purge_entry((self.classname, nodeid, prop))
1857 else:
1858 # and index them under (classname, nodeid, property)
1859 self.db.indexer.add_text((self.classname, nodeid, prop),
1860 value)
1862 #
1863 # Detector interface
1864 #
1865 def audit(self, event, detector):
1866 '''Register a detector
1867 '''
1868 l = self.auditors[event]
1869 if detector not in l:
1870 self.auditors[event].append(detector)
1872 def fireAuditors(self, action, nodeid, newvalues):
1873 '''Fire all registered auditors.
1874 '''
1875 for audit in self.auditors[action]:
1876 audit(self.db, self, nodeid, newvalues)
1878 def react(self, event, detector):
1879 '''Register a detector
1880 '''
1881 l = self.reactors[event]
1882 if detector not in l:
1883 self.reactors[event].append(detector)
1885 def fireReactors(self, action, nodeid, oldvalues):
1886 '''Fire all registered reactors.
1887 '''
1888 for react in self.reactors[action]:
1889 react(self.db, self, nodeid, oldvalues)
1891 class FileClass(Class, hyperdb.FileClass):
1892 '''This class defines a large chunk of data. To support this, it has a
1893 mandatory String property "content" which is typically saved off
1894 externally to the hyperdb.
1896 The default MIME type of this data is defined by the
1897 "default_mime_type" class attribute, which may be overridden by each
1898 node if the class defines a "type" String property.
1899 '''
1900 default_mime_type = 'text/plain'
1902 def create(self, **propvalues):
1903 ''' Snarf the "content" propvalue and store in a file
1904 '''
1905 # we need to fire the auditors now, or the content property won't
1906 # be in propvalues for the auditors to play with
1907 self.fireAuditors('create', None, propvalues)
1909 # now remove the content property so it's not stored in the db
1910 content = propvalues['content']
1911 del propvalues['content']
1913 # do the database create
1914 newid = Class.create_inner(self, **propvalues)
1916 # fire reactors
1917 self.fireReactors('create', newid, None)
1919 # store off the content as a file
1920 self.db.storefile(self.classname, newid, None, content)
1921 return newid
1923 def import_list(self, propnames, proplist):
1924 ''' Trap the "content" property...
1925 '''
1926 # dupe this list so we don't affect others
1927 propnames = propnames[:]
1929 # extract the "content" property from the proplist
1930 i = propnames.index('content')
1931 content = eval(proplist[i])
1932 del propnames[i]
1933 del proplist[i]
1935 # do the normal import
1936 newid = Class.import_list(self, propnames, proplist)
1938 # save off the "content" file
1939 self.db.storefile(self.classname, newid, None, content)
1940 return newid
1942 def get(self, nodeid, propname, default=_marker, cache=1):
1943 ''' trap the content propname and get it from the file
1944 '''
1945 poss_msg = 'Possibly an access right configuration problem.'
1946 if propname == 'content':
1947 try:
1948 return self.db.getfile(self.classname, nodeid, None)
1949 except IOError, (strerror):
1950 # XXX by catching this we donot see an error in the log.
1951 return 'ERROR reading file: %s%s\n%s\n%s'%(
1952 self.classname, nodeid, poss_msg, strerror)
1953 if default is not _marker:
1954 return Class.get(self, nodeid, propname, default, cache=cache)
1955 else:
1956 return Class.get(self, nodeid, propname, cache=cache)
1958 def getprops(self, protected=1):
1959 ''' In addition to the actual properties on the node, these methods
1960 provide the "content" property. If the "protected" flag is true,
1961 we include protected properties - those which may not be
1962 modified.
1963 '''
1964 d = Class.getprops(self, protected=protected).copy()
1965 d['content'] = hyperdb.String()
1966 return d
1968 def index(self, nodeid):
1969 ''' Index the node in the search index.
1971 We want to index the content in addition to the normal String
1972 property indexing.
1973 '''
1974 # perform normal indexing
1975 Class.index(self, nodeid)
1977 # get the content to index
1978 content = self.get(nodeid, 'content')
1980 # figure the mime type
1981 if self.properties.has_key('type'):
1982 mime_type = self.get(nodeid, 'type')
1983 else:
1984 mime_type = self.default_mime_type
1986 # and index!
1987 self.db.indexer.add_text((self.classname, nodeid, 'content'), content,
1988 mime_type)
1990 # deviation from spec - was called ItemClass
1991 class IssueClass(Class, roundupdb.IssueClass):
1992 # Overridden methods:
1993 def __init__(self, db, classname, **properties):
1994 '''The newly-created class automatically includes the "messages",
1995 "files", "nosy", and "superseder" properties. If the 'properties'
1996 dictionary attempts to specify any of these properties or a
1997 "creation" or "activity" property, a ValueError is raised.
1998 '''
1999 if not properties.has_key('title'):
2000 properties['title'] = hyperdb.String(indexme='yes')
2001 if not properties.has_key('messages'):
2002 properties['messages'] = hyperdb.Multilink("msg")
2003 if not properties.has_key('files'):
2004 properties['files'] = hyperdb.Multilink("file")
2005 if not properties.has_key('nosy'):
2006 # note: journalling is turned off as it really just wastes
2007 # space. this behaviour may be overridden in an instance
2008 properties['nosy'] = hyperdb.Multilink("user", do_journal="no")
2009 if not properties.has_key('superseder'):
2010 properties['superseder'] = hyperdb.Multilink(classname)
2011 Class.__init__(self, db, classname, **properties)
2013 #