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