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