Code

Fixed "documentation" of getnodeids in roundup.hyperdb
[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 # $Id: hyperdb.py,v 1.90 2003-10-24 22:52:48 richard Exp $
20 """
21 Hyperdatabase implementation, especially field types.
22 """
24 # standard python modules
25 import sys, os, time, re
27 # roundup modules
28 import date, password
30 # configure up the DEBUG and TRACE captures
31 class Sink:
32     def write(self, content):
33         pass
34 DEBUG = os.environ.get('HYPERDBDEBUG', '')
35 if DEBUG and __debug__:
36     if DEBUG == 'stdout':
37         DEBUG = sys.stdout
38     else:
39         DEBUG = open(DEBUG, 'a')
40 else:
41     DEBUG = Sink()
42 TRACE = os.environ.get('HYPERDBTRACE', '')
43 if TRACE and __debug__:
44     if TRACE == 'stdout':
45         TRACE = sys.stdout
46     else:
47         TRACE = open(TRACE, 'w')
48 else:
49     TRACE = Sink()
50 def traceMark():
51     print >>TRACE, '**MARK', time.ctime()
52 del Sink
54 #
55 # Types
56 #
57 class String:
58     """An object designating a String property."""
59     def __init__(self, indexme='no'):
60         self.indexme = indexme == 'yes'
61     def __repr__(self):
62         ' more useful for dumps '
63         return '<%s>'%self.__class__
65 class Password:
66     """An object designating a Password property."""
67     def __repr__(self):
68         ' more useful for dumps '
69         return '<%s>'%self.__class__
71 class Date:
72     """An object designating a Date property."""
73     def __repr__(self):
74         ' more useful for dumps '
75         return '<%s>'%self.__class__
77 class Interval:
78     """An object designating an Interval property."""
79     def __repr__(self):
80         ' more useful for dumps '
81         return '<%s>'%self.__class__
83 class Link:
84     """An object designating a Link property that links to a
85        node in a specified class."""
86     def __init__(self, classname, do_journal='yes'):
87         ''' Default is to not journal link and unlink events
88         '''
89         self.classname = classname
90         self.do_journal = do_journal == 'yes'
91     def __repr__(self):
92         ' more useful for dumps '
93         return '<%s to "%s">'%(self.__class__, self.classname)
95 class Multilink:
96     """An object designating a Multilink property that links
97        to nodes in a specified class.
99        "classname" indicates the class to link to
101        "do_journal" indicates whether the linked-to nodes should have
102                     'link' and 'unlink' events placed in their journal
103     """
104     def __init__(self, classname, do_journal='yes'):
105         ''' Default is to not journal link and unlink events
106         '''
107         self.classname = classname
108         self.do_journal = do_journal == 'yes'
109     def __repr__(self):
110         ' more useful for dumps '
111         return '<%s to "%s">'%(self.__class__, self.classname)
113 class Boolean:
114     """An object designating a boolean property"""
115     def __repr__(self):
116         'more useful for dumps'
117         return '<%s>' % self.__class__
118     
119 class Number:
120     """An object designating a numeric property"""
121     def __repr__(self):
122         'more useful for dumps'
123         return '<%s>' % self.__class__
125 # Support for splitting designators
127 class DesignatorError(ValueError):
128     pass
129 def splitDesignator(designator, dre=re.compile(r'([^\d]+)(\d+)')):
130     ''' Take a foo123 and return ('foo', 123)
131     '''
132     m = dre.match(designator)
133     if m is None:
134         raise DesignatorError, '"%s" not a node designator'%designator
135     return m.group(1), m.group(2)
138 # the base Database class
140 class DatabaseError(ValueError):
141     '''Error to be raised when there is some problem in the database code
142     '''
143     pass
144 class Database:
145     '''A database for storing records containing flexible data types.
147 This class defines a hyperdatabase storage layer, which the Classes use to
148 store their data.
151 Transactions
152 ------------
153 The Database should support transactions through the commit() and
154 rollback() methods. All other Database methods should be transaction-aware,
155 using data from the current transaction before looking up the database.
157 An implementation must provide an override for the get() method so that the
158 in-database value is returned in preference to the in-transaction value.
159 This is necessary to determine if any values have changed during a
160 transaction.
163 Implementation
164 --------------
166 All methods except __repr__ and getnode must be implemented by a
167 concrete backend Class.
169 '''
171     # flag to set on retired entries
172     RETIRED_FLAG = '__hyperdb_retired'
174     def __init__(self, config, journaltag=None):
175         """Open a hyperdatabase given a specifier to some storage.
177         The 'storagelocator' is obtained from config.DATABASE.
178         The meaning of 'storagelocator' depends on the particular
179         implementation of the hyperdatabase.  It could be a file name,
180         a directory path, a socket descriptor for a connection to a
181         database over the network, etc.
183         The 'journaltag' is a token that will be attached to the journal
184         entries for any edits done on the database.  If 'journaltag' is
185         None, the database is opened in read-only mode: the Class.create(),
186         Class.set(), and Class.retire() methods are disabled.
187         """
188         raise NotImplementedError
190     def post_init(self):
191         """Called once the schema initialisation has finished. 
192            If 'refresh' is true, we want to rebuild the backend
193            structures.
194         """
195         raise NotImplementedError
197     def refresh_database(self):
198         """Called to indicate that the backend should rebuild all tables
199            and structures. Not called in normal usage."""
200         raise NotImplementedError
202     def __getattr__(self, classname):
203         """A convenient way of calling self.getclass(classname)."""
204         raise NotImplementedError
206     def addclass(self, cl):
207         '''Add a Class to the hyperdatabase.
208         '''
209         raise NotImplementedError
211     def getclasses(self):
212         """Return a list of the names of all existing classes."""
213         raise NotImplementedError
215     def getclass(self, classname):
216         """Get the Class object representing a particular class.
218         If 'classname' is not a valid class name, a KeyError is raised.
219         """
220         raise NotImplementedError
222     def clear(self):
223         '''Delete all database contents.
224         '''
225         raise NotImplementedError
227     def getclassdb(self, classname, mode='r'):
228         '''Obtain a connection to the class db that will be used for
229            multiple actions.
230         '''
231         raise NotImplementedError
233     def addnode(self, classname, nodeid, node):
234         '''Add the specified node to its class's db.
235         '''
236         raise NotImplementedError
238     def serialise(self, classname, node):
239         '''Copy the node contents, converting non-marshallable data into
240            marshallable data.
241         '''
242         return node
244     def setnode(self, classname, nodeid, node):
245         '''Change the specified node.
246         '''
247         raise NotImplementedError
249     def unserialise(self, classname, node):
250         '''Decode the marshalled node data
251         '''
252         return node
254     def getnode(self, classname, nodeid, db=None, cache=1):
255         '''Get a node from the database.
257         'cache' exists for backwards compatibility, and is not used.
258         '''
259         raise NotImplementedError
261     def hasnode(self, classname, nodeid, db=None):
262         '''Determine if the database has a given node.
263         '''
264         raise NotImplementedError
266     def countnodes(self, classname, db=None):
267         '''Count the number of nodes that exist for a particular Class.
268         '''
269         raise NotImplementedError
271     def storefile(self, classname, nodeid, property, content):
272         '''Store the content of the file in the database.
273         
274            The property may be None, in which case the filename does not
275            indicate which property is being saved.
276         '''
277         raise NotImplementedError
279     def getfile(self, classname, nodeid, property):
280         '''Store the content of the file in the database.
281         '''
282         raise NotImplementedError
284     def addjournal(self, classname, nodeid, action, params):
285         ''' Journal the Action
286         'action' may be:
288             'create' or 'set' -- 'params' is a dictionary of property values
289             'link' or 'unlink' -- 'params' is (classname, nodeid, propname)
290             'retire' -- 'params' is None
291         '''
292         raise NotImplementedError
294     def getjournal(self, classname, nodeid):
295         ''' get the journal for id
296         '''
297         raise NotImplementedError
299     def pack(self, pack_before):
300         ''' pack the database
301         '''
302         raise NotImplementedError
304     def commit(self):
305         ''' Commit the current transactions.
307         Save all data changed since the database was opened or since the
308         last commit() or rollback().
309         '''
310         raise NotImplementedError
312     def rollback(self):
313         ''' Reverse all actions from the current transaction.
315         Undo all the changes made since the database was opened or the last
316         commit() or rollback() was performed.
317         '''
318         raise NotImplementedError
321 # The base Class class
323 class Class:
324     """ The handle to a particular class of nodes in a hyperdatabase.
325         
326         All methods except __repr__ and getnode must be implemented by a
327         concrete backend Class.
328     """
330     def __init__(self, db, classname, **properties):
331         """Create a new class with a given name and property specification.
333         'classname' must not collide with the name of an existing class,
334         or a ValueError is raised.  The keyword arguments in 'properties'
335         must map names to property objects, or a TypeError is raised.
336         """
337         raise NotImplementedError
339     def __repr__(self):
340         '''Slightly more useful representation
341         '''
342         return '<hyperdb.Class "%s">'%self.classname
344     # Editing nodes:
346     def create(self, **propvalues):
347         """Create a new node of this class and return its id.
349         The keyword arguments in 'propvalues' map property names to values.
351         The values of arguments must be acceptable for the types of their
352         corresponding properties or a TypeError is raised.
353         
354         If this class has a key property, it must be present and its value
355         must not collide with other key strings or a ValueError is raised.
356         
357         Any other properties on this class that are missing from the
358         'propvalues' dictionary are set to None.
359         
360         If an id in a link or multilink property does not refer to a valid
361         node, an IndexError is raised.
362         """
363         raise NotImplementedError
365     _marker = []
366     def get(self, nodeid, propname, default=_marker, cache=1):
367         """Get the value of a property on an existing node of this class.
369         'nodeid' must be the id of an existing node of this class or an
370         IndexError is raised.  'propname' must be the name of a property
371         of this class or a KeyError is raised.
373         'cache' exists for backwards compatibility, and is not used.
374         """
375         raise NotImplementedError
377     def getnode(self, nodeid, cache=1):
378         ''' Return a convenience wrapper for the node.
380         'nodeid' must be the id of an existing node of this class or an
381         IndexError is raised.
383         'cache' exists for backwards compatibility, and is not used.
384         '''
385         return Node(self, nodeid)
387     def getnodeids(self, db=None):
388         '''Retrieve all the ids of the nodes for a particular Class.
389         '''
390         raise NotImplementedError
392     def set(self, nodeid, **propvalues):
393         """Modify a property on an existing node of this class.
394         
395         'nodeid' must be the id of an existing node of this class or an
396         IndexError is raised.
398         Each key in 'propvalues' must be the name of a property of this
399         class or a KeyError is raised.
401         All values in 'propvalues' must be acceptable types for their
402         corresponding properties or a TypeError is raised.
404         If the value of the key property is set, it must not collide with
405         other key strings or a ValueError is raised.
407         If the value of a Link or Multilink property contains an invalid
408         node id, a ValueError is raised.
409         """
410         raise NotImplementedError
412     def retire(self, nodeid):
413         """Retire a node.
414         
415         The properties on the node remain available from the get() method,
416         and the node's id is never reused.
417         
418         Retired nodes are not returned by the find(), list(), or lookup()
419         methods, and other nodes may reuse the values of their key properties.
420         """
421         raise NotImplementedError
423     def restore(self, nodeid):
424         '''Restpre a retired node.
426         Make node available for all operations like it was before retirement.
427         '''
428         raise NotImplementedError
429     
430     def is_retired(self, nodeid):
431         '''Return true if the node is rerired
432         '''
433         raise NotImplementedError
435     def destroy(self, nodeid):
436         """Destroy a node.
437         
438         WARNING: this method should never be used except in extremely rare
439                  situations where there could never be links to the node being
440                  deleted
441         WARNING: use retire() instead
442         WARNING: the properties of this node will not be available ever again
443         WARNING: really, use retire() instead
445         Well, I think that's enough warnings. This method exists mostly to
446         support the session storage of the cgi interface.
448         The node is completely removed from the hyperdb, including all journal
449         entries. It will no longer be available, and will generally break code
450         if there are any references to the node.
451         """
453     def history(self, nodeid):
454         """Retrieve the journal of edits on a particular node.
456         'nodeid' must be the id of an existing node of this class or an
457         IndexError is raised.
459         The returned list contains tuples of the form
461             (date, tag, action, params)
463         'date' is a Timestamp object specifying the time of the change and
464         'tag' is the journaltag specified when the database was opened.
465         """
466         raise NotImplementedError
468     # Locating nodes:
469     def hasnode(self, nodeid):
470         '''Determine if the given nodeid actually exists
471         '''
472         raise NotImplementedError
474     def setkey(self, propname):
475         """Select a String property of this class to be the key property.
477         'propname' must be the name of a String property of this class or
478         None, or a TypeError is raised.  The values of the key property on
479         all existing nodes must be unique or a ValueError is raised.
480         """
481         raise NotImplementedError
483     def getkey(self):
484         """Return the name of the key property for this class or None."""
485         raise NotImplementedError
487     def labelprop(self, default_to_id=0):
488         ''' Return the property name for a label for the given node.
490         This method attempts to generate a consistent label for the node.
491         It tries the following in order:
492             1. key property
493             2. "name" property
494             3. "title" property
495             4. first property from the sorted property name list
496         '''
497         raise NotImplementedError
499     def lookup(self, keyvalue):
500         """Locate a particular node by its key property and return its id.
502         If this class has no key property, a TypeError is raised.  If the
503         'keyvalue' matches one of the values for the key property among
504         the nodes in this class, the matching node's id is returned;
505         otherwise a KeyError is raised.
506         """
507         raise NotImplementedError
509     def find(self, **propspec):
510         """Get the ids of nodes in this class which link to the given nodes.
512         'propspec' consists of keyword args propname={nodeid:1,}   
513         'propname' must be the name of a property in this class, or a
514         KeyError is raised.  That property must be a Link or Multilink
515         property, or a TypeError is raised.
517         Any node in this class whose 'propname' property links to any of the
518         nodeids will be returned. Used by the full text indexing, which knows
519         that "foo" occurs in msg1, msg3 and file7, so we have hits on these
520         issues:
522             db.issue.find(messages={'1':1,'3':1}, files={'7':1})
523         """
524         raise NotImplementedError
526     def filter(self, search_matches, filterspec, sort=(None,None),
527             group=(None,None)):
528         ''' Return a list of the ids of the active nodes in this class that
529             match the 'filter' spec, sorted by the group spec and then the
530             sort spec.
532             "filterspec" is {propname: value(s)}
533             "sort" and "group" are (dir, prop) where dir is '+', '-' or None
534                                and prop is a prop name or None
535             "search_matches" is {nodeid: marker}
537             The filter must match all properties specificed - but if the
538             property value to match is a list, any one of the values in the
539             list may match for that property to match.
540         '''
541         raise NotImplementedError
543     def count(self):
544         """Get the number of nodes in this class.
546         If the returned integer is 'numnodes', the ids of all the nodes
547         in this class run from 1 to numnodes, and numnodes+1 will be the
548         id of the next node to be created in this class.
549         """
550         raise NotImplementedError
552     # Manipulating properties:
553     def getprops(self, protected=1):
554         """Return a dictionary mapping property names to property objects.
555            If the "protected" flag is true, we include protected properties -
556            those which may not be modified.
557         """
558         raise NotImplementedError
560     def addprop(self, **properties):
561         """Add properties to this class.
563         The keyword arguments in 'properties' must map names to property
564         objects, or a TypeError is raised.  None of the keys in 'properties'
565         may collide with the names of existing properties, or a ValueError
566         is raised before any properties have been added.
567         """
568         raise NotImplementedError
570     def index(self, nodeid):
571         '''Add (or refresh) the node to search indexes
572         '''
573         raise NotImplementedError
575 class FileClass:
576     ''' A class that requires the "content" property and stores it on
577         disk.
578     '''
579     pass
581 class Node:
582     ''' A convenience wrapper for the given node
583     '''
584     def __init__(self, cl, nodeid, cache=1):
585         self.__dict__['cl'] = cl
586         self.__dict__['nodeid'] = nodeid
587     def keys(self, protected=1):
588         return self.cl.getprops(protected=protected).keys()
589     def values(self, protected=1):
590         l = []
591         for name in self.cl.getprops(protected=protected).keys():
592             l.append(self.cl.get(self.nodeid, name))
593         return l
594     def items(self, protected=1):
595         l = []
596         for name in self.cl.getprops(protected=protected).keys():
597             l.append((name, self.cl.get(self.nodeid, name)))
598         return l
599     def has_key(self, name):
600         return self.cl.getprops().has_key(name)
601     def get(self, name, default=None): 
602         if self.has_key(name):
603             return self[name]
604         else:
605             return default
606     def __getattr__(self, name):
607         if self.__dict__.has_key(name):
608             return self.__dict__[name]
609         try:
610             return self.cl.get(self.nodeid, name)
611         except KeyError, value:
612             # we trap this but re-raise it as AttributeError - all other
613             # exceptions should pass through untrapped
614             pass
615         # nope, no such attribute
616         raise AttributeError, str(value)
617     def __getitem__(self, name):
618         return self.cl.get(self.nodeid, name)
619     def __setattr__(self, name, value):
620         try:
621             return self.cl.set(self.nodeid, **{name: value})
622         except KeyError, value:
623             raise AttributeError, str(value)
624     def __setitem__(self, name, value):
625         self.cl.set(self.nodeid, **{name: value})
626     def history(self):
627         return self.cl.history(self.nodeid)
628     def retire(self):
629         return self.cl.retire(self.nodeid)
632 def Choice(name, db, *options):
633     '''Quick helper to create a simple class with choices
634     '''
635     cl = Class(db, name, name=String(), order=String())
636     for i in range(len(options)):
637         cl.create(name=options[i], order=i)
638     return hyperdb.Link(name)
640 # vim: set filetype=python ts=4 sw=4 et si