Code

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