4c13318efb13dac271743e2cf371d39f3c1874b6
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.
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.
95 Any other properties on this class that are missing from the
96 'propvalues' dictionary are set to None.
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.
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.
346 The properties on the node remain available from the get() method,
347 and the node's id is never reused.
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.
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)
768 #
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.
774 #
775 # Revision 1.3 2001/07/27 05:17:14 richard
776 # just some comments
777 #
778 # Revision 1.2 2001/07/22 12:09:32 richard
779 # Final commit of Grande Splite
780 #
781 # Revision 1.1 2001/07/22 11:58:35 richard
782 # More Grande Splite
783 #