Premiere version : mise en route du suivi.
[auf_roundup.git] / roundup / hyperdb.py
1 #
2 # Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/)
3 # This module is free software, and you may redistribute it and/or modify
4 # under the same terms as Python, so long as this copyright message and
5 # disclaimer are retained in their original form.
6 #
7 # IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR
8 # DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING
9 # OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE
10 # POSSIBILITY OF SUCH DAMAGE.
11 #
12 # BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING,
13 # BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
14 # FOR A PARTICULAR PURPOSE. THE CODE PROVIDED HEREUNDER IS ON AN "AS IS"
15 # BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
16 # SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
17 #
18
19 """Hyperdatabase implementation, especially field types.
20 """
21 __docformat__ = 'restructuredtext'
22
23 # standard python modules
24 import os, re, shutil, weakref
25 # Python 2.3 ... 2.6 compatibility:
26 from roundup.anypy.sets_ import set
27
28 # roundup modules
29 import date, password
30 from support import ensureParentsExist, PrioList, sorted, reversed
31 from roundup.i18n import _
32
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
50
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()
69
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 m = password.Password.pwre.match(value)
76 if m:
77 # password is being given to us encrypted
78 p = password.Password()
79 p.scheme = m.group(1)
80 if p.scheme not in 'SHA crypt plaintext'.split():
81 raise HyperdbValueError, \
82 ('property %s: unknown encryption scheme %r') %\
83 (kw['propname'], p.scheme)
84 p.password = m.group(2)
85 value = p
86 else:
87 try:
88 value = password.Password(value)
89 except password.PasswordValueError, message:
90 raise HyperdbValueError, \
91 _('property %s: %s')%(kw['propname'], message)
92 return value
93 def sort_repr (self, cls, val, name):
94 if not val:
95 return val
96 return str(val)
97
98 class Date(_Type):
99 """An object designating a Date property."""
100 def __init__(self, offset=None, required=False):
101 super(Date, self).__init__(required)
102 self._offset = offset
103 def offset(self, db):
104 if self._offset is not None:
105 return self._offset
106 return db.getUserTimezone()
107 def from_raw(self, value, db, **kw):
108 try:
109 value = date.Date(value, self.offset(db))
110 except ValueError, message:
111 raise HyperdbValueError, _('property %s: %r is an invalid '\
112 'date (%s)')%(kw['propname'], value, message)
113 return value
114 def range_from_raw(self, value, db):
115 """return Range value from given raw value with offset correction"""
116 return date.Range(value, date.Date, offset=self.offset(db))
117 def sort_repr (self, cls, val, name):
118 if not val:
119 return val
120 return str(val)
121
122 class Interval(_Type):
123 """An object designating an Interval property."""
124 def from_raw(self, value, **kw):
125 try:
126 value = date.Interval(value)
127 except ValueError, message:
128 raise HyperdbValueError, _('property %s: %r is an invalid '\
129 'date interval (%s)')%(kw['propname'], value, message)
130 return value
131 def sort_repr (self, cls, val, name):
132 if not val:
133 return val
134 return val.as_seconds()
135
136 class _Pointer(_Type):
137 """An object designating a Pointer property that links or multilinks
138 to a node in a specified class."""
139 def __init__(self, classname, do_journal='yes', required=False):
140 """ Default is to journal link and unlink events
141 """
142 super(_Pointer, self).__init__(required)
143 self.classname = classname
144 self.do_journal = do_journal == 'yes'
145 def __repr__(self):
146 """more useful for dumps. But beware: This is also used in schema
147 storage in SQL backends!
148 """
149 return '<%s.%s to "%s">'%(self.__class__.__module__,
150 self.__class__.__name__, self.classname)
151
152 class Link(_Pointer):
153 """An object designating a Link property that links to a
154 node in a specified class."""
155 def from_raw(self, value, db, propname, **kw):
156 if value == '-1' or not value:
157 value = None
158 else:
159 value = convertLinkValue(db, propname, self, value)
160 return value
161 def sort_repr (self, cls, val, name):
162 if not val:
163 return val
164 op = cls.labelprop()
165 if op == 'id':
166 return int(cls.get(val, op))
167 return cls.get(val, op)
168
169 class Multilink(_Pointer):
170 """An object designating a Multilink property that links
171 to nodes in a specified class.
172
173 "classname" indicates the class to link to
174
175 "do_journal" indicates whether the linked-to nodes should have
176 'link' and 'unlink' events placed in their journal
177 """
178 def from_raw(self, value, db, klass, propname, itemid, **kw):
179 if not value:
180 return []
181
182 # get the current item value if it's not a new item
183 if itemid and not itemid.startswith('-'):
184 curvalue = klass.get(itemid, propname)
185 else:
186 curvalue = []
187
188 # if the value is a comma-separated string then split it now
189 if isinstance(value, type('')):
190 value = value.split(',')
191
192 # handle each add/remove in turn
193 # keep an extra list for all items that are
194 # definitely in the new list (in case of e.g.
195 # <propname>=A,+B, which should replace the old
196 # list with A,B)
197 do_set = 1
198 newvalue = []
199 for item in value:
200 item = item.strip()
201
202 # skip blanks
203 if not item: continue
204
205 # handle +/-
206 remove = 0
207 if item.startswith('-'):
208 remove = 1
209 item = item[1:]
210 do_set = 0
211 elif item.startswith('+'):
212 item = item[1:]
213 do_set = 0
214
215 # look up the value
216 itemid = convertLinkValue(db, propname, self, item)
217
218 # perform the add/remove
219 if remove:
220 try:
221 curvalue.remove(itemid)
222 except ValueError:
223 raise HyperdbValueError, _('property %s: %r is not ' \
224 'currently an element')%(propname, item)
225 else:
226 newvalue.append(itemid)
227 if itemid not in curvalue:
228 curvalue.append(itemid)
229
230 # that's it, set the new Multilink property value,
231 # or overwrite it completely
232 if do_set:
233 value = newvalue
234 else:
235 value = curvalue
236
237 # TODO: one day, we'll switch to numeric ids and this will be
238 # unnecessary :(
239 value = [int(x) for x in value]
240 value.sort()
241 value = [str(x) for x in value]
242 return value
243
244 def sort_repr (self, cls, val, name):
245 if not val:
246 return val
247 op = cls.labelprop()
248 if op == 'id':
249 return [int(cls.get(v, op)) for v in val]
250 return [cls.get(v, op) for v in val]
251
252 class Boolean(_Type):
253 """An object designating a boolean property"""
254 def from_raw(self, value, **kw):
255 value = value.strip()
256 # checked is a common HTML checkbox value
257 value = value.lower() in ('checked', 'yes', 'true', 'on', '1')
258 return value
259
260 class Number(_Type):
261 """An object designating a numeric property"""
262 def from_raw(self, value, **kw):
263 value = value.strip()
264 try:
265 value = float(value)
266 except ValueError:
267 raise HyperdbValueError, _('property %s: %r is not a number')%(
268 kw['propname'], value)
269 return value
270 #
271 # Support for splitting designators
272 #
273 class DesignatorError(ValueError):
274 pass
275 def splitDesignator(designator, dre=re.compile(r'([^\d]+)(\d+)')):
276 """ Take a foo123 and return ('foo', 123)
277 """
278 m = dre.match(designator)
279 if m is None:
280 raise DesignatorError, _('"%s" not a node designator')%designator
281 return m.group(1), m.group(2)
282
283 class Proptree(object):
284 """ Simple tree data structure for optimizing searching of
285 properties. Each node in the tree represents a roundup Class
286 Property that has to be navigated for finding the given search
287 or sort properties. The sort_type attribute is used for
288 distinguishing nodes in the tree used for sorting or searching: If
289 it is 0 for a node, that node is not used for sorting. If it is 1,
290 it is used for both, sorting and searching. If it is 2 it is used
291 for sorting only.
292
293 The Proptree is also used for transitively searching attributes for
294 backends that do not support transitive search (e.g. anydbm). The
295 _val attribute with set_val is used for this.
296 """
297
298 def __init__(self, db, cls, name, props, parent = None):
299 self.db = db
300 self.name = name
301 self.props = props
302 self.parent = parent
303 self._val = None
304 self.has_values = False
305 self.cls = cls
306 self.classname = None
307 self.uniqname = None
308 self.children = []
309 self.sortattr = []
310 self.propdict = {}
311 self.sort_type = 0
312 self.sort_direction = None
313 self.sort_ids = None
314 self.sort_ids_needed = False
315 self.sort_result = None
316 self.attr_sort_done = False
317 self.tree_sort_done = False
318 self.propclass = None
319 self.orderby = []
320 if parent:
321 self.root = parent.root
322 self.depth = parent.depth + 1
323 else:
324 self.root = self
325 self.seqno = 1
326 self.depth = 0
327 self.sort_type = 1
328 self.id = self.root.seqno
329 self.root.seqno += 1
330 if self.cls:
331 self.classname = self.cls.classname
332 self.uniqname = '%s%s' % (self.cls.classname, self.id)
333 if not self.parent:
334 self.uniqname = self.cls.classname
335
336 def append(self, name, sort_type = 0):
337 """Append a property to self.children. Will create a new
338 propclass for the child.
339 """
340 if name in self.propdict:
341 pt = self.propdict[name]
342 if sort_type and not pt.sort_type:
343 pt.sort_type = 1
344 return pt
345 propclass = self.props[name]
346 cls = None
347 props = None
348 if isinstance(propclass, (Link, Multilink)):
349 cls = self.db.getclass(propclass.classname)
350 props = cls.getprops()
351 child = self.__class__(self.db, cls, name, props, parent = self)
352 child.sort_type = sort_type
353 child.propclass = propclass
354 self.children.append(child)
355 self.propdict[name] = child
356 return child
357
358 def compute_sort_done(self, mlseen=False):
359 """ Recursively check if attribute is needed for sorting
360 (self.sort_type > 0) 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 not self.sort_type:
375 self.tree_sort_done = True
376 if mlseen:
377 self.tree_sort_done = False
378
379 def ancestors(self):
380 p = self
381 while p.parent:
382 yield p
383 p = p.parent
384
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 p.sort_type < 2:
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
398
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
409
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 p.sort_type > 0 and (intermediate or p.sort_direction)]
417
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
424
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
438
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.
448
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
482
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
497
498 val = property(lambda self: self._val, _set_val)
499
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.
508
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 #print >> sys.stderr, "\nsortattr", sortattr
538 sortattr = zip (*sortattr)
539 for dir, i in reversed(zip(directions, dir_idx)):
540 rev = dir == '-'
541 sortattr = sorted (sortattr, key = lambda x:x[i:idx], reverse = rev)
542 idx = i
543 return [x[-1] for x in sortattr]
544
545 def _sort_repr(self, sortrep, ids):
546 """Call sortrep for given ids -- possibly a list of list. We
547 recurse until we have a list of ids.
548 """
549 if not ids:
550 return ids
551 if isinstance (ids[0], list):
552 res = [self._sort_repr(sortrep, i) for i in ids]
553 else:
554 res = [sortrep(self.cls, i, self.name) for i in ids]
555 return res
556
557 def __repr__(self):
558 r = ["proptree:" + self.name]
559 for n in self:
560 r.append("proptree:" + " " * n.depth + n.name)
561 return '\n'.join(r)
562 __str__ = __repr__
563
564 #
565 # the base Database class
566 #
567 class DatabaseError(ValueError):
568 """Error to be raised when there is some problem in the database code
569 """
570 pass
571 class Database:
572 """A database for storing records containing flexible data types.
573
574 This class defines a hyperdatabase storage layer, which the Classes use to
575 store their data.
576
577
578 Transactions
579 ------------
580 The Database should support transactions through the commit() and
581 rollback() methods. All other Database methods should be transaction-aware,
582 using data from the current transaction before looking up the database.
583
584 An implementation must provide an override for the get() method so that the
585 in-database value is returned in preference to the in-transaction value.
586 This is necessary to determine if any values have changed during a
587 transaction.
588
589
590 Implementation
591 --------------
592
593 All methods except __repr__ must be implemented by a concrete backend Database.
594
595 """
596
597 # flag to set on retired entries
598 RETIRED_FLAG = '__hyperdb_retired'
599
600 BACKEND_MISSING_STRING = None
601 BACKEND_MISSING_NUMBER = None
602 BACKEND_MISSING_BOOLEAN = None
603
604 def __init__(self, config, journaltag=None):
605 """Open a hyperdatabase given a specifier to some storage.
606
607 The 'storagelocator' is obtained from config.DATABASE.
608 The meaning of 'storagelocator' depends on the particular
609 implementation of the hyperdatabase. It could be a file name,
610 a directory path, a socket descriptor for a connection to a
611 database over the network, etc.
612
613 The 'journaltag' is a token that will be attached to the journal
614 entries for any edits done on the database. If 'journaltag' is
615 None, the database is opened in read-only mode: the Class.create(),
616 Class.set(), and Class.retire() methods are disabled.
617 """
618 raise NotImplementedError
619
620 def post_init(self):
621 """Called once the schema initialisation has finished.
622 If 'refresh' is true, we want to rebuild the backend
623 structures.
624 """
625 raise NotImplementedError
626
627 def refresh_database(self):
628 """Called to indicate that the backend should rebuild all tables
629 and structures. Not called in normal usage."""
630 raise NotImplementedError
631
632 def __getattr__(self, classname):
633 """A convenient way of calling self.getclass(classname)."""
634 raise NotImplementedError
635
636 def addclass(self, cl):
637 """Add a Class to the hyperdatabase.
638 """
639 raise NotImplementedError
640
641 def getclasses(self):
642 """Return a list of the names of all existing classes."""
643 raise NotImplementedError
644
645 def getclass(self, classname):
646 """Get the Class object representing a particular class.
647
648 If 'classname' is not a valid class name, a KeyError is raised.
649 """
650 raise NotImplementedError
651
652 def clear(self):
653 """Delete all database contents.
654 """
655 raise NotImplementedError
656
657 def getclassdb(self, classname, mode='r'):
658 """Obtain a connection to the class db that will be used for
659 multiple actions.
660 """
661 raise NotImplementedError
662
663 def addnode(self, classname, nodeid, node):
664 """Add the specified node to its class's db.
665 """
666 raise NotImplementedError
667
668 def serialise(self, classname, node):
669 """Copy the node contents, converting non-marshallable data into
670 marshallable data.
671 """
672 return node
673
674 def setnode(self, classname, nodeid, node):
675 """Change the specified node.
676 """
677 raise NotImplementedError
678
679 def unserialise(self, classname, node):
680 """Decode the marshalled node data
681 """
682 return node
683
684 def getnode(self, classname, nodeid):
685 """Get a node from the database.
686
687 'cache' exists for backwards compatibility, and is not used.
688 """
689 raise NotImplementedError
690
691 def hasnode(self, classname, nodeid):
692 """Determine if the database has a given node.
693 """
694 raise NotImplementedError
695
696 def countnodes(self, classname):
697 """Count the number of nodes that exist for a particular Class.
698 """
699 raise NotImplementedError
700
701 def storefile(self, classname, nodeid, property, content):
702 """Store the content of the file in the database.
703
704 The property may be None, in which case the filename does not
705 indicate which property is being saved.
706 """
707 raise NotImplementedError
708
709 def getfile(self, classname, nodeid, property):
710 """Get the content of the file in the database.
711 """
712 raise NotImplementedError
713
714 def addjournal(self, classname, nodeid, action, params):
715 """ Journal the Action
716 'action' may be:
717
718 'create' or 'set' -- 'params' is a dictionary of property values
719 'link' or 'unlink' -- 'params' is (classname, nodeid, propname)
720 'retire' -- 'params' is None
721 """
722 raise NotImplementedError
723
724 def getjournal(self, classname, nodeid):
725 """ get the journal for id
726 """
727 raise NotImplementedError
728
729 def pack(self, pack_before):
730 """ pack the database
731 """
732 raise NotImplementedError
733
734 def commit(self):
735 """ Commit the current transactions.
736
737 Save all data changed since the database was opened or since the
738 last commit() or rollback().
739
740 fail_ok indicates that the commit is allowed to fail. This is used
741 in the web interface when committing cleaning of the session
742 database. We don't care if there's a concurrency issue there.
743
744 The only backend this seems to affect is postgres.
745 """
746 raise NotImplementedError
747
748 def rollback(self):
749 """ Reverse all actions from the current transaction.
750
751 Undo all the changes made since the database was opened or the last
752 commit() or rollback() was performed.
753 """
754 raise NotImplementedError
755
756 def close(self):
757 """Close the database.
758
759 This method must be called at the end of processing.
760
761 """
762
763 def iter_roles(roles):
764 ''' handle the text processing of turning the roles list
765 into something python can use more easily
766 '''
767 if not roles or not roles.strip():
768 raise StopIteration, "Empty roles given"
769 for role in [x.lower().strip() for x in roles.split(',')]:
770 yield role
771
772
773 #
774 # The base Class class
775 #
776 class Class:
777 """ The handle to a particular class of nodes in a hyperdatabase.
778
779 All methods except __repr__ and getnode must be implemented by a
780 concrete backend Class.
781 """
782
783 def __init__(self, db, classname, **properties):
784 """Create a new class with a given name and property specification.
785
786 'classname' must not collide with the name of an existing class,
787 or a ValueError is raised. The keyword arguments in 'properties'
788 must map names to property objects, or a TypeError is raised.
789 """
790 for name in 'creation activity creator actor'.split():
791 if properties.has_key(name):
792 raise ValueError, '"creation", "activity", "creator" and '\
793 '"actor" are reserved'
794
795 self.classname = classname
796 self.properties = properties
797 self.db = weakref.proxy(db) # use a weak ref to avoid circularity
798 self.key = ''
799
800 # should we journal changes (default yes)
801 self.do_journal = 1
802
803 # do the db-related init stuff
804 db.addclass(self)
805
806 actions = "create set retire restore".split()
807 self.auditors = dict([(a, PrioList()) for a in actions])
808 self.reactors = dict([(a, PrioList()) for a in actions])
809
810 def __repr__(self):
811 """Slightly more useful representation
812 """
813 return '<hyperdb.Class "%s">'%self.classname
814
815 # Editing nodes:
816
817 def create(self, **propvalues):
818 """Create a new node of this class and return its id.
819
820 The keyword arguments in 'propvalues' map property names to values.
821
822 The values of arguments must be acceptable for the types of their
823 corresponding properties or a TypeError is raised.
824
825 If this class has a key property, it must be present and its value
826 must not collide with other key strings or a ValueError is raised.
827
828 Any other properties on this class that are missing from the
829 'propvalues' dictionary are set to None.
830
831 If an id in a link or multilink property does not refer to a valid
832 node, an IndexError is raised.
833 """
834 raise NotImplementedError
835
836 _marker = []
837 def get(self, nodeid, propname, default=_marker, cache=1):
838 """Get the value of a property on an existing node of this class.
839
840 'nodeid' must be the id of an existing node of this class or an
841 IndexError is raised. 'propname' must be the name of a property
842 of this class or a KeyError is raised.
843
844 'cache' exists for backwards compatibility, and is not used.
845 """
846 raise NotImplementedError
847
848 # not in spec
849 def getnode(self, nodeid):
850 """ Return a convenience wrapper for the node.
851
852 'nodeid' must be the id of an existing node of this class or an
853 IndexError is raised.
854
855 'cache' exists for backwards compatibility, and is not used.
856 """
857 return Node(self, nodeid)
858
859 def getnodeids(self, retired=None):
860 """Retrieve all the ids of the nodes for a particular Class.
861 """
862 raise NotImplementedError
863
864 def set(self, nodeid, **propvalues):
865 """Modify a property on an existing node of this class.
866
867 'nodeid' must be the id of an existing node of this class or an
868 IndexError is raised.
869
870 Each key in 'propvalues' must be the name of a property of this
871 class or a KeyError is raised.
872
873 All values in 'propvalues' must be acceptable types for their
874 corresponding properties or a TypeError is raised.
875
876 If the value of the key property is set, it must not collide with
877 other key strings or a ValueError is raised.
878
879 If the value of a Link or Multilink property contains an invalid
880 node id, a ValueError is raised.
881 """
882 raise NotImplementedError
883
884 def retire(self, nodeid):
885 """Retire a node.
886
887 The properties on the node remain available from the get() method,
888 and the node's id is never reused.
889
890 Retired nodes are not returned by the find(), list(), or lookup()
891 methods, and other nodes may reuse the values of their key properties.
892 """
893 raise NotImplementedError
894
895 def restore(self, nodeid):
896 """Restpre a retired node.
897
898 Make node available for all operations like it was before retirement.
899 """
900 raise NotImplementedError
901
902 def is_retired(self, nodeid):
903 """Return true if the node is rerired
904 """
905 raise NotImplementedError
906
907 def destroy(self, nodeid):
908 """Destroy a node.
909
910 WARNING: this method should never be used except in extremely rare
911 situations where there could never be links to the node being
912 deleted
913
914 WARNING: use retire() instead
915
916 WARNING: the properties of this node will not be available ever again
917
918 WARNING: really, use retire() instead
919
920 Well, I think that's enough warnings. This method exists mostly to
921 support the session storage of the cgi interface.
922
923 The node is completely removed from the hyperdb, including all journal
924 entries. It will no longer be available, and will generally break code
925 if there are any references to the node.
926 """
927
928 def history(self, nodeid):
929 """Retrieve the journal of edits on a particular node.
930
931 'nodeid' must be the id of an existing node of this class or an
932 IndexError is raised.
933
934 The returned list contains tuples of the form
935
936 (date, tag, action, params)
937
938 'date' is a Timestamp object specifying the time of the change and
939 'tag' is the journaltag specified when the database was opened.
940 """
941 raise NotImplementedError
942
943 # Locating nodes:
944 def hasnode(self, nodeid):
945 """Determine if the given nodeid actually exists
946 """
947 raise NotImplementedError
948
949 def setkey(self, propname):
950 """Select a String property of this class to be the key property.
951
952 'propname' must be the name of a String property of this class or
953 None, or a TypeError is raised. The values of the key property on
954 all existing nodes must be unique or a ValueError is raised.
955 """
956 raise NotImplementedError
957
958 def setlabelprop(self, labelprop):
959 """Set the label property. Used for override of labelprop
960 resolution order.
961 """
962 if labelprop not in self.getprops():
963 raise ValueError, _("Not a property name: %s") % labelprop
964 self._labelprop = labelprop
965
966 def setorderprop(self, orderprop):
967 """Set the order property. Used for override of orderprop
968 resolution order
969 """
970 if orderprop not in self.getprops():
971 raise ValueError, _("Not a property name: %s") % orderprop
972 self._orderprop = orderprop
973
974 def getkey(self):
975 """Return the name of the key property for this class or None."""
976 raise NotImplementedError
977
978 def labelprop(self, default_to_id=0):
979 """Return the property name for a label for the given node.
980
981 This method attempts to generate a consistent label for the node.
982 It tries the following in order:
983
984 0. self._labelprop if set
985 1. key property
986 2. "name" property
987 3. "title" property
988 4. first property from the sorted property name list
989 """
990 if hasattr(self, '_labelprop'):
991 return self._labelprop
992 k = self.getkey()
993 if k:
994 return k
995 props = self.getprops()
996 if props.has_key('name'):
997 return 'name'
998 elif props.has_key('title'):
999 return 'title'
1000 if default_to_id:
1001 return 'id'
1002 props = props.keys()
1003 props.sort()
1004 return props[0]
1005
1006 def orderprop(self):
1007 """Return the property name to use for sorting for the given node.
1008
1009 This method computes the property for sorting.
1010 It tries the following in order:
1011
1012 0. self._orderprop if set
1013 1. "order" property
1014 2. self.labelprop()
1015 """
1016
1017 if hasattr(self, '_orderprop'):
1018 return self._orderprop
1019 props = self.getprops()
1020 if props.has_key('order'):
1021 return 'order'
1022 return self.labelprop()
1023
1024 def lookup(self, keyvalue):
1025 """Locate a particular node by its key property and return its id.
1026
1027 If this class has no key property, a TypeError is raised. If the
1028 'keyvalue' matches one of the values for the key property among
1029 the nodes in this class, the matching node's id is returned;
1030 otherwise a KeyError is raised.
1031 """
1032 raise NotImplementedError
1033
1034 def find(self, **propspec):
1035 """Get the ids of nodes in this class which link to the given nodes.
1036
1037 'propspec' consists of keyword args propname={nodeid:1,}
1038 'propname' must be the name of a property in this class, or a
1039 KeyError is raised. That property must be a Link or Multilink
1040 property, or a TypeError is raised.
1041
1042 Any node in this class whose 'propname' property links to any of the
1043 nodeids will be returned. Used by the full text indexing, which knows
1044 that "foo" occurs in msg1, msg3 and file7, so we have hits on these
1045 issues:
1046
1047 db.issue.find(messages={'1':1,'3':1}, files={'7':1})
1048 """
1049 raise NotImplementedError
1050
1051 def _filter(self, search_matches, filterspec, sort=(None,None),
1052 group=(None,None)):
1053 """For some backends this implements the non-transitive
1054 search, for more information see the filter method.
1055 """
1056 raise NotImplementedError
1057
1058 def _proptree(self, filterspec, sortattr=[]):
1059 """Build a tree of all transitive properties in the given
1060 filterspec.
1061 """
1062 proptree = Proptree(self.db, self, '', self.getprops())
1063 for key, v in filterspec.iteritems():
1064 keys = key.split('.')
1065 p = proptree
1066 for k in keys:
1067 p = p.append(k)
1068 p.val = v
1069 multilinks = {}
1070 for s in sortattr:
1071 keys = s[1].split('.')
1072 p = proptree
1073 for k in keys:
1074 p = p.append(k, sort_type = 2)
1075 if isinstance (p.propclass, Multilink):
1076 multilinks[p] = True
1077 if p.cls:
1078 p = p.append(p.cls.orderprop(), sort_type = 2)
1079 if p.sort_direction: # if an orderprop is also specified explicitly
1080 continue
1081 p.sort_direction = s[0]
1082 proptree.sortattr.append (p)
1083 for p in multilinks.iterkeys():
1084 sattr = {}
1085 for c in p:
1086 if c.sort_direction:
1087 sattr [c] = True
1088 for sa in proptree.sortattr:
1089 if sa in sattr:
1090 p.sortattr.append (sa)
1091 return proptree
1092
1093 def get_transitive_prop(self, propname_path, default = None):
1094 """Expand a transitive property (individual property names
1095 separated by '.' into a new property at the end of the path. If
1096 one of the names does not refer to a valid property, we return
1097 None.
1098 Example propname_path (for class issue): "messages.author"
1099 """
1100 props = self.db.getclass(self.classname).getprops()
1101 for k in propname_path.split('.'):
1102 try:
1103 prop = props[k]
1104 except KeyError, TypeError:
1105 return default
1106 cl = getattr(prop, 'classname', None)
1107 props = None
1108 if cl:
1109 props = self.db.getclass(cl).getprops()
1110 return prop
1111
1112 def _sortattr(self, sort=[], group=[]):
1113 """Build a single list of sort attributes in the correct order
1114 with sanity checks (no duplicate properties) included. Always
1115 sort last by id -- if id is not already in sortattr.
1116 """
1117 seen = {}
1118 sortattr = []
1119 for srt in group, sort:
1120 if not isinstance(srt, list):
1121 srt = [srt]
1122 for s in srt:
1123 if s[1] and s[1] not in seen:
1124 sortattr.append((s[0] or '+', s[1]))
1125 seen[s[1]] = True
1126 if 'id' not in seen :
1127 sortattr.append(('+', 'id'))
1128 return sortattr
1129
1130 def filter(self, search_matches, filterspec, sort=[], group=[]):
1131 """Return a list of the ids of the active nodes in this class that
1132 match the 'filter' spec, sorted by the group spec and then the
1133 sort spec.
1134
1135 "filterspec" is {propname: value(s)}
1136
1137 "sort" and "group" are [(dir, prop), ...] where dir is '+', '-'
1138 or None and prop is a prop name or None. Note that for
1139 backward-compatibility reasons a single (dir, prop) tuple is
1140 also allowed.
1141
1142 "search_matches" is a container type
1143
1144 The filter must match all properties specificed. If the property
1145 value to match is a list:
1146
1147 1. String properties must match all elements in the list, and
1148 2. Other properties must match any of the elements in the list.
1149
1150 Note that now the propname in filterspec and prop in a
1151 sort/group spec may be transitive, i.e., it may contain
1152 properties of the form link.link.link.name, e.g. you can search
1153 for all issues where a message was added by a certain user in
1154 the last week with a filterspec of
1155 {'messages.author' : '42', 'messages.creation' : '.-1w;'}
1156
1157 Implementation note:
1158 This implements a non-optimized version of Transitive search
1159 using _filter implemented in a backend class. A more efficient
1160 version can be implemented in the individual backends -- e.g.,
1161 an SQL backen will want to create a single SQL statement and
1162 override the filter method instead of implementing _filter.
1163 """
1164 sortattr = self._sortattr(sort = sort, group = group)
1165 proptree = self._proptree(filterspec, sortattr)
1166 proptree.search(search_matches)
1167 return proptree.sort()
1168
1169 def count(self):
1170 """Get the number of nodes in this class.
1171
1172 If the returned integer is 'numnodes', the ids of all the nodes
1173 in this class run from 1 to numnodes, and numnodes+1 will be the
1174 id of the next node to be created in this class.
1175 """
1176 raise NotImplementedError
1177
1178 # Manipulating properties:
1179 def getprops(self, protected=1):
1180 """Return a dictionary mapping property names to property objects.
1181 If the "protected" flag is true, we include protected properties -
1182 those which may not be modified.
1183 """
1184 raise NotImplementedError
1185
1186 def get_required_props(self, propnames = []):
1187 """Return a dict of property names mapping to property objects.
1188 All properties that have the "required" flag set will be
1189 returned in addition to all properties in the propnames
1190 parameter.
1191 """
1192 props = self.getprops(protected = False)
1193 pdict = dict([(p, props[p]) for p in propnames])
1194 pdict.update([(k, v) for k, v in props.iteritems() if v.required])
1195 return pdict
1196
1197 def addprop(self, **properties):
1198 """Add properties to this class.
1199
1200 The keyword arguments in 'properties' must map names to property
1201 objects, or a TypeError is raised. None of the keys in 'properties'
1202 may collide with the names of existing properties, or a ValueError
1203 is raised before any properties have been added.
1204 """
1205 raise NotImplementedError
1206
1207 def index(self, nodeid):
1208 """Add (or refresh) the node to search indexes"""
1209 raise NotImplementedError
1210
1211 #
1212 # Detector interface
1213 #
1214 def audit(self, event, detector, priority = 100):
1215 """Register an auditor detector"""
1216 self.auditors[event].append((priority, detector.__name__, detector))
1217
1218 def fireAuditors(self, event, nodeid, newvalues):
1219 """Fire all registered auditors"""
1220 for prio, name, audit in self.auditors[event]:
1221 audit(self.db, self, nodeid, newvalues)
1222
1223 def react(self, event, detector, priority = 100):
1224 """Register a reactor detector"""
1225 self.reactors[event].append((priority, detector.__name__, detector))
1226
1227 def fireReactors(self, event, nodeid, oldvalues):
1228 """Fire all registered reactors"""
1229 for prio, name, react in self.reactors[event]:
1230 react(self.db, self, nodeid, oldvalues)
1231
1232 #
1233 # import / export support
1234 #
1235 def export_propnames(self):
1236 """List the property names for export from this Class"""
1237 propnames = self.getprops().keys()
1238 propnames.sort()
1239 return propnames
1240 #
1241 # convenience methods
1242 #
1243 def get_roles(self, nodeid):
1244 """Return iterator for all roles for this nodeid.
1245
1246 Yields string-processed roles.
1247 This method can be overridden to provide a hook where we can
1248 insert other permission models (e.g. get roles from database)
1249 In standard schemas only a user has a roles property but
1250 this may be different in customized schemas.
1251 Note that this is the *central place* where role
1252 processing happens!
1253 """
1254 node = self.db.getnode(self.classname, nodeid)
1255 return iter_roles(node['roles'])
1256
1257 def has_role(self, nodeid, *roles):
1258 '''See if this node has any roles that appear in roles.
1259
1260 For convenience reasons we take a list.
1261 In standard schemas only a user has a roles property but
1262 this may be different in customized schemas.
1263 '''
1264 roles = dict.fromkeys ([r.strip().lower() for r in roles])
1265 for role in self.get_roles(nodeid):
1266 if role in roles:
1267 return True
1268 return False
1269
1270
1271 class HyperdbValueError(ValueError):
1272 """ Error converting a raw value into a Hyperdb value """
1273 pass
1274
1275 def convertLinkValue(db, propname, prop, value, idre=re.compile('^\d+$')):
1276 """ Convert the link value (may be id or key value) to an id value. """
1277 linkcl = db.classes[prop.classname]
1278 if not idre.match(value):
1279 if linkcl.getkey():
1280 try:
1281 value = linkcl.lookup(value)
1282 except KeyError, message:
1283 raise HyperdbValueError, _('property %s: %r is not a %s.')%(
1284 propname, value, prop.classname)
1285 else:
1286 raise HyperdbValueError, _('you may only enter ID values '\
1287 'for property %s')%propname
1288 return value
1289
1290 def fixNewlines(text):
1291 """ Homogenise line endings.
1292
1293 Different web clients send different line ending values, but
1294 other systems (eg. email) don't necessarily handle those line
1295 endings. Our solution is to convert all line endings to LF.
1296 """
1297 text = text.replace('\r\n', '\n')
1298 return text.replace('\r', '\n')
1299
1300 def rawToHyperdb(db, klass, itemid, propname, value, **kw):
1301 """ Convert the raw (user-input) value to a hyperdb-storable value. The
1302 value is for the "propname" property on itemid (may be None for a
1303 new item) of "klass" in "db".
1304
1305 The value is usually a string, but in the case of multilink inputs
1306 it may be either a list of strings or a string with comma-separated
1307 values.
1308 """
1309 properties = klass.getprops()
1310
1311 # ensure it's a valid property name
1312 propname = propname.strip()
1313 try:
1314 proptype = properties[propname]
1315 except KeyError:
1316 raise HyperdbValueError, _('%r is not a property of %s')%(propname,
1317 klass.classname)
1318
1319 # if we got a string, strip it now
1320 if isinstance(value, type('')):
1321 value = value.strip()
1322
1323 # convert the input value to a real property value
1324 value = proptype.from_raw(value, db=db, klass=klass,
1325 propname=propname, itemid=itemid, **kw)
1326
1327 return value
1328
1329 class FileClass:
1330 """ A class that requires the "content" property and stores it on
1331 disk.
1332 """
1333 default_mime_type = 'text/plain'
1334
1335 def __init__(self, db, classname, **properties):
1336 """The newly-created class automatically includes the "content"
1337 property.
1338 """
1339 if not properties.has_key('content'):
1340 properties['content'] = String(indexme='yes')
1341
1342 def export_propnames(self):
1343 """ Don't export the "content" property
1344 """
1345 propnames = self.getprops().keys()
1346 propnames.remove('content')
1347 propnames.sort()
1348 return propnames
1349
1350 def exportFilename(self, dirname, nodeid):
1351 subdir_filename = self.db.subdirFilename(self.classname, nodeid)
1352 return os.path.join(dirname, self.classname+'-files', subdir_filename)
1353
1354 def export_files(self, dirname, nodeid):
1355 """ Export the "content" property as a file, not csv column
1356 """
1357 source = self.db.filename(self.classname, nodeid)
1358
1359 dest = self.exportFilename(dirname, nodeid)
1360 ensureParentsExist(dest)
1361 shutil.copyfile(source, dest)
1362
1363 def import_files(self, dirname, nodeid):
1364 """ Import the "content" property as a file
1365 """
1366 source = self.exportFilename(dirname, nodeid)
1367
1368 dest = self.db.filename(self.classname, nodeid, create=1)
1369 ensureParentsExist(dest)
1370 shutil.copyfile(source, dest)
1371
1372 mime_type = None
1373 props = self.getprops()
1374 if props.has_key('type'):
1375 mime_type = self.get(nodeid, 'type')
1376 if not mime_type:
1377 mime_type = self.default_mime_type
1378 if props['content'].indexme:
1379 self.db.indexer.add_text((self.classname, nodeid, 'content'),
1380 self.get(nodeid, 'content'), mime_type)
1381
1382 class Node:
1383 """ A convenience wrapper for the given node
1384 """
1385 def __init__(self, cl, nodeid, cache=1):
1386 self.__dict__['cl'] = cl
1387 self.__dict__['nodeid'] = nodeid
1388 def keys(self, protected=1):
1389 return self.cl.getprops(protected=protected).keys()
1390 def values(self, protected=1):
1391 l = []
1392 for name in self.cl.getprops(protected=protected).keys():
1393 l.append(self.cl.get(self.nodeid, name))
1394 return l
1395 def items(self, protected=1):
1396 l = []
1397 for name in self.cl.getprops(protected=protected).keys():
1398 l.append((name, self.cl.get(self.nodeid, name)))
1399 return l
1400 def has_key(self, name):
1401 return self.cl.getprops().has_key(name)
1402 def get(self, name, default=None):
1403 if self.has_key(name):
1404 return self[name]
1405 else:
1406 return default
1407 def __getattr__(self, name):
1408 if self.__dict__.has_key(name):
1409 return self.__dict__[name]
1410 try:
1411 return self.cl.get(self.nodeid, name)
1412 except KeyError, value:
1413 # we trap this but re-raise it as AttributeError - all other
1414 # exceptions should pass through untrapped
1415 pass
1416 # nope, no such attribute
1417 raise AttributeError, str(value)
1418 def __getitem__(self, name):
1419 return self.cl.get(self.nodeid, name)
1420 def __setattr__(self, name, value):
1421 try:
1422 return self.cl.set(self.nodeid, **{name: value})
1423 except KeyError, value:
1424 raise AttributeError, str(value)
1425 def __setitem__(self, name, value):
1426 self.cl.set(self.nodeid, **{name: value})
1427 def history(self):
1428 return self.cl.history(self.nodeid)
1429 def retire(self):
1430 return self.cl.retire(self.nodeid)
1431
1432
1433 def Choice(name, db, *options):
1434 """Quick helper to create a simple class with choices
1435 """
1436 cl = Class(db, name, name=String(), order=String())
1437 for i in range(len(options)):
1438 cl.create(name=options[i], order=i)
1439 return Link(name)
1440
1441 # vim: set filetype=python sts=4 sw=4 et si :