Code

Added the fabricated property "id".
[roundup.git] / roundup / hyperdb.py
1 # $Id: hyperdb.py,v 1.5 2001-07-29 04:05:37 richard Exp $
3 # standard python modules
4 import cPickle, re, string
6 # roundup modules
7 import date
10 #
11 # Types
12 #
13 class BaseType:
14     isStringType = 0
15     isDateType = 0
16     isIntervalType = 0
17     isLinkType = 0
18     isMultilinkType = 0
20 class String(BaseType):
21     def __init__(self):
22         """An object designating a String property."""
23         pass
24     def __repr__(self):
25         return '<%s>'%self.__class__
26     isStringType = 1
28 class Date(BaseType, String):
29     isDateType = 1
31 class Interval(BaseType, String):
32     isIntervalType = 1
34 class Link(BaseType):
35     def __init__(self, classname):
36         """An object designating a Link property that links to
37         nodes in a specified class."""
38         self.classname = classname
39     def __repr__(self):
40         return '<%s to "%s">'%(self.__class__, self.classname)
41     isLinkType = 1
43 class Multilink(BaseType, Link):
44     """An object designating a Multilink property that links
45        to nodes in a specified class.
46     """
47     isMultilinkType = 1
49 class DatabaseError(ValueError):
50     pass
53 #
54 # the base Database class
55 #
56 class Database:
57     # flag to set on retired entries
58     RETIRED_FLAG = '__hyperdb_retired'
61 #
62 # The base Class class
63 #
64 class Class:
65     """The handle to a particular class of nodes in a hyperdatabase."""
67     def __init__(self, db, classname, **properties):
68         """Create a new class with a given name and property specification.
70         'classname' must not collide with the name of an existing class,
71         or a ValueError is raised.  The keyword arguments in 'properties'
72         must map names to property objects, or a TypeError is raised.
73         """
74         self.classname = classname
75         self.properties = properties
76         self.db = db
77         self.key = ''
79         # do the db-related init stuff
80         db.addclass(self)
82     # Editing nodes:
84     def create(self, **propvalues):
85         """Create a new node of this class and return its id.
87         The keyword arguments in 'propvalues' map property names to values.
89         The values of arguments must be acceptable for the types of their
90         corresponding properties or a TypeError is raised.
91         
92         If this class has a key property, it must be present and its value
93         must not collide with other key strings or a ValueError is raised.
94         
95         Any other properties on this class that are missing from the
96         'propvalues' dictionary are set to None.
97         
98         If an id in a link or multilink property does not refer to a valid
99         node, an IndexError is raised.
100         """
101         if propvalues.has_key('id'):
102             raise KeyError, '"id" is reserved'
104         if self.db.journaltag is None:
105             raise DatabaseError, 'Database open read-only'
107         # new node's id
108         newid = str(self.count() + 1)
110         # validate propvalues
111         num_re = re.compile('^\d+$')
112         for key, value in propvalues.items():
113             if key == self.key:
114                 try:
115                     self.lookup(value)
116                 except KeyError:
117                     pass
118                 else:
119                     raise ValueError, 'node with key "%s" exists'%value
121             prop = self.properties[key]
123             if prop.isLinkType:
124                 if type(value) != type(''):
125                     raise ValueError, 'link value must be String'
126 #                value = str(value)
127                 link_class = self.properties[key].classname
128                 # if it isn't a number, it's a key
129                 if not num_re.match(value):
130                     try:
131                         value = self.db.classes[link_class].lookup(value)
132                     except:
133                         raise IndexError, 'new property "%s": %s not a %s'%(
134                             key, value, self.properties[key].classname)
135                 propvalues[key] = value
136                 if not self.db.hasnode(link_class, value):
137                     raise IndexError, '%s has no node %s'%(link_class, value)
139                 # register the link with the newly linked node
140                 self.db.addjournal(link_class, value, 'link',
141                     (self.classname, newid, key))
143             elif prop.isMultilinkType:
144                 if type(value) != type([]):
145                     raise TypeError, 'new property "%s" not a list of ids'%key
146                 link_class = self.properties[key].classname
147                 l = []
148                 for entry in value:
149                     if type(entry) != type(''):
150                         raise ValueError, 'link value must be String'
151                     # if it isn't a number, it's a key
152                     if not num_re.match(entry):
153                         try:
154                             entry = self.db.classes[link_class].lookup(entry)
155                         except:
156                             raise IndexError, 'new property "%s": %s not a %s'%(
157                                 key, entry, self.properties[key].classname)
158                     l.append(entry)
159                 value = l
160                 propvalues[key] = value
162                 # handle additions
163                 for id in value:
164                     if not self.db.hasnode(link_class, id):
165                         raise IndexError, '%s has no node %s'%(link_class, id)
166                     # register the link with the newly linked node
167                     self.db.addjournal(link_class, id, 'link',
168                         (self.classname, newid, key))
170             elif prop.isStringType:
171                 if type(value) != type(''):
172                     raise TypeError, 'new property "%s" not a string'%key
174             elif prop.isDateType:
175                 if not hasattr(value, 'isDate'):
176                     raise TypeError, 'new property "%s" not a Date'% key
178             elif prop.isIntervalType:
179                 if not hasattr(value, 'isInterval'):
180                     raise TypeError, 'new property "%s" not an Interval'% key
182         for key, prop in self.properties.items():
183             if propvalues.has_key(key):
184                 continue
185             if prop.isMultilinkType:
186                 propvalues[key] = []
187             else:
188                 propvalues[key] = None
190         # done
191         self.db.addnode(self.classname, newid, propvalues)
192         self.db.addjournal(self.classname, newid, 'create', propvalues)
193         return newid
195     def get(self, nodeid, propname):
196         """Get the value of a property on an existing node of this class.
198         'nodeid' must be the id of an existing node of this class or an
199         IndexError is raised.  'propname' must be the name of a property
200         of this class or a KeyError is raised.
201         """
202         if propname == 'id':
203             return nodeid
204 #        nodeid = str(nodeid)
205         d = self.db.getnode(self.classname, nodeid)
206         return d[propname]
208     # XXX not in spec
209     def getnode(self, nodeid):
210         ''' Return a convenience wrapper for the node
211         '''
212         return Node(self, nodeid)
214     def set(self, nodeid, **propvalues):
215         """Modify a property on an existing node of this class.
216         
217         'nodeid' must be the id of an existing node of this class or an
218         IndexError is raised.
220         Each key in 'propvalues' must be the name of a property of this
221         class or a KeyError is raised.
223         All values in 'propvalues' must be acceptable types for their
224         corresponding properties or a TypeError is raised.
226         If the value of the key property is set, it must not collide with
227         other key strings or a ValueError is raised.
229         If the value of a Link or Multilink property contains an invalid
230         node id, a ValueError is raised.
231         """
232         if not propvalues:
233             return
235         if propvalues.has_key('id'):
236             raise KeyError, '"id" is reserved'
238         if self.db.journaltag is None:
239             raise DatabaseError, 'Database open read-only'
241 #        nodeid = str(nodeid)
242         node = self.db.getnode(self.classname, nodeid)
243         if node.has_key(self.db.RETIRED_FLAG):
244             raise IndexError
245         num_re = re.compile('^\d+$')
246         for key, value in propvalues.items():
247             if not node.has_key(key):
248                 raise KeyError, key
250             if key == self.key:
251                 try:
252                     self.lookup(value)
253                 except KeyError:
254                     pass
255                 else:
256                     raise ValueError, 'node with key "%s" exists'%value
258             prop = self.properties[key]
260             if prop.isLinkType:
261 #                value = str(value)
262                 link_class = self.properties[key].classname
263                 # if it isn't a number, it's a key
264                 if type(value) != type(''):
265                     raise ValueError, 'link value must be String'
266                 if not num_re.match(value):
267                     try:
268                         value = self.db.classes[link_class].lookup(value)
269                     except:
270                         raise IndexError, 'new property "%s": %s not a %s'%(
271                             key, value, self.properties[key].classname)
273                 if not self.db.hasnode(link_class, value):
274                     raise IndexError, '%s has no node %s'%(link_class, value)
276                 # register the unlink with the old linked node
277                 if node[key] is not None:
278                     self.db.addjournal(link_class, node[key], 'unlink',
279                         (self.classname, nodeid, key))
281                 # register the link with the newly linked node
282                 if value is not None:
283                     self.db.addjournal(link_class, value, 'link',
284                         (self.classname, nodeid, key))
286             elif prop.isMultilinkType:
287                 if type(value) != type([]):
288                     raise TypeError, 'new property "%s" not a list of ids'%key
289                 link_class = self.properties[key].classname
290                 l = []
291                 for entry in value:
292                     # if it isn't a number, it's a key
293                     if type(entry) != type(''):
294                         raise ValueError, 'link value must be String'
295                     if not num_re.match(entry):
296                         try:
297                             entry = self.db.classes[link_class].lookup(entry)
298                         except:
299                             raise IndexError, 'new property "%s": %s not a %s'%(
300                                 key, entry, self.properties[key].classname)
301                     l.append(entry)
302                 value = l
303                 propvalues[key] = value
305                 #handle removals
306                 l = node[key]
307                 for id in l[:]:
308                     if id in value:
309                         continue
310                     # register the unlink with the old linked node
311                     self.db.addjournal(link_class, id, 'unlink',
312                         (self.classname, nodeid, key))
313                     l.remove(id)
315                 # handle additions
316                 for id in value:
317                     if not self.db.hasnode(link_class, id):
318                         raise IndexError, '%s has no node %s'%(link_class, id)
319                     if id in l:
320                         continue
321                     # register the link with the newly linked node
322                     self.db.addjournal(link_class, id, 'link',
323                         (self.classname, nodeid, key))
324                     l.append(id)
326             elif prop.isStringType:
327                 if value is not None and type(value) != type(''):
328                     raise TypeError, 'new property "%s" not a string'%key
330             elif prop.isDateType:
331                 if not hasattr(value, 'isDate'):
332                     raise TypeError, 'new property "%s" not a Date'% key
334             elif prop.isIntervalType:
335                 if not hasattr(value, 'isInterval'):
336                     raise TypeError, 'new property "%s" not an Interval'% key
338             node[key] = value
340         self.db.setnode(self.classname, nodeid, node)
341         self.db.addjournal(self.classname, nodeid, 'set', propvalues)
343     def retire(self, nodeid):
344         """Retire a node.
345         
346         The properties on the node remain available from the get() method,
347         and the node's id is never reused.
348         
349         Retired nodes are not returned by the find(), list(), or lookup()
350         methods, and other nodes may reuse the values of their key properties.
351         """
352 #        nodeid = str(nodeid)
353         if self.db.journaltag is None:
354             raise DatabaseError, 'Database open read-only'
355         node = self.db.getnode(self.classname, nodeid)
356         node[self.db.RETIRED_FLAG] = 1
357         self.db.setnode(self.classname, nodeid, node)
358         self.db.addjournal(self.classname, nodeid, 'retired', None)
360     def history(self, nodeid):
361         """Retrieve the journal of edits on a particular node.
363         'nodeid' must be the id of an existing node of this class or an
364         IndexError is raised.
366         The returned list contains tuples of the form
368             (date, tag, action, params)
370         'date' is a Timestamp object specifying the time of the change and
371         'tag' is the journaltag specified when the database was opened.
372         """
373         return self.db.getjournal(self.classname, nodeid)
375     # Locating nodes:
377     def setkey(self, propname):
378         """Select a String property of this class to be the key property.
380         'propname' must be the name of a String property of this class or
381         None, or a TypeError is raised.  The values of the key property on
382         all existing nodes must be unique or a ValueError is raised.
383         """
384         self.key = propname
386     def getkey(self):
387         """Return the name of the key property for this class or None."""
388         return self.key
390     # TODO: set up a separate index db file for this? profile?
391     def lookup(self, keyvalue):
392         """Locate a particular node by its key property and return its id.
394         If this class has no key property, a TypeError is raised.  If the
395         'keyvalue' matches one of the values for the key property among
396         the nodes in this class, the matching node's id is returned;
397         otherwise a KeyError is raised.
398         """
399         cldb = self.db.getclassdb(self.classname)
400         for nodeid in self.db.getnodeids(self.classname, cldb):
401             node = self.db.getnode(self.classname, nodeid, cldb)
402             if node.has_key(self.db.RETIRED_FLAG):
403                 continue
404             if node[self.key] == keyvalue:
405                 return nodeid
406         cldb.close()
407         raise KeyError, keyvalue
409     # XXX: change from spec - allows multiple props to match
410     def find(self, **propspec):
411         """Get the ids of nodes in this class which link to a given node.
413         'propspec' consists of keyword args propname=nodeid   
414           'propname' must be the name of a property in this class, or a
415             KeyError is raised.  That property must be a Link or Multilink
416             property, or a TypeError is raised.
418           'nodeid' must be the id of an existing node in the class linked
419             to by the given property, or an IndexError is raised.
420         """
421         propspec = propspec.items()
422         for propname, nodeid in propspec:
423 #            nodeid = str(nodeid)
424             # check the prop is OK
425             prop = self.properties[propname]
426             if not prop.isLinkType and not prop.isMultilinkType:
427                 raise TypeError, "'%s' not a Link/Multilink property"%propname
428             if not self.db.hasnode(prop.classname, nodeid):
429                 raise ValueError, '%s has no node %s'%(link_class, nodeid)
431         # ok, now do the find
432         cldb = self.db.getclassdb(self.classname)
433         l = []
434         for id in self.db.getnodeids(self.classname, cldb):
435             node = self.db.getnode(self.classname, id, cldb)
436             if node.has_key(self.db.RETIRED_FLAG):
437                 continue
438             for propname, nodeid in propspec:
439 #                nodeid = str(nodeid)
440                 property = node[propname]
441                 if prop.isLinkType and nodeid == property:
442                     l.append(id)
443                 elif prop.isMultilinkType and nodeid in property:
444                     l.append(id)
445         cldb.close()
446         return l
448     def stringFind(self, **requirements):
449         """Locate a particular node by matching a set of its String properties.
451         If the property is not a String property, a TypeError is raised.
452         
453         The return is a list of the id of all nodes that match.
454         """
455         for propname in requirements.keys():
456             prop = self.properties[propname]
457             if not prop.isStringType:
458                 raise TypeError, "'%s' not a String property"%propname
459         l = []
460         cldb = self.db.getclassdb(self.classname)
461         for nodeid in self.db.getnodeids(self.classname, cldb):
462             node = self.db.getnode(self.classname, nodeid, cldb)
463             if node.has_key(self.db.RETIRED_FLAG):
464                 continue
465             for key, value in requirements.items():
466                 if node[key] != value:
467                     break
468             else:
469                 l.append(nodeid)
470         cldb.close()
471         return l
473     def list(self):
474         """Return a list of the ids of the active nodes in this class."""
475         l = []
476         cn = self.classname
477         cldb = self.db.getclassdb(cn)
478         for nodeid in self.db.getnodeids(cn, cldb):
479             node = self.db.getnode(cn, nodeid, cldb)
480             if node.has_key(self.db.RETIRED_FLAG):
481                 continue
482             l.append(nodeid)
483         l.sort()
484         cldb.close()
485         return l
487     # XXX not in spec
488     def filter(self, filterspec, sort, group, num_re = re.compile('^\d+$')):
489         ''' Return a list of the ids of the active nodes in this class that
490             match the 'filter' spec, sorted by the group spec and then the
491             sort spec
492         '''
493         cn = self.classname
495         # optimise filterspec
496         l = []
497         props = self.getprops()
498         for k, v in filterspec.items():
499             propclass = props[k]
500             if propclass.isLinkType:
501                 if type(v) is not type([]):
502                     v = [v]
503                 # replace key values with node ids
504                 u = []
505                 link_class =  self.db.classes[propclass.classname]
506                 for entry in v:
507                     if not num_re.match(entry):
508                         try:
509                             entry = link_class.lookup(entry)
510                         except:
511                             raise ValueError, 'new property "%s": %s not a %s'%(
512                                 k, entry, self.properties[k].classname)
513                     u.append(entry)
515                 l.append((0, k, u))
516             elif propclass.isMultilinkType:
517                 if type(v) is not type([]):
518                     v = [v]
519                 # replace key values with node ids
520                 u = []
521                 link_class =  self.db.classes[propclass.classname]
522                 for entry in v:
523                     if not num_re.match(entry):
524                         try:
525                             entry = link_class.lookup(entry)
526                         except:
527                             raise ValueError, 'new property "%s": %s not a %s'%(
528                                 k, entry, self.properties[k].classname)
529                     u.append(entry)
530                 l.append((1, k, u))
531             elif propclass.isStringType:
532                 v = v[0]
533                 if '*' in v or '?' in v:
534                     # simple glob searching
535                     v = v.replace('?', '.')
536                     v = v.replace('*', '.*?')
537                     v = re.compile(v)
538                     l.append((2, k, v))
539                 elif v[0] == '^':
540                     # start-anchored
541                     if v[-1] == '$':
542                         # _and_ end-anchored
543                         l.append((6, k, v[1:-1]))
544                     l.append((3, k, v[1:]))
545                 elif v[-1] == '$':
546                     # end-anchored
547                     l.append((4, k, v[:-1]))
548                 else:
549                     # substring
550                     l.append((5, k, v))
551             else:
552                 l.append((6, k, v))
553         filterspec = l
555         # now, find all the nodes that are active and pass filtering
556         l = []
557         cldb = self.db.getclassdb(cn)
558         for nodeid in self.db.getnodeids(cn, cldb):
559             node = self.db.getnode(cn, nodeid, cldb)
560             if node.has_key(self.db.RETIRED_FLAG):
561                 continue
562             # apply filter
563             for t, k, v in filterspec:
564                 if t == 0 and node[k] not in v:
565                     # link - if this node'd property doesn't appear in the
566                     # filterspec's nodeid list, skip it
567                     break
568                 elif t == 1:
569                     # multilink - if any of the nodeids required by the
570                     # filterspec aren't in this node's property, then skip
571                     # it
572                     for value in v:
573                         if value not in node[k]:
574                             break
575                     else:
576                         continue
577                     break
578                 elif t == 2 and not v.search(node[k]):
579                     # RE search
580                     break
581                 elif t == 3 and node[k][:len(v)] != v:
582                     # start anchored
583                     break
584                 elif t == 4 and node[k][-len(v):] != v:
585                     # end anchored
586                     break
587                 elif t == 5 and node[k].find(v) == -1:
588                     # substring search
589                     break
590                 elif t == 6 and node[k] != v:
591                     # straight value comparison for the other types
592                     break
593             else:
594                 l.append((nodeid, node))
595         l.sort()
596         cldb.close()
598         # optimise sort
599         m = []
600         for entry in sort:
601             if entry[0] != '-':
602                 m.append(('+', entry))
603             else:
604                 m.append((entry[0], entry[1:]))
605         sort = m
607         # optimise group
608         m = []
609         for entry in group:
610             if entry[0] != '-':
611                 m.append(('+', entry))
612             else:
613                 m.append((entry[0], entry[1:]))
614         group = m
616         # now, sort the result
617         def sortfun(a, b, sort=sort, group=group, properties=self.getprops(),
618                 db = self.db, cl=self):
619             a_id, an = a
620             b_id, bn = b
621             # sort by group and then sort
622             for list in group, sort:
623                 for dir, prop in list:
624                     # handle the properties that might be "faked"
625                     if not an.has_key(prop):
626                         an[prop] = cl.get(a_id, prop)
627                     av = an[prop]
628                     if not bn.has_key(prop):
629                         bn[prop] = cl.get(b_id, prop)
630                     bv = bn[prop]
632                     # sorting is class-specific
633                     propclass = properties[prop]
635                     # String and Date values are sorted in the natural way
636                     if propclass.isStringType:
637                         # clean up the strings
638                         if av and av[0] in string.uppercase:
639                             av = an[prop] = av.lower()
640                         if bv and bv[0] in string.uppercase:
641                             bv = bn[prop] = bv.lower()
642                     if propclass.isStringType or propclass.isDateType:
643                         if dir == '+':
644                             r = cmp(av, bv)
645                             if r != 0: return r
646                         elif dir == '-':
647                             r = cmp(bv, av)
648                             if r != 0: return r
650                     # Link properties are sorted according to the value of
651                     # the "order" property on the linked nodes if it is
652                     # present; or otherwise on the key string of the linked
653                     # nodes; or finally on  the node ids.
654                     elif propclass.isLinkType:
655                         link = db.classes[propclass.classname]
656                         if link.getprops().has_key('order'):
657                             if dir == '+':
658                                 r = cmp(link.get(av, 'order'),
659                                     link.get(bv, 'order'))
660                                 if r != 0: return r
661                             elif dir == '-':
662                                 r = cmp(link.get(bv, 'order'),
663                                     link.get(av, 'order'))
664                                 if r != 0: return r
665                         elif link.getkey():
666                             key = link.getkey()
667                             if dir == '+':
668                                 r = cmp(link.get(av, key), link.get(bv, key))
669                                 if r != 0: return r
670                             elif dir == '-':
671                                 r = cmp(link.get(bv, key), link.get(av, key))
672                                 if r != 0: return r
673                         else:
674                             if dir == '+':
675                                 r = cmp(av, bv)
676                                 if r != 0: return r
677                             elif dir == '-':
678                                 r = cmp(bv, av)
679                                 if r != 0: return r
681                     # Multilink properties are sorted according to how many
682                     # links are present.
683                     elif propclass.isMultilinkType:
684                         if dir == '+':
685                             r = cmp(len(av), len(bv))
686                             if r != 0: return r
687                         elif dir == '-':
688                             r = cmp(len(bv), len(av))
689                             if r != 0: return r
690                 # end for dir, prop in list:
691             # end for list in sort, group:
692             # if all else fails, compare the ids
693             return cmp(a[0], b[0])
695         l.sort(sortfun)
696         return [i[0] for i in l]
698     def count(self):
699         """Get the number of nodes in this class.
701         If the returned integer is 'numnodes', the ids of all the nodes
702         in this class run from 1 to numnodes, and numnodes+1 will be the
703         id of the next node to be created in this class.
704         """
705         return self.db.countnodes(self.classname)
707     # Manipulating properties:
709     def getprops(self):
710         """Return a dictionary mapping property names to property objects."""
711         d = self.properties.copy()
712         d['id'] = String()
713         return d
715     def addprop(self, **properties):
716         """Add properties to this class.
718         The keyword arguments in 'properties' must map names to property
719         objects, or a TypeError is raised.  None of the keys in 'properties'
720         may collide with the names of existing properties, or a ValueError
721         is raised before any properties have been added.
722         """
723         for key in properties.keys():
724             if self.properties.has_key(key):
725                 raise ValueError, key
726         self.properties.update(properties)
729 # XXX not in spec
730 class Node:
731     ''' A convenience wrapper for the given node
732     '''
733     def __init__(self, cl, nodeid):
734         self.__dict__['cl'] = cl
735         self.__dict__['nodeid'] = nodeid
736     def keys(self):
737         return self.cl.getprops().keys()
738     def has_key(self, name):
739         return self.cl.getprops().has_key(name)
740     def __getattr__(self, name):
741         if self.__dict__.has_key(name):
742             return self.__dict__['name']
743         try:
744             return self.cl.get(self.nodeid, name)
745         except KeyError, value:
746             raise AttributeError, str(value)
747     def __getitem__(self, name):
748         return self.cl.get(self.nodeid, name)
749     def __setattr__(self, name, value):
750         try:
751             return self.cl.set(self.nodeid, **{name: value})
752         except KeyError, value:
753             raise AttributeError, str(value)
754     def __setitem__(self, name, value):
755         self.cl.set(self.nodeid, **{name: value})
756     def history(self):
757         return self.cl.history(self.nodeid)
758     def retire(self):
759         return self.cl.retire(self.nodeid)
762 def Choice(name, *options):
763     cl = Class(db, name, name=hyperdb.String(), order=hyperdb.String())
764     for i in range(len(options)):
765         cl.create(name=option[i], order=i)
766     return hyperdb.Link(name)
769 # $Log: not supported by cvs2svn $
770 # Revision 1.4  2001/07/27 06:25:35  richard
771 # Fixed some of the exceptions so they're the right type.
772 # Removed the str()-ification of node ids so we don't mask oopsy errors any
773 # more.
775 # Revision 1.3  2001/07/27 05:17:14  richard
776 # just some comments
778 # Revision 1.2  2001/07/22 12:09:32  richard
779 # Final commit of Grande Splite
781 # Revision 1.1  2001/07/22 11:58:35  richard
782 # More Grande Splite