252194c0c93636a041f3a316d733841848c0f9a8
1 # $Id: hyperdb.py,v 1.9 2001-07-29 09:28:23 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):
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 if '*' in v or '?' in v:
555 # simple glob searching
556 v = v.replace('?', '.')
557 v = v.replace('*', '.*?')
558 v = re.compile(v)
559 l.append((2, k, v))
560 elif v[0] == '^':
561 # start-anchored
562 if v[-1] == '$':
563 # _and_ end-anchored
564 l.append((6, k, v[1:-1]))
565 l.append((3, k, v[1:]))
566 elif v[-1] == '$':
567 # end-anchored
568 l.append((4, k, v[:-1]))
569 else:
570 # substring
571 l.append((5, k, v))
572 else:
573 l.append((6, k, v))
574 filterspec = l
576 # now, find all the nodes that are active and pass filtering
577 l = []
578 cldb = self.db.getclassdb(cn)
579 for nodeid in self.db.getnodeids(cn, cldb):
580 node = self.db.getnode(cn, nodeid, cldb)
581 if node.has_key(self.db.RETIRED_FLAG):
582 continue
583 # apply filter
584 for t, k, v in filterspec:
585 if t == 0 and node[k] not in v:
586 # link - if this node'd property doesn't appear in the
587 # filterspec's nodeid list, skip it
588 break
589 elif t == 1:
590 # multilink - if any of the nodeids required by the
591 # filterspec aren't in this node's property, then skip
592 # it
593 for value in v:
594 if value not in node[k]:
595 break
596 else:
597 continue
598 break
599 elif t == 2 and not v.search(node[k]):
600 # RE search
601 break
602 elif t == 3 and node[k][:len(v)] != v:
603 # start anchored
604 break
605 elif t == 4 and node[k][-len(v):] != v:
606 # end anchored
607 break
608 elif t == 5 and node[k].find(v) == -1:
609 # substring search
610 break
611 elif t == 6 and node[k] != v:
612 # straight value comparison for the other types
613 break
614 else:
615 l.append((nodeid, node))
616 l.sort()
617 cldb.close()
619 # optimise sort
620 m = []
621 for entry in sort:
622 if entry[0] != '-':
623 m.append(('+', entry))
624 else:
625 m.append((entry[0], entry[1:]))
626 sort = m
628 # optimise group
629 m = []
630 for entry in group:
631 if entry[0] != '-':
632 m.append(('+', entry))
633 else:
634 m.append((entry[0], entry[1:]))
635 group = m
636 # now, sort the result
637 def sortfun(a, b, sort=sort, group=group, properties=self.getprops(),
638 db = self.db, cl=self):
639 a_id, an = a
640 b_id, bn = b
641 # sort by group and then sort
642 for list in group, sort:
643 for dir, prop in list:
644 # handle the properties that might be "faked"
645 if not an.has_key(prop):
646 an[prop] = cl.get(a_id, prop)
647 av = an[prop]
648 if not bn.has_key(prop):
649 bn[prop] = cl.get(b_id, prop)
650 bv = bn[prop]
652 # sorting is class-specific
653 propclass = properties[prop]
655 # String and Date values are sorted in the natural way
656 if propclass.isStringType:
657 # clean up the strings
658 if av and av[0] in string.uppercase:
659 av = an[prop] = av.lower()
660 if bv and bv[0] in string.uppercase:
661 bv = bn[prop] = bv.lower()
662 if propclass.isStringType or propclass.isDateType:
663 if dir == '+':
664 r = cmp(av, bv)
665 if r != 0: return r
666 elif dir == '-':
667 r = cmp(bv, av)
668 if r != 0: return r
670 # Link properties are sorted according to the value of
671 # the "order" property on the linked nodes if it is
672 # present; or otherwise on the key string of the linked
673 # nodes; or finally on the node ids.
674 elif propclass.isLinkType:
675 link = db.classes[propclass.classname]
676 if av is None and bv is not None: return -1
677 if av is not None and bv is None: return 1
678 if av is None and bv is None: return 0
679 if link.getprops().has_key('order'):
680 if dir == '+':
681 r = cmp(link.get(av, 'order'),
682 link.get(bv, 'order'))
683 if r != 0: return r
684 elif dir == '-':
685 r = cmp(link.get(bv, 'order'),
686 link.get(av, 'order'))
687 if r != 0: return r
688 elif link.getkey():
689 key = link.getkey()
690 if dir == '+':
691 r = cmp(link.get(av, key), link.get(bv, key))
692 if r != 0: return r
693 elif dir == '-':
694 r = cmp(link.get(bv, key), link.get(av, key))
695 if r != 0: return r
696 else:
697 if dir == '+':
698 r = cmp(av, bv)
699 if r != 0: return r
700 elif dir == '-':
701 r = cmp(bv, av)
702 if r != 0: return r
704 # Multilink properties are sorted according to how many
705 # links are present.
706 elif propclass.isMultilinkType:
707 if dir == '+':
708 r = cmp(len(av), len(bv))
709 if r != 0: return r
710 elif dir == '-':
711 r = cmp(len(bv), len(av))
712 if r != 0: return r
713 # end for dir, prop in list:
714 # end for list in sort, group:
715 # if all else fails, compare the ids
716 return cmp(a[0], b[0])
718 l.sort(sortfun)
719 return [i[0] for i in l]
721 def count(self):
722 """Get the number of nodes in this class.
724 If the returned integer is 'numnodes', the ids of all the nodes
725 in this class run from 1 to numnodes, and numnodes+1 will be the
726 id of the next node to be created in this class.
727 """
728 return self.db.countnodes(self.classname)
730 # Manipulating properties:
732 def getprops(self):
733 """Return a dictionary mapping property names to property objects."""
734 d = self.properties.copy()
735 d['id'] = String()
736 return d
738 def addprop(self, **properties):
739 """Add properties to this class.
741 The keyword arguments in 'properties' must map names to property
742 objects, or a TypeError is raised. None of the keys in 'properties'
743 may collide with the names of existing properties, or a ValueError
744 is raised before any properties have been added.
745 """
746 for key in properties.keys():
747 if self.properties.has_key(key):
748 raise ValueError, key
749 self.properties.update(properties)
752 # XXX not in spec
753 class Node:
754 ''' A convenience wrapper for the given node
755 '''
756 def __init__(self, cl, nodeid):
757 self.__dict__['cl'] = cl
758 self.__dict__['nodeid'] = nodeid
759 def keys(self):
760 return self.cl.getprops().keys()
761 def has_key(self, name):
762 return self.cl.getprops().has_key(name)
763 def __getattr__(self, name):
764 if self.__dict__.has_key(name):
765 return self.__dict__['name']
766 try:
767 return self.cl.get(self.nodeid, name)
768 except KeyError, value:
769 raise AttributeError, str(value)
770 def __getitem__(self, name):
771 return self.cl.get(self.nodeid, name)
772 def __setattr__(self, name, value):
773 try:
774 return self.cl.set(self.nodeid, **{name: value})
775 except KeyError, value:
776 raise AttributeError, str(value)
777 def __setitem__(self, name, value):
778 self.cl.set(self.nodeid, **{name: value})
779 def history(self):
780 return self.cl.history(self.nodeid)
781 def retire(self):
782 return self.cl.retire(self.nodeid)
785 def Choice(name, *options):
786 cl = Class(db, name, name=hyperdb.String(), order=hyperdb.String())
787 for i in range(len(options)):
788 cl.create(name=option[i], order=i)
789 return hyperdb.Link(name)
791 #
792 # $Log: not supported by cvs2svn $
793 # Revision 1.8 2001/07/29 08:27:40 richard
794 # Fixed handling of passed-in values in form elements (ie. during a
795 # drill-down)
796 #
797 # Revision 1.7 2001/07/29 07:01:39 richard
798 # Added vim command to all source so that we don't get no steenkin' tabs :)
799 #
800 # Revision 1.6 2001/07/29 05:36:14 richard
801 # Cleanup of the link label generation.
802 #
803 # Revision 1.5 2001/07/29 04:05:37 richard
804 # Added the fabricated property "id".
805 #
806 # Revision 1.4 2001/07/27 06:25:35 richard
807 # Fixed some of the exceptions so they're the right type.
808 # Removed the str()-ification of node ids so we don't mask oopsy errors any
809 # more.
810 #
811 # Revision 1.3 2001/07/27 05:17:14 richard
812 # just some comments
813 #
814 # Revision 1.2 2001/07/22 12:09:32 richard
815 # Final commit of Grande Splite
816 #
817 # Revision 1.1 2001/07/22 11:58:35 richard
818 # More Grande Splite
819 #
820 #
821 # vim: set filetype=python ts=4 sw=4 et si