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