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.135 2004-02-11 23:55:08 richard Exp $
19 '''This module defines a backend that saves the hyperdatabase in a
20 database chosen by anydbm. It is guaranteed to always be available in python
21 versions >2.1.1 (the dumbdbm fallback in 2.1.1 and earlier has several
22 serious bugs, and is not available)
23 '''
24 __docformat__ = 'restructuredtext'
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:
54 - check the timestamp of the class file and nuke the cache if it's
55 modified. Do some sort of conflict checking on the dirty stuff.
56 - 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 """
104 self.reindex()
106 def reindex(self):
107 for klass in self.classes.values():
108 for nodeid in klass.list():
109 klass.index(nodeid)
110 self.indexer.save_index()
112 def __repr__(self):
113 return '<back_anydbm instance at %x>'%id(self)
115 #
116 # Classes
117 #
118 def __getattr__(self, classname):
119 '''A convenient way of calling self.getclass(classname).'''
120 if self.classes.has_key(classname):
121 if __debug__:
122 print >>hyperdb.DEBUG, '__getattr__', (self, classname)
123 return self.classes[classname]
124 raise AttributeError, classname
126 def addclass(self, cl):
127 if __debug__:
128 print >>hyperdb.DEBUG, 'addclass', (self, cl)
129 cn = cl.classname
130 if self.classes.has_key(cn):
131 raise ValueError, cn
132 self.classes[cn] = cl
134 def getclasses(self):
135 '''Return a list of the names of all existing classes.'''
136 if __debug__:
137 print >>hyperdb.DEBUG, 'getclasses', (self,)
138 l = self.classes.keys()
139 l.sort()
140 return l
142 def getclass(self, classname):
143 '''Get the Class object representing a particular class.
145 If 'classname' is not a valid class name, a KeyError is raised.
146 '''
147 if __debug__:
148 print >>hyperdb.DEBUG, 'getclass', (self, classname)
149 try:
150 return self.classes[classname]
151 except KeyError:
152 raise KeyError, 'There is no class called "%s"'%classname
154 #
155 # Class DBs
156 #
157 def clear(self):
158 '''Delete all database contents
159 '''
160 if __debug__:
161 print >>hyperdb.DEBUG, 'clear', (self,)
162 for cn in self.classes.keys():
163 for dummy in 'nodes', 'journals':
164 path = os.path.join(self.dir, 'journals.%s'%cn)
165 if os.path.exists(path):
166 os.remove(path)
167 elif os.path.exists(path+'.db'): # dbm appends .db
168 os.remove(path+'.db')
170 def getclassdb(self, classname, mode='r'):
171 ''' grab a connection to the class db that will be used for
172 multiple actions
173 '''
174 if __debug__:
175 print >>hyperdb.DEBUG, 'getclassdb', (self, classname, mode)
176 return self.opendb('nodes.%s'%classname, mode)
178 def determine_db_type(self, path):
179 ''' determine which DB wrote the class file
180 '''
181 db_type = ''
182 if os.path.exists(path):
183 db_type = whichdb.whichdb(path)
184 if not db_type:
185 raise DatabaseError, "Couldn't identify database type"
186 elif os.path.exists(path+'.db'):
187 # if the path ends in '.db', it's a dbm database, whether
188 # anydbm says it's dbhash or not!
189 db_type = 'dbm'
190 return db_type
192 def opendb(self, name, mode):
193 '''Low-level database opener that gets around anydbm/dbm
194 eccentricities.
195 '''
196 if __debug__:
197 print >>hyperdb.DEBUG, 'opendb', (self, name, mode)
199 # figure the class db type
200 path = os.path.join(os.getcwd(), self.dir, name)
201 db_type = self.determine_db_type(path)
203 # new database? let anydbm pick the best dbm
204 if not db_type:
205 if __debug__:
206 print >>hyperdb.DEBUG, "opendb anydbm.open(%r, 'c')"%path
207 return anydbm.open(path, 'c')
209 # open the database with the correct module
210 try:
211 dbm = __import__(db_type)
212 except ImportError:
213 raise DatabaseError, \
214 "Couldn't open database - the required module '%s'"\
215 " is not available"%db_type
216 if __debug__:
217 print >>hyperdb.DEBUG, "opendb %r.open(%r, %r)"%(db_type, path,
218 mode)
219 return dbm.open(path, mode)
221 #
222 # Node IDs
223 #
224 def newid(self, classname):
225 ''' Generate a new id for the given class
226 '''
227 # open the ids DB - create if if doesn't exist
228 db = self.opendb('_ids', 'c')
229 if db.has_key(classname):
230 newid = db[classname] = str(int(db[classname]) + 1)
231 else:
232 # the count() bit is transitional - older dbs won't start at 1
233 newid = str(self.getclass(classname).count()+1)
234 db[classname] = newid
235 db.close()
236 return newid
238 def setid(self, classname, setid):
239 ''' Set the id counter: used during import of database
240 '''
241 # open the ids DB - create if if doesn't exist
242 db = self.opendb('_ids', 'c')
243 db[classname] = str(setid)
244 db.close()
246 #
247 # Nodes
248 #
249 def addnode(self, classname, nodeid, node):
250 ''' add the specified node to its class's db
251 '''
252 if __debug__:
253 print >>hyperdb.DEBUG, 'addnode', (self, classname, nodeid, node)
255 # we'll be supplied these props if we're doing an import
256 if not node.has_key('creator'):
257 # add in the "calculated" properties (dupe so we don't affect
258 # calling code's node assumptions)
259 node = node.copy()
260 node['creator'] = self.getuid()
261 node['creation'] = node['activity'] = date.Date()
263 self.newnodes.setdefault(classname, {})[nodeid] = 1
264 self.cache.setdefault(classname, {})[nodeid] = node
265 self.savenode(classname, nodeid, node)
267 def setnode(self, classname, nodeid, node):
268 ''' change the specified node
269 '''
270 if __debug__:
271 print >>hyperdb.DEBUG, 'setnode', (self, classname, nodeid, node)
272 self.dirtynodes.setdefault(classname, {})[nodeid] = 1
274 # update the activity time (dupe so we don't affect
275 # calling code's node assumptions)
276 node = node.copy()
277 node['activity'] = date.Date()
279 # can't set without having already loaded the node
280 self.cache[classname][nodeid] = node
281 self.savenode(classname, nodeid, node)
283 def savenode(self, classname, nodeid, node):
284 ''' perform the saving of data specified by the set/addnode
285 '''
286 if __debug__:
287 print >>hyperdb.DEBUG, 'savenode', (self, classname, nodeid, node)
288 self.transactions.append((self.doSaveNode, (classname, nodeid, node)))
290 def getnode(self, classname, nodeid, db=None, cache=1):
291 ''' get a node from the database
293 Note the "cache" parameter is not used, and exists purely for
294 backward compatibility!
295 '''
296 if __debug__:
297 print >>hyperdb.DEBUG, 'getnode', (self, classname, nodeid, db)
299 # try the cache
300 cache_dict = self.cache.setdefault(classname, {})
301 if cache_dict.has_key(nodeid):
302 if __debug__:
303 print >>hyperdb.TRACE, 'get %s %s cached'%(classname,
304 nodeid)
305 return cache_dict[nodeid]
307 if __debug__:
308 print >>hyperdb.TRACE, 'get %s %s'%(classname, nodeid)
310 # get from the database and save in the cache
311 if db is None:
312 db = self.getclassdb(classname)
313 if not db.has_key(nodeid):
314 raise IndexError, "no such %s %s"%(classname, nodeid)
316 # check the uncommitted, destroyed nodes
317 if (self.destroyednodes.has_key(classname) and
318 self.destroyednodes[classname].has_key(nodeid)):
319 raise IndexError, "no such %s %s"%(classname, nodeid)
321 # decode
322 res = marshal.loads(db[nodeid])
324 # reverse the serialisation
325 res = self.unserialise(classname, res)
327 # store off in the cache dict
328 if cache:
329 cache_dict[nodeid] = res
331 return res
333 def destroynode(self, classname, nodeid):
334 '''Remove a node from the database. Called exclusively by the
335 destroy() method on Class.
336 '''
337 if __debug__:
338 print >>hyperdb.DEBUG, 'destroynode', (self, classname, nodeid)
340 # remove from cache and newnodes if it's there
341 if (self.cache.has_key(classname) and
342 self.cache[classname].has_key(nodeid)):
343 del self.cache[classname][nodeid]
344 if (self.newnodes.has_key(classname) and
345 self.newnodes[classname].has_key(nodeid)):
346 del self.newnodes[classname][nodeid]
348 # see if there's any obvious commit actions that we should get rid of
349 for entry in self.transactions[:]:
350 if entry[1][:2] == (classname, nodeid):
351 self.transactions.remove(entry)
353 # add to the destroyednodes map
354 self.destroyednodes.setdefault(classname, {})[nodeid] = 1
356 # add the destroy commit action
357 self.transactions.append((self.doDestroyNode, (classname, nodeid)))
359 def serialise(self, classname, node):
360 '''Copy the node contents, converting non-marshallable data into
361 marshallable data.
362 '''
363 if __debug__:
364 print >>hyperdb.DEBUG, 'serialise', classname, node
365 properties = self.getclass(classname).getprops()
366 d = {}
367 for k, v in node.items():
368 # if the property doesn't exist, or is the "retired" flag then
369 # it won't be in the properties dict
370 if not properties.has_key(k):
371 d[k] = v
372 continue
374 # get the property spec
375 prop = properties[k]
377 if isinstance(prop, Password) and v is not None:
378 d[k] = str(v)
379 elif isinstance(prop, Date) and v is not None:
380 d[k] = v.serialise()
381 elif isinstance(prop, Interval) and v is not None:
382 d[k] = v.serialise()
383 else:
384 d[k] = v
385 return d
387 def unserialise(self, classname, node):
388 '''Decode the marshalled node data
389 '''
390 if __debug__:
391 print >>hyperdb.DEBUG, 'unserialise', classname, node
392 properties = self.getclass(classname).getprops()
393 d = {}
394 for k, v in node.items():
395 # if the property doesn't exist, or is the "retired" flag then
396 # it won't be in the properties dict
397 if not properties.has_key(k):
398 d[k] = v
399 continue
401 # get the property spec
402 prop = properties[k]
404 if isinstance(prop, Date) and v is not None:
405 d[k] = date.Date(v)
406 elif isinstance(prop, Interval) and v is not None:
407 d[k] = date.Interval(v)
408 elif isinstance(prop, Password) and v is not None:
409 p = password.Password()
410 p.unpack(v)
411 d[k] = p
412 else:
413 d[k] = v
414 return d
416 def hasnode(self, classname, nodeid, db=None):
417 ''' determine if the database has a given node
418 '''
419 if __debug__:
420 print >>hyperdb.DEBUG, 'hasnode', (self, classname, nodeid, db)
422 # try the cache
423 cache = self.cache.setdefault(classname, {})
424 if cache.has_key(nodeid):
425 if __debug__:
426 print >>hyperdb.TRACE, 'has %s %s cached'%(classname, nodeid)
427 return 1
428 if __debug__:
429 print >>hyperdb.TRACE, 'has %s %s'%(classname, nodeid)
431 # not in the cache - check the database
432 if db is None:
433 db = self.getclassdb(classname)
434 res = db.has_key(nodeid)
435 return res
437 def countnodes(self, classname, db=None):
438 if __debug__:
439 print >>hyperdb.DEBUG, 'countnodes', (self, classname, db)
441 count = 0
443 # include the uncommitted nodes
444 if self.newnodes.has_key(classname):
445 count += len(self.newnodes[classname])
446 if self.destroyednodes.has_key(classname):
447 count -= len(self.destroyednodes[classname])
449 # and count those in the DB
450 if db is None:
451 db = self.getclassdb(classname)
452 count = count + len(db.keys())
453 return count
456 #
457 # Files - special node properties
458 # inherited from FileStorage
460 #
461 # Journal
462 #
463 def addjournal(self, classname, nodeid, action, params, creator=None,
464 creation=None):
465 ''' Journal the Action
466 'action' may be:
468 'create' or 'set' -- 'params' is a dictionary of property values
469 'link' or 'unlink' -- 'params' is (classname, nodeid, propname)
470 'retire' -- 'params' is None
471 '''
472 if __debug__:
473 print >>hyperdb.DEBUG, 'addjournal', (self, classname, nodeid,
474 action, params, creator, creation)
475 self.transactions.append((self.doSaveJournal, (classname, nodeid,
476 action, params, creator, creation)))
478 def getjournal(self, classname, nodeid):
479 ''' get the journal for id
481 Raise IndexError if the node doesn't exist (as per history()'s
482 API)
483 '''
484 if __debug__:
485 print >>hyperdb.DEBUG, 'getjournal', (self, classname, nodeid)
487 # our journal result
488 res = []
490 # add any journal entries for transactions not committed to the
491 # database
492 for method, args in self.transactions:
493 if method != self.doSaveJournal:
494 continue
495 (cache_classname, cache_nodeid, cache_action, cache_params,
496 cache_creator, cache_creation) = args
497 if cache_classname == classname and cache_nodeid == nodeid:
498 if not cache_creator:
499 cache_creator = self.getuid()
500 if not cache_creation:
501 cache_creation = date.Date()
502 res.append((cache_nodeid, cache_creation, cache_creator,
503 cache_action, cache_params))
505 # attempt to open the journal - in some rare cases, the journal may
506 # not exist
507 try:
508 db = self.opendb('journals.%s'%classname, 'r')
509 except anydbm.error, error:
510 if str(error) == "need 'c' or 'n' flag to open new db":
511 raise IndexError, 'no such %s %s'%(classname, nodeid)
512 elif error.args[0] != 2:
513 # this isn't a "not found" error, be alarmed!
514 raise
515 if res:
516 # we have unsaved journal entries, return them
517 return res
518 raise IndexError, 'no such %s %s'%(classname, nodeid)
519 try:
520 journal = marshal.loads(db[nodeid])
521 except KeyError:
522 db.close()
523 if res:
524 # we have some unsaved journal entries, be happy!
525 return res
526 raise IndexError, 'no such %s %s'%(classname, nodeid)
527 db.close()
529 # add all the saved journal entries for this node
530 for nodeid, date_stamp, user, action, params in journal:
531 res.append((nodeid, date.Date(date_stamp), user, action, params))
532 return res
534 def pack(self, pack_before):
535 ''' Delete all journal entries except "create" before 'pack_before'.
536 '''
537 if __debug__:
538 print >>hyperdb.DEBUG, 'packjournal', (self, pack_before)
540 pack_before = pack_before.serialise()
541 for classname in self.getclasses():
542 # get the journal db
543 db_name = 'journals.%s'%classname
544 path = os.path.join(os.getcwd(), self.dir, classname)
545 db_type = self.determine_db_type(path)
546 db = self.opendb(db_name, 'w')
548 for key in db.keys():
549 # get the journal for this db entry
550 journal = marshal.loads(db[key])
551 l = []
552 last_set_entry = None
553 for entry in journal:
554 # unpack the entry
555 (nodeid, date_stamp, self.journaltag, action,
556 params) = entry
557 # if the entry is after the pack date, _or_ the initial
558 # create entry, then it stays
559 if date_stamp > pack_before or action == 'create':
560 l.append(entry)
561 db[key] = marshal.dumps(l)
562 if db_type == 'gdbm':
563 db.reorganize()
564 db.close()
567 #
568 # Basic transaction support
569 #
570 def commit(self):
571 ''' Commit the current transactions.
572 '''
573 if __debug__:
574 print >>hyperdb.DEBUG, 'commit', (self,)
576 # keep a handle to all the database files opened
577 self.databases = {}
579 try:
580 # now, do all the transactions
581 reindex = {}
582 for method, args in self.transactions:
583 reindex[method(*args)] = 1
584 finally:
585 # make sure we close all the database files
586 for db in self.databases.values():
587 db.close()
588 del self.databases
590 # reindex the nodes that request it
591 for classname, nodeid in filter(None, reindex.keys()):
592 print >>hyperdb.DEBUG, 'commit.reindex', (classname, nodeid)
593 self.getclass(classname).index(nodeid)
595 # save the indexer state
596 self.indexer.save_index()
598 self.clearCache()
600 def clearCache(self):
601 # all transactions committed, back to normal
602 self.cache = {}
603 self.dirtynodes = {}
604 self.newnodes = {}
605 self.destroyednodes = {}
606 self.transactions = []
608 def getCachedClassDB(self, classname):
609 ''' get the class db, looking in our cache of databases for commit
610 '''
611 # get the database handle
612 db_name = 'nodes.%s'%classname
613 if not self.databases.has_key(db_name):
614 self.databases[db_name] = self.getclassdb(classname, 'c')
615 return self.databases[db_name]
617 def doSaveNode(self, classname, nodeid, node):
618 if __debug__:
619 print >>hyperdb.DEBUG, 'doSaveNode', (self, classname, nodeid,
620 node)
622 db = self.getCachedClassDB(classname)
624 # now save the marshalled data
625 db[nodeid] = marshal.dumps(self.serialise(classname, node))
627 # return the classname, nodeid so we reindex this content
628 return (classname, nodeid)
630 def getCachedJournalDB(self, classname):
631 ''' get the journal db, looking in our cache of databases for commit
632 '''
633 # get the database handle
634 db_name = 'journals.%s'%classname
635 if not self.databases.has_key(db_name):
636 self.databases[db_name] = self.opendb(db_name, 'c')
637 return self.databases[db_name]
639 def doSaveJournal(self, classname, nodeid, action, params, creator,
640 creation):
641 # serialise the parameters now if necessary
642 if isinstance(params, type({})):
643 if action in ('set', 'create'):
644 params = self.serialise(classname, params)
646 # handle supply of the special journalling parameters (usually
647 # supplied on importing an existing database)
648 if creator:
649 journaltag = creator
650 else:
651 journaltag = self.getuid()
652 if creation:
653 journaldate = creation.serialise()
654 else:
655 journaldate = date.Date().serialise()
657 # create the journal entry
658 entry = (nodeid, journaldate, journaltag, action, params)
660 if __debug__:
661 print >>hyperdb.DEBUG, 'doSaveJournal', entry
663 db = self.getCachedJournalDB(classname)
665 # now insert the journal entry
666 if db.has_key(nodeid):
667 # append to existing
668 s = db[nodeid]
669 l = marshal.loads(s)
670 l.append(entry)
671 else:
672 l = [entry]
674 db[nodeid] = marshal.dumps(l)
676 def doDestroyNode(self, classname, nodeid):
677 if __debug__:
678 print >>hyperdb.DEBUG, 'doDestroyNode', (self, classname, nodeid)
680 # delete from the class database
681 db = self.getCachedClassDB(classname)
682 if db.has_key(nodeid):
683 del db[nodeid]
685 # delete from the database
686 db = self.getCachedJournalDB(classname)
687 if db.has_key(nodeid):
688 del db[nodeid]
690 # return the classname, nodeid so we reindex this content
691 return (classname, nodeid)
693 def rollback(self):
694 ''' Reverse all actions from the current transaction.
695 '''
696 if __debug__:
697 print >>hyperdb.DEBUG, 'rollback', (self, )
698 for method, args in self.transactions:
699 # delete temporary files
700 if method == self.doStoreFile:
701 self.rollbackStoreFile(*args)
702 self.cache = {}
703 self.dirtynodes = {}
704 self.newnodes = {}
705 self.destroyednodes = {}
706 self.transactions = []
708 def close(self):
709 ''' Nothing to do
710 '''
711 if self.lockfile is not None:
712 locking.release_lock(self.lockfile)
713 if self.lockfile is not None:
714 self.lockfile.close()
715 self.lockfile = None
717 _marker = []
718 class Class(hyperdb.Class):
719 '''The handle to a particular class of nodes in a hyperdatabase.'''
721 def __init__(self, db, classname, **properties):
722 '''Create a new class with a given name and property specification.
724 'classname' must not collide with the name of an existing class,
725 or a ValueError is raised. The keyword arguments in 'properties'
726 must map names to property objects, or a TypeError is raised.
727 '''
728 if (properties.has_key('creation') or properties.has_key('activity')
729 or properties.has_key('creator')):
730 raise ValueError, '"creation", "activity" and "creator" are '\
731 'reserved'
733 self.classname = classname
734 self.properties = properties
735 self.db = weakref.proxy(db) # use a weak ref to avoid circularity
736 self.key = ''
738 # should we journal changes (default yes)
739 self.do_journal = 1
741 # do the db-related init stuff
742 db.addclass(self)
744 self.auditors = {'create': [], 'set': [], 'retire': [], 'restore': []}
745 self.reactors = {'create': [], 'set': [], 'retire': [], 'restore': []}
747 def enableJournalling(self):
748 '''Turn journalling on for this class
749 '''
750 self.do_journal = 1
752 def disableJournalling(self):
753 '''Turn journalling off for this class
754 '''
755 self.do_journal = 0
757 # Editing nodes:
759 def create(self, **propvalues):
760 '''Create a new node of this class and return its id.
762 The keyword arguments in 'propvalues' map property names to values.
764 The values of arguments must be acceptable for the types of their
765 corresponding properties or a TypeError is raised.
767 If this class has a key property, it must be present and its value
768 must not collide with other key strings or a ValueError is raised.
770 Any other properties on this class that are missing from the
771 'propvalues' dictionary are set to None.
773 If an id in a link or multilink property does not refer to a valid
774 node, an IndexError is raised.
776 These operations trigger detectors and can be vetoed. Attempts
777 to modify the "creation" or "activity" properties cause a KeyError.
778 '''
779 self.fireAuditors('create', None, propvalues)
780 newid = self.create_inner(**propvalues)
781 self.fireReactors('create', newid, None)
782 return newid
784 def create_inner(self, **propvalues):
785 ''' Called by create, in-between the audit and react calls.
786 '''
787 if propvalues.has_key('id'):
788 raise KeyError, '"id" is reserved'
790 if self.db.journaltag is None:
791 raise DatabaseError, 'Database open read-only'
793 if propvalues.has_key('creation') or propvalues.has_key('activity'):
794 raise KeyError, '"creation" and "activity" are reserved'
795 # new node's id
796 newid = self.db.newid(self.classname)
798 # validate propvalues
799 num_re = re.compile('^\d+$')
800 for key, value in propvalues.items():
801 if key == self.key:
802 try:
803 self.lookup(value)
804 except KeyError:
805 pass
806 else:
807 raise ValueError, 'node with key "%s" exists'%value
809 # try to handle this property
810 try:
811 prop = self.properties[key]
812 except KeyError:
813 raise KeyError, '"%s" has no property "%s"'%(self.classname,
814 key)
816 if value is not None and isinstance(prop, Link):
817 if type(value) != type(''):
818 raise ValueError, 'link value must be String'
819 link_class = self.properties[key].classname
820 # if it isn't a number, it's a key
821 if not num_re.match(value):
822 try:
823 value = self.db.classes[link_class].lookup(value)
824 except (TypeError, KeyError):
825 raise IndexError, 'new property "%s": %s not a %s'%(
826 key, value, link_class)
827 elif not self.db.getclass(link_class).hasnode(value):
828 raise IndexError, '%s has no node %s'%(link_class, value)
830 # save off the value
831 propvalues[key] = value
833 # register the link with the newly linked node
834 if self.do_journal and self.properties[key].do_journal:
835 self.db.addjournal(link_class, value, 'link',
836 (self.classname, newid, key))
838 elif isinstance(prop, Multilink):
839 if type(value) != type([]):
840 raise TypeError, 'new property "%s" not a list of ids'%key
842 # clean up and validate the list of links
843 link_class = self.properties[key].classname
844 l = []
845 for entry in value:
846 if type(entry) != type(''):
847 raise ValueError, '"%s" multilink value (%r) '\
848 'must contain Strings'%(key, value)
849 # if it isn't a number, it's a key
850 if not num_re.match(entry):
851 try:
852 entry = self.db.classes[link_class].lookup(entry)
853 except (TypeError, KeyError):
854 raise IndexError, 'new property "%s": %s not a %s'%(
855 key, entry, self.properties[key].classname)
856 l.append(entry)
857 value = l
858 propvalues[key] = value
860 # handle additions
861 for nodeid in value:
862 if not self.db.getclass(link_class).hasnode(nodeid):
863 raise IndexError, '%s has no node %s'%(link_class,
864 nodeid)
865 # register the link with the newly linked node
866 if self.do_journal and self.properties[key].do_journal:
867 self.db.addjournal(link_class, nodeid, 'link',
868 (self.classname, newid, key))
870 elif isinstance(prop, String):
871 if type(value) != type('') and type(value) != type(u''):
872 raise TypeError, 'new property "%s" not a string'%key
874 elif isinstance(prop, Password):
875 if not isinstance(value, password.Password):
876 raise TypeError, 'new property "%s" not a Password'%key
878 elif isinstance(prop, Date):
879 if value is not None and not isinstance(value, date.Date):
880 raise TypeError, 'new property "%s" not a Date'%key
882 elif isinstance(prop, Interval):
883 if value is not None and not isinstance(value, date.Interval):
884 raise TypeError, 'new property "%s" not an Interval'%key
886 elif value is not None and isinstance(prop, Number):
887 try:
888 float(value)
889 except ValueError:
890 raise TypeError, 'new property "%s" not numeric'%key
892 elif value is not None and isinstance(prop, Boolean):
893 try:
894 int(value)
895 except ValueError:
896 raise TypeError, 'new property "%s" not boolean'%key
898 # make sure there's data where there needs to be
899 for key, prop in self.properties.items():
900 if propvalues.has_key(key):
901 continue
902 if key == self.key:
903 raise ValueError, 'key property "%s" is required'%key
904 if isinstance(prop, Multilink):
905 propvalues[key] = []
906 else:
907 propvalues[key] = None
909 # done
910 self.db.addnode(self.classname, newid, propvalues)
911 if self.do_journal:
912 self.db.addjournal(self.classname, newid, 'create', {})
914 return newid
916 def export_list(self, propnames, nodeid):
917 ''' Export a node - generate a list of CSV-able data in the order
918 specified by propnames for the given node.
919 '''
920 properties = self.getprops()
921 l = []
922 for prop in propnames:
923 proptype = properties[prop]
924 value = self.get(nodeid, prop)
925 # "marshal" data where needed
926 if value is None:
927 pass
928 elif isinstance(proptype, hyperdb.Date):
929 value = value.get_tuple()
930 elif isinstance(proptype, hyperdb.Interval):
931 value = value.get_tuple()
932 elif isinstance(proptype, hyperdb.Password):
933 value = str(value)
934 l.append(repr(value))
936 # append retired flag
937 l.append(repr(self.is_retired(nodeid)))
939 return l
941 def import_list(self, propnames, proplist):
942 ''' Import a node - all information including "id" is present and
943 should not be sanity checked. Triggers are not triggered. The
944 journal should be initialised using the "creator" and "created"
945 information.
947 Return the nodeid of the node imported.
948 '''
949 if self.db.journaltag is None:
950 raise DatabaseError, 'Database open read-only'
951 properties = self.getprops()
953 # make the new node's property map
954 d = {}
955 newid = None
956 for i in range(len(propnames)):
957 # Figure the property for this column
958 propname = propnames[i]
960 # Use eval to reverse the repr() used to output the CSV
961 value = eval(proplist[i])
963 # "unmarshal" where necessary
964 if propname == 'id':
965 newid = value
966 continue
967 elif propname == 'is retired':
968 # is the item retired?
969 if int(value):
970 d[self.db.RETIRED_FLAG] = 1
971 continue
972 elif value is None:
973 d[propname] = None
974 continue
976 prop = properties[propname]
977 if isinstance(prop, hyperdb.Date):
978 value = date.Date(value)
979 elif isinstance(prop, hyperdb.Interval):
980 value = date.Interval(value)
981 elif isinstance(prop, hyperdb.Password):
982 pwd = password.Password()
983 pwd.unpack(value)
984 value = pwd
985 d[propname] = value
987 # get a new id if necessary
988 if newid is None:
989 newid = self.db.newid(self.classname)
991 # add the node and journal
992 self.db.addnode(self.classname, newid, d)
994 # extract the journalling stuff and nuke it
995 if d.has_key('creator'):
996 creator = d['creator']
997 del d['creator']
998 else:
999 creator = None
1000 if d.has_key('creation'):
1001 creation = d['creation']
1002 del d['creation']
1003 else:
1004 creation = None
1005 if d.has_key('activity'):
1006 del d['activity']
1007 self.db.addjournal(self.classname, newid, 'create', {}, creator,
1008 creation)
1009 return newid
1011 def get(self, nodeid, propname, default=_marker, cache=1):
1012 '''Get the value of a property on an existing node of this class.
1014 'nodeid' must be the id of an existing node of this class or an
1015 IndexError is raised. 'propname' must be the name of a property
1016 of this class or a KeyError is raised.
1018 'cache' exists for backward compatibility, and is not used.
1020 Attempts to get the "creation" or "activity" properties should
1021 do the right thing.
1022 '''
1023 if propname == 'id':
1024 return nodeid
1026 # get the node's dict
1027 d = self.db.getnode(self.classname, nodeid)
1029 # check for one of the special props
1030 if propname == 'creation':
1031 if d.has_key('creation'):
1032 return d['creation']
1033 if not self.do_journal:
1034 raise ValueError, 'Journalling is disabled for this class'
1035 journal = self.db.getjournal(self.classname, nodeid)
1036 if journal:
1037 return self.db.getjournal(self.classname, nodeid)[0][1]
1038 else:
1039 # on the strange chance that there's no journal
1040 return date.Date()
1041 if propname == 'activity':
1042 if d.has_key('activity'):
1043 return d['activity']
1044 if not self.do_journal:
1045 raise ValueError, 'Journalling is disabled for this class'
1046 journal = self.db.getjournal(self.classname, nodeid)
1047 if journal:
1048 return self.db.getjournal(self.classname, nodeid)[-1][1]
1049 else:
1050 # on the strange chance that there's no journal
1051 return date.Date()
1052 if propname == 'creator':
1053 if d.has_key('creator'):
1054 return d['creator']
1055 if not self.do_journal:
1056 raise ValueError, 'Journalling is disabled for this class'
1057 journal = self.db.getjournal(self.classname, nodeid)
1058 if journal:
1059 num_re = re.compile('^\d+$')
1060 value = self.db.getjournal(self.classname, nodeid)[0][2]
1061 if num_re.match(value):
1062 return value
1063 else:
1064 # old-style "username" journal tag
1065 try:
1066 return self.db.user.lookup(value)
1067 except KeyError:
1068 # user's been retired, return admin
1069 return '1'
1070 else:
1071 return self.db.getuid()
1073 # get the property (raises KeyErorr if invalid)
1074 prop = self.properties[propname]
1076 if not d.has_key(propname):
1077 if default is _marker:
1078 if isinstance(prop, Multilink):
1079 return []
1080 else:
1081 return None
1082 else:
1083 return default
1085 # return a dupe of the list so code doesn't get confused
1086 if isinstance(prop, Multilink):
1087 return d[propname][:]
1089 return d[propname]
1091 def set(self, nodeid, **propvalues):
1092 '''Modify a property on an existing node of this class.
1094 'nodeid' must be the id of an existing node of this class or an
1095 IndexError is raised.
1097 Each key in 'propvalues' must be the name of a property of this
1098 class or a KeyError is raised.
1100 All values in 'propvalues' must be acceptable types for their
1101 corresponding properties or a TypeError is raised.
1103 If the value of the key property is set, it must not collide with
1104 other key strings or a ValueError is raised.
1106 If the value of a Link or Multilink property contains an invalid
1107 node id, a ValueError is raised.
1109 These operations trigger detectors and can be vetoed. Attempts
1110 to modify the "creation" or "activity" properties cause a KeyError.
1111 '''
1112 if not propvalues:
1113 return propvalues
1115 if propvalues.has_key('creation') or propvalues.has_key('activity'):
1116 raise KeyError, '"creation" and "activity" are reserved'
1118 if propvalues.has_key('id'):
1119 raise KeyError, '"id" is reserved'
1121 if self.db.journaltag is None:
1122 raise DatabaseError, 'Database open read-only'
1124 self.fireAuditors('set', nodeid, propvalues)
1125 # Take a copy of the node dict so that the subsequent set
1126 # operation doesn't modify the oldvalues structure.
1127 oldvalues = copy.deepcopy(self.db.getnode(self.classname, nodeid))
1129 node = self.db.getnode(self.classname, nodeid)
1130 if node.has_key(self.db.RETIRED_FLAG):
1131 raise IndexError
1132 num_re = re.compile('^\d+$')
1134 # if the journal value is to be different, store it in here
1135 journalvalues = {}
1137 for propname, value in propvalues.items():
1138 # check to make sure we're not duplicating an existing key
1139 if propname == self.key and node[propname] != value:
1140 try:
1141 self.lookup(value)
1142 except KeyError:
1143 pass
1144 else:
1145 raise ValueError, 'node with key "%s" exists'%value
1147 # this will raise the KeyError if the property isn't valid
1148 # ... we don't use getprops() here because we only care about
1149 # the writeable properties.
1150 try:
1151 prop = self.properties[propname]
1152 except KeyError:
1153 raise KeyError, '"%s" has no property named "%s"'%(
1154 self.classname, propname)
1156 # if the value's the same as the existing value, no sense in
1157 # doing anything
1158 current = node.get(propname, None)
1159 if value == current:
1160 del propvalues[propname]
1161 continue
1162 journalvalues[propname] = current
1164 # do stuff based on the prop type
1165 if isinstance(prop, Link):
1166 link_class = prop.classname
1167 # if it isn't a number, it's a key
1168 if value is not None and not isinstance(value, type('')):
1169 raise ValueError, 'property "%s" link value be a string'%(
1170 propname)
1171 if isinstance(value, type('')) and not num_re.match(value):
1172 try:
1173 value = self.db.classes[link_class].lookup(value)
1174 except (TypeError, KeyError):
1175 raise IndexError, 'new property "%s": %s not a %s'%(
1176 propname, value, prop.classname)
1178 if (value is not None and
1179 not self.db.getclass(link_class).hasnode(value)):
1180 raise IndexError, '%s has no node %s'%(link_class, value)
1182 if self.do_journal and prop.do_journal:
1183 # register the unlink with the old linked node
1184 if node.has_key(propname) and node[propname] is not None:
1185 self.db.addjournal(link_class, node[propname], 'unlink',
1186 (self.classname, nodeid, propname))
1188 # register the link with the newly linked node
1189 if value is not None:
1190 self.db.addjournal(link_class, value, 'link',
1191 (self.classname, nodeid, propname))
1193 elif isinstance(prop, Multilink):
1194 if type(value) != type([]):
1195 raise TypeError, 'new property "%s" not a list of'\
1196 ' ids'%propname
1197 link_class = self.properties[propname].classname
1198 l = []
1199 for entry in value:
1200 # if it isn't a number, it's a key
1201 if type(entry) != type(''):
1202 raise ValueError, 'new property "%s" link value ' \
1203 'must be a string'%propname
1204 if not num_re.match(entry):
1205 try:
1206 entry = self.db.classes[link_class].lookup(entry)
1207 except (TypeError, KeyError):
1208 raise IndexError, 'new property "%s": %s not a %s'%(
1209 propname, entry,
1210 self.properties[propname].classname)
1211 l.append(entry)
1212 value = l
1213 propvalues[propname] = value
1215 # figure the journal entry for this property
1216 add = []
1217 remove = []
1219 # handle removals
1220 if node.has_key(propname):
1221 l = node[propname]
1222 else:
1223 l = []
1224 for id in l[:]:
1225 if id in value:
1226 continue
1227 # register the unlink with the old linked node
1228 if self.do_journal and self.properties[propname].do_journal:
1229 self.db.addjournal(link_class, id, 'unlink',
1230 (self.classname, nodeid, propname))
1231 l.remove(id)
1232 remove.append(id)
1234 # handle additions
1235 for id in value:
1236 if not self.db.getclass(link_class).hasnode(id):
1237 raise IndexError, '%s has no node %s'%(link_class, id)
1238 if id in l:
1239 continue
1240 # register the link with the newly linked node
1241 if self.do_journal and self.properties[propname].do_journal:
1242 self.db.addjournal(link_class, id, 'link',
1243 (self.classname, nodeid, propname))
1244 l.append(id)
1245 add.append(id)
1247 # figure the journal entry
1248 l = []
1249 if add:
1250 l.append(('+', add))
1251 if remove:
1252 l.append(('-', remove))
1253 if l:
1254 journalvalues[propname] = tuple(l)
1256 elif isinstance(prop, String):
1257 if value is not None and type(value) != type('') and type(value) != type(u''):
1258 raise TypeError, 'new property "%s" not a string'%propname
1260 elif isinstance(prop, Password):
1261 if not isinstance(value, password.Password):
1262 raise TypeError, 'new property "%s" not a Password'%propname
1263 propvalues[propname] = value
1265 elif value is not None and isinstance(prop, Date):
1266 if not isinstance(value, date.Date):
1267 raise TypeError, 'new property "%s" not a Date'% propname
1268 propvalues[propname] = value
1270 elif value is not None and isinstance(prop, Interval):
1271 if not isinstance(value, date.Interval):
1272 raise TypeError, 'new property "%s" not an '\
1273 'Interval'%propname
1274 propvalues[propname] = value
1276 elif value is not None and isinstance(prop, Number):
1277 try:
1278 float(value)
1279 except ValueError:
1280 raise TypeError, 'new property "%s" not numeric'%propname
1282 elif value is not None and isinstance(prop, Boolean):
1283 try:
1284 int(value)
1285 except ValueError:
1286 raise TypeError, 'new property "%s" not boolean'%propname
1288 node[propname] = value
1290 # nothing to do?
1291 if not propvalues:
1292 return propvalues
1294 # do the set, and journal it
1295 self.db.setnode(self.classname, nodeid, node)
1297 if self.do_journal:
1298 self.db.addjournal(self.classname, nodeid, 'set', journalvalues)
1300 self.fireReactors('set', nodeid, oldvalues)
1302 return propvalues
1304 def retire(self, nodeid):
1305 '''Retire a node.
1307 The properties on the node remain available from the get() method,
1308 and the node's id is never reused.
1310 Retired nodes are not returned by the find(), list(), or lookup()
1311 methods, and other nodes may reuse the values of their key properties.
1313 These operations trigger detectors and can be vetoed. Attempts
1314 to modify the "creation" or "activity" properties cause a KeyError.
1315 '''
1316 if self.db.journaltag is None:
1317 raise DatabaseError, 'Database open read-only'
1319 self.fireAuditors('retire', nodeid, None)
1321 node = self.db.getnode(self.classname, nodeid)
1322 node[self.db.RETIRED_FLAG] = 1
1323 self.db.setnode(self.classname, nodeid, node)
1324 if self.do_journal:
1325 self.db.addjournal(self.classname, nodeid, 'retired', None)
1327 self.fireReactors('retire', nodeid, None)
1329 def restore(self, nodeid):
1330 '''Restpre a retired node.
1332 Make node available for all operations like it was before retirement.
1333 '''
1334 if self.db.journaltag is None:
1335 raise DatabaseError, 'Database open read-only'
1337 node = self.db.getnode(self.classname, nodeid)
1338 # check if key property was overrided
1339 key = self.getkey()
1340 try:
1341 id = self.lookup(node[key])
1342 except KeyError:
1343 pass
1344 else:
1345 raise KeyError, "Key property (%s) of retired node clashes with \
1346 existing one (%s)" % (key, node[key])
1347 # Now we can safely restore node
1348 self.fireAuditors('restore', nodeid, None)
1349 del node[self.db.RETIRED_FLAG]
1350 self.db.setnode(self.classname, nodeid, node)
1351 if self.do_journal:
1352 self.db.addjournal(self.classname, nodeid, 'restored', None)
1354 self.fireReactors('restore', nodeid, None)
1356 def is_retired(self, nodeid, cldb=None):
1357 '''Return true if the node is retired.
1358 '''
1359 node = self.db.getnode(self.classname, nodeid, cldb)
1360 if node.has_key(self.db.RETIRED_FLAG):
1361 return 1
1362 return 0
1364 def destroy(self, nodeid):
1365 '''Destroy a node.
1367 WARNING: this method should never be used except in extremely rare
1368 situations where there could never be links to the node being
1369 deleted
1371 WARNING: use retire() instead
1373 WARNING: the properties of this node will not be available ever again
1375 WARNING: really, use retire() instead
1377 Well, I think that's enough warnings. This method exists mostly to
1378 support the session storage of the cgi interface.
1379 '''
1380 if self.db.journaltag is None:
1381 raise DatabaseError, 'Database open read-only'
1382 self.db.destroynode(self.classname, nodeid)
1384 def history(self, nodeid):
1385 '''Retrieve the journal of edits on a particular node.
1387 'nodeid' must be the id of an existing node of this class or an
1388 IndexError is raised.
1390 The returned list contains tuples of the form
1392 (nodeid, date, tag, action, params)
1394 'date' is a Timestamp object specifying the time of the change and
1395 'tag' is the journaltag specified when the database was opened.
1396 '''
1397 if not self.do_journal:
1398 raise ValueError, 'Journalling is disabled for this class'
1399 return self.db.getjournal(self.classname, nodeid)
1401 # Locating nodes:
1402 def hasnode(self, nodeid):
1403 '''Determine if the given nodeid actually exists
1404 '''
1405 return self.db.hasnode(self.classname, nodeid)
1407 def setkey(self, propname):
1408 '''Select a String property of this class to be the key property.
1410 'propname' must be the name of a String property of this class or
1411 None, or a TypeError is raised. The values of the key property on
1412 all existing nodes must be unique or a ValueError is raised. If the
1413 property doesn't exist, KeyError is raised.
1414 '''
1415 prop = self.getprops()[propname]
1416 if not isinstance(prop, String):
1417 raise TypeError, 'key properties must be String'
1418 self.key = propname
1420 def getkey(self):
1421 '''Return the name of the key property for this class or None.'''
1422 return self.key
1424 def labelprop(self, default_to_id=0):
1425 '''Return the property name for a label for the given node.
1427 This method attempts to generate a consistent label for the node.
1428 It tries the following in order:
1430 1. key property
1431 2. "name" property
1432 3. "title" property
1433 4. first property from the sorted property name list
1434 '''
1435 k = self.getkey()
1436 if k:
1437 return k
1438 props = self.getprops()
1439 if props.has_key('name'):
1440 return 'name'
1441 elif props.has_key('title'):
1442 return 'title'
1443 if default_to_id:
1444 return 'id'
1445 props = props.keys()
1446 props.sort()
1447 return props[0]
1449 # TODO: set up a separate index db file for this? profile?
1450 def lookup(self, keyvalue):
1451 '''Locate a particular node by its key property and return its id.
1453 If this class has no key property, a TypeError is raised. If the
1454 'keyvalue' matches one of the values for the key property among
1455 the nodes in this class, the matching node's id is returned;
1456 otherwise a KeyError is raised.
1457 '''
1458 if not self.key:
1459 raise TypeError, 'No key property set for class %s'%self.classname
1460 cldb = self.db.getclassdb(self.classname)
1461 try:
1462 for nodeid in self.getnodeids(cldb):
1463 node = self.db.getnode(self.classname, nodeid, cldb)
1464 if node.has_key(self.db.RETIRED_FLAG):
1465 continue
1466 if node[self.key] == keyvalue:
1467 return nodeid
1468 finally:
1469 cldb.close()
1470 raise KeyError, 'No key (%s) value "%s" for "%s"'%(self.key,
1471 keyvalue, self.classname)
1473 # change from spec - allows multiple props to match
1474 def find(self, **propspec):
1475 '''Get the ids of items in this class which link to the given items.
1477 'propspec' consists of keyword args propname=itemid or
1478 propname={itemid:1, }
1479 'propname' must be the name of a property in this class, or a
1480 KeyError is raised. That property must be a Link or
1481 Multilink property, or a TypeError is raised.
1483 Any item in this class whose 'propname' property links to any of the
1484 itemids will be returned. Used by the full text indexing, which knows
1485 that "foo" occurs in msg1, msg3 and file7, so we have hits on these
1486 issues:
1488 db.issue.find(messages={'1':1,'3':1}, files={'7':1})
1489 '''
1490 propspec = propspec.items()
1491 for propname, itemids in propspec:
1492 # check the prop is OK
1493 prop = self.properties[propname]
1494 if not isinstance(prop, Link) and not isinstance(prop, Multilink):
1495 raise TypeError, "'%s' not a Link/Multilink property"%propname
1497 # ok, now do the find
1498 cldb = self.db.getclassdb(self.classname)
1499 l = []
1500 try:
1501 for id in self.getnodeids(db=cldb):
1502 item = self.db.getnode(self.classname, id, db=cldb)
1503 if item.has_key(self.db.RETIRED_FLAG):
1504 continue
1505 for propname, itemids in propspec:
1506 # can't test if the item doesn't have this property
1507 if not item.has_key(propname):
1508 continue
1509 if type(itemids) is not type({}):
1510 itemids = {itemids:1}
1512 # grab the property definition and its value on this item
1513 prop = self.properties[propname]
1514 value = item[propname]
1515 if isinstance(prop, Link) and itemids.has_key(value):
1516 l.append(id)
1517 break
1518 elif isinstance(prop, Multilink):
1519 hit = 0
1520 for v in value:
1521 if itemids.has_key(v):
1522 l.append(id)
1523 hit = 1
1524 break
1525 if hit:
1526 break
1527 finally:
1528 cldb.close()
1529 return l
1531 def stringFind(self, **requirements):
1532 '''Locate a particular node by matching a set of its String
1533 properties in a caseless search.
1535 If the property is not a String property, a TypeError is raised.
1537 The return is a list of the id of all nodes that match.
1538 '''
1539 for propname in requirements.keys():
1540 prop = self.properties[propname]
1541 if not isinstance(prop, String):
1542 raise TypeError, "'%s' not a String property"%propname
1543 requirements[propname] = requirements[propname].lower()
1544 l = []
1545 cldb = self.db.getclassdb(self.classname)
1546 try:
1547 for nodeid in self.getnodeids(cldb):
1548 node = self.db.getnode(self.classname, nodeid, cldb)
1549 if node.has_key(self.db.RETIRED_FLAG):
1550 continue
1551 for key, value in requirements.items():
1552 if not node.has_key(key):
1553 break
1554 if node[key] is None or node[key].lower() != value:
1555 break
1556 else:
1557 l.append(nodeid)
1558 finally:
1559 cldb.close()
1560 return l
1562 def list(self):
1563 ''' Return a list of the ids of the active nodes in this class.
1564 '''
1565 l = []
1566 cn = self.classname
1567 cldb = self.db.getclassdb(cn)
1568 try:
1569 for nodeid in self.getnodeids(cldb):
1570 node = self.db.getnode(cn, nodeid, cldb)
1571 if node.has_key(self.db.RETIRED_FLAG):
1572 continue
1573 l.append(nodeid)
1574 finally:
1575 cldb.close()
1576 l.sort()
1577 return l
1579 def getnodeids(self, db=None):
1580 ''' Return a list of ALL nodeids
1581 '''
1582 if __debug__:
1583 print >>hyperdb.DEBUG, 'getnodeids', (self, self.classname, db)
1585 res = []
1587 # start off with the new nodes
1588 if self.db.newnodes.has_key(self.classname):
1589 res += self.db.newnodes[self.classname].keys()
1591 if db is None:
1592 db = self.db.getclassdb(self.classname)
1593 res = res + db.keys()
1595 # remove the uncommitted, destroyed nodes
1596 if self.db.destroyednodes.has_key(self.classname):
1597 for nodeid in self.db.destroyednodes[self.classname].keys():
1598 if db.has_key(nodeid):
1599 res.remove(nodeid)
1601 return res
1603 def filter(self, search_matches, filterspec, sort=(None,None),
1604 group=(None,None), num_re = re.compile('^\d+$')):
1605 """Return a list of the ids of the active nodes in this class that
1606 match the 'filter' spec, sorted by the group spec and then the
1607 sort spec.
1609 "filterspec" is {propname: value(s)}
1611 "sort" and "group" are (dir, prop) where dir is '+', '-' or None
1612 and prop is a prop name or None
1614 "search_matches" is {nodeid: marker}
1616 The filter must match all properties specificed - but if the
1617 property value to match is a list, any one of the values in the
1618 list may match for that property to match. Unless the property
1619 is a Multilink, in which case the item's property list must
1620 match the filterspec list.
1621 """
1622 cn = self.classname
1624 # optimise filterspec
1625 l = []
1626 props = self.getprops()
1627 LINK = 0
1628 MULTILINK = 1
1629 STRING = 2
1630 DATE = 3
1631 INTERVAL = 4
1632 OTHER = 6
1634 timezone = self.db.getUserTimezone()
1635 for k, v in filterspec.items():
1636 propclass = props[k]
1637 if isinstance(propclass, Link):
1638 if type(v) is not type([]):
1639 v = [v]
1640 u = []
1641 for entry in v:
1642 # the value -1 is a special "not set" sentinel
1643 if entry == '-1':
1644 entry = None
1645 u.append(entry)
1646 l.append((LINK, k, u))
1647 elif isinstance(propclass, Multilink):
1648 # the value -1 is a special "not set" sentinel
1649 if v in ('-1', ['-1']):
1650 v = []
1651 elif type(v) is not type([]):
1652 v = [v]
1653 l.append((MULTILINK, k, v))
1654 elif isinstance(propclass, String) and k != 'id':
1655 if type(v) is not type([]):
1656 v = [v]
1657 m = []
1658 for v in v:
1659 # simple glob searching
1660 v = re.sub(r'([\|\{\}\\\.\+\[\]\(\)])', r'\\\1', v)
1661 v = v.replace('?', '.')
1662 v = v.replace('*', '.*?')
1663 m.append(v)
1664 m = re.compile('(%s)'%('|'.join(m)), re.I)
1665 l.append((STRING, k, m))
1666 elif isinstance(propclass, Date):
1667 try:
1668 date_rng = Range(v, date.Date, offset=timezone)
1669 l.append((DATE, k, date_rng))
1670 except ValueError:
1671 # If range creation fails - ignore that search parameter
1672 pass
1673 elif isinstance(propclass, Interval):
1674 try:
1675 intv_rng = Range(v, date.Interval)
1676 l.append((INTERVAL, k, intv_rng))
1677 except ValueError:
1678 # If range creation fails - ignore that search parameter
1679 pass
1681 elif isinstance(propclass, Boolean):
1682 if type(v) is type(''):
1683 bv = v.lower() in ('yes', 'true', 'on', '1')
1684 else:
1685 bv = v
1686 l.append((OTHER, k, bv))
1687 elif isinstance(propclass, Number):
1688 l.append((OTHER, k, int(v)))
1689 else:
1690 l.append((OTHER, k, v))
1691 filterspec = l
1693 # now, find all the nodes that are active and pass filtering
1694 l = []
1695 cldb = self.db.getclassdb(cn)
1696 try:
1697 # TODO: only full-scan once (use items())
1698 for nodeid in self.getnodeids(cldb):
1699 node = self.db.getnode(cn, nodeid, cldb)
1700 if node.has_key(self.db.RETIRED_FLAG):
1701 continue
1702 # apply filter
1703 for t, k, v in filterspec:
1704 # handle the id prop
1705 if k == 'id' and v == nodeid:
1706 continue
1708 # make sure the node has the property
1709 if not node.has_key(k):
1710 # this node doesn't have this property, so reject it
1711 break
1713 # now apply the property filter
1714 if t == LINK:
1715 # link - if this node's property doesn't appear in the
1716 # filterspec's nodeid list, skip it
1717 if node[k] not in v:
1718 break
1719 elif t == MULTILINK:
1720 # multilink - if any of the nodeids required by the
1721 # filterspec aren't in this node's property, then skip
1722 # it
1723 have = node[k]
1724 # check for matching the absence of multilink values
1725 if not v and have:
1726 break
1728 # othewise, make sure this node has each of the
1729 # required values
1730 for want in v:
1731 if want not in have:
1732 break
1733 else:
1734 continue
1735 break
1736 elif t == STRING:
1737 if node[k] is None:
1738 break
1739 # RE search
1740 if not v.search(node[k]):
1741 break
1742 elif t == DATE or t == INTERVAL:
1743 if node[k] is None:
1744 break
1745 if v.to_value:
1746 if not (v.from_value <= node[k] and v.to_value >= node[k]):
1747 break
1748 else:
1749 if not (v.from_value <= node[k]):
1750 break
1751 elif t == OTHER:
1752 # straight value comparison for the other types
1753 if node[k] != v:
1754 break
1755 else:
1756 l.append((nodeid, node))
1757 finally:
1758 cldb.close()
1759 l.sort()
1761 # filter based on full text search
1762 if search_matches is not None:
1763 k = []
1764 for v in l:
1765 if search_matches.has_key(v[0]):
1766 k.append(v)
1767 l = k
1769 # now, sort the result
1770 def sortfun(a, b, sort=sort, group=group, properties=self.getprops(),
1771 db = self.db, cl=self):
1772 a_id, an = a
1773 b_id, bn = b
1774 # sort by group and then sort
1775 for dir, prop in group, sort:
1776 if dir is None or prop is None: continue
1778 # sorting is class-specific
1779 propclass = properties[prop]
1781 # handle the properties that might be "faked"
1782 # also, handle possible missing properties
1783 try:
1784 if not an.has_key(prop):
1785 an[prop] = cl.get(a_id, prop)
1786 av = an[prop]
1787 except KeyError:
1788 # the node doesn't have a value for this property
1789 if isinstance(propclass, Multilink): av = []
1790 else: av = ''
1791 try:
1792 if not bn.has_key(prop):
1793 bn[prop] = cl.get(b_id, prop)
1794 bv = bn[prop]
1795 except KeyError:
1796 # the node doesn't have a value for this property
1797 if isinstance(propclass, Multilink): bv = []
1798 else: bv = ''
1800 # String and Date values are sorted in the natural way
1801 if isinstance(propclass, String):
1802 # clean up the strings
1803 if av and av[0] in string.uppercase:
1804 av = av.lower()
1805 if bv and bv[0] in string.uppercase:
1806 bv = bv.lower()
1807 if (isinstance(propclass, String) or
1808 isinstance(propclass, Date)):
1809 # it might be a string that's really an integer
1810 try:
1811 av = int(av)
1812 bv = int(bv)
1813 except:
1814 pass
1815 if dir == '+':
1816 r = cmp(av, bv)
1817 if r != 0: return r
1818 elif dir == '-':
1819 r = cmp(bv, av)
1820 if r != 0: return r
1822 # Link properties are sorted according to the value of
1823 # the "order" property on the linked nodes if it is
1824 # present; or otherwise on the key string of the linked
1825 # nodes; or finally on the node ids.
1826 elif isinstance(propclass, Link):
1827 link = db.classes[propclass.classname]
1828 if av is None and bv is not None: return -1
1829 if av is not None and bv is None: return 1
1830 if av is None and bv is None: continue
1831 if link.getprops().has_key('order'):
1832 if dir == '+':
1833 r = cmp(link.get(av, 'order'),
1834 link.get(bv, 'order'))
1835 if r != 0: return r
1836 elif dir == '-':
1837 r = cmp(link.get(bv, 'order'),
1838 link.get(av, 'order'))
1839 if r != 0: return r
1840 elif link.getkey():
1841 key = link.getkey()
1842 if dir == '+':
1843 r = cmp(link.get(av, key), link.get(bv, key))
1844 if r != 0: return r
1845 elif dir == '-':
1846 r = cmp(link.get(bv, key), link.get(av, key))
1847 if r != 0: return r
1848 else:
1849 if dir == '+':
1850 r = cmp(av, bv)
1851 if r != 0: return r
1852 elif dir == '-':
1853 r = cmp(bv, av)
1854 if r != 0: return r
1856 else:
1857 # all other types just compare
1858 if dir == '+':
1859 r = cmp(av, bv)
1860 elif dir == '-':
1861 r = cmp(bv, av)
1862 if r != 0: return r
1864 # end for dir, prop in sort, group:
1865 # if all else fails, compare the ids
1866 return cmp(a[0], b[0])
1868 l.sort(sortfun)
1869 return [i[0] for i in l]
1871 def count(self):
1872 '''Get the number of nodes in this class.
1874 If the returned integer is 'numnodes', the ids of all the nodes
1875 in this class run from 1 to numnodes, and numnodes+1 will be the
1876 id of the next node to be created in this class.
1877 '''
1878 return self.db.countnodes(self.classname)
1880 # Manipulating properties:
1882 def getprops(self, protected=1):
1883 '''Return a dictionary mapping property names to property objects.
1884 If the "protected" flag is true, we include protected properties -
1885 those which may not be modified.
1887 In addition to the actual properties on the node, these
1888 methods provide the "creation" and "activity" properties. If the
1889 "protected" flag is true, we include protected properties - those
1890 which may not be modified.
1891 '''
1892 d = self.properties.copy()
1893 if protected:
1894 d['id'] = String()
1895 d['creation'] = hyperdb.Date()
1896 d['activity'] = hyperdb.Date()
1897 d['creator'] = hyperdb.Link('user')
1898 return d
1900 def addprop(self, **properties):
1901 '''Add properties to this class.
1903 The keyword arguments in 'properties' must map names to property
1904 objects, or a TypeError is raised. None of the keys in 'properties'
1905 may collide with the names of existing properties, or a ValueError
1906 is raised before any properties have been added.
1907 '''
1908 for key in properties.keys():
1909 if self.properties.has_key(key):
1910 raise ValueError, key
1911 self.properties.update(properties)
1913 def index(self, nodeid):
1914 '''Add (or refresh) the node to search indexes
1915 '''
1916 # find all the String properties that have indexme
1917 for prop, propclass in self.getprops().items():
1918 if isinstance(propclass, String) and propclass.indexme:
1919 try:
1920 value = str(self.get(nodeid, prop))
1921 except IndexError:
1922 # node no longer exists - entry should be removed
1923 self.db.indexer.purge_entry((self.classname, nodeid, prop))
1924 else:
1925 # and index them under (classname, nodeid, property)
1926 self.db.indexer.add_text((self.classname, nodeid, prop),
1927 value)
1929 #
1930 # Detector interface
1931 #
1932 def audit(self, event, detector):
1933 '''Register a detector
1934 '''
1935 l = self.auditors[event]
1936 if detector not in l:
1937 self.auditors[event].append(detector)
1939 def fireAuditors(self, action, nodeid, newvalues):
1940 '''Fire all registered auditors.
1941 '''
1942 for audit in self.auditors[action]:
1943 audit(self.db, self, nodeid, newvalues)
1945 def react(self, event, detector):
1946 '''Register a detector
1947 '''
1948 l = self.reactors[event]
1949 if detector not in l:
1950 self.reactors[event].append(detector)
1952 def fireReactors(self, action, nodeid, oldvalues):
1953 '''Fire all registered reactors.
1954 '''
1955 for react in self.reactors[action]:
1956 react(self.db, self, nodeid, oldvalues)
1958 class FileClass(Class, hyperdb.FileClass):
1959 '''This class defines a large chunk of data. To support this, it has a
1960 mandatory String property "content" which is typically saved off
1961 externally to the hyperdb.
1963 The default MIME type of this data is defined by the
1964 "default_mime_type" class attribute, which may be overridden by each
1965 node if the class defines a "type" String property.
1966 '''
1967 default_mime_type = 'text/plain'
1969 def create(self, **propvalues):
1970 ''' Snarf the "content" propvalue and store in a file
1971 '''
1972 # we need to fire the auditors now, or the content property won't
1973 # be in propvalues for the auditors to play with
1974 self.fireAuditors('create', None, propvalues)
1976 # now remove the content property so it's not stored in the db
1977 content = propvalues['content']
1978 del propvalues['content']
1980 # do the database create
1981 newid = Class.create_inner(self, **propvalues)
1983 # fire reactors
1984 self.fireReactors('create', newid, None)
1986 # store off the content as a file
1987 self.db.storefile(self.classname, newid, None, content)
1988 return newid
1990 def import_list(self, propnames, proplist):
1991 ''' Trap the "content" property...
1992 '''
1993 # dupe this list so we don't affect others
1994 propnames = propnames[:]
1996 # extract the "content" property from the proplist
1997 i = propnames.index('content')
1998 content = eval(proplist[i])
1999 del propnames[i]
2000 del proplist[i]
2002 # do the normal import
2003 newid = Class.import_list(self, propnames, proplist)
2005 # save off the "content" file
2006 self.db.storefile(self.classname, newid, None, content)
2007 return newid
2009 def get(self, nodeid, propname, default=_marker, cache=1):
2010 ''' Trap the content propname and get it from the file
2012 'cache' exists for backwards compatibility, and is not used.
2013 '''
2014 poss_msg = 'Possibly an access right configuration problem.'
2015 if propname == 'content':
2016 try:
2017 return self.db.getfile(self.classname, nodeid, None)
2018 except IOError, (strerror):
2019 # XXX by catching this we donot see an error in the log.
2020 return 'ERROR reading file: %s%s\n%s\n%s'%(
2021 self.classname, nodeid, poss_msg, strerror)
2022 if default is not _marker:
2023 return Class.get(self, nodeid, propname, default)
2024 else:
2025 return Class.get(self, nodeid, propname)
2027 def getprops(self, protected=1):
2028 ''' In addition to the actual properties on the node, these methods
2029 provide the "content" property. If the "protected" flag is true,
2030 we include protected properties - those which may not be
2031 modified.
2032 '''
2033 d = Class.getprops(self, protected=protected).copy()
2034 d['content'] = hyperdb.String()
2035 return d
2037 def index(self, nodeid):
2038 ''' Index the node in the search index.
2040 We want to index the content in addition to the normal String
2041 property indexing.
2042 '''
2043 # perform normal indexing
2044 Class.index(self, nodeid)
2046 # get the content to index
2047 content = self.get(nodeid, 'content')
2049 # figure the mime type
2050 if self.properties.has_key('type'):
2051 mime_type = self.get(nodeid, 'type')
2052 else:
2053 mime_type = self.default_mime_type
2055 # and index!
2056 self.db.indexer.add_text((self.classname, nodeid, 'content'), content,
2057 mime_type)
2059 # deviation from spec - was called ItemClass
2060 class IssueClass(Class, roundupdb.IssueClass):
2061 # Overridden methods:
2062 def __init__(self, db, classname, **properties):
2063 '''The newly-created class automatically includes the "messages",
2064 "files", "nosy", and "superseder" properties. If the 'properties'
2065 dictionary attempts to specify any of these properties or a
2066 "creation" or "activity" property, a ValueError is raised.
2067 '''
2068 if not properties.has_key('title'):
2069 properties['title'] = hyperdb.String(indexme='yes')
2070 if not properties.has_key('messages'):
2071 properties['messages'] = hyperdb.Multilink("msg")
2072 if not properties.has_key('files'):
2073 properties['files'] = hyperdb.Multilink("file")
2074 if not properties.has_key('nosy'):
2075 # note: journalling is turned off as it really just wastes
2076 # space. this behaviour may be overridden in an instance
2077 properties['nosy'] = hyperdb.Multilink("user", do_journal="no")
2078 if not properties.has_key('superseder'):
2079 properties['superseder'] = hyperdb.Multilink(classname)
2080 Class.__init__(self, db, classname, **properties)
2082 #