Code

*** empty log message ***
[roundup.git] / roundup / hyperdb.py
index 6a1c83108d97b2a0534a22e8c5893dffa86304cd..4bac87fc63039f2f258e2a26737990063e101f3b 100644 (file)
 # BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
 # SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
 # 
-# $Id: hyperdb.py,v 1.90 2003-10-24 22:52:48 richard Exp $
+# $Id: hyperdb.py,v 1.96 2004-02-11 23:55:08 richard Exp $
 
+"""Hyperdatabase implementation, especially field types.
 """
-Hyperdatabase implementation, especially field types.
-"""
+__docformat__ = 'restructuredtext'
 
 # standard python modules
 import sys, os, time, re
@@ -163,8 +163,7 @@ transaction.
 Implementation
 --------------
 
-All methods except __repr__ and getnode must be implemented by a
-concrete backend Class.
+All methods except __repr__ must be implemented by a concrete backend Database.
 
 '''
 
@@ -231,8 +230,8 @@ concrete backend Class.
         raise NotImplementedError
 
     def addnode(self, classname, nodeid, node):
-        '''Add the specified node to its class's db.
-        '''
+        """Add the specified node to its class's db.
+        """
         raise NotImplementedError
 
     def serialise(self, classname, node):
@@ -251,19 +250,19 @@ concrete backend Class.
         '''
         return node
 
-    def getnode(self, classname, nodeid, db=None, cache=1):
+    def getnode(self, classname, nodeid):
         '''Get a node from the database.
 
         'cache' exists for backwards compatibility, and is not used.
         '''
         raise NotImplementedError
 
-    def hasnode(self, classname, nodeid, db=None):
+    def hasnode(self, classname, nodeid):
         '''Determine if the database has a given node.
         '''
         raise NotImplementedError
 
-    def countnodes(self, classname, db=None):
+    def countnodes(self, classname):
         '''Count the number of nodes that exist for a particular Class.
         '''
         raise NotImplementedError
@@ -374,7 +373,8 @@ class Class:
         """
         raise NotImplementedError
 
-    def getnode(self, nodeid, cache=1):
+    # not in spec
+    def getnode(self, nodeid):
         ''' Return a convenience wrapper for the node.
 
         'nodeid' must be the id of an existing node of this class or an
@@ -384,7 +384,7 @@ class Class:
         '''
         return Node(self, nodeid)
 
-    def getnodeids(self, db=None):
+    def getnodeids(self, retired=None):
         '''Retrieve all the ids of the nodes for a particular Class.
         '''
         raise NotImplementedError
@@ -438,8 +438,11 @@ class Class:
         WARNING: this method should never be used except in extremely rare
                  situations where there could never be links to the node being
                  deleted
+
         WARNING: use retire() instead
+
         WARNING: the properties of this node will not be available ever again
+
         WARNING: really, use retire() instead
 
         Well, I think that's enough warnings. This method exists mostly to
@@ -485,15 +488,16 @@ class Class:
         raise NotImplementedError
 
     def labelprop(self, default_to_id=0):
-        ''' Return the property name for a label for the given node.
+        """Return the property name for a label for the given node.
 
         This method attempts to generate a consistent label for the node.
         It tries the following in order:
-            1. key property
-            2. "name" property
-            3. "title" property
-            4. first property from the sorted property name list
-        '''
+
+        1. key property
+        2. "name" property
+        3. "title" property
+        4. first property from the sorted property name list
+        """
         raise NotImplementedError
 
     def lookup(self, keyvalue):
@@ -525,19 +529,21 @@ class Class:
 
     def filter(self, search_matches, filterspec, sort=(None,None),
             group=(None,None)):
-        ''' Return a list of the ids of the active nodes in this class that
-            match the 'filter' spec, sorted by the group spec and then the
-            sort spec.
-
-            "filterspec" is {propname: value(s)}
-            "sort" and "group" are (dir, prop) where dir is '+', '-' or None
-                               and prop is a prop name or None
-            "search_matches" is {nodeid: marker}
-
-            The filter must match all properties specificed - but if the
-            property value to match is a list, any one of the values in the
-            list may match for that property to match.
-        '''
+        """Return a list of the ids of the active nodes in this class that
+        match the 'filter' spec, sorted by the group spec and then the
+        sort spec.
+
+        "filterspec" is {propname: value(s)}
+
+        "sort" and "group" are (dir, prop) where dir is '+', '-' or None
+        and prop is a prop name or None
+
+        "search_matches" is {nodeid: marker}
+
+        The filter must match all properties specificed - but if the
+        property value to match is a list, any one of the values in the
+        list may match for that property to match.
+        """
         raise NotImplementedError
 
     def count(self):
@@ -572,6 +578,181 @@ class Class:
         '''
         raise NotImplementedError
 
+    def safeget(self, nodeid, propname, default=None):
+        """Safely get the value of a property on an existing node of this class.
+
+        Return 'default' if the node doesn't exist.
+        """
+        try:
+            return self.get(nodeid, propname)
+        except IndexError:
+            return default            
+
+class HyperdbValueError(ValueError):
+    ''' Error converting a raw value into a Hyperdb value '''
+    pass
+
+def convertLinkValue(db, propname, prop, value, idre=re.compile('\d+')):
+    ''' Convert the link value (may be id or key value) to an id value. '''
+    linkcl = db.classes[prop.classname]
+    if not idre.match(value):
+        if linkcl.getkey():
+            try:
+                value = linkcl.lookup(value)
+            except KeyError, message:
+                raise HyperdbValueError, 'property %s: %r is not a %s.'%(
+                    propname, value, prop.classname)
+        else:
+            raise HyperdbValueError, 'you may only enter ID values '\
+                'for property %s'%propname
+    return value
+
+def fixNewlines(text):
+    """ Homogenise line endings.
+
+        Different web clients send different line ending values, but
+        other systems (eg. email) don't necessarily handle those line
+        endings. Our solution is to convert all line endings to LF.
+    """
+    text = text.replace('\r\n', '\n')
+    return text.replace('\r', '\n')
+
+def rawToHyperdb(db, klass, itemid, propname, value,
+        pwre=re.compile(r'{(\w+)}(.+)')):
+    ''' Convert the raw (user-input) value to a hyperdb-storable value. The
+        value is for the "propname" property on itemid (may be None for a
+        new item) of "klass" in "db".
+
+        The value is usually a string, but in the case of multilink inputs
+        it may be either a list of strings or a string with comma-separated
+        values.
+    '''
+    properties = klass.getprops()
+
+    # ensure it's a valid property name
+    propname = propname.strip()
+    try:
+        proptype =  properties[propname]
+    except KeyError:
+        raise HyperdbValueError, '%r is not a property of %s'%(propname,
+            klass.classname)
+
+    # if we got a string, strip it now
+    if isinstance(value, type('')):
+        value = value.strip()
+
+    # convert the input value to a real property value
+    if isinstance(proptype, String):
+        # fix the CRLF/CR -> LF stuff
+        value = fixNewlines(value)
+    if isinstance(proptype, Password):
+        m = pwre.match(value)
+        if m:
+            # password is being given to us encrypted
+            p = password.Password()
+            p.scheme = m.group(1)
+            if p.scheme not in 'SHA crypt plaintext'.split():
+                raise HyperdbValueError, 'property %s: unknown encryption '\
+                    'scheme %r'%(propname, p.scheme)
+            p.password = m.group(2)
+            value = p
+        else:
+            try:
+                value = password.Password(value)
+            except password.PasswordValueError, message:
+                raise HyperdbValueError, 'property %s: %s'%(propname, message)
+    elif isinstance(proptype, Date):
+        try:
+            tz = db.getUserTimezone()
+            value = date.Date(value).local(tz)
+        except ValueError, message:
+            raise HyperdbValueError, 'property %s: %r is an invalid '\
+                'date (%s)'%(propname, value, message)
+    elif isinstance(proptype, Interval):
+        try:
+            value = date.Interval(value)
+        except ValueError, message:
+            raise HyperdbValueError, 'property %s: %r is an invalid '\
+                'date interval (%s)'%(propname, value, message)
+    elif isinstance(proptype, Link):
+        if value == '-1' or not value:
+            value = None
+        else:
+            value = convertLinkValue(db, propname, proptype, value)
+
+    elif isinstance(proptype, Multilink):
+        # get the current item value if it's not a new item
+        if itemid and not itemid.startswith('-'):
+            curvalue = klass.get(itemid, propname)
+        else:
+            curvalue = []
+
+        # if the value is a comma-separated string then split it now
+        if isinstance(value, type('')):
+            value = value.split(',')
+
+        # handle each add/remove in turn
+        # keep an extra list for all items that are
+        # definitely in the new list (in case of e.g.
+        # <propname>=A,+B, which should replace the old
+        # list with A,B)
+        set = 1
+        newvalue = []
+        for item in value:
+            item = item.strip()
+
+            # skip blanks
+            if not item: continue
+
+            # handle +/-
+            remove = 0
+            if item.startswith('-'):
+                remove = 1
+                item = item[1:]
+                set = 0
+            elif item.startswith('+'):
+                item = item[1:]
+                set = 0
+
+            # look up the value
+            itemid = convertLinkValue(db, propname, proptype, item)
+
+            # perform the add/remove
+            if remove:
+                try:
+                    curvalue.remove(itemid)
+                except ValueError:
+                    raise HyperdbValueError, 'property %s: %r is not ' \
+                        'currently an element'%(propname, item)
+            else:
+                newvalue.append(itemid)
+                if itemid not in curvalue:
+                    curvalue.append(itemid)
+
+        # that's it, set the new Multilink property value,
+        # or overwrite it completely
+        if set:
+            value = newvalue
+        else:
+            value = curvalue
+
+        # TODO: one day, we'll switch to numeric ids and this will be
+        # unnecessary :(
+        value = [int(x) for x in value]
+        value.sort()
+        value = [str(x) for x in value]
+    elif isinstance(proptype, Boolean):
+        value = value.strip()
+        value = value.lower() in ('yes', 'true', 'on', '1')
+    elif isinstance(proptype, Number):
+        value = value.strip()
+        try:
+            value = float(value)
+        except ValueError:
+            raise HyperdbValueError, 'property %s: %r is not a number'%(
+                propname, value)
+    return value
+
 class FileClass:
     ''' A class that requires the "content" property and stores it on
         disk.