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
269 #
270 # Support for splitting designators
271 #
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__
563 #
564 # the base Database class
565 #
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 """
762 #
763 # The base Class class
764 #
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 :