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.81 2002-09-19 02:37:41 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
30 from roundup.indexer import Indexer
31 from locking import acquire_lock, release_lock
32 from roundup.hyperdb import String, Password, Date, Interval, Link, \
33 Multilink, DatabaseError, Boolean, Number
35 #
36 # Now the database
37 #
38 class Database(FileStorage, hyperdb.Database, roundupdb.Database):
39 '''A database for storing records containing flexible data types.
41 Transaction stuff TODO:
42 . check the timestamp of the class file and nuke the cache if it's
43 modified. Do some sort of conflict checking on the dirty stuff.
44 . perhaps detect write collisions (related to above)?
46 '''
47 def __init__(self, config, journaltag=None):
48 '''Open a hyperdatabase given a specifier to some storage.
50 The 'storagelocator' is obtained from config.DATABASE.
51 The meaning of 'storagelocator' depends on the particular
52 implementation of the hyperdatabase. It could be a file name,
53 a directory path, a socket descriptor for a connection to a
54 database over the network, etc.
56 The 'journaltag' is a token that will be attached to the journal
57 entries for any edits done on the database. If 'journaltag' is
58 None, the database is opened in read-only mode: the Class.create(),
59 Class.set(), and Class.retire() methods are disabled.
60 '''
61 self.config, self.journaltag = config, journaltag
62 self.dir = config.DATABASE
63 self.classes = {}
64 self.cache = {} # cache of nodes loaded or created
65 self.dirtynodes = {} # keep track of the dirty nodes by class
66 self.newnodes = {} # keep track of the new nodes by class
67 self.destroyednodes = {}# keep track of the destroyed nodes by class
68 self.transactions = []
69 self.indexer = Indexer(self.dir)
70 self.sessions = Sessions(self.config)
71 self.security = security.Security(self)
72 # ensure files are group readable and writable
73 os.umask(0002)
75 def post_init(self):
76 '''Called once the schema initialisation has finished.'''
77 # reindex the db if necessary
78 if self.indexer.should_reindex():
79 self.reindex()
81 def reindex(self):
82 for klass in self.classes.values():
83 for nodeid in klass.list():
84 klass.index(nodeid)
85 self.indexer.save_index()
87 def __repr__(self):
88 return '<back_anydbm instance at %x>'%id(self)
90 #
91 # Classes
92 #
93 def __getattr__(self, classname):
94 '''A convenient way of calling self.getclass(classname).'''
95 if self.classes.has_key(classname):
96 if __debug__:
97 print >>hyperdb.DEBUG, '__getattr__', (self, classname)
98 return self.classes[classname]
99 raise AttributeError, classname
101 def addclass(self, cl):
102 if __debug__:
103 print >>hyperdb.DEBUG, 'addclass', (self, cl)
104 cn = cl.classname
105 if self.classes.has_key(cn):
106 raise ValueError, cn
107 self.classes[cn] = cl
109 def getclasses(self):
110 '''Return a list of the names of all existing classes.'''
111 if __debug__:
112 print >>hyperdb.DEBUG, 'getclasses', (self,)
113 l = self.classes.keys()
114 l.sort()
115 return l
117 def getclass(self, classname):
118 '''Get the Class object representing a particular class.
120 If 'classname' is not a valid class name, a KeyError is raised.
121 '''
122 if __debug__:
123 print >>hyperdb.DEBUG, 'getclass', (self, classname)
124 try:
125 return self.classes[classname]
126 except KeyError:
127 raise KeyError, 'There is no class called "%s"'%classname
129 #
130 # Class DBs
131 #
132 def clear(self):
133 '''Delete all database contents
134 '''
135 if __debug__:
136 print >>hyperdb.DEBUG, 'clear', (self,)
137 for cn in self.classes.keys():
138 for dummy in 'nodes', 'journals':
139 path = os.path.join(self.dir, 'journals.%s'%cn)
140 if os.path.exists(path):
141 os.remove(path)
142 elif os.path.exists(path+'.db'): # dbm appends .db
143 os.remove(path+'.db')
145 def getclassdb(self, classname, mode='r'):
146 ''' grab a connection to the class db that will be used for
147 multiple actions
148 '''
149 if __debug__:
150 print >>hyperdb.DEBUG, 'getclassdb', (self, classname, mode)
151 return self.opendb('nodes.%s'%classname, mode)
153 def determine_db_type(self, path):
154 ''' determine which DB wrote the class file
155 '''
156 db_type = ''
157 if os.path.exists(path):
158 db_type = whichdb.whichdb(path)
159 if not db_type:
160 raise DatabaseError, "Couldn't identify database type"
161 elif os.path.exists(path+'.db'):
162 # if the path ends in '.db', it's a dbm database, whether
163 # anydbm says it's dbhash or not!
164 db_type = 'dbm'
165 return db_type
167 def opendb(self, name, mode):
168 '''Low-level database opener that gets around anydbm/dbm
169 eccentricities.
170 '''
171 if __debug__:
172 print >>hyperdb.DEBUG, 'opendb', (self, name, mode)
174 # figure the class db type
175 path = os.path.join(os.getcwd(), self.dir, name)
176 db_type = self.determine_db_type(path)
178 # new database? let anydbm pick the best dbm
179 if not db_type:
180 if __debug__:
181 print >>hyperdb.DEBUG, "opendb anydbm.open(%r, 'c')"%path
182 return anydbm.open(path, 'c')
184 # open the database with the correct module
185 try:
186 dbm = __import__(db_type)
187 except ImportError:
188 raise DatabaseError, \
189 "Couldn't open database - the required module '%s'"\
190 " is not available"%db_type
191 if __debug__:
192 print >>hyperdb.DEBUG, "opendb %r.open(%r, %r)"%(db_type, path,
193 mode)
194 return dbm.open(path, mode)
196 def lockdb(self, name):
197 ''' Lock a database file
198 '''
199 path = os.path.join(os.getcwd(), self.dir, '%s.lock'%name)
200 return acquire_lock(path)
202 #
203 # Node IDs
204 #
205 def newid(self, classname):
206 ''' Generate a new id for the given class
207 '''
208 # open the ids DB - create if if doesn't exist
209 lock = self.lockdb('_ids')
210 db = self.opendb('_ids', 'c')
211 if db.has_key(classname):
212 newid = db[classname] = str(int(db[classname]) + 1)
213 else:
214 # the count() bit is transitional - older dbs won't start at 1
215 newid = str(self.getclass(classname).count()+1)
216 db[classname] = newid
217 db.close()
218 release_lock(lock)
219 return newid
221 def setid(self, classname, setid):
222 ''' Set the id counter: used during import of database
223 '''
224 # open the ids DB - create if if doesn't exist
225 lock = self.lockdb('_ids')
226 db = self.opendb('_ids', 'c')
227 db[classname] = str(setid)
228 db.close()
229 release_lock(lock)
231 #
232 # Nodes
233 #
234 def addnode(self, classname, nodeid, node):
235 ''' add the specified node to its class's db
236 '''
237 if __debug__:
238 print >>hyperdb.DEBUG, 'addnode', (self, classname, nodeid, node)
239 self.newnodes.setdefault(classname, {})[nodeid] = 1
240 self.cache.setdefault(classname, {})[nodeid] = node
241 self.savenode(classname, nodeid, node)
243 def setnode(self, classname, nodeid, node):
244 ''' change the specified node
245 '''
246 if __debug__:
247 print >>hyperdb.DEBUG, 'setnode', (self, classname, nodeid, node)
248 self.dirtynodes.setdefault(classname, {})[nodeid] = 1
250 # can't set without having already loaded the node
251 self.cache[classname][nodeid] = node
252 self.savenode(classname, nodeid, node)
254 def savenode(self, classname, nodeid, node):
255 ''' perform the saving of data specified by the set/addnode
256 '''
257 if __debug__:
258 print >>hyperdb.DEBUG, 'savenode', (self, classname, nodeid, node)
259 self.transactions.append((self.doSaveNode, (classname, nodeid, node)))
261 def getnode(self, classname, nodeid, db=None, cache=1):
262 ''' get a node from the database
263 '''
264 if __debug__:
265 print >>hyperdb.DEBUG, 'getnode', (self, classname, nodeid, db)
266 if cache:
267 # try the cache
268 cache_dict = self.cache.setdefault(classname, {})
269 if cache_dict.has_key(nodeid):
270 if __debug__:
271 print >>hyperdb.TRACE, 'get %s %s cached'%(classname,
272 nodeid)
273 return cache_dict[nodeid]
275 if __debug__:
276 print >>hyperdb.TRACE, 'get %s %s'%(classname, nodeid)
278 # get from the database and save in the cache
279 if db is None:
280 db = self.getclassdb(classname)
281 if not db.has_key(nodeid):
282 raise IndexError, "no such %s %s"%(classname, nodeid)
284 # check the uncommitted, destroyed nodes
285 if (self.destroyednodes.has_key(classname) and
286 self.destroyednodes[classname].has_key(nodeid)):
287 raise IndexError, "no such %s %s"%(classname, nodeid)
289 # decode
290 res = marshal.loads(db[nodeid])
292 # reverse the serialisation
293 res = self.unserialise(classname, res)
295 # store off in the cache dict
296 if cache:
297 cache_dict[nodeid] = res
299 return res
301 def destroynode(self, classname, nodeid):
302 '''Remove a node from the database. Called exclusively by the
303 destroy() method on Class.
304 '''
305 if __debug__:
306 print >>hyperdb.DEBUG, 'destroynode', (self, classname, nodeid)
308 # remove from cache and newnodes if it's there
309 if (self.cache.has_key(classname) and
310 self.cache[classname].has_key(nodeid)):
311 del self.cache[classname][nodeid]
312 if (self.newnodes.has_key(classname) and
313 self.newnodes[classname].has_key(nodeid)):
314 del self.newnodes[classname][nodeid]
316 # see if there's any obvious commit actions that we should get rid of
317 for entry in self.transactions[:]:
318 if entry[1][:2] == (classname, nodeid):
319 self.transactions.remove(entry)
321 # add to the destroyednodes map
322 self.destroyednodes.setdefault(classname, {})[nodeid] = 1
324 # add the destroy commit action
325 self.transactions.append((self.doDestroyNode, (classname, nodeid)))
327 def serialise(self, classname, node):
328 '''Copy the node contents, converting non-marshallable data into
329 marshallable data.
330 '''
331 if __debug__:
332 print >>hyperdb.DEBUG, 'serialise', classname, node
333 properties = self.getclass(classname).getprops()
334 d = {}
335 for k, v in node.items():
336 # if the property doesn't exist, or is the "retired" flag then
337 # it won't be in the properties dict
338 if not properties.has_key(k):
339 d[k] = v
340 continue
342 # get the property spec
343 prop = properties[k]
345 if isinstance(prop, Password):
346 d[k] = str(v)
347 elif isinstance(prop, Date) and v is not None:
348 d[k] = v.serialise()
349 elif isinstance(prop, Interval) and v is not None:
350 d[k] = v.serialise()
351 else:
352 d[k] = v
353 return d
355 def unserialise(self, classname, node):
356 '''Decode the marshalled node data
357 '''
358 if __debug__:
359 print >>hyperdb.DEBUG, 'unserialise', classname, node
360 properties = self.getclass(classname).getprops()
361 d = {}
362 for k, v in node.items():
363 # if the property doesn't exist, or is the "retired" flag then
364 # it won't be in the properties dict
365 if not properties.has_key(k):
366 d[k] = v
367 continue
369 # get the property spec
370 prop = properties[k]
372 if isinstance(prop, Date) and v is not None:
373 d[k] = date.Date(v)
374 elif isinstance(prop, Interval) and v is not None:
375 d[k] = date.Interval(v)
376 elif isinstance(prop, Password):
377 p = password.Password()
378 p.unpack(v)
379 d[k] = p
380 else:
381 d[k] = v
382 return d
384 def hasnode(self, classname, nodeid, db=None):
385 ''' determine if the database has a given node
386 '''
387 if __debug__:
388 print >>hyperdb.DEBUG, 'hasnode', (self, classname, nodeid, db)
390 # try the cache
391 cache = self.cache.setdefault(classname, {})
392 if cache.has_key(nodeid):
393 if __debug__:
394 print >>hyperdb.TRACE, 'has %s %s cached'%(classname, nodeid)
395 return 1
396 if __debug__:
397 print >>hyperdb.TRACE, 'has %s %s'%(classname, nodeid)
399 # not in the cache - check the database
400 if db is None:
401 db = self.getclassdb(classname)
402 res = db.has_key(nodeid)
403 return res
405 def countnodes(self, classname, db=None):
406 if __debug__:
407 print >>hyperdb.DEBUG, 'countnodes', (self, classname, db)
409 count = 0
411 # include the uncommitted nodes
412 if self.newnodes.has_key(classname):
413 count += len(self.newnodes[classname])
414 if self.destroyednodes.has_key(classname):
415 count -= len(self.destroyednodes[classname])
417 # and count those in the DB
418 if db is None:
419 db = self.getclassdb(classname)
420 count = count + len(db.keys())
421 return count
423 def getnodeids(self, classname, db=None):
424 if __debug__:
425 print >>hyperdb.DEBUG, 'getnodeids', (self, classname, db)
427 res = []
429 # start off with the new nodes
430 if self.newnodes.has_key(classname):
431 res += self.newnodes[classname].keys()
433 if db is None:
434 db = self.getclassdb(classname)
435 res = res + db.keys()
437 # remove the uncommitted, destroyed nodes
438 if self.destroyednodes.has_key(classname):
439 for nodeid in self.destroyednodes[classname].keys():
440 if db.has_key(nodeid):
441 res.remove(nodeid)
443 return res
446 #
447 # Files - special node properties
448 # inherited from FileStorage
450 #
451 # Journal
452 #
453 def addjournal(self, classname, nodeid, action, params, creator=None,
454 creation=None):
455 ''' Journal the Action
456 'action' may be:
458 'create' or 'set' -- 'params' is a dictionary of property values
459 'link' or 'unlink' -- 'params' is (classname, nodeid, propname)
460 'retire' -- 'params' is None
461 '''
462 if __debug__:
463 print >>hyperdb.DEBUG, 'addjournal', (self, classname, nodeid,
464 action, params, creator, creation)
465 self.transactions.append((self.doSaveJournal, (classname, nodeid,
466 action, params, creator, creation)))
468 def getjournal(self, classname, nodeid):
469 ''' get the journal for id
471 Raise IndexError if the node doesn't exist (as per history()'s
472 API)
473 '''
474 if __debug__:
475 print >>hyperdb.DEBUG, 'getjournal', (self, classname, nodeid)
476 # attempt to open the journal - in some rare cases, the journal may
477 # not exist
478 try:
479 db = self.opendb('journals.%s'%classname, 'r')
480 except anydbm.error, error:
481 if str(error) == "need 'c' or 'n' flag to open new db":
482 raise IndexError, 'no such %s %s'%(classname, nodeid)
483 elif error.args[0] != 2:
484 raise
485 raise IndexError, 'no such %s %s'%(classname, nodeid)
486 try:
487 journal = marshal.loads(db[nodeid])
488 except KeyError:
489 db.close()
490 raise IndexError, 'no such %s %s'%(classname, nodeid)
491 db.close()
492 res = []
493 for nodeid, date_stamp, user, action, params in journal:
494 res.append((nodeid, date.Date(date_stamp), user, action, params))
495 return res
497 def pack(self, pack_before):
498 ''' Delete all journal entries except "create" before 'pack_before'.
499 '''
500 if __debug__:
501 print >>hyperdb.DEBUG, 'packjournal', (self, pack_before)
503 for classname in self.getclasses():
504 # get the journal db
505 db_name = 'journals.%s'%classname
506 path = os.path.join(os.getcwd(), self.dir, classname)
507 db_type = self.determine_db_type(path)
508 db = self.opendb(db_name, 'w')
510 for key in db.keys():
511 # get the journal for this db entry
512 journal = marshal.loads(db[key])
513 l = []
514 last_set_entry = None
515 for entry in journal:
516 # unpack the entry
517 (nodeid, date_stamp, self.journaltag, action,
518 params) = entry
519 date_stamp = date.Date(date_stamp)
520 # if the entry is after the pack date, _or_ the initial
521 # create entry, then it stays
522 if date_stamp > pack_before or action == 'create':
523 l.append(entry)
524 elif action == 'set':
525 # grab the last set entry to keep information on
526 # activity
527 last_set_entry = entry
528 if last_set_entry:
529 date_stamp = last_set_entry[1]
530 # if the last set entry was made after the pack date
531 # then it is already in the list
532 if date_stamp < pack_before:
533 l.append(last_set_entry)
534 db[key] = marshal.dumps(l)
535 if db_type == 'gdbm':
536 db.reorganize()
537 db.close()
540 #
541 # Basic transaction support
542 #
543 def commit(self):
544 ''' Commit the current transactions.
545 '''
546 if __debug__:
547 print >>hyperdb.DEBUG, 'commit', (self,)
548 # TODO: lock the DB
550 # keep a handle to all the database files opened
551 self.databases = {}
553 # now, do all the transactions
554 reindex = {}
555 for method, args in self.transactions:
556 reindex[method(*args)] = 1
558 # now close all the database files
559 for db in self.databases.values():
560 db.close()
561 del self.databases
562 # TODO: unlock the DB
564 # reindex the nodes that request it
565 for classname, nodeid in filter(None, reindex.keys()):
566 print >>hyperdb.DEBUG, 'commit.reindex', (classname, nodeid)
567 self.getclass(classname).index(nodeid)
569 # save the indexer state
570 self.indexer.save_index()
572 # all transactions committed, back to normal
573 self.cache = {}
574 self.dirtynodes = {}
575 self.newnodes = {}
576 self.destroyednodes = {}
577 self.transactions = []
579 def getCachedClassDB(self, classname):
580 ''' get the class db, looking in our cache of databases for commit
581 '''
582 # get the database handle
583 db_name = 'nodes.%s'%classname
584 if not self.databases.has_key(db_name):
585 self.databases[db_name] = self.getclassdb(classname, 'c')
586 return self.databases[db_name]
588 def doSaveNode(self, classname, nodeid, node):
589 if __debug__:
590 print >>hyperdb.DEBUG, 'doSaveNode', (self, classname, nodeid,
591 node)
593 db = self.getCachedClassDB(classname)
595 # now save the marshalled data
596 db[nodeid] = marshal.dumps(self.serialise(classname, node))
598 # return the classname, nodeid so we reindex this content
599 return (classname, nodeid)
601 def getCachedJournalDB(self, classname):
602 ''' get the journal db, looking in our cache of databases for commit
603 '''
604 # get the database handle
605 db_name = 'journals.%s'%classname
606 if not self.databases.has_key(db_name):
607 self.databases[db_name] = self.opendb(db_name, 'c')
608 return self.databases[db_name]
610 def doSaveJournal(self, classname, nodeid, action, params, creator,
611 creation):
612 # serialise the parameters now if necessary
613 if isinstance(params, type({})):
614 if action in ('set', 'create'):
615 params = self.serialise(classname, params)
617 # handle supply of the special journalling parameters (usually
618 # supplied on importing an existing database)
619 if creator:
620 journaltag = creator
621 else:
622 journaltag = self.journaltag
623 if creation:
624 journaldate = creation.serialise()
625 else:
626 journaldate = date.Date().serialise()
628 # create the journal entry
629 entry = (nodeid, journaldate, journaltag, action, params)
631 if __debug__:
632 print >>hyperdb.DEBUG, 'doSaveJournal', entry
634 db = self.getCachedJournalDB(classname)
636 # now insert the journal entry
637 if db.has_key(nodeid):
638 # append to existing
639 s = db[nodeid]
640 l = marshal.loads(s)
641 l.append(entry)
642 else:
643 l = [entry]
645 db[nodeid] = marshal.dumps(l)
647 def doDestroyNode(self, classname, nodeid):
648 if __debug__:
649 print >>hyperdb.DEBUG, 'doDestroyNode', (self, classname, nodeid)
651 # delete from the class database
652 db = self.getCachedClassDB(classname)
653 if db.has_key(nodeid):
654 del db[nodeid]
656 # delete from the database
657 db = self.getCachedJournalDB(classname)
658 if db.has_key(nodeid):
659 del db[nodeid]
661 # return the classname, nodeid so we reindex this content
662 return (classname, nodeid)
664 def rollback(self):
665 ''' Reverse all actions from the current transaction.
666 '''
667 if __debug__:
668 print >>hyperdb.DEBUG, 'rollback', (self, )
669 for method, args in self.transactions:
670 # delete temporary files
671 if method == self.doStoreFile:
672 self.rollbackStoreFile(*args)
673 self.cache = {}
674 self.dirtynodes = {}
675 self.newnodes = {}
676 self.destroyednodes = {}
677 self.transactions = []
679 def close(self):
680 ''' Nothing to do
681 '''
682 pass
684 _marker = []
685 class Class(hyperdb.Class):
686 '''The handle to a particular class of nodes in a hyperdatabase.'''
688 def __init__(self, db, classname, **properties):
689 '''Create a new class with a given name and property specification.
691 'classname' must not collide with the name of an existing class,
692 or a ValueError is raised. The keyword arguments in 'properties'
693 must map names to property objects, or a TypeError is raised.
694 '''
695 if (properties.has_key('creation') or properties.has_key('activity')
696 or properties.has_key('creator')):
697 raise ValueError, '"creation", "activity" and "creator" are '\
698 'reserved'
700 self.classname = classname
701 self.properties = properties
702 self.db = weakref.proxy(db) # use a weak ref to avoid circularity
703 self.key = ''
705 # should we journal changes (default yes)
706 self.do_journal = 1
708 # do the db-related init stuff
709 db.addclass(self)
711 self.auditors = {'create': [], 'set': [], 'retire': []}
712 self.reactors = {'create': [], 'set': [], 'retire': []}
714 def enableJournalling(self):
715 '''Turn journalling on for this class
716 '''
717 self.do_journal = 1
719 def disableJournalling(self):
720 '''Turn journalling off for this class
721 '''
722 self.do_journal = 0
724 # Editing nodes:
726 def create(self, **propvalues):
727 '''Create a new node of this class and return its id.
729 The keyword arguments in 'propvalues' map property names to values.
731 The values of arguments must be acceptable for the types of their
732 corresponding properties or a TypeError is raised.
734 If this class has a key property, it must be present and its value
735 must not collide with other key strings or a ValueError is raised.
737 Any other properties on this class that are missing from the
738 'propvalues' dictionary are set to None.
740 If an id in a link or multilink property does not refer to a valid
741 node, an IndexError is raised.
743 These operations trigger detectors and can be vetoed. Attempts
744 to modify the "creation" or "activity" properties cause a KeyError.
745 '''
746 if propvalues.has_key('id'):
747 raise KeyError, '"id" is reserved'
749 if self.db.journaltag is None:
750 raise DatabaseError, 'Database open read-only'
752 if propvalues.has_key('creation') or propvalues.has_key('activity'):
753 raise KeyError, '"creation" and "activity" are reserved'
755 self.fireAuditors('create', None, propvalues)
757 # new node's id
758 newid = self.db.newid(self.classname)
760 # validate propvalues
761 num_re = re.compile('^\d+$')
762 for key, value in propvalues.items():
763 if key == self.key:
764 try:
765 self.lookup(value)
766 except KeyError:
767 pass
768 else:
769 raise ValueError, 'node with key "%s" exists'%value
771 # try to handle this property
772 try:
773 prop = self.properties[key]
774 except KeyError:
775 raise KeyError, '"%s" has no property "%s"'%(self.classname,
776 key)
778 if value is not None and isinstance(prop, Link):
779 if type(value) != type(''):
780 raise ValueError, 'link value must be String'
781 link_class = self.properties[key].classname
782 # if it isn't a number, it's a key
783 if not num_re.match(value):
784 try:
785 value = self.db.classes[link_class].lookup(value)
786 except (TypeError, KeyError):
787 raise IndexError, 'new property "%s": %s not a %s'%(
788 key, value, link_class)
789 elif not self.db.getclass(link_class).hasnode(value):
790 raise IndexError, '%s has no node %s'%(link_class, value)
792 # save off the value
793 propvalues[key] = value
795 # register the link with the newly linked node
796 if self.do_journal and self.properties[key].do_journal:
797 self.db.addjournal(link_class, value, 'link',
798 (self.classname, newid, key))
800 elif isinstance(prop, Multilink):
801 if type(value) != type([]):
802 raise TypeError, 'new property "%s" not a list of ids'%key
804 # clean up and validate the list of links
805 link_class = self.properties[key].classname
806 l = []
807 for entry in value:
808 if type(entry) != type(''):
809 raise ValueError, '"%s" multilink value (%r) '\
810 'must contain Strings'%(key, value)
811 # if it isn't a number, it's a key
812 if not num_re.match(entry):
813 try:
814 entry = self.db.classes[link_class].lookup(entry)
815 except (TypeError, KeyError):
816 raise IndexError, 'new property "%s": %s not a %s'%(
817 key, entry, self.properties[key].classname)
818 l.append(entry)
819 value = l
820 propvalues[key] = value
822 # handle additions
823 for nodeid in value:
824 if not self.db.getclass(link_class).hasnode(nodeid):
825 raise IndexError, '%s has no node %s'%(link_class,
826 nodeid)
827 # register the link with the newly linked node
828 if self.do_journal and self.properties[key].do_journal:
829 self.db.addjournal(link_class, nodeid, 'link',
830 (self.classname, newid, key))
832 elif isinstance(prop, String):
833 if type(value) != type(''):
834 raise TypeError, 'new property "%s" not a string'%key
836 elif isinstance(prop, Password):
837 if not isinstance(value, password.Password):
838 raise TypeError, 'new property "%s" not a Password'%key
840 elif isinstance(prop, Date):
841 if value is not None and not isinstance(value, date.Date):
842 raise TypeError, 'new property "%s" not a Date'%key
844 elif isinstance(prop, Interval):
845 if value is not None and not isinstance(value, date.Interval):
846 raise TypeError, 'new property "%s" not an Interval'%key
848 elif value is not None and isinstance(prop, Number):
849 try:
850 float(value)
851 except ValueError:
852 raise TypeError, 'new property "%s" not numeric'%key
854 elif value is not None and isinstance(prop, Boolean):
855 try:
856 int(value)
857 except ValueError:
858 raise TypeError, 'new property "%s" not boolean'%key
860 # make sure there's data where there needs to be
861 for key, prop in self.properties.items():
862 if propvalues.has_key(key):
863 continue
864 if key == self.key:
865 raise ValueError, 'key property "%s" is required'%key
866 if isinstance(prop, Multilink):
867 propvalues[key] = []
868 else:
869 propvalues[key] = None
871 # done
872 self.db.addnode(self.classname, newid, propvalues)
873 if self.do_journal:
874 self.db.addjournal(self.classname, newid, 'create', propvalues)
876 self.fireReactors('create', newid, None)
878 return newid
880 def export_list(self, propnames, nodeid):
881 ''' Export a node - generate a list of CSV-able data in the order
882 specified by propnames for the given node.
883 '''
884 properties = self.getprops()
885 l = []
886 for prop in propnames:
887 proptype = properties[prop]
888 value = self.get(nodeid, prop)
889 # "marshal" data where needed
890 if value is None:
891 pass
892 elif isinstance(proptype, hyperdb.Date):
893 value = value.get_tuple()
894 elif isinstance(proptype, hyperdb.Interval):
895 value = value.get_tuple()
896 elif isinstance(proptype, hyperdb.Password):
897 value = str(value)
898 l.append(repr(value))
899 return l
901 def import_list(self, propnames, proplist):
902 ''' Import a node - all information including "id" is present and
903 should not be sanity checked. Triggers are not triggered. The
904 journal should be initialised using the "creator" and "created"
905 information.
907 Return the nodeid of the node imported.
908 '''
909 if self.db.journaltag is None:
910 raise DatabaseError, 'Database open read-only'
911 properties = self.getprops()
913 # make the new node's property map
914 d = {}
915 for i in range(len(propnames)):
916 # Use eval to reverse the repr() used to output the CSV
917 value = eval(proplist[i])
919 # Figure the property for this column
920 propname = propnames[i]
921 prop = properties[propname]
923 # "unmarshal" where necessary
924 if propname == 'id':
925 newid = value
926 continue
927 elif value is None:
928 # don't set Nones
929 continue
930 elif isinstance(prop, hyperdb.Date):
931 value = date.Date(value)
932 elif isinstance(prop, hyperdb.Interval):
933 value = date.Interval(value)
934 elif isinstance(prop, hyperdb.Password):
935 pwd = password.Password()
936 pwd.unpack(value)
937 value = pwd
938 d[propname] = value
940 # extract the extraneous journalling gumpf and nuke it
941 if d.has_key('creator'):
942 creator = d['creator']
943 del d['creator']
944 else:
945 creator = None
946 if d.has_key('creation'):
947 creation = d['creation']
948 del d['creation']
949 else:
950 creation = None
951 if d.has_key('activity'):
952 del d['activity']
954 # add the node and journal
955 self.db.addnode(self.classname, newid, d)
956 self.db.addjournal(self.classname, newid, 'create', d, creator,
957 creation)
958 return newid
960 def get(self, nodeid, propname, default=_marker, cache=1):
961 '''Get the value of a property on an existing node of this class.
963 'nodeid' must be the id of an existing node of this class or an
964 IndexError is raised. 'propname' must be the name of a property
965 of this class or a KeyError is raised.
967 'cache' indicates whether the transaction cache should be queried
968 for the node. If the node has been modified and you need to
969 determine what its values prior to modification are, you need to
970 set cache=0.
972 Attempts to get the "creation" or "activity" properties should
973 do the right thing.
974 '''
975 if propname == 'id':
976 return nodeid
978 if propname == 'creation':
979 if not self.do_journal:
980 raise ValueError, 'Journalling is disabled for this class'
981 journal = self.db.getjournal(self.classname, nodeid)
982 if journal:
983 return self.db.getjournal(self.classname, nodeid)[0][1]
984 else:
985 # on the strange chance that there's no journal
986 return date.Date()
987 if propname == 'activity':
988 if not self.do_journal:
989 raise ValueError, 'Journalling is disabled for this class'
990 journal = self.db.getjournal(self.classname, nodeid)
991 if journal:
992 return self.db.getjournal(self.classname, nodeid)[-1][1]
993 else:
994 # on the strange chance that there's no journal
995 return date.Date()
996 if propname == 'creator':
997 if not self.do_journal:
998 raise ValueError, 'Journalling is disabled for this class'
999 journal = self.db.getjournal(self.classname, nodeid)
1000 if journal:
1001 return self.db.getjournal(self.classname, nodeid)[0][2]
1002 else:
1003 return self.db.journaltag
1005 # get the property (raises KeyErorr if invalid)
1006 prop = self.properties[propname]
1008 # get the node's dict
1009 d = self.db.getnode(self.classname, nodeid, cache=cache)
1011 if not d.has_key(propname):
1012 if default is _marker:
1013 if isinstance(prop, Multilink):
1014 return []
1015 else:
1016 return None
1017 else:
1018 return default
1020 # return a dupe of the list so code doesn't get confused
1021 if isinstance(prop, Multilink):
1022 return d[propname][:]
1024 return d[propname]
1026 # not in spec
1027 def getnode(self, nodeid, cache=1):
1028 ''' Return a convenience wrapper for the node.
1030 'nodeid' must be the id of an existing node of this class or an
1031 IndexError is raised.
1033 'cache' indicates whether the transaction cache should be queried
1034 for the node. If the node has been modified and you need to
1035 determine what its values prior to modification are, you need to
1036 set cache=0.
1037 '''
1038 return Node(self, nodeid, cache=cache)
1040 def set(self, nodeid, **propvalues):
1041 '''Modify a property on an existing node of this class.
1043 'nodeid' must be the id of an existing node of this class or an
1044 IndexError is raised.
1046 Each key in 'propvalues' must be the name of a property of this
1047 class or a KeyError is raised.
1049 All values in 'propvalues' must be acceptable types for their
1050 corresponding properties or a TypeError is raised.
1052 If the value of the key property is set, it must not collide with
1053 other key strings or a ValueError is raised.
1055 If the value of a Link or Multilink property contains an invalid
1056 node id, a ValueError is raised.
1058 These operations trigger detectors and can be vetoed. Attempts
1059 to modify the "creation" or "activity" properties cause a KeyError.
1060 '''
1061 if not propvalues:
1062 return propvalues
1064 if propvalues.has_key('creation') or propvalues.has_key('activity'):
1065 raise KeyError, '"creation" and "activity" are reserved'
1067 if propvalues.has_key('id'):
1068 raise KeyError, '"id" is reserved'
1070 if self.db.journaltag is None:
1071 raise DatabaseError, 'Database open read-only'
1073 self.fireAuditors('set', nodeid, propvalues)
1074 # Take a copy of the node dict so that the subsequent set
1075 # operation doesn't modify the oldvalues structure.
1076 try:
1077 # try not using the cache initially
1078 oldvalues = copy.deepcopy(self.db.getnode(self.classname, nodeid,
1079 cache=0))
1080 except IndexError:
1081 # this will be needed if somone does a create() and set()
1082 # with no intervening commit()
1083 oldvalues = copy.deepcopy(self.db.getnode(self.classname, nodeid))
1085 node = self.db.getnode(self.classname, nodeid)
1086 if node.has_key(self.db.RETIRED_FLAG):
1087 raise IndexError
1088 num_re = re.compile('^\d+$')
1090 # if the journal value is to be different, store it in here
1091 journalvalues = {}
1093 for propname, value in propvalues.items():
1094 # check to make sure we're not duplicating an existing key
1095 if propname == self.key and node[propname] != value:
1096 try:
1097 self.lookup(value)
1098 except KeyError:
1099 pass
1100 else:
1101 raise ValueError, 'node with key "%s" exists'%value
1103 # this will raise the KeyError if the property isn't valid
1104 # ... we don't use getprops() here because we only care about
1105 # the writeable properties.
1106 prop = self.properties[propname]
1108 # if the value's the same as the existing value, no sense in
1109 # doing anything
1110 if node.has_key(propname) and value == node[propname]:
1111 del propvalues[propname]
1112 continue
1114 # do stuff based on the prop type
1115 if isinstance(prop, Link):
1116 link_class = prop.classname
1117 # if it isn't a number, it's a key
1118 if value is not None and not isinstance(value, type('')):
1119 raise ValueError, 'property "%s" link value be a string'%(
1120 propname)
1121 if isinstance(value, type('')) and not num_re.match(value):
1122 try:
1123 value = self.db.classes[link_class].lookup(value)
1124 except (TypeError, KeyError):
1125 raise IndexError, 'new property "%s": %s not a %s'%(
1126 propname, value, prop.classname)
1128 if (value is not None and
1129 not self.db.getclass(link_class).hasnode(value)):
1130 raise IndexError, '%s has no node %s'%(link_class, value)
1132 if self.do_journal and prop.do_journal:
1133 # register the unlink with the old linked node
1134 if node[propname] is not None:
1135 self.db.addjournal(link_class, node[propname], 'unlink',
1136 (self.classname, nodeid, propname))
1138 # register the link with the newly linked node
1139 if value is not None:
1140 self.db.addjournal(link_class, value, 'link',
1141 (self.classname, nodeid, propname))
1143 elif isinstance(prop, Multilink):
1144 if type(value) != type([]):
1145 raise TypeError, 'new property "%s" not a list of'\
1146 ' ids'%propname
1147 link_class = self.properties[propname].classname
1148 l = []
1149 for entry in value:
1150 # if it isn't a number, it's a key
1151 if type(entry) != type(''):
1152 raise ValueError, 'new property "%s" link value ' \
1153 'must be a string'%propname
1154 if not num_re.match(entry):
1155 try:
1156 entry = self.db.classes[link_class].lookup(entry)
1157 except (TypeError, KeyError):
1158 raise IndexError, 'new property "%s": %s not a %s'%(
1159 propname, entry,
1160 self.properties[propname].classname)
1161 l.append(entry)
1162 value = l
1163 propvalues[propname] = value
1165 # figure the journal entry for this property
1166 add = []
1167 remove = []
1169 # handle removals
1170 if node.has_key(propname):
1171 l = node[propname]
1172 else:
1173 l = []
1174 for id in l[:]:
1175 if id in value:
1176 continue
1177 # register the unlink with the old linked node
1178 if self.do_journal and self.properties[propname].do_journal:
1179 self.db.addjournal(link_class, id, 'unlink',
1180 (self.classname, nodeid, propname))
1181 l.remove(id)
1182 remove.append(id)
1184 # handle additions
1185 for id in value:
1186 if not self.db.getclass(link_class).hasnode(id):
1187 raise IndexError, '%s has no node %s'%(link_class, id)
1188 if id in l:
1189 continue
1190 # register the link with the newly linked node
1191 if self.do_journal and self.properties[propname].do_journal:
1192 self.db.addjournal(link_class, id, 'link',
1193 (self.classname, nodeid, propname))
1194 l.append(id)
1195 add.append(id)
1197 # figure the journal entry
1198 l = []
1199 if add:
1200 l.append(('+', add))
1201 if remove:
1202 l.append(('-', remove))
1203 if l:
1204 journalvalues[propname] = tuple(l)
1206 elif isinstance(prop, String):
1207 if value is not None and type(value) != type(''):
1208 raise TypeError, 'new property "%s" not a string'%propname
1210 elif isinstance(prop, Password):
1211 if not isinstance(value, password.Password):
1212 raise TypeError, 'new property "%s" not a Password'%propname
1213 propvalues[propname] = value
1215 elif value is not None and isinstance(prop, Date):
1216 if not isinstance(value, date.Date):
1217 raise TypeError, 'new property "%s" not a Date'% propname
1218 propvalues[propname] = value
1220 elif value is not None and isinstance(prop, Interval):
1221 if not isinstance(value, date.Interval):
1222 raise TypeError, 'new property "%s" not an '\
1223 'Interval'%propname
1224 propvalues[propname] = value
1226 elif value is not None and isinstance(prop, Number):
1227 try:
1228 float(value)
1229 except ValueError:
1230 raise TypeError, 'new property "%s" not numeric'%propname
1232 elif value is not None and isinstance(prop, Boolean):
1233 try:
1234 int(value)
1235 except ValueError:
1236 raise TypeError, 'new property "%s" not boolean'%propname
1238 node[propname] = value
1240 # nothing to do?
1241 if not propvalues:
1242 return propvalues
1244 # do the set, and journal it
1245 self.db.setnode(self.classname, nodeid, node)
1247 if self.do_journal:
1248 propvalues.update(journalvalues)
1249 self.db.addjournal(self.classname, nodeid, 'set', propvalues)
1251 self.fireReactors('set', nodeid, oldvalues)
1253 return propvalues
1255 def retire(self, nodeid):
1256 '''Retire a node.
1258 The properties on the node remain available from the get() method,
1259 and the node's id is never reused.
1261 Retired nodes are not returned by the find(), list(), or lookup()
1262 methods, and other nodes may reuse the values of their key properties.
1264 These operations trigger detectors and can be vetoed. Attempts
1265 to modify the "creation" or "activity" properties cause a KeyError.
1266 '''
1267 if self.db.journaltag is None:
1268 raise DatabaseError, 'Database open read-only'
1270 self.fireAuditors('retire', nodeid, None)
1272 node = self.db.getnode(self.classname, nodeid)
1273 node[self.db.RETIRED_FLAG] = 1
1274 self.db.setnode(self.classname, nodeid, node)
1275 if self.do_journal:
1276 self.db.addjournal(self.classname, nodeid, 'retired', None)
1278 self.fireReactors('retire', nodeid, None)
1280 def is_retired(self, nodeid):
1281 '''Return true if the node is retired.
1282 '''
1283 node = self.db.getnode(cn, nodeid, cldb)
1284 if node.has_key(self.db.RETIRED_FLAG):
1285 return 1
1286 return 0
1288 def destroy(self, nodeid):
1289 '''Destroy a node.
1291 WARNING: this method should never be used except in extremely rare
1292 situations where there could never be links to the node being
1293 deleted
1294 WARNING: use retire() instead
1295 WARNING: the properties of this node will not be available ever again
1296 WARNING: really, use retire() instead
1298 Well, I think that's enough warnings. This method exists mostly to
1299 support the session storage of the cgi interface.
1300 '''
1301 if self.db.journaltag is None:
1302 raise DatabaseError, 'Database open read-only'
1303 self.db.destroynode(self.classname, nodeid)
1305 def history(self, nodeid):
1306 '''Retrieve the journal of edits on a particular node.
1308 'nodeid' must be the id of an existing node of this class or an
1309 IndexError is raised.
1311 The returned list contains tuples of the form
1313 (date, tag, action, params)
1315 'date' is a Timestamp object specifying the time of the change and
1316 'tag' is the journaltag specified when the database was opened.
1317 '''
1318 if not self.do_journal:
1319 raise ValueError, 'Journalling is disabled for this class'
1320 return self.db.getjournal(self.classname, nodeid)
1322 # Locating nodes:
1323 def hasnode(self, nodeid):
1324 '''Determine if the given nodeid actually exists
1325 '''
1326 return self.db.hasnode(self.classname, nodeid)
1328 def setkey(self, propname):
1329 '''Select a String property of this class to be the key property.
1331 'propname' must be the name of a String property of this class or
1332 None, or a TypeError is raised. The values of the key property on
1333 all existing nodes must be unique or a ValueError is raised. If the
1334 property doesn't exist, KeyError is raised.
1335 '''
1336 prop = self.getprops()[propname]
1337 if not isinstance(prop, String):
1338 raise TypeError, 'key properties must be String'
1339 self.key = propname
1341 def getkey(self):
1342 '''Return the name of the key property for this class or None.'''
1343 return self.key
1345 def labelprop(self, default_to_id=0):
1346 ''' Return the property name for a label for the given node.
1348 This method attempts to generate a consistent label for the node.
1349 It tries the following in order:
1350 1. key property
1351 2. "name" property
1352 3. "title" property
1353 4. first property from the sorted property name list
1354 '''
1355 k = self.getkey()
1356 if k:
1357 return k
1358 props = self.getprops()
1359 if props.has_key('name'):
1360 return 'name'
1361 elif props.has_key('title'):
1362 return 'title'
1363 if default_to_id:
1364 return 'id'
1365 props = props.keys()
1366 props.sort()
1367 return props[0]
1369 # TODO: set up a separate index db file for this? profile?
1370 def lookup(self, keyvalue):
1371 '''Locate a particular node by its key property and return its id.
1373 If this class has no key property, a TypeError is raised. If the
1374 'keyvalue' matches one of the values for the key property among
1375 the nodes in this class, the matching node's id is returned;
1376 otherwise a KeyError is raised.
1377 '''
1378 if not self.key:
1379 raise TypeError, 'No key property set for class %s'%self.classname
1380 cldb = self.db.getclassdb(self.classname)
1381 try:
1382 for nodeid in self.db.getnodeids(self.classname, cldb):
1383 node = self.db.getnode(self.classname, nodeid, cldb)
1384 if node.has_key(self.db.RETIRED_FLAG):
1385 continue
1386 if node[self.key] == keyvalue:
1387 cldb.close()
1388 return nodeid
1389 finally:
1390 cldb.close()
1391 raise KeyError, keyvalue
1393 # change from spec - allows multiple props to match
1394 def find(self, **propspec):
1395 '''Get the ids of nodes in this class which link to the given nodes.
1397 'propspec' consists of keyword args propname={nodeid:1,}
1398 'propname' must be the name of a property in this class, or a
1399 KeyError is raised. That property must be a Link or Multilink
1400 property, or a TypeError is raised.
1402 Any node in this class whose 'propname' property links to any of the
1403 nodeids will be returned. Used by the full text indexing, which knows
1404 that "foo" occurs in msg1, msg3 and file7, so we have hits on these issues:
1405 db.issue.find(messages={'1':1,'3':1}, files={'7':1})
1406 '''
1407 propspec = propspec.items()
1408 for propname, nodeids in propspec:
1409 # check the prop is OK
1410 prop = self.properties[propname]
1411 if not isinstance(prop, Link) and not isinstance(prop, Multilink):
1412 raise TypeError, "'%s' not a Link/Multilink property"%propname
1414 # ok, now do the find
1415 cldb = self.db.getclassdb(self.classname)
1416 l = []
1417 try:
1418 for id in self.db.getnodeids(self.classname, db=cldb):
1419 node = self.db.getnode(self.classname, id, db=cldb)
1420 if node.has_key(self.db.RETIRED_FLAG):
1421 continue
1422 for propname, nodeids in propspec:
1423 # can't test if the node doesn't have this property
1424 if not node.has_key(propname):
1425 continue
1426 if type(nodeids) is type(''):
1427 nodeids = {nodeids:1}
1428 prop = self.properties[propname]
1429 value = node[propname]
1430 if isinstance(prop, Link) and nodeids.has_key(value):
1431 l.append(id)
1432 break
1433 elif isinstance(prop, Multilink):
1434 hit = 0
1435 for v in value:
1436 if nodeids.has_key(v):
1437 l.append(id)
1438 hit = 1
1439 break
1440 if hit:
1441 break
1442 finally:
1443 cldb.close()
1444 return l
1446 def stringFind(self, **requirements):
1447 '''Locate a particular node by matching a set of its String
1448 properties in a caseless search.
1450 If the property is not a String property, a TypeError is raised.
1452 The return is a list of the id of all nodes that match.
1453 '''
1454 for propname in requirements.keys():
1455 prop = self.properties[propname]
1456 if isinstance(not prop, String):
1457 raise TypeError, "'%s' not a String property"%propname
1458 requirements[propname] = requirements[propname].lower()
1459 l = []
1460 cldb = self.db.getclassdb(self.classname)
1461 try:
1462 for nodeid in self.db.getnodeids(self.classname, cldb):
1463 node = self.db.getnode(self.classname, nodeid, cldb)
1464 if node.has_key(self.db.RETIRED_FLAG):
1465 continue
1466 for key, value in requirements.items():
1467 if node[key] is None or node[key].lower() != value:
1468 break
1469 else:
1470 l.append(nodeid)
1471 finally:
1472 cldb.close()
1473 return l
1475 def list(self):
1476 ''' Return a list of the ids of the active nodes in this class.
1477 '''
1478 l = []
1479 cn = self.classname
1480 cldb = self.db.getclassdb(cn)
1481 try:
1482 for nodeid in self.db.getnodeids(cn, cldb):
1483 node = self.db.getnode(cn, nodeid, cldb)
1484 if node.has_key(self.db.RETIRED_FLAG):
1485 continue
1486 l.append(nodeid)
1487 finally:
1488 cldb.close()
1489 l.sort()
1490 return l
1492 def filter(self, search_matches, filterspec, sort, group,
1493 num_re = re.compile('^\d+$')):
1494 ''' Return a list of the ids of the active nodes in this class that
1495 match the 'filter' spec, sorted by the group spec and then the
1496 sort spec.
1498 "filterspec" is {propname: value(s)}
1499 "sort" and "group" are (dir, prop) where dir is '+', '-' or None
1500 and prop is a prop name or None
1501 "search_matches" is {nodeid: marker}
1503 The filter must match all properties specificed - but if the
1504 property value to match is a list, any one of the values in the
1505 list may match for that property to match.
1506 '''
1507 cn = self.classname
1509 # optimise filterspec
1510 l = []
1511 props = self.getprops()
1512 LINK = 0
1513 MULTILINK = 1
1514 STRING = 2
1515 OTHER = 6
1516 for k, v in filterspec.items():
1517 propclass = props[k]
1518 if isinstance(propclass, Link):
1519 if type(v) is not type([]):
1520 v = [v]
1521 # replace key values with node ids
1522 u = []
1523 link_class = self.db.classes[propclass.classname]
1524 for entry in v:
1525 if entry == '-1': entry = None
1526 elif not num_re.match(entry):
1527 try:
1528 entry = link_class.lookup(entry)
1529 except (TypeError,KeyError):
1530 raise ValueError, 'property "%s": %s not a %s'%(
1531 k, entry, self.properties[k].classname)
1532 u.append(entry)
1534 l.append((LINK, k, u))
1535 elif isinstance(propclass, Multilink):
1536 if type(v) is not type([]):
1537 v = [v]
1538 # replace key values with node ids
1539 u = []
1540 link_class = self.db.classes[propclass.classname]
1541 for entry in v:
1542 if not num_re.match(entry):
1543 try:
1544 entry = link_class.lookup(entry)
1545 except (TypeError,KeyError):
1546 raise ValueError, 'new property "%s": %s not a %s'%(
1547 k, entry, self.properties[k].classname)
1548 u.append(entry)
1549 l.append((MULTILINK, k, u))
1550 elif isinstance(propclass, String):
1551 # simple glob searching
1552 v = re.sub(r'([\|\{\}\\\.\+\[\]\(\)])', r'\\\1', v)
1553 v = v.replace('?', '.')
1554 v = v.replace('*', '.*?')
1555 l.append((STRING, k, re.compile(v, re.I)))
1556 elif isinstance(propclass, Boolean):
1557 if type(v) is type(''):
1558 bv = v.lower() in ('yes', 'true', 'on', '1')
1559 else:
1560 bv = v
1561 l.append((OTHER, k, bv))
1562 elif isinstance(propclass, Number):
1563 l.append((OTHER, k, int(v)))
1564 else:
1565 l.append((OTHER, k, v))
1566 filterspec = l
1568 # now, find all the nodes that are active and pass filtering
1569 l = []
1570 cldb = self.db.getclassdb(cn)
1571 try:
1572 # TODO: only full-scan once (use items())
1573 for nodeid in self.db.getnodeids(cn, cldb):
1574 node = self.db.getnode(cn, nodeid, cldb)
1575 if node.has_key(self.db.RETIRED_FLAG):
1576 continue
1577 # apply filter
1578 for t, k, v in filterspec:
1579 # make sure the node has the property
1580 if not node.has_key(k):
1581 # this node doesn't have this property, so reject it
1582 break
1584 # now apply the property filter
1585 if t == LINK:
1586 # link - if this node's property doesn't appear in the
1587 # filterspec's nodeid list, skip it
1588 if node[k] not in v:
1589 break
1590 elif t == MULTILINK:
1591 # multilink - if any of the nodeids required by the
1592 # filterspec aren't in this node's property, then skip
1593 # it
1594 have = node[k]
1595 for want in v:
1596 if want not in have:
1597 break
1598 else:
1599 continue
1600 break
1601 elif t == STRING:
1602 # RE search
1603 if node[k] is None or not v.search(node[k]):
1604 break
1605 elif t == OTHER:
1606 # straight value comparison for the other types
1607 if node[k] != v:
1608 break
1609 else:
1610 l.append((nodeid, node))
1611 finally:
1612 cldb.close()
1613 l.sort()
1615 # filter based on full text search
1616 if search_matches is not None:
1617 k = []
1618 for v in l:
1619 if search_matches.has_key(v[0]):
1620 k.append(v)
1621 l = k
1623 # now, sort the result
1624 def sortfun(a, b, sort=sort, group=group, properties=self.getprops(),
1625 db = self.db, cl=self):
1626 a_id, an = a
1627 b_id, bn = b
1628 # sort by group and then sort
1629 for dir, prop in group, sort:
1630 if dir is None or prop is None: continue
1632 # sorting is class-specific
1633 propclass = properties[prop]
1635 # handle the properties that might be "faked"
1636 # also, handle possible missing properties
1637 try:
1638 if not an.has_key(prop):
1639 an[prop] = cl.get(a_id, prop)
1640 av = an[prop]
1641 except KeyError:
1642 # the node doesn't have a value for this property
1643 if isinstance(propclass, Multilink): av = []
1644 else: av = ''
1645 try:
1646 if not bn.has_key(prop):
1647 bn[prop] = cl.get(b_id, prop)
1648 bv = bn[prop]
1649 except KeyError:
1650 # the node doesn't have a value for this property
1651 if isinstance(propclass, Multilink): bv = []
1652 else: bv = ''
1654 # String and Date values are sorted in the natural way
1655 if isinstance(propclass, String):
1656 # clean up the strings
1657 if av and av[0] in string.uppercase:
1658 av = an[prop] = av.lower()
1659 if bv and bv[0] in string.uppercase:
1660 bv = bn[prop] = bv.lower()
1661 if (isinstance(propclass, String) or
1662 isinstance(propclass, Date)):
1663 # it might be a string that's really an integer
1664 try:
1665 av = int(av)
1666 bv = int(bv)
1667 except:
1668 pass
1669 if dir == '+':
1670 r = cmp(av, bv)
1671 if r != 0: return r
1672 elif dir == '-':
1673 r = cmp(bv, av)
1674 if r != 0: return r
1676 # Link properties are sorted according to the value of
1677 # the "order" property on the linked nodes if it is
1678 # present; or otherwise on the key string of the linked
1679 # nodes; or finally on the node ids.
1680 elif isinstance(propclass, Link):
1681 link = db.classes[propclass.classname]
1682 if av is None and bv is not None: return -1
1683 if av is not None and bv is None: return 1
1684 if av is None and bv is None: continue
1685 if link.getprops().has_key('order'):
1686 if dir == '+':
1687 r = cmp(link.get(av, 'order'),
1688 link.get(bv, 'order'))
1689 if r != 0: return r
1690 elif dir == '-':
1691 r = cmp(link.get(bv, 'order'),
1692 link.get(av, 'order'))
1693 if r != 0: return r
1694 elif link.getkey():
1695 key = link.getkey()
1696 if dir == '+':
1697 r = cmp(link.get(av, key), link.get(bv, key))
1698 if r != 0: return r
1699 elif dir == '-':
1700 r = cmp(link.get(bv, key), link.get(av, key))
1701 if r != 0: return r
1702 else:
1703 if dir == '+':
1704 r = cmp(av, bv)
1705 if r != 0: return r
1706 elif dir == '-':
1707 r = cmp(bv, av)
1708 if r != 0: return r
1710 # Multilink properties are sorted according to how many
1711 # links are present.
1712 elif isinstance(propclass, Multilink):
1713 if dir == '+':
1714 r = cmp(len(av), len(bv))
1715 if r != 0: return r
1716 elif dir == '-':
1717 r = cmp(len(bv), len(av))
1718 if r != 0: return r
1719 elif isinstance(propclass, Number) or isinstance(propclass, Boolean):
1720 if dir == '+':
1721 r = cmp(av, bv)
1722 elif dir == '-':
1723 r = cmp(bv, av)
1725 # end for dir, prop in sort, group:
1726 # if all else fails, compare the ids
1727 return cmp(a[0], b[0])
1729 l.sort(sortfun)
1730 return [i[0] for i in l]
1732 def count(self):
1733 '''Get the number of nodes in this class.
1735 If the returned integer is 'numnodes', the ids of all the nodes
1736 in this class run from 1 to numnodes, and numnodes+1 will be the
1737 id of the next node to be created in this class.
1738 '''
1739 return self.db.countnodes(self.classname)
1741 # Manipulating properties:
1743 def getprops(self, protected=1):
1744 '''Return a dictionary mapping property names to property objects.
1745 If the "protected" flag is true, we include protected properties -
1746 those which may not be modified.
1748 In addition to the actual properties on the node, these
1749 methods provide the "creation" and "activity" properties. If the
1750 "protected" flag is true, we include protected properties - those
1751 which may not be modified.
1752 '''
1753 d = self.properties.copy()
1754 if protected:
1755 d['id'] = String()
1756 d['creation'] = hyperdb.Date()
1757 d['activity'] = hyperdb.Date()
1758 # can't be a link to user because the user might have been
1759 # retired since the journal entry was created
1760 d['creator'] = hyperdb.String()
1761 return d
1763 def addprop(self, **properties):
1764 '''Add properties to this class.
1766 The keyword arguments in 'properties' must map names to property
1767 objects, or a TypeError is raised. None of the keys in 'properties'
1768 may collide with the names of existing properties, or a ValueError
1769 is raised before any properties have been added.
1770 '''
1771 for key in properties.keys():
1772 if self.properties.has_key(key):
1773 raise ValueError, key
1774 self.properties.update(properties)
1776 def index(self, nodeid):
1777 '''Add (or refresh) the node to search indexes
1778 '''
1779 # find all the String properties that have indexme
1780 for prop, propclass in self.getprops().items():
1781 if isinstance(propclass, String) and propclass.indexme:
1782 try:
1783 value = str(self.get(nodeid, prop))
1784 except IndexError:
1785 # node no longer exists - entry should be removed
1786 self.db.indexer.purge_entry((self.classname, nodeid, prop))
1787 else:
1788 # and index them under (classname, nodeid, property)
1789 self.db.indexer.add_text((self.classname, nodeid, prop),
1790 value)
1792 #
1793 # Detector interface
1794 #
1795 def audit(self, event, detector):
1796 '''Register a detector
1797 '''
1798 l = self.auditors[event]
1799 if detector not in l:
1800 self.auditors[event].append(detector)
1802 def fireAuditors(self, action, nodeid, newvalues):
1803 '''Fire all registered auditors.
1804 '''
1805 for audit in self.auditors[action]:
1806 audit(self.db, self, nodeid, newvalues)
1808 def react(self, event, detector):
1809 '''Register a detector
1810 '''
1811 l = self.reactors[event]
1812 if detector not in l:
1813 self.reactors[event].append(detector)
1815 def fireReactors(self, action, nodeid, oldvalues):
1816 '''Fire all registered reactors.
1817 '''
1818 for react in self.reactors[action]:
1819 react(self.db, self, nodeid, oldvalues)
1821 class FileClass(Class):
1822 '''This class defines a large chunk of data. To support this, it has a
1823 mandatory String property "content" which is typically saved off
1824 externally to the hyperdb.
1826 The default MIME type of this data is defined by the
1827 "default_mime_type" class attribute, which may be overridden by each
1828 node if the class defines a "type" String property.
1829 '''
1830 default_mime_type = 'text/plain'
1832 def create(self, **propvalues):
1833 ''' snaffle the file propvalue and store in a file
1834 '''
1835 content = propvalues['content']
1836 del propvalues['content']
1837 newid = Class.create(self, **propvalues)
1838 self.db.storefile(self.classname, newid, None, content)
1839 return newid
1841 def import_list(self, propnames, proplist):
1842 ''' Trap the "content" property...
1843 '''
1844 # dupe this list so we don't affect others
1845 propnames = propnames[:]
1847 # extract the "content" property from the proplist
1848 i = propnames.index('content')
1849 content = eval(proplist[i])
1850 del propnames[i]
1851 del proplist[i]
1853 # do the normal import
1854 newid = Class.import_list(self, propnames, proplist)
1856 # save off the "content" file
1857 self.db.storefile(self.classname, newid, None, content)
1858 return newid
1860 def get(self, nodeid, propname, default=_marker, cache=1):
1861 ''' trap the content propname and get it from the file
1862 '''
1864 poss_msg = 'Possibly a access right configuration problem.'
1865 if propname == 'content':
1866 try:
1867 return self.db.getfile(self.classname, nodeid, None)
1868 except IOError, (strerror):
1869 # BUG: by catching this we donot see an error in the log.
1870 return 'ERROR reading file: %s%s\n%s\n%s'%(
1871 self.classname, nodeid, poss_msg, strerror)
1872 if default is not _marker:
1873 return Class.get(self, nodeid, propname, default, cache=cache)
1874 else:
1875 return Class.get(self, nodeid, propname, cache=cache)
1877 def getprops(self, protected=1):
1878 ''' In addition to the actual properties on the node, these methods
1879 provide the "content" property. If the "protected" flag is true,
1880 we include protected properties - those which may not be
1881 modified.
1882 '''
1883 d = Class.getprops(self, protected=protected).copy()
1884 d['content'] = hyperdb.String()
1885 return d
1887 def index(self, nodeid):
1888 ''' Index the node in the search index.
1890 We want to index the content in addition to the normal String
1891 property indexing.
1892 '''
1893 # perform normal indexing
1894 Class.index(self, nodeid)
1896 # get the content to index
1897 content = self.get(nodeid, 'content')
1899 # figure the mime type
1900 if self.properties.has_key('type'):
1901 mime_type = self.get(nodeid, 'type')
1902 else:
1903 mime_type = self.default_mime_type
1905 # and index!
1906 self.db.indexer.add_text((self.classname, nodeid, 'content'), content,
1907 mime_type)
1909 # deviation from spec - was called ItemClass
1910 class IssueClass(Class, roundupdb.IssueClass):
1911 # Overridden methods:
1912 def __init__(self, db, classname, **properties):
1913 '''The newly-created class automatically includes the "messages",
1914 "files", "nosy", and "superseder" properties. If the 'properties'
1915 dictionary attempts to specify any of these properties or a
1916 "creation" or "activity" property, a ValueError is raised.
1917 '''
1918 if not properties.has_key('title'):
1919 properties['title'] = hyperdb.String(indexme='yes')
1920 if not properties.has_key('messages'):
1921 properties['messages'] = hyperdb.Multilink("msg")
1922 if not properties.has_key('files'):
1923 properties['files'] = hyperdb.Multilink("file")
1924 if not properties.has_key('nosy'):
1925 # note: journalling is turned off as it really just wastes
1926 # space. this behaviour may be overridden in an instance
1927 properties['nosy'] = hyperdb.Multilink("user", do_journal="no")
1928 if not properties.has_key('superseder'):
1929 properties['superseder'] = hyperdb.Multilink(classname)
1930 Class.__init__(self, db, classname, **properties)
1932 #