Code

Uniformly use """...""" instead of '''...''' for comments.
[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 #
19 """Hyperdatabase implementation, especially field types.
20 """
21 __docformat__ = 'restructuredtext'
23 # standard python modules
24 import os, re, shutil, weakref
25 from sets import Set
27 # roundup modules
28 import date, password
29 from support import ensureParentsExist, PrioList, sorted, reversed
30 from roundup.i18n import _
32 #
33 # Types
34 #
35 class _Type(object):
36     """A roundup property type."""
37     def __init__(self, required=False):
38         self.required = required
39     def __repr__(self):
40         ' more useful for dumps '
41         return '<%s.%s>'%(self.__class__.__module__, self.__class__.__name__)
42     def sort_repr (self, cls, val, name):
43         """Representation used for sorting. This should be a python
44         built-in type, otherwise sorting will take ages. Note that
45         individual backends may chose to use something different for
46         sorting as long as the outcome is the same.
47         """
48         return val
50 class String(_Type):
51     """An object designating a String property."""
52     def __init__(self, indexme='no', required=False):
53         super(String, self).__init__(required)
54         self.indexme = indexme == 'yes'
55     def from_raw(self, value, propname='', **kw):
56         """fix the CRLF/CR -> LF stuff"""
57         if propname == 'content':
58             # Why oh why wasn't the FileClass content property a File
59             # type from the beginning?
60             return value
61         return fixNewlines(value)
62     def sort_repr (self, cls, val, name):
63         if not val:
64             return val
65         if name == 'id':
66             return int(val)
67         return val.lower()
69 class Password(_Type):
70     """An object designating a Password property."""
71     def from_raw(self, value, **kw):
72         if not value:
73             return None
74         m = password.Password.pwre.match(value)
75         if m:
76             # password is being given to us encrypted
77             p = password.Password()
78             p.scheme = m.group(1)
79             if p.scheme not in 'SHA crypt plaintext'.split():
80                 raise HyperdbValueError, \
81                         ('property %s: unknown encryption scheme %r') %\
82                         (kw['propname'], p.scheme)
83             p.password = m.group(2)
84             value = p
85         else:
86             try:
87                 value = password.Password(value)
88             except password.PasswordValueError, message:
89                 raise HyperdbValueError, \
90                         _('property %s: %s')%(kw['propname'], message)
91         return value
92     def sort_repr (self, cls, val, name):
93         if not val:
94             return val
95         return str(val)
97 class Date(_Type):
98     """An object designating a Date property."""
99     def __init__(self, offset=None, required=False):
100         super(Date, self).__init__(required)
101         self._offset = offset
102     def offset(self, db):
103         if self._offset is not None:
104             return self._offset
105         return db.getUserTimezone()
106     def from_raw(self, value, db, **kw):
107         try:
108             value = date.Date(value, self.offset(db))
109         except ValueError, message:
110             raise HyperdbValueError, _('property %s: %r is an invalid '\
111                 'date (%s)')%(kw['propname'], value, message)
112         return value
113     def range_from_raw(self, value, db):
114         """return Range value from given raw value with offset correction"""
115         return date.Range(value, date.Date, offset=self.offset(db))
116     def sort_repr (self, cls, val, name):
117         if not val:
118             return val
119         return str(val)
121 class Interval(_Type):
122     """An object designating an Interval property."""
123     def from_raw(self, value, **kw):
124         try:
125             value = date.Interval(value)
126         except ValueError, message:
127             raise HyperdbValueError, _('property %s: %r is an invalid '\
128                 'date interval (%s)')%(kw['propname'], value, message)
129         return value
130     def sort_repr (self, cls, val, name):
131         if not val:
132             return val
133         return val.as_seconds()
135 class _Pointer(_Type):
136     """An object designating a Pointer property that links or multilinks
137     to a node in a specified class."""
138     def __init__(self, classname, do_journal='yes', required=False):
139         """ Default is to journal link and unlink events
140         """
141         super(_Pointer, self).__init__(required)
142         self.classname = classname
143         self.do_journal = do_journal == 'yes'
144     def __repr__(self):
145         """more useful for dumps. But beware: This is also used in schema
146         storage in SQL backends!
147         """
148         return '<%s.%s to "%s">'%(self.__class__.__module__,
149             self.__class__.__name__, self.classname)
151 class Link(_Pointer):
152     """An object designating a Link property that links to a
153        node in a specified class."""
154     def from_raw(self, value, db, propname, **kw):
155         if value == '-1' or not value:
156             value = None
157         else:
158             value = convertLinkValue(db, propname, self, value)
159         return value
160     def sort_repr (self, cls, val, name):
161         if not val:
162             return val
163         op = cls.labelprop()
164         if op == 'id':
165             return int(cls.get(val, op))
166         return cls.get(val, op)
168 class Multilink(_Pointer):
169     """An object designating a Multilink property that links
170        to nodes in a specified class.
172        "classname" indicates the class to link to
174        "do_journal" indicates whether the linked-to nodes should have
175                     'link' and 'unlink' events placed in their journal
176     """
177     def from_raw(self, value, db, klass, propname, itemid, **kw):
178         if not value:
179             return []
181         # get the current item value if it's not a new item
182         if itemid and not itemid.startswith('-'):
183             curvalue = klass.get(itemid, propname)
184         else:
185             curvalue = []
187         # if the value is a comma-separated string then split it now
188         if isinstance(value, type('')):
189             value = value.split(',')
191         # handle each add/remove in turn
192         # keep an extra list for all items that are
193         # definitely in the new list (in case of e.g.
194         # <propname>=A,+B, which should replace the old
195         # list with A,B)
196         set = 1
197         newvalue = []
198         for item in value:
199             item = item.strip()
201             # skip blanks
202             if not item: continue
204             # handle +/-
205             remove = 0
206             if item.startswith('-'):
207                 remove = 1
208                 item = item[1:]
209                 set = 0
210             elif item.startswith('+'):
211                 item = item[1:]
212                 set = 0
214             # look up the value
215             itemid = convertLinkValue(db, propname, self, item)
217             # perform the add/remove
218             if remove:
219                 try:
220                     curvalue.remove(itemid)
221                 except ValueError:
222                     raise HyperdbValueError, _('property %s: %r is not ' \
223                         'currently an element')%(propname, item)
224             else:
225                 newvalue.append(itemid)
226                 if itemid not in curvalue:
227                     curvalue.append(itemid)
229         # that's it, set the new Multilink property value,
230         # or overwrite it completely
231         if set:
232             value = newvalue
233         else:
234             value = curvalue
236         # TODO: one day, we'll switch to numeric ids and this will be
237         # unnecessary :(
238         value = [int(x) for x in value]
239         value.sort()
240         value = [str(x) for x in value]
241         return value
243     def sort_repr (self, cls, val, name):
244         if not val:
245             return val
246         op = cls.labelprop()
247         if op == 'id':
248             return [int(cls.get(v, op)) for v in val]
249         return [cls.get(v, op) for v in val]
251 class Boolean(_Type):
252     """An object designating a boolean property"""
253     def from_raw(self, value, **kw):
254         value = value.strip()
255         # checked is a common HTML checkbox value
256         value = value.lower() in ('checked', 'yes', 'true', 'on', '1')
257         return value
259 class Number(_Type):
260     """An object designating a numeric property"""
261     def from_raw(self, value, **kw):
262         value = value.strip()
263         try:
264             value = float(value)
265         except ValueError:
266             raise HyperdbValueError, _('property %s: %r is not a number')%(
267                 kw['propname'], value)
268         return value
270 # Support for splitting designators
272 class DesignatorError(ValueError):
273     pass
274 def splitDesignator(designator, dre=re.compile(r'([^\d]+)(\d+)')):
275     """ Take a foo123 and return ('foo', 123)
276     """
277     m = dre.match(designator)
278     if m is None:
279         raise DesignatorError, _('"%s" not a node designator')%designator
280     return m.group(1), m.group(2)
282 class Proptree(object):
283     """ Simple tree data structure for optimizing searching of
284     properties. Each node in the tree represents a roundup Class
285     Property that has to be navigated for finding the given search
286     or sort properties. The sort_type attribute is used for
287     distinguishing nodes in the tree used for sorting or searching: If
288     it is 0 for a node, that node is not used for sorting. If it is 1,
289     it is used for both, sorting and searching. If it is 2 it is used
290     for sorting only.
292     The Proptree is also used for transitively searching attributes for
293     backends that do not support transitive search (e.g. anydbm). The
294     _val attribute with set_val is used for this.
295     """
297     def __init__(self, db, cls, name, props, parent = None):
298         self.db = db
299         self.name = name
300         self.props = props
301         self.parent = parent
302         self._val = None
303         self.has_values = False
304         self.cls = cls
305         self.classname = None
306         self.uniqname = None
307         self.children = []
308         self.sortattr = []
309         self.propdict = {}
310         self.sort_type = 0
311         self.sort_direction = None
312         self.sort_ids = None
313         self.sort_ids_needed = False
314         self.sort_result = None
315         self.attr_sort_done = False
316         self.tree_sort_done = False
317         self.propclass = None
318         self.orderby = []
319         if parent:
320             self.root = parent.root
321             self.depth = parent.depth + 1
322         else:
323             self.root = self
324             self.seqno = 1
325             self.depth = 0
326             self.sort_type = 1
327         self.id = self.root.seqno
328         self.root.seqno += 1
329         if self.cls:
330             self.classname = self.cls.classname
331             self.uniqname = '%s%s' % (self.cls.classname, self.id)
332         if not self.parent:
333             self.uniqname = self.cls.classname
335     def append(self, name, sort_type = 0):
336         """Append a property to self.children. Will create a new
337         propclass for the child.
338         """
339         if name in self.propdict:
340             pt = self.propdict[name]
341             if sort_type and not pt.sort_type:
342                 pt.sort_type = 1
343             return pt
344         propclass = self.props[name]
345         cls = None
346         props = None
347         if isinstance(propclass, (Link, Multilink)):
348             cls = self.db.getclass(propclass.classname)
349             props = cls.getprops()
350         child = self.__class__(self.db, cls, name, props, parent = self)
351         child.sort_type = sort_type
352         child.propclass = propclass
353         self.children.append(child)
354         self.propdict[name] = child
355         return child
357     def compute_sort_done(self, mlseen=False):
358         """ Recursively check if attribute is needed for sorting
359         (self.sort_type > 0) or all children have tree_sort_done set and
360         sort_ids_needed unset: set self.tree_sort_done if one of the conditions
361         holds. Also remove sort_ids_needed recursively once having seen a
362         Multilink.
363         """
364         if isinstance (self.propclass, Multilink):
365             mlseen = True
366         if mlseen:
367             self.sort_ids_needed = False
368         self.tree_sort_done = True
369         for p in self.children:
370             p.compute_sort_done(mlseen)
371             if not p.tree_sort_done:
372                 self.tree_sort_done = False
373         if not self.sort_type:
374             self.tree_sort_done = True
375         if mlseen:
376             self.tree_sort_done = False
378     def ancestors(self):
379         p = self
380         while p.parent:
381             yield p
382             p = p.parent
384     def search(self, search_matches=None, sort=True):
385         """ Recursively search for the given properties in a proptree.
386         Once all properties are non-transitive, the search generates a
387         simple _filter call which does the real work
388         """
389         filterspec = {}
390         for p in self.children:
391             if p.sort_type < 2:
392                 if p.children:
393                     p.search(sort = False)
394                 filterspec[p.name] = p.val
395         self.val = self.cls._filter(search_matches, filterspec, sort and self)
396         return self.val
398     def sort (self, ids=None):
399         """ Sort ids by the order information stored in self. With
400         optimisations: Some order attributes may be precomputed (by the
401         backend) and some properties may already be sorted.
402         """
403         if ids is None:
404             ids = self.val
405         if self.sortattr and [s for s in self.sortattr if not s.attr_sort_done]:
406             return self._searchsort(ids, True, True)
407         return ids
409     def sortable_children(self, intermediate=False):
410         """ All children needed for sorting. If intermediate is True,
411         intermediate nodes (not being a sort attribute) are returned,
412         too.
413         """
414         return [p for p in self.children
415                 if p.sort_type > 0 and (intermediate or p.sort_direction)]
417     def __iter__(self):
418         """ Yield nodes in depth-first order -- visited nodes first """
419         for p in self.children:
420             yield p
421             for c in p:
422                 yield c
424     def _get (self, ids):
425         """Lookup given ids -- possibly a list of list. We recurse until
426         we have a list of ids.
427         """
428         if not ids:
429             return ids
430         if isinstance (ids[0], list):
431             cids = [self._get(i) for i in ids]
432         else:
433             cids = [i and self.parent.cls.get(i, self.name) for i in ids]
434             if self.sortattr:
435                 cids = [self._searchsort(i, False, True) for i in cids]
436         return cids
438     def _searchsort(self, ids=None, update=True, dosort=True):
439         """ Recursively compute the sort attributes. Note that ids
440         may be a deeply nested list of lists of ids if several
441         multilinks are encountered on the way from the root to an
442         individual attribute. We make sure that everything is properly
443         sorted on the way up. Note that the individual backend may
444         already have precomputed self.result or self.sort_ids. In this
445         case we do nothing for existing sa.result and recurse further if
446         self.sort_ids is available.
448         Yech, Multilinks: This gets especially complicated if somebody
449         sorts by different attributes of the same multilink (or
450         transitively across several multilinks). My use-case is sorting
451         by issue.messages.author and (reverse) by issue.messages.date.
452         In this case we sort the messages by author and date and use
453         this sorted list twice for sorting issues. This means that
454         issues are sorted by author and then by the time of the messages
455         *of this author*. Probably what the user intends in that case,
456         so we do *not* use two sorted lists of messages, one sorted by
457         author and one sorted by date for sorting issues.
458         """
459         for pt in self.sortable_children(intermediate = True):
460             # ids can be an empty list
461             if pt.tree_sort_done or not ids:
462                 continue
463             if pt.sort_ids: # cached or computed by backend
464                 cids = pt.sort_ids
465             else:
466                 cids = pt._get(ids)
467             if pt.sort_direction and not pt.sort_result:
468                 sortrep = pt.propclass.sort_repr
469                 pt.sort_result = pt._sort_repr(sortrep, cids)
470             pt.sort_ids = cids
471             if pt.children:
472                 pt._searchsort(cids, update, False)
473         if self.sortattr and dosort:
474             ids = self._sort(ids)
475         if not update:
476             for pt in self.sortable_children(intermediate = True):
477                 pt.sort_ids = None
478             for pt in self.sortattr:
479                 pt.sort_result = None
480         return ids
482     def _set_val(self, val):
483         """Check if self._val is already defined. If yes, we compute the
484         intersection of the old and the new value(s)
485         """
486         if self.has_values:
487             v = self._val
488             if not isinstance(self._val, type([])):
489                 v = [self._val]
490             vals = Set(v)
491             vals.intersection_update(val)
492             self._val = [v for v in vals]
493         else:
494             self._val = val
495         self.has_values = True
497     val = property(lambda self: self._val, _set_val)
499     def _sort(self, val):
500         """Finally sort by the given sortattr.sort_result. Note that we
501         do not sort by attrs having attr_sort_done set. The caller is
502         responsible for setting attr_sort_done only for trailing
503         attributes (otherwise the sort order is wrong). Since pythons
504         sort is stable, we can sort already sorted lists without
505         destroying the sort-order for items that compare equal with the
506         current sort.
508         Sorting-Strategy: We sort repeatedly by different sort-keys from
509         right to left. Since pythons sort is stable, we can safely do
510         that. An optimisation is a "run-length encoding" of the
511         sort-directions: If several sort attributes sort in the same
512         direction we can combine them into a single sort. Note that
513         repeated sorting is probably more efficient than using
514         compare-methods in python due to the overhead added by compare
515         methods.
516         """
517         if not val:
518             return val
519         sortattr = []
520         directions = []
521         dir_idx = []
522         idx = 0
523         curdir = None
524         for sa in self.sortattr:
525             if sa.attr_sort_done:
526                 break
527             if sortattr:
528                 assert len(sortattr[0]) == len(sa.sort_result)
529             sortattr.append (sa.sort_result)
530             if curdir != sa.sort_direction:
531                 dir_idx.append (idx)
532                 directions.append (sa.sort_direction)
533                 curdir = sa.sort_direction
534             idx += 1
535         sortattr.append (val)
536         #print >> sys.stderr, "\nsortattr", sortattr
537         sortattr = zip (*sortattr)
538         for dir, i in reversed(zip(directions, dir_idx)):
539             rev = dir == '-'
540             sortattr = sorted (sortattr, key = lambda x:x[i:idx], reverse = rev)
541             idx = i
542         return [x[-1] for x in sortattr]
544     def _sort_repr(self, sortrep, ids):
545         """Call sortrep for given ids -- possibly a list of list. We
546         recurse until we have a list of ids.
547         """
548         if not ids:
549             return ids
550         if isinstance (ids[0], list):
551             res = [self._sort_repr(sortrep, i) for i in ids]
552         else:
553             res = [sortrep(self.cls, i, self.name) for i in ids]
554         return res
556     def __repr__(self):
557         r = ["proptree:" + self.name]
558         for n in self:
559             r.append("proptree:" + "    " * n.depth + n.name)
560         return '\n'.join(r)
561     __str__ = __repr__
564 # the base Database class
566 class DatabaseError(ValueError):
567     """Error to be raised when there is some problem in the database code
568     """
569     pass
570 class Database:
571     """A database for storing records containing flexible data types.
573 This class defines a hyperdatabase storage layer, which the Classes use to
574 store their data.
577 Transactions
578 ------------
579 The Database should support transactions through the commit() and
580 rollback() methods. All other Database methods should be transaction-aware,
581 using data from the current transaction before looking up the database.
583 An implementation must provide an override for the get() method so that the
584 in-database value is returned in preference to the in-transaction value.
585 This is necessary to determine if any values have changed during a
586 transaction.
589 Implementation
590 --------------
592 All methods except __repr__ must be implemented by a concrete backend Database.
594 """
596     # flag to set on retired entries
597     RETIRED_FLAG = '__hyperdb_retired'
599     BACKEND_MISSING_STRING = None
600     BACKEND_MISSING_NUMBER = None
601     BACKEND_MISSING_BOOLEAN = None
603     def __init__(self, config, journaltag=None):
604         """Open a hyperdatabase given a specifier to some storage.
606         The 'storagelocator' is obtained from config.DATABASE.
607         The meaning of 'storagelocator' depends on the particular
608         implementation of the hyperdatabase.  It could be a file name,
609         a directory path, a socket descriptor for a connection to a
610         database over the network, etc.
612         The 'journaltag' is a token that will be attached to the journal
613         entries for any edits done on the database.  If 'journaltag' is
614         None, the database is opened in read-only mode: the Class.create(),
615         Class.set(), and Class.retire() methods are disabled.
616         """
617         raise NotImplementedError
619     def post_init(self):
620         """Called once the schema initialisation has finished.
621            If 'refresh' is true, we want to rebuild the backend
622            structures.
623         """
624         raise NotImplementedError
626     def refresh_database(self):
627         """Called to indicate that the backend should rebuild all tables
628            and structures. Not called in normal usage."""
629         raise NotImplementedError
631     def __getattr__(self, classname):
632         """A convenient way of calling self.getclass(classname)."""
633         raise NotImplementedError
635     def addclass(self, cl):
636         """Add a Class to the hyperdatabase.
637         """
638         raise NotImplementedError
640     def getclasses(self):
641         """Return a list of the names of all existing classes."""
642         raise NotImplementedError
644     def getclass(self, classname):
645         """Get the Class object representing a particular class.
647         If 'classname' is not a valid class name, a KeyError is raised.
648         """
649         raise NotImplementedError
651     def clear(self):
652         """Delete all database contents.
653         """
654         raise NotImplementedError
656     def getclassdb(self, classname, mode='r'):
657         """Obtain a connection to the class db that will be used for
658            multiple actions.
659         """
660         raise NotImplementedError
662     def addnode(self, classname, nodeid, node):
663         """Add the specified node to its class's db.
664         """
665         raise NotImplementedError
667     def serialise(self, classname, node):
668         """Copy the node contents, converting non-marshallable data into
669            marshallable data.
670         """
671         return node
673     def setnode(self, classname, nodeid, node):
674         """Change the specified node.
675         """
676         raise NotImplementedError
678     def unserialise(self, classname, node):
679         """Decode the marshalled node data
680         """
681         return node
683     def getnode(self, classname, nodeid):
684         """Get a node from the database.
686         'cache' exists for backwards compatibility, and is not used.
687         """
688         raise NotImplementedError
690     def hasnode(self, classname, nodeid):
691         """Determine if the database has a given node.
692         """
693         raise NotImplementedError
695     def countnodes(self, classname):
696         """Count the number of nodes that exist for a particular Class.
697         """
698         raise NotImplementedError
700     def storefile(self, classname, nodeid, property, content):
701         """Store the content of the file in the database.
703            The property may be None, in which case the filename does not
704            indicate which property is being saved.
705         """
706         raise NotImplementedError
708     def getfile(self, classname, nodeid, property):
709         """Get the content of the file in the database.
710         """
711         raise NotImplementedError
713     def addjournal(self, classname, nodeid, action, params):
714         """ Journal the Action
715         'action' may be:
717             'create' or 'set' -- 'params' is a dictionary of property values
718             'link' or 'unlink' -- 'params' is (classname, nodeid, propname)
719             'retire' -- 'params' is None
720         """
721         raise NotImplementedError
723     def getjournal(self, classname, nodeid):
724         """ get the journal for id
725         """
726         raise NotImplementedError
728     def pack(self, pack_before):
729         """ pack the database
730         """
731         raise NotImplementedError
733     def commit(self):
734         """ Commit the current transactions.
736         Save all data changed since the database was opened or since the
737         last commit() or rollback().
739         fail_ok indicates that the commit is allowed to fail. This is used
740         in the web interface when committing cleaning of the session
741         database. We don't care if there's a concurrency issue there.
743         The only backend this seems to affect is postgres.
744         """
745         raise NotImplementedError
747     def rollback(self):
748         """ Reverse all actions from the current transaction.
750         Undo all the changes made since the database was opened or the last
751         commit() or rollback() was performed.
752         """
753         raise NotImplementedError
755     def close(self):
756         """Close the database.
758         This method must be called at the end of processing.
760         """
763 # The base Class class
765 class Class:
766     """ The handle to a particular class of nodes in a hyperdatabase.
768         All methods except __repr__ and getnode must be implemented by a
769         concrete backend Class.
770     """
772     def __init__(self, db, classname, **properties):
773         """Create a new class with a given name and property specification.
775         'classname' must not collide with the name of an existing class,
776         or a ValueError is raised.  The keyword arguments in 'properties'
777         must map names to property objects, or a TypeError is raised.
778         """
779         for name in 'creation activity creator actor'.split():
780             if properties.has_key(name):
781                 raise ValueError, '"creation", "activity", "creator" and '\
782                     '"actor" are reserved'
784         self.classname = classname
785         self.properties = properties
786         self.db = weakref.proxy(db)       # use a weak ref to avoid circularity
787         self.key = ''
789         # should we journal changes (default yes)
790         self.do_journal = 1
792         # do the db-related init stuff
793         db.addclass(self)
795         actions = "create set retire restore".split()
796         self.auditors = dict([(a, PrioList()) for a in actions])
797         self.reactors = dict([(a, PrioList()) for a in actions])
799     def __repr__(self):
800         """Slightly more useful representation
801         """
802         return '<hyperdb.Class "%s">'%self.classname
804     # Editing nodes:
806     def create(self, **propvalues):
807         """Create a new node of this class and return its id.
809         The keyword arguments in 'propvalues' map property names to values.
811         The values of arguments must be acceptable for the types of their
812         corresponding properties or a TypeError is raised.
814         If this class has a key property, it must be present and its value
815         must not collide with other key strings or a ValueError is raised.
817         Any other properties on this class that are missing from the
818         'propvalues' dictionary are set to None.
820         If an id in a link or multilink property does not refer to a valid
821         node, an IndexError is raised.
822         """
823         raise NotImplementedError
825     _marker = []
826     def get(self, nodeid, propname, default=_marker, cache=1):
827         """Get the value of a property on an existing node of this class.
829         'nodeid' must be the id of an existing node of this class or an
830         IndexError is raised.  'propname' must be the name of a property
831         of this class or a KeyError is raised.
833         'cache' exists for backwards compatibility, and is not used.
834         """
835         raise NotImplementedError
837     # not in spec
838     def getnode(self, nodeid):
839         """ Return a convenience wrapper for the node.
841         'nodeid' must be the id of an existing node of this class or an
842         IndexError is raised.
844         'cache' exists for backwards compatibility, and is not used.
845         """
846         return Node(self, nodeid)
848     def getnodeids(self, retired=None):
849         """Retrieve all the ids of the nodes for a particular Class.
850         """
851         raise NotImplementedError
853     def set(self, nodeid, **propvalues):
854         """Modify a property on an existing node of this class.
856         'nodeid' must be the id of an existing node of this class or an
857         IndexError is raised.
859         Each key in 'propvalues' must be the name of a property of this
860         class or a KeyError is raised.
862         All values in 'propvalues' must be acceptable types for their
863         corresponding properties or a TypeError is raised.
865         If the value of the key property is set, it must not collide with
866         other key strings or a ValueError is raised.
868         If the value of a Link or Multilink property contains an invalid
869         node id, a ValueError is raised.
870         """
871         raise NotImplementedError
873     def retire(self, nodeid):
874         """Retire a node.
876         The properties on the node remain available from the get() method,
877         and the node's id is never reused.
879         Retired nodes are not returned by the find(), list(), or lookup()
880         methods, and other nodes may reuse the values of their key properties.
881         """
882         raise NotImplementedError
884     def restore(self, nodeid):
885         """Restpre a retired node.
887         Make node available for all operations like it was before retirement.
888         """
889         raise NotImplementedError
891     def is_retired(self, nodeid):
892         """Return true if the node is rerired
893         """
894         raise NotImplementedError
896     def destroy(self, nodeid):
897         """Destroy a node.
899         WARNING: this method should never be used except in extremely rare
900                  situations where there could never be links to the node being
901                  deleted
903         WARNING: use retire() instead
905         WARNING: the properties of this node will not be available ever again
907         WARNING: really, use retire() instead
909         Well, I think that's enough warnings. This method exists mostly to
910         support the session storage of the cgi interface.
912         The node is completely removed from the hyperdb, including all journal
913         entries. It will no longer be available, and will generally break code
914         if there are any references to the node.
915         """
917     def history(self, nodeid):
918         """Retrieve the journal of edits on a particular node.
920         'nodeid' must be the id of an existing node of this class or an
921         IndexError is raised.
923         The returned list contains tuples of the form
925             (date, tag, action, params)
927         'date' is a Timestamp object specifying the time of the change and
928         'tag' is the journaltag specified when the database was opened.
929         """
930         raise NotImplementedError
932     # Locating nodes:
933     def hasnode(self, nodeid):
934         """Determine if the given nodeid actually exists
935         """
936         raise NotImplementedError
938     def setkey(self, propname):
939         """Select a String property of this class to be the key property.
941         'propname' must be the name of a String property of this class or
942         None, or a TypeError is raised.  The values of the key property on
943         all existing nodes must be unique or a ValueError is raised.
944         """
945         raise NotImplementedError
947     def setlabelprop(self, labelprop):
948         """Set the label property. Used for override of labelprop
949            resolution order.
950         """
951         if labelprop not in self.getprops():
952             raise ValueError, _("Not a property name: %s") % labelprop
953         self._labelprop = labelprop
955     def setorderprop(self, orderprop):
956         """Set the order property. Used for override of orderprop
957            resolution order
958         """
959         if orderprop not in self.getprops():
960             raise ValueError, _("Not a property name: %s") % orderprop
961         self._orderprop = orderprop
963     def getkey(self):
964         """Return the name of the key property for this class or None."""
965         raise NotImplementedError
967     def labelprop(self, default_to_id=0):
968         """Return the property name for a label for the given node.
970         This method attempts to generate a consistent label for the node.
971         It tries the following in order:
973         0. self._labelprop if set
974         1. key property
975         2. "name" property
976         3. "title" property
977         4. first property from the sorted property name list
978         """
979         if hasattr(self, '_labelprop'):
980             return self._labelprop
981         k = self.getkey()
982         if  k:
983             return k
984         props = self.getprops()
985         if props.has_key('name'):
986             return 'name'
987         elif props.has_key('title'):
988             return 'title'
989         if default_to_id:
990             return 'id'
991         props = props.keys()
992         props.sort()
993         return props[0]
995     def orderprop(self):
996         """Return the property name to use for sorting for the given node.
998         This method computes the property for sorting.
999         It tries the following in order:
1001         0. self._orderprop if set
1002         1. "order" property
1003         2. self.labelprop()
1004         """
1006         if hasattr(self, '_orderprop'):
1007             return self._orderprop
1008         props = self.getprops()
1009         if props.has_key('order'):
1010             return 'order'
1011         return self.labelprop()
1013     def lookup(self, keyvalue):
1014         """Locate a particular node by its key property and return its id.
1016         If this class has no key property, a TypeError is raised.  If the
1017         'keyvalue' matches one of the values for the key property among
1018         the nodes in this class, the matching node's id is returned;
1019         otherwise a KeyError is raised.
1020         """
1021         raise NotImplementedError
1023     def find(self, **propspec):
1024         """Get the ids of nodes in this class which link to the given nodes.
1026         'propspec' consists of keyword args propname={nodeid:1,}
1027         'propname' must be the name of a property in this class, or a
1028         KeyError is raised.  That property must be a Link or Multilink
1029         property, or a TypeError is raised.
1031         Any node in this class whose 'propname' property links to any of the
1032         nodeids will be returned. Used by the full text indexing, which knows
1033         that "foo" occurs in msg1, msg3 and file7, so we have hits on these
1034         issues:
1036             db.issue.find(messages={'1':1,'3':1}, files={'7':1})
1037         """
1038         raise NotImplementedError
1040     def _filter(self, search_matches, filterspec, sort=(None,None),
1041             group=(None,None)):
1042         """For some backends this implements the non-transitive
1043         search, for more information see the filter method.
1044         """
1045         raise NotImplementedError
1047     def _proptree(self, filterspec, sortattr=[]):
1048         """Build a tree of all transitive properties in the given
1049         filterspec.
1050         """
1051         proptree = Proptree(self.db, self, '', self.getprops())
1052         for key, v in filterspec.iteritems():
1053             keys = key.split('.')
1054             p = proptree
1055             for k in keys:
1056                 p = p.append(k)
1057             p.val = v
1058         multilinks = {}
1059         for s in sortattr:
1060             keys = s[1].split('.')
1061             p = proptree
1062             for k in keys:
1063                 p = p.append(k, sort_type = 2)
1064                 if isinstance (p.propclass, Multilink):
1065                     multilinks[p] = True
1066             if p.cls:
1067                 p = p.append(p.cls.orderprop(), sort_type = 2)
1068             if p.sort_direction: # if an orderprop is also specified explicitly
1069                 continue
1070             p.sort_direction = s[0]
1071             proptree.sortattr.append (p)
1072         for p in multilinks.iterkeys():
1073             sattr = {}
1074             for c in p:
1075                 if c.sort_direction:
1076                     sattr [c] = True
1077             for sa in proptree.sortattr:
1078                 if sa in sattr:
1079                     p.sortattr.append (sa)
1080         return proptree
1082     def get_transitive_prop(self, propname_path, default = None):
1083         """Expand a transitive property (individual property names
1084         separated by '.' into a new property at the end of the path. If
1085         one of the names does not refer to a valid property, we return
1086         None.
1087         Example propname_path (for class issue): "messages.author"
1088         """
1089         props = self.db.getclass(self.classname).getprops()
1090         for k in propname_path.split('.'):
1091             try:
1092                 prop = props[k]
1093             except KeyError, TypeError:
1094                 return default
1095             cl = getattr(prop, 'classname', None)
1096             props = None
1097             if cl:
1098                 props = self.db.getclass(cl).getprops()
1099         return prop
1101     def _sortattr(self, sort=[], group=[]):
1102         """Build a single list of sort attributes in the correct order
1103         with sanity checks (no duplicate properties) included. Always
1104         sort last by id -- if id is not already in sortattr.
1105         """
1106         seen = {}
1107         sortattr = []
1108         for srt in group, sort:
1109             if not isinstance(srt, list):
1110                 srt = [srt]
1111             for s in srt:
1112                 if s[1] and s[1] not in seen:
1113                     sortattr.append((s[0] or '+', s[1]))
1114                     seen[s[1]] = True
1115         if 'id' not in seen :
1116             sortattr.append(('+', 'id'))
1117         return sortattr
1119     def filter(self, search_matches, filterspec, sort=[], group=[]):
1120         """Return a list of the ids of the active nodes in this class that
1121         match the 'filter' spec, sorted by the group spec and then the
1122         sort spec.
1124         "filterspec" is {propname: value(s)}
1126         "sort" and "group" are [(dir, prop), ...] where dir is '+', '-'
1127         or None and prop is a prop name or None. Note that for
1128         backward-compatibility reasons a single (dir, prop) tuple is
1129         also allowed.
1131         "search_matches" is a container type
1133         The filter must match all properties specificed. If the property
1134         value to match is a list:
1136         1. String properties must match all elements in the list, and
1137         2. Other properties must match any of the elements in the list.
1139         Note that now the propname in filterspec and prop in a
1140         sort/group spec may be transitive, i.e., it may contain
1141         properties of the form link.link.link.name, e.g. you can search
1142         for all issues where a message was added by a certain user in
1143         the last week with a filterspec of
1144         {'messages.author' : '42', 'messages.creation' : '.-1w;'}
1146         Implementation note:
1147         This implements a non-optimized version of Transitive search
1148         using _filter implemented in a backend class. A more efficient
1149         version can be implemented in the individual backends -- e.g.,
1150         an SQL backen will want to create a single SQL statement and
1151         override the filter method instead of implementing _filter.
1152         """
1153         sortattr = self._sortattr(sort = sort, group = group)
1154         proptree = self._proptree(filterspec, sortattr)
1155         proptree.search(search_matches)
1156         return proptree.sort()
1158     def count(self):
1159         """Get the number of nodes in this class.
1161         If the returned integer is 'numnodes', the ids of all the nodes
1162         in this class run from 1 to numnodes, and numnodes+1 will be the
1163         id of the next node to be created in this class.
1164         """
1165         raise NotImplementedError
1167     # Manipulating properties:
1168     def getprops(self, protected=1):
1169         """Return a dictionary mapping property names to property objects.
1170            If the "protected" flag is true, we include protected properties -
1171            those which may not be modified.
1172         """
1173         raise NotImplementedError
1175     def get_required_props(self, propnames = []):
1176         """Return a dict of property names mapping to property objects.
1177         All properties that have the "required" flag set will be
1178         returned in addition to all properties in the propnames
1179         parameter.
1180         """
1181         props = self.getprops(protected = False)
1182         pdict = dict([(p, props[p]) for p in propnames])
1183         pdict.update([(k, v) for k, v in props.iteritems() if v.required])
1184         return pdict
1186     def addprop(self, **properties):
1187         """Add properties to this class.
1189         The keyword arguments in 'properties' must map names to property
1190         objects, or a TypeError is raised.  None of the keys in 'properties'
1191         may collide with the names of existing properties, or a ValueError
1192         is raised before any properties have been added.
1193         """
1194         raise NotImplementedError
1196     def index(self, nodeid):
1197         """Add (or refresh) the node to search indexes"""
1198         raise NotImplementedError
1200     #
1201     # Detector interface
1202     #
1203     def audit(self, event, detector, priority = 100):
1204         """Register an auditor detector"""
1205         self.auditors[event].append((priority, detector.__name__, detector))
1207     def fireAuditors(self, event, nodeid, newvalues):
1208         """Fire all registered auditors"""
1209         for prio, name, audit in self.auditors[event]:
1210             audit(self.db, self, nodeid, newvalues)
1212     def react(self, event, detector, priority = 100):
1213         """Register a reactor detector"""
1214         self.reactors[event].append((priority, detector.__name__, detector))
1216     def fireReactors(self, event, nodeid, oldvalues):
1217         """Fire all registered reactors"""
1218         for prio, name, react in self.reactors[event]:
1219             react(self.db, self, nodeid, oldvalues)
1221     #
1222     # import / export support
1223     #
1224     def export_propnames(self):
1225         """List the property names for export from this Class"""
1226         propnames = self.getprops().keys()
1227         propnames.sort()
1228         return propnames
1231 class HyperdbValueError(ValueError):
1232     """ Error converting a raw value into a Hyperdb value """
1233     pass
1235 def convertLinkValue(db, propname, prop, value, idre=re.compile('^\d+$')):
1236     """ Convert the link value (may be id or key value) to an id value. """
1237     linkcl = db.classes[prop.classname]
1238     if not idre.match(value):
1239         if linkcl.getkey():
1240             try:
1241                 value = linkcl.lookup(value)
1242             except KeyError, message:
1243                 raise HyperdbValueError, _('property %s: %r is not a %s.')%(
1244                     propname, value, prop.classname)
1245         else:
1246             raise HyperdbValueError, _('you may only enter ID values '\
1247                 'for property %s')%propname
1248     return value
1250 def fixNewlines(text):
1251     """ Homogenise line endings.
1253         Different web clients send different line ending values, but
1254         other systems (eg. email) don't necessarily handle those line
1255         endings. Our solution is to convert all line endings to LF.
1256     """
1257     text = text.replace('\r\n', '\n')
1258     return text.replace('\r', '\n')
1260 def rawToHyperdb(db, klass, itemid, propname, value, **kw):
1261     """ Convert the raw (user-input) value to a hyperdb-storable value. The
1262         value is for the "propname" property on itemid (may be None for a
1263         new item) of "klass" in "db".
1265         The value is usually a string, but in the case of multilink inputs
1266         it may be either a list of strings or a string with comma-separated
1267         values.
1268     """
1269     properties = klass.getprops()
1271     # ensure it's a valid property name
1272     propname = propname.strip()
1273     try:
1274         proptype =  properties[propname]
1275     except KeyError:
1276         raise HyperdbValueError, _('%r is not a property of %s')%(propname,
1277             klass.classname)
1279     # if we got a string, strip it now
1280     if isinstance(value, type('')):
1281         value = value.strip()
1283     # convert the input value to a real property value
1284     value = proptype.from_raw(value, db=db, klass=klass,
1285         propname=propname, itemid=itemid, **kw)
1287     return value
1289 class FileClass:
1290     """ A class that requires the "content" property and stores it on
1291         disk.
1292     """
1293     default_mime_type = 'text/plain'
1295     def __init__(self, db, classname, **properties):
1296         """The newly-created class automatically includes the "content"
1297         property.
1298         """
1299         if not properties.has_key('content'):
1300             properties['content'] = String(indexme='yes')
1302     def export_propnames(self):
1303         """ Don't export the "content" property
1304         """
1305         propnames = self.getprops().keys()
1306         propnames.remove('content')
1307         propnames.sort()
1308         return propnames
1310     def exportFilename(self, dirname, nodeid):
1311         subdir_filename = self.db.subdirFilename(self.classname, nodeid)
1312         return os.path.join(dirname, self.classname+'-files', subdir_filename)
1314     def export_files(self, dirname, nodeid):
1315         """ Export the "content" property as a file, not csv column
1316         """
1317         source = self.db.filename(self.classname, nodeid)
1319         dest = self.exportFilename(dirname, nodeid)
1320         ensureParentsExist(dest)
1321         shutil.copyfile(source, dest)
1323     def import_files(self, dirname, nodeid):
1324         """ Import the "content" property as a file
1325         """
1326         source = self.exportFilename(dirname, nodeid)
1328         dest = self.db.filename(self.classname, nodeid, create=1)
1329         ensureParentsExist(dest)
1330         shutil.copyfile(source, dest)
1332         mime_type = None
1333         props = self.getprops()
1334         if props.has_key('type'):
1335             mime_type = self.get(nodeid, 'type')
1336         if not mime_type:
1337             mime_type = self.default_mime_type
1338         if props['content'].indexme:
1339             self.db.indexer.add_text((self.classname, nodeid, 'content'),
1340                 self.get(nodeid, 'content'), mime_type)
1342 class Node:
1343     """ A convenience wrapper for the given node
1344     """
1345     def __init__(self, cl, nodeid, cache=1):
1346         self.__dict__['cl'] = cl
1347         self.__dict__['nodeid'] = nodeid
1348     def keys(self, protected=1):
1349         return self.cl.getprops(protected=protected).keys()
1350     def values(self, protected=1):
1351         l = []
1352         for name in self.cl.getprops(protected=protected).keys():
1353             l.append(self.cl.get(self.nodeid, name))
1354         return l
1355     def items(self, protected=1):
1356         l = []
1357         for name in self.cl.getprops(protected=protected).keys():
1358             l.append((name, self.cl.get(self.nodeid, name)))
1359         return l
1360     def has_key(self, name):
1361         return self.cl.getprops().has_key(name)
1362     def get(self, name, default=None):
1363         if self.has_key(name):
1364             return self[name]
1365         else:
1366             return default
1367     def __getattr__(self, name):
1368         if self.__dict__.has_key(name):
1369             return self.__dict__[name]
1370         try:
1371             return self.cl.get(self.nodeid, name)
1372         except KeyError, value:
1373             # we trap this but re-raise it as AttributeError - all other
1374             # exceptions should pass through untrapped
1375             pass
1376         # nope, no such attribute
1377         raise AttributeError, str(value)
1378     def __getitem__(self, name):
1379         return self.cl.get(self.nodeid, name)
1380     def __setattr__(self, name, value):
1381         try:
1382             return self.cl.set(self.nodeid, **{name: value})
1383         except KeyError, value:
1384             raise AttributeError, str(value)
1385     def __setitem__(self, name, value):
1386         self.cl.set(self.nodeid, **{name: value})
1387     def history(self):
1388         return self.cl.history(self.nodeid)
1389     def retire(self):
1390         return self.cl.retire(self.nodeid)
1393 def Choice(name, db, *options):
1394     """Quick helper to create a simple class with choices
1395     """
1396     cl = Class(db, name, name=String(), order=String())
1397     for i in range(len(options)):
1398         cl.create(name=options[i], order=i)
1399     return Link(name)
1401 # vim: set filetype=python sts=4 sw=4 et si :