Code

2e62efb6d5ec1a7e811d91e99f714a89b642a794
[roundup.git] / roundup / hyperdb.py
1 # $Id: hyperdb.py,v 1.3 2001-07-27 05:17:14 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 self.db.journaltag is None:
102             raise DatabaseError, 'Database open read-only'
103         newid = str(self.count() + 1)
105         # validate propvalues
106         num_re = re.compile('^\d+$')
107         for key, value in propvalues.items():
108             if key == self.key:
109                 try:
110                     self.lookup(value)
111                 except KeyError:
112                     pass
113                 else:
114                     raise ValueError, 'node with key "%s" exists'%value
116             prop = self.properties[key]
118             if prop.isLinkType:
119                 value = str(value)
120                 link_class = self.properties[key].classname
121                 if not num_re.match(value):
122                     try:
123                         value = self.db.classes[link_class].lookup(value)
124                     except:
125                         raise ValueError, 'new property "%s": %s not a %s'%(
126                             key, value, self.properties[key].classname)
127                 propvalues[key] = value
128                 if not self.db.hasnode(link_class, value):
129                     raise ValueError, '%s has no node %s'%(link_class, value)
131                 # register the link with the newly linked node
132                 self.db.addjournal(link_class, value, 'link',
133                     (self.classname, newid, key))
135             elif prop.isMultilinkType:
136                 if type(value) != type([]):
137                     raise TypeError, 'new property "%s" not a list of ids'%key
138                 link_class = self.properties[key].classname
139                 l = []
140                 for entry in map(str, value):
141                     if not num_re.match(entry):
142                         try:
143                             entry = self.db.classes[link_class].lookup(entry)
144                         except:
145                             raise ValueError, 'new property "%s": %s not a %s'%(
146                                 key, entry, self.properties[key].classname)
147                     l.append(entry)
148                 value = l
149                 propvalues[key] = value
151                 # handle additions
152                 for id in value:
153                     if not self.db.hasnode(link_class, id):
154                         raise ValueError, '%s has no node %s'%(link_class, id)
155                     # register the link with the newly linked node
156                     self.db.addjournal(link_class, id, 'link',
157                         (self.classname, newid, key))
159             elif prop.isStringType:
160                 if type(value) != type(''):
161                     raise TypeError, 'new property "%s" not a string'%key
163             elif prop.isDateType:
164                 if not hasattr(value, 'isDate'):
165                     raise TypeError, 'new property "%s" not a Date'% key
167             elif prop.isIntervalType:
168                 if not hasattr(value, 'isInterval'):
169                     raise TypeError, 'new property "%s" not an Interval'% key
171         for key,prop in self.properties.items():
172             if propvalues.has_key(str(key)):
173                 continue
174             if prop.isMultilinkType:
175                 propvalues[key] = []
176             else:
177                 propvalues[key] = None
179         # done
180         self.db.addnode(self.classname, newid, propvalues)
181         self.db.addjournal(self.classname, newid, 'create', propvalues)
182         return newid
184     def get(self, nodeid, propname):
185         """Get the value of a property on an existing node of this class.
187         'nodeid' must be the id of an existing node of this class or an
188         IndexError is raised.  'propname' must be the name of a property
189         of this class or a KeyError is raised.
190         """
191         d = self.db.getnode(self.classname, str(nodeid))
192         return d[propname]
194     # XXX not in spec
195     def getnode(self, nodeid):
196         ''' Return a convenience wrapper for the node
197         '''
198         return Node(self, nodeid)
200     def set(self, nodeid, **propvalues):
201         """Modify a property on an existing node of this class.
202         
203         'nodeid' must be the id of an existing node of this class or an
204         IndexError is raised.
206         Each key in 'propvalues' must be the name of a property of this
207         class or a KeyError is raised.
209         All values in 'propvalues' must be acceptable types for their
210         corresponding properties or a TypeError is raised.
212         If the value of the key property is set, it must not collide with
213         other key strings or a ValueError is raised.
215         If the value of a Link or Multilink property contains an invalid
216         node id, a ValueError is raised.
217         """
218         if not propvalues:
219             return
220         if self.db.journaltag is None:
221             raise DatabaseError, 'Database open read-only'
222         nodeid = str(nodeid)
223         node = self.db.getnode(self.classname, nodeid)
224         if node.has_key(self.db.RETIRED_FLAG):
225             raise IndexError
226         num_re = re.compile('^\d+$')
227         for key, value in propvalues.items():
228             if not node.has_key(key):
229                 raise KeyError, key
231             if key == self.key:
232                 try:
233                     self.lookup(value)
234                 except KeyError:
235                     pass
236                 else:
237                     raise ValueError, 'node with key "%s" exists'%value
239             prop = self.properties[key]
241             if prop.isLinkType:
242                 value = str(value)
243                 link_class = self.properties[key].classname
244                 if not num_re.match(value):
245                     try:
246                         value = self.db.classes[link_class].lookup(value)
247                     except:
248                         raise ValueError, 'new property "%s": %s not a %s'%(
249                             key, value, self.properties[key].classname)
251                 if not self.db.hasnode(link_class, value):
252                     raise ValueError, '%s has no node %s'%(link_class, value)
254                 # register the unlink with the old linked node
255                 if node[key] is not None:
256                     self.db.addjournal(link_class, node[key], 'unlink',
257                         (self.classname, nodeid, key))
259                 # register the link with the newly linked node
260                 if value is not None:
261                     self.db.addjournal(link_class, value, 'link',
262                         (self.classname, nodeid, key))
264             elif prop.isMultilinkType:
265                 if type(value) != type([]):
266                     raise TypeError, 'new property "%s" not a list of ids'%key
267                 link_class = self.properties[key].classname
268                 l = []
269                 for entry in map(str, value):
270                     if not num_re.match(entry):
271                         try:
272                             entry = self.db.classes[link_class].lookup(entry)
273                         except:
274                             raise ValueError, 'new property "%s": %s not a %s'%(
275                                 key, entry, self.properties[key].classname)
276                     l.append(entry)
277                 value = l
278                 propvalues[key] = value
280                 #handle removals
281                 l = node[key]
282                 for id in l[:]:
283                     if id in value:
284                         continue
285                     # register the unlink with the old linked node
286                     self.db.addjournal(link_class, id, 'unlink',
287                         (self.classname, nodeid, key))
288                     l.remove(id)
290                 # handle additions
291                 for id in value:
292                     if not self.db.hasnode(link_class, id):
293                         raise ValueError, '%s has no node %s'%(link_class, id)
294                     if id in l:
295                         continue
296                     # register the link with the newly linked node
297                     self.db.addjournal(link_class, id, 'link',
298                         (self.classname, nodeid, key))
299                     l.append(id)
301             elif prop.isStringType:
302                 if value is not None and type(value) != type(''):
303                     raise TypeError, 'new property "%s" not a string'%key
305             elif prop.isDateType:
306                 if not hasattr(value, 'isDate'):
307                     raise TypeError, 'new property "%s" not a Date'% key
309             elif prop.isIntervalType:
310                 if not hasattr(value, 'isInterval'):
311                     raise TypeError, 'new property "%s" not an Interval'% key
313             node[key] = value
315         self.db.setnode(self.classname, nodeid, node)
316         self.db.addjournal(self.classname, nodeid, 'set', propvalues)
318     def retire(self, nodeid):
319         """Retire a node.
320         
321         The properties on the node remain available from the get() method,
322         and the node's id is never reused.
323         
324         Retired nodes are not returned by the find(), list(), or lookup()
325         methods, and other nodes may reuse the values of their key properties.
326         """
327         nodeid = str(nodeid)
328         if self.db.journaltag is None:
329             raise DatabaseError, 'Database open read-only'
330         node = self.db.getnode(self.classname, nodeid)
331         node[self.db.RETIRED_FLAG] = 1
332         self.db.setnode(self.classname, nodeid, node)
333         self.db.addjournal(self.classname, nodeid, 'retired', None)
335     def history(self, nodeid):
336         """Retrieve the journal of edits on a particular node.
338         'nodeid' must be the id of an existing node of this class or an
339         IndexError is raised.
341         The returned list contains tuples of the form
343             (date, tag, action, params)
345         'date' is a Timestamp object specifying the time of the change and
346         'tag' is the journaltag specified when the database was opened.
347         """
348         return self.db.getjournal(self.classname, nodeid)
350     # Locating nodes:
352     def setkey(self, propname):
353         """Select a String property of this class to be the key property.
355         'propname' must be the name of a String property of this class or
356         None, or a TypeError is raised.  The values of the key property on
357         all existing nodes must be unique or a ValueError is raised.
358         """
359         self.key = propname
361     def getkey(self):
362         """Return the name of the key property for this class or None."""
363         return self.key
365     # TODO: set up a separate index db file for this? profile?
366     def lookup(self, keyvalue):
367         """Locate a particular node by its key property and return its id.
369         If this class has no key property, a TypeError is raised.  If the
370         'keyvalue' matches one of the values for the key property among
371         the nodes in this class, the matching node's id is returned;
372         otherwise a KeyError is raised.
373         """
374         cldb = self.db.getclassdb(self.classname)
375         for nodeid in self.db.getnodeids(self.classname, cldb):
376             node = self.db.getnode(self.classname, nodeid, cldb)
377             if node.has_key(self.db.RETIRED_FLAG):
378                 continue
379             if node[self.key] == keyvalue:
380                 return nodeid
381         cldb.close()
382         raise KeyError, keyvalue
384     # XXX: change from spec - allows multiple props to match
385     def find(self, **propspec):
386         """Get the ids of nodes in this class which link to a given node.
388         'propspec' consists of keyword args propname=nodeid   
389           'propname' must be the name of a property in this class, or a
390             KeyError is raised.  That property must be a Link or Multilink
391             property, or a TypeError is raised.
393           'nodeid' must be the id of an existing node in the class linked
394             to by the given property, or an IndexError is raised.
395         """
396         propspec = propspec.items()
397         for propname, nodeid in propspec:
398             nodeid = str(nodeid)
399             # check the prop is OK
400             prop = self.properties[propname]
401             if not prop.isLinkType and not prop.isMultilinkType:
402                 raise TypeError, "'%s' not a Link/Multilink property"%propname
403             if not self.db.hasnode(prop.classname, nodeid):
404                 raise ValueError, '%s has no node %s'%(link_class, nodeid)
406         # ok, now do the find
407         cldb = self.db.getclassdb(self.classname)
408         l = []
409         for id in self.db.getnodeids(self.classname, cldb):
410             node = self.db.getnode(self.classname, id, cldb)
411             if node.has_key(self.db.RETIRED_FLAG):
412                 continue
413             for propname, nodeid in propspec:
414                 nodeid = str(nodeid)
415                 property = node[propname]
416                 if prop.isLinkType and nodeid == property:
417                     l.append(id)
418                 elif prop.isMultilinkType and nodeid in property:
419                     l.append(id)
420         cldb.close()
421         return l
423     def stringFind(self, **requirements):
424         """Locate a particular node by matching a set of its String properties.
426         If the property is not a String property, a TypeError is raised.
427         
428         The return is a list of the id of all nodes that match.
429         """
430         for propname in requirements.keys():
431             prop = self.properties[propname]
432             if not prop.isStringType:
433                 raise TypeError, "'%s' not a String property"%propname
434         l = []
435         cldb = self.db.getclassdb(self.classname)
436         for nodeid in self.db.getnodeids(self.classname, cldb):
437             node = self.db.getnode(self.classname, nodeid, cldb)
438             if node.has_key(self.db.RETIRED_FLAG):
439                 continue
440             for key, value in requirements.items():
441                 if node[key] != value:
442                     break
443             else:
444                 l.append(nodeid)
445         cldb.close()
446         return l
448     def list(self):
449         """Return a list of the ids of the active nodes in this class."""
450         l = []
451         cn = self.classname
452         cldb = self.db.getclassdb(cn)
453         for nodeid in self.db.getnodeids(cn, cldb):
454             node = self.db.getnode(cn, nodeid, cldb)
455             if node.has_key(self.db.RETIRED_FLAG):
456                 continue
457             l.append(nodeid)
458         l.sort()
459         cldb.close()
460         return l
462     # XXX not in spec
463     def filter(self, filterspec, sort, group, num_re = re.compile('^\d+$')):
464         ''' Return a list of the ids of the active nodes in this class that
465             match the 'filter' spec, sorted by the group spec and then the
466             sort spec
467         '''
468         cn = self.classname
470         # optimise filterspec
471         l = []
472         props = self.getprops()
473         for k, v in filterspec.items():
474             propclass = props[k]
475             if propclass.isLinkType:
476                 if type(v) is not type([]):
477                     v = [v]
478                 # replace key values with node ids
479                 u = []
480                 link_class =  self.db.classes[propclass.classname]
481                 for entry in v:
482                     if not num_re.match(entry):
483                         try:
484                             entry = link_class.lookup(entry)
485                         except:
486                             raise ValueError, 'new property "%s": %s not a %s'%(
487                                 k, entry, self.properties[k].classname)
488                     u.append(entry)
490                 l.append((0, k, u))
491             elif propclass.isMultilinkType:
492                 if type(v) is not type([]):
493                     v = [v]
494                 # replace key values with node ids
495                 u = []
496                 link_class =  self.db.classes[propclass.classname]
497                 for entry in v:
498                     if not num_re.match(entry):
499                         try:
500                             entry = link_class.lookup(entry)
501                         except:
502                             raise ValueError, 'new property "%s": %s not a %s'%(
503                                 k, entry, self.properties[k].classname)
504                     u.append(entry)
505                 l.append((1, k, u))
506             elif propclass.isStringType:
507                 v = v[0]
508                 if '*' in v or '?' in v:
509                     # simple glob searching
510                     v = v.replace('?', '.')
511                     v = v.replace('*', '.*?')
512                     v = re.compile(v)
513                     l.append((2, k, v))
514                 elif v[0] == '^':
515                     # start-anchored
516                     if v[-1] == '$':
517                         # _and_ end-anchored
518                         l.append((6, k, v[1:-1]))
519                     l.append((3, k, v[1:]))
520                 elif v[-1] == '$':
521                     # end-anchored
522                     l.append((4, k, v[:-1]))
523                 else:
524                     # substring
525                     l.append((5, k, v))
526             else:
527                 l.append((6, k, v))
528         filterspec = l
530         # now, find all the nodes that are active and pass filtering
531         l = []
532         cldb = self.db.getclassdb(cn)
533         for nodeid in self.db.getnodeids(cn, cldb):
534             node = self.db.getnode(cn, nodeid, cldb)
535             if node.has_key(self.db.RETIRED_FLAG):
536                 continue
537             # apply filter
538             for t, k, v in filterspec:
539                 if t == 0 and node[k] not in v:
540                     # link - if this node'd property doesn't appear in the
541                     # filterspec's nodeid list, skip it
542                     break
543                 elif t == 1:
544                     # multilink - if any of the nodeids required by the
545                     # filterspec aren't in this node's property, then skip
546                     # it
547                     for value in v:
548                         if value not in node[k]:
549                             break
550                     else:
551                         continue
552                     break
553                 elif t == 2 and not v.search(node[k]):
554                     # RE search
555                     break
556                 elif t == 3 and node[k][:len(v)] != v:
557                     # start anchored
558                     break
559                 elif t == 4 and node[k][-len(v):] != v:
560                     # end anchored
561                     break
562                 elif t == 5 and node[k].find(v) == -1:
563                     # substring search
564                     break
565                 elif t == 6 and node[k] != v:
566                     # straight value comparison for the other types
567                     break
568             else:
569                 l.append((nodeid, node))
570         l.sort()
571         cldb.close()
573         # optimise sort
574         m = []
575         for entry in sort:
576             if entry[0] != '-':
577                 m.append(('+', entry))
578             else:
579                 m.append((entry[0], entry[1:]))
580         sort = m
582         # optimise group
583         m = []
584         for entry in group:
585             if entry[0] != '-':
586                 m.append(('+', entry))
587             else:
588                 m.append((entry[0], entry[1:]))
589         group = m
591         # now, sort the result
592         def sortfun(a, b, sort=sort, group=group, properties=self.getprops(),
593                 db = self.db, cl=self):
594             a_id, an = a
595             b_id, bn = b
596             # sort by group and then sort
597             for list in group, sort:
598                 for dir, prop in list:
599                     # handle the properties that might be "faked"
600                     if not an.has_key(prop):
601                         an[prop] = cl.get(a_id, prop)
602                     av = an[prop]
603                     if not bn.has_key(prop):
604                         bn[prop] = cl.get(b_id, prop)
605                     bv = bn[prop]
607                     # sorting is class-specific
608                     propclass = properties[prop]
610                     # String and Date values are sorted in the natural way
611                     if propclass.isStringType:
612                         # clean up the strings
613                         if av and av[0] in string.uppercase:
614                             av = an[prop] = av.lower()
615                         if bv and bv[0] in string.uppercase:
616                             bv = bn[prop] = bv.lower()
617                     if propclass.isStringType or propclass.isDateType:
618                         if dir == '+':
619                             r = cmp(av, bv)
620                             if r != 0: return r
621                         elif dir == '-':
622                             r = cmp(bv, av)
623                             if r != 0: return r
625                     # Link properties are sorted according to the value of
626                     # the "order" property on the linked nodes if it is
627                     # present; or otherwise on the key string of the linked
628                     # nodes; or finally on  the node ids.
629                     elif propclass.isLinkType:
630                         link = db.classes[propclass.classname]
631                         if link.getprops().has_key('order'):
632                             if dir == '+':
633                                 r = cmp(link.get(av, 'order'),
634                                     link.get(bv, 'order'))
635                                 if r != 0: return r
636                             elif dir == '-':
637                                 r = cmp(link.get(bv, 'order'),
638                                     link.get(av, 'order'))
639                                 if r != 0: return r
640                         elif link.getkey():
641                             key = link.getkey()
642                             if dir == '+':
643                                 r = cmp(link.get(av, key), link.get(bv, key))
644                                 if r != 0: return r
645                             elif dir == '-':
646                                 r = cmp(link.get(bv, key), link.get(av, key))
647                                 if r != 0: return r
648                         else:
649                             if dir == '+':
650                                 r = cmp(av, bv)
651                                 if r != 0: return r
652                             elif dir == '-':
653                                 r = cmp(bv, av)
654                                 if r != 0: return r
656                     # Multilink properties are sorted according to how many
657                     # links are present.
658                     elif propclass.isMultilinkType:
659                         if dir == '+':
660                             r = cmp(len(av), len(bv))
661                             if r != 0: return r
662                         elif dir == '-':
663                             r = cmp(len(bv), len(av))
664                             if r != 0: return r
665                 # end for dir, prop in list:
666             # end for list in sort, group:
667             # if all else fails, compare the ids
668             return cmp(a[0], b[0])
670         l.sort(sortfun)
671         return [i[0] for i in l]
673     def count(self):
674         """Get the number of nodes in this class.
676         If the returned integer is 'numnodes', the ids of all the nodes
677         in this class run from 1 to numnodes, and numnodes+1 will be the
678         id of the next node to be created in this class.
679         """
680         return self.db.countnodes(self.classname)
682     # Manipulating properties:
684     def getprops(self):
685         """Return a dictionary mapping property names to property objects."""
686         return self.properties
688     def addprop(self, **properties):
689         """Add properties to this class.
691         The keyword arguments in 'properties' must map names to property
692         objects, or a TypeError is raised.  None of the keys in 'properties'
693         may collide with the names of existing properties, or a ValueError
694         is raised before any properties have been added.
695         """
696         for key in properties.keys():
697             if self.properties.has_key(key):
698                 raise ValueError, key
699         self.properties.update(properties)
702 # XXX not in spec
703 class Node:
704     ''' A convenience wrapper for the given node
705     '''
706     def __init__(self, cl, nodeid):
707         self.__dict__['cl'] = cl
708         self.__dict__['nodeid'] = nodeid
709     def keys(self):
710         return self.cl.getprops().keys()
711     def has_key(self, name):
712         return self.cl.getprops().has_key(name)
713     def __getattr__(self, name):
714         if self.__dict__.has_key(name):
715             return self.__dict__['name']
716         try:
717             return self.cl.get(self.nodeid, name)
718         except KeyError, value:
719             raise AttributeError, str(value)
720     def __getitem__(self, name):
721         return self.cl.get(self.nodeid, name)
722     def __setattr__(self, name, value):
723         try:
724             return self.cl.set(self.nodeid, **{name: value})
725         except KeyError, value:
726             raise AttributeError, str(value)
727     def __setitem__(self, name, value):
728         self.cl.set(self.nodeid, **{name: value})
729     def history(self):
730         return self.cl.history(self.nodeid)
731     def retire(self):
732         return self.cl.retire(self.nodeid)
735 def Choice(name, *options):
736     cl = Class(db, name, name=hyperdb.String(), order=hyperdb.String())
737     for i in range(len(options)):
738         cl.create(name=option[i], order=i)
739     return hyperdb.Link(name)
742 # $Log: not supported by cvs2svn $
743 # Revision 1.2  2001/07/22 12:09:32  richard
744 # Final commit of Grande Splite
746 # Revision 1.1  2001/07/22 11:58:35  richard
747 # More Grande Splite