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