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.51 2002-01-21 03:01:29 richard 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 commit(self):
215 ''' Commit the current transactions.
217 Save all data changed since the database was opened or since the
218 last commit() or rollback().
219 '''
220 raise NotImplementedError
222 def rollback(self):
223 ''' Reverse all actions from the current transaction.
225 Undo all the changes made since the database was opened or the last
226 commit() or rollback() was performed.
227 '''
228 raise NotImplementedError
230 _marker = []
231 #
232 # The base Class class
233 #
234 class Class:
235 """The handle to a particular class of nodes in a hyperdatabase."""
237 def __init__(self, db, classname, **properties):
238 """Create a new class with a given name and property specification.
240 'classname' must not collide with the name of an existing class,
241 or a ValueError is raised. The keyword arguments in 'properties'
242 must map names to property objects, or a TypeError is raised.
243 """
244 self.classname = classname
245 self.properties = properties
246 self.db = weakref.proxy(db) # use a weak ref to avoid circularity
247 self.key = ''
249 # do the db-related init stuff
250 db.addclass(self)
252 def __repr__(self):
253 return '<hypderdb.Class "%s">'%self.classname
255 # Editing nodes:
257 def create(self, **propvalues):
258 """Create a new node of this class and return its id.
260 The keyword arguments in 'propvalues' map property names to values.
262 The values of arguments must be acceptable for the types of their
263 corresponding properties or a TypeError is raised.
265 If this class has a key property, it must be present and its value
266 must not collide with other key strings or a ValueError is raised.
268 Any other properties on this class that are missing from the
269 'propvalues' dictionary are set to None.
271 If an id in a link or multilink property does not refer to a valid
272 node, an IndexError is raised.
273 """
274 if propvalues.has_key('id'):
275 raise KeyError, '"id" is reserved'
277 if self.db.journaltag is None:
278 raise DatabaseError, 'Database open read-only'
280 # new node's id
281 newid = str(self.count() + 1)
283 # validate propvalues
284 num_re = re.compile('^\d+$')
285 for key, value in propvalues.items():
286 if key == self.key:
287 try:
288 self.lookup(value)
289 except KeyError:
290 pass
291 else:
292 raise ValueError, 'node with key "%s" exists'%value
294 # try to handle this property
295 try:
296 prop = self.properties[key]
297 except KeyError:
298 raise KeyError, '"%s" has no property "%s"'%(self.classname,
299 key)
301 if isinstance(prop, Link):
302 if type(value) != type(''):
303 raise ValueError, 'link value must be String'
304 link_class = self.properties[key].classname
305 # if it isn't a number, it's a key
306 if not num_re.match(value):
307 try:
308 value = self.db.classes[link_class].lookup(value)
309 except (TypeError, KeyError):
310 raise IndexError, 'new property "%s": %s not a %s'%(
311 key, value, link_class)
312 elif not self.db.hasnode(link_class, value):
313 raise IndexError, '%s has no node %s'%(link_class, value)
315 # save off the value
316 propvalues[key] = value
318 # register the link with the newly linked node
319 if self.properties[key].do_journal:
320 self.db.addjournal(link_class, value, 'link',
321 (self.classname, newid, key))
323 elif isinstance(prop, Multilink):
324 if type(value) != type([]):
325 raise TypeError, 'new property "%s" not a list of ids'%key
326 link_class = self.properties[key].classname
327 l = []
328 for entry in value:
329 if type(entry) != type(''):
330 raise ValueError, 'link value must be String'
331 # if it isn't a number, it's a key
332 if not num_re.match(entry):
333 try:
334 entry = self.db.classes[link_class].lookup(entry)
335 except (TypeError, KeyError):
336 raise IndexError, 'new property "%s": %s not a %s'%(
337 key, entry, self.properties[key].classname)
338 l.append(entry)
339 value = l
340 propvalues[key] = value
342 # handle additions
343 for id in value:
344 if not self.db.hasnode(link_class, id):
345 raise IndexError, '%s has no node %s'%(link_class, id)
346 # register the link with the newly linked node
347 if self.properties[key].do_journal:
348 self.db.addjournal(link_class, id, 'link',
349 (self.classname, newid, key))
351 elif isinstance(prop, String):
352 if type(value) != type(''):
353 raise TypeError, 'new property "%s" not a string'%key
355 elif isinstance(prop, Password):
356 if not isinstance(value, password.Password):
357 raise TypeError, 'new property "%s" not a Password'%key
359 elif isinstance(prop, Date):
360 if value is not None and not isinstance(value, date.Date):
361 raise TypeError, 'new property "%s" not a Date'%key
363 elif isinstance(prop, Interval):
364 if value is not None and not isinstance(value, date.Interval):
365 raise TypeError, 'new property "%s" not an Interval'%key
367 # make sure there's data where there needs to be
368 for key, prop in self.properties.items():
369 if propvalues.has_key(key):
370 continue
371 if key == self.key:
372 raise ValueError, 'key property "%s" is required'%key
373 if isinstance(prop, Multilink):
374 propvalues[key] = []
375 else:
376 # TODO: None isn't right here, I think...
377 propvalues[key] = None
379 # convert all data to strings
380 for key, prop in self.properties.items():
381 if isinstance(prop, Date):
382 if propvalues[key] is not None:
383 propvalues[key] = propvalues[key].get_tuple()
384 elif isinstance(prop, Interval):
385 if propvalues[key] is not None:
386 propvalues[key] = propvalues[key].get_tuple()
387 elif isinstance(prop, Password):
388 propvalues[key] = str(propvalues[key])
390 # done
391 self.db.addnode(self.classname, newid, propvalues)
392 self.db.addjournal(self.classname, newid, 'create', propvalues)
393 return newid
395 def get(self, nodeid, propname, default=_marker, cache=1):
396 """Get the value of a property on an existing node of this class.
398 'nodeid' must be the id of an existing node of this class or an
399 IndexError is raised. 'propname' must be the name of a property
400 of this class or a KeyError is raised.
402 'cache' indicates whether the transaction cache should be queried
403 for the node. If the node has been modified and you need to
404 determine what its values prior to modification are, you need to
405 set cache=0.
406 """
407 if propname == 'id':
408 return nodeid
410 # get the property (raises KeyErorr if invalid)
411 prop = self.properties[propname]
413 # get the node's dict
414 d = self.db.getnode(self.classname, nodeid, cache=cache)
416 if not d.has_key(propname):
417 if default is _marker:
418 if isinstance(prop, Multilink):
419 return []
420 else:
421 # TODO: None isn't right here, I think...
422 return None
423 else:
424 return default
426 # possibly convert the marshalled data to instances
427 if isinstance(prop, Date):
428 if d[propname] is None:
429 return None
430 return date.Date(d[propname])
431 elif isinstance(prop, Interval):
432 if d[propname] is None:
433 return None
434 return date.Interval(d[propname])
435 elif isinstance(prop, Password):
436 p = password.Password()
437 p.unpack(d[propname])
438 return p
440 return d[propname]
442 # XXX not in spec
443 def getnode(self, nodeid, cache=1):
444 ''' Return a convenience wrapper for the node.
446 'nodeid' must be the id of an existing node of this class or an
447 IndexError is raised.
449 'cache' indicates whether the transaction cache should be queried
450 for the node. If the node has been modified and you need to
451 determine what its values prior to modification are, you need to
452 set cache=0.
453 '''
454 return Node(self, nodeid, cache=cache)
456 def set(self, nodeid, **propvalues):
457 """Modify a property on an existing node of this class.
459 'nodeid' must be the id of an existing node of this class or an
460 IndexError is raised.
462 Each key in 'propvalues' must be the name of a property of this
463 class or a KeyError is raised.
465 All values in 'propvalues' must be acceptable types for their
466 corresponding properties or a TypeError is raised.
468 If the value of the key property is set, it must not collide with
469 other key strings or a ValueError is raised.
471 If the value of a Link or Multilink property contains an invalid
472 node id, a ValueError is raised.
473 """
474 if not propvalues:
475 return
477 if propvalues.has_key('id'):
478 raise KeyError, '"id" is reserved'
480 if self.db.journaltag is None:
481 raise DatabaseError, 'Database open read-only'
483 node = self.db.getnode(self.classname, nodeid)
484 if node.has_key(self.db.RETIRED_FLAG):
485 raise IndexError
486 num_re = re.compile('^\d+$')
487 for key, value in propvalues.items():
488 # check to make sure we're not duplicating an existing key
489 if key == self.key and node[key] != value:
490 try:
491 self.lookup(value)
492 except KeyError:
493 pass
494 else:
495 raise ValueError, 'node with key "%s" exists'%value
497 # this will raise the KeyError if the property isn't valid
498 # ... we don't use getprops() here because we only care about
499 # the writeable properties.
500 prop = self.properties[key]
502 if isinstance(prop, Link):
503 link_class = self.properties[key].classname
504 # if it isn't a number, it's a key
505 if type(value) != type(''):
506 raise ValueError, 'link value must be String'
507 if not num_re.match(value):
508 try:
509 value = self.db.classes[link_class].lookup(value)
510 except (TypeError, KeyError):
511 raise IndexError, 'new property "%s": %s not a %s'%(
512 key, value, self.properties[key].classname)
514 if not self.db.hasnode(link_class, value):
515 raise IndexError, '%s has no node %s'%(link_class, value)
517 if self.properties[key].do_journal:
518 # register the unlink with the old linked node
519 if node[key] is not None:
520 self.db.addjournal(link_class, node[key], 'unlink',
521 (self.classname, nodeid, key))
523 # register the link with the newly linked node
524 if value is not None:
525 self.db.addjournal(link_class, value, 'link',
526 (self.classname, nodeid, key))
528 elif isinstance(prop, Multilink):
529 if type(value) != type([]):
530 raise TypeError, 'new property "%s" not a list of ids'%key
531 link_class = self.properties[key].classname
532 l = []
533 for entry in value:
534 # if it isn't a number, it's a key
535 if type(entry) != type(''):
536 raise ValueError, 'link value must be String'
537 if not num_re.match(entry):
538 try:
539 entry = self.db.classes[link_class].lookup(entry)
540 except (TypeError, KeyError):
541 raise IndexError, 'new property "%s": %s not a %s'%(
542 key, entry, self.properties[key].classname)
543 l.append(entry)
544 value = l
545 propvalues[key] = value
547 # handle removals
548 if node.has_key(key):
549 l = node[key]
550 else:
551 l = []
552 for id in l[:]:
553 if id in value:
554 continue
555 # register the unlink with the old linked node
556 if self.properties[key].do_journal:
557 self.db.addjournal(link_class, id, 'unlink',
558 (self.classname, nodeid, key))
559 l.remove(id)
561 # handle additions
562 for id in value:
563 if not self.db.hasnode(link_class, id):
564 raise IndexError, '%s has no node %s'%(
565 link_class, id)
566 if id in l:
567 continue
568 # register the link with the newly linked node
569 if self.properties[key].do_journal:
570 self.db.addjournal(link_class, id, 'link',
571 (self.classname, nodeid, key))
572 l.append(id)
574 elif isinstance(prop, String):
575 if value is not None and type(value) != type(''):
576 raise TypeError, 'new property "%s" not a string'%key
578 elif isinstance(prop, Password):
579 if not isinstance(value, password.Password):
580 raise TypeError, 'new property "%s" not a Password'% key
581 propvalues[key] = value = str(value)
583 elif value is not None and isinstance(prop, Date):
584 if not isinstance(value, date.Date):
585 raise TypeError, 'new property "%s" not a Date'% key
586 propvalues[key] = value = value.get_tuple()
588 elif value is not None and isinstance(prop, Interval):
589 if not isinstance(value, date.Interval):
590 raise TypeError, 'new property "%s" not an Interval'% key
591 propvalues[key] = value = value.get_tuple()
593 node[key] = value
595 self.db.setnode(self.classname, nodeid, node)
596 self.db.addjournal(self.classname, nodeid, 'set', propvalues)
598 def retire(self, nodeid):
599 """Retire a node.
601 The properties on the node remain available from the get() method,
602 and the node's id is never reused.
604 Retired nodes are not returned by the find(), list(), or lookup()
605 methods, and other nodes may reuse the values of their key properties.
606 """
607 if self.db.journaltag is None:
608 raise DatabaseError, 'Database open read-only'
609 node = self.db.getnode(self.classname, nodeid)
610 node[self.db.RETIRED_FLAG] = 1
611 self.db.setnode(self.classname, nodeid, node)
612 self.db.addjournal(self.classname, nodeid, 'retired', None)
614 def history(self, nodeid):
615 """Retrieve the journal of edits on a particular node.
617 'nodeid' must be the id of an existing node of this class or an
618 IndexError is raised.
620 The returned list contains tuples of the form
622 (date, tag, action, params)
624 'date' is a Timestamp object specifying the time of the change and
625 'tag' is the journaltag specified when the database was opened.
626 """
627 return self.db.getjournal(self.classname, nodeid)
629 # Locating nodes:
631 def setkey(self, propname):
632 """Select a String property of this class to be the key property.
634 'propname' must be the name of a String property of this class or
635 None, or a TypeError is raised. The values of the key property on
636 all existing nodes must be unique or a ValueError is raised.
637 """
638 # TODO: validate that the property is a String!
639 self.key = propname
641 def getkey(self):
642 """Return the name of the key property for this class or None."""
643 return self.key
645 def labelprop(self, default_to_id=0):
646 ''' Return the property name for a label for the given node.
648 This method attempts to generate a consistent label for the node.
649 It tries the following in order:
650 1. key property
651 2. "name" property
652 3. "title" property
653 4. first property from the sorted property name list
654 '''
655 k = self.getkey()
656 if k:
657 return k
658 props = self.getprops()
659 if props.has_key('name'):
660 return 'name'
661 elif props.has_key('title'):
662 return 'title'
663 if default_to_id:
664 return 'id'
665 props = props.keys()
666 props.sort()
667 return props[0]
669 # TODO: set up a separate index db file for this? profile?
670 def lookup(self, keyvalue):
671 """Locate a particular node by its key property and return its id.
673 If this class has no key property, a TypeError is raised. If the
674 'keyvalue' matches one of the values for the key property among
675 the nodes in this class, the matching node's id is returned;
676 otherwise a KeyError is raised.
677 """
678 cldb = self.db.getclassdb(self.classname)
679 for nodeid in self.db.getnodeids(self.classname, cldb):
680 node = self.db.getnode(self.classname, nodeid, cldb)
681 if node.has_key(self.db.RETIRED_FLAG):
682 continue
683 if node[self.key] == keyvalue:
684 return nodeid
685 raise KeyError, keyvalue
687 # XXX: change from spec - allows multiple props to match
688 def find(self, **propspec):
689 """Get the ids of nodes in this class which link to a given node.
691 'propspec' consists of keyword args propname=nodeid
692 'propname' must be the name of a property in this class, or a
693 KeyError is raised. That property must be a Link or Multilink
694 property, or a TypeError is raised.
696 'nodeid' must be the id of an existing node in the class linked
697 to by the given property, or an IndexError is raised.
698 """
699 propspec = propspec.items()
700 for propname, nodeid in propspec:
701 # check the prop is OK
702 prop = self.properties[propname]
703 if not isinstance(prop, Link) and not isinstance(prop, Multilink):
704 raise TypeError, "'%s' not a Link/Multilink property"%propname
705 if not self.db.hasnode(prop.classname, nodeid):
706 raise ValueError, '%s has no node %s'%(prop.classname, nodeid)
708 # ok, now do the find
709 cldb = self.db.getclassdb(self.classname)
710 l = []
711 for id in self.db.getnodeids(self.classname, cldb):
712 node = self.db.getnode(self.classname, id, cldb)
713 if node.has_key(self.db.RETIRED_FLAG):
714 continue
715 for propname, nodeid in propspec:
716 property = node[propname]
717 if isinstance(prop, Link) and nodeid == property:
718 l.append(id)
719 elif isinstance(prop, Multilink) and nodeid in property:
720 l.append(id)
721 return l
723 def stringFind(self, **requirements):
724 """Locate a particular node by matching a set of its String
725 properties in a caseless search.
727 If the property is not a String property, a TypeError is raised.
729 The return is a list of the id of all nodes that match.
730 """
731 for propname in requirements.keys():
732 prop = self.properties[propname]
733 if isinstance(not prop, String):
734 raise TypeError, "'%s' not a String property"%propname
735 requirements[propname] = requirements[propname].lower()
736 l = []
737 cldb = self.db.getclassdb(self.classname)
738 for nodeid in self.db.getnodeids(self.classname, cldb):
739 node = self.db.getnode(self.classname, nodeid, cldb)
740 if node.has_key(self.db.RETIRED_FLAG):
741 continue
742 for key, value in requirements.items():
743 if node[key] and node[key].lower() != value:
744 break
745 else:
746 l.append(nodeid)
747 return l
749 def list(self):
750 """Return a list of the ids of the active nodes in this class."""
751 l = []
752 cn = self.classname
753 cldb = self.db.getclassdb(cn)
754 for nodeid in self.db.getnodeids(cn, cldb):
755 node = self.db.getnode(cn, nodeid, cldb)
756 if node.has_key(self.db.RETIRED_FLAG):
757 continue
758 l.append(nodeid)
759 l.sort()
760 return l
762 # XXX not in spec
763 def filter(self, filterspec, sort, group, num_re = re.compile('^\d+$')):
764 ''' Return a list of the ids of the active nodes in this class that
765 match the 'filter' spec, sorted by the group spec and then the
766 sort spec
767 '''
768 cn = self.classname
770 # optimise filterspec
771 l = []
772 props = self.getprops()
773 for k, v in filterspec.items():
774 propclass = props[k]
775 if isinstance(propclass, Link):
776 if type(v) is not type([]):
777 v = [v]
778 # replace key values with node ids
779 u = []
780 link_class = self.db.classes[propclass.classname]
781 for entry in v:
782 if entry == '-1': entry = None
783 elif not num_re.match(entry):
784 try:
785 entry = link_class.lookup(entry)
786 except (TypeError,KeyError):
787 raise ValueError, 'property "%s": %s not a %s'%(
788 k, entry, self.properties[k].classname)
789 u.append(entry)
791 l.append((0, k, u))
792 elif isinstance(propclass, Multilink):
793 if type(v) is not type([]):
794 v = [v]
795 # replace key values with node ids
796 u = []
797 link_class = self.db.classes[propclass.classname]
798 for entry in v:
799 if not num_re.match(entry):
800 try:
801 entry = link_class.lookup(entry)
802 except (TypeError,KeyError):
803 raise ValueError, 'new property "%s": %s not a %s'%(
804 k, entry, self.properties[k].classname)
805 u.append(entry)
806 l.append((1, k, u))
807 elif isinstance(propclass, String):
808 # simple glob searching
809 v = re.sub(r'([\|\{\}\\\.\+\[\]\(\)])', r'\\\1', v)
810 v = v.replace('?', '.')
811 v = v.replace('*', '.*?')
812 l.append((2, k, re.compile(v, re.I)))
813 else:
814 l.append((6, k, v))
815 filterspec = l
817 # now, find all the nodes that are active and pass filtering
818 l = []
819 cldb = self.db.getclassdb(cn)
820 for nodeid in self.db.getnodeids(cn, cldb):
821 node = self.db.getnode(cn, nodeid, cldb)
822 if node.has_key(self.db.RETIRED_FLAG):
823 continue
824 # apply filter
825 for t, k, v in filterspec:
826 # this node doesn't have this property, so reject it
827 if not node.has_key(k): break
829 if t == 0 and node[k] not in v:
830 # link - if this node'd property doesn't appear in the
831 # filterspec's nodeid list, skip it
832 break
833 elif t == 1:
834 # multilink - if any of the nodeids required by the
835 # filterspec aren't in this node's property, then skip
836 # it
837 for value in v:
838 if value not in node[k]:
839 break
840 else:
841 continue
842 break
843 elif t == 2 and not v.search(node[k]):
844 # RE search
845 break
846 elif t == 6 and node[k] != v:
847 # straight value comparison for the other types
848 break
849 else:
850 l.append((nodeid, node))
851 l.sort()
853 # optimise sort
854 m = []
855 for entry in sort:
856 if entry[0] != '-':
857 m.append(('+', entry))
858 else:
859 m.append((entry[0], entry[1:]))
860 sort = m
862 # optimise group
863 m = []
864 for entry in group:
865 if entry[0] != '-':
866 m.append(('+', entry))
867 else:
868 m.append((entry[0], entry[1:]))
869 group = m
870 # now, sort the result
871 def sortfun(a, b, sort=sort, group=group, properties=self.getprops(),
872 db = self.db, cl=self):
873 a_id, an = a
874 b_id, bn = b
875 # sort by group and then sort
876 for list in group, sort:
877 for dir, prop in list:
878 # sorting is class-specific
879 propclass = properties[prop]
881 # handle the properties that might be "faked"
882 # also, handle possible missing properties
883 try:
884 if not an.has_key(prop):
885 an[prop] = cl.get(a_id, prop)
886 av = an[prop]
887 except KeyError:
888 # the node doesn't have a value for this property
889 if isinstance(propclass, Multilink): av = []
890 else: av = ''
891 try:
892 if not bn.has_key(prop):
893 bn[prop] = cl.get(b_id, prop)
894 bv = bn[prop]
895 except KeyError:
896 # the node doesn't have a value for this property
897 if isinstance(propclass, Multilink): bv = []
898 else: bv = ''
900 # String and Date values are sorted in the natural way
901 if isinstance(propclass, String):
902 # clean up the strings
903 if av and av[0] in string.uppercase:
904 av = an[prop] = av.lower()
905 if bv and bv[0] in string.uppercase:
906 bv = bn[prop] = bv.lower()
907 if (isinstance(propclass, String) or
908 isinstance(propclass, Date)):
909 # it might be a string that's really an integer
910 try:
911 av = int(av)
912 bv = int(bv)
913 except:
914 pass
915 if dir == '+':
916 r = cmp(av, bv)
917 if r != 0: return r
918 elif dir == '-':
919 r = cmp(bv, av)
920 if r != 0: return r
922 # Link properties are sorted according to the value of
923 # the "order" property on the linked nodes if it is
924 # present; or otherwise on the key string of the linked
925 # nodes; or finally on the node ids.
926 elif isinstance(propclass, Link):
927 link = db.classes[propclass.classname]
928 if av is None and bv is not None: return -1
929 if av is not None and bv is None: return 1
930 if av is None and bv is None: continue
931 if link.getprops().has_key('order'):
932 if dir == '+':
933 r = cmp(link.get(av, 'order'),
934 link.get(bv, 'order'))
935 if r != 0: return r
936 elif dir == '-':
937 r = cmp(link.get(bv, 'order'),
938 link.get(av, 'order'))
939 if r != 0: return r
940 elif link.getkey():
941 key = link.getkey()
942 if dir == '+':
943 r = cmp(link.get(av, key), link.get(bv, key))
944 if r != 0: return r
945 elif dir == '-':
946 r = cmp(link.get(bv, key), link.get(av, key))
947 if r != 0: return r
948 else:
949 if dir == '+':
950 r = cmp(av, bv)
951 if r != 0: return r
952 elif dir == '-':
953 r = cmp(bv, av)
954 if r != 0: return r
956 # Multilink properties are sorted according to how many
957 # links are present.
958 elif isinstance(propclass, Multilink):
959 if dir == '+':
960 r = cmp(len(av), len(bv))
961 if r != 0: return r
962 elif dir == '-':
963 r = cmp(len(bv), len(av))
964 if r != 0: return r
965 # end for dir, prop in list:
966 # end for list in sort, group:
967 # if all else fails, compare the ids
968 return cmp(a[0], b[0])
970 l.sort(sortfun)
971 return [i[0] for i in l]
973 def count(self):
974 """Get the number of nodes in this class.
976 If the returned integer is 'numnodes', the ids of all the nodes
977 in this class run from 1 to numnodes, and numnodes+1 will be the
978 id of the next node to be created in this class.
979 """
980 return self.db.countnodes(self.classname)
982 # Manipulating properties:
984 def getprops(self, protected=1):
985 """Return a dictionary mapping property names to property objects.
986 If the "protected" flag is true, we include protected properties -
987 those which may not be modified."""
988 d = self.properties.copy()
989 if protected:
990 d['id'] = String()
991 return d
993 def addprop(self, **properties):
994 """Add properties to this class.
996 The keyword arguments in 'properties' must map names to property
997 objects, or a TypeError is raised. None of the keys in 'properties'
998 may collide with the names of existing properties, or a ValueError
999 is raised before any properties have been added.
1000 """
1001 for key in properties.keys():
1002 if self.properties.has_key(key):
1003 raise ValueError, key
1004 self.properties.update(properties)
1007 # XXX not in spec
1008 class Node:
1009 ''' A convenience wrapper for the given node
1010 '''
1011 def __init__(self, cl, nodeid, cache=1):
1012 self.__dict__['cl'] = cl
1013 self.__dict__['nodeid'] = nodeid
1014 self.__dict__['cache'] = cache
1015 def keys(self, protected=1):
1016 return self.cl.getprops(protected=protected).keys()
1017 def values(self, protected=1):
1018 l = []
1019 for name in self.cl.getprops(protected=protected).keys():
1020 l.append(self.cl.get(self.nodeid, name, cache=self.cache))
1021 return l
1022 def items(self, protected=1):
1023 l = []
1024 for name in self.cl.getprops(protected=protected).keys():
1025 l.append((name, self.cl.get(self.nodeid, name, cache=self.cache)))
1026 return l
1027 def has_key(self, name):
1028 return self.cl.getprops().has_key(name)
1029 def __getattr__(self, name):
1030 if self.__dict__.has_key(name):
1031 return self.__dict__[name]
1032 try:
1033 return self.cl.get(self.nodeid, name, cache=self.cache)
1034 except KeyError, value:
1035 # we trap this but re-raise it as AttributeError - all other
1036 # exceptions should pass through untrapped
1037 pass
1038 # nope, no such attribute
1039 raise AttributeError, str(value)
1040 def __getitem__(self, name):
1041 return self.cl.get(self.nodeid, name, cache=self.cache)
1042 def __setattr__(self, name, value):
1043 try:
1044 return self.cl.set(self.nodeid, **{name: value})
1045 except KeyError, value:
1046 raise AttributeError, str(value)
1047 def __setitem__(self, name, value):
1048 self.cl.set(self.nodeid, **{name: value})
1049 def history(self):
1050 return self.cl.history(self.nodeid)
1051 def retire(self):
1052 return self.cl.retire(self.nodeid)
1055 def Choice(name, *options):
1056 cl = Class(db, name, name=hyperdb.String(), order=hyperdb.String())
1057 for i in range(len(options)):
1058 cl.create(name=option[i], order=i)
1059 return hyperdb.Link(name)
1061 #
1062 # $Log: not supported by cvs2svn $
1063 # Revision 1.50 2002/01/19 13:16:04 rochecompaan
1064 # Journal entries for link and multilink properties can now be switched on
1065 # or off.
1066 #
1067 # Revision 1.49 2002/01/16 07:02:57 richard
1068 # . lots of date/interval related changes:
1069 # - more relaxed date format for input
1070 #
1071 # Revision 1.48 2002/01/14 06:32:34 richard
1072 # . #502951 ] adding new properties to old database
1073 #
1074 # Revision 1.47 2002/01/14 02:20:15 richard
1075 # . changed all config accesses so they access either the instance or the
1076 # config attriubute on the db. This means that all config is obtained from
1077 # instance_config instead of the mish-mash of classes. This will make
1078 # switching to a ConfigParser setup easier too, I hope.
1079 #
1080 # At a minimum, this makes migration a _little_ easier (a lot easier in the
1081 # 0.5.0 switch, I hope!)
1082 #
1083 # Revision 1.46 2002/01/07 10:42:23 richard
1084 # oops
1085 #
1086 # Revision 1.45 2002/01/02 04:18:17 richard
1087 # hyperdb docstrings
1088 #
1089 # Revision 1.44 2002/01/02 02:31:38 richard
1090 # Sorry for the huge checkin message - I was only intending to implement #496356
1091 # but I found a number of places where things had been broken by transactions:
1092 # . modified ROUNDUPDBSENDMAILDEBUG to be SENDMAILDEBUG and hold a filename
1093 # for _all_ roundup-generated smtp messages to be sent to.
1094 # . the transaction cache had broken the roundupdb.Class set() reactors
1095 # . newly-created author users in the mailgw weren't being committed to the db
1096 #
1097 # Stuff that made it into CHANGES.txt (ie. the stuff I was actually working
1098 # on when I found that stuff :):
1099 # . #496356 ] Use threading in messages
1100 # . detectors were being registered multiple times
1101 # . added tests for mailgw
1102 # . much better attaching of erroneous messages in the mail gateway
1103 #
1104 # Revision 1.43 2001/12/20 06:13:24 rochecompaan
1105 # Bugs fixed:
1106 # . Exception handling in hyperdb for strings-that-look-like numbers got
1107 # lost somewhere
1108 # . Internet Explorer submits full path for filename - we now strip away
1109 # the path
1110 # Features added:
1111 # . Link and multilink properties are now displayed sorted in the cgi
1112 # interface
1113 #
1114 # Revision 1.42 2001/12/16 10:53:37 richard
1115 # take a copy of the node dict so that the subsequent set
1116 # operation doesn't modify the oldvalues structure
1117 #
1118 # Revision 1.41 2001/12/15 23:47:47 richard
1119 # Cleaned up some bare except statements
1120 #
1121 # Revision 1.40 2001/12/14 23:42:57 richard
1122 # yuck, a gdbm instance tests false :(
1123 # I've left the debugging code in - it should be removed one day if we're ever
1124 # _really_ anal about performace :)
1125 #
1126 # Revision 1.39 2001/12/02 05:06:16 richard
1127 # . We now use weakrefs in the Classes to keep the database reference, so
1128 # the close() method on the database is no longer needed.
1129 # I bumped the minimum python requirement up to 2.1 accordingly.
1130 # . #487480 ] roundup-server
1131 # . #487476 ] INSTALL.txt
1132 #
1133 # I also cleaned up the change message / post-edit stuff in the cgi client.
1134 # There's now a clearly marked "TODO: append the change note" where I believe
1135 # the change note should be added there. The "changes" list will obviously
1136 # have to be modified to be a dict of the changes, or somesuch.
1137 #
1138 # More testing needed.
1139 #
1140 # Revision 1.38 2001/12/01 07:17:50 richard
1141 # . We now have basic transaction support! Information is only written to
1142 # the database when the commit() method is called. Only the anydbm
1143 # backend is modified in this way - neither of the bsddb backends have been.
1144 # The mail, admin and cgi interfaces all use commit (except the admin tool
1145 # doesn't have a commit command, so interactive users can't commit...)
1146 # . Fixed login/registration forwarding the user to the right page (or not,
1147 # on a failure)
1148 #
1149 # Revision 1.37 2001/11/28 21:55:35 richard
1150 # . login_action and newuser_action return values were being ignored
1151 # . Woohoo! Found that bloody re-login bug that was killing the mail
1152 # gateway.
1153 # (also a minor cleanup in hyperdb)
1154 #
1155 # Revision 1.36 2001/11/27 03:16:09 richard
1156 # Another place that wasn't handling missing properties.
1157 #
1158 # Revision 1.35 2001/11/22 15:46:42 jhermann
1159 # Added module docstrings to all modules.
1160 #
1161 # Revision 1.34 2001/11/21 04:04:43 richard
1162 # *sigh* more missing value handling
1163 #
1164 # Revision 1.33 2001/11/21 03:40:54 richard
1165 # more new property handling
1166 #
1167 # Revision 1.32 2001/11/21 03:11:28 richard
1168 # Better handling of new properties.
1169 #
1170 # Revision 1.31 2001/11/12 22:01:06 richard
1171 # Fixed issues with nosy reaction and author copies.
1172 #
1173 # Revision 1.30 2001/11/09 10:11:08 richard
1174 # . roundup-admin now handles all hyperdb exceptions
1175 #
1176 # Revision 1.29 2001/10/27 00:17:41 richard
1177 # Made Class.stringFind() do caseless matching.
1178 #
1179 # Revision 1.28 2001/10/21 04:44:50 richard
1180 # bug #473124: UI inconsistency with Link fields.
1181 # This also prompted me to fix a fairly long-standing usability issue -
1182 # that of being able to turn off certain filters.
1183 #
1184 # Revision 1.27 2001/10/20 23:44:27 richard
1185 # Hyperdatabase sorts strings-that-look-like-numbers as numbers now.
1186 #
1187 # Revision 1.26 2001/10/16 03:48:01 richard
1188 # admin tool now complains if a "find" is attempted with a non-link property.
1189 #
1190 # Revision 1.25 2001/10/11 00:17:51 richard
1191 # Reverted a change in hyperdb so the default value for missing property
1192 # values in a create() is None and not '' (the empty string.) This obviously
1193 # breaks CSV import/export - the string 'None' will be created in an
1194 # export/import operation.
1195 #
1196 # Revision 1.24 2001/10/10 03:54:57 richard
1197 # Added database importing and exporting through CSV files.
1198 # Uses the csv module from object-craft for exporting if it's available.
1199 # Requires the csv module for importing.
1200 #
1201 # Revision 1.23 2001/10/09 23:58:10 richard
1202 # Moved the data stringification up into the hyperdb.Class class' get, set
1203 # and create methods. This means that the data is also stringified for the
1204 # journal call, and removes duplication of code from the backends. The
1205 # backend code now only sees strings.
1206 #
1207 # Revision 1.22 2001/10/09 07:25:59 richard
1208 # Added the Password property type. See "pydoc roundup.password" for
1209 # implementation details. Have updated some of the documentation too.
1210 #
1211 # Revision 1.21 2001/10/05 02:23:24 richard
1212 # . roundup-admin create now prompts for property info if none is supplied
1213 # on the command-line.
1214 # . hyperdb Class getprops() method may now return only the mutable
1215 # properties.
1216 # . Login now uses cookies, which makes it a whole lot more flexible. We can
1217 # now support anonymous user access (read-only, unless there's an
1218 # "anonymous" user, in which case write access is permitted). Login
1219 # handling has been moved into cgi_client.Client.main()
1220 # . The "extended" schema is now the default in roundup init.
1221 # . The schemas have had their page headings modified to cope with the new
1222 # login handling. Existing installations should copy the interfaces.py
1223 # file from the roundup lib directory to their instance home.
1224 # . Incorrectly had a Bizar Software copyright on the cgitb.py module from
1225 # Ping - has been removed.
1226 # . Fixed a whole bunch of places in the CGI interface where we should have
1227 # been returning Not Found instead of throwing an exception.
1228 # . Fixed a deviation from the spec: trying to modify the 'id' property of
1229 # an item now throws an exception.
1230 #
1231 # Revision 1.20 2001/10/04 02:12:42 richard
1232 # Added nicer command-line item adding: passing no arguments will enter an
1233 # interactive more which asks for each property in turn. While I was at it, I
1234 # fixed an implementation problem WRT the spec - I wasn't raising a
1235 # ValueError if the key property was missing from a create(). Also added a
1236 # protected=boolean argument to getprops() so we can list only the mutable
1237 # properties (defaults to yes, which lists the immutables).
1238 #
1239 # Revision 1.19 2001/08/29 04:47:18 richard
1240 # Fixed CGI client change messages so they actually include the properties
1241 # changed (again).
1242 #
1243 # Revision 1.18 2001/08/16 07:34:59 richard
1244 # better CGI text searching - but hidden filter fields are disappearing...
1245 #
1246 # Revision 1.17 2001/08/16 06:59:58 richard
1247 # all searches use re now - and they're all case insensitive
1248 #
1249 # Revision 1.16 2001/08/15 23:43:18 richard
1250 # Fixed some isFooTypes that I missed.
1251 # Refactored some code in the CGI code.
1252 #
1253 # Revision 1.15 2001/08/12 06:32:36 richard
1254 # using isinstance(blah, Foo) now instead of isFooType
1255 #
1256 # Revision 1.14 2001/08/07 00:24:42 richard
1257 # stupid typo
1258 #
1259 # Revision 1.13 2001/08/07 00:15:51 richard
1260 # Added the copyright/license notice to (nearly) all files at request of
1261 # Bizar Software.
1262 #
1263 # Revision 1.12 2001/08/02 06:38:17 richard
1264 # Roundupdb now appends "mailing list" information to its messages which
1265 # include the e-mail address and web interface address. Templates may
1266 # override this in their db classes to include specific information (support
1267 # instructions, etc).
1268 #
1269 # Revision 1.11 2001/08/01 04:24:21 richard
1270 # mailgw was assuming certain properties existed on the issues being created.
1271 #
1272 # Revision 1.10 2001/07/30 02:38:31 richard
1273 # get() now has a default arg - for migration only.
1274 #
1275 # Revision 1.9 2001/07/29 09:28:23 richard
1276 # Fixed sorting by clicking on column headings.
1277 #
1278 # Revision 1.8 2001/07/29 08:27:40 richard
1279 # Fixed handling of passed-in values in form elements (ie. during a
1280 # drill-down)
1281 #
1282 # Revision 1.7 2001/07/29 07:01:39 richard
1283 # Added vim command to all source so that we don't get no steenkin' tabs :)
1284 #
1285 # Revision 1.6 2001/07/29 05:36:14 richard
1286 # Cleanup of the link label generation.
1287 #
1288 # Revision 1.5 2001/07/29 04:05:37 richard
1289 # Added the fabricated property "id".
1290 #
1291 # Revision 1.4 2001/07/27 06:25:35 richard
1292 # Fixed some of the exceptions so they're the right type.
1293 # Removed the str()-ification of node ids so we don't mask oopsy errors any
1294 # more.
1295 #
1296 # Revision 1.3 2001/07/27 05:17:14 richard
1297 # just some comments
1298 #
1299 # Revision 1.2 2001/07/22 12:09:32 richard
1300 # Final commit of Grande Splite
1301 #
1302 # Revision 1.1 2001/07/22 11:58:35 richard
1303 # More Grande Splite
1304 #
1305 #
1306 # vim: set filetype=python ts=4 sw=4 et si