Code

26a6c906fcb8edb4c2c1bb207fa5cb9d6ef97958
[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.93 2003-11-16 19:59:10 jlgijsbers Exp $
20 """
21 Hyperdatabase implementation, especially field types.
22 """
24 # standard python modules
25 import sys, os, time, re
27 # roundup modules
28 import date, password
30 # configure up the DEBUG and TRACE captures
31 class Sink:
32     def write(self, content):
33         pass
34 DEBUG = os.environ.get('HYPERDBDEBUG', '')
35 if DEBUG and __debug__:
36     if DEBUG == 'stdout':
37         DEBUG = sys.stdout
38     else:
39         DEBUG = open(DEBUG, 'a')
40 else:
41     DEBUG = Sink()
42 TRACE = os.environ.get('HYPERDBTRACE', '')
43 if TRACE and __debug__:
44     if TRACE == 'stdout':
45         TRACE = sys.stdout
46     else:
47         TRACE = open(TRACE, 'w')
48 else:
49     TRACE = Sink()
50 def traceMark():
51     print >>TRACE, '**MARK', time.ctime()
52 del Sink
54 #
55 # Types
56 #
57 class String:
58     """An object designating a String property."""
59     def __init__(self, indexme='no'):
60         self.indexme = indexme == 'yes'
61     def __repr__(self):
62         ' more useful for dumps '
63         return '<%s>'%self.__class__
65 class Password:
66     """An object designating a Password property."""
67     def __repr__(self):
68         ' more useful for dumps '
69         return '<%s>'%self.__class__
71 class Date:
72     """An object designating a Date property."""
73     def __repr__(self):
74         ' more useful for dumps '
75         return '<%s>'%self.__class__
77 class Interval:
78     """An object designating an Interval property."""
79     def __repr__(self):
80         ' more useful for dumps '
81         return '<%s>'%self.__class__
83 class Link:
84     """An object designating a Link property that links to a
85        node in a specified class."""
86     def __init__(self, classname, do_journal='yes'):
87         ''' Default is to not journal link and unlink events
88         '''
89         self.classname = classname
90         self.do_journal = do_journal == 'yes'
91     def __repr__(self):
92         ' more useful for dumps '
93         return '<%s to "%s">'%(self.__class__, self.classname)
95 class Multilink:
96     """An object designating a Multilink property that links
97        to nodes in a specified class.
99        "classname" indicates the class to link to
101        "do_journal" indicates whether the linked-to nodes should have
102                     'link' and 'unlink' events placed in their journal
103     """
104     def __init__(self, classname, do_journal='yes'):
105         ''' Default is to not journal link and unlink events
106         '''
107         self.classname = classname
108         self.do_journal = do_journal == 'yes'
109     def __repr__(self):
110         ' more useful for dumps '
111         return '<%s to "%s">'%(self.__class__, self.classname)
113 class Boolean:
114     """An object designating a boolean property"""
115     def __repr__(self):
116         'more useful for dumps'
117         return '<%s>' % self.__class__
118     
119 class Number:
120     """An object designating a numeric property"""
121     def __repr__(self):
122         'more useful for dumps'
123         return '<%s>' % self.__class__
125 # Support for splitting designators
127 class DesignatorError(ValueError):
128     pass
129 def splitDesignator(designator, dre=re.compile(r'([^\d]+)(\d+)')):
130     ''' Take a foo123 and return ('foo', 123)
131     '''
132     m = dre.match(designator)
133     if m is None:
134         raise DesignatorError, '"%s" not a node designator'%designator
135     return m.group(1), m.group(2)
138 # the base Database class
140 class DatabaseError(ValueError):
141     '''Error to be raised when there is some problem in the database code
142     '''
143     pass
144 class Database:
145     '''A database for storing records containing flexible data types.
147 This class defines a hyperdatabase storage layer, which the Classes use to
148 store their data.
151 Transactions
152 ------------
153 The Database should support transactions through the commit() and
154 rollback() methods. All other Database methods should be transaction-aware,
155 using data from the current transaction before looking up the database.
157 An implementation must provide an override for the get() method so that the
158 in-database value is returned in preference to the in-transaction value.
159 This is necessary to determine if any values have changed during a
160 transaction.
163 Implementation
164 --------------
166 All methods except __repr__ must be implemented by a concrete backend Database.
168 '''
170     # flag to set on retired entries
171     RETIRED_FLAG = '__hyperdb_retired'
173     def __init__(self, config, journaltag=None):
174         """Open a hyperdatabase given a specifier to some storage.
176         The 'storagelocator' is obtained from config.DATABASE.
177         The meaning of 'storagelocator' depends on the particular
178         implementation of the hyperdatabase.  It could be a file name,
179         a directory path, a socket descriptor for a connection to a
180         database over the network, etc.
182         The 'journaltag' is a token that will be attached to the journal
183         entries for any edits done on the database.  If 'journaltag' is
184         None, the database is opened in read-only mode: the Class.create(),
185         Class.set(), and Class.retire() methods are disabled.
186         """
187         raise NotImplementedError
189     def post_init(self):
190         """Called once the schema initialisation has finished. 
191            If 'refresh' is true, we want to rebuild the backend
192            structures.
193         """
194         raise NotImplementedError
196     def refresh_database(self):
197         """Called to indicate that the backend should rebuild all tables
198            and structures. Not called in normal usage."""
199         raise NotImplementedError
201     def __getattr__(self, classname):
202         """A convenient way of calling self.getclass(classname)."""
203         raise NotImplementedError
205     def addclass(self, cl):
206         '''Add a Class to the hyperdatabase.
207         '''
208         raise NotImplementedError
210     def getclasses(self):
211         """Return a list of the names of all existing classes."""
212         raise NotImplementedError
214     def getclass(self, classname):
215         """Get the Class object representing a particular class.
217         If 'classname' is not a valid class name, a KeyError is raised.
218         """
219         raise NotImplementedError
221     def clear(self):
222         '''Delete all database contents.
223         '''
224         raise NotImplementedError
226     def getclassdb(self, classname, mode='r'):
227         '''Obtain a connection to the class db that will be used for
228            multiple actions.
229         '''
230         raise NotImplementedError
232     def addnode(self, classname, nodeid, node):
233         '''Add the specified node to its class's db.
234         '''
235         raise NotImplementedError
237     def serialise(self, classname, node):
238         '''Copy the node contents, converting non-marshallable data into
239            marshallable data.
240         '''
241         return node
243     def setnode(self, classname, nodeid, node):
244         '''Change the specified node.
245         '''
246         raise NotImplementedError
248     def unserialise(self, classname, node):
249         '''Decode the marshalled node data
250         '''
251         return node
253     def getnode(self, classname, nodeid, db=None, cache=1):
254         '''Get a node from the database.
256         'cache' exists for backwards compatibility, and is not used.
257         '''
258         raise NotImplementedError
260     def hasnode(self, classname, nodeid, db=None):
261         '''Determine if the database has a given node.
262         '''
263         raise NotImplementedError
265     def countnodes(self, classname, db=None):
266         '''Count the number of nodes that exist for a particular Class.
267         '''
268         raise NotImplementedError
270     def storefile(self, classname, nodeid, property, content):
271         '''Store the content of the file in the database.
272         
273            The property may be None, in which case the filename does not
274            indicate which property is being saved.
275         '''
276         raise NotImplementedError
278     def getfile(self, classname, nodeid, property):
279         '''Store the content of the file in the database.
280         '''
281         raise NotImplementedError
283     def addjournal(self, classname, nodeid, action, params):
284         ''' Journal the Action
285         'action' may be:
287             'create' or 'set' -- 'params' is a dictionary of property values
288             'link' or 'unlink' -- 'params' is (classname, nodeid, propname)
289             'retire' -- 'params' is None
290         '''
291         raise NotImplementedError
293     def getjournal(self, classname, nodeid):
294         ''' get the journal for id
295         '''
296         raise NotImplementedError
298     def pack(self, pack_before):
299         ''' pack the database
300         '''
301         raise NotImplementedError
303     def commit(self):
304         ''' Commit the current transactions.
306         Save all data changed since the database was opened or since the
307         last commit() or rollback().
308         '''
309         raise NotImplementedError
311     def rollback(self):
312         ''' Reverse all actions from the current transaction.
314         Undo all the changes made since the database was opened or the last
315         commit() or rollback() was performed.
316         '''
317         raise NotImplementedError
320 # The base Class class
322 class Class:
323     """ The handle to a particular class of nodes in a hyperdatabase.
324         
325         All methods except __repr__ and getnode must be implemented by a
326         concrete backend Class.
327     """
329     def __init__(self, db, classname, **properties):
330         """Create a new class with a given name and property specification.
332         'classname' must not collide with the name of an existing class,
333         or a ValueError is raised.  The keyword arguments in 'properties'
334         must map names to property objects, or a TypeError is raised.
335         """
336         raise NotImplementedError
338     def __repr__(self):
339         '''Slightly more useful representation
340         '''
341         return '<hyperdb.Class "%s">'%self.classname
343     # Editing nodes:
345     def create(self, **propvalues):
346         """Create a new node of this class and return its id.
348         The keyword arguments in 'propvalues' map property names to values.
350         The values of arguments must be acceptable for the types of their
351         corresponding properties or a TypeError is raised.
352         
353         If this class has a key property, it must be present and its value
354         must not collide with other key strings or a ValueError is raised.
355         
356         Any other properties on this class that are missing from the
357         'propvalues' dictionary are set to None.
358         
359         If an id in a link or multilink property does not refer to a valid
360         node, an IndexError is raised.
361         """
362         raise NotImplementedError
364     _marker = []
365     def get(self, nodeid, propname, default=_marker, cache=1):
366         """Get the value of a property on an existing node of this class.
368         'nodeid' must be the id of an existing node of this class or an
369         IndexError is raised.  'propname' must be the name of a property
370         of this class or a KeyError is raised.
372         'cache' exists for backwards compatibility, and is not used.
373         """
374         raise NotImplementedError
376     # not in spec
377     def getnode(self, nodeid, cache=1):
378         ''' Return a convenience wrapper for the node.
380         'nodeid' must be the id of an existing node of this class or an
381         IndexError is raised.
383         'cache' exists for backwards compatibility, and is not used.
384         '''
385         return Node(self, nodeid)
387     def getnodeids(self, db=None):
388         '''Retrieve all the ids of the nodes for a particular Class.
389         '''
390         raise NotImplementedError
392     def set(self, nodeid, **propvalues):
393         """Modify a property on an existing node of this class.
394         
395         'nodeid' must be the id of an existing node of this class or an
396         IndexError is raised.
398         Each key in 'propvalues' must be the name of a property of this
399         class or a KeyError is raised.
401         All values in 'propvalues' must be acceptable types for their
402         corresponding properties or a TypeError is raised.
404         If the value of the key property is set, it must not collide with
405         other key strings or a ValueError is raised.
407         If the value of a Link or Multilink property contains an invalid
408         node id, a ValueError is raised.
409         """
410         raise NotImplementedError
412     def retire(self, nodeid):
413         """Retire a node.
414         
415         The properties on the node remain available from the get() method,
416         and the node's id is never reused.
417         
418         Retired nodes are not returned by the find(), list(), or lookup()
419         methods, and other nodes may reuse the values of their key properties.
420         """
421         raise NotImplementedError
423     def restore(self, nodeid):
424         '''Restpre a retired node.
426         Make node available for all operations like it was before retirement.
427         '''
428         raise NotImplementedError
429     
430     def is_retired(self, nodeid):
431         '''Return true if the node is rerired
432         '''
433         raise NotImplementedError
435     def destroy(self, nodeid):
436         """Destroy a node.
437         
438         WARNING: this method should never be used except in extremely rare
439                  situations where there could never be links to the node being
440                  deleted
441         WARNING: use retire() instead
442         WARNING: the properties of this node will not be available ever again
443         WARNING: really, use retire() instead
445         Well, I think that's enough warnings. This method exists mostly to
446         support the session storage of the cgi interface.
448         The node is completely removed from the hyperdb, including all journal
449         entries. It will no longer be available, and will generally break code
450         if there are any references to the node.
451         """
453     def history(self, nodeid):
454         """Retrieve the journal of edits on a particular node.
456         'nodeid' must be the id of an existing node of this class or an
457         IndexError is raised.
459         The returned list contains tuples of the form
461             (date, tag, action, params)
463         'date' is a Timestamp object specifying the time of the change and
464         'tag' is the journaltag specified when the database was opened.
465         """
466         raise NotImplementedError
468     # Locating nodes:
469     def hasnode(self, nodeid):
470         '''Determine if the given nodeid actually exists
471         '''
472         raise NotImplementedError
474     def setkey(self, propname):
475         """Select a String property of this class to be the key property.
477         'propname' must be the name of a String property of this class or
478         None, or a TypeError is raised.  The values of the key property on
479         all existing nodes must be unique or a ValueError is raised.
480         """
481         raise NotImplementedError
483     def getkey(self):
484         """Return the name of the key property for this class or None."""
485         raise NotImplementedError
487     def labelprop(self, default_to_id=0):
488         ''' Return the property name for a label for the given node.
490         This method attempts to generate a consistent label for the node.
491         It tries the following in order:
492             1. key property
493             2. "name" property
494             3. "title" property
495             4. first property from the sorted property name list
496         '''
497         raise NotImplementedError
499     def lookup(self, keyvalue):
500         """Locate a particular node by its key property and return its id.
502         If this class has no key property, a TypeError is raised.  If the
503         'keyvalue' matches one of the values for the key property among
504         the nodes in this class, the matching node's id is returned;
505         otherwise a KeyError is raised.
506         """
507         raise NotImplementedError
509     def find(self, **propspec):
510         """Get the ids of nodes in this class which link to the given nodes.
512         'propspec' consists of keyword args propname={nodeid:1,}   
513         'propname' must be the name of a property in this class, or a
514         KeyError is raised.  That property must be a Link or Multilink
515         property, or a TypeError is raised.
517         Any node in this class whose 'propname' property links to any of the
518         nodeids will be returned. Used by the full text indexing, which knows
519         that "foo" occurs in msg1, msg3 and file7, so we have hits on these
520         issues:
522             db.issue.find(messages={'1':1,'3':1}, files={'7':1})
523         """
524         raise NotImplementedError
526     def filter(self, search_matches, filterspec, sort=(None,None),
527             group=(None,None)):
528         ''' Return a list of the ids of the active nodes in this class that
529             match the 'filter' spec, sorted by the group spec and then the
530             sort spec.
532             "filterspec" is {propname: value(s)}
533             "sort" and "group" are (dir, prop) where dir is '+', '-' or None
534                                and prop is a prop name or None
535             "search_matches" is {nodeid: marker}
537             The filter must match all properties specificed - but if the
538             property value to match is a list, any one of the values in the
539             list may match for that property to match.
540         '''
541         raise NotImplementedError
543     def count(self):
544         """Get the number of nodes in this class.
546         If the returned integer is 'numnodes', the ids of all the nodes
547         in this class run from 1 to numnodes, and numnodes+1 will be the
548         id of the next node to be created in this class.
549         """
550         raise NotImplementedError
552     # Manipulating properties:
553     def getprops(self, protected=1):
554         """Return a dictionary mapping property names to property objects.
555            If the "protected" flag is true, we include protected properties -
556            those which may not be modified.
557         """
558         raise NotImplementedError
560     def addprop(self, **properties):
561         """Add properties to this class.
563         The keyword arguments in 'properties' must map names to property
564         objects, or a TypeError is raised.  None of the keys in 'properties'
565         may collide with the names of existing properties, or a ValueError
566         is raised before any properties have been added.
567         """
568         raise NotImplementedError
570     def index(self, nodeid):
571         '''Add (or refresh) the node to search indexes
572         '''
573         raise NotImplementedError
575     def safeget(self, nodeid, propname, default=None):
576         try:
577             return self.get(nodeid, propname)
578         except (KeyError, IndexError):
579             return default            
581 class HyperdbValueError(ValueError):
582     ''' Error converting a raw value into a Hyperdb value '''
583     pass
585 def convertLinkValue(db, propname, prop, value, idre=re.compile('\d+')):
586     ''' Convert the link value (may be id or key value) to an id value. '''
587     linkcl = db.classes[prop.classname]
588     if not idre.match(value):
589         if linkcl.getkey():
590             try:
591                 value = linkcl.lookup(value)
592             except KeyError, message:
593                 raise HyperdbValueError, 'property %s: %r is not a %s.'%(
594                     propname, value, prop.classname)
595         else:
596             raise HyperdbValueError, 'you may only enter ID values '\
597                 'for property %s'%propname
598     return value
600 def fixNewlines(text):
601     ''' Homogenise line endings.
603         Different web clients send different line ending values, but
604         other systems (eg. email) don't necessarily handle those line
605         endings. Our solution is to convert all line endings to LF.
606     '''
607     text = text.replace('\r\n', '\n')
608     return text.replace('\r', '\n')
610 def rawToHyperdb(db, klass, itemid, propname, value,
611         pwre=re.compile(r'{(\w+)}(.+)')):
612     ''' Convert the raw (user-input) value to a hyperdb-storable value. The
613         value is for the "propname" property on itemid (may be None for a
614         new item) of "klass" in "db".
616         The value is usually a string, but in the case of multilink inputs
617         it may be either a list of strings or a string with comma-separated
618         values.
619     '''
620     properties = klass.getprops()
622     # ensure it's a valid property name
623     propname = propname.strip()
624     try:
625         proptype =  properties[propname]
626     except KeyError:
627         raise HyperdbValueError, '%r is not a property of %s'%(propname,
628             klass.classname)
630     # if we got a string, strip it now
631     if isinstance(value, type('')):
632         value = value.strip()
634     # convert the input value to a real property value
635     if isinstance(proptype, String):
636         # fix the CRLF/CR -> LF stuff
637         value = fixNewlines(value)
638     if isinstance(proptype, Password):
639         m = pwre.match(value)
640         if m:
641             # password is being given to us encrypted
642             p = password.Password()
643             p.scheme = m.group(1)
644             if p.scheme not in 'SHA crypt plaintext'.split():
645                 raise HyperdbValueError, 'property %s: unknown encryption '\
646                     'scheme %r'%(propname, p.scheme)
647             p.password = m.group(2)
648             value = p
649         else:
650             try:
651                 value = password.Password(value)
652             except password.PasswordValueError, message:
653                 raise HyperdbValueError, 'property %s: %s'%(propname, message)
654     elif isinstance(proptype, Date):
655         try:
656             tz = db.getUserTimezone()
657             value = date.Date(value).local(tz)
658         except ValueError, message:
659             raise HyperdbValueError, 'property %s: %r is an invalid '\
660                 'date (%s)'%(propname, value, message)
661     elif isinstance(proptype, Interval):
662         try:
663             value = date.Interval(value)
664         except ValueError, message:
665             raise HyperdbValueError, 'property %s: %r is an invalid '\
666                 'date interval (%s)'%(propname, value, message)
667     elif isinstance(proptype, Link):
668         if value == '-1' or not value:
669             value = None
670         else:
671             value = convertLinkValue(db, propname, proptype, value)
673     elif isinstance(proptype, Multilink):
674         # get the current item value if it's not a new item
675         if itemid and not itemid.startswith('-'):
676             curvalue = klass.get(itemid, propname)
677         else:
678             curvalue = []
680         # if the value is a comma-separated string then split it now
681         if isinstance(value, type('')):
682             value = value.split(',')
684         # handle each add/remove in turn
685         # keep an extra list for all items that are
686         # definitely in the new list (in case of e.g.
687         # <propname>=A,+B, which should replace the old
688         # list with A,B)
689         set = 1
690         newvalue = []
691         for item in value:
692             item = item.strip()
694             # skip blanks
695             if not item: continue
697             # handle +/-
698             remove = 0
699             if item.startswith('-'):
700                 remove = 1
701                 item = item[1:]
702                 set = 0
703             elif item.startswith('+'):
704                 item = item[1:]
705                 set = 0
707             # look up the value
708             itemid = convertLinkValue(db, propname, proptype, item)
710             # perform the add/remove
711             if remove:
712                 try:
713                     curvalue.remove(itemid)
714                 except ValueError:
715                     raise HyperdbValueError, 'property %s: %r is not ' \
716                         'currently an element'%(propname, item)
717             else:
718                 newvalue.append(itemid)
719                 if itemid not in curvalue:
720                     curvalue.append(itemid)
722         # that's it, set the new Multilink property value,
723         # or overwrite it completely
724         if set:
725             value = newvalue
726         else:
727             value = curvalue
729         # TODO: one day, we'll switch to numeric ids and this will be
730         # unnecessary :(
731         value = [int(x) for x in value]
732         value.sort()
733         value = [str(x) for x in value]
734     elif isinstance(proptype, Boolean):
735         value = value.strip()
736         value = value.lower() in ('yes', 'true', 'on', '1')
737     elif isinstance(proptype, Number):
738         value = value.strip()
739         try:
740             value = float(value)
741         except ValueError:
742             raise HyperdbValueError, 'property %s: %r is not a number'%(
743                 propname, value)
744     return value
746 class FileClass:
747     ''' A class that requires the "content" property and stores it on
748         disk.
749     '''
750     pass
752 class Node:
753     ''' A convenience wrapper for the given node
754     '''
755     def __init__(self, cl, nodeid, cache=1):
756         self.__dict__['cl'] = cl
757         self.__dict__['nodeid'] = nodeid
758     def keys(self, protected=1):
759         return self.cl.getprops(protected=protected).keys()
760     def values(self, protected=1):
761         l = []
762         for name in self.cl.getprops(protected=protected).keys():
763             l.append(self.cl.get(self.nodeid, name))
764         return l
765     def items(self, protected=1):
766         l = []
767         for name in self.cl.getprops(protected=protected).keys():
768             l.append((name, self.cl.get(self.nodeid, name)))
769         return l
770     def has_key(self, name):
771         return self.cl.getprops().has_key(name)
772     def get(self, name, default=None): 
773         if self.has_key(name):
774             return self[name]
775         else:
776             return default
777     def __getattr__(self, name):
778         if self.__dict__.has_key(name):
779             return self.__dict__[name]
780         try:
781             return self.cl.get(self.nodeid, name)
782         except KeyError, value:
783             # we trap this but re-raise it as AttributeError - all other
784             # exceptions should pass through untrapped
785             pass
786         # nope, no such attribute
787         raise AttributeError, str(value)
788     def __getitem__(self, name):
789         return self.cl.get(self.nodeid, name)
790     def __setattr__(self, name, value):
791         try:
792             return self.cl.set(self.nodeid, **{name: value})
793         except KeyError, value:
794             raise AttributeError, str(value)
795     def __setitem__(self, name, value):
796         self.cl.set(self.nodeid, **{name: value})
797     def history(self):
798         return self.cl.history(self.nodeid)
799     def retire(self):
800         return self.cl.retire(self.nodeid)
803 def Choice(name, db, *options):
804     '''Quick helper to create a simple class with choices
805     '''
806     cl = Class(db, name, name=String(), order=String())
807     for i in range(len(options)):
808         cl.create(name=options[i], order=i)
809     return hyperdb.Link(name)
811 # vim: set filetype=python ts=4 sw=4 et si