8a12af13add2e30984844e49a1cfd9499ad50992
1 #
2 # Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/)
3 # This module is free software, and you may redistribute it and/or modify
4 # under the same terms as Python, so long as this copyright message and
5 # disclaimer are retained in their original form.
6 #
7 # IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR
8 # DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING
9 # OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE
10 # POSSIBILITY OF SUCH DAMAGE.
11 #
12 # BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING,
13 # BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
14 # FOR A PARTICULAR PURPOSE. THE CODE PROVIDED HEREUNDER IS ON AN "AS IS"
15 # BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
16 # SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
17 #
18 # $Id: hyperdb.py,v 1.52 2002-01-21 16:33:19 rochecompaan Exp $
20 __doc__ = """
21 Hyperdatabase implementation, especially field types.
22 """
24 # standard python modules
25 import cPickle, re, string, weakref
27 # roundup modules
28 import date, password
31 #
32 # Types
33 #
34 class String:
35 """An object designating a String property."""
36 def __repr__(self):
37 return '<%s>'%self.__class__
39 class Password:
40 """An object designating a Password property."""
41 def __repr__(self):
42 return '<%s>'%self.__class__
44 class Date:
45 """An object designating a Date property."""
46 def __repr__(self):
47 return '<%s>'%self.__class__
49 class Interval:
50 """An object designating an Interval property."""
51 def __repr__(self):
52 return '<%s>'%self.__class__
54 class Link:
55 """An object designating a Link property that links to a
56 node in a specified class."""
57 def __init__(self, classname, do_journal='no'):
58 self.classname = classname
59 self.do_journal = do_journal == 'yes'
60 def __repr__(self):
61 return '<%s to "%s">'%(self.__class__, self.classname)
63 class Multilink:
64 """An object designating a Multilink property that links
65 to nodes in a specified class.
67 "classname" indicates the class to link to
69 "do_journal" indicates whether the linked-to nodes should have
70 'link' and 'unlink' events placed in their journal
71 """
72 def __init__(self, classname, do_journal='no'):
73 self.classname = classname
74 self.do_journal = do_journal == 'yes'
75 def __repr__(self):
76 return '<%s to "%s">'%(self.__class__, self.classname)
78 class DatabaseError(ValueError):
79 pass
82 #
83 # the base Database class
84 #
85 class Database:
86 '''A database for storing records containing flexible data types.
88 This class defines a hyperdatabase storage layer, which the Classes use to
89 store their data.
92 Transactions
93 ------------
94 The Database should support transactions through the commit() and
95 rollback() methods. All other Database methods should be transaction-aware,
96 using data from the current transaction before looking up the database.
98 An implementation must provide an override for the get() method so that the
99 in-database value is returned in preference to the in-transaction value.
100 This is necessary to determine if any values have changed during a
101 transaction.
103 '''
105 # flag to set on retired entries
106 RETIRED_FLAG = '__hyperdb_retired'
108 # XXX deviates from spec: storagelocator is obtained from the config
109 def __init__(self, config, journaltag=None):
110 """Open a hyperdatabase given a specifier to some storage.
112 The 'storagelocator' is obtained from config.DATABASE.
113 The meaning of 'storagelocator' depends on the particular
114 implementation of the hyperdatabase. It could be a file name,
115 a directory path, a socket descriptor for a connection to a
116 database over the network, etc.
118 The 'journaltag' is a token that will be attached to the journal
119 entries for any edits done on the database. If 'journaltag' is
120 None, the database is opened in read-only mode: the Class.create(),
121 Class.set(), and Class.retire() methods are disabled.
122 """
123 raise NotImplementedError
125 def __getattr__(self, classname):
126 """A convenient way of calling self.getclass(classname)."""
127 raise NotImplementedError
129 def addclass(self, cl):
130 '''Add a Class to the hyperdatabase.
131 '''
132 raise NotImplementedError
134 def getclasses(self):
135 """Return a list of the names of all existing classes."""
136 raise NotImplementedError
138 def getclass(self, classname):
139 """Get the Class object representing a particular class.
141 If 'classname' is not a valid class name, a KeyError is raised.
142 """
143 raise NotImplementedError
145 def clear(self):
146 '''Delete all database contents.
147 '''
148 raise NotImplementedError
150 def getclassdb(self, classname, mode='r'):
151 '''Obtain a connection to the class db that will be used for
152 multiple actions.
153 '''
154 raise NotImplementedError
156 def addnode(self, classname, nodeid, node):
157 '''Add the specified node to its class's db.
158 '''
159 raise NotImplementedError
161 def setnode(self, classname, nodeid, node):
162 '''Change the specified node.
163 '''
164 raise NotImplementedError
166 def getnode(self, classname, nodeid, db=None, cache=1):
167 '''Get a node from the database.
168 '''
169 raise NotImplementedError
171 def hasnode(self, classname, nodeid, db=None):
172 '''Determine if the database has a given node.
173 '''
174 raise NotImplementedError
176 def countnodes(self, classname, db=None):
177 '''Count the number of nodes that exist for a particular Class.
178 '''
179 raise NotImplementedError
181 def getnodeids(self, classname, db=None):
182 '''Retrieve all the ids of the nodes for a particular Class.
183 '''
184 raise NotImplementedError
186 def storefile(self, classname, nodeid, property, content):
187 '''Store the content of the file in the database.
189 The property may be None, in which case the filename does not
190 indicate which property is being saved.
191 '''
192 raise NotImplementedError
194 def getfile(self, classname, nodeid, property):
195 '''Store the content of the file in the database.
196 '''
197 raise NotImplementedError
199 def addjournal(self, classname, nodeid, action, params):
200 ''' Journal the Action
201 'action' may be:
203 'create' or 'set' -- 'params' is a dictionary of property values
204 'link' or 'unlink' -- 'params' is (classname, nodeid, propname)
205 'retire' -- 'params' is None
206 '''
207 raise NotImplementedError
209 def getjournal(self, classname, nodeid):
210 ''' get the journal for id
211 '''
212 raise NotImplementedError
214 def pack(self, pack_before):
215 ''' pack the database
216 '''
217 raise NotImplementedError
219 def commit(self):
220 ''' Commit the current transactions.
222 Save all data changed since the database was opened or since the
223 last commit() or rollback().
224 '''
225 raise NotImplementedError
227 def rollback(self):
228 ''' Reverse all actions from the current transaction.
230 Undo all the changes made since the database was opened or the last
231 commit() or rollback() was performed.
232 '''
233 raise NotImplementedError
235 _marker = []
236 #
237 # The base Class class
238 #
239 class Class:
240 """The handle to a particular class of nodes in a hyperdatabase."""
242 def __init__(self, db, classname, **properties):
243 """Create a new class with a given name and property specification.
245 'classname' must not collide with the name of an existing class,
246 or a ValueError is raised. The keyword arguments in 'properties'
247 must map names to property objects, or a TypeError is raised.
248 """
249 self.classname = classname
250 self.properties = properties
251 self.db = weakref.proxy(db) # use a weak ref to avoid circularity
252 self.key = ''
254 # do the db-related init stuff
255 db.addclass(self)
257 def __repr__(self):
258 return '<hypderdb.Class "%s">'%self.classname
260 # Editing nodes:
262 def create(self, **propvalues):
263 """Create a new node of this class and return its id.
265 The keyword arguments in 'propvalues' map property names to values.
267 The values of arguments must be acceptable for the types of their
268 corresponding properties or a TypeError is raised.
270 If this class has a key property, it must be present and its value
271 must not collide with other key strings or a ValueError is raised.
273 Any other properties on this class that are missing from the
274 'propvalues' dictionary are set to None.
276 If an id in a link or multilink property does not refer to a valid
277 node, an IndexError is raised.
278 """
279 if propvalues.has_key('id'):
280 raise KeyError, '"id" is reserved'
282 if self.db.journaltag is None:
283 raise DatabaseError, 'Database open read-only'
285 # new node's id
286 newid = str(self.count() + 1)
288 # validate propvalues
289 num_re = re.compile('^\d+$')
290 for key, value in propvalues.items():
291 if key == self.key:
292 try:
293 self.lookup(value)
294 except KeyError:
295 pass
296 else:
297 raise ValueError, 'node with key "%s" exists'%value
299 # try to handle this property
300 try:
301 prop = self.properties[key]
302 except KeyError:
303 raise KeyError, '"%s" has no property "%s"'%(self.classname,
304 key)
306 if isinstance(prop, Link):
307 if type(value) != type(''):
308 raise ValueError, 'link value must be String'
309 link_class = self.properties[key].classname
310 # if it isn't a number, it's a key
311 if not num_re.match(value):
312 try:
313 value = self.db.classes[link_class].lookup(value)
314 except (TypeError, KeyError):
315 raise IndexError, 'new property "%s": %s not a %s'%(
316 key, value, link_class)
317 elif not self.db.hasnode(link_class, value):
318 raise IndexError, '%s has no node %s'%(link_class, value)
320 # save off the value
321 propvalues[key] = value
323 # register the link with the newly linked node
324 if self.properties[key].do_journal:
325 self.db.addjournal(link_class, value, 'link',
326 (self.classname, newid, key))
328 elif isinstance(prop, Multilink):
329 if type(value) != type([]):
330 raise TypeError, 'new property "%s" not a list of ids'%key
331 link_class = self.properties[key].classname
332 l = []
333 for entry in value:
334 if type(entry) != type(''):
335 raise ValueError, 'link value must be String'
336 # if it isn't a number, it's a key
337 if not num_re.match(entry):
338 try:
339 entry = self.db.classes[link_class].lookup(entry)
340 except (TypeError, KeyError):
341 raise IndexError, 'new property "%s": %s not a %s'%(
342 key, entry, self.properties[key].classname)
343 l.append(entry)
344 value = l
345 propvalues[key] = value
347 # handle additions
348 for id in value:
349 if not self.db.hasnode(link_class, id):
350 raise IndexError, '%s has no node %s'%(link_class, id)
351 # register the link with the newly linked node
352 if self.properties[key].do_journal:
353 self.db.addjournal(link_class, id, 'link',
354 (self.classname, newid, key))
356 elif isinstance(prop, String):
357 if type(value) != type(''):
358 raise TypeError, 'new property "%s" not a string'%key
360 elif isinstance(prop, Password):
361 if not isinstance(value, password.Password):
362 raise TypeError, 'new property "%s" not a Password'%key
364 elif isinstance(prop, Date):
365 if value is not None and not isinstance(value, date.Date):
366 raise TypeError, 'new property "%s" not a Date'%key
368 elif isinstance(prop, Interval):
369 if value is not None and not isinstance(value, date.Interval):
370 raise TypeError, 'new property "%s" not an Interval'%key
372 # make sure there's data where there needs to be
373 for key, prop in self.properties.items():
374 if propvalues.has_key(key):
375 continue
376 if key == self.key:
377 raise ValueError, 'key property "%s" is required'%key
378 if isinstance(prop, Multilink):
379 propvalues[key] = []
380 else:
381 # TODO: None isn't right here, I think...
382 propvalues[key] = None
384 # convert all data to strings
385 for key, prop in self.properties.items():
386 if isinstance(prop, Date):
387 if propvalues[key] is not None:
388 propvalues[key] = propvalues[key].get_tuple()
389 elif isinstance(prop, Interval):
390 if propvalues[key] is not None:
391 propvalues[key] = propvalues[key].get_tuple()
392 elif isinstance(prop, Password):
393 propvalues[key] = str(propvalues[key])
395 # done
396 self.db.addnode(self.classname, newid, propvalues)
397 self.db.addjournal(self.classname, newid, 'create', propvalues)
398 return newid
400 def get(self, nodeid, propname, default=_marker, cache=1):
401 """Get the value of a property on an existing node of this class.
403 'nodeid' must be the id of an existing node of this class or an
404 IndexError is raised. 'propname' must be the name of a property
405 of this class or a KeyError is raised.
407 'cache' indicates whether the transaction cache should be queried
408 for the node. If the node has been modified and you need to
409 determine what its values prior to modification are, you need to
410 set cache=0.
411 """
412 if propname == 'id':
413 return nodeid
415 # get the property (raises KeyErorr if invalid)
416 prop = self.properties[propname]
418 # get the node's dict
419 d = self.db.getnode(self.classname, nodeid, cache=cache)
421 if not d.has_key(propname):
422 if default is _marker:
423 if isinstance(prop, Multilink):
424 return []
425 else:
426 # TODO: None isn't right here, I think...
427 return None
428 else:
429 return default
431 # possibly convert the marshalled data to instances
432 if isinstance(prop, Date):
433 if d[propname] is None:
434 return None
435 return date.Date(d[propname])
436 elif isinstance(prop, Interval):
437 if d[propname] is None:
438 return None
439 return date.Interval(d[propname])
440 elif isinstance(prop, Password):
441 p = password.Password()
442 p.unpack(d[propname])
443 return p
445 return d[propname]
447 # XXX not in spec
448 def getnode(self, nodeid, cache=1):
449 ''' Return a convenience wrapper for the node.
451 'nodeid' must be the id of an existing node of this class or an
452 IndexError is raised.
454 'cache' indicates whether the transaction cache should be queried
455 for the node. If the node has been modified and you need to
456 determine what its values prior to modification are, you need to
457 set cache=0.
458 '''
459 return Node(self, nodeid, cache=cache)
461 def set(self, nodeid, **propvalues):
462 """Modify a property on an existing node of this class.
464 'nodeid' must be the id of an existing node of this class or an
465 IndexError is raised.
467 Each key in 'propvalues' must be the name of a property of this
468 class or a KeyError is raised.
470 All values in 'propvalues' must be acceptable types for their
471 corresponding properties or a TypeError is raised.
473 If the value of the key property is set, it must not collide with
474 other key strings or a ValueError is raised.
476 If the value of a Link or Multilink property contains an invalid
477 node id, a ValueError is raised.
478 """
479 if not propvalues:
480 return
482 if propvalues.has_key('id'):
483 raise KeyError, '"id" is reserved'
485 if self.db.journaltag is None:
486 raise DatabaseError, 'Database open read-only'
488 node = self.db.getnode(self.classname, nodeid)
489 if node.has_key(self.db.RETIRED_FLAG):
490 raise IndexError
491 num_re = re.compile('^\d+$')
492 for key, value in propvalues.items():
493 # check to make sure we're not duplicating an existing key
494 if key == self.key and node[key] != value:
495 try:
496 self.lookup(value)
497 except KeyError:
498 pass
499 else:
500 raise ValueError, 'node with key "%s" exists'%value
502 # this will raise the KeyError if the property isn't valid
503 # ... we don't use getprops() here because we only care about
504 # the writeable properties.
505 prop = self.properties[key]
507 if isinstance(prop, Link):
508 link_class = self.properties[key].classname
509 # if it isn't a number, it's a key
510 if type(value) != type(''):
511 raise ValueError, 'link value must be String'
512 if not num_re.match(value):
513 try:
514 value = self.db.classes[link_class].lookup(value)
515 except (TypeError, KeyError):
516 raise IndexError, 'new property "%s": %s not a %s'%(
517 key, value, self.properties[key].classname)
519 if not self.db.hasnode(link_class, value):
520 raise IndexError, '%s has no node %s'%(link_class, value)
522 if self.properties[key].do_journal:
523 # register the unlink with the old linked node
524 if node[key] is not None:
525 self.db.addjournal(link_class, node[key], 'unlink',
526 (self.classname, nodeid, key))
528 # register the link with the newly linked node
529 if value is not None:
530 self.db.addjournal(link_class, value, 'link',
531 (self.classname, nodeid, key))
533 elif isinstance(prop, Multilink):
534 if type(value) != type([]):
535 raise TypeError, 'new property "%s" not a list of ids'%key
536 link_class = self.properties[key].classname
537 l = []
538 for entry in value:
539 # if it isn't a number, it's a key
540 if type(entry) != type(''):
541 raise ValueError, 'link value must be String'
542 if not num_re.match(entry):
543 try:
544 entry = self.db.classes[link_class].lookup(entry)
545 except (TypeError, KeyError):
546 raise IndexError, 'new property "%s": %s not a %s'%(
547 key, entry, self.properties[key].classname)
548 l.append(entry)
549 value = l
550 propvalues[key] = value
552 # handle removals
553 if node.has_key(key):
554 l = node[key]
555 else:
556 l = []
557 for id in l[:]:
558 if id in value:
559 continue
560 # register the unlink with the old linked node
561 if self.properties[key].do_journal:
562 self.db.addjournal(link_class, id, 'unlink',
563 (self.classname, nodeid, key))
564 l.remove(id)
566 # handle additions
567 for id in value:
568 if not self.db.hasnode(link_class, id):
569 raise IndexError, '%s has no node %s'%(
570 link_class, id)
571 if id in l:
572 continue
573 # register the link with the newly linked node
574 if self.properties[key].do_journal:
575 self.db.addjournal(link_class, id, 'link',
576 (self.classname, nodeid, key))
577 l.append(id)
579 elif isinstance(prop, String):
580 if value is not None and type(value) != type(''):
581 raise TypeError, 'new property "%s" not a string'%key
583 elif isinstance(prop, Password):
584 if not isinstance(value, password.Password):
585 raise TypeError, 'new property "%s" not a Password'% key
586 propvalues[key] = value = str(value)
588 elif value is not None and isinstance(prop, Date):
589 if not isinstance(value, date.Date):
590 raise TypeError, 'new property "%s" not a Date'% key
591 propvalues[key] = value = value.get_tuple()
593 elif value is not None and isinstance(prop, Interval):
594 if not isinstance(value, date.Interval):
595 raise TypeError, 'new property "%s" not an Interval'% key
596 propvalues[key] = value = value.get_tuple()
598 node[key] = value
600 self.db.setnode(self.classname, nodeid, node)
601 self.db.addjournal(self.classname, nodeid, 'set', propvalues)
603 def retire(self, nodeid):
604 """Retire a node.
606 The properties on the node remain available from the get() method,
607 and the node's id is never reused.
609 Retired nodes are not returned by the find(), list(), or lookup()
610 methods, and other nodes may reuse the values of their key properties.
611 """
612 if self.db.journaltag is None:
613 raise DatabaseError, 'Database open read-only'
614 node = self.db.getnode(self.classname, nodeid)
615 node[self.db.RETIRED_FLAG] = 1
616 self.db.setnode(self.classname, nodeid, node)
617 self.db.addjournal(self.classname, nodeid, 'retired', None)
619 def history(self, nodeid):
620 """Retrieve the journal of edits on a particular node.
622 'nodeid' must be the id of an existing node of this class or an
623 IndexError is raised.
625 The returned list contains tuples of the form
627 (date, tag, action, params)
629 'date' is a Timestamp object specifying the time of the change and
630 'tag' is the journaltag specified when the database was opened.
631 """
632 return self.db.getjournal(self.classname, nodeid)
634 # Locating nodes:
636 def setkey(self, propname):
637 """Select a String property of this class to be the key property.
639 'propname' must be the name of a String property of this class or
640 None, or a TypeError is raised. The values of the key property on
641 all existing nodes must be unique or a ValueError is raised.
642 """
643 # TODO: validate that the property is a String!
644 self.key = propname
646 def getkey(self):
647 """Return the name of the key property for this class or None."""
648 return self.key
650 def labelprop(self, default_to_id=0):
651 ''' Return the property name for a label for the given node.
653 This method attempts to generate a consistent label for the node.
654 It tries the following in order:
655 1. key property
656 2. "name" property
657 3. "title" property
658 4. first property from the sorted property name list
659 '''
660 k = self.getkey()
661 if k:
662 return k
663 props = self.getprops()
664 if props.has_key('name'):
665 return 'name'
666 elif props.has_key('title'):
667 return 'title'
668 if default_to_id:
669 return 'id'
670 props = props.keys()
671 props.sort()
672 return props[0]
674 # TODO: set up a separate index db file for this? profile?
675 def lookup(self, keyvalue):
676 """Locate a particular node by its key property and return its id.
678 If this class has no key property, a TypeError is raised. If the
679 'keyvalue' matches one of the values for the key property among
680 the nodes in this class, the matching node's id is returned;
681 otherwise a KeyError is raised.
682 """
683 cldb = self.db.getclassdb(self.classname)
684 for nodeid in self.db.getnodeids(self.classname, cldb):
685 node = self.db.getnode(self.classname, nodeid, cldb)
686 if node.has_key(self.db.RETIRED_FLAG):
687 continue
688 if node[self.key] == keyvalue:
689 return nodeid
690 raise KeyError, keyvalue
692 # XXX: change from spec - allows multiple props to match
693 def find(self, **propspec):
694 """Get the ids of nodes in this class which link to a given node.
696 'propspec' consists of keyword args propname=nodeid
697 'propname' must be the name of a property in this class, or a
698 KeyError is raised. That property must be a Link or Multilink
699 property, or a TypeError is raised.
701 'nodeid' must be the id of an existing node in the class linked
702 to by the given property, or an IndexError is raised.
703 """
704 propspec = propspec.items()
705 for propname, nodeid in propspec:
706 # check the prop is OK
707 prop = self.properties[propname]
708 if not isinstance(prop, Link) and not isinstance(prop, Multilink):
709 raise TypeError, "'%s' not a Link/Multilink property"%propname
710 if not self.db.hasnode(prop.classname, nodeid):
711 raise ValueError, '%s has no node %s'%(prop.classname, nodeid)
713 # ok, now do the find
714 cldb = self.db.getclassdb(self.classname)
715 l = []
716 for id in self.db.getnodeids(self.classname, cldb):
717 node = self.db.getnode(self.classname, id, cldb)
718 if node.has_key(self.db.RETIRED_FLAG):
719 continue
720 for propname, nodeid in propspec:
721 property = node[propname]
722 if isinstance(prop, Link) and nodeid == property:
723 l.append(id)
724 elif isinstance(prop, Multilink) and nodeid in property:
725 l.append(id)
726 return l
728 def stringFind(self, **requirements):
729 """Locate a particular node by matching a set of its String
730 properties in a caseless search.
732 If the property is not a String property, a TypeError is raised.
734 The return is a list of the id of all nodes that match.
735 """
736 for propname in requirements.keys():
737 prop = self.properties[propname]
738 if isinstance(not prop, String):
739 raise TypeError, "'%s' not a String property"%propname
740 requirements[propname] = requirements[propname].lower()
741 l = []
742 cldb = self.db.getclassdb(self.classname)
743 for nodeid in self.db.getnodeids(self.classname, cldb):
744 node = self.db.getnode(self.classname, nodeid, cldb)
745 if node.has_key(self.db.RETIRED_FLAG):
746 continue
747 for key, value in requirements.items():
748 if node[key] and node[key].lower() != value:
749 break
750 else:
751 l.append(nodeid)
752 return l
754 def list(self):
755 """Return a list of the ids of the active nodes in this class."""
756 l = []
757 cn = self.classname
758 cldb = self.db.getclassdb(cn)
759 for nodeid in self.db.getnodeids(cn, cldb):
760 node = self.db.getnode(cn, nodeid, cldb)
761 if node.has_key(self.db.RETIRED_FLAG):
762 continue
763 l.append(nodeid)
764 l.sort()
765 return l
767 # XXX not in spec
768 def filter(self, filterspec, sort, group, num_re = re.compile('^\d+$')):
769 ''' Return a list of the ids of the active nodes in this class that
770 match the 'filter' spec, sorted by the group spec and then the
771 sort spec
772 '''
773 cn = self.classname
775 # optimise filterspec
776 l = []
777 props = self.getprops()
778 for k, v in filterspec.items():
779 propclass = props[k]
780 if isinstance(propclass, Link):
781 if type(v) is not type([]):
782 v = [v]
783 # replace key values with node ids
784 u = []
785 link_class = self.db.classes[propclass.classname]
786 for entry in v:
787 if entry == '-1': entry = None
788 elif not num_re.match(entry):
789 try:
790 entry = link_class.lookup(entry)
791 except (TypeError,KeyError):
792 raise ValueError, 'property "%s": %s not a %s'%(
793 k, entry, self.properties[k].classname)
794 u.append(entry)
796 l.append((0, k, u))
797 elif isinstance(propclass, Multilink):
798 if type(v) is not type([]):
799 v = [v]
800 # replace key values with node ids
801 u = []
802 link_class = self.db.classes[propclass.classname]
803 for entry in v:
804 if not num_re.match(entry):
805 try:
806 entry = link_class.lookup(entry)
807 except (TypeError,KeyError):
808 raise ValueError, 'new property "%s": %s not a %s'%(
809 k, entry, self.properties[k].classname)
810 u.append(entry)
811 l.append((1, k, u))
812 elif isinstance(propclass, String):
813 # simple glob searching
814 v = re.sub(r'([\|\{\}\\\.\+\[\]\(\)])', r'\\\1', v)
815 v = v.replace('?', '.')
816 v = v.replace('*', '.*?')
817 l.append((2, k, re.compile(v, re.I)))
818 else:
819 l.append((6, k, v))
820 filterspec = l
822 # now, find all the nodes that are active and pass filtering
823 l = []
824 cldb = self.db.getclassdb(cn)
825 for nodeid in self.db.getnodeids(cn, cldb):
826 node = self.db.getnode(cn, nodeid, cldb)
827 if node.has_key(self.db.RETIRED_FLAG):
828 continue
829 # apply filter
830 for t, k, v in filterspec:
831 # this node doesn't have this property, so reject it
832 if not node.has_key(k): break
834 if t == 0 and node[k] not in v:
835 # link - if this node'd property doesn't appear in the
836 # filterspec's nodeid list, skip it
837 break
838 elif t == 1:
839 # multilink - if any of the nodeids required by the
840 # filterspec aren't in this node's property, then skip
841 # it
842 for value in v:
843 if value not in node[k]:
844 break
845 else:
846 continue
847 break
848 elif t == 2 and not v.search(node[k]):
849 # RE search
850 break
851 elif t == 6 and node[k] != v:
852 # straight value comparison for the other types
853 break
854 else:
855 l.append((nodeid, node))
856 l.sort()
858 # optimise sort
859 m = []
860 for entry in sort:
861 if entry[0] != '-':
862 m.append(('+', entry))
863 else:
864 m.append((entry[0], entry[1:]))
865 sort = m
867 # optimise group
868 m = []
869 for entry in group:
870 if entry[0] != '-':
871 m.append(('+', entry))
872 else:
873 m.append((entry[0], entry[1:]))
874 group = m
875 # now, sort the result
876 def sortfun(a, b, sort=sort, group=group, properties=self.getprops(),
877 db = self.db, cl=self):
878 a_id, an = a
879 b_id, bn = b
880 # sort by group and then sort
881 for list in group, sort:
882 for dir, prop in list:
883 # sorting is class-specific
884 propclass = properties[prop]
886 # handle the properties that might be "faked"
887 # also, handle possible missing properties
888 try:
889 if not an.has_key(prop):
890 an[prop] = cl.get(a_id, prop)
891 av = an[prop]
892 except KeyError:
893 # the node doesn't have a value for this property
894 if isinstance(propclass, Multilink): av = []
895 else: av = ''
896 try:
897 if not bn.has_key(prop):
898 bn[prop] = cl.get(b_id, prop)
899 bv = bn[prop]
900 except KeyError:
901 # the node doesn't have a value for this property
902 if isinstance(propclass, Multilink): bv = []
903 else: bv = ''
905 # String and Date values are sorted in the natural way
906 if isinstance(propclass, String):
907 # clean up the strings
908 if av and av[0] in string.uppercase:
909 av = an[prop] = av.lower()
910 if bv and bv[0] in string.uppercase:
911 bv = bn[prop] = bv.lower()
912 if (isinstance(propclass, String) or
913 isinstance(propclass, Date)):
914 # it might be a string that's really an integer
915 try:
916 av = int(av)
917 bv = int(bv)
918 except:
919 pass
920 if dir == '+':
921 r = cmp(av, bv)
922 if r != 0: return r
923 elif dir == '-':
924 r = cmp(bv, av)
925 if r != 0: return r
927 # Link properties are sorted according to the value of
928 # the "order" property on the linked nodes if it is
929 # present; or otherwise on the key string of the linked
930 # nodes; or finally on the node ids.
931 elif isinstance(propclass, Link):
932 link = db.classes[propclass.classname]
933 if av is None and bv is not None: return -1
934 if av is not None and bv is None: return 1
935 if av is None and bv is None: continue
936 if link.getprops().has_key('order'):
937 if dir == '+':
938 r = cmp(link.get(av, 'order'),
939 link.get(bv, 'order'))
940 if r != 0: return r
941 elif dir == '-':
942 r = cmp(link.get(bv, 'order'),
943 link.get(av, 'order'))
944 if r != 0: return r
945 elif link.getkey():
946 key = link.getkey()
947 if dir == '+':
948 r = cmp(link.get(av, key), link.get(bv, key))
949 if r != 0: return r
950 elif dir == '-':
951 r = cmp(link.get(bv, key), link.get(av, key))
952 if r != 0: return r
953 else:
954 if dir == '+':
955 r = cmp(av, bv)
956 if r != 0: return r
957 elif dir == '-':
958 r = cmp(bv, av)
959 if r != 0: return r
961 # Multilink properties are sorted according to how many
962 # links are present.
963 elif isinstance(propclass, Multilink):
964 if dir == '+':
965 r = cmp(len(av), len(bv))
966 if r != 0: return r
967 elif dir == '-':
968 r = cmp(len(bv), len(av))
969 if r != 0: return r
970 # end for dir, prop in list:
971 # end for list in sort, group:
972 # if all else fails, compare the ids
973 return cmp(a[0], b[0])
975 l.sort(sortfun)
976 return [i[0] for i in l]
978 def count(self):
979 """Get the number of nodes in this class.
981 If the returned integer is 'numnodes', the ids of all the nodes
982 in this class run from 1 to numnodes, and numnodes+1 will be the
983 id of the next node to be created in this class.
984 """
985 return self.db.countnodes(self.classname)
987 # Manipulating properties:
989 def getprops(self, protected=1):
990 """Return a dictionary mapping property names to property objects.
991 If the "protected" flag is true, we include protected properties -
992 those which may not be modified."""
993 d = self.properties.copy()
994 if protected:
995 d['id'] = String()
996 return d
998 def addprop(self, **properties):
999 """Add properties to this class.
1001 The keyword arguments in 'properties' must map names to property
1002 objects, or a TypeError is raised. None of the keys in 'properties'
1003 may collide with the names of existing properties, or a ValueError
1004 is raised before any properties have been added.
1005 """
1006 for key in properties.keys():
1007 if self.properties.has_key(key):
1008 raise ValueError, key
1009 self.properties.update(properties)
1012 # XXX not in spec
1013 class Node:
1014 ''' A convenience wrapper for the given node
1015 '''
1016 def __init__(self, cl, nodeid, cache=1):
1017 self.__dict__['cl'] = cl
1018 self.__dict__['nodeid'] = nodeid
1019 self.__dict__['cache'] = cache
1020 def keys(self, protected=1):
1021 return self.cl.getprops(protected=protected).keys()
1022 def values(self, protected=1):
1023 l = []
1024 for name in self.cl.getprops(protected=protected).keys():
1025 l.append(self.cl.get(self.nodeid, name, cache=self.cache))
1026 return l
1027 def items(self, protected=1):
1028 l = []
1029 for name in self.cl.getprops(protected=protected).keys():
1030 l.append((name, self.cl.get(self.nodeid, name, cache=self.cache)))
1031 return l
1032 def has_key(self, name):
1033 return self.cl.getprops().has_key(name)
1034 def __getattr__(self, name):
1035 if self.__dict__.has_key(name):
1036 return self.__dict__[name]
1037 try:
1038 return self.cl.get(self.nodeid, name, cache=self.cache)
1039 except KeyError, value:
1040 # we trap this but re-raise it as AttributeError - all other
1041 # exceptions should pass through untrapped
1042 pass
1043 # nope, no such attribute
1044 raise AttributeError, str(value)
1045 def __getitem__(self, name):
1046 return self.cl.get(self.nodeid, name, cache=self.cache)
1047 def __setattr__(self, name, value):
1048 try:
1049 return self.cl.set(self.nodeid, **{name: value})
1050 except KeyError, value:
1051 raise AttributeError, str(value)
1052 def __setitem__(self, name, value):
1053 self.cl.set(self.nodeid, **{name: value})
1054 def history(self):
1055 return self.cl.history(self.nodeid)
1056 def retire(self):
1057 return self.cl.retire(self.nodeid)
1060 def Choice(name, *options):
1061 cl = Class(db, name, name=hyperdb.String(), order=hyperdb.String())
1062 for i in range(len(options)):
1063 cl.create(name=option[i], order=i)
1064 return hyperdb.Link(name)
1066 #
1067 # $Log: not supported by cvs2svn $
1068 # Revision 1.51 2002/01/21 03:01:29 richard
1069 # brief docco on the do_journal argument
1070 #
1071 # Revision 1.50 2002/01/19 13:16:04 rochecompaan
1072 # Journal entries for link and multilink properties can now be switched on
1073 # or off.
1074 #
1075 # Revision 1.49 2002/01/16 07:02:57 richard
1076 # . lots of date/interval related changes:
1077 # - more relaxed date format for input
1078 #
1079 # Revision 1.48 2002/01/14 06:32:34 richard
1080 # . #502951 ] adding new properties to old database
1081 #
1082 # Revision 1.47 2002/01/14 02:20:15 richard
1083 # . changed all config accesses so they access either the instance or the
1084 # config attriubute on the db. This means that all config is obtained from
1085 # instance_config instead of the mish-mash of classes. This will make
1086 # switching to a ConfigParser setup easier too, I hope.
1087 #
1088 # At a minimum, this makes migration a _little_ easier (a lot easier in the
1089 # 0.5.0 switch, I hope!)
1090 #
1091 # Revision 1.46 2002/01/07 10:42:23 richard
1092 # oops
1093 #
1094 # Revision 1.45 2002/01/02 04:18:17 richard
1095 # hyperdb docstrings
1096 #
1097 # Revision 1.44 2002/01/02 02:31:38 richard
1098 # Sorry for the huge checkin message - I was only intending to implement #496356
1099 # but I found a number of places where things had been broken by transactions:
1100 # . modified ROUNDUPDBSENDMAILDEBUG to be SENDMAILDEBUG and hold a filename
1101 # for _all_ roundup-generated smtp messages to be sent to.
1102 # . the transaction cache had broken the roundupdb.Class set() reactors
1103 # . newly-created author users in the mailgw weren't being committed to the db
1104 #
1105 # Stuff that made it into CHANGES.txt (ie. the stuff I was actually working
1106 # on when I found that stuff :):
1107 # . #496356 ] Use threading in messages
1108 # . detectors were being registered multiple times
1109 # . added tests for mailgw
1110 # . much better attaching of erroneous messages in the mail gateway
1111 #
1112 # Revision 1.43 2001/12/20 06:13:24 rochecompaan
1113 # Bugs fixed:
1114 # . Exception handling in hyperdb for strings-that-look-like numbers got
1115 # lost somewhere
1116 # . Internet Explorer submits full path for filename - we now strip away
1117 # the path
1118 # Features added:
1119 # . Link and multilink properties are now displayed sorted in the cgi
1120 # interface
1121 #
1122 # Revision 1.42 2001/12/16 10:53:37 richard
1123 # take a copy of the node dict so that the subsequent set
1124 # operation doesn't modify the oldvalues structure
1125 #
1126 # Revision 1.41 2001/12/15 23:47:47 richard
1127 # Cleaned up some bare except statements
1128 #
1129 # Revision 1.40 2001/12/14 23:42:57 richard
1130 # yuck, a gdbm instance tests false :(
1131 # I've left the debugging code in - it should be removed one day if we're ever
1132 # _really_ anal about performace :)
1133 #
1134 # Revision 1.39 2001/12/02 05:06:16 richard
1135 # . We now use weakrefs in the Classes to keep the database reference, so
1136 # the close() method on the database is no longer needed.
1137 # I bumped the minimum python requirement up to 2.1 accordingly.
1138 # . #487480 ] roundup-server
1139 # . #487476 ] INSTALL.txt
1140 #
1141 # I also cleaned up the change message / post-edit stuff in the cgi client.
1142 # There's now a clearly marked "TODO: append the change note" where I believe
1143 # the change note should be added there. The "changes" list will obviously
1144 # have to be modified to be a dict of the changes, or somesuch.
1145 #
1146 # More testing needed.
1147 #
1148 # Revision 1.38 2001/12/01 07:17:50 richard
1149 # . We now have basic transaction support! Information is only written to
1150 # the database when the commit() method is called. Only the anydbm
1151 # backend is modified in this way - neither of the bsddb backends have been.
1152 # The mail, admin and cgi interfaces all use commit (except the admin tool
1153 # doesn't have a commit command, so interactive users can't commit...)
1154 # . Fixed login/registration forwarding the user to the right page (or not,
1155 # on a failure)
1156 #
1157 # Revision 1.37 2001/11/28 21:55:35 richard
1158 # . login_action and newuser_action return values were being ignored
1159 # . Woohoo! Found that bloody re-login bug that was killing the mail
1160 # gateway.
1161 # (also a minor cleanup in hyperdb)
1162 #
1163 # Revision 1.36 2001/11/27 03:16:09 richard
1164 # Another place that wasn't handling missing properties.
1165 #
1166 # Revision 1.35 2001/11/22 15:46:42 jhermann
1167 # Added module docstrings to all modules.
1168 #
1169 # Revision 1.34 2001/11/21 04:04:43 richard
1170 # *sigh* more missing value handling
1171 #
1172 # Revision 1.33 2001/11/21 03:40:54 richard
1173 # more new property handling
1174 #
1175 # Revision 1.32 2001/11/21 03:11:28 richard
1176 # Better handling of new properties.
1177 #
1178 # Revision 1.31 2001/11/12 22:01:06 richard
1179 # Fixed issues with nosy reaction and author copies.
1180 #
1181 # Revision 1.30 2001/11/09 10:11:08 richard
1182 # . roundup-admin now handles all hyperdb exceptions
1183 #
1184 # Revision 1.29 2001/10/27 00:17:41 richard
1185 # Made Class.stringFind() do caseless matching.
1186 #
1187 # Revision 1.28 2001/10/21 04:44:50 richard
1188 # bug #473124: UI inconsistency with Link fields.
1189 # This also prompted me to fix a fairly long-standing usability issue -
1190 # that of being able to turn off certain filters.
1191 #
1192 # Revision 1.27 2001/10/20 23:44:27 richard
1193 # Hyperdatabase sorts strings-that-look-like-numbers as numbers now.
1194 #
1195 # Revision 1.26 2001/10/16 03:48:01 richard
1196 # admin tool now complains if a "find" is attempted with a non-link property.
1197 #
1198 # Revision 1.25 2001/10/11 00:17:51 richard
1199 # Reverted a change in hyperdb so the default value for missing property
1200 # values in a create() is None and not '' (the empty string.) This obviously
1201 # breaks CSV import/export - the string 'None' will be created in an
1202 # export/import operation.
1203 #
1204 # Revision 1.24 2001/10/10 03:54:57 richard
1205 # Added database importing and exporting through CSV files.
1206 # Uses the csv module from object-craft for exporting if it's available.
1207 # Requires the csv module for importing.
1208 #
1209 # Revision 1.23 2001/10/09 23:58:10 richard
1210 # Moved the data stringification up into the hyperdb.Class class' get, set
1211 # and create methods. This means that the data is also stringified for the
1212 # journal call, and removes duplication of code from the backends. The
1213 # backend code now only sees strings.
1214 #
1215 # Revision 1.22 2001/10/09 07:25:59 richard
1216 # Added the Password property type. See "pydoc roundup.password" for
1217 # implementation details. Have updated some of the documentation too.
1218 #
1219 # Revision 1.21 2001/10/05 02:23:24 richard
1220 # . roundup-admin create now prompts for property info if none is supplied
1221 # on the command-line.
1222 # . hyperdb Class getprops() method may now return only the mutable
1223 # properties.
1224 # . Login now uses cookies, which makes it a whole lot more flexible. We can
1225 # now support anonymous user access (read-only, unless there's an
1226 # "anonymous" user, in which case write access is permitted). Login
1227 # handling has been moved into cgi_client.Client.main()
1228 # . The "extended" schema is now the default in roundup init.
1229 # . The schemas have had their page headings modified to cope with the new
1230 # login handling. Existing installations should copy the interfaces.py
1231 # file from the roundup lib directory to their instance home.
1232 # . Incorrectly had a Bizar Software copyright on the cgitb.py module from
1233 # Ping - has been removed.
1234 # . Fixed a whole bunch of places in the CGI interface where we should have
1235 # been returning Not Found instead of throwing an exception.
1236 # . Fixed a deviation from the spec: trying to modify the 'id' property of
1237 # an item now throws an exception.
1238 #
1239 # Revision 1.20 2001/10/04 02:12:42 richard
1240 # Added nicer command-line item adding: passing no arguments will enter an
1241 # interactive more which asks for each property in turn. While I was at it, I
1242 # fixed an implementation problem WRT the spec - I wasn't raising a
1243 # ValueError if the key property was missing from a create(). Also added a
1244 # protected=boolean argument to getprops() so we can list only the mutable
1245 # properties (defaults to yes, which lists the immutables).
1246 #
1247 # Revision 1.19 2001/08/29 04:47:18 richard
1248 # Fixed CGI client change messages so they actually include the properties
1249 # changed (again).
1250 #
1251 # Revision 1.18 2001/08/16 07:34:59 richard
1252 # better CGI text searching - but hidden filter fields are disappearing...
1253 #
1254 # Revision 1.17 2001/08/16 06:59:58 richard
1255 # all searches use re now - and they're all case insensitive
1256 #
1257 # Revision 1.16 2001/08/15 23:43:18 richard
1258 # Fixed some isFooTypes that I missed.
1259 # Refactored some code in the CGI code.
1260 #
1261 # Revision 1.15 2001/08/12 06:32:36 richard
1262 # using isinstance(blah, Foo) now instead of isFooType
1263 #
1264 # Revision 1.14 2001/08/07 00:24:42 richard
1265 # stupid typo
1266 #
1267 # Revision 1.13 2001/08/07 00:15:51 richard
1268 # Added the copyright/license notice to (nearly) all files at request of
1269 # Bizar Software.
1270 #
1271 # Revision 1.12 2001/08/02 06:38:17 richard
1272 # Roundupdb now appends "mailing list" information to its messages which
1273 # include the e-mail address and web interface address. Templates may
1274 # override this in their db classes to include specific information (support
1275 # instructions, etc).
1276 #
1277 # Revision 1.11 2001/08/01 04:24:21 richard
1278 # mailgw was assuming certain properties existed on the issues being created.
1279 #
1280 # Revision 1.10 2001/07/30 02:38:31 richard
1281 # get() now has a default arg - for migration only.
1282 #
1283 # Revision 1.9 2001/07/29 09:28:23 richard
1284 # Fixed sorting by clicking on column headings.
1285 #
1286 # Revision 1.8 2001/07/29 08:27:40 richard
1287 # Fixed handling of passed-in values in form elements (ie. during a
1288 # drill-down)
1289 #
1290 # Revision 1.7 2001/07/29 07:01:39 richard
1291 # Added vim command to all source so that we don't get no steenkin' tabs :)
1292 #
1293 # Revision 1.6 2001/07/29 05:36:14 richard
1294 # Cleanup of the link label generation.
1295 #
1296 # Revision 1.5 2001/07/29 04:05:37 richard
1297 # Added the fabricated property "id".
1298 #
1299 # Revision 1.4 2001/07/27 06:25:35 richard
1300 # Fixed some of the exceptions so they're the right type.
1301 # Removed the str()-ification of node ids so we don't mask oopsy errors any
1302 # more.
1303 #
1304 # Revision 1.3 2001/07/27 05:17:14 richard
1305 # just some comments
1306 #
1307 # Revision 1.2 2001/07/22 12:09:32 richard
1308 # Final commit of Grande Splite
1309 #
1310 # Revision 1.1 2001/07/22 11:58:35 richard
1311 # More Grande Splite
1312 #
1313 #
1314 # vim: set filetype=python ts=4 sw=4 et si