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