Code

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