Code

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