Code

. Fixed bug in re generation in the filter (I hadn't finished the code ;)
[roundup.git] / hyperdb.py
1 import bsddb, os, cPickle, re, string
3 import date
4 #
5 # Types
6 #
7 class BaseType:
8     isStringType = 0
9     isDateType = 0
10     isIntervalType = 0
11     isLinkType = 0
12     isMultilinkType = 0
14 class String(BaseType):
15     def __init__(self):
16         """An object designating a String property."""
17         pass
18     def __repr__(self):
19         return '<%s>'%self.__class__
20     isStringType = 1
22 class Date(BaseType, String):
23     isDateType = 1
25 class Interval(BaseType, String):
26     isIntervalType = 1
28 class Link(BaseType):
29     def __init__(self, classname):
30         """An object designating a Link property that links to
31         nodes in a specified class."""
32         self.classname = classname
33     def __repr__(self):
34         return '<%s to "%s">'%(self.__class__, self.classname)
35     isLinkType = 1
37 class Multilink(BaseType, Link):
38     """An object designating a Multilink property that links
39        to nodes in a specified class.
40     """
41     isMultilinkType = 1
43 class DatabaseError(ValueError):
44     pass
46 #
47 # Now the database
48 #
49 RETIRED_FLAG = '__hyperdb_retired'
50 class Database:
51     """A database for storing records containing flexible data types."""
53     def __init__(self, storagelocator, journaltag=None):
54         """Open a hyperdatabase given a specifier to some storage.
56         The meaning of 'storagelocator' depends on the particular
57         implementation of the hyperdatabase.  It could be a file name,
58         a directory path, a socket descriptor for a connection to a
59         database over the network, etc.
61         The 'journaltag' is a token that will be attached to the journal
62         entries for any edits done on the database.  If 'journaltag' is
63         None, the database is opened in read-only mode: the Class.create(),
64         Class.set(), and Class.retire() methods are disabled.
65         """
66         self.dir, self.journaltag = storagelocator, journaltag
67         self.classes = {}
69     #
70     # Classes
71     #
72     def __getattr__(self, classname):
73         """A convenient way of calling self.getclass(classname)."""
74         return self.classes[classname]
76     def addclass(self, cl):
77         cn = cl.classname
78         if self.classes.has_key(cn):
79             raise ValueError, cn
80         self.classes[cn] = cl
82     def getclasses(self):
83         """Return a list of the names of all existing classes."""
84         l = self.classes.keys()
85         l.sort()
86         return l
88     def getclass(self, classname):
89         """Get the Class object representing a particular class.
91         If 'classname' is not a valid class name, a KeyError is raised.
92         """
93         return self.classes[classname]
95     #
96     # Class DBs
97     #
98     def clear(self):
99         for cn in self.classes.keys():
100             db = os.path.join(self.dir, 'nodes.%s'%cn)
101             bsddb.btopen(db, 'n')
102             db = os.path.join(self.dir, 'journals.%s'%cn)
103             bsddb.btopen(db, 'n')
105     def getclassdb(self, classname, mode='r'):
106         ''' grab a connection to the class db that will be used for
107             multiple actions
108         '''
109         path = os.path.join(os.getcwd(), self.dir, 'nodes.%s'%classname)
110         return bsddb.btopen(path, mode)
112     def addnode(self, classname, nodeid, node):
113         ''' add the specified node to its class's db
114         '''
115         db = self.getclassdb(classname, 'c')
116         db[nodeid] = cPickle.dumps(node, 1)
117         db.close()
118     setnode = addnode
120     def getnode(self, classname, nodeid, cldb=None):
121         ''' add the specified node to its class's db
122         '''
123         db = cldb or self.getclassdb(classname)
124         if not db.has_key(nodeid):
125             raise IndexError, nodeid
126         res = cPickle.loads(db[nodeid])
127         if not cldb: db.close()
128         return res
130     def hasnode(self, classname, nodeid, cldb=None):
131         ''' add the specified node to its class's db
132         '''
133         db = cldb or self.getclassdb(classname)
134         res = db.has_key(nodeid)
135         if not cldb: db.close()
136         return res
138     def countnodes(self, classname, cldb=None):
139         db = cldb or self.getclassdb(classname)
140         return len(db.keys())
141         if not cldb: db.close()
142         return res
144     def getnodeids(self, classname, cldb=None):
145         db = cldb or self.getclassdb(classname)
146         res = db.keys()
147         if not cldb: db.close()
148         return res
150     #
151     # Journal
152     #
153     def addjournal(self, classname, nodeid, action, params):
154         ''' Journal the Action
155         'action' may be:
157             'create' or 'set' -- 'params' is a dictionary of property values
158             'link' or 'unlink' -- 'params' is (classname, nodeid, propname)
159             'retire' -- 'params' is None
160         '''
161         entry = (nodeid, date.Date(), self.journaltag, action, params)
162         db = bsddb.btopen(os.path.join(self.dir, 'journals.%s'%classname), 'c')
163         if db.has_key(nodeid):
164             s = db[nodeid]
165             l = cPickle.loads(db[nodeid])
166             l.append(entry)
167         else:
168             l = [entry]
169         db[nodeid] = cPickle.dumps(l)
170         db.close()
172     def getjournal(self, classname, nodeid):
173         ''' get the journal for id
174         '''
175         db = bsddb.btopen(os.path.join(self.dir, 'journals.%s'%classname), 'r')
176         res = cPickle.loads(db[nodeid])
177         db.close()
178         return res
180     def close(self):
181         ''' Close the Database - we must release the circular refs so that
182             we can be del'ed and the underlying bsddb connections closed
183             cleanly.
184         '''
185         self.classes = None
188     #
189     # Basic transaction support
190     #
191     # TODO: well, write these methods (and then use them in other code)
192     def register_action(self):
193         ''' Register an action to the transaction undo log
194         '''
196     def commit(self):
197         ''' Commit the current transaction, start a new one
198         '''
200     def rollback(self):
201         ''' Reverse all actions from the current transaction
202         '''
205 class Class:
206     """The handle to a particular class of nodes in a hyperdatabase."""
208     def __init__(self, db, classname, **properties):
209         """Create a new class with a given name and property specification.
211         'classname' must not collide with the name of an existing class,
212         or a ValueError is raised.  The keyword arguments in 'properties'
213         must map names to property objects, or a TypeError is raised.
214         """
215         self.classname = classname
216         self.properties = properties
217         self.db = db
218         self.key = ''
220         # do the db-related init stuff
221         db.addclass(self)
223     # Editing nodes:
225     def create(self, **propvalues):
226         """Create a new node of this class and return its id.
228         The keyword arguments in 'propvalues' map property names to values.
230         The values of arguments must be acceptable for the types of their
231         corresponding properties or a TypeError is raised.
232         
233         If this class has a key property, it must be present and its value
234         must not collide with other key strings or a ValueError is raised.
235         
236         Any other properties on this class that are missing from the
237         'propvalues' dictionary are set to None.
238         
239         If an id in a link or multilink property does not refer to a valid
240         node, an IndexError is raised.
241         """
242         if self.db.journaltag is None:
243             raise DatabaseError, 'Database open read-only'
244         newid = str(self.count() + 1)
246         # validate propvalues
247         num_re = re.compile('^\d+$')
248         for key, value in propvalues.items():
249             if key == self.key:
250                 try:
251                     self.lookup(value)
252                 except KeyError:
253                     pass
254                 else:
255                     raise ValueError, 'node with key "%s" exists'%value
257             prop = self.properties[key]
259             if prop.isLinkType:
260                 value = str(value)
261                 link_class = self.properties[key].classname
262                 if not num_re.match(value):
263                     try:
264                         value = self.db.classes[link_class].lookup(value)
265                     except:
266                         raise ValueError, 'new property "%s": %s not a %s'%(
267                             key, value, self.properties[key].classname)
268                 propvalues[key] = value
269                 if not self.db.hasnode(link_class, value):
270                     raise ValueError, '%s has no node %s'%(link_class, value)
272                 # register the link with the newly linked node
273                 self.db.addjournal(link_class, value, 'link',
274                     (self.classname, newid, key))
276             elif prop.isMultilinkType:
277                 if type(value) != type([]):
278                     raise TypeError, 'new property "%s" not a list of ids'%key
279                 link_class = self.properties[key].classname
280                 l = []
281                 for entry in map(str, value):
282                     if not num_re.match(entry):
283                         try:
284                             entry = self.db.classes[link_class].lookup(entry)
285                         except:
286                             raise ValueError, 'new property "%s": %s not a %s'%(
287                                 key, entry, self.properties[key].classname)
288                     l.append(entry)
289                 value = l
290                 propvalues[key] = value
292                 # handle additions
293                 for id in value:
294                     if not self.db.hasnode(link_class, id):
295                         raise ValueError, '%s has no node %s'%(link_class, id)
296                     # register the link with the newly linked node
297                     self.db.addjournal(link_class, id, 'link',
298                         (self.classname, newid, key))
300             elif prop.isStringType:
301                 if type(value) != type(''):
302                     raise TypeError, 'new property "%s" not a string'%key
304             elif prop.isDateType:
305                 if not hasattr(value, 'isDate'):
306                     raise TypeError, 'new property "%s" not a Date'% key
308             elif prop.isIntervalType:
309                 if not hasattr(value, 'isInterval'):
310                     raise TypeError, 'new property "%s" not an Interval'% key
312         for key,prop in self.properties.items():
313             if propvalues.has_key(str(key)):
314                 continue
315             if prop.isMultilinkType:
316                 propvalues[key] = []
317             else:
318                 propvalues[key] = None
320         # done
321         self.db.addnode(self.classname, newid, propvalues)
322         self.db.addjournal(self.classname, newid, 'create', propvalues)
323         return newid
325     def get(self, nodeid, propname):
326         """Get the value of a property on an existing node of this class.
328         'nodeid' must be the id of an existing node of this class or an
329         IndexError is raised.  'propname' must be the name of a property
330         of this class or a KeyError is raised.
331         """
332         d = self.db.getnode(self.classname, str(nodeid))
333         return d[propname]
335     # XXX not in spec
336     def getnode(self, nodeid):
337         ''' Return a convenience wrapper for the node
338         '''
339         return Node(self, nodeid)
341     def set(self, nodeid, **propvalues):
342         """Modify a property on an existing node of this class.
343         
344         'nodeid' must be the id of an existing node of this class or an
345         IndexError is raised.
347         Each key in 'propvalues' must be the name of a property of this
348         class or a KeyError is raised.
350         All values in 'propvalues' must be acceptable types for their
351         corresponding properties or a TypeError is raised.
353         If the value of the key property is set, it must not collide with
354         other key strings or a ValueError is raised.
356         If the value of a Link or Multilink property contains an invalid
357         node id, a ValueError is raised.
358         """
359         if not propvalues:
360             return
361         if self.db.journaltag is None:
362             raise DatabaseError, 'Database open read-only'
363         nodeid = str(nodeid)
364         node = self.db.getnode(self.classname, nodeid)
365         if node.has_key(RETIRED_FLAG):
366             raise IndexError
367         num_re = re.compile('^\d+$')
368         for key, value in propvalues.items():
369             if not node.has_key(key):
370                 raise KeyError, key
372             if key == self.key:
373                 try:
374                     self.lookup(value)
375                 except KeyError:
376                     pass
377                 else:
378                     raise ValueError, 'node with key "%s" exists'%value
380             prop = self.properties[key]
382             if prop.isLinkType:
383                 value = str(value)
384                 link_class = self.properties[key].classname
385                 if not num_re.match(value):
386                     try:
387                         value = self.db.classes[link_class].lookup(value)
388                     except:
389                         raise ValueError, 'new property "%s": %s not a %s'%(
390                             key, value, self.properties[key].classname)
392                 if not self.db.hasnode(link_class, value):
393                     raise ValueError, '%s has no node %s'%(link_class, value)
395                 # register the unlink with the old linked node
396                 if node[key] is not None:
397                     self.db.addjournal(link_class, node[key], 'unlink',
398                         (self.classname, nodeid, key))
400                 # register the link with the newly linked node
401                 if value is not None:
402                     self.db.addjournal(link_class, value, 'link',
403                         (self.classname, nodeid, key))
405             elif prop.isMultilinkType:
406                 if type(value) != type([]):
407                     raise TypeError, 'new property "%s" not a list of ids'%key
408                 link_class = self.properties[key].classname
409                 l = []
410                 for entry in map(str, value):
411                     if not num_re.match(entry):
412                         try:
413                             entry = self.db.classes[link_class].lookup(entry)
414                         except:
415                             raise ValueError, 'new property "%s": %s not a %s'%(
416                                 key, entry, self.properties[key].classname)
417                     l.append(entry)
418                 value = l
419                 propvalues[key] = value
421                 #handle removals
422                 l = node[key]
423                 for id in l[:]:
424                     if id in value:
425                         continue
426                     # register the unlink with the old linked node
427                     self.db.addjournal(link_class, id, 'unlink',
428                         (self.classname, nodeid, key))
429                     l.remove(id)
431                 # handle additions
432                 for id in value:
433                     if not self.db.hasnode(link_class, id):
434                         raise ValueError, '%s has no node %s'%(link_class, id)
435                     if id in l:
436                         continue
437                     # register the link with the newly linked node
438                     self.db.addjournal(link_class, id, 'link',
439                         (self.classname, nodeid, key))
440                     l.append(id)
442             elif prop.isStringType:
443                 if value is not None and type(value) != type(''):
444                     raise TypeError, 'new property "%s" not a string'%key
446             elif prop.isDateType:
447                 if not hasattr(value, 'isDate'):
448                     raise TypeError, 'new property "%s" not a Date'% key
450             elif prop.isIntervalType:
451                 if not hasattr(value, 'isInterval'):
452                     raise TypeError, 'new property "%s" not an Interval'% key
454             node[key] = value
456         self.db.setnode(self.classname, nodeid, node)
457         self.db.addjournal(self.classname, nodeid, 'set', propvalues)
459     def retire(self, nodeid):
460         """Retire a node.
461         
462         The properties on the node remain available from the get() method,
463         and the node's id is never reused.
464         
465         Retired nodes are not returned by the find(), list(), or lookup()
466         methods, and other nodes may reuse the values of their key properties.
467         """
468         nodeid = str(nodeid)
469         if self.db.journaltag is None:
470             raise DatabaseError, 'Database open read-only'
471         node = self.db.getnode(self.classname, nodeid)
472         node[RETIRED_FLAG] = 1
473         self.db.setnode(self.classname, nodeid, node)
474         self.db.addjournal(self.classname, nodeid, 'retired', None)
476     def history(self, nodeid):
477         """Retrieve the journal of edits on a particular node.
479         'nodeid' must be the id of an existing node of this class or an
480         IndexError is raised.
482         The returned list contains tuples of the form
484             (date, tag, action, params)
486         'date' is a Timestamp object specifying the time of the change and
487         'tag' is the journaltag specified when the database was opened.
488         """
489         return self.db.getjournal(self.classname, nodeid)
491     # Locating nodes:
493     def setkey(self, propname):
494         """Select a String property of this class to be the key property.
496         'propname' must be the name of a String property of this class or
497         None, or a TypeError is raised.  The values of the key property on
498         all existing nodes must be unique or a ValueError is raised.
499         """
500         self.key = propname
502     def getkey(self):
503         """Return the name of the key property for this class or None."""
504         return self.key
506     # TODO: set up a separate index db file for this? profile?
507     def lookup(self, keyvalue):
508         """Locate a particular node by its key property and return its id.
510         If this class has no key property, a TypeError is raised.  If the
511         'keyvalue' matches one of the values for the key property among
512         the nodes in this class, the matching node's id is returned;
513         otherwise a KeyError is raised.
514         """
515         cldb = self.db.getclassdb(self.classname)
516         for nodeid in self.db.getnodeids(self.classname, cldb):
517             node = self.db.getnode(self.classname, nodeid, cldb)
518             if node.has_key(RETIRED_FLAG):
519                 continue
520             if node[self.key] == keyvalue:
521                 return nodeid
522         cldb.close()
523         raise KeyError, keyvalue
525     # XXX: change from spec - allows multiple props to match
526     def find(self, **propspec):
527         """Get the ids of nodes in this class which link to a given node.
529         'propspec' consists of keyword args propname=nodeid   
530           'propname' must be the name of a property in this class, or a
531             KeyError is raised.  That property must be a Link or Multilink
532             property, or a TypeError is raised.
534           'nodeid' must be the id of an existing node in the class linked
535             to by the given property, or an IndexError is raised.
536         """
537         propspec = propspec.items()
538         for propname, nodeid in propspec:
539             nodeid = str(nodeid)
540             # check the prop is OK
541             prop = self.properties[propname]
542             if not prop.isLinkType and not prop.isMultilinkType:
543                 raise TypeError, "'%s' not a Link/Multilink property"%propname
544             if not self.db.hasnode(prop.classname, nodeid):
545                 raise ValueError, '%s has no node %s'%(link_class, nodeid)
547         # ok, now do the find
548         cldb = self.db.getclassdb(self.classname)
549         l = []
550         for id in self.db.getnodeids(self.classname, cldb):
551             node = self.db.getnode(self.classname, id, cldb)
552             if node.has_key(RETIRED_FLAG):
553                 continue
554             for propname, nodeid in propspec:
555                 nodeid = str(nodeid)
556                 property = node[propname]
557                 if prop.isLinkType and nodeid == property:
558                     l.append(id)
559                 elif prop.isMultilinkType and nodeid in property:
560                     l.append(id)
561         cldb.close()
562         return l
564     def stringFind(self, **requirements):
565         """Locate a particular node by matching a set of its String properties.
567         If the property is not a String property, a TypeError is raised.
568         
569         The return is a list of the id of all nodes that match.
570         """
571         for propname in requirements.keys():
572             prop = self.properties[propname]
573             if not prop.isStringType:
574                 raise TypeError, "'%s' not a String property"%propname
575         l = []
576         cldb = self.db.getclassdb(self.classname)
577         for nodeid in self.db.getnodeids(self.classname, cldb):
578             node = self.db.getnode(self.classname, nodeid, cldb)
579             if node.has_key(RETIRED_FLAG):
580                 continue
581             for key, value in requirements.items():
582                 if node[key] != value:
583                     break
584             else:
585                 l.append(nodeid)
586         cldb.close()
587         return l
589     def list(self):
590         """Return a list of the ids of the active nodes in this class."""
591         l = []
592         cn = self.classname
593         cldb = self.db.getclassdb(cn)
594         for nodeid in self.db.getnodeids(cn, cldb):
595             node = self.db.getnode(cn, nodeid, cldb)
596             if node.has_key(RETIRED_FLAG):
597                 continue
598             l.append(nodeid)
599         l.sort()
600         cldb.close()
601         return l
603     # XXX not in spec
604     def filter(self, filterspec, sort, group, num_re = re.compile('^\d+$')):
605         ''' Return a list of the ids of the active nodes in this class that
606             match the 'filter' spec, sorted by the group spec and then the
607             sort spec
608         '''
609         cn = self.classname
611         # optimise filterspec
612         l = []
613         props = self.getprops()
614         for k, v in filterspec.items():
615             propclass = props[k]
616             if propclass.isLinkType:
617                 if type(v) is not type([]):
618                     v = [v]
619                 # replace key values with node ids
620                 u = []
621                 link_class =  self.db.classes[propclass.classname]
622                 for entry in v:
623                     if not num_re.match(entry):
624                         try:
625                             entry = link_class.lookup(entry)
626                         except:
627                             raise ValueError, 'new property "%s": %s not a %s'%(
628                                 key, entry, self.properties[key].classname)
629                     u.append(entry)
631                 l.append((0, k, u))
632             elif propclass.isMultilinkType:
633                 if type(v) is not type([]):
634                     v = [v]
635                 # replace key values with node ids
636                 u = []
637                 link_class =  self.db.classes[propclass.classname]
638                 for entry in v:
639                     if not num_re.match(entry):
640                         try:
641                             entry = link_class.lookup(entry)
642                         except:
643                             raise ValueError, 'new property "%s": %s not a %s'%(
644                                 key, entry, self.properties[key].classname)
645                     u.append(entry)
646                 l.append((1, k, u))
647             elif propclass.isStringType:
648                 v = v[0]
649                 if '*' in v or '?' in v:
650                     # simple glob searching
651                     v = v.replace('?', '.')
652                     v = v.replace('*', '.*?')
653                     v = re.compile(v)
654                     l.append((2, k, v))
655                 elif v[0] == '^':
656                     # start-anchored
657                     if v[-1] == '$':
658                         # _and_ end-anchored
659                         l.append((6, k, v[1:-1]))
660                     l.append((3, k, v[1:]))
661                 elif v[-1] == '$':
662                     # end-anchored
663                     l.append((4, k, v[:-1]))
664                 else:
665                     # substring
666                     l.append((5, k, v))
667             else:
668                 l.append((6, k, v))
669         filterspec = l
671         # now, find all the nodes that are active and pass filtering
672         l = []
673         cldb = self.db.getclassdb(cn)
674         for nodeid in self.db.getnodeids(cn, cldb):
675             node = self.db.getnode(cn, nodeid, cldb)
676             if node.has_key(RETIRED_FLAG):
677                 continue
678             # apply filter
679             for t, k, v in filterspec:
680                 if t == 0 and node[k] not in v:
681                     # link - if this node'd property doesn't appear in the
682                     # filterspec's nodeid list, skip it
683                     break
684                 elif t == 1:
685                     # multilink - if any of the nodeids required by the
686                     # filterspec aren't in this node's property, then skip
687                     # it
688                     for value in v:
689                         if value not in node[k]:
690                             break
691                     else:
692                         continue
693                     break
694                 elif t == 2 and not v.search(node[k]):
695                     # RE search
696                     break
697                 elif t == 3 and node[k][:len(v)] != v:
698                     # start anchored
699                     break
700                 elif t == 4 and node[k][-len(v):] != v:
701                     # end anchored
702                     break
703                 elif t == 5 and node[k].find(v) == -1:
704                     # substring search
705                     break
706                 elif t == 6 and node[k] != v:
707                     # straight value comparison for the other types
708                     break
709             else:
710                 l.append((nodeid, node))
711         l.sort()
712         cldb.close()
714         # optimise sort
715         m = []
716         for entry in sort:
717             if entry[0] != '-':
718                 m.append(('+', entry))
719             else:
720                 m.append((entry[0], entry[1:]))
721         sort = m
723         # optimise group
724         m = []
725         for entry in group:
726             if entry[0] != '-':
727                 m.append(('+', entry))
728             else:
729                 m.append((entry[0], entry[1:]))
730         group = m
732         # now, sort the result
733         def sortfun(a, b, sort=sort, group=group, properties=self.getprops(),
734                 db = self.db, cl=self):
735             a_id, an = a
736             b_id, bn = b
737             for list in group, sort:
738                 for dir, prop in list:
739                     # handle the properties that might be "faked"
740                     if not an.has_key(prop):
741                         an[prop] = cl.get(a_id, prop)
742                     av = an[prop]
743                     if not bn.has_key(prop):
744                         bn[prop] = cl.get(b_id, prop)
745                     bv = bn[prop]
747                     # sorting is class-specific
748                     propclass = properties[prop]
750                     # String and Date values are sorted in the natural way
751                     if propclass.isStringType:
752                         # clean up the strings
753                         if av and av[0] in string.uppercase:
754                             av = an[prop] = av.lower()
755                         if bv and bv[0] in string.uppercase:
756                             bv = bn[prop] = bv.lower()
757                     if propclass.isStringType or propclass.isDateType:
758                         if dir == '+':
759                             r = cmp(av, bv)
760                             if r != 0: return r
761                         elif dir == '-':
762                             r = cmp(bv, av)
763                             if r != 0: return r
765                     # Link properties are sorted according to the value of
766                     # the "order" property on the linked nodes if it is
767                     # present; or otherwise on the key string of the linked
768                     # nodes; or finally on  the node ids.
769                     elif propclass.isLinkType:
770                         link = db.classes[propclass.classname]
771                         if link.getprops().has_key('order'):
772                             if dir == '+':
773                                 r = cmp(link.get(av, 'order'),
774                                     link.get(bv, 'order'))
775                                 if r != 0: return r
776                             elif dir == '-':
777                                 r = cmp(link.get(bv, 'order'),
778                                     link.get(av, 'order'))
779                                 if r != 0: return r
780                         elif link.getkey():
781                             key = link.getkey()
782                             if dir == '+':
783                                 r = cmp(link.get(av, key), link.get(bv, key))
784                                 if r != 0: return r
785                             elif dir == '-':
786                                 r = cmp(link.get(bv, key), link.get(av, key))
787                                 if r != 0: return r
788                         else:
789                             if dir == '+':
790                                 r = cmp(av, bv)
791                                 if r != 0: return r
792                             elif dir == '-':
793                                 r = cmp(bv, av)
794                                 if r != 0: return r
796                     # Multilink properties are sorted according to how many
797                     # links are present.
798                     elif propclass.isMultilinkType:
799                         if dir == '+':
800                             r = cmp(len(av), len(bv))
801                             if r != 0: return r
802                         elif dir == '-':
803                             r = cmp(len(bv), len(av))
804                             if r != 0: return r
805             return cmp(a[0], b[0])
806         l.sort(sortfun)
807         return [i[0] for i in l]
809     def count(self):
810         """Get the number of nodes in this class.
812         If the returned integer is 'numnodes', the ids of all the nodes
813         in this class run from 1 to numnodes, and numnodes+1 will be the
814         id of the next node to be created in this class.
815         """
816         return self.db.countnodes(self.classname)
818     # Manipulating properties:
820     def getprops(self):
821         """Return a dictionary mapping property names to property objects."""
822         return self.properties
824     def addprop(self, **properties):
825         """Add properties to this class.
827         The keyword arguments in 'properties' must map names to property
828         objects, or a TypeError is raised.  None of the keys in 'properties'
829         may collide with the names of existing properties, or a ValueError
830         is raised before any properties have been added.
831         """
832         for key in properties.keys():
833             if self.properties.has_key(key):
834                 raise ValueError, key
835         self.properties.update(properties)
838 # XXX not in spec
839 class Node:
840     ''' A convenience wrapper for the given node
841     '''
842     def __init__(self, cl, nodeid):
843         self.__dict__['cl'] = cl
844         self.__dict__['nodeid'] = nodeid
845     def keys(self):
846         return self.cl.getprops().keys()
847     def has_key(self, name):
848         return self.cl.getprops().has_key(name)
849     def __getattr__(self, name):
850         if self.__dict__.has_key(name):
851             return self.__dict__['name']
852         try:
853             return self.cl.get(self.nodeid, name)
854         except KeyError, value:
855             raise AttributeError, str(value)
856     def __getitem__(self, name):
857         return self.cl.get(self.nodeid, name)
858     def __setattr__(self, name, value):
859         try:
860             return self.cl.set(self.nodeid, **{name: value})
861         except KeyError, value:
862             raise AttributeError, str(value)
863     def __setitem__(self, name, value):
864         self.cl.set(self.nodeid, **{name: value})
865     def history(self):
866         return self.cl.history(self.nodeid)
867     def retire(self):
868         return self.cl.retire(self.nodeid)
871 def Choice(name, *options):
872     cl = Class(db, name, name=hyperdb.String(), order=hyperdb.String())
873     for i in range(len(options)):
874         cl.create(name=option[i], order=i)
875     return hyperdb.Link(name)
878 if __name__ == '__main__':
879     import pprint
880     db = Database("test_db", "richard")
881     status = Class(db, "status", name=String())
882     status.setkey("name")
883     print db.status.create(name="unread")
884     print db.status.create(name="in-progress")
885     print db.status.create(name="testing")
886     print db.status.create(name="resolved")
887     print db.status.count()
888     print db.status.list()
889     print db.status.lookup("in-progress")
890     db.status.retire(3)
891     print db.status.list()
892     issue = Class(db, "issue", title=String(), status=Link("status"))
893     db.issue.create(title="spam", status=1)
894     db.issue.create(title="eggs", status=2)
895     db.issue.create(title="ham", status=4)
896     db.issue.create(title="arguments", status=2)
897     db.issue.create(title="abuse", status=1)
898     user = Class(db, "user", username=String(), password=String())
899     user.setkey("username")
900     db.issue.addprop(fixer=Link("user"))
901     print db.issue.getprops()
902 #{"title": <hyperdb.String>, "status": <hyperdb.Link to "status">,
903 #"user": <hyperdb.Link to "user">}
904     db.issue.set(5, status=2)
905     print db.issue.get(5, "status")
906     print db.status.get(2, "name")
907     print db.issue.get(5, "title")
908     print db.issue.find(status = db.status.lookup("in-progress"))
909     print db.issue.history(5)
910 # [(<Date 2000-06-28.19:09:43>, "ping", "create", {"title": "abuse", "status": 1}),
911 # (<Date 2000-06-28.19:11:04>, "ping", "set", {"status": 2})]
912     print db.status.history(1)
913 # [(<Date 2000-06-28.19:09:43>, "ping", "link", ("issue", 5, "status")),
914 # (<Date 2000-06-28.19:11:04>, "ping", "unlink", ("issue", 5, "status"))]
915     print db.status.history(2)
916 # [(<Date 2000-06-28.19:11:04>, "ping", "link", ("issue", 5, "status"))]
918     # TODO: set up some filter tests