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: hyperdb.py,v 1.50 2002-01-19 13:16:04 rochecompaan Exp $
20 __doc__ = """
21 Hyperdatabase implementation, especially field types.
22 """
24 # standard python modules
25 import cPickle, re, string, weakref
27 # roundup modules
28 import date, password
31 #
32 # Types
33 #
34 class String:
35 """An object designating a String property."""
36 def __repr__(self):
37 return '<%s>'%self.__class__
39 class Password:
40 """An object designating a Password property."""
41 def __repr__(self):
42 return '<%s>'%self.__class__
44 class Date:
45 """An object designating a Date property."""
46 def __repr__(self):
47 return '<%s>'%self.__class__
49 class Interval:
50 """An object designating an Interval property."""
51 def __repr__(self):
52 return '<%s>'%self.__class__
54 class Link:
55 """An object designating a Link property that links to a
56 node in a specified class."""
57 def __init__(self, classname, do_journal='no'):
58 self.classname = classname
59 self.do_journal = do_journal == 'yes'
60 def __repr__(self):
61 return '<%s to "%s">'%(self.__class__, self.classname)
63 class Multilink:
64 """An object designating a Multilink property that links
65 to nodes in a specified class.
66 """
67 def __init__(self, classname, do_journal='no'):
68 self.classname = classname
69 self.do_journal = do_journal == 'yes'
70 def __repr__(self):
71 return '<%s to "%s">'%(self.__class__, self.classname)
73 class DatabaseError(ValueError):
74 pass
77 #
78 # the base Database class
79 #
80 class Database:
81 '''A database for storing records containing flexible data types.
83 This class defines a hyperdatabase storage layer, which the Classes use to
84 store their data.
87 Transactions
88 ------------
89 The Database should support transactions through the commit() and
90 rollback() methods. All other Database methods should be transaction-aware,
91 using data from the current transaction before looking up the database.
93 An implementation must provide an override for the get() method so that the
94 in-database value is returned in preference to the in-transaction value.
95 This is necessary to determine if any values have changed during a
96 transaction.
98 '''
100 # flag to set on retired entries
101 RETIRED_FLAG = '__hyperdb_retired'
103 # XXX deviates from spec: storagelocator is obtained from the config
104 def __init__(self, config, journaltag=None):
105 """Open a hyperdatabase given a specifier to some storage.
107 The 'storagelocator' is obtained from config.DATABASE.
108 The meaning of 'storagelocator' depends on the particular
109 implementation of the hyperdatabase. It could be a file name,
110 a directory path, a socket descriptor for a connection to a
111 database over the network, etc.
113 The 'journaltag' is a token that will be attached to the journal
114 entries for any edits done on the database. If 'journaltag' is
115 None, the database is opened in read-only mode: the Class.create(),
116 Class.set(), and Class.retire() methods are disabled.
117 """
118 raise NotImplementedError
120 def __getattr__(self, classname):
121 """A convenient way of calling self.getclass(classname)."""
122 raise NotImplementedError
124 def addclass(self, cl):
125 '''Add a Class to the hyperdatabase.
126 '''
127 raise NotImplementedError
129 def getclasses(self):
130 """Return a list of the names of all existing classes."""
131 raise NotImplementedError
133 def getclass(self, classname):
134 """Get the Class object representing a particular class.
136 If 'classname' is not a valid class name, a KeyError is raised.
137 """
138 raise NotImplementedError
140 def clear(self):
141 '''Delete all database contents.
142 '''
143 raise NotImplementedError
145 def getclassdb(self, classname, mode='r'):
146 '''Obtain a connection to the class db that will be used for
147 multiple actions.
148 '''
149 raise NotImplementedError
151 def addnode(self, classname, nodeid, node):
152 '''Add the specified node to its class's db.
153 '''
154 raise NotImplementedError
156 def setnode(self, classname, nodeid, node):
157 '''Change the specified node.
158 '''
159 raise NotImplementedError
161 def getnode(self, classname, nodeid, db=None, cache=1):
162 '''Get a node from the database.
163 '''
164 raise NotImplementedError
166 def hasnode(self, classname, nodeid, db=None):
167 '''Determine if the database has a given node.
168 '''
169 raise NotImplementedError
171 def countnodes(self, classname, db=None):
172 '''Count the number of nodes that exist for a particular Class.
173 '''
174 raise NotImplementedError
176 def getnodeids(self, classname, db=None):
177 '''Retrieve all the ids of the nodes for a particular Class.
178 '''
179 raise NotImplementedError
181 def storefile(self, classname, nodeid, property, content):
182 '''Store the content of the file in the database.
184 The property may be None, in which case the filename does not
185 indicate which property is being saved.
186 '''
187 raise NotImplementedError
189 def getfile(self, classname, nodeid, property):
190 '''Store the content of the file in the database.
191 '''
192 raise NotImplementedError
194 def addjournal(self, classname, nodeid, action, params):
195 ''' Journal the Action
196 'action' may be:
198 'create' or 'set' -- 'params' is a dictionary of property values
199 'link' or 'unlink' -- 'params' is (classname, nodeid, propname)
200 'retire' -- 'params' is None
201 '''
202 raise NotImplementedError
204 def getjournal(self, classname, nodeid):
205 ''' get the journal for id
206 '''
207 raise NotImplementedError
209 def commit(self):
210 ''' Commit the current transactions.
212 Save all data changed since the database was opened or since the
213 last commit() or rollback().
214 '''
215 raise NotImplementedError
217 def rollback(self):
218 ''' Reverse all actions from the current transaction.
220 Undo all the changes made since the database was opened or the last
221 commit() or rollback() was performed.
222 '''
223 raise NotImplementedError
225 _marker = []
226 #
227 # The base Class class
228 #
229 class Class:
230 """The handle to a particular class of nodes in a hyperdatabase."""
232 def __init__(self, db, classname, **properties):
233 """Create a new class with a given name and property specification.
235 'classname' must not collide with the name of an existing class,
236 or a ValueError is raised. The keyword arguments in 'properties'
237 must map names to property objects, or a TypeError is raised.
238 """
239 self.classname = classname
240 self.properties = properties
241 self.db = weakref.proxy(db) # use a weak ref to avoid circularity
242 self.key = ''
244 # do the db-related init stuff
245 db.addclass(self)
247 def __repr__(self):
248 return '<hypderdb.Class "%s">'%self.classname
250 # Editing nodes:
252 def create(self, **propvalues):
253 """Create a new node of this class and return its id.
255 The keyword arguments in 'propvalues' map property names to values.
257 The values of arguments must be acceptable for the types of their
258 corresponding properties or a TypeError is raised.
260 If this class has a key property, it must be present and its value
261 must not collide with other key strings or a ValueError is raised.
263 Any other properties on this class that are missing from the
264 'propvalues' dictionary are set to None.
266 If an id in a link or multilink property does not refer to a valid
267 node, an IndexError is raised.
268 """
269 if propvalues.has_key('id'):
270 raise KeyError, '"id" is reserved'
272 if self.db.journaltag is None:
273 raise DatabaseError, 'Database open read-only'
275 # new node's id
276 newid = str(self.count() + 1)
278 # validate propvalues
279 num_re = re.compile('^\d+$')
280 for key, value in propvalues.items():
281 if key == self.key:
282 try:
283 self.lookup(value)
284 except KeyError:
285 pass
286 else:
287 raise ValueError, 'node with key "%s" exists'%value
289 # try to handle this property
290 try:
291 prop = self.properties[key]
292 except KeyError:
293 raise KeyError, '"%s" has no property "%s"'%(self.classname,
294 key)
296 if isinstance(prop, Link):
297 if type(value) != type(''):
298 raise ValueError, 'link value must be String'
299 link_class = self.properties[key].classname
300 # if it isn't a number, it's a key
301 if not num_re.match(value):
302 try:
303 value = self.db.classes[link_class].lookup(value)
304 except (TypeError, KeyError):
305 raise IndexError, 'new property "%s": %s not a %s'%(
306 key, value, link_class)
307 elif not self.db.hasnode(link_class, value):
308 raise IndexError, '%s has no node %s'%(link_class, value)
310 # save off the value
311 propvalues[key] = value
313 # register the link with the newly linked node
314 if self.properties[key].do_journal:
315 self.db.addjournal(link_class, value, 'link',
316 (self.classname, newid, key))
318 elif isinstance(prop, Multilink):
319 if type(value) != type([]):
320 raise TypeError, 'new property "%s" not a list of ids'%key
321 link_class = self.properties[key].classname
322 l = []
323 for entry in value:
324 if type(entry) != type(''):
325 raise ValueError, 'link value must be String'
326 # if it isn't a number, it's a key
327 if not num_re.match(entry):
328 try:
329 entry = self.db.classes[link_class].lookup(entry)
330 except (TypeError, KeyError):
331 raise IndexError, 'new property "%s": %s not a %s'%(
332 key, entry, self.properties[key].classname)
333 l.append(entry)
334 value = l
335 propvalues[key] = value
337 # handle additions
338 for id in value:
339 if not self.db.hasnode(link_class, id):
340 raise IndexError, '%s has no node %s'%(link_class, id)
341 # register the link with the newly linked node
342 if self.properties[key].do_journal:
343 self.db.addjournal(link_class, id, 'link',
344 (self.classname, newid, key))
346 elif isinstance(prop, String):
347 if type(value) != type(''):
348 raise TypeError, 'new property "%s" not a string'%key
350 elif isinstance(prop, Password):
351 if not isinstance(value, password.Password):
352 raise TypeError, 'new property "%s" not a Password'%key
354 elif isinstance(prop, Date):
355 if value is not None and not isinstance(value, date.Date):
356 raise TypeError, 'new property "%s" not a Date'%key
358 elif isinstance(prop, Interval):
359 if value is not None and not isinstance(value, date.Interval):
360 raise TypeError, 'new property "%s" not an Interval'%key
362 # make sure there's data where there needs to be
363 for key, prop in self.properties.items():
364 if propvalues.has_key(key):
365 continue
366 if key == self.key:
367 raise ValueError, 'key property "%s" is required'%key
368 if isinstance(prop, Multilink):
369 propvalues[key] = []
370 else:
371 # TODO: None isn't right here, I think...
372 propvalues[key] = None
374 # convert all data to strings
375 for key, prop in self.properties.items():
376 if isinstance(prop, Date):
377 if propvalues[key] is not None:
378 propvalues[key] = propvalues[key].get_tuple()
379 elif isinstance(prop, Interval):
380 if propvalues[key] is not None:
381 propvalues[key] = propvalues[key].get_tuple()
382 elif isinstance(prop, Password):
383 propvalues[key] = str(propvalues[key])
385 # done
386 self.db.addnode(self.classname, newid, propvalues)
387 self.db.addjournal(self.classname, newid, 'create', propvalues)
388 return newid
390 def get(self, nodeid, propname, default=_marker, cache=1):
391 """Get the value of a property on an existing node of this class.
393 'nodeid' must be the id of an existing node of this class or an
394 IndexError is raised. 'propname' must be the name of a property
395 of this class or a KeyError is raised.
397 'cache' indicates whether the transaction cache should be queried
398 for the node. If the node has been modified and you need to
399 determine what its values prior to modification are, you need to
400 set cache=0.
401 """
402 if propname == 'id':
403 return nodeid
405 # get the property (raises KeyErorr if invalid)
406 prop = self.properties[propname]
408 # get the node's dict
409 d = self.db.getnode(self.classname, nodeid, cache=cache)
411 if not d.has_key(propname):
412 if default is _marker:
413 if isinstance(prop, Multilink):
414 return []
415 else:
416 # TODO: None isn't right here, I think...
417 return None
418 else:
419 return default
421 # possibly convert the marshalled data to instances
422 if isinstance(prop, Date):
423 if d[propname] is None:
424 return None
425 return date.Date(d[propname])
426 elif isinstance(prop, Interval):
427 if d[propname] is None:
428 return None
429 return date.Interval(d[propname])
430 elif isinstance(prop, Password):
431 p = password.Password()
432 p.unpack(d[propname])
433 return p
435 return d[propname]
437 # XXX not in spec
438 def getnode(self, nodeid, cache=1):
439 ''' Return a convenience wrapper for the node.
441 'nodeid' must be the id of an existing node of this class or an
442 IndexError is raised.
444 'cache' indicates whether the transaction cache should be queried
445 for the node. If the node has been modified and you need to
446 determine what its values prior to modification are, you need to
447 set cache=0.
448 '''
449 return Node(self, nodeid, cache=cache)
451 def set(self, nodeid, **propvalues):
452 """Modify a property on an existing node of this class.
454 'nodeid' must be the id of an existing node of this class or an
455 IndexError is raised.
457 Each key in 'propvalues' must be the name of a property of this
458 class or a KeyError is raised.
460 All values in 'propvalues' must be acceptable types for their
461 corresponding properties or a TypeError is raised.
463 If the value of the key property is set, it must not collide with
464 other key strings or a ValueError is raised.
466 If the value of a Link or Multilink property contains an invalid
467 node id, a ValueError is raised.
468 """
469 if not propvalues:
470 return
472 if propvalues.has_key('id'):
473 raise KeyError, '"id" is reserved'
475 if self.db.journaltag is None:
476 raise DatabaseError, 'Database open read-only'
478 node = self.db.getnode(self.classname, nodeid)
479 if node.has_key(self.db.RETIRED_FLAG):
480 raise IndexError
481 num_re = re.compile('^\d+$')
482 for key, value in propvalues.items():
483 # check to make sure we're not duplicating an existing key
484 if key == self.key and node[key] != value:
485 try:
486 self.lookup(value)
487 except KeyError:
488 pass
489 else:
490 raise ValueError, 'node with key "%s" exists'%value
492 # this will raise the KeyError if the property isn't valid
493 # ... we don't use getprops() here because we only care about
494 # the writeable properties.
495 prop = self.properties[key]
497 if isinstance(prop, Link):
498 link_class = self.properties[key].classname
499 # if it isn't a number, it's a key
500 if type(value) != type(''):
501 raise ValueError, 'link value must be String'
502 if not num_re.match(value):
503 try:
504 value = self.db.classes[link_class].lookup(value)
505 except (TypeError, KeyError):
506 raise IndexError, 'new property "%s": %s not a %s'%(
507 key, value, self.properties[key].classname)
509 if not self.db.hasnode(link_class, value):
510 raise IndexError, '%s has no node %s'%(link_class, value)
512 if self.properties[key].do_journal:
513 # register the unlink with the old linked node
514 if node[key] is not None:
515 self.db.addjournal(link_class, node[key], 'unlink',
516 (self.classname, nodeid, key))
518 # register the link with the newly linked node
519 if value is not None:
520 self.db.addjournal(link_class, value, 'link',
521 (self.classname, nodeid, key))
523 elif isinstance(prop, Multilink):
524 if type(value) != type([]):
525 raise TypeError, 'new property "%s" not a list of ids'%key
526 link_class = self.properties[key].classname
527 l = []
528 for entry in value:
529 # if it isn't a number, it's a key
530 if type(entry) != type(''):
531 raise ValueError, 'link value must be String'
532 if not num_re.match(entry):
533 try:
534 entry = self.db.classes[link_class].lookup(entry)
535 except (TypeError, KeyError):
536 raise IndexError, 'new property "%s": %s not a %s'%(
537 key, entry, self.properties[key].classname)
538 l.append(entry)
539 value = l
540 propvalues[key] = value
542 # handle removals
543 if node.has_key(key):
544 l = node[key]
545 else:
546 l = []
547 for id in l[:]:
548 if id in value:
549 continue
550 # register the unlink with the old linked node
551 if self.properties[key].do_journal:
552 self.db.addjournal(link_class, id, 'unlink',
553 (self.classname, nodeid, key))
554 l.remove(id)
556 # handle additions
557 for id in value:
558 if not self.db.hasnode(link_class, id):
559 raise IndexError, '%s has no node %s'%(
560 link_class, id)
561 if id in l:
562 continue
563 # register the link with the newly linked node
564 if self.properties[key].do_journal:
565 self.db.addjournal(link_class, id, 'link',
566 (self.classname, nodeid, key))
567 l.append(id)
569 elif isinstance(prop, String):
570 if value is not None and type(value) != type(''):
571 raise TypeError, 'new property "%s" not a string'%key
573 elif isinstance(prop, Password):
574 if not isinstance(value, password.Password):
575 raise TypeError, 'new property "%s" not a Password'% key
576 propvalues[key] = value = str(value)
578 elif value is not None and isinstance(prop, Date):
579 if not isinstance(value, date.Date):
580 raise TypeError, 'new property "%s" not a Date'% key
581 propvalues[key] = value = value.get_tuple()
583 elif value is not None and isinstance(prop, Interval):
584 if not isinstance(value, date.Interval):
585 raise TypeError, 'new property "%s" not an Interval'% key
586 propvalues[key] = value = value.get_tuple()
588 node[key] = value
590 self.db.setnode(self.classname, nodeid, node)
591 self.db.addjournal(self.classname, nodeid, 'set', propvalues)
593 def retire(self, nodeid):
594 """Retire a node.
596 The properties on the node remain available from the get() method,
597 and the node's id is never reused.
599 Retired nodes are not returned by the find(), list(), or lookup()
600 methods, and other nodes may reuse the values of their key properties.
601 """
602 if self.db.journaltag is None:
603 raise DatabaseError, 'Database open read-only'
604 node = self.db.getnode(self.classname, nodeid)
605 node[self.db.RETIRED_FLAG] = 1
606 self.db.setnode(self.classname, nodeid, node)
607 self.db.addjournal(self.classname, nodeid, 'retired', None)
609 def history(self, nodeid):
610 """Retrieve the journal of edits on a particular node.
612 'nodeid' must be the id of an existing node of this class or an
613 IndexError is raised.
615 The returned list contains tuples of the form
617 (date, tag, action, params)
619 'date' is a Timestamp object specifying the time of the change and
620 'tag' is the journaltag specified when the database was opened.
621 """
622 return self.db.getjournal(self.classname, nodeid)
624 # Locating nodes:
626 def setkey(self, propname):
627 """Select a String property of this class to be the key property.
629 'propname' must be the name of a String property of this class or
630 None, or a TypeError is raised. The values of the key property on
631 all existing nodes must be unique or a ValueError is raised.
632 """
633 # TODO: validate that the property is a String!
634 self.key = propname
636 def getkey(self):
637 """Return the name of the key property for this class or None."""
638 return self.key
640 def labelprop(self, default_to_id=0):
641 ''' Return the property name for a label for the given node.
643 This method attempts to generate a consistent label for the node.
644 It tries the following in order:
645 1. key property
646 2. "name" property
647 3. "title" property
648 4. first property from the sorted property name list
649 '''
650 k = self.getkey()
651 if k:
652 return k
653 props = self.getprops()
654 if props.has_key('name'):
655 return 'name'
656 elif props.has_key('title'):
657 return 'title'
658 if default_to_id:
659 return 'id'
660 props = props.keys()
661 props.sort()
662 return props[0]
664 # TODO: set up a separate index db file for this? profile?
665 def lookup(self, keyvalue):
666 """Locate a particular node by its key property and return its id.
668 If this class has no key property, a TypeError is raised. If the
669 'keyvalue' matches one of the values for the key property among
670 the nodes in this class, the matching node's id is returned;
671 otherwise a KeyError is raised.
672 """
673 cldb = self.db.getclassdb(self.classname)
674 for nodeid in self.db.getnodeids(self.classname, cldb):
675 node = self.db.getnode(self.classname, nodeid, cldb)
676 if node.has_key(self.db.RETIRED_FLAG):
677 continue
678 if node[self.key] == keyvalue:
679 return nodeid
680 raise KeyError, keyvalue
682 # XXX: change from spec - allows multiple props to match
683 def find(self, **propspec):
684 """Get the ids of nodes in this class which link to a given node.
686 'propspec' consists of keyword args propname=nodeid
687 'propname' must be the name of a property in this class, or a
688 KeyError is raised. That property must be a Link or Multilink
689 property, or a TypeError is raised.
691 'nodeid' must be the id of an existing node in the class linked
692 to by the given property, or an IndexError is raised.
693 """
694 propspec = propspec.items()
695 for propname, nodeid in propspec:
696 # check the prop is OK
697 prop = self.properties[propname]
698 if not isinstance(prop, Link) and not isinstance(prop, Multilink):
699 raise TypeError, "'%s' not a Link/Multilink property"%propname
700 if not self.db.hasnode(prop.classname, nodeid):
701 raise ValueError, '%s has no node %s'%(prop.classname, nodeid)
703 # ok, now do the find
704 cldb = self.db.getclassdb(self.classname)
705 l = []
706 for id in self.db.getnodeids(self.classname, cldb):
707 node = self.db.getnode(self.classname, id, cldb)
708 if node.has_key(self.db.RETIRED_FLAG):
709 continue
710 for propname, nodeid in propspec:
711 property = node[propname]
712 if isinstance(prop, Link) and nodeid == property:
713 l.append(id)
714 elif isinstance(prop, Multilink) and nodeid in property:
715 l.append(id)
716 return l
718 def stringFind(self, **requirements):
719 """Locate a particular node by matching a set of its String
720 properties in a caseless search.
722 If the property is not a String property, a TypeError is raised.
724 The return is a list of the id of all nodes that match.
725 """
726 for propname in requirements.keys():
727 prop = self.properties[propname]
728 if isinstance(not prop, String):
729 raise TypeError, "'%s' not a String property"%propname
730 requirements[propname] = requirements[propname].lower()
731 l = []
732 cldb = self.db.getclassdb(self.classname)
733 for nodeid in self.db.getnodeids(self.classname, cldb):
734 node = self.db.getnode(self.classname, nodeid, cldb)
735 if node.has_key(self.db.RETIRED_FLAG):
736 continue
737 for key, value in requirements.items():
738 if node[key] and node[key].lower() != value:
739 break
740 else:
741 l.append(nodeid)
742 return l
744 def list(self):
745 """Return a list of the ids of the active nodes in this class."""
746 l = []
747 cn = self.classname
748 cldb = self.db.getclassdb(cn)
749 for nodeid in self.db.getnodeids(cn, cldb):
750 node = self.db.getnode(cn, nodeid, cldb)
751 if node.has_key(self.db.RETIRED_FLAG):
752 continue
753 l.append(nodeid)
754 l.sort()
755 return l
757 # XXX not in spec
758 def filter(self, filterspec, sort, group, num_re = re.compile('^\d+$')):
759 ''' Return a list of the ids of the active nodes in this class that
760 match the 'filter' spec, sorted by the group spec and then the
761 sort spec
762 '''
763 cn = self.classname
765 # optimise filterspec
766 l = []
767 props = self.getprops()
768 for k, v in filterspec.items():
769 propclass = props[k]
770 if isinstance(propclass, Link):
771 if type(v) is not type([]):
772 v = [v]
773 # replace key values with node ids
774 u = []
775 link_class = self.db.classes[propclass.classname]
776 for entry in v:
777 if entry == '-1': entry = None
778 elif not num_re.match(entry):
779 try:
780 entry = link_class.lookup(entry)
781 except (TypeError,KeyError):
782 raise ValueError, 'property "%s": %s not a %s'%(
783 k, entry, self.properties[k].classname)
784 u.append(entry)
786 l.append((0, k, u))
787 elif isinstance(propclass, Multilink):
788 if type(v) is not type([]):
789 v = [v]
790 # replace key values with node ids
791 u = []
792 link_class = self.db.classes[propclass.classname]
793 for entry in v:
794 if not num_re.match(entry):
795 try:
796 entry = link_class.lookup(entry)
797 except (TypeError,KeyError):
798 raise ValueError, 'new property "%s": %s not a %s'%(
799 k, entry, self.properties[k].classname)
800 u.append(entry)
801 l.append((1, k, u))
802 elif isinstance(propclass, String):
803 # simple glob searching
804 v = re.sub(r'([\|\{\}\\\.\+\[\]\(\)])', r'\\\1', v)
805 v = v.replace('?', '.')
806 v = v.replace('*', '.*?')
807 l.append((2, k, re.compile(v, re.I)))
808 else:
809 l.append((6, k, v))
810 filterspec = l
812 # now, find all the nodes that are active and pass filtering
813 l = []
814 cldb = self.db.getclassdb(cn)
815 for nodeid in self.db.getnodeids(cn, cldb):
816 node = self.db.getnode(cn, nodeid, cldb)
817 if node.has_key(self.db.RETIRED_FLAG):
818 continue
819 # apply filter
820 for t, k, v in filterspec:
821 # this node doesn't have this property, so reject it
822 if not node.has_key(k): break
824 if t == 0 and node[k] not in v:
825 # link - if this node'd property doesn't appear in the
826 # filterspec's nodeid list, skip it
827 break
828 elif t == 1:
829 # multilink - if any of the nodeids required by the
830 # filterspec aren't in this node's property, then skip
831 # it
832 for value in v:
833 if value not in node[k]:
834 break
835 else:
836 continue
837 break
838 elif t == 2 and not v.search(node[k]):
839 # RE search
840 break
841 elif t == 6 and node[k] != v:
842 # straight value comparison for the other types
843 break
844 else:
845 l.append((nodeid, node))
846 l.sort()
848 # optimise sort
849 m = []
850 for entry in sort:
851 if entry[0] != '-':
852 m.append(('+', entry))
853 else:
854 m.append((entry[0], entry[1:]))
855 sort = m
857 # optimise group
858 m = []
859 for entry in group:
860 if entry[0] != '-':
861 m.append(('+', entry))
862 else:
863 m.append((entry[0], entry[1:]))
864 group = m
865 # now, sort the result
866 def sortfun(a, b, sort=sort, group=group, properties=self.getprops(),
867 db = self.db, cl=self):
868 a_id, an = a
869 b_id, bn = b
870 # sort by group and then sort
871 for list in group, sort:
872 for dir, prop in list:
873 # sorting is class-specific
874 propclass = properties[prop]
876 # handle the properties that might be "faked"
877 # also, handle possible missing properties
878 try:
879 if not an.has_key(prop):
880 an[prop] = cl.get(a_id, prop)
881 av = an[prop]
882 except KeyError:
883 # the node doesn't have a value for this property
884 if isinstance(propclass, Multilink): av = []
885 else: av = ''
886 try:
887 if not bn.has_key(prop):
888 bn[prop] = cl.get(b_id, prop)
889 bv = bn[prop]
890 except KeyError:
891 # the node doesn't have a value for this property
892 if isinstance(propclass, Multilink): bv = []
893 else: bv = ''
895 # String and Date values are sorted in the natural way
896 if isinstance(propclass, String):
897 # clean up the strings
898 if av and av[0] in string.uppercase:
899 av = an[prop] = av.lower()
900 if bv and bv[0] in string.uppercase:
901 bv = bn[prop] = bv.lower()
902 if (isinstance(propclass, String) or
903 isinstance(propclass, Date)):
904 # it might be a string that's really an integer
905 try:
906 av = int(av)
907 bv = int(bv)
908 except:
909 pass
910 if dir == '+':
911 r = cmp(av, bv)
912 if r != 0: return r
913 elif dir == '-':
914 r = cmp(bv, av)
915 if r != 0: return r
917 # Link properties are sorted according to the value of
918 # the "order" property on the linked nodes if it is
919 # present; or otherwise on the key string of the linked
920 # nodes; or finally on the node ids.
921 elif isinstance(propclass, Link):
922 link = db.classes[propclass.classname]
923 if av is None and bv is not None: return -1
924 if av is not None and bv is None: return 1
925 if av is None and bv is None: continue
926 if link.getprops().has_key('order'):
927 if dir == '+':
928 r = cmp(link.get(av, 'order'),
929 link.get(bv, 'order'))
930 if r != 0: return r
931 elif dir == '-':
932 r = cmp(link.get(bv, 'order'),
933 link.get(av, 'order'))
934 if r != 0: return r
935 elif link.getkey():
936 key = link.getkey()
937 if dir == '+':
938 r = cmp(link.get(av, key), link.get(bv, key))
939 if r != 0: return r
940 elif dir == '-':
941 r = cmp(link.get(bv, key), link.get(av, key))
942 if r != 0: return r
943 else:
944 if dir == '+':
945 r = cmp(av, bv)
946 if r != 0: return r
947 elif dir == '-':
948 r = cmp(bv, av)
949 if r != 0: return r
951 # Multilink properties are sorted according to how many
952 # links are present.
953 elif isinstance(propclass, Multilink):
954 if dir == '+':
955 r = cmp(len(av), len(bv))
956 if r != 0: return r
957 elif dir == '-':
958 r = cmp(len(bv), len(av))
959 if r != 0: return r
960 # end for dir, prop in list:
961 # end for list in sort, group:
962 # if all else fails, compare the ids
963 return cmp(a[0], b[0])
965 l.sort(sortfun)
966 return [i[0] for i in l]
968 def count(self):
969 """Get the number of nodes in this class.
971 If the returned integer is 'numnodes', the ids of all the nodes
972 in this class run from 1 to numnodes, and numnodes+1 will be the
973 id of the next node to be created in this class.
974 """
975 return self.db.countnodes(self.classname)
977 # Manipulating properties:
979 def getprops(self, protected=1):
980 """Return a dictionary mapping property names to property objects.
981 If the "protected" flag is true, we include protected properties -
982 those which may not be modified."""
983 d = self.properties.copy()
984 if protected:
985 d['id'] = String()
986 return d
988 def addprop(self, **properties):
989 """Add properties to this class.
991 The keyword arguments in 'properties' must map names to property
992 objects, or a TypeError is raised. None of the keys in 'properties'
993 may collide with the names of existing properties, or a ValueError
994 is raised before any properties have been added.
995 """
996 for key in properties.keys():
997 if self.properties.has_key(key):
998 raise ValueError, key
999 self.properties.update(properties)
1002 # XXX not in spec
1003 class Node:
1004 ''' A convenience wrapper for the given node
1005 '''
1006 def __init__(self, cl, nodeid, cache=1):
1007 self.__dict__['cl'] = cl
1008 self.__dict__['nodeid'] = nodeid
1009 self.__dict__['cache'] = cache
1010 def keys(self, protected=1):
1011 return self.cl.getprops(protected=protected).keys()
1012 def values(self, protected=1):
1013 l = []
1014 for name in self.cl.getprops(protected=protected).keys():
1015 l.append(self.cl.get(self.nodeid, name, cache=self.cache))
1016 return l
1017 def items(self, protected=1):
1018 l = []
1019 for name in self.cl.getprops(protected=protected).keys():
1020 l.append((name, self.cl.get(self.nodeid, name, cache=self.cache)))
1021 return l
1022 def has_key(self, name):
1023 return self.cl.getprops().has_key(name)
1024 def __getattr__(self, name):
1025 if self.__dict__.has_key(name):
1026 return self.__dict__[name]
1027 try:
1028 return self.cl.get(self.nodeid, name, cache=self.cache)
1029 except KeyError, value:
1030 # we trap this but re-raise it as AttributeError - all other
1031 # exceptions should pass through untrapped
1032 pass
1033 # nope, no such attribute
1034 raise AttributeError, str(value)
1035 def __getitem__(self, name):
1036 return self.cl.get(self.nodeid, name, cache=self.cache)
1037 def __setattr__(self, name, value):
1038 try:
1039 return self.cl.set(self.nodeid, **{name: value})
1040 except KeyError, value:
1041 raise AttributeError, str(value)
1042 def __setitem__(self, name, value):
1043 self.cl.set(self.nodeid, **{name: value})
1044 def history(self):
1045 return self.cl.history(self.nodeid)
1046 def retire(self):
1047 return self.cl.retire(self.nodeid)
1050 def Choice(name, *options):
1051 cl = Class(db, name, name=hyperdb.String(), order=hyperdb.String())
1052 for i in range(len(options)):
1053 cl.create(name=option[i], order=i)
1054 return hyperdb.Link(name)
1056 #
1057 # $Log: not supported by cvs2svn $
1058 # Revision 1.49 2002/01/16 07:02:57 richard
1059 # . lots of date/interval related changes:
1060 # - more relaxed date format for input
1061 #
1062 # Revision 1.48 2002/01/14 06:32:34 richard
1063 # . #502951 ] adding new properties to old database
1064 #
1065 # Revision 1.47 2002/01/14 02:20:15 richard
1066 # . changed all config accesses so they access either the instance or the
1067 # config attriubute on the db. This means that all config is obtained from
1068 # instance_config instead of the mish-mash of classes. This will make
1069 # switching to a ConfigParser setup easier too, I hope.
1070 #
1071 # At a minimum, this makes migration a _little_ easier (a lot easier in the
1072 # 0.5.0 switch, I hope!)
1073 #
1074 # Revision 1.46 2002/01/07 10:42:23 richard
1075 # oops
1076 #
1077 # Revision 1.45 2002/01/02 04:18:17 richard
1078 # hyperdb docstrings
1079 #
1080 # Revision 1.44 2002/01/02 02:31:38 richard
1081 # Sorry for the huge checkin message - I was only intending to implement #496356
1082 # but I found a number of places where things had been broken by transactions:
1083 # . modified ROUNDUPDBSENDMAILDEBUG to be SENDMAILDEBUG and hold a filename
1084 # for _all_ roundup-generated smtp messages to be sent to.
1085 # . the transaction cache had broken the roundupdb.Class set() reactors
1086 # . newly-created author users in the mailgw weren't being committed to the db
1087 #
1088 # Stuff that made it into CHANGES.txt (ie. the stuff I was actually working
1089 # on when I found that stuff :):
1090 # . #496356 ] Use threading in messages
1091 # . detectors were being registered multiple times
1092 # . added tests for mailgw
1093 # . much better attaching of erroneous messages in the mail gateway
1094 #
1095 # Revision 1.43 2001/12/20 06:13:24 rochecompaan
1096 # Bugs fixed:
1097 # . Exception handling in hyperdb for strings-that-look-like numbers got
1098 # lost somewhere
1099 # . Internet Explorer submits full path for filename - we now strip away
1100 # the path
1101 # Features added:
1102 # . Link and multilink properties are now displayed sorted in the cgi
1103 # interface
1104 #
1105 # Revision 1.42 2001/12/16 10:53:37 richard
1106 # take a copy of the node dict so that the subsequent set
1107 # operation doesn't modify the oldvalues structure
1108 #
1109 # Revision 1.41 2001/12/15 23:47:47 richard
1110 # Cleaned up some bare except statements
1111 #
1112 # Revision 1.40 2001/12/14 23:42:57 richard
1113 # yuck, a gdbm instance tests false :(
1114 # I've left the debugging code in - it should be removed one day if we're ever
1115 # _really_ anal about performace :)
1116 #
1117 # Revision 1.39 2001/12/02 05:06:16 richard
1118 # . We now use weakrefs in the Classes to keep the database reference, so
1119 # the close() method on the database is no longer needed.
1120 # I bumped the minimum python requirement up to 2.1 accordingly.
1121 # . #487480 ] roundup-server
1122 # . #487476 ] INSTALL.txt
1123 #
1124 # I also cleaned up the change message / post-edit stuff in the cgi client.
1125 # There's now a clearly marked "TODO: append the change note" where I believe
1126 # the change note should be added there. The "changes" list will obviously
1127 # have to be modified to be a dict of the changes, or somesuch.
1128 #
1129 # More testing needed.
1130 #
1131 # Revision 1.38 2001/12/01 07:17:50 richard
1132 # . We now have basic transaction support! Information is only written to
1133 # the database when the commit() method is called. Only the anydbm
1134 # backend is modified in this way - neither of the bsddb backends have been.
1135 # The mail, admin and cgi interfaces all use commit (except the admin tool
1136 # doesn't have a commit command, so interactive users can't commit...)
1137 # . Fixed login/registration forwarding the user to the right page (or not,
1138 # on a failure)
1139 #
1140 # Revision 1.37 2001/11/28 21:55:35 richard
1141 # . login_action and newuser_action return values were being ignored
1142 # . Woohoo! Found that bloody re-login bug that was killing the mail
1143 # gateway.
1144 # (also a minor cleanup in hyperdb)
1145 #
1146 # Revision 1.36 2001/11/27 03:16:09 richard
1147 # Another place that wasn't handling missing properties.
1148 #
1149 # Revision 1.35 2001/11/22 15:46:42 jhermann
1150 # Added module docstrings to all modules.
1151 #
1152 # Revision 1.34 2001/11/21 04:04:43 richard
1153 # *sigh* more missing value handling
1154 #
1155 # Revision 1.33 2001/11/21 03:40:54 richard
1156 # more new property handling
1157 #
1158 # Revision 1.32 2001/11/21 03:11:28 richard
1159 # Better handling of new properties.
1160 #
1161 # Revision 1.31 2001/11/12 22:01:06 richard
1162 # Fixed issues with nosy reaction and author copies.
1163 #
1164 # Revision 1.30 2001/11/09 10:11:08 richard
1165 # . roundup-admin now handles all hyperdb exceptions
1166 #
1167 # Revision 1.29 2001/10/27 00:17:41 richard
1168 # Made Class.stringFind() do caseless matching.
1169 #
1170 # Revision 1.28 2001/10/21 04:44:50 richard
1171 # bug #473124: UI inconsistency with Link fields.
1172 # This also prompted me to fix a fairly long-standing usability issue -
1173 # that of being able to turn off certain filters.
1174 #
1175 # Revision 1.27 2001/10/20 23:44:27 richard
1176 # Hyperdatabase sorts strings-that-look-like-numbers as numbers now.
1177 #
1178 # Revision 1.26 2001/10/16 03:48:01 richard
1179 # admin tool now complains if a "find" is attempted with a non-link property.
1180 #
1181 # Revision 1.25 2001/10/11 00:17:51 richard
1182 # Reverted a change in hyperdb so the default value for missing property
1183 # values in a create() is None and not '' (the empty string.) This obviously
1184 # breaks CSV import/export - the string 'None' will be created in an
1185 # export/import operation.
1186 #
1187 # Revision 1.24 2001/10/10 03:54:57 richard
1188 # Added database importing and exporting through CSV files.
1189 # Uses the csv module from object-craft for exporting if it's available.
1190 # Requires the csv module for importing.
1191 #
1192 # Revision 1.23 2001/10/09 23:58:10 richard
1193 # Moved the data stringification up into the hyperdb.Class class' get, set
1194 # and create methods. This means that the data is also stringified for the
1195 # journal call, and removes duplication of code from the backends. The
1196 # backend code now only sees strings.
1197 #
1198 # Revision 1.22 2001/10/09 07:25:59 richard
1199 # Added the Password property type. See "pydoc roundup.password" for
1200 # implementation details. Have updated some of the documentation too.
1201 #
1202 # Revision 1.21 2001/10/05 02:23:24 richard
1203 # . roundup-admin create now prompts for property info if none is supplied
1204 # on the command-line.
1205 # . hyperdb Class getprops() method may now return only the mutable
1206 # properties.
1207 # . Login now uses cookies, which makes it a whole lot more flexible. We can
1208 # now support anonymous user access (read-only, unless there's an
1209 # "anonymous" user, in which case write access is permitted). Login
1210 # handling has been moved into cgi_client.Client.main()
1211 # . The "extended" schema is now the default in roundup init.
1212 # . The schemas have had their page headings modified to cope with the new
1213 # login handling. Existing installations should copy the interfaces.py
1214 # file from the roundup lib directory to their instance home.
1215 # . Incorrectly had a Bizar Software copyright on the cgitb.py module from
1216 # Ping - has been removed.
1217 # . Fixed a whole bunch of places in the CGI interface where we should have
1218 # been returning Not Found instead of throwing an exception.
1219 # . Fixed a deviation from the spec: trying to modify the 'id' property of
1220 # an item now throws an exception.
1221 #
1222 # Revision 1.20 2001/10/04 02:12:42 richard
1223 # Added nicer command-line item adding: passing no arguments will enter an
1224 # interactive more which asks for each property in turn. While I was at it, I
1225 # fixed an implementation problem WRT the spec - I wasn't raising a
1226 # ValueError if the key property was missing from a create(). Also added a
1227 # protected=boolean argument to getprops() so we can list only the mutable
1228 # properties (defaults to yes, which lists the immutables).
1229 #
1230 # Revision 1.19 2001/08/29 04:47:18 richard
1231 # Fixed CGI client change messages so they actually include the properties
1232 # changed (again).
1233 #
1234 # Revision 1.18 2001/08/16 07:34:59 richard
1235 # better CGI text searching - but hidden filter fields are disappearing...
1236 #
1237 # Revision 1.17 2001/08/16 06:59:58 richard
1238 # all searches use re now - and they're all case insensitive
1239 #
1240 # Revision 1.16 2001/08/15 23:43:18 richard
1241 # Fixed some isFooTypes that I missed.
1242 # Refactored some code in the CGI code.
1243 #
1244 # Revision 1.15 2001/08/12 06:32:36 richard
1245 # using isinstance(blah, Foo) now instead of isFooType
1246 #
1247 # Revision 1.14 2001/08/07 00:24:42 richard
1248 # stupid typo
1249 #
1250 # Revision 1.13 2001/08/07 00:15:51 richard
1251 # Added the copyright/license notice to (nearly) all files at request of
1252 # Bizar Software.
1253 #
1254 # Revision 1.12 2001/08/02 06:38:17 richard
1255 # Roundupdb now appends "mailing list" information to its messages which
1256 # include the e-mail address and web interface address. Templates may
1257 # override this in their db classes to include specific information (support
1258 # instructions, etc).
1259 #
1260 # Revision 1.11 2001/08/01 04:24:21 richard
1261 # mailgw was assuming certain properties existed on the issues being created.
1262 #
1263 # Revision 1.10 2001/07/30 02:38:31 richard
1264 # get() now has a default arg - for migration only.
1265 #
1266 # Revision 1.9 2001/07/29 09:28:23 richard
1267 # Fixed sorting by clicking on column headings.
1268 #
1269 # Revision 1.8 2001/07/29 08:27:40 richard
1270 # Fixed handling of passed-in values in form elements (ie. during a
1271 # drill-down)
1272 #
1273 # Revision 1.7 2001/07/29 07:01:39 richard
1274 # Added vim command to all source so that we don't get no steenkin' tabs :)
1275 #
1276 # Revision 1.6 2001/07/29 05:36:14 richard
1277 # Cleanup of the link label generation.
1278 #
1279 # Revision 1.5 2001/07/29 04:05:37 richard
1280 # Added the fabricated property "id".
1281 #
1282 # Revision 1.4 2001/07/27 06:25:35 richard
1283 # Fixed some of the exceptions so they're the right type.
1284 # Removed the str()-ification of node ids so we don't mask oopsy errors any
1285 # more.
1286 #
1287 # Revision 1.3 2001/07/27 05:17:14 richard
1288 # just some comments
1289 #
1290 # Revision 1.2 2001/07/22 12:09:32 richard
1291 # Final commit of Grande Splite
1292 #
1293 # Revision 1.1 2001/07/22 11:58:35 richard
1294 # More Grande Splite
1295 #
1296 #
1297 # vim: set filetype=python ts=4 sw=4 et si