Code

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