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