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