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