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.
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 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.
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.
334 The properties on the node remain available from the get() method,
335 and the node's id is never reused.
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.
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)
754 #
755 # $Log: not supported by cvs2svn $
756 # Revision 1.3 2001/07/27 05:17:14 richard
757 # just some comments
758 #
759 # Revision 1.2 2001/07/22 12:09:32 richard
760 # Final commit of Grande Splite
761 #
762 # Revision 1.1 2001/07/22 11:58:35 richard
763 # More Grande Splite
764 #