3b85d788a954f1b3023b9b93a533d781d2e68007
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.54 2002-07-26 08:26:59 richard Exp $
19 '''
20 This module defines a backend that saves the hyperdatabase in a database
21 chosen by anydbm. It is guaranteed to always be available in python
22 versions >2.1.1 (the dumbdbm fallback in 2.1.1 and earlier has several
23 serious bugs, and is not available)
24 '''
26 import whichdb, anydbm, os, marshal, re, weakref, string, copy
27 from roundup import hyperdb, date, password, roundupdb, security
28 from blobfiles import FileStorage
29 from roundup.indexer import Indexer
30 from locking import acquire_lock, release_lock
31 from roundup.hyperdb import String, Password, Date, Interval, Link, \
32 Multilink, DatabaseError, Boolean, Number
34 #
35 # Now the database
36 #
37 class Database(FileStorage, hyperdb.Database, roundupdb.Database):
38 """A database for storing records containing flexible data types.
40 Transaction stuff TODO:
41 . check the timestamp of the class file and nuke the cache if it's
42 modified. Do some sort of conflict checking on the dirty stuff.
43 . perhaps detect write collisions (related to above)?
45 """
46 def __init__(self, config, journaltag=None):
47 """Open a hyperdatabase given a specifier to some storage.
49 The 'storagelocator' is obtained from config.DATABASE.
50 The meaning of 'storagelocator' depends on the particular
51 implementation of the hyperdatabase. It could be a file name,
52 a directory path, a socket descriptor for a connection to a
53 database over the network, etc.
55 The 'journaltag' is a token that will be attached to the journal
56 entries for any edits done on the database. If 'journaltag' is
57 None, the database is opened in read-only mode: the Class.create(),
58 Class.set(), and Class.retire() methods are disabled.
59 """
60 self.config, self.journaltag = config, journaltag
61 self.dir = config.DATABASE
62 self.classes = {}
63 self.cache = {} # cache of nodes loaded or created
64 self.dirtynodes = {} # keep track of the dirty nodes by class
65 self.newnodes = {} # keep track of the new nodes by class
66 self.destroyednodes = {}# keep track of the destroyed nodes by class
67 self.transactions = []
68 self.indexer = Indexer(self.dir)
69 self.security = security.Security(self)
70 # ensure files are group readable and writable
71 os.umask(0002)
73 def post_init(self):
74 """Called once the schema initialisation has finished."""
75 # reindex the db if necessary
76 if self.indexer.should_reindex():
77 self.reindex()
79 def reindex(self):
80 for klass in self.classes.values():
81 for nodeid in klass.list():
82 klass.index(nodeid)
83 self.indexer.save_index()
85 def __repr__(self):
86 return '<back_anydbm instance at %x>'%id(self)
88 #
89 # Classes
90 #
91 def __getattr__(self, classname):
92 """A convenient way of calling self.getclass(classname)."""
93 if self.classes.has_key(classname):
94 if __debug__:
95 print >>hyperdb.DEBUG, '__getattr__', (self, classname)
96 return self.classes[classname]
97 raise AttributeError, classname
99 def addclass(self, cl):
100 if __debug__:
101 print >>hyperdb.DEBUG, 'addclass', (self, cl)
102 cn = cl.classname
103 if self.classes.has_key(cn):
104 raise ValueError, cn
105 self.classes[cn] = cl
107 def getclasses(self):
108 """Return a list of the names of all existing classes."""
109 if __debug__:
110 print >>hyperdb.DEBUG, 'getclasses', (self,)
111 l = self.classes.keys()
112 l.sort()
113 return l
115 def getclass(self, classname):
116 """Get the Class object representing a particular class.
118 If 'classname' is not a valid class name, a KeyError is raised.
119 """
120 if __debug__:
121 print >>hyperdb.DEBUG, 'getclass', (self, classname)
122 return self.classes[classname]
124 #
125 # Class DBs
126 #
127 def clear(self):
128 '''Delete all database contents
129 '''
130 if __debug__:
131 print >>hyperdb.DEBUG, 'clear', (self,)
132 for cn in self.classes.keys():
133 for dummy in 'nodes', 'journals':
134 path = os.path.join(self.dir, 'journals.%s'%cn)
135 if os.path.exists(path):
136 os.remove(path)
137 elif os.path.exists(path+'.db'): # dbm appends .db
138 os.remove(path+'.db')
140 def getclassdb(self, classname, mode='r'):
141 ''' grab a connection to the class db that will be used for
142 multiple actions
143 '''
144 if __debug__:
145 print >>hyperdb.DEBUG, 'getclassdb', (self, classname, mode)
146 return self.opendb('nodes.%s'%classname, mode)
148 def determine_db_type(self, path):
149 ''' determine which DB wrote the class file
150 '''
151 db_type = ''
152 if os.path.exists(path):
153 db_type = whichdb.whichdb(path)
154 if not db_type:
155 raise hyperdb.DatabaseError, "Couldn't identify database type"
156 elif os.path.exists(path+'.db'):
157 # if the path ends in '.db', it's a dbm database, whether
158 # anydbm says it's dbhash or not!
159 db_type = 'dbm'
160 return db_type
162 def opendb(self, name, mode):
163 '''Low-level database opener that gets around anydbm/dbm
164 eccentricities.
165 '''
166 if __debug__:
167 print >>hyperdb.DEBUG, 'opendb', (self, name, mode)
169 # figure the class db type
170 path = os.path.join(os.getcwd(), self.dir, name)
171 db_type = self.determine_db_type(path)
173 # new database? let anydbm pick the best dbm
174 if not db_type:
175 if __debug__:
176 print >>hyperdb.DEBUG, "opendb anydbm.open(%r, 'n')"%path
177 return anydbm.open(path, 'n')
179 # open the database with the correct module
180 try:
181 dbm = __import__(db_type)
182 except ImportError:
183 raise hyperdb.DatabaseError, \
184 "Couldn't open database - the required module '%s'"\
185 " is not available"%db_type
186 if __debug__:
187 print >>hyperdb.DEBUG, "opendb %r.open(%r, %r)"%(db_type, path,
188 mode)
189 return dbm.open(path, mode)
191 def lockdb(self, name):
192 ''' Lock a database file
193 '''
194 path = os.path.join(os.getcwd(), self.dir, '%s.lock'%name)
195 return acquire_lock(path)
197 #
198 # Node IDs
199 #
200 def newid(self, classname):
201 ''' Generate a new id for the given class
202 '''
203 # open the ids DB - create if if doesn't exist
204 lock = self.lockdb('_ids')
205 db = self.opendb('_ids', 'c')
206 if db.has_key(classname):
207 newid = db[classname] = str(int(db[classname]) + 1)
208 else:
209 # the count() bit is transitional - older dbs won't start at 1
210 newid = str(self.getclass(classname).count()+1)
211 db[classname] = newid
212 db.close()
213 release_lock(lock)
214 return newid
216 #
217 # Nodes
218 #
219 def addnode(self, classname, nodeid, node):
220 ''' add the specified node to its class's db
221 '''
222 if __debug__:
223 print >>hyperdb.DEBUG, 'addnode', (self, classname, nodeid, node)
224 self.newnodes.setdefault(classname, {})[nodeid] = 1
225 self.cache.setdefault(classname, {})[nodeid] = node
226 self.savenode(classname, nodeid, node)
228 def setnode(self, classname, nodeid, node):
229 ''' change the specified node
230 '''
231 if __debug__:
232 print >>hyperdb.DEBUG, 'setnode', (self, classname, nodeid, node)
233 self.dirtynodes.setdefault(classname, {})[nodeid] = 1
235 # can't set without having already loaded the node
236 self.cache[classname][nodeid] = node
237 self.savenode(classname, nodeid, node)
239 def savenode(self, classname, nodeid, node):
240 ''' perform the saving of data specified by the set/addnode
241 '''
242 if __debug__:
243 print >>hyperdb.DEBUG, 'savenode', (self, classname, nodeid, node)
244 self.transactions.append((self.doSaveNode, (classname, nodeid, node)))
246 def getnode(self, classname, nodeid, db=None, cache=1):
247 ''' get a node from the database
248 '''
249 if __debug__:
250 print >>hyperdb.DEBUG, 'getnode', (self, classname, nodeid, db)
251 if cache:
252 # try the cache
253 cache_dict = self.cache.setdefault(classname, {})
254 if cache_dict.has_key(nodeid):
255 if __debug__:
256 print >>hyperdb.TRACE, 'get %s %s cached'%(classname,
257 nodeid)
258 return cache_dict[nodeid]
260 if __debug__:
261 print >>hyperdb.TRACE, 'get %s %s'%(classname, nodeid)
263 # get from the database and save in the cache
264 if db is None:
265 db = self.getclassdb(classname)
266 if not db.has_key(nodeid):
267 raise IndexError, "no such %s %s"%(classname, nodeid)
269 # check the uncommitted, destroyed nodes
270 if (self.destroyednodes.has_key(classname) and
271 self.destroyednodes[classname].has_key(nodeid)):
272 raise IndexError, "no such %s %s"%(classname, nodeid)
274 # decode
275 res = marshal.loads(db[nodeid])
277 # reverse the serialisation
278 res = self.unserialise(classname, res)
280 # store off in the cache dict
281 if cache:
282 cache_dict[nodeid] = res
284 return res
286 def destroynode(self, classname, nodeid):
287 '''Remove a node from the database. Called exclusively by the
288 destroy() method on Class.
289 '''
290 if __debug__:
291 print >>hyperdb.DEBUG, 'destroynode', (self, classname, nodeid)
293 # remove from cache and newnodes if it's there
294 if (self.cache.has_key(classname) and
295 self.cache[classname].has_key(nodeid)):
296 del self.cache[classname][nodeid]
297 if (self.newnodes.has_key(classname) and
298 self.newnodes[classname].has_key(nodeid)):
299 del self.newnodes[classname][nodeid]
301 # see if there's any obvious commit actions that we should get rid of
302 for entry in self.transactions[:]:
303 if entry[1][:2] == (classname, nodeid):
304 self.transactions.remove(entry)
306 # add to the destroyednodes map
307 self.destroyednodes.setdefault(classname, {})[nodeid] = 1
309 # add the destroy commit action
310 self.transactions.append((self.doDestroyNode, (classname, nodeid)))
312 def serialise(self, classname, node):
313 '''Copy the node contents, converting non-marshallable data into
314 marshallable data.
315 '''
316 if __debug__:
317 print >>hyperdb.DEBUG, 'serialise', classname, node
318 properties = self.getclass(classname).getprops()
319 d = {}
320 for k, v in node.items():
321 # if the property doesn't exist, or is the "retired" flag then
322 # it won't be in the properties dict
323 if not properties.has_key(k):
324 d[k] = v
325 continue
327 # get the property spec
328 prop = properties[k]
330 if isinstance(prop, Password):
331 d[k] = str(v)
332 elif isinstance(prop, Date) and v is not None:
333 d[k] = v.get_tuple()
334 elif isinstance(prop, Interval) and v is not None:
335 d[k] = v.get_tuple()
336 else:
337 d[k] = v
338 return d
340 def unserialise(self, classname, node):
341 '''Decode the marshalled node data
342 '''
343 if __debug__:
344 print >>hyperdb.DEBUG, 'unserialise', classname, node
345 properties = self.getclass(classname).getprops()
346 d = {}
347 for k, v in node.items():
348 # if the property doesn't exist, or is the "retired" flag then
349 # it won't be in the properties dict
350 if not properties.has_key(k):
351 d[k] = v
352 continue
354 # get the property spec
355 prop = properties[k]
357 if isinstance(prop, Date) and v is not None:
358 d[k] = date.Date(v)
359 elif isinstance(prop, Interval) and v is not None:
360 d[k] = date.Interval(v)
361 elif isinstance(prop, Password):
362 p = password.Password()
363 p.unpack(v)
364 d[k] = p
365 else:
366 d[k] = v
367 return d
369 def hasnode(self, classname, nodeid, db=None):
370 ''' determine if the database has a given node
371 '''
372 if __debug__:
373 print >>hyperdb.DEBUG, 'hasnode', (self, classname, nodeid, db)
375 # try the cache
376 cache = self.cache.setdefault(classname, {})
377 if cache.has_key(nodeid):
378 if __debug__:
379 print >>hyperdb.TRACE, 'has %s %s cached'%(classname, nodeid)
380 return 1
381 if __debug__:
382 print >>hyperdb.TRACE, 'has %s %s'%(classname, nodeid)
384 # not in the cache - check the database
385 if db is None:
386 db = self.getclassdb(classname)
387 res = db.has_key(nodeid)
388 return res
390 def countnodes(self, classname, db=None):
391 if __debug__:
392 print >>hyperdb.DEBUG, 'countnodes', (self, classname, db)
394 count = 0
396 # include the uncommitted nodes
397 if self.newnodes.has_key(classname):
398 count += len(self.newnodes[classname])
399 if self.destroyednodes.has_key(classname):
400 count -= len(self.destroyednodes[classname])
402 # and count those in the DB
403 if db is None:
404 db = self.getclassdb(classname)
405 count = count + len(db.keys())
406 return count
408 def getnodeids(self, classname, db=None):
409 if __debug__:
410 print >>hyperdb.DEBUG, 'getnodeids', (self, classname, db)
412 res = []
414 # start off with the new nodes
415 if self.newnodes.has_key(classname):
416 res += self.newnodes[classname].keys()
418 if db is None:
419 db = self.getclassdb(classname)
420 res = res + db.keys()
422 # remove the uncommitted, destroyed nodes
423 if self.destroyednodes.has_key(classname):
424 for nodeid in self.destroyednodes[classname].keys():
425 if db.has_key(nodeid):
426 res.remove(nodeid)
428 return res
431 #
432 # Files - special node properties
433 # inherited from FileStorage
435 #
436 # Journal
437 #
438 def addjournal(self, classname, nodeid, action, params):
439 ''' Journal the Action
440 'action' may be:
442 'create' or 'set' -- 'params' is a dictionary of property values
443 'link' or 'unlink' -- 'params' is (classname, nodeid, propname)
444 'retire' -- 'params' is None
445 '''
446 if __debug__:
447 print >>hyperdb.DEBUG, 'addjournal', (self, classname, nodeid,
448 action, params)
449 self.transactions.append((self.doSaveJournal, (classname, nodeid,
450 action, params)))
452 def getjournal(self, classname, nodeid):
453 ''' get the journal for id
455 Raise IndexError if the node doesn't exist (as per history()'s
456 API)
457 '''
458 if __debug__:
459 print >>hyperdb.DEBUG, 'getjournal', (self, classname, nodeid)
460 # attempt to open the journal - in some rare cases, the journal may
461 # not exist
462 try:
463 db = self.opendb('journals.%s'%classname, 'r')
464 except anydbm.error, error:
465 if str(error) == "need 'c' or 'n' flag to open new db":
466 raise IndexError, 'no such %s %s'%(classname, nodeid)
467 elif error.args[0] != 2:
468 raise
469 raise IndexError, 'no such %s %s'%(classname, nodeid)
470 try:
471 journal = marshal.loads(db[nodeid])
472 except KeyError:
473 db.close()
474 raise IndexError, 'no such %s %s'%(classname, nodeid)
475 db.close()
476 res = []
477 for nodeid, date_stamp, user, action, params in journal:
478 res.append((nodeid, date.Date(date_stamp), user, action, params))
479 return res
481 def pack(self, pack_before):
482 ''' delete all journal entries before 'pack_before' '''
483 if __debug__:
484 print >>hyperdb.DEBUG, 'packjournal', (self, pack_before)
486 pack_before = pack_before.get_tuple()
488 classes = self.getclasses()
490 # figure the class db type
492 for classname in classes:
493 db_name = 'journals.%s'%classname
494 path = os.path.join(os.getcwd(), self.dir, classname)
495 db_type = self.determine_db_type(path)
496 db = self.opendb(db_name, 'w')
498 for key in db.keys():
499 journal = marshal.loads(db[key])
500 l = []
501 last_set_entry = None
502 for entry in journal:
503 (nodeid, date_stamp, self.journaltag, action,
504 params) = entry
505 if date_stamp > pack_before or action == 'create':
506 l.append(entry)
507 elif action == 'set':
508 # grab the last set entry to keep information on
509 # activity
510 last_set_entry = entry
511 if last_set_entry:
512 date_stamp = last_set_entry[1]
513 # if the last set entry was made after the pack date
514 # then it is already in the list
515 if date_stamp < pack_before:
516 l.append(last_set_entry)
517 db[key] = marshal.dumps(l)
518 if db_type == 'gdbm':
519 db.reorganize()
520 db.close()
523 #
524 # Basic transaction support
525 #
526 def commit(self):
527 ''' Commit the current transactions.
528 '''
529 if __debug__:
530 print >>hyperdb.DEBUG, 'commit', (self,)
531 # TODO: lock the DB
533 # keep a handle to all the database files opened
534 self.databases = {}
536 # now, do all the transactions
537 reindex = {}
538 for method, args in self.transactions:
539 reindex[method(*args)] = 1
541 # now close all the database files
542 for db in self.databases.values():
543 db.close()
544 del self.databases
545 # TODO: unlock the DB
547 # reindex the nodes that request it
548 for classname, nodeid in filter(None, reindex.keys()):
549 print >>hyperdb.DEBUG, 'commit.reindex', (classname, nodeid)
550 self.getclass(classname).index(nodeid)
552 # save the indexer state
553 self.indexer.save_index()
555 # all transactions committed, back to normal
556 self.cache = {}
557 self.dirtynodes = {}
558 self.newnodes = {}
559 self.destroyednodes = {}
560 self.transactions = []
562 def getCachedClassDB(self, classname):
563 ''' get the class db, looking in our cache of databases for commit
564 '''
565 # get the database handle
566 db_name = 'nodes.%s'%classname
567 if not self.databases.has_key(db_name):
568 self.databases[db_name] = self.getclassdb(classname, 'c')
569 return self.databases[db_name]
571 def doSaveNode(self, classname, nodeid, node):
572 if __debug__:
573 print >>hyperdb.DEBUG, 'doSaveNode', (self, classname, nodeid,
574 node)
576 db = self.getCachedClassDB(classname)
578 # now save the marshalled data
579 db[nodeid] = marshal.dumps(self.serialise(classname, node))
581 # return the classname, nodeid so we reindex this content
582 return (classname, nodeid)
584 def getCachedJournalDB(self, classname):
585 ''' get the journal db, looking in our cache of databases for commit
586 '''
587 # get the database handle
588 db_name = 'journals.%s'%classname
589 if not self.databases.has_key(db_name):
590 self.databases[db_name] = self.opendb(db_name, 'c')
591 return self.databases[db_name]
593 def doSaveJournal(self, classname, nodeid, action, params):
594 # serialise first
595 if action in ('set', 'create'):
596 params = self.serialise(classname, params)
598 # create the journal entry
599 entry = (nodeid, date.Date().get_tuple(), self.journaltag, action,
600 params)
602 if __debug__:
603 print >>hyperdb.DEBUG, 'doSaveJournal', entry
605 db = self.getCachedJournalDB(classname)
607 # now insert the journal entry
608 if db.has_key(nodeid):
609 # append to existing
610 s = db[nodeid]
611 l = marshal.loads(s)
612 l.append(entry)
613 else:
614 l = [entry]
616 db[nodeid] = marshal.dumps(l)
618 def doDestroyNode(self, classname, nodeid):
619 if __debug__:
620 print >>hyperdb.DEBUG, 'doDestroyNode', (self, classname, nodeid)
622 # delete from the class database
623 db = self.getCachedClassDB(classname)
624 if db.has_key(nodeid):
625 del db[nodeid]
627 # delete from the database
628 db = self.getCachedJournalDB(classname)
629 if db.has_key(nodeid):
630 del db[nodeid]
632 # return the classname, nodeid so we reindex this content
633 return (classname, nodeid)
635 def rollback(self):
636 ''' Reverse all actions from the current transaction.
637 '''
638 if __debug__:
639 print >>hyperdb.DEBUG, 'rollback', (self, )
640 for method, args in self.transactions:
641 # delete temporary files
642 if method == self.doStoreFile:
643 self.rollbackStoreFile(*args)
644 self.cache = {}
645 self.dirtynodes = {}
646 self.newnodes = {}
647 self.destroyednodes = {}
648 self.transactions = []
650 _marker = []
651 class Class(hyperdb.Class):
652 """The handle to a particular class of nodes in a hyperdatabase."""
654 def __init__(self, db, classname, **properties):
655 """Create a new class with a given name and property specification.
657 'classname' must not collide with the name of an existing class,
658 or a ValueError is raised. The keyword arguments in 'properties'
659 must map names to property objects, or a TypeError is raised.
660 """
661 if (properties.has_key('creation') or properties.has_key('activity')
662 or properties.has_key('creator')):
663 raise ValueError, '"creation", "activity" and "creator" are '\
664 'reserved'
666 self.classname = classname
667 self.properties = properties
668 self.db = weakref.proxy(db) # use a weak ref to avoid circularity
669 self.key = ''
671 # should we journal changes (default yes)
672 self.do_journal = 1
674 # do the db-related init stuff
675 db.addclass(self)
677 self.auditors = {'create': [], 'set': [], 'retire': []}
678 self.reactors = {'create': [], 'set': [], 'retire': []}
680 def enableJournalling(self):
681 '''Turn journalling on for this class
682 '''
683 self.do_journal = 1
685 def disableJournalling(self):
686 '''Turn journalling off for this class
687 '''
688 self.do_journal = 0
690 # Editing nodes:
692 def create(self, **propvalues):
693 """Create a new node of this class and return its id.
695 The keyword arguments in 'propvalues' map property names to values.
697 The values of arguments must be acceptable for the types of their
698 corresponding properties or a TypeError is raised.
700 If this class has a key property, it must be present and its value
701 must not collide with other key strings or a ValueError is raised.
703 Any other properties on this class that are missing from the
704 'propvalues' dictionary are set to None.
706 If an id in a link or multilink property does not refer to a valid
707 node, an IndexError is raised.
709 These operations trigger detectors and can be vetoed. Attempts
710 to modify the "creation" or "activity" properties cause a KeyError.
711 """
712 if propvalues.has_key('id'):
713 raise KeyError, '"id" is reserved'
715 if self.db.journaltag is None:
716 raise DatabaseError, 'Database open read-only'
718 if propvalues.has_key('creation') or propvalues.has_key('activity'):
719 raise KeyError, '"creation" and "activity" are reserved'
721 self.fireAuditors('create', None, propvalues)
723 # new node's id
724 newid = self.db.newid(self.classname)
726 # validate propvalues
727 num_re = re.compile('^\d+$')
728 for key, value in propvalues.items():
729 if key == self.key:
730 try:
731 self.lookup(value)
732 except KeyError:
733 pass
734 else:
735 raise ValueError, 'node with key "%s" exists'%value
737 # try to handle this property
738 try:
739 prop = self.properties[key]
740 except KeyError:
741 raise KeyError, '"%s" has no property "%s"'%(self.classname,
742 key)
744 if isinstance(prop, Link):
745 if type(value) != type(''):
746 raise ValueError, 'link value must be String'
747 link_class = self.properties[key].classname
748 # if it isn't a number, it's a key
749 if not num_re.match(value):
750 try:
751 value = self.db.classes[link_class].lookup(value)
752 except (TypeError, KeyError):
753 raise IndexError, 'new property "%s": %s not a %s'%(
754 key, value, link_class)
755 elif not self.db.getclass(link_class).hasnode(value):
756 raise IndexError, '%s has no node %s'%(link_class, value)
758 # save off the value
759 propvalues[key] = value
761 # register the link with the newly linked node
762 if self.do_journal and self.properties[key].do_journal:
763 self.db.addjournal(link_class, value, 'link',
764 (self.classname, newid, key))
766 elif isinstance(prop, Multilink):
767 if type(value) != type([]):
768 raise TypeError, 'new property "%s" not a list of ids'%key
770 # clean up and validate the list of links
771 link_class = self.properties[key].classname
772 l = []
773 for entry in value:
774 if type(entry) != type(''):
775 raise ValueError, '"%s" link value (%s) must be '\
776 'String'%(key, value)
777 # if it isn't a number, it's a key
778 if not num_re.match(entry):
779 try:
780 entry = self.db.classes[link_class].lookup(entry)
781 except (TypeError, KeyError):
782 raise IndexError, 'new property "%s": %s not a %s'%(
783 key, entry, self.properties[key].classname)
784 l.append(entry)
785 value = l
786 propvalues[key] = value
788 # handle additions
789 for nodeid in value:
790 if not self.db.getclass(link_class).hasnode(nodeid):
791 raise IndexError, '%s has no node %s'%(link_class,
792 nodeid)
793 # register the link with the newly linked node
794 if self.do_journal and self.properties[key].do_journal:
795 self.db.addjournal(link_class, nodeid, 'link',
796 (self.classname, newid, key))
798 elif isinstance(prop, String):
799 if type(value) != type(''):
800 raise TypeError, 'new property "%s" not a string'%key
802 elif isinstance(prop, Password):
803 if not isinstance(value, password.Password):
804 raise TypeError, 'new property "%s" not a Password'%key
806 elif isinstance(prop, Date):
807 if value is not None and not isinstance(value, date.Date):
808 raise TypeError, 'new property "%s" not a Date'%key
810 elif isinstance(prop, Interval):
811 if value is not None and not isinstance(value, date.Interval):
812 raise TypeError, 'new property "%s" not an Interval'%key
814 elif value is not None and isinstance(prop, Number):
815 try:
816 float(value)
817 except ValueError:
818 raise TypeError, 'new property "%s" not numeric'%key
820 elif value is not None and isinstance(prop, Boolean):
821 try:
822 int(value)
823 except ValueError:
824 raise TypeError, 'new property "%s" not boolean'%key
826 # make sure there's data where there needs to be
827 for key, prop in self.properties.items():
828 if propvalues.has_key(key):
829 continue
830 if key == self.key:
831 raise ValueError, 'key property "%s" is required'%key
832 if isinstance(prop, Multilink):
833 propvalues[key] = []
834 else:
835 propvalues[key] = None
837 # done
838 self.db.addnode(self.classname, newid, propvalues)
839 if self.do_journal:
840 self.db.addjournal(self.classname, newid, 'create', propvalues)
842 self.fireReactors('create', newid, None)
844 return newid
846 def get(self, nodeid, propname, default=_marker, cache=1):
847 """Get the value of a property on an existing node of this class.
849 'nodeid' must be the id of an existing node of this class or an
850 IndexError is raised. 'propname' must be the name of a property
851 of this class or a KeyError is raised.
853 'cache' indicates whether the transaction cache should be queried
854 for the node. If the node has been modified and you need to
855 determine what its values prior to modification are, you need to
856 set cache=0.
858 Attempts to get the "creation" or "activity" properties should
859 do the right thing.
860 """
861 if propname == 'id':
862 return nodeid
864 if propname == 'creation':
865 if not self.do_journal:
866 raise ValueError, 'Journalling is disabled for this class'
867 journal = self.db.getjournal(self.classname, nodeid)
868 if journal:
869 return self.db.getjournal(self.classname, nodeid)[0][1]
870 else:
871 # on the strange chance that there's no journal
872 return date.Date()
873 if propname == 'activity':
874 if not self.do_journal:
875 raise ValueError, 'Journalling is disabled for this class'
876 journal = self.db.getjournal(self.classname, nodeid)
877 if journal:
878 return self.db.getjournal(self.classname, nodeid)[-1][1]
879 else:
880 # on the strange chance that there's no journal
881 return date.Date()
882 if propname == 'creator':
883 if not self.do_journal:
884 raise ValueError, 'Journalling is disabled for this class'
885 journal = self.db.getjournal(self.classname, nodeid)
886 if journal:
887 name = self.db.getjournal(self.classname, nodeid)[0][2]
888 else:
889 return None
890 return self.db.user.lookup(name)
892 # get the property (raises KeyErorr if invalid)
893 prop = self.properties[propname]
895 # get the node's dict
896 d = self.db.getnode(self.classname, nodeid, cache=cache)
898 if not d.has_key(propname):
899 if default is _marker:
900 if isinstance(prop, Multilink):
901 return []
902 else:
903 return None
904 else:
905 return default
907 return d[propname]
909 # XXX not in spec
910 def getnode(self, nodeid, cache=1):
911 ''' Return a convenience wrapper for the node.
913 'nodeid' must be the id of an existing node of this class or an
914 IndexError is raised.
916 'cache' indicates whether the transaction cache should be queried
917 for the node. If the node has been modified and you need to
918 determine what its values prior to modification are, you need to
919 set cache=0.
920 '''
921 return Node(self, nodeid, cache=cache)
923 def set(self, nodeid, **propvalues):
924 """Modify a property on an existing node of this class.
926 'nodeid' must be the id of an existing node of this class or an
927 IndexError is raised.
929 Each key in 'propvalues' must be the name of a property of this
930 class or a KeyError is raised.
932 All values in 'propvalues' must be acceptable types for their
933 corresponding properties or a TypeError is raised.
935 If the value of the key property is set, it must not collide with
936 other key strings or a ValueError is raised.
938 If the value of a Link or Multilink property contains an invalid
939 node id, a ValueError is raised.
941 These operations trigger detectors and can be vetoed. Attempts
942 to modify the "creation" or "activity" properties cause a KeyError.
943 """
944 if not propvalues:
945 return
947 if propvalues.has_key('creation') or propvalues.has_key('activity'):
948 raise KeyError, '"creation" and "activity" are reserved'
950 if propvalues.has_key('id'):
951 raise KeyError, '"id" is reserved'
953 if self.db.journaltag is None:
954 raise DatabaseError, 'Database open read-only'
956 self.fireAuditors('set', nodeid, propvalues)
957 # Take a copy of the node dict so that the subsequent set
958 # operation doesn't modify the oldvalues structure.
959 try:
960 # try not using the cache initially
961 oldvalues = copy.deepcopy(self.db.getnode(self.classname, nodeid,
962 cache=0))
963 except IndexError:
964 # this will be needed if somone does a create() and set()
965 # with no intervening commit()
966 oldvalues = copy.deepcopy(self.db.getnode(self.classname, nodeid))
968 node = self.db.getnode(self.classname, nodeid)
969 if node.has_key(self.db.RETIRED_FLAG):
970 raise IndexError
971 num_re = re.compile('^\d+$')
973 # if the journal value is to be different, store it in here
974 journalvalues = {}
976 for propname, value in propvalues.items():
977 # check to make sure we're not duplicating an existing key
978 if propname == self.key and node[propname] != value:
979 try:
980 self.lookup(value)
981 except KeyError:
982 pass
983 else:
984 raise ValueError, 'node with key "%s" exists'%value
986 # this will raise the KeyError if the property isn't valid
987 # ... we don't use getprops() here because we only care about
988 # the writeable properties.
989 prop = self.properties[propname]
991 # if the value's the same as the existing value, no sense in
992 # doing anything
993 if node.has_key(propname) and value == node[propname]:
994 del propvalues[propname]
995 continue
997 # do stuff based on the prop type
998 if isinstance(prop, Link):
999 link_class = self.properties[propname].classname
1000 # if it isn't a number, it's a key
1001 if type(value) != type(''):
1002 raise ValueError, 'link value must be String'
1003 if not num_re.match(value):
1004 try:
1005 value = self.db.classes[link_class].lookup(value)
1006 except (TypeError, KeyError):
1007 raise IndexError, 'new property "%s": %s not a %s'%(
1008 propname, value, self.properties[propname].classname)
1010 if not self.db.getclass(link_class).hasnode(value):
1011 raise IndexError, '%s has no node %s'%(link_class, value)
1013 if self.do_journal and self.properties[propname].do_journal:
1014 # register the unlink with the old linked node
1015 if node[propname] is not None:
1016 self.db.addjournal(link_class, node[propname], 'unlink',
1017 (self.classname, nodeid, propname))
1019 # register the link with the newly linked node
1020 if value is not None:
1021 self.db.addjournal(link_class, value, 'link',
1022 (self.classname, nodeid, propname))
1024 elif isinstance(prop, Multilink):
1025 if type(value) != type([]):
1026 raise TypeError, 'new property "%s" not a list of'\
1027 ' ids'%propname
1028 link_class = self.properties[propname].classname
1029 l = []
1030 for entry in value:
1031 # if it isn't a number, it's a key
1032 if type(entry) != type(''):
1033 raise ValueError, 'new property "%s" link value ' \
1034 'must be a string'%propname
1035 if not num_re.match(entry):
1036 try:
1037 entry = self.db.classes[link_class].lookup(entry)
1038 except (TypeError, KeyError):
1039 raise IndexError, 'new property "%s": %s not a %s'%(
1040 propname, entry,
1041 self.properties[propname].classname)
1042 l.append(entry)
1043 value = l
1044 propvalues[propname] = value
1046 # figure the journal entry for this property
1047 add = []
1048 remove = []
1050 # handle removals
1051 if node.has_key(propname):
1052 l = node[propname]
1053 else:
1054 l = []
1055 for id in l[:]:
1056 if id in value:
1057 continue
1058 # register the unlink with the old linked node
1059 if self.do_journal and self.properties[propname].do_journal:
1060 self.db.addjournal(link_class, id, 'unlink',
1061 (self.classname, nodeid, propname))
1062 l.remove(id)
1063 remove.append(id)
1065 # handle additions
1066 for id in value:
1067 if not self.db.getclass(link_class).hasnode(id):
1068 raise IndexError, '%s has no node %s'%(link_class, id)
1069 if id in l:
1070 continue
1071 # register the link with the newly linked node
1072 if self.do_journal and self.properties[propname].do_journal:
1073 self.db.addjournal(link_class, id, 'link',
1074 (self.classname, nodeid, propname))
1075 l.append(id)
1076 add.append(id)
1078 # figure the journal entry
1079 l = []
1080 if add:
1081 l.append(('add', add))
1082 if remove:
1083 l.append(('remove', remove))
1084 if l:
1085 journalvalues[propname] = tuple(l)
1087 elif isinstance(prop, String):
1088 if value is not None and type(value) != type(''):
1089 raise TypeError, 'new property "%s" not a string'%propname
1091 elif isinstance(prop, Password):
1092 if not isinstance(value, password.Password):
1093 raise TypeError, 'new property "%s" not a Password'%propname
1094 propvalues[propname] = value
1096 elif value is not None and isinstance(prop, Date):
1097 if not isinstance(value, date.Date):
1098 raise TypeError, 'new property "%s" not a Date'% propname
1099 propvalues[propname] = value
1101 elif value is not None and isinstance(prop, Interval):
1102 if not isinstance(value, date.Interval):
1103 raise TypeError, 'new property "%s" not an '\
1104 'Interval'%propname
1105 propvalues[propname] = value
1107 elif value is not None and isinstance(prop, Number):
1108 try:
1109 float(value)
1110 except ValueError:
1111 raise TypeError, 'new property "%s" not numeric'%propname
1113 elif value is not None and isinstance(prop, Boolean):
1114 try:
1115 int(value)
1116 except ValueError:
1117 raise TypeError, 'new property "%s" not boolean'%propname
1119 node[propname] = value
1121 # nothing to do?
1122 if not propvalues:
1123 return
1125 # do the set, and journal it
1126 self.db.setnode(self.classname, nodeid, node)
1128 if self.do_journal:
1129 propvalues.update(journalvalues)
1130 self.db.addjournal(self.classname, nodeid, 'set', propvalues)
1132 self.fireReactors('set', nodeid, oldvalues)
1134 def retire(self, nodeid):
1135 """Retire a node.
1137 The properties on the node remain available from the get() method,
1138 and the node's id is never reused.
1140 Retired nodes are not returned by the find(), list(), or lookup()
1141 methods, and other nodes may reuse the values of their key properties.
1143 These operations trigger detectors and can be vetoed. Attempts
1144 to modify the "creation" or "activity" properties cause a KeyError.
1145 """
1146 if self.db.journaltag is None:
1147 raise DatabaseError, 'Database open read-only'
1149 self.fireAuditors('retire', nodeid, None)
1151 node = self.db.getnode(self.classname, nodeid)
1152 node[self.db.RETIRED_FLAG] = 1
1153 self.db.setnode(self.classname, nodeid, node)
1154 if self.do_journal:
1155 self.db.addjournal(self.classname, nodeid, 'retired', None)
1157 self.fireReactors('retire', nodeid, None)
1159 def destroy(self, nodeid):
1160 """Destroy a node.
1162 WARNING: this method should never be used except in extremely rare
1163 situations where there could never be links to the node being
1164 deleted
1165 WARNING: use retire() instead
1166 WARNING: the properties of this node will not be available ever again
1167 WARNING: really, use retire() instead
1169 Well, I think that's enough warnings. This method exists mostly to
1170 support the session storage of the cgi interface.
1171 """
1172 if self.db.journaltag is None:
1173 raise DatabaseError, 'Database open read-only'
1174 self.db.destroynode(self.classname, nodeid)
1176 def history(self, nodeid):
1177 """Retrieve the journal of edits on a particular node.
1179 'nodeid' must be the id of an existing node of this class or an
1180 IndexError is raised.
1182 The returned list contains tuples of the form
1184 (date, tag, action, params)
1186 'date' is a Timestamp object specifying the time of the change and
1187 'tag' is the journaltag specified when the database was opened.
1188 """
1189 if not self.do_journal:
1190 raise ValueError, 'Journalling is disabled for this class'
1191 return self.db.getjournal(self.classname, nodeid)
1193 # Locating nodes:
1194 def hasnode(self, nodeid):
1195 '''Determine if the given nodeid actually exists
1196 '''
1197 return self.db.hasnode(self.classname, nodeid)
1199 def setkey(self, propname):
1200 """Select a String property of this class to be the key property.
1202 'propname' must be the name of a String property of this class or
1203 None, or a TypeError is raised. The values of the key property on
1204 all existing nodes must be unique or a ValueError is raised. If the
1205 property doesn't exist, KeyError is raised.
1206 """
1207 prop = self.getprops()[propname]
1208 if not isinstance(prop, String):
1209 raise TypeError, 'key properties must be String'
1210 self.key = propname
1212 def getkey(self):
1213 """Return the name of the key property for this class or None."""
1214 return self.key
1216 def labelprop(self, default_to_id=0):
1217 ''' Return the property name for a label for the given node.
1219 This method attempts to generate a consistent label for the node.
1220 It tries the following in order:
1221 1. key property
1222 2. "name" property
1223 3. "title" property
1224 4. first property from the sorted property name list
1225 '''
1226 k = self.getkey()
1227 if k:
1228 return k
1229 props = self.getprops()
1230 if props.has_key('name'):
1231 return 'name'
1232 elif props.has_key('title'):
1233 return 'title'
1234 if default_to_id:
1235 return 'id'
1236 props = props.keys()
1237 props.sort()
1238 return props[0]
1240 # TODO: set up a separate index db file for this? profile?
1241 def lookup(self, keyvalue):
1242 """Locate a particular node by its key property and return its id.
1244 If this class has no key property, a TypeError is raised. If the
1245 'keyvalue' matches one of the values for the key property among
1246 the nodes in this class, the matching node's id is returned;
1247 otherwise a KeyError is raised.
1248 """
1249 cldb = self.db.getclassdb(self.classname)
1250 try:
1251 for nodeid in self.db.getnodeids(self.classname, cldb):
1252 node = self.db.getnode(self.classname, nodeid, cldb)
1253 if node.has_key(self.db.RETIRED_FLAG):
1254 continue
1255 if node[self.key] == keyvalue:
1256 cldb.close()
1257 return nodeid
1258 finally:
1259 cldb.close()
1260 raise KeyError, keyvalue
1262 # XXX: change from spec - allows multiple props to match
1263 def find(self, **propspec):
1264 """Get the ids of nodes in this class which link to the given nodes.
1266 'propspec' consists of keyword args propname={nodeid:1,}
1267 'propname' must be the name of a property in this class, or a
1268 KeyError is raised. That property must be a Link or Multilink
1269 property, or a TypeError is raised.
1271 Any node in this class whose 'propname' property links to any of the
1272 nodeids will be returned. Used by the full text indexing, which knows
1273 that "foo" occurs in msg1, msg3 and file7, so we have hits on these issues:
1274 db.issue.find(messages={'1':1,'3':1}, files={'7':1})
1275 """
1276 propspec = propspec.items()
1277 for propname, nodeids in propspec:
1278 # check the prop is OK
1279 prop = self.properties[propname]
1280 if not isinstance(prop, Link) and not isinstance(prop, Multilink):
1281 raise TypeError, "'%s' not a Link/Multilink property"%propname
1283 # ok, now do the find
1284 cldb = self.db.getclassdb(self.classname)
1285 l = []
1286 try:
1287 for id in self.db.getnodeids(self.classname, db=cldb):
1288 node = self.db.getnode(self.classname, id, db=cldb)
1289 if node.has_key(self.db.RETIRED_FLAG):
1290 continue
1291 for propname, nodeids in propspec:
1292 # can't test if the node doesn't have this property
1293 if not node.has_key(propname):
1294 continue
1295 if type(nodeids) is type(''):
1296 nodeids = {nodeids:1}
1297 prop = self.properties[propname]
1298 value = node[propname]
1299 if isinstance(prop, Link) and nodeids.has_key(value):
1300 l.append(id)
1301 break
1302 elif isinstance(prop, Multilink):
1303 hit = 0
1304 for v in value:
1305 if nodeids.has_key(v):
1306 l.append(id)
1307 hit = 1
1308 break
1309 if hit:
1310 break
1311 finally:
1312 cldb.close()
1313 return l
1315 def stringFind(self, **requirements):
1316 """Locate a particular node by matching a set of its String
1317 properties in a caseless search.
1319 If the property is not a String property, a TypeError is raised.
1321 The return is a list of the id of all nodes that match.
1322 """
1323 for propname in requirements.keys():
1324 prop = self.properties[propname]
1325 if isinstance(not prop, String):
1326 raise TypeError, "'%s' not a String property"%propname
1327 requirements[propname] = requirements[propname].lower()
1328 l = []
1329 cldb = self.db.getclassdb(self.classname)
1330 try:
1331 for nodeid in self.db.getnodeids(self.classname, cldb):
1332 node = self.db.getnode(self.classname, nodeid, cldb)
1333 if node.has_key(self.db.RETIRED_FLAG):
1334 continue
1335 for key, value in requirements.items():
1336 if node[key] is None or node[key].lower() != value:
1337 break
1338 else:
1339 l.append(nodeid)
1340 finally:
1341 cldb.close()
1342 return l
1344 def list(self):
1345 """Return a list of the ids of the active nodes in this class."""
1346 l = []
1347 cn = self.classname
1348 cldb = self.db.getclassdb(cn)
1349 try:
1350 for nodeid in self.db.getnodeids(cn, cldb):
1351 node = self.db.getnode(cn, nodeid, cldb)
1352 if node.has_key(self.db.RETIRED_FLAG):
1353 continue
1354 l.append(nodeid)
1355 finally:
1356 cldb.close()
1357 l.sort()
1358 return l
1360 # XXX not in spec
1361 def filter(self, search_matches, filterspec, sort, group,
1362 num_re = re.compile('^\d+$')):
1363 ''' Return a list of the ids of the active nodes in this class that
1364 match the 'filter' spec, sorted by the group spec and then the
1365 sort spec
1366 '''
1367 cn = self.classname
1369 # optimise filterspec
1370 l = []
1371 props = self.getprops()
1372 for k, v in filterspec.items():
1373 propclass = props[k]
1374 if isinstance(propclass, Link):
1375 if type(v) is not type([]):
1376 v = [v]
1377 # replace key values with node ids
1378 u = []
1379 link_class = self.db.classes[propclass.classname]
1380 for entry in v:
1381 if entry == '-1': entry = None
1382 elif not num_re.match(entry):
1383 try:
1384 entry = link_class.lookup(entry)
1385 except (TypeError,KeyError):
1386 raise ValueError, 'property "%s": %s not a %s'%(
1387 k, entry, self.properties[k].classname)
1388 u.append(entry)
1390 l.append((0, k, u))
1391 elif isinstance(propclass, Multilink):
1392 if type(v) is not type([]):
1393 v = [v]
1394 # replace key values with node ids
1395 u = []
1396 link_class = self.db.classes[propclass.classname]
1397 for entry in v:
1398 if not num_re.match(entry):
1399 try:
1400 entry = link_class.lookup(entry)
1401 except (TypeError,KeyError):
1402 raise ValueError, 'new property "%s": %s not a %s'%(
1403 k, entry, self.properties[k].classname)
1404 u.append(entry)
1405 l.append((1, k, u))
1406 elif isinstance(propclass, String):
1407 # simple glob searching
1408 v = re.sub(r'([\|\{\}\\\.\+\[\]\(\)])', r'\\\1', v)
1409 v = v.replace('?', '.')
1410 v = v.replace('*', '.*?')
1411 l.append((2, k, re.compile(v, re.I)))
1412 elif isinstance(propclass, Boolean):
1413 if type(v) is type(''):
1414 bv = v.lower() in ('yes', 'true', 'on', '1')
1415 else:
1416 bv = v
1417 l.append((6, k, bv))
1418 elif isinstance(propclass, Number):
1419 l.append((6, k, int(v)))
1420 else:
1421 l.append((6, k, v))
1422 filterspec = l
1424 # now, find all the nodes that are active and pass filtering
1425 l = []
1426 cldb = self.db.getclassdb(cn)
1427 try:
1428 for nodeid in self.db.getnodeids(cn, cldb):
1429 node = self.db.getnode(cn, nodeid, cldb)
1430 if node.has_key(self.db.RETIRED_FLAG):
1431 continue
1432 # apply filter
1433 for t, k, v in filterspec:
1434 # this node doesn't have this property, so reject it
1435 if not node.has_key(k): break
1437 if t == 0 and node[k] not in v:
1438 # link - if this node'd property doesn't appear in the
1439 # filterspec's nodeid list, skip it
1440 break
1441 elif t == 1:
1442 # multilink - if any of the nodeids required by the
1443 # filterspec aren't in this node's property, then skip
1444 # it
1445 for value in v:
1446 if value not in node[k]:
1447 break
1448 else:
1449 continue
1450 break
1451 elif t == 2 and (node[k] is None or not v.search(node[k])):
1452 # RE search
1453 break
1454 elif t == 6 and node[k] != v:
1455 # straight value comparison for the other types
1456 break
1457 else:
1458 l.append((nodeid, node))
1459 finally:
1460 cldb.close()
1461 l.sort()
1463 # filter based on full text search
1464 if search_matches is not None:
1465 k = []
1466 l_debug = []
1467 for v in l:
1468 l_debug.append(v[0])
1469 if search_matches.has_key(v[0]):
1470 k.append(v)
1471 l = k
1473 # optimise sort
1474 m = []
1475 for entry in sort:
1476 if entry[0] != '-':
1477 m.append(('+', entry))
1478 else:
1479 m.append((entry[0], entry[1:]))
1480 sort = m
1482 # optimise group
1483 m = []
1484 for entry in group:
1485 if entry[0] != '-':
1486 m.append(('+', entry))
1487 else:
1488 m.append((entry[0], entry[1:]))
1489 group = m
1490 # now, sort the result
1491 def sortfun(a, b, sort=sort, group=group, properties=self.getprops(),
1492 db = self.db, cl=self):
1493 a_id, an = a
1494 b_id, bn = b
1495 # sort by group and then sort
1496 for list in group, sort:
1497 for dir, prop in list:
1498 # sorting is class-specific
1499 propclass = properties[prop]
1501 # handle the properties that might be "faked"
1502 # also, handle possible missing properties
1503 try:
1504 if not an.has_key(prop):
1505 an[prop] = cl.get(a_id, prop)
1506 av = an[prop]
1507 except KeyError:
1508 # the node doesn't have a value for this property
1509 if isinstance(propclass, Multilink): av = []
1510 else: av = ''
1511 try:
1512 if not bn.has_key(prop):
1513 bn[prop] = cl.get(b_id, prop)
1514 bv = bn[prop]
1515 except KeyError:
1516 # the node doesn't have a value for this property
1517 if isinstance(propclass, Multilink): bv = []
1518 else: bv = ''
1520 # String and Date values are sorted in the natural way
1521 if isinstance(propclass, String):
1522 # clean up the strings
1523 if av and av[0] in string.uppercase:
1524 av = an[prop] = av.lower()
1525 if bv and bv[0] in string.uppercase:
1526 bv = bn[prop] = bv.lower()
1527 if (isinstance(propclass, String) or
1528 isinstance(propclass, Date)):
1529 # it might be a string that's really an integer
1530 try:
1531 av = int(av)
1532 bv = int(bv)
1533 except:
1534 pass
1535 if dir == '+':
1536 r = cmp(av, bv)
1537 if r != 0: return r
1538 elif dir == '-':
1539 r = cmp(bv, av)
1540 if r != 0: return r
1542 # Link properties are sorted according to the value of
1543 # the "order" property on the linked nodes if it is
1544 # present; or otherwise on the key string of the linked
1545 # nodes; or finally on the node ids.
1546 elif isinstance(propclass, Link):
1547 link = db.classes[propclass.classname]
1548 if av is None and bv is not None: return -1
1549 if av is not None and bv is None: return 1
1550 if av is None and bv is None: continue
1551 if link.getprops().has_key('order'):
1552 if dir == '+':
1553 r = cmp(link.get(av, 'order'),
1554 link.get(bv, 'order'))
1555 if r != 0: return r
1556 elif dir == '-':
1557 r = cmp(link.get(bv, 'order'),
1558 link.get(av, 'order'))
1559 if r != 0: return r
1560 elif link.getkey():
1561 key = link.getkey()
1562 if dir == '+':
1563 r = cmp(link.get(av, key), link.get(bv, key))
1564 if r != 0: return r
1565 elif dir == '-':
1566 r = cmp(link.get(bv, key), link.get(av, key))
1567 if r != 0: return r
1568 else:
1569 if dir == '+':
1570 r = cmp(av, bv)
1571 if r != 0: return r
1572 elif dir == '-':
1573 r = cmp(bv, av)
1574 if r != 0: return r
1576 # Multilink properties are sorted according to how many
1577 # links are present.
1578 elif isinstance(propclass, Multilink):
1579 if dir == '+':
1580 r = cmp(len(av), len(bv))
1581 if r != 0: return r
1582 elif dir == '-':
1583 r = cmp(len(bv), len(av))
1584 if r != 0: return r
1585 elif isinstance(propclass, Number) or isinstance(propclass, Boolean):
1586 if dir == '+':
1587 r = cmp(av, bv)
1588 elif dir == '-':
1589 r = cmp(bv, av)
1591 # end for dir, prop in list:
1592 # end for list in sort, group:
1593 # if all else fails, compare the ids
1594 return cmp(a[0], b[0])
1596 l.sort(sortfun)
1597 return [i[0] for i in l]
1599 def count(self):
1600 """Get the number of nodes in this class.
1602 If the returned integer is 'numnodes', the ids of all the nodes
1603 in this class run from 1 to numnodes, and numnodes+1 will be the
1604 id of the next node to be created in this class.
1605 """
1606 return self.db.countnodes(self.classname)
1608 # Manipulating properties:
1610 def getprops(self, protected=1):
1611 """Return a dictionary mapping property names to property objects.
1612 If the "protected" flag is true, we include protected properties -
1613 those which may not be modified.
1615 In addition to the actual properties on the node, these
1616 methods provide the "creation" and "activity" properties. If the
1617 "protected" flag is true, we include protected properties - those
1618 which may not be modified.
1619 """
1620 d = self.properties.copy()
1621 if protected:
1622 d['id'] = String()
1623 d['creation'] = hyperdb.Date()
1624 d['activity'] = hyperdb.Date()
1625 d['creator'] = hyperdb.Link("user")
1626 return d
1628 def addprop(self, **properties):
1629 """Add properties to this class.
1631 The keyword arguments in 'properties' must map names to property
1632 objects, or a TypeError is raised. None of the keys in 'properties'
1633 may collide with the names of existing properties, or a ValueError
1634 is raised before any properties have been added.
1635 """
1636 for key in properties.keys():
1637 if self.properties.has_key(key):
1638 raise ValueError, key
1639 self.properties.update(properties)
1641 def index(self, nodeid):
1642 '''Add (or refresh) the node to search indexes
1643 '''
1644 # find all the String properties that have indexme
1645 for prop, propclass in self.getprops().items():
1646 if isinstance(propclass, String) and propclass.indexme:
1647 try:
1648 value = str(self.get(nodeid, prop))
1649 except IndexError:
1650 # node no longer exists - entry should be removed
1651 self.db.indexer.purge_entry((self.classname, nodeid, prop))
1652 else:
1653 # and index them under (classname, nodeid, property)
1654 self.db.indexer.add_text((self.classname, nodeid, prop),
1655 value)
1657 #
1658 # Detector interface
1659 #
1660 def audit(self, event, detector):
1661 """Register a detector
1662 """
1663 l = self.auditors[event]
1664 if detector not in l:
1665 self.auditors[event].append(detector)
1667 def fireAuditors(self, action, nodeid, newvalues):
1668 """Fire all registered auditors.
1669 """
1670 for audit in self.auditors[action]:
1671 audit(self.db, self, nodeid, newvalues)
1673 def react(self, event, detector):
1674 """Register a detector
1675 """
1676 l = self.reactors[event]
1677 if detector not in l:
1678 self.reactors[event].append(detector)
1680 def fireReactors(self, action, nodeid, oldvalues):
1681 """Fire all registered reactors.
1682 """
1683 for react in self.reactors[action]:
1684 react(self.db, self, nodeid, oldvalues)
1686 class FileClass(Class):
1687 '''This class defines a large chunk of data. To support this, it has a
1688 mandatory String property "content" which is typically saved off
1689 externally to the hyperdb.
1691 The default MIME type of this data is defined by the
1692 "default_mime_type" class attribute, which may be overridden by each
1693 node if the class defines a "type" String property.
1694 '''
1695 default_mime_type = 'text/plain'
1697 def create(self, **propvalues):
1698 ''' snaffle the file propvalue and store in a file
1699 '''
1700 content = propvalues['content']
1701 del propvalues['content']
1702 newid = Class.create(self, **propvalues)
1703 self.db.storefile(self.classname, newid, None, content)
1704 return newid
1706 def get(self, nodeid, propname, default=_marker, cache=1):
1707 ''' trap the content propname and get it from the file
1708 '''
1710 poss_msg = 'Possibly a access right configuration problem.'
1711 if propname == 'content':
1712 try:
1713 return self.db.getfile(self.classname, nodeid, None)
1714 except IOError, (strerror):
1715 # BUG: by catching this we donot see an error in the log.
1716 return 'ERROR reading file: %s%s\n%s\n%s'%(
1717 self.classname, nodeid, poss_msg, strerror)
1718 if default is not _marker:
1719 return Class.get(self, nodeid, propname, default, cache=cache)
1720 else:
1721 return Class.get(self, nodeid, propname, cache=cache)
1723 def getprops(self, protected=1):
1724 ''' In addition to the actual properties on the node, these methods
1725 provide the "content" property. If the "protected" flag is true,
1726 we include protected properties - those which may not be
1727 modified.
1728 '''
1729 d = Class.getprops(self, protected=protected).copy()
1730 if protected:
1731 d['content'] = hyperdb.String()
1732 return d
1734 def index(self, nodeid):
1735 ''' Index the node in the search index.
1737 We want to index the content in addition to the normal String
1738 property indexing.
1739 '''
1740 # perform normal indexing
1741 Class.index(self, nodeid)
1743 # get the content to index
1744 content = self.get(nodeid, 'content')
1746 # figure the mime type
1747 if self.properties.has_key('type'):
1748 mime_type = self.get(nodeid, 'type')
1749 else:
1750 mime_type = self.default_mime_type
1752 # and index!
1753 self.db.indexer.add_text((self.classname, nodeid, 'content'), content,
1754 mime_type)
1756 # XXX deviation from spec - was called ItemClass
1757 class IssueClass(Class, roundupdb.IssueClass):
1758 # Overridden methods:
1759 def __init__(self, db, classname, **properties):
1760 """The newly-created class automatically includes the "messages",
1761 "files", "nosy", and "superseder" properties. If the 'properties'
1762 dictionary attempts to specify any of these properties or a
1763 "creation" or "activity" property, a ValueError is raised.
1764 """
1765 if not properties.has_key('title'):
1766 properties['title'] = hyperdb.String(indexme='yes')
1767 if not properties.has_key('messages'):
1768 properties['messages'] = hyperdb.Multilink("msg")
1769 if not properties.has_key('files'):
1770 properties['files'] = hyperdb.Multilink("file")
1771 if not properties.has_key('nosy'):
1772 properties['nosy'] = hyperdb.Multilink("user")
1773 if not properties.has_key('superseder'):
1774 properties['superseder'] = hyperdb.Multilink(classname)
1775 Class.__init__(self, db, classname, **properties)
1777 #
1778 #$Log: not supported by cvs2svn $
1779 #Revision 1.53 2002/07/25 07:14:06 richard
1780 #Bugger it. Here's the current shape of the new security implementation.
1781 #Still to do:
1782 # . call the security funcs from cgi and mailgw
1783 # . change shipped templates to include correct initialisation and remove
1784 # the old config vars
1785 #... that seems like a lot. The bulk of the work has been done though. Honest :)
1786 #
1787 #Revision 1.52 2002/07/19 03:36:34 richard
1788 #Implemented the destroy() method needed by the session database (and possibly
1789 #others). At the same time, I removed the leading underscores from the hyperdb
1790 #methods that Really Didn't Need Them.
1791 #The journal also raises IndexError now for all situations where there is a
1792 #request for the journal of a node that doesn't have one. It used to return
1793 #[] in _some_ situations, but not all. This _may_ break code, but the tests
1794 #pass...
1795 #
1796 #Revision 1.51 2002/07/18 23:07:08 richard
1797 #Unit tests and a few fixes.
1798 #
1799 #Revision 1.50 2002/07/18 11:50:58 richard
1800 #added tests for number type too
1801 #
1802 #Revision 1.49 2002/07/18 11:41:10 richard
1803 #added tests for boolean type, and fixes to anydbm backend
1804 #
1805 #Revision 1.48 2002/07/18 11:17:31 gmcm
1806 #Add Number and Boolean types to hyperdb.
1807 #Add conversion cases to web, mail & admin interfaces.
1808 #Add storage/serialization cases to back_anydbm & back_metakit.
1809 #
1810 #Revision 1.47 2002/07/14 23:18:20 richard
1811 #. fixed the journal bloat from multilink changes - we just log the add or
1812 # remove operations, not the whole list
1813 #
1814 #Revision 1.46 2002/07/14 06:06:34 richard
1815 #Did some old TODOs
1816 #
1817 #Revision 1.45 2002/07/14 04:03:14 richard
1818 #Implemented a switch to disable journalling for a Class. CGI session
1819 #database now uses it.
1820 #
1821 #Revision 1.44 2002/07/14 02:05:53 richard
1822 #. all storage-specific code (ie. backend) is now implemented by the backends
1823 #
1824 #Revision 1.43 2002/07/10 06:30:30 richard
1825 #...except of course it's nice to use valid Python syntax
1826 #
1827 #Revision 1.42 2002/07/10 06:21:38 richard
1828 #Be extra safe
1829 #
1830 #Revision 1.41 2002/07/10 00:21:45 richard
1831 #explicit database closing
1832 #
1833 #Revision 1.40 2002/07/09 04:19:09 richard
1834 #Added reindex command to roundup-admin.
1835 #Fixed reindex on first access.
1836 #Also fixed reindexing of entries that change.
1837 #
1838 #Revision 1.39 2002/07/09 03:02:52 richard
1839 #More indexer work:
1840 #- all String properties may now be indexed too. Currently there's a bit of
1841 # "issue" specific code in the actual searching which needs to be
1842 # addressed. In a nutshell:
1843 # + pass 'indexme="yes"' as a String() property initialisation arg, eg:
1844 # file = FileClass(db, "file", name=String(), type=String(),
1845 # comment=String(indexme="yes"))
1846 # + the comment will then be indexed and be searchable, with the results
1847 # related back to the issue that the file is linked to
1848 #- as a result of this work, the FileClass has a default MIME type that may
1849 # be overridden in a subclass, or by the use of a "type" property as is
1850 # done in the default templates.
1851 #- the regeneration of the indexes (if necessary) is done once the schema is
1852 # set up in the dbinit.
1853 #
1854 #Revision 1.38 2002/07/08 06:58:15 richard
1855 #cleaned up the indexer code:
1856 # - it splits more words out (much simpler, faster splitter)
1857 # - removed code we'll never use (roundup.roundup_indexer has the full
1858 # implementation, and replaces roundup.indexer)
1859 # - only index text/plain and rfc822/message (ideas for other text formats to
1860 # index are welcome)
1861 # - added simple unit test for indexer. Needs more tests for regression.
1862 #
1863 #Revision 1.37 2002/06/20 23:52:35 richard
1864 #More informative error message
1865 #
1866 #Revision 1.36 2002/06/19 03:07:19 richard
1867 #Moved the file storage commit into blobfiles where it belongs.
1868 #
1869 #Revision 1.35 2002/05/25 07:16:24 rochecompaan
1870 #Merged search_indexing-branch with HEAD
1871 #
1872 #Revision 1.34 2002/05/15 06:21:21 richard
1873 # . node caching now works, and gives a small boost in performance
1874 #
1875 #As a part of this, I cleaned up the DEBUG output and implemented TRACE
1876 #output (HYPERDBTRACE='file to trace to') with checkpoints at the start of
1877 #CGI requests. Run roundup with python -O to skip all the DEBUG/TRACE stuff
1878 #(using if __debug__ which is compiled out with -O)
1879 #
1880 #Revision 1.33 2002/04/24 10:38:26 rochecompaan
1881 #All database files are now created group readable and writable.
1882 #
1883 #Revision 1.32 2002/04/15 23:25:15 richard
1884 #. node ids are now generated from a lockable store - no more race conditions
1885 #
1886 #We're using the portalocker code by Jonathan Feinberg that was contributed
1887 #to the ASPN Python cookbook. This gives us locking across Unix and Windows.
1888 #
1889 #Revision 1.31 2002/04/03 05:54:31 richard
1890 #Fixed serialisation problem by moving the serialisation step out of the
1891 #hyperdb.Class (get, set) into the hyperdb.Database.
1892 #
1893 #Also fixed htmltemplate after the showid changes I made yesterday.
1894 #
1895 #Unit tests for all of the above written.
1896 #
1897 #Revision 1.30.2.1 2002/04/03 11:55:57 rochecompaan
1898 # . Added feature #526730 - search for messages capability
1899 #
1900 #Revision 1.30 2002/02/27 03:40:59 richard
1901 #Ran it through pychecker, made fixes
1902 #
1903 #Revision 1.29 2002/02/25 14:34:31 grubert
1904 # . use blobfiles in back_anydbm which is used in back_bsddb.
1905 # change test_db as dirlist does not work for subdirectories.
1906 # ATTENTION: blobfiles now creates subdirectories for files.
1907 #
1908 #Revision 1.28 2002/02/16 09:14:17 richard
1909 # . #514854 ] History: "User" is always ticket creator
1910 #
1911 #Revision 1.27 2002/01/22 07:21:13 richard
1912 #. fixed back_bsddb so it passed the journal tests
1913 #
1914 #... it didn't seem happy using the back_anydbm _open method, which is odd.
1915 #Yet another occurrance of whichdb not being able to recognise older bsddb
1916 #databases. Yadda yadda. Made the HYPERDBDEBUG stuff more sane in the
1917 #process.
1918 #
1919 #Revision 1.26 2002/01/22 05:18:38 rochecompaan
1920 #last_set_entry was referenced before assignment
1921 #
1922 #Revision 1.25 2002/01/22 05:06:08 rochecompaan
1923 #We need to keep the last 'set' entry in the journal to preserve
1924 #information on 'activity' for nodes.
1925 #
1926 #Revision 1.24 2002/01/21 16:33:20 rochecompaan
1927 #You can now use the roundup-admin tool to pack the database
1928 #
1929 #Revision 1.23 2002/01/18 04:32:04 richard
1930 #Rollback was breaking because a message hadn't actually been written to the file. Needs
1931 #more investigation.
1932 #
1933 #Revision 1.22 2002/01/14 02:20:15 richard
1934 # . changed all config accesses so they access either the instance or the
1935 # config attriubute on the db. This means that all config is obtained from
1936 # instance_config instead of the mish-mash of classes. This will make
1937 # switching to a ConfigParser setup easier too, I hope.
1938 #
1939 #At a minimum, this makes migration a _little_ easier (a lot easier in the
1940 #0.5.0 switch, I hope!)
1941 #
1942 #Revision 1.21 2002/01/02 02:31:38 richard
1943 #Sorry for the huge checkin message - I was only intending to implement #496356
1944 #but I found a number of places where things had been broken by transactions:
1945 # . modified ROUNDUPDBSENDMAILDEBUG to be SENDMAILDEBUG and hold a filename
1946 # for _all_ roundup-generated smtp messages to be sent to.
1947 # . the transaction cache had broken the roundupdb.Class set() reactors
1948 # . newly-created author users in the mailgw weren't being committed to the db
1949 #
1950 #Stuff that made it into CHANGES.txt (ie. the stuff I was actually working
1951 #on when I found that stuff :):
1952 # . #496356 ] Use threading in messages
1953 # . detectors were being registered multiple times
1954 # . added tests for mailgw
1955 # . much better attaching of erroneous messages in the mail gateway
1956 #
1957 #Revision 1.20 2001/12/18 15:30:34 rochecompaan
1958 #Fixed bugs:
1959 # . Fixed file creation and retrieval in same transaction in anydbm
1960 # backend
1961 # . Cgi interface now renders new issue after issue creation
1962 # . Could not set issue status to resolved through cgi interface
1963 # . Mail gateway was changing status back to 'chatting' if status was
1964 # omitted as an argument
1965 #
1966 #Revision 1.19 2001/12/17 03:52:48 richard
1967 #Implemented file store rollback. As a bonus, the hyperdb is now capable of
1968 #storing more than one file per node - if a property name is supplied,
1969 #the file is called designator.property.
1970 #I decided not to migrate the existing files stored over to the new naming
1971 #scheme - the FileClass just doesn't specify the property name.
1972 #
1973 #Revision 1.18 2001/12/16 10:53:38 richard
1974 #take a copy of the node dict so that the subsequent set
1975 #operation doesn't modify the oldvalues structure
1976 #
1977 #Revision 1.17 2001/12/14 23:42:57 richard
1978 #yuck, a gdbm instance tests false :(
1979 #I've left the debugging code in - it should be removed one day if we're ever
1980 #_really_ anal about performace :)
1981 #
1982 #Revision 1.16 2001/12/12 03:23:14 richard
1983 #Cor blimey this anydbm/whichdb stuff is yecchy. Turns out that whichdb
1984 #incorrectly identifies a dbm file as a dbhash file on my system. This has
1985 #been submitted to the python bug tracker as issue #491888:
1986 #https://sourceforge.net/tracker/index.php?func=detail&aid=491888&group_id=5470&atid=105470
1987 #
1988 #Revision 1.15 2001/12/12 02:30:51 richard
1989 #I fixed the problems with people whose anydbm was using the dbm module at the
1990 #backend. It turns out the dbm module modifies the file name to append ".db"
1991 #and my check to determine if we're opening an existing or new db just
1992 #tested os.path.exists() on the filename. Well, no longer! We now perform a
1993 #much better check _and_ cope with the anydbm implementation module changing
1994 #too!
1995 #I also fixed the backends __init__ so only ImportError is squashed.
1996 #
1997 #Revision 1.14 2001/12/10 22:20:01 richard
1998 #Enabled transaction support in the bsddb backend. It uses the anydbm code
1999 #where possible, only replacing methods where the db is opened (it uses the
2000 #btree opener specifically.)
2001 #Also cleaned up some change note generation.
2002 #Made the backends package work with pydoc too.
2003 #
2004 #Revision 1.13 2001/12/02 05:06:16 richard
2005 #. We now use weakrefs in the Classes to keep the database reference, so
2006 # the close() method on the database is no longer needed.
2007 # I bumped the minimum python requirement up to 2.1 accordingly.
2008 #. #487480 ] roundup-server
2009 #. #487476 ] INSTALL.txt
2010 #
2011 #I also cleaned up the change message / post-edit stuff in the cgi client.
2012 #There's now a clearly marked "TODO: append the change note" where I believe
2013 #the change note should be added there. The "changes" list will obviously
2014 #have to be modified to be a dict of the changes, or somesuch.
2015 #
2016 #More testing needed.
2017 #
2018 #Revision 1.12 2001/12/01 07:17:50 richard
2019 #. We now have basic transaction support! Information is only written to
2020 # the database when the commit() method is called. Only the anydbm
2021 # backend is modified in this way - neither of the bsddb backends have been.
2022 # The mail, admin and cgi interfaces all use commit (except the admin tool
2023 # doesn't have a commit command, so interactive users can't commit...)
2024 #. Fixed login/registration forwarding the user to the right page (or not,
2025 # on a failure)
2026 #
2027 #Revision 1.11 2001/11/21 02:34:18 richard
2028 #Added a target version field to the extended issue schema
2029 #
2030 #Revision 1.10 2001/10/09 23:58:10 richard
2031 #Moved the data stringification up into the hyperdb.Class class' get, set
2032 #and create methods. This means that the data is also stringified for the
2033 #journal call, and removes duplication of code from the backends. The
2034 #backend code now only sees strings.
2035 #
2036 #Revision 1.9 2001/10/09 07:25:59 richard
2037 #Added the Password property type. See "pydoc roundup.password" for
2038 #implementation details. Have updated some of the documentation too.
2039 #
2040 #Revision 1.8 2001/09/29 13:27:00 richard
2041 #CGI interfaces now spit up a top-level index of all the instances they can
2042 #serve.
2043 #
2044 #Revision 1.7 2001/08/12 06:32:36 richard
2045 #using isinstance(blah, Foo) now instead of isFooType
2046 #
2047 #Revision 1.6 2001/08/07 00:24:42 richard
2048 #stupid typo
2049 #
2050 #Revision 1.5 2001/08/07 00:15:51 richard
2051 #Added the copyright/license notice to (nearly) all files at request of
2052 #Bizar Software.
2053 #
2054 #Revision 1.4 2001/07/30 01:41:36 richard
2055 #Makes schema changes mucho easier.
2056 #
2057 #Revision 1.3 2001/07/25 01:23:07 richard
2058 #Added the Roundup spec to the new documentation directory.
2059 #
2060 #Revision 1.2 2001/07/23 08:20:44 richard
2061 #Moved over to using marshal in the bsddb and anydbm backends.
2062 #roundup-admin now has a "freshen" command that'll load/save all nodes (not
2063 # retired - mod hyperdb.Class.list() so it lists retired nodes)
2064 #
2065 #