2a09c3c96a73ed11a6bd65164d265f9ccc17ee85
1 # $Id: hyperdb.py,v 1.6 2001-07-29 05:36: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.
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 def labelprop(self, nodeid):
391 ''' Return the property name for a label for the given node.
393 This method attempts to generate a consistent label for the node.
394 It tries the following in order:
395 1. key property
396 2. "name" property
397 3. "title" property
398 4. first property from the sorted property name list
399 '''
400 k = self.getkey()
401 if k:
402 return k
403 props = self.getprops()
404 if props.has_key('name'):
405 return 'name'
406 elif props.has_key('title'):
407 return 'title'
408 props = props.keys()
409 props.sort()
410 return props[0]
412 # TODO: set up a separate index db file for this? profile?
413 def lookup(self, keyvalue):
414 """Locate a particular node by its key property and return its id.
416 If this class has no key property, a TypeError is raised. If the
417 'keyvalue' matches one of the values for the key property among
418 the nodes in this class, the matching node's id is returned;
419 otherwise a KeyError is raised.
420 """
421 cldb = self.db.getclassdb(self.classname)
422 for nodeid in self.db.getnodeids(self.classname, cldb):
423 node = self.db.getnode(self.classname, nodeid, cldb)
424 if node.has_key(self.db.RETIRED_FLAG):
425 continue
426 if node[self.key] == keyvalue:
427 return nodeid
428 cldb.close()
429 raise KeyError, keyvalue
431 # XXX: change from spec - allows multiple props to match
432 def find(self, **propspec):
433 """Get the ids of nodes in this class which link to a given node.
435 'propspec' consists of keyword args propname=nodeid
436 'propname' must be the name of a property in this class, or a
437 KeyError is raised. That property must be a Link or Multilink
438 property, or a TypeError is raised.
440 'nodeid' must be the id of an existing node in the class linked
441 to by the given property, or an IndexError is raised.
442 """
443 propspec = propspec.items()
444 for propname, nodeid in propspec:
445 # nodeid = str(nodeid)
446 # check the prop is OK
447 prop = self.properties[propname]
448 if not prop.isLinkType and not prop.isMultilinkType:
449 raise TypeError, "'%s' not a Link/Multilink property"%propname
450 if not self.db.hasnode(prop.classname, nodeid):
451 raise ValueError, '%s has no node %s'%(link_class, nodeid)
453 # ok, now do the find
454 cldb = self.db.getclassdb(self.classname)
455 l = []
456 for id in self.db.getnodeids(self.classname, cldb):
457 node = self.db.getnode(self.classname, id, cldb)
458 if node.has_key(self.db.RETIRED_FLAG):
459 continue
460 for propname, nodeid in propspec:
461 # nodeid = str(nodeid)
462 property = node[propname]
463 if prop.isLinkType and nodeid == property:
464 l.append(id)
465 elif prop.isMultilinkType and nodeid in property:
466 l.append(id)
467 cldb.close()
468 return l
470 def stringFind(self, **requirements):
471 """Locate a particular node by matching a set of its String properties.
473 If the property is not a String property, a TypeError is raised.
475 The return is a list of the id of all nodes that match.
476 """
477 for propname in requirements.keys():
478 prop = self.properties[propname]
479 if not prop.isStringType:
480 raise TypeError, "'%s' not a String property"%propname
481 l = []
482 cldb = self.db.getclassdb(self.classname)
483 for nodeid in self.db.getnodeids(self.classname, cldb):
484 node = self.db.getnode(self.classname, nodeid, cldb)
485 if node.has_key(self.db.RETIRED_FLAG):
486 continue
487 for key, value in requirements.items():
488 if node[key] != value:
489 break
490 else:
491 l.append(nodeid)
492 cldb.close()
493 return l
495 def list(self):
496 """Return a list of the ids of the active nodes in this class."""
497 l = []
498 cn = self.classname
499 cldb = self.db.getclassdb(cn)
500 for nodeid in self.db.getnodeids(cn, cldb):
501 node = self.db.getnode(cn, nodeid, cldb)
502 if node.has_key(self.db.RETIRED_FLAG):
503 continue
504 l.append(nodeid)
505 l.sort()
506 cldb.close()
507 return l
509 # XXX not in spec
510 def filter(self, filterspec, sort, group, num_re = re.compile('^\d+$')):
511 ''' Return a list of the ids of the active nodes in this class that
512 match the 'filter' spec, sorted by the group spec and then the
513 sort spec
514 '''
515 cn = self.classname
517 # optimise filterspec
518 l = []
519 props = self.getprops()
520 for k, v in filterspec.items():
521 propclass = props[k]
522 if propclass.isLinkType:
523 if type(v) is not type([]):
524 v = [v]
525 # replace key values with node ids
526 u = []
527 link_class = self.db.classes[propclass.classname]
528 for entry in v:
529 if not num_re.match(entry):
530 try:
531 entry = link_class.lookup(entry)
532 except:
533 raise ValueError, 'new property "%s": %s not a %s'%(
534 k, entry, self.properties[k].classname)
535 u.append(entry)
537 l.append((0, k, u))
538 elif propclass.isMultilinkType:
539 if type(v) is not type([]):
540 v = [v]
541 # replace key values with node ids
542 u = []
543 link_class = self.db.classes[propclass.classname]
544 for entry in v:
545 if not num_re.match(entry):
546 try:
547 entry = link_class.lookup(entry)
548 except:
549 raise ValueError, 'new property "%s": %s not a %s'%(
550 k, entry, self.properties[k].classname)
551 u.append(entry)
552 l.append((1, k, u))
553 elif propclass.isStringType:
554 v = v[0]
555 if '*' in v or '?' in v:
556 # simple glob searching
557 v = v.replace('?', '.')
558 v = v.replace('*', '.*?')
559 v = re.compile(v)
560 l.append((2, k, v))
561 elif v[0] == '^':
562 # start-anchored
563 if v[-1] == '$':
564 # _and_ end-anchored
565 l.append((6, k, v[1:-1]))
566 l.append((3, k, v[1:]))
567 elif v[-1] == '$':
568 # end-anchored
569 l.append((4, k, v[:-1]))
570 else:
571 # substring
572 l.append((5, k, v))
573 else:
574 l.append((6, k, v))
575 filterspec = l
577 # now, find all the nodes that are active and pass filtering
578 l = []
579 cldb = self.db.getclassdb(cn)
580 for nodeid in self.db.getnodeids(cn, cldb):
581 node = self.db.getnode(cn, nodeid, cldb)
582 if node.has_key(self.db.RETIRED_FLAG):
583 continue
584 # apply filter
585 for t, k, v in filterspec:
586 if t == 0 and node[k] not in v:
587 # link - if this node'd property doesn't appear in the
588 # filterspec's nodeid list, skip it
589 break
590 elif t == 1:
591 # multilink - if any of the nodeids required by the
592 # filterspec aren't in this node's property, then skip
593 # it
594 for value in v:
595 if value not in node[k]:
596 break
597 else:
598 continue
599 break
600 elif t == 2 and not v.search(node[k]):
601 # RE search
602 break
603 elif t == 3 and node[k][:len(v)] != v:
604 # start anchored
605 break
606 elif t == 4 and node[k][-len(v):] != v:
607 # end anchored
608 break
609 elif t == 5 and node[k].find(v) == -1:
610 # substring search
611 break
612 elif t == 6 and node[k] != v:
613 # straight value comparison for the other types
614 break
615 else:
616 l.append((nodeid, node))
617 l.sort()
618 cldb.close()
620 # optimise sort
621 m = []
622 for entry in sort:
623 if entry[0] != '-':
624 m.append(('+', entry))
625 else:
626 m.append((entry[0], entry[1:]))
627 sort = m
629 # optimise group
630 m = []
631 for entry in group:
632 if entry[0] != '-':
633 m.append(('+', entry))
634 else:
635 m.append((entry[0], entry[1:]))
636 group = m
638 # now, sort the result
639 def sortfun(a, b, sort=sort, group=group, properties=self.getprops(),
640 db = self.db, cl=self):
641 a_id, an = a
642 b_id, bn = b
643 # sort by group and then sort
644 for list in group, sort:
645 for dir, prop in list:
646 # handle the properties that might be "faked"
647 if not an.has_key(prop):
648 an[prop] = cl.get(a_id, prop)
649 av = an[prop]
650 if not bn.has_key(prop):
651 bn[prop] = cl.get(b_id, prop)
652 bv = bn[prop]
654 # sorting is class-specific
655 propclass = properties[prop]
657 # String and Date values are sorted in the natural way
658 if propclass.isStringType:
659 # clean up the strings
660 if av and av[0] in string.uppercase:
661 av = an[prop] = av.lower()
662 if bv and bv[0] in string.uppercase:
663 bv = bn[prop] = bv.lower()
664 if propclass.isStringType or propclass.isDateType:
665 if dir == '+':
666 r = cmp(av, bv)
667 if r != 0: return r
668 elif dir == '-':
669 r = cmp(bv, av)
670 if r != 0: return r
672 # Link properties are sorted according to the value of
673 # the "order" property on the linked nodes if it is
674 # present; or otherwise on the key string of the linked
675 # nodes; or finally on the node ids.
676 elif propclass.isLinkType:
677 link = db.classes[propclass.classname]
678 if link.getprops().has_key('order'):
679 if dir == '+':
680 r = cmp(link.get(av, 'order'),
681 link.get(bv, 'order'))
682 if r != 0: return r
683 elif dir == '-':
684 r = cmp(link.get(bv, 'order'),
685 link.get(av, 'order'))
686 if r != 0: return r
687 elif link.getkey():
688 key = link.getkey()
689 if dir == '+':
690 r = cmp(link.get(av, key), link.get(bv, key))
691 if r != 0: return r
692 elif dir == '-':
693 r = cmp(link.get(bv, key), link.get(av, key))
694 if r != 0: return r
695 else:
696 if dir == '+':
697 r = cmp(av, bv)
698 if r != 0: return r
699 elif dir == '-':
700 r = cmp(bv, av)
701 if r != 0: return r
703 # Multilink properties are sorted according to how many
704 # links are present.
705 elif propclass.isMultilinkType:
706 if dir == '+':
707 r = cmp(len(av), len(bv))
708 if r != 0: return r
709 elif dir == '-':
710 r = cmp(len(bv), len(av))
711 if r != 0: return r
712 # end for dir, prop in list:
713 # end for list in sort, group:
714 # if all else fails, compare the ids
715 return cmp(a[0], b[0])
717 l.sort(sortfun)
718 return [i[0] for i in l]
720 def count(self):
721 """Get the number of nodes in this class.
723 If the returned integer is 'numnodes', the ids of all the nodes
724 in this class run from 1 to numnodes, and numnodes+1 will be the
725 id of the next node to be created in this class.
726 """
727 return self.db.countnodes(self.classname)
729 # Manipulating properties:
731 def getprops(self):
732 """Return a dictionary mapping property names to property objects."""
733 d = self.properties.copy()
734 d['id'] = String()
735 return d
737 def addprop(self, **properties):
738 """Add properties to this class.
740 The keyword arguments in 'properties' must map names to property
741 objects, or a TypeError is raised. None of the keys in 'properties'
742 may collide with the names of existing properties, or a ValueError
743 is raised before any properties have been added.
744 """
745 for key in properties.keys():
746 if self.properties.has_key(key):
747 raise ValueError, key
748 self.properties.update(properties)
751 # XXX not in spec
752 class Node:
753 ''' A convenience wrapper for the given node
754 '''
755 def __init__(self, cl, nodeid):
756 self.__dict__['cl'] = cl
757 self.__dict__['nodeid'] = nodeid
758 def keys(self):
759 return self.cl.getprops().keys()
760 def has_key(self, name):
761 return self.cl.getprops().has_key(name)
762 def __getattr__(self, name):
763 if self.__dict__.has_key(name):
764 return self.__dict__['name']
765 try:
766 return self.cl.get(self.nodeid, name)
767 except KeyError, value:
768 raise AttributeError, str(value)
769 def __getitem__(self, name):
770 return self.cl.get(self.nodeid, name)
771 def __setattr__(self, name, value):
772 try:
773 return self.cl.set(self.nodeid, **{name: value})
774 except KeyError, value:
775 raise AttributeError, str(value)
776 def __setitem__(self, name, value):
777 self.cl.set(self.nodeid, **{name: value})
778 def history(self):
779 return self.cl.history(self.nodeid)
780 def retire(self):
781 return self.cl.retire(self.nodeid)
784 def Choice(name, *options):
785 cl = Class(db, name, name=hyperdb.String(), order=hyperdb.String())
786 for i in range(len(options)):
787 cl.create(name=option[i], order=i)
788 return hyperdb.Link(name)
790 #
791 # $Log: not supported by cvs2svn $
792 # Revision 1.5 2001/07/29 04:05:37 richard
793 # Added the fabricated property "id".
794 #
795 # Revision 1.4 2001/07/27 06:25:35 richard
796 # Fixed some of the exceptions so they're the right type.
797 # Removed the str()-ification of node ids so we don't mask oopsy errors any
798 # more.
799 #
800 # Revision 1.3 2001/07/27 05:17:14 richard
801 # just some comments
802 #
803 # Revision 1.2 2001/07/22 12:09:32 richard
804 # Final commit of Grande Splite
805 #
806 # Revision 1.1 2001/07/22 11:58:35 richard
807 # More Grande Splite
808 #