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 # Python 2.3 ... 2.6 compatibility:
26 from roundup.anypy.sets_ import set
28 # roundup modules
29 import date, password
30 from support import ensureParentsExist, PrioList, sorted, reversed
31 from roundup.i18n import _
33 #
34 # Types
35 #
36 class _Type(object):
37 """A roundup property type."""
38 def __init__(self, required=False):
39 self.required = required
40 def __repr__(self):
41 ' more useful for dumps '
42 return '<%s.%s>'%(self.__class__.__module__, self.__class__.__name__)
43 def sort_repr (self, cls, val, name):
44 """Representation used for sorting. This should be a python
45 built-in type, otherwise sorting will take ages. Note that
46 individual backends may chose to use something different for
47 sorting as long as the outcome is the same.
48 """
49 return val
51 class String(_Type):
52 """An object designating a String property."""
53 def __init__(self, indexme='no', required=False):
54 super(String, self).__init__(required)
55 self.indexme = indexme == 'yes'
56 def from_raw(self, value, propname='', **kw):
57 """fix the CRLF/CR -> LF stuff"""
58 if propname == 'content':
59 # Why oh why wasn't the FileClass content property a File
60 # type from the beginning?
61 return value
62 return fixNewlines(value)
63 def sort_repr (self, cls, val, name):
64 if not val:
65 return val
66 if name == 'id':
67 return int(val)
68 return val.lower()
70 class Password(_Type):
71 """An object designating a Password property."""
72 def from_raw(self, value, **kw):
73 if not value:
74 return None
75 try:
76 return password.Password(encrypted=value, strict=True)
77 except password.PasswordValueError, message:
78 raise HyperdbValueError, \
79 _('property %s: %s')%(kw['propname'], message)
81 def sort_repr (self, cls, val, name):
82 if not val:
83 return val
84 return str(val)
86 class Date(_Type):
87 """An object designating a Date property."""
88 def __init__(self, offset=None, required=False):
89 super(Date, self).__init__(required)
90 self._offset = offset
91 def offset(self, db):
92 if self._offset is not None:
93 return self._offset
94 return db.getUserTimezone()
95 def from_raw(self, value, db, **kw):
96 try:
97 value = date.Date(value, self.offset(db))
98 except ValueError, message:
99 raise HyperdbValueError, _('property %s: %r is an invalid '\
100 'date (%s)')%(kw['propname'], value, message)
101 return value
102 def range_from_raw(self, value, db):
103 """return Range value from given raw value with offset correction"""
104 return date.Range(value, date.Date, offset=self.offset(db))
105 def sort_repr (self, cls, val, name):
106 if not val:
107 return val
108 return str(val)
110 class Interval(_Type):
111 """An object designating an Interval property."""
112 def from_raw(self, value, **kw):
113 try:
114 value = date.Interval(value)
115 except ValueError, message:
116 raise HyperdbValueError, _('property %s: %r is an invalid '\
117 'date interval (%s)')%(kw['propname'], value, message)
118 return value
119 def sort_repr (self, cls, val, name):
120 if not val:
121 return val
122 return val.as_seconds()
124 class _Pointer(_Type):
125 """An object designating a Pointer property that links or multilinks
126 to a node in a specified class."""
127 def __init__(self, classname, do_journal='yes', required=False):
128 """ Default is to journal link and unlink events
129 """
130 super(_Pointer, self).__init__(required)
131 self.classname = classname
132 self.do_journal = do_journal == 'yes'
133 def __repr__(self):
134 """more useful for dumps. But beware: This is also used in schema
135 storage in SQL backends!
136 """
137 return '<%s.%s to "%s">'%(self.__class__.__module__,
138 self.__class__.__name__, self.classname)
140 class Link(_Pointer):
141 """An object designating a Link property that links to a
142 node in a specified class."""
143 def from_raw(self, value, db, propname, **kw):
144 if value == '-1' or not value:
145 value = None
146 else:
147 value = convertLinkValue(db, propname, self, value)
148 return value
149 def sort_repr (self, cls, val, name):
150 if not val:
151 return val
152 op = cls.labelprop()
153 if op == 'id':
154 return int(cls.get(val, op))
155 return cls.get(val, op)
157 class Multilink(_Pointer):
158 """An object designating a Multilink property that links
159 to nodes in a specified class.
161 "classname" indicates the class to link to
163 "do_journal" indicates whether the linked-to nodes should have
164 'link' and 'unlink' events placed in their journal
165 """
166 def from_raw(self, value, db, klass, propname, itemid, **kw):
167 if not value:
168 return []
170 # get the current item value if it's not a new item
171 if itemid and not itemid.startswith('-'):
172 curvalue = klass.get(itemid, propname)
173 else:
174 curvalue = []
176 # if the value is a comma-separated string then split it now
177 if isinstance(value, type('')):
178 value = value.split(',')
180 # handle each add/remove in turn
181 # keep an extra list for all items that are
182 # definitely in the new list (in case of e.g.
183 # <propname>=A,+B, which should replace the old
184 # list with A,B)
185 do_set = 1
186 newvalue = []
187 for item in value:
188 item = item.strip()
190 # skip blanks
191 if not item: continue
193 # handle +/-
194 remove = 0
195 if item.startswith('-'):
196 remove = 1
197 item = item[1:]
198 do_set = 0
199 elif item.startswith('+'):
200 item = item[1:]
201 do_set = 0
203 # look up the value
204 itemid = convertLinkValue(db, propname, self, item)
206 # perform the add/remove
207 if remove:
208 try:
209 curvalue.remove(itemid)
210 except ValueError:
211 raise HyperdbValueError, _('property %s: %r is not ' \
212 'currently an element')%(propname, item)
213 else:
214 newvalue.append(itemid)
215 if itemid not in curvalue:
216 curvalue.append(itemid)
218 # that's it, set the new Multilink property value,
219 # or overwrite it completely
220 if do_set:
221 value = newvalue
222 else:
223 value = curvalue
225 # TODO: one day, we'll switch to numeric ids and this will be
226 # unnecessary :(
227 value = [int(x) for x in value]
228 value.sort()
229 value = [str(x) for x in value]
230 return value
232 def sort_repr (self, cls, val, name):
233 if not val:
234 return val
235 op = cls.labelprop()
236 if op == 'id':
237 return [int(cls.get(v, op)) for v in val]
238 return [cls.get(v, op) for v in val]
240 class Boolean(_Type):
241 """An object designating a boolean property"""
242 def from_raw(self, value, **kw):
243 value = value.strip()
244 # checked is a common HTML checkbox value
245 value = value.lower() in ('checked', 'yes', 'true', 'on', '1')
246 return value
248 class Number(_Type):
249 """An object designating a numeric property"""
250 def from_raw(self, value, **kw):
251 value = value.strip()
252 try:
253 value = float(value)
254 except ValueError:
255 raise HyperdbValueError, _('property %s: %r is not a number')%(
256 kw['propname'], value)
257 return value
258 #
259 # Support for splitting designators
260 #
261 class DesignatorError(ValueError):
262 pass
263 def splitDesignator(designator, dre=re.compile(r'([^\d]+)(\d+)')):
264 """ Take a foo123 and return ('foo', 123)
265 """
266 m = dre.match(designator)
267 if m is None:
268 raise DesignatorError, _('"%s" not a node designator')%designator
269 return m.group(1), m.group(2)
271 class Proptree(object):
272 """ Simple tree data structure for optimizing searching of
273 properties. Each node in the tree represents a roundup Class
274 Property that has to be navigated for finding the given search
275 or sort properties. The need_for attribute is used for
276 distinguishing nodes in the tree used for sorting, searching or
277 retrieval: The attribute is a dictionary containing one or several
278 of the values 'sort', 'search', 'retrieve'.
280 The Proptree is also used for transitively searching attributes for
281 backends that do not support transitive search (e.g. anydbm). The
282 _val attribute with set_val is used for this.
283 """
285 def __init__(self, db, cls, name, props, parent=None, retr=False):
286 self.db = db
287 self.name = name
288 self.props = props
289 self.parent = parent
290 self._val = None
291 self.has_values = False
292 self.cls = cls
293 self.classname = None
294 self.uniqname = None
295 self.children = []
296 self.sortattr = []
297 self.propdict = {}
298 self.need_for = {'search' : True}
299 self.sort_direction = None
300 self.sort_ids = None
301 self.sort_ids_needed = False
302 self.sort_result = None
303 self.attr_sort_done = False
304 self.tree_sort_done = False
305 self.propclass = None
306 self.orderby = []
307 self.sql_idx = None # index of retrieved column in sql result
308 if parent:
309 self.root = parent.root
310 self.depth = parent.depth + 1
311 else:
312 self.root = self
313 self.seqno = 1
314 self.depth = 0
315 self.need_for['sort'] = True
316 self.id = self.root.seqno
317 self.root.seqno += 1
318 if self.cls:
319 self.classname = self.cls.classname
320 self.uniqname = '%s%s' % (self.cls.classname, self.id)
321 if not self.parent:
322 self.uniqname = self.cls.classname
323 if retr:
324 self.append_retr_props()
326 def append(self, name, need_for='search', retr=False):
327 """Append a property to self.children. Will create a new
328 propclass for the child.
329 """
330 if name in self.propdict:
331 pt = self.propdict[name]
332 pt.need_for[need_for] = True
333 if retr and isinstance(pt.propclass, Link):
334 pt.append_retr_props()
335 return pt
336 propclass = self.props[name]
337 cls = None
338 props = None
339 if isinstance(propclass, (Link, Multilink)):
340 cls = self.db.getclass(propclass.classname)
341 props = cls.getprops()
342 child = self.__class__(self.db, cls, name, props, parent = self)
343 child.need_for = {need_for : True}
344 child.propclass = propclass
345 self.children.append(child)
346 self.propdict[name] = child
347 if retr and isinstance(child.propclass, Link):
348 child.append_retr_props()
349 return child
351 def append_retr_props(self):
352 """Append properties for retrieval."""
353 for name, prop in self.cls.getprops(protected=1).iteritems():
354 if isinstance(prop, Multilink):
355 continue
356 self.append(name, need_for='retrieve')
358 def compute_sort_done(self, mlseen=False):
359 """ Recursively check if attribute is needed for sorting
360 ('sort' in self.need_for) or all children have tree_sort_done set and
361 sort_ids_needed unset: set self.tree_sort_done if one of the conditions
362 holds. Also remove sort_ids_needed recursively once having seen a
363 Multilink.
364 """
365 if isinstance (self.propclass, Multilink):
366 mlseen = True
367 if mlseen:
368 self.sort_ids_needed = False
369 self.tree_sort_done = True
370 for p in self.children:
371 p.compute_sort_done(mlseen)
372 if not p.tree_sort_done:
373 self.tree_sort_done = False
374 if 'sort' not in self.need_for:
375 self.tree_sort_done = True
376 if mlseen:
377 self.tree_sort_done = False
379 def ancestors(self):
380 p = self
381 while p.parent:
382 yield p
383 p = p.parent
385 def search(self, search_matches=None, sort=True):
386 """ Recursively search for the given properties in a proptree.
387 Once all properties are non-transitive, the search generates a
388 simple _filter call which does the real work
389 """
390 filterspec = {}
391 for p in self.children:
392 if 'search' in p.need_for:
393 if p.children:
394 p.search(sort = False)
395 filterspec[p.name] = p.val
396 self.val = self.cls._filter(search_matches, filterspec, sort and self)
397 return self.val
399 def sort (self, ids=None):
400 """ Sort ids by the order information stored in self. With
401 optimisations: Some order attributes may be precomputed (by the
402 backend) and some properties may already be sorted.
403 """
404 if ids is None:
405 ids = self.val
406 if self.sortattr and [s for s in self.sortattr if not s.attr_sort_done]:
407 return self._searchsort(ids, True, True)
408 return ids
410 def sortable_children(self, intermediate=False):
411 """ All children needed for sorting. If intermediate is True,
412 intermediate nodes (not being a sort attribute) are returned,
413 too.
414 """
415 return [p for p in self.children
416 if 'sort' in p.need_for and (intermediate or p.sort_direction)]
418 def __iter__(self):
419 """ Yield nodes in depth-first order -- visited nodes first """
420 for p in self.children:
421 yield p
422 for c in p:
423 yield c
425 def _get (self, ids):
426 """Lookup given ids -- possibly a list of list. We recurse until
427 we have a list of ids.
428 """
429 if not ids:
430 return ids
431 if isinstance (ids[0], list):
432 cids = [self._get(i) for i in ids]
433 else:
434 cids = [i and self.parent.cls.get(i, self.name) for i in ids]
435 if self.sortattr:
436 cids = [self._searchsort(i, False, True) for i in cids]
437 return cids
439 def _searchsort(self, ids=None, update=True, dosort=True):
440 """ Recursively compute the sort attributes. Note that ids
441 may be a deeply nested list of lists of ids if several
442 multilinks are encountered on the way from the root to an
443 individual attribute. We make sure that everything is properly
444 sorted on the way up. Note that the individual backend may
445 already have precomputed self.result or self.sort_ids. In this
446 case we do nothing for existing sa.result and recurse further if
447 self.sort_ids is available.
449 Yech, Multilinks: This gets especially complicated if somebody
450 sorts by different attributes of the same multilink (or
451 transitively across several multilinks). My use-case is sorting
452 by issue.messages.author and (reverse) by issue.messages.date.
453 In this case we sort the messages by author and date and use
454 this sorted list twice for sorting issues. This means that
455 issues are sorted by author and then by the time of the messages
456 *of this author*. Probably what the user intends in that case,
457 so we do *not* use two sorted lists of messages, one sorted by
458 author and one sorted by date for sorting issues.
459 """
460 for pt in self.sortable_children(intermediate = True):
461 # ids can be an empty list
462 if pt.tree_sort_done or not ids:
463 continue
464 if pt.sort_ids: # cached or computed by backend
465 cids = pt.sort_ids
466 else:
467 cids = pt._get(ids)
468 if pt.sort_direction and not pt.sort_result:
469 sortrep = pt.propclass.sort_repr
470 pt.sort_result = pt._sort_repr(sortrep, cids)
471 pt.sort_ids = cids
472 if pt.children:
473 pt._searchsort(cids, update, False)
474 if self.sortattr and dosort:
475 ids = self._sort(ids)
476 if not update:
477 for pt in self.sortable_children(intermediate = True):
478 pt.sort_ids = None
479 for pt in self.sortattr:
480 pt.sort_result = None
481 return ids
483 def _set_val(self, val):
484 """Check if self._val is already defined. If yes, we compute the
485 intersection of the old and the new value(s)
486 """
487 if self.has_values:
488 v = self._val
489 if not isinstance(self._val, type([])):
490 v = [self._val]
491 vals = set(v)
492 vals.intersection_update(val)
493 self._val = [v for v in vals]
494 else:
495 self._val = val
496 self.has_values = True
498 val = property(lambda self: self._val, _set_val)
500 def _sort(self, val):
501 """Finally sort by the given sortattr.sort_result. Note that we
502 do not sort by attrs having attr_sort_done set. The caller is
503 responsible for setting attr_sort_done only for trailing
504 attributes (otherwise the sort order is wrong). Since pythons
505 sort is stable, we can sort already sorted lists without
506 destroying the sort-order for items that compare equal with the
507 current sort.
509 Sorting-Strategy: We sort repeatedly by different sort-keys from
510 right to left. Since pythons sort is stable, we can safely do
511 that. An optimisation is a "run-length encoding" of the
512 sort-directions: If several sort attributes sort in the same
513 direction we can combine them into a single sort. Note that
514 repeated sorting is probably more efficient than using
515 compare-methods in python due to the overhead added by compare
516 methods.
517 """
518 if not val:
519 return val
520 sortattr = []
521 directions = []
522 dir_idx = []
523 idx = 0
524 curdir = None
525 for sa in self.sortattr:
526 if sa.attr_sort_done:
527 break
528 if sortattr:
529 assert len(sortattr[0]) == len(sa.sort_result)
530 sortattr.append (sa.sort_result)
531 if curdir != sa.sort_direction:
532 dir_idx.append (idx)
533 directions.append (sa.sort_direction)
534 curdir = sa.sort_direction
535 idx += 1
536 sortattr.append (val)
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 def iter_roles(roles):
763 ''' handle the text processing of turning the roles list
764 into something python can use more easily
765 '''
766 if not roles or not roles.strip():
767 raise StopIteration, "Empty roles given"
768 for role in [x.lower().strip() for x in roles.split(',')]:
769 yield role
772 #
773 # The base Class class
774 #
775 class Class:
776 """ The handle to a particular class of nodes in a hyperdatabase.
778 All methods except __repr__ and getnode must be implemented by a
779 concrete backend Class.
780 """
782 def __init__(self, db, classname, **properties):
783 """Create a new class with a given name and property specification.
785 'classname' must not collide with the name of an existing class,
786 or a ValueError is raised. The keyword arguments in 'properties'
787 must map names to property objects, or a TypeError is raised.
788 """
789 for name in 'creation activity creator actor'.split():
790 if properties.has_key(name):
791 raise ValueError, '"creation", "activity", "creator" and '\
792 '"actor" are reserved'
794 self.classname = classname
795 self.properties = properties
796 self.db = weakref.proxy(db) # use a weak ref to avoid circularity
797 self.key = ''
799 # should we journal changes (default yes)
800 self.do_journal = 1
802 # do the db-related init stuff
803 db.addclass(self)
805 actions = "create set retire restore".split()
806 self.auditors = dict([(a, PrioList()) for a in actions])
807 self.reactors = dict([(a, PrioList()) for a in actions])
809 def __repr__(self):
810 """Slightly more useful representation
811 """
812 return '<hyperdb.Class "%s">'%self.classname
814 # Editing nodes:
816 def create(self, **propvalues):
817 """Create a new node of this class and return its id.
819 The keyword arguments in 'propvalues' map property names to values.
821 The values of arguments must be acceptable for the types of their
822 corresponding properties or a TypeError is raised.
824 If this class has a key property, it must be present and its value
825 must not collide with other key strings or a ValueError is raised.
827 Any other properties on this class that are missing from the
828 'propvalues' dictionary are set to None.
830 If an id in a link or multilink property does not refer to a valid
831 node, an IndexError is raised.
832 """
833 raise NotImplementedError
835 _marker = []
836 def get(self, nodeid, propname, default=_marker, cache=1):
837 """Get the value of a property on an existing node of this class.
839 'nodeid' must be the id of an existing node of this class or an
840 IndexError is raised. 'propname' must be the name of a property
841 of this class or a KeyError is raised.
843 'cache' exists for backwards compatibility, and is not used.
844 """
845 raise NotImplementedError
847 # not in spec
848 def getnode(self, nodeid):
849 """ Return a convenience wrapper for the node.
851 'nodeid' must be the id of an existing node of this class or an
852 IndexError is raised.
854 'cache' exists for backwards compatibility, and is not used.
855 """
856 return Node(self, nodeid)
858 def getnodeids(self, retired=None):
859 """Retrieve all the ids of the nodes for a particular Class.
860 """
861 raise NotImplementedError
863 def set(self, nodeid, **propvalues):
864 """Modify a property on an existing node of this class.
866 'nodeid' must be the id of an existing node of this class or an
867 IndexError is raised.
869 Each key in 'propvalues' must be the name of a property of this
870 class or a KeyError is raised.
872 All values in 'propvalues' must be acceptable types for their
873 corresponding properties or a TypeError is raised.
875 If the value of the key property is set, it must not collide with
876 other key strings or a ValueError is raised.
878 If the value of a Link or Multilink property contains an invalid
879 node id, a ValueError is raised.
880 """
881 raise NotImplementedError
883 def retire(self, nodeid):
884 """Retire a node.
886 The properties on the node remain available from the get() method,
887 and the node's id is never reused.
889 Retired nodes are not returned by the find(), list(), or lookup()
890 methods, and other nodes may reuse the values of their key properties.
891 """
892 raise NotImplementedError
894 def restore(self, nodeid):
895 """Restpre a retired node.
897 Make node available for all operations like it was before retirement.
898 """
899 raise NotImplementedError
901 def is_retired(self, nodeid):
902 """Return true if the node is rerired
903 """
904 raise NotImplementedError
906 def destroy(self, nodeid):
907 """Destroy a node.
909 WARNING: this method should never be used except in extremely rare
910 situations where there could never be links to the node being
911 deleted
913 WARNING: use retire() instead
915 WARNING: the properties of this node will not be available ever again
917 WARNING: really, use retire() instead
919 Well, I think that's enough warnings. This method exists mostly to
920 support the session storage of the cgi interface.
922 The node is completely removed from the hyperdb, including all journal
923 entries. It will no longer be available, and will generally break code
924 if there are any references to the node.
925 """
927 def history(self, nodeid):
928 """Retrieve the journal of edits on a particular node.
930 'nodeid' must be the id of an existing node of this class or an
931 IndexError is raised.
933 The returned list contains tuples of the form
935 (date, tag, action, params)
937 'date' is a Timestamp object specifying the time of the change and
938 'tag' is the journaltag specified when the database was opened.
939 """
940 raise NotImplementedError
942 # Locating nodes:
943 def hasnode(self, nodeid):
944 """Determine if the given nodeid actually exists
945 """
946 raise NotImplementedError
948 def setkey(self, propname):
949 """Select a String property of this class to be the key property.
951 'propname' must be the name of a String property of this class or
952 None, or a TypeError is raised. The values of the key property on
953 all existing nodes must be unique or a ValueError is raised.
954 """
955 raise NotImplementedError
957 def setlabelprop(self, labelprop):
958 """Set the label property. Used for override of labelprop
959 resolution order.
960 """
961 if labelprop not in self.getprops():
962 raise ValueError, _("Not a property name: %s") % labelprop
963 self._labelprop = labelprop
965 def setorderprop(self, orderprop):
966 """Set the order property. Used for override of orderprop
967 resolution order
968 """
969 if orderprop not in self.getprops():
970 raise ValueError, _("Not a property name: %s") % orderprop
971 self._orderprop = orderprop
973 def getkey(self):
974 """Return the name of the key property for this class or None."""
975 raise NotImplementedError
977 def labelprop(self, default_to_id=0):
978 """Return the property name for a label for the given node.
980 This method attempts to generate a consistent label for the node.
981 It tries the following in order:
983 0. self._labelprop if set
984 1. key property
985 2. "name" property
986 3. "title" property
987 4. first property from the sorted property name list
988 """
989 if hasattr(self, '_labelprop'):
990 return self._labelprop
991 k = self.getkey()
992 if k:
993 return k
994 props = self.getprops()
995 if props.has_key('name'):
996 return 'name'
997 elif props.has_key('title'):
998 return 'title'
999 if default_to_id:
1000 return 'id'
1001 props = props.keys()
1002 props.sort()
1003 return props[0]
1005 def orderprop(self):
1006 """Return the property name to use for sorting for the given node.
1008 This method computes the property for sorting.
1009 It tries the following in order:
1011 0. self._orderprop if set
1012 1. "order" property
1013 2. self.labelprop()
1014 """
1016 if hasattr(self, '_orderprop'):
1017 return self._orderprop
1018 props = self.getprops()
1019 if props.has_key('order'):
1020 return 'order'
1021 return self.labelprop()
1023 def lookup(self, keyvalue):
1024 """Locate a particular node by its key property and return its id.
1026 If this class has no key property, a TypeError is raised. If the
1027 'keyvalue' matches one of the values for the key property among
1028 the nodes in this class, the matching node's id is returned;
1029 otherwise a KeyError is raised.
1030 """
1031 raise NotImplementedError
1033 def find(self, **propspec):
1034 """Get the ids of nodes in this class which link to the given nodes.
1036 'propspec' consists of keyword args propname={nodeid:1,}
1037 'propname' must be the name of a property in this class, or a
1038 KeyError is raised. That property must be a Link or Multilink
1039 property, or a TypeError is raised.
1041 Any node in this class whose 'propname' property links to any of the
1042 nodeids will be returned. Used by the full text indexing, which knows
1043 that "foo" occurs in msg1, msg3 and file7, so we have hits on these
1044 issues:
1046 db.issue.find(messages={'1':1,'3':1}, files={'7':1})
1047 """
1048 raise NotImplementedError
1050 def _filter(self, search_matches, filterspec, sort=(None,None),
1051 group=(None,None)):
1052 """For some backends this implements the non-transitive
1053 search, for more information see the filter method.
1054 """
1055 raise NotImplementedError
1057 def _proptree(self, filterspec, sortattr=[], retr=False):
1058 """Build a tree of all transitive properties in the given
1059 filterspec.
1060 If we retrieve (retr is True) linked items we don't follow
1061 across multilinks. We also don't follow if the searched value
1062 can contain NULL values.
1063 """
1064 proptree = Proptree(self.db, self, '', self.getprops(), retr=retr)
1065 for key, v in filterspec.iteritems():
1066 keys = key.split('.')
1067 p = proptree
1068 mlseen = False
1069 for k in keys:
1070 if isinstance (p.propclass, Multilink):
1071 mlseen = True
1072 isnull = v == '-1' or v is None
1073 nullin = isinstance(v, type([])) and ('-1' in v or None in v)
1074 r = retr and not mlseen and not isnull and not nullin
1075 p = p.append(k, retr=r)
1076 p.val = v
1077 multilinks = {}
1078 for s in sortattr:
1079 keys = s[1].split('.')
1080 p = proptree
1081 mlseen = False
1082 for k in keys:
1083 if isinstance (p.propclass, Multilink):
1084 mlseen = True
1085 r = retr and not mlseen
1086 p = p.append(k, need_for='sort', retr=r)
1087 if isinstance (p.propclass, Multilink):
1088 multilinks[p] = True
1089 if p.cls:
1090 p = p.append(p.cls.orderprop(), need_for='sort')
1091 if p.sort_direction: # if an orderprop is also specified explicitly
1092 continue
1093 p.sort_direction = s[0]
1094 proptree.sortattr.append (p)
1095 for p in multilinks.iterkeys():
1096 sattr = {}
1097 for c in p:
1098 if c.sort_direction:
1099 sattr [c] = True
1100 for sa in proptree.sortattr:
1101 if sa in sattr:
1102 p.sortattr.append (sa)
1103 return proptree
1105 def get_transitive_prop(self, propname_path, default = None):
1106 """Expand a transitive property (individual property names
1107 separated by '.' into a new property at the end of the path. If
1108 one of the names does not refer to a valid property, we return
1109 None.
1110 Example propname_path (for class issue): "messages.author"
1111 """
1112 props = self.db.getclass(self.classname).getprops()
1113 for k in propname_path.split('.'):
1114 try:
1115 prop = props[k]
1116 except (KeyError, TypeError):
1117 return default
1118 cl = getattr(prop, 'classname', None)
1119 props = None
1120 if cl:
1121 props = self.db.getclass(cl).getprops()
1122 return prop
1124 def _sortattr(self, sort=[], group=[]):
1125 """Build a single list of sort attributes in the correct order
1126 with sanity checks (no duplicate properties) included. Always
1127 sort last by id -- if id is not already in sortattr.
1128 """
1129 seen = {}
1130 sortattr = []
1131 for srt in group, sort:
1132 if not isinstance(srt, list):
1133 srt = [srt]
1134 for s in srt:
1135 if s[1] and s[1] not in seen:
1136 sortattr.append((s[0] or '+', s[1]))
1137 seen[s[1]] = True
1138 if 'id' not in seen :
1139 sortattr.append(('+', 'id'))
1140 return sortattr
1142 def filter(self, search_matches, filterspec, sort=[], group=[]):
1143 """Return a list of the ids of the active nodes in this class that
1144 match the 'filter' spec, sorted by the group spec and then the
1145 sort spec.
1147 "filterspec" is {propname: value(s)}
1149 "sort" and "group" are [(dir, prop), ...] where dir is '+', '-'
1150 or None and prop is a prop name or None. Note that for
1151 backward-compatibility reasons a single (dir, prop) tuple is
1152 also allowed.
1154 "search_matches" is a container type
1156 The filter must match all properties specificed. If the property
1157 value to match is a list:
1159 1. String properties must match all elements in the list, and
1160 2. Other properties must match any of the elements in the list.
1162 Note that now the propname in filterspec and prop in a
1163 sort/group spec may be transitive, i.e., it may contain
1164 properties of the form link.link.link.name, e.g. you can search
1165 for all issues where a message was added by a certain user in
1166 the last week with a filterspec of
1167 {'messages.author' : '42', 'messages.creation' : '.-1w;'}
1169 Implementation note:
1170 This implements a non-optimized version of Transitive search
1171 using _filter implemented in a backend class. A more efficient
1172 version can be implemented in the individual backends -- e.g.,
1173 an SQL backend will want to create a single SQL statement and
1174 override the filter method instead of implementing _filter.
1175 """
1176 sortattr = self._sortattr(sort = sort, group = group)
1177 proptree = self._proptree(filterspec, sortattr)
1178 proptree.search(search_matches)
1179 return proptree.sort()
1181 # non-optimized filter_iter, a backend may chose to implement a
1182 # better version that provides a real iterator that pre-fills the
1183 # cache for each id returned. Note that the filter_iter doesn't
1184 # promise to correctly sort by multilink (which isn't sane to do
1185 # anyway).
1186 filter_iter = filter
1188 def count(self):
1189 """Get the number of nodes in this class.
1191 If the returned integer is 'numnodes', the ids of all the nodes
1192 in this class run from 1 to numnodes, and numnodes+1 will be the
1193 id of the next node to be created in this class.
1194 """
1195 raise NotImplementedError
1197 # Manipulating properties:
1198 def getprops(self, protected=1):
1199 """Return a dictionary mapping property names to property objects.
1200 If the "protected" flag is true, we include protected properties -
1201 those which may not be modified.
1202 """
1203 raise NotImplementedError
1205 def get_required_props(self, propnames = []):
1206 """Return a dict of property names mapping to property objects.
1207 All properties that have the "required" flag set will be
1208 returned in addition to all properties in the propnames
1209 parameter.
1210 """
1211 props = self.getprops(protected = False)
1212 pdict = dict([(p, props[p]) for p in propnames])
1213 pdict.update([(k, v) for k, v in props.iteritems() if v.required])
1214 return pdict
1216 def addprop(self, **properties):
1217 """Add properties to this class.
1219 The keyword arguments in 'properties' must map names to property
1220 objects, or a TypeError is raised. None of the keys in 'properties'
1221 may collide with the names of existing properties, or a ValueError
1222 is raised before any properties have been added.
1223 """
1224 raise NotImplementedError
1226 def index(self, nodeid):
1227 """Add (or refresh) the node to search indexes"""
1228 raise NotImplementedError
1230 #
1231 # Detector interface
1232 #
1233 def audit(self, event, detector, priority = 100):
1234 """Register an auditor detector"""
1235 self.auditors[event].append((priority, detector.__name__, detector))
1237 def fireAuditors(self, event, nodeid, newvalues):
1238 """Fire all registered auditors"""
1239 for prio, name, audit in self.auditors[event]:
1240 audit(self.db, self, nodeid, newvalues)
1242 def react(self, event, detector, priority = 100):
1243 """Register a reactor detector"""
1244 self.reactors[event].append((priority, detector.__name__, detector))
1246 def fireReactors(self, event, nodeid, oldvalues):
1247 """Fire all registered reactors"""
1248 for prio, name, react in self.reactors[event]:
1249 react(self.db, self, nodeid, oldvalues)
1251 #
1252 # import / export support
1253 #
1254 def export_propnames(self):
1255 """List the property names for export from this Class"""
1256 propnames = self.getprops().keys()
1257 propnames.sort()
1258 return propnames
1260 def import_journals(self, entries):
1261 """Import a class's journal.
1263 Uses setjournal() to set the journal for each item.
1264 Strategy for import: Sort first by id, then import journals for
1265 each id, this way the memory footprint is a lot smaller than the
1266 initial implementation which stored everything in a big hash by
1267 id and then proceeded to import journals for each id."""
1268 properties = self.getprops()
1269 a = []
1270 for l in entries:
1271 # first element in sorted list is the (numeric) id
1272 # in python2.4 and up we would use sorted with a key...
1273 a.append ((int (l [0].strip ("'")), l))
1274 a.sort ()
1277 last = 0
1278 r = []
1279 for n, l in a:
1280 nodeid, jdate, user, action, params = map(eval, l)
1281 assert (str(n) == nodeid)
1282 if n != last:
1283 if r:
1284 self.db.setjournal(self.classname, str(last), r)
1285 last = n
1286 r = []
1288 if action == 'set':
1289 for propname, value in params.iteritems():
1290 prop = properties[propname]
1291 if value is None:
1292 pass
1293 elif isinstance(prop, Date):
1294 value = date.Date(value)
1295 elif isinstance(prop, Interval):
1296 value = date.Interval(value)
1297 elif isinstance(prop, Password):
1298 value = password.Password(encrypted=value)
1299 params[propname] = value
1300 elif action == 'create' and params:
1301 # old tracker with data stored in the create!
1302 params = {}
1303 r.append((nodeid, date.Date(jdate), user, action, params))
1304 if r:
1305 self.db.setjournal(self.classname, nodeid, r)
1307 #
1308 # convenience methods
1309 #
1310 def get_roles(self, nodeid):
1311 """Return iterator for all roles for this nodeid.
1313 Yields string-processed roles.
1314 This method can be overridden to provide a hook where we can
1315 insert other permission models (e.g. get roles from database)
1316 In standard schemas only a user has a roles property but
1317 this may be different in customized schemas.
1318 Note that this is the *central place* where role
1319 processing happens!
1320 """
1321 node = self.db.getnode(self.classname, nodeid)
1322 return iter_roles(node['roles'])
1324 def has_role(self, nodeid, *roles):
1325 '''See if this node has any roles that appear in roles.
1327 For convenience reasons we take a list.
1328 In standard schemas only a user has a roles property but
1329 this may be different in customized schemas.
1330 '''
1331 roles = dict.fromkeys ([r.strip().lower() for r in roles])
1332 for role in self.get_roles(nodeid):
1333 if role in roles:
1334 return True
1335 return False
1338 class HyperdbValueError(ValueError):
1339 """ Error converting a raw value into a Hyperdb value """
1340 pass
1342 def convertLinkValue(db, propname, prop, value, idre=re.compile('^\d+$')):
1343 """ Convert the link value (may be id or key value) to an id value. """
1344 linkcl = db.classes[prop.classname]
1345 if not idre.match(value):
1346 if linkcl.getkey():
1347 try:
1348 value = linkcl.lookup(value)
1349 except KeyError, message:
1350 raise HyperdbValueError, _('property %s: %r is not a %s.')%(
1351 propname, value, prop.classname)
1352 else:
1353 raise HyperdbValueError, _('you may only enter ID values '\
1354 'for property %s')%propname
1355 return value
1357 def fixNewlines(text):
1358 """ Homogenise line endings.
1360 Different web clients send different line ending values, but
1361 other systems (eg. email) don't necessarily handle those line
1362 endings. Our solution is to convert all line endings to LF.
1363 """
1364 text = text.replace('\r\n', '\n')
1365 return text.replace('\r', '\n')
1367 def rawToHyperdb(db, klass, itemid, propname, value, **kw):
1368 """ Convert the raw (user-input) value to a hyperdb-storable value. The
1369 value is for the "propname" property on itemid (may be None for a
1370 new item) of "klass" in "db".
1372 The value is usually a string, but in the case of multilink inputs
1373 it may be either a list of strings or a string with comma-separated
1374 values.
1375 """
1376 properties = klass.getprops()
1378 # ensure it's a valid property name
1379 propname = propname.strip()
1380 try:
1381 proptype = properties[propname]
1382 except KeyError:
1383 raise HyperdbValueError, _('%r is not a property of %s')%(propname,
1384 klass.classname)
1386 # if we got a string, strip it now
1387 if isinstance(value, type('')):
1388 value = value.strip()
1390 # convert the input value to a real property value
1391 value = proptype.from_raw(value, db=db, klass=klass,
1392 propname=propname, itemid=itemid, **kw)
1394 return value
1396 class FileClass:
1397 """ A class that requires the "content" property and stores it on
1398 disk.
1399 """
1400 default_mime_type = 'text/plain'
1402 def __init__(self, db, classname, **properties):
1403 """The newly-created class automatically includes the "content"
1404 property.
1405 """
1406 if not properties.has_key('content'):
1407 properties['content'] = String(indexme='yes')
1409 def export_propnames(self):
1410 """ Don't export the "content" property
1411 """
1412 propnames = self.getprops().keys()
1413 propnames.remove('content')
1414 propnames.sort()
1415 return propnames
1417 def exportFilename(self, dirname, nodeid):
1418 subdir_filename = self.db.subdirFilename(self.classname, nodeid)
1419 return os.path.join(dirname, self.classname+'-files', subdir_filename)
1421 def export_files(self, dirname, nodeid):
1422 """ Export the "content" property as a file, not csv column
1423 """
1424 source = self.db.filename(self.classname, nodeid)
1426 dest = self.exportFilename(dirname, nodeid)
1427 ensureParentsExist(dest)
1428 shutil.copyfile(source, dest)
1430 def import_files(self, dirname, nodeid):
1431 """ Import the "content" property as a file
1432 """
1433 source = self.exportFilename(dirname, nodeid)
1435 dest = self.db.filename(self.classname, nodeid, create=1)
1436 ensureParentsExist(dest)
1437 shutil.copyfile(source, dest)
1439 mime_type = None
1440 props = self.getprops()
1441 if props.has_key('type'):
1442 mime_type = self.get(nodeid, 'type')
1443 if not mime_type:
1444 mime_type = self.default_mime_type
1445 if props['content'].indexme:
1446 self.db.indexer.add_text((self.classname, nodeid, 'content'),
1447 self.get(nodeid, 'content'), mime_type)
1449 class Node:
1450 """ A convenience wrapper for the given node
1451 """
1452 def __init__(self, cl, nodeid, cache=1):
1453 self.__dict__['cl'] = cl
1454 self.__dict__['nodeid'] = nodeid
1455 def keys(self, protected=1):
1456 return self.cl.getprops(protected=protected).keys()
1457 def values(self, protected=1):
1458 l = []
1459 for name in self.cl.getprops(protected=protected).keys():
1460 l.append(self.cl.get(self.nodeid, name))
1461 return l
1462 def items(self, protected=1):
1463 l = []
1464 for name in self.cl.getprops(protected=protected).keys():
1465 l.append((name, self.cl.get(self.nodeid, name)))
1466 return l
1467 def has_key(self, name):
1468 return self.cl.getprops().has_key(name)
1469 def get(self, name, default=None):
1470 if self.has_key(name):
1471 return self[name]
1472 else:
1473 return default
1474 def __getattr__(self, name):
1475 if self.__dict__.has_key(name):
1476 return self.__dict__[name]
1477 try:
1478 return self.cl.get(self.nodeid, name)
1479 except KeyError, value:
1480 # we trap this but re-raise it as AttributeError - all other
1481 # exceptions should pass through untrapped
1482 pass
1483 # nope, no such attribute
1484 raise AttributeError, str(value)
1485 def __getitem__(self, name):
1486 return self.cl.get(self.nodeid, name)
1487 def __setattr__(self, name, value):
1488 try:
1489 return self.cl.set(self.nodeid, **{name: value})
1490 except KeyError, value:
1491 raise AttributeError, str(value)
1492 def __setitem__(self, name, value):
1493 self.cl.set(self.nodeid, **{name: value})
1494 def history(self):
1495 return self.cl.history(self.nodeid)
1496 def retire(self):
1497 return self.cl.retire(self.nodeid)
1500 def Choice(name, db, *options):
1501 """Quick helper to create a simple class with choices
1502 """
1503 cl = Class(db, name, name=String(), order=String())
1504 for i in range(len(options)):
1505 cl.create(name=options[i], order=i)
1506 return Link(name)
1508 # vim: set filetype=python sts=4 sw=4 et si :