Code

4ea2a2dcdccdc5ad160733de44e8b47b9289327b
[roundup.git] / roundup / backends / back_anydbm.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 """This module defines a backend that saves the hyperdatabase in a
19 database chosen by anydbm. It is guaranteed to always be available in python
20 versions >2.1.1 (the dumbdbm fallback in 2.1.1 and earlier has several
21 serious bugs, and is not available)
22 """
23 __docformat__ = 'restructuredtext'
25 import os, marshal, re, weakref, string, copy, time, shutil, logging
27 from roundup.anypy.dbm_ import anydbm, whichdb, key_in
29 from roundup import hyperdb, date, password, roundupdb, security, support
30 from roundup.support import reversed
31 from roundup.backends import locking
32 from roundup.i18n import _
34 from roundup.backends.blobfiles import FileStorage
35 from roundup.backends.sessions_dbm import Sessions, OneTimeKeys
37 try:
38     from roundup.backends.indexer_xapian import Indexer
39 except ImportError:
40     from roundup.backends.indexer_dbm import Indexer
42 def db_exists(config):
43     # check for the user db
44     for db in 'nodes.user nodes.user.db'.split():
45         if os.path.exists(os.path.join(config.DATABASE, db)):
46             return 1
47     return 0
49 def db_nuke(config):
50     shutil.rmtree(config.DATABASE)
52 class Binary:
54     def __init__(self, x, y):
55         self.x = x
56         self.y = y
58     def visit(self, visitor):
59         self.x.visit(visitor)
60         self.y.visit(visitor)
62 class Unary:
64     def __init__(self, x):
65         self.x = x
67     def generate(self, atom):
68         return atom(self)
70     def visit(self, visitor):
71         self.x.visit(visitor)
73 class Equals(Unary):
75     def evaluate(self, v):
76         return self.x in v
78     def visit(self, visitor):
79         visitor(self)
81 class Not(Unary):
83     def evaluate(self, v):
84         return not self.x.evaluate(v)
86     def generate(self, atom):
87         return "NOT(%s)" % self.x.generate(atom)
89 class Or(Binary):
91     def evaluate(self, v):
92         return self.x.evaluate(v) or self.y.evaluate(v)
94     def generate(self, atom):
95         return "(%s)OR(%s)" % (
96             self.x.generate(atom),
97             self.y.generate(atom))
99 class And(Binary):
101     def evaluate(self, v):
102         return self.x.evaluate(v) and self.y.evaluate(v)
104     def generate(self, atom):
105         return "(%s)AND(%s)" % (
106             self.x.generate(atom),
107             self.y.generate(atom))
109 def compile_expression(opcodes):
111     stack = []
112     push, pop = stack.append, stack.pop
113     for opcode in opcodes:
114         if   opcode == -2: push(Not(pop()))
115         elif opcode == -3: push(And(pop(), pop()))
116         elif opcode == -4: push(Or(pop(), pop()))
117         else:              push(Equals(opcode))
119     return pop()
121 class Expression:
123     def __init__(self, v):
124         try:
125             opcodes = [int(x) for x in v]
126             if min(opcodes) >= -1: raise ValueError()
128             compiled = compile_expression(opcodes)
129             self.evaluate = lambda x: compiled.evaluate([int(y) for y in x])
130         except:
131             self.evaluate = lambda x: bool(set(x) & set(v))
134 # Now the database
136 class Database(FileStorage, hyperdb.Database, roundupdb.Database):
137     """A database for storing records containing flexible data types.
139     Transaction stuff TODO:
141     - check the timestamp of the class file and nuke the cache if it's
142       modified. Do some sort of conflict checking on the dirty stuff.
143     - perhaps detect write collisions (related to above)?
144     """
145     def __init__(self, config, journaltag=None):
146         """Open a hyperdatabase given a specifier to some storage.
148         The 'storagelocator' is obtained from config.DATABASE.
149         The meaning of 'storagelocator' depends on the particular
150         implementation of the hyperdatabase.  It could be a file name,
151         a directory path, a socket descriptor for a connection to a
152         database over the network, etc.
154         The 'journaltag' is a token that will be attached to the journal
155         entries for any edits done on the database.  If 'journaltag' is
156         None, the database is opened in read-only mode: the Class.create(),
157         Class.set(), Class.retire(), and Class.restore() methods are
158         disabled.
159         """
160         FileStorage.__init__(self, config.UMASK)
161         self.config, self.journaltag = config, journaltag
162         self.dir = config.DATABASE
163         self.classes = {}
164         self.cache = {}         # cache of nodes loaded or created
165         self.stats = {'cache_hits': 0, 'cache_misses': 0, 'get_items': 0,
166             'filtering': 0}
167         self.dirtynodes = {}    # keep track of the dirty nodes by class
168         self.newnodes = {}      # keep track of the new nodes by class
169         self.destroyednodes = {}# keep track of the destroyed nodes by class
170         self.transactions = []
171         self.indexer = Indexer(self)
172         self.security = security.Security(self)
173         os.umask(config.UMASK)
175         # lock it
176         lockfilenm = os.path.join(self.dir, 'lock')
177         self.lockfile = locking.acquire_lock(lockfilenm)
178         self.lockfile.write(str(os.getpid()))
179         self.lockfile.flush()
181     def post_init(self):
182         """Called once the schema initialisation has finished.
183         """
184         # reindex the db if necessary
185         if self.indexer.should_reindex():
186             self.reindex()
188     def refresh_database(self):
189         """Rebuild the database
190         """
191         self.reindex()
193     def getSessionManager(self):
194         return Sessions(self)
196     def getOTKManager(self):
197         return OneTimeKeys(self)
199     def reindex(self, classname=None, show_progress=False):
200         if classname:
201             classes = [self.getclass(classname)]
202         else:
203             classes = self.classes.values()
204         for klass in classes:
205             if show_progress:
206                 for nodeid in support.Progress('Reindex %s'%klass.classname,
207                         klass.list()):
208                     klass.index(nodeid)
209             else:
210                 for nodeid in klass.list():
211                     klass.index(nodeid)
212         self.indexer.save_index()
214     def __repr__(self):
215         return '<back_anydbm instance at %x>'%id(self)
217     #
218     # Classes
219     #
220     def __getattr__(self, classname):
221         """A convenient way of calling self.getclass(classname)."""
222         if classname in self.classes:
223             return self.classes[classname]
224         raise AttributeError, classname
226     def addclass(self, cl):
227         cn = cl.classname
228         if cn in self.classes:
229             raise ValueError, cn
230         self.classes[cn] = cl
232         # add default Edit and View permissions
233         self.security.addPermission(name="Create", klass=cn,
234             description="User is allowed to create "+cn)
235         self.security.addPermission(name="Edit", klass=cn,
236             description="User is allowed to edit "+cn)
237         self.security.addPermission(name="View", klass=cn,
238             description="User is allowed to access "+cn)
240     def getclasses(self):
241         """Return a list of the names of all existing classes."""
242         l = self.classes.keys()
243         l.sort()
244         return l
246     def getclass(self, classname):
247         """Get the Class object representing a particular class.
249         If 'classname' is not a valid class name, a KeyError is raised.
250         """
251         try:
252             return self.classes[classname]
253         except KeyError:
254             raise KeyError('There is no class called "%s"'%classname)
256     #
257     # Class DBs
258     #
259     def clear(self):
260         """Delete all database contents
261         """
262         logging.getLogger('roundup.hyperdb').info('clear')
263         for cn in self.classes:
264             for dummy in 'nodes', 'journals':
265                 path = os.path.join(self.dir, 'journals.%s'%cn)
266                 if os.path.exists(path):
267                     os.remove(path)
268                 elif os.path.exists(path+'.db'):    # dbm appends .db
269                     os.remove(path+'.db')
270         # reset id sequences
271         path = os.path.join(os.getcwd(), self.dir, '_ids')
272         if os.path.exists(path):
273             os.remove(path)
274         elif os.path.exists(path+'.db'):    # dbm appends .db
275             os.remove(path+'.db')
277     def getclassdb(self, classname, mode='r'):
278         """ grab a connection to the class db that will be used for
279             multiple actions
280         """
281         return self.opendb('nodes.%s'%classname, mode)
283     def determine_db_type(self, path):
284         """ determine which DB wrote the class file
285         """
286         db_type = ''
287         if os.path.exists(path):
288             db_type = whichdb(path)
289             if not db_type:
290                 raise hyperdb.DatabaseError(_("Couldn't identify database type"))
291         elif os.path.exists(path+'.db'):
292             # if the path ends in '.db', it's a dbm database, whether
293             # anydbm says it's dbhash or not!
294             db_type = 'dbm'
295         return db_type
297     def opendb(self, name, mode):
298         """Low-level database opener that gets around anydbm/dbm
299            eccentricities.
300         """
301         # figure the class db type
302         path = os.path.join(os.getcwd(), self.dir, name)
303         db_type = self.determine_db_type(path)
305         # new database? let anydbm pick the best dbm
306         # in Python 3+ the "dbm" ("anydbm" to us) module already uses the
307         # whichdb() function to do this
308         if not db_type or hasattr(anydbm, 'whichdb'):
309             if __debug__:
310                 logging.getLogger('roundup.hyperdb').debug(
311                     "opendb anydbm.open(%r, 'c')"%path)
312             return anydbm.open(path, 'c')
314         # in Python <3 it anydbm was a little dumb so manually open the
315         # database with the correct module
316         try:
317             dbm = __import__(db_type)
318         except ImportError:
319             raise hyperdb.DatabaseError(_("Couldn't open database - the "
320                 "required module '%s' is not available")%db_type)
321         if __debug__:
322             logging.getLogger('roundup.hyperdb').debug(
323                 "opendb %r.open(%r, %r)"%(db_type, path, mode))
324         return dbm.open(path, mode)
326     #
327     # Node IDs
328     #
329     def newid(self, classname):
330         """ Generate a new id for the given class
331         """
332         # open the ids DB - create if if doesn't exist
333         db = self.opendb('_ids', 'c')
334         if key_in(db, classname):
335             newid = db[classname] = str(int(db[classname]) + 1)
336         else:
337             # the count() bit is transitional - older dbs won't start at 1
338             newid = str(self.getclass(classname).count()+1)
339             db[classname] = newid
340         db.close()
341         return newid
343     def setid(self, classname, setid):
344         """ Set the id counter: used during import of database
345         """
346         # open the ids DB - create if if doesn't exist
347         db = self.opendb('_ids', 'c')
348         db[classname] = str(setid)
349         db.close()
351     #
352     # Nodes
353     #
354     def addnode(self, classname, nodeid, node):
355         """ add the specified node to its class's db
356         """
357         # we'll be supplied these props if we're doing an import
358         if 'creator' not in node:
359             # add in the "calculated" properties (dupe so we don't affect
360             # calling code's node assumptions)
361             node = node.copy()
362             node['creator'] = self.getuid()
363             node['actor'] = self.getuid()
364             node['creation'] = node['activity'] = date.Date()
366         self.newnodes.setdefault(classname, {})[nodeid] = 1
367         self.cache.setdefault(classname, {})[nodeid] = node
368         self.savenode(classname, nodeid, node)
370     def setnode(self, classname, nodeid, node):
371         """ change the specified node
372         """
373         self.dirtynodes.setdefault(classname, {})[nodeid] = 1
375         # can't set without having already loaded the node
376         self.cache[classname][nodeid] = node
377         self.savenode(classname, nodeid, node)
379     def savenode(self, classname, nodeid, node):
380         """ perform the saving of data specified by the set/addnode
381         """
382         if __debug__:
383             logging.getLogger('roundup.hyperdb').debug(
384                 'save %s%s %r'%(classname, nodeid, node))
385         self.transactions.append((self.doSaveNode, (classname, nodeid, node)))
387     def getnode(self, classname, nodeid, db=None, cache=1):
388         """ get a node from the database
390             Note the "cache" parameter is not used, and exists purely for
391             backward compatibility!
392         """
393         # try the cache
394         cache_dict = self.cache.setdefault(classname, {})
395         if nodeid in cache_dict:
396             if __debug__:
397                 logging.getLogger('roundup.hyperdb').debug(
398                     'get %s%s cached'%(classname, nodeid))
399                 self.stats['cache_hits'] += 1
400             return cache_dict[nodeid]
402         if __debug__:
403             self.stats['cache_misses'] += 1
404             start_t = time.time()
405             logging.getLogger('roundup.hyperdb').debug(
406                 'get %s%s'%(classname, nodeid))
408         # get from the database and save in the cache
409         if db is None:
410             db = self.getclassdb(classname)
411         if not key_in(db, nodeid):
412             raise IndexError("no such %s %s"%(classname, nodeid))
414         # check the uncommitted, destroyed nodes
415         if (classname in self.destroyednodes and
416                 nodeid in self.destroyednodes[classname]):
417             raise IndexError("no such %s %s"%(classname, nodeid))
419         # decode
420         res = marshal.loads(db[nodeid])
422         # reverse the serialisation
423         res = self.unserialise(classname, res)
425         # store off in the cache dict
426         if cache:
427             cache_dict[nodeid] = res
429         if __debug__:
430             self.stats['get_items'] += (time.time() - start_t)
432         return res
434     def destroynode(self, classname, nodeid):
435         """Remove a node from the database. Called exclusively by the
436            destroy() method on Class.
437         """
438         logging.getLogger('roundup.hyperdb').info(
439             'destroy %s%s'%(classname, nodeid))
441         # remove from cache and newnodes if it's there
442         if (classname in self.cache and nodeid in self.cache[classname]):
443             del self.cache[classname][nodeid]
444         if (classname in self.newnodes and nodeid in self.newnodes[classname]):
445             del self.newnodes[classname][nodeid]
447         # see if there's any obvious commit actions that we should get rid of
448         for entry in self.transactions[:]:
449             if entry[1][:2] == (classname, nodeid):
450                 self.transactions.remove(entry)
452         # add to the destroyednodes map
453         self.destroyednodes.setdefault(classname, {})[nodeid] = 1
455         # add the destroy commit action
456         self.transactions.append((self.doDestroyNode, (classname, nodeid)))
457         self.transactions.append((FileStorage.destroy, (self, classname, nodeid)))
459     def serialise(self, classname, node):
460         """Copy the node contents, converting non-marshallable data into
461            marshallable data.
462         """
463         properties = self.getclass(classname).getprops()
464         d = {}
465         for k, v in node.iteritems():
466             if k == self.RETIRED_FLAG:
467                 d[k] = v
468                 continue
470             # if the property doesn't exist then we really don't care
471             if k not in properties:
472                 continue
474             # get the property spec
475             prop = properties[k]
477             if isinstance(prop, hyperdb.Password) and v is not None:
478                 d[k] = str(v)
479             elif isinstance(prop, hyperdb.Date) and v is not None:
480                 d[k] = v.serialise()
481             elif isinstance(prop, hyperdb.Interval) and v is not None:
482                 d[k] = v.serialise()
483             else:
484                 d[k] = v
485         return d
487     def unserialise(self, classname, node):
488         """Decode the marshalled node data
489         """
490         properties = self.getclass(classname).getprops()
491         d = {}
492         for k, v in node.iteritems():
493             # if the property doesn't exist, or is the "retired" flag then
494             # it won't be in the properties dict
495             if k not in properties:
496                 d[k] = v
497                 continue
499             # get the property spec
500             prop = properties[k]
502             if isinstance(prop, hyperdb.Date) and v is not None:
503                 d[k] = date.Date(v)
504             elif isinstance(prop, hyperdb.Interval) and v is not None:
505                 d[k] = date.Interval(v)
506             elif isinstance(prop, hyperdb.Password) and v is not None:
507                 p = password.Password()
508                 p.unpack(v)
509                 d[k] = p
510             else:
511                 d[k] = v
512         return d
514     def hasnode(self, classname, nodeid, db=None):
515         """ determine if the database has a given node
516         """
517         # try the cache
518         cache = self.cache.setdefault(classname, {})
519         if nodeid in cache:
520             return 1
522         # not in the cache - check the database
523         if db is None:
524             db = self.getclassdb(classname)
525         return key_in(db, nodeid)
527     def countnodes(self, classname, db=None):
528         count = 0
530         # include the uncommitted nodes
531         if classname in self.newnodes:
532             count += len(self.newnodes[classname])
533         if classname in self.destroyednodes:
534             count -= len(self.destroyednodes[classname])
536         # and count those in the DB
537         if db is None:
538             db = self.getclassdb(classname)
539         return count + len(db)
542     #
543     # Files - special node properties
544     # inherited from FileStorage
546     #
547     # Journal
548     #
549     def addjournal(self, classname, nodeid, action, params, creator=None,
550             creation=None):
551         """ Journal the Action
552         'action' may be:
554             'create' or 'set' -- 'params' is a dictionary of property values
555             'link' or 'unlink' -- 'params' is (classname, nodeid, propname)
556             'retire' -- 'params' is None
558             'creator' -- the user performing the action, which defaults to
559             the current user.
560         """
561         if __debug__:
562             logging.getLogger('roundup.hyperdb').debug(
563                 'addjournal %s%s %s %r %s %r'%(classname,
564                 nodeid, action, params, creator, creation))
565         if creator is None:
566             creator = self.getuid()
567         self.transactions.append((self.doSaveJournal, (classname, nodeid,
568             action, params, creator, creation)))
570     def setjournal(self, classname, nodeid, journal):
571         """Set the journal to the "journal" list."""
572         if __debug__:
573             logging.getLogger('roundup.hyperdb').debug(
574                 'setjournal %s%s %r'%(classname, nodeid, journal))
575         self.transactions.append((self.doSetJournal, (classname, nodeid,
576             journal)))
578     def getjournal(self, classname, nodeid):
579         """ get the journal for id
581             Raise IndexError if the node doesn't exist (as per history()'s
582             API)
583         """
584         # our journal result
585         res = []
587         # add any journal entries for transactions not committed to the
588         # database
589         for method, args in self.transactions:
590             if method != self.doSaveJournal:
591                 continue
592             (cache_classname, cache_nodeid, cache_action, cache_params,
593                 cache_creator, cache_creation) = args
594             if cache_classname == classname and cache_nodeid == nodeid:
595                 if not cache_creator:
596                     cache_creator = self.getuid()
597                 if not cache_creation:
598                     cache_creation = date.Date()
599                 res.append((cache_nodeid, cache_creation, cache_creator,
600                     cache_action, cache_params))
602         # attempt to open the journal - in some rare cases, the journal may
603         # not exist
604         try:
605             db = self.opendb('journals.%s'%classname, 'r')
606         except anydbm.error, error:
607             if str(error) == "need 'c' or 'n' flag to open new db":
608                 raise IndexError('no such %s %s'%(classname, nodeid))
609             elif error.args[0] != 2:
610                 # this isn't a "not found" error, be alarmed!
611                 raise
612             if res:
613                 # we have unsaved journal entries, return them
614                 return res
615             raise IndexError('no such %s %s'%(classname, nodeid))
616         try:
617             journal = marshal.loads(db[nodeid])
618         except KeyError:
619             db.close()
620             if res:
621                 # we have some unsaved journal entries, be happy!
622                 return res
623             raise IndexError('no such %s %s'%(classname, nodeid))
624         db.close()
626         # add all the saved journal entries for this node
627         for nodeid, date_stamp, user, action, params in journal:
628             res.append((nodeid, date.Date(date_stamp), user, action, params))
629         return res
631     def pack(self, pack_before):
632         """ Delete all journal entries except "create" before 'pack_before'.
633         """
634         pack_before = pack_before.serialise()
635         for classname in self.getclasses():
636             packed = 0
637             # get the journal db
638             db_name = 'journals.%s'%classname
639             path = os.path.join(os.getcwd(), self.dir, classname)
640             db_type = self.determine_db_type(path)
641             db = self.opendb(db_name, 'w')
643             for key in db.keys():
644                 # get the journal for this db entry
645                 journal = marshal.loads(db[key])
646                 l = []
647                 last_set_entry = None
648                 for entry in journal:
649                     # unpack the entry
650                     (nodeid, date_stamp, self.journaltag, action,
651                         params) = entry
652                     # if the entry is after the pack date, _or_ the initial
653                     # create entry, then it stays
654                     if date_stamp > pack_before or action == 'create':
655                         l.append(entry)
656                     else:
657                         packed += 1
658                 db[key] = marshal.dumps(l)
660                 logging.getLogger('roundup.hyperdb').info(
661                     'packed %d %s items'%(packed, classname))
663             if db_type == 'gdbm':
664                 db.reorganize()
665             db.close()
668     #
669     # Basic transaction support
670     #
671     def commit(self, fail_ok=False):
672         """ Commit the current transactions.
674         Save all data changed since the database was opened or since the
675         last commit() or rollback().
677         fail_ok indicates that the commit is allowed to fail. This is used
678         in the web interface when committing cleaning of the session
679         database. We don't care if there's a concurrency issue there.
681         The only backend this seems to affect is postgres.
682         """
683         logging.getLogger('roundup.hyperdb').info('commit %s transactions'%(
684             len(self.transactions)))
686         # keep a handle to all the database files opened
687         self.databases = {}
689         try:
690             # now, do all the transactions
691             reindex = {}
692             for method, args in self.transactions:
693                 reindex[method(*args)] = 1
694         finally:
695             # make sure we close all the database files
696             for db in self.databases.itervalues():
697                 db.close()
698             del self.databases
700         # clear the transactions list now so the blobfile implementation
701         # doesn't think there's still pending file commits when it tries
702         # to access the file data
703         self.transactions = []
705         # reindex the nodes that request it
706         for classname, nodeid in [k for k in reindex if k]:
707             self.getclass(classname).index(nodeid)
709         # save the indexer state
710         self.indexer.save_index()
712         self.clearCache()
714     def clearCache(self):
715         # all transactions committed, back to normal
716         self.cache = {}
717         self.dirtynodes = {}
718         self.newnodes = {}
719         self.destroyednodes = {}
720         self.transactions = []
722     def getCachedClassDB(self, classname):
723         """ get the class db, looking in our cache of databases for commit
724         """
725         # get the database handle
726         db_name = 'nodes.%s'%classname
727         if db_name not in self.databases:
728             self.databases[db_name] = self.getclassdb(classname, 'c')
729         return self.databases[db_name]
731     def doSaveNode(self, classname, nodeid, node):
732         db = self.getCachedClassDB(classname)
734         # now save the marshalled data
735         db[nodeid] = marshal.dumps(self.serialise(classname, node))
737         # return the classname, nodeid so we reindex this content
738         return (classname, nodeid)
740     def getCachedJournalDB(self, classname):
741         """ get the journal db, looking in our cache of databases for commit
742         """
743         # get the database handle
744         db_name = 'journals.%s'%classname
745         if db_name not in self.databases:
746             self.databases[db_name] = self.opendb(db_name, 'c')
747         return self.databases[db_name]
749     def doSaveJournal(self, classname, nodeid, action, params, creator,
750             creation):
751         # serialise the parameters now if necessary
752         if isinstance(params, type({})):
753             if action in ('set', 'create'):
754                 params = self.serialise(classname, params)
756         # handle supply of the special journalling parameters (usually
757         # supplied on importing an existing database)
758         journaltag = creator
759         if creation:
760             journaldate = creation.serialise()
761         else:
762             journaldate = date.Date().serialise()
764         # create the journal entry
765         entry = (nodeid, journaldate, journaltag, action, params)
767         db = self.getCachedJournalDB(classname)
769         # now insert the journal entry
770         if key_in(db, nodeid):
771             # append to existing
772             s = db[nodeid]
773             l = marshal.loads(s)
774             l.append(entry)
775         else:
776             l = [entry]
778         db[nodeid] = marshal.dumps(l)
780     def doSetJournal(self, classname, nodeid, journal):
781         l = []
782         for nodeid, journaldate, journaltag, action, params in journal:
783             # serialise the parameters now if necessary
784             if isinstance(params, type({})):
785                 if action in ('set', 'create'):
786                     params = self.serialise(classname, params)
787             journaldate = journaldate.serialise()
788             l.append((nodeid, journaldate, journaltag, action, params))
789         db = self.getCachedJournalDB(classname)
790         db[nodeid] = marshal.dumps(l)
792     def doDestroyNode(self, classname, nodeid):
793         # delete from the class database
794         db = self.getCachedClassDB(classname)
795         if key_in(db, nodeid):
796             del db[nodeid]
798         # delete from the database
799         db = self.getCachedJournalDB(classname)
800         if key_in(db, nodeid):
801             del db[nodeid]
803     def rollback(self):
804         """ Reverse all actions from the current transaction.
805         """
806         logging.getLogger('roundup.hyperdb').info('rollback %s transactions'%(
807             len(self.transactions)))
809         for method, args in self.transactions:
810             # delete temporary files
811             if method == self.doStoreFile:
812                 self.rollbackStoreFile(*args)
813         self.cache = {}
814         self.dirtynodes = {}
815         self.newnodes = {}
816         self.destroyednodes = {}
817         self.transactions = []
819     def close(self):
820         """ Nothing to do
821         """
822         if self.lockfile is not None:
823             locking.release_lock(self.lockfile)
824             self.lockfile.close()
825             self.lockfile = None
827 _marker = []
828 class Class(hyperdb.Class):
829     """The handle to a particular class of nodes in a hyperdatabase."""
831     def enableJournalling(self):
832         """Turn journalling on for this class
833         """
834         self.do_journal = 1
836     def disableJournalling(self):
837         """Turn journalling off for this class
838         """
839         self.do_journal = 0
841     # Editing nodes:
843     def create(self, **propvalues):
844         """Create a new node of this class and return its id.
846         The keyword arguments in 'propvalues' map property names to values.
848         The values of arguments must be acceptable for the types of their
849         corresponding properties or a TypeError is raised.
851         If this class has a key property, it must be present and its value
852         must not collide with other key strings or a ValueError is raised.
854         Any other properties on this class that are missing from the
855         'propvalues' dictionary are set to None.
857         If an id in a link or multilink property does not refer to a valid
858         node, an IndexError is raised.
860         These operations trigger detectors and can be vetoed.  Attempts
861         to modify the "creation" or "activity" properties cause a KeyError.
862         """
863         if self.db.journaltag is None:
864             raise hyperdb.DatabaseError(_('Database open read-only'))
865         self.fireAuditors('create', None, propvalues)
866         newid = self.create_inner(**propvalues)
867         self.fireReactors('create', newid, None)
868         return newid
870     def create_inner(self, **propvalues):
871         """ Called by create, in-between the audit and react calls.
872         """
873         if 'id' in propvalues:
874             raise KeyError('"id" is reserved')
876         if self.db.journaltag is None:
877             raise hyperdb.DatabaseError(_('Database open read-only'))
879         if 'creation' in propvalues or 'activity' in propvalues:
880             raise KeyError('"creation" and "activity" are reserved')
881         # new node's id
882         newid = self.db.newid(self.classname)
884         # validate propvalues
885         num_re = re.compile('^\d+$')
886         for key, value in propvalues.iteritems():
887             if key == self.key:
888                 try:
889                     self.lookup(value)
890                 except KeyError:
891                     pass
892                 else:
893                     raise ValueError('node with key "%s" exists'%value)
895             # try to handle this property
896             try:
897                 prop = self.properties[key]
898             except KeyError:
899                 raise KeyError('"%s" has no property "%s"'%(self.classname,
900                     key))
902             if value is not None and isinstance(prop, hyperdb.Link):
903                 if type(value) != type(''):
904                     raise ValueError('link value must be String')
905                 link_class = self.properties[key].classname
906                 # if it isn't a number, it's a key
907                 if not num_re.match(value):
908                     try:
909                         value = self.db.classes[link_class].lookup(value)
910                     except (TypeError, KeyError):
911                         raise IndexError('new property "%s": %s not a %s'%(
912                             key, value, link_class))
913                 elif not self.db.getclass(link_class).hasnode(value):
914                     raise IndexError('%s has no node %s'%(link_class,
915                         value))
917                 # save off the value
918                 propvalues[key] = value
920                 # register the link with the newly linked node
921                 if self.do_journal and self.properties[key].do_journal:
922                     self.db.addjournal(link_class, value, 'link',
923                         (self.classname, newid, key))
925             elif isinstance(prop, hyperdb.Multilink):
926                 if value is None:
927                     value = []
928                 if not hasattr(value, '__iter__'):
929                     raise TypeError('new property "%s" not an iterable of ids'%key)
931                 # clean up and validate the list of links
932                 link_class = self.properties[key].classname
933                 l = []
934                 for entry in value:
935                     if type(entry) != type(''):
936                         raise ValueError('"%s" multilink value (%r) '\
937                             'must contain Strings'%(key, value))
938                     # if it isn't a number, it's a key
939                     if not num_re.match(entry):
940                         try:
941                             entry = self.db.classes[link_class].lookup(entry)
942                         except (TypeError, KeyError):
943                             raise IndexError('new property "%s": %s not a %s'%(
944                                 key, entry, self.properties[key].classname))
945                     l.append(entry)
946                 value = l
947                 propvalues[key] = value
949                 # handle additions
950                 for nodeid in value:
951                     if not self.db.getclass(link_class).hasnode(nodeid):
952                         raise IndexError('%s has no node %s'%(link_class,
953                             nodeid))
954                     # register the link with the newly linked node
955                     if self.do_journal and self.properties[key].do_journal:
956                         self.db.addjournal(link_class, nodeid, 'link',
957                             (self.classname, newid, key))
959             elif isinstance(prop, hyperdb.String):
960                 if type(value) != type('') and type(value) != type(u''):
961                     raise TypeError('new property "%s" not a string'%key)
962                 if prop.indexme:
963                     self.db.indexer.add_text((self.classname, newid, key),
964                         value)
966             elif isinstance(prop, hyperdb.Password):
967                 if not isinstance(value, password.Password):
968                     raise TypeError('new property "%s" not a Password'%key)
970             elif isinstance(prop, hyperdb.Date):
971                 if value is not None and not isinstance(value, date.Date):
972                     raise TypeError('new property "%s" not a Date'%key)
974             elif isinstance(prop, hyperdb.Interval):
975                 if value is not None and not isinstance(value, date.Interval):
976                     raise TypeError('new property "%s" not an Interval'%key)
978             elif value is not None and isinstance(prop, hyperdb.Number):
979                 try:
980                     float(value)
981                 except ValueError:
982                     raise TypeError('new property "%s" not numeric'%key)
984             elif value is not None and isinstance(prop, hyperdb.Boolean):
985                 try:
986                     int(value)
987                 except ValueError:
988                     raise TypeError('new property "%s" not boolean'%key)
990         # make sure there's data where there needs to be
991         for key, prop in self.properties.iteritems():
992             if key in propvalues:
993                 continue
994             if key == self.key:
995                 raise ValueError('key property "%s" is required'%key)
996             if isinstance(prop, hyperdb.Multilink):
997                 propvalues[key] = []
999         # done
1000         self.db.addnode(self.classname, newid, propvalues)
1001         if self.do_journal:
1002             self.db.addjournal(self.classname, newid, 'create', {})
1004         return newid
1006     def get(self, nodeid, propname, default=_marker, cache=1):
1007         """Get the value of a property on an existing node of this class.
1009         'nodeid' must be the id of an existing node of this class or an
1010         IndexError is raised.  'propname' must be the name of a property
1011         of this class or a KeyError is raised.
1013         'cache' exists for backward compatibility, and is not used.
1015         Attempts to get the "creation" or "activity" properties should
1016         do the right thing.
1017         """
1018         if propname == 'id':
1019             return nodeid
1021         # get the node's dict
1022         d = self.db.getnode(self.classname, nodeid)
1024         # check for one of the special props
1025         if propname == 'creation':
1026             if 'creation' in d:
1027                 return d['creation']
1028             if not self.do_journal:
1029                 raise ValueError('Journalling is disabled for this class')
1030             journal = self.db.getjournal(self.classname, nodeid)
1031             if journal:
1032                 return journal[0][1]
1033             else:
1034                 # on the strange chance that there's no journal
1035                 return date.Date()
1036         if propname == 'activity':
1037             if 'activity' in d:
1038                 return d['activity']
1039             if not self.do_journal:
1040                 raise ValueError('Journalling is disabled for this class')
1041             journal = self.db.getjournal(self.classname, nodeid)
1042             if journal:
1043                 return self.db.getjournal(self.classname, nodeid)[-1][1]
1044             else:
1045                 # on the strange chance that there's no journal
1046                 return date.Date()
1047         if propname == 'creator':
1048             if 'creator' in d:
1049                 return d['creator']
1050             if not self.do_journal:
1051                 raise ValueError('Journalling is disabled for this class')
1052             journal = self.db.getjournal(self.classname, nodeid)
1053             if journal:
1054                 num_re = re.compile('^\d+$')
1055                 value = journal[0][2]
1056                 if num_re.match(value):
1057                     return value
1058                 else:
1059                     # old-style "username" journal tag
1060                     try:
1061                         return self.db.user.lookup(value)
1062                     except KeyError:
1063                         # user's been retired, return admin
1064                         return '1'
1065             else:
1066                 return self.db.getuid()
1067         if propname == 'actor':
1068             if 'actor' in d:
1069                 return d['actor']
1070             if not self.do_journal:
1071                 raise ValueError('Journalling is disabled for this class')
1072             journal = self.db.getjournal(self.classname, nodeid)
1073             if journal:
1074                 num_re = re.compile('^\d+$')
1075                 value = journal[-1][2]
1076                 if num_re.match(value):
1077                     return value
1078                 else:
1079                     # old-style "username" journal tag
1080                     try:
1081                         return self.db.user.lookup(value)
1082                     except KeyError:
1083                         # user's been retired, return admin
1084                         return '1'
1085             else:
1086                 return self.db.getuid()
1088         # get the property (raises KeyErorr if invalid)
1089         prop = self.properties[propname]
1091         if propname not in d:
1092             if default is _marker:
1093                 if isinstance(prop, hyperdb.Multilink):
1094                     return []
1095                 else:
1096                     return None
1097             else:
1098                 return default
1100         # return a dupe of the list so code doesn't get confused
1101         if isinstance(prop, hyperdb.Multilink):
1102             return d[propname][:]
1104         return d[propname]
1106     def set(self, nodeid, **propvalues):
1107         """Modify a property on an existing node of this class.
1109         'nodeid' must be the id of an existing node of this class or an
1110         IndexError is raised.
1112         Each key in 'propvalues' must be the name of a property of this
1113         class or a KeyError is raised.
1115         All values in 'propvalues' must be acceptable types for their
1116         corresponding properties or a TypeError is raised.
1118         If the value of the key property is set, it must not collide with
1119         other key strings or a ValueError is raised.
1121         If the value of a Link or Multilink property contains an invalid
1122         node id, a ValueError is raised.
1124         These operations trigger detectors and can be vetoed.  Attempts
1125         to modify the "creation" or "activity" properties cause a KeyError.
1126         """
1127         if self.db.journaltag is None:
1128             raise hyperdb.DatabaseError(_('Database open read-only'))
1130         self.fireAuditors('set', nodeid, propvalues)
1131         oldvalues = copy.deepcopy(self.db.getnode(self.classname, nodeid))
1132         for name, prop in self.getprops(protected=0).iteritems():
1133             if name in oldvalues:
1134                 continue
1135             if isinstance(prop, hyperdb.Multilink):
1136                 oldvalues[name] = []
1137             else:
1138                 oldvalues[name] = None
1139         propvalues = self.set_inner(nodeid, **propvalues)
1140         self.fireReactors('set', nodeid, oldvalues)
1141         return propvalues
1143     def set_inner(self, nodeid, **propvalues):
1144         """ Called by set, in-between the audit and react calls.
1145         """
1146         if not propvalues:
1147             return propvalues
1149         if 'creation' in propvalues or 'activity' in propvalues:
1150             raise KeyError, '"creation" and "activity" are reserved'
1152         if 'id' in propvalues:
1153             raise KeyError, '"id" is reserved'
1155         if self.db.journaltag is None:
1156             raise hyperdb.DatabaseError(_('Database open read-only'))
1158         node = self.db.getnode(self.classname, nodeid)
1159         if self.db.RETIRED_FLAG in node:
1160             raise IndexError
1161         num_re = re.compile('^\d+$')
1163         # if the journal value is to be different, store it in here
1164         journalvalues = {}
1166         # list() propvalues 'cos it might be modified by the loop
1167         for propname, value in list(propvalues.items()):
1168             # check to make sure we're not duplicating an existing key
1169             if propname == self.key and node[propname] != value:
1170                 try:
1171                     self.lookup(value)
1172                 except KeyError:
1173                     pass
1174                 else:
1175                     raise ValueError('node with key "%s" exists'%value)
1177             # this will raise the KeyError if the property isn't valid
1178             # ... we don't use getprops() here because we only care about
1179             # the writeable properties.
1180             try:
1181                 prop = self.properties[propname]
1182             except KeyError:
1183                 raise KeyError('"%s" has no property named "%s"'%(
1184                     self.classname, propname))
1186             # if the value's the same as the existing value, no sense in
1187             # doing anything
1188             current = node.get(propname, None)
1189             if value == current:
1190                 del propvalues[propname]
1191                 continue
1192             journalvalues[propname] = current
1194             # do stuff based on the prop type
1195             if isinstance(prop, hyperdb.Link):
1196                 link_class = prop.classname
1197                 # if it isn't a number, it's a key
1198                 if value is not None and not isinstance(value, type('')):
1199                     raise ValueError('property "%s" link value be a string'%(
1200                         propname))
1201                 if isinstance(value, type('')) and not num_re.match(value):
1202                     try:
1203                         value = self.db.classes[link_class].lookup(value)
1204                     except (TypeError, KeyError):
1205                         raise IndexError('new property "%s": %s not a %s'%(
1206                             propname, value, prop.classname))
1208                 if (value is not None and
1209                         not self.db.getclass(link_class).hasnode(value)):
1210                     raise IndexError('%s has no node %s'%(link_class,
1211                         value))
1213                 if self.do_journal and prop.do_journal:
1214                     # register the unlink with the old linked node
1215                     if propname in node and node[propname] is not None:
1216                         self.db.addjournal(link_class, node[propname], 'unlink',
1217                             (self.classname, nodeid, propname))
1219                     # register the link with the newly linked node
1220                     if value is not None:
1221                         self.db.addjournal(link_class, value, 'link',
1222                             (self.classname, nodeid, propname))
1224             elif isinstance(prop, hyperdb.Multilink):
1225                 if value is None:
1226                     value = []
1227                 if not hasattr(value, '__iter__'):
1228                     raise TypeError('new property "%s" not an iterable of'
1229                         ' ids'%propname)
1230                 link_class = self.properties[propname].classname
1231                 l = []
1232                 for entry in value:
1233                     # if it isn't a number, it's a key
1234                     if type(entry) != type(''):
1235                         raise ValueError('new property "%s" link value '
1236                             'must be a string'%propname)
1237                     if not num_re.match(entry):
1238                         try:
1239                             entry = self.db.classes[link_class].lookup(entry)
1240                         except (TypeError, KeyError):
1241                             raise IndexError('new property "%s": %s not a %s'%(
1242                                 propname, entry,
1243                                 self.properties[propname].classname))
1244                     l.append(entry)
1245                 value = l
1246                 propvalues[propname] = value
1248                 # figure the journal entry for this property
1249                 add = []
1250                 remove = []
1252                 # handle removals
1253                 if propname in node:
1254                     l = node[propname]
1255                 else:
1256                     l = []
1257                 for id in l[:]:
1258                     if id in value:
1259                         continue
1260                     # register the unlink with the old linked node
1261                     if self.do_journal and self.properties[propname].do_journal:
1262                         self.db.addjournal(link_class, id, 'unlink',
1263                             (self.classname, nodeid, propname))
1264                     l.remove(id)
1265                     remove.append(id)
1267                 # handle additions
1268                 for id in value:
1269                     if not self.db.getclass(link_class).hasnode(id):
1270                         raise IndexError('%s has no node %s'%(link_class,
1271                             id))
1272                     if id in l:
1273                         continue
1274                     # register the link with the newly linked node
1275                     if self.do_journal and self.properties[propname].do_journal:
1276                         self.db.addjournal(link_class, id, 'link',
1277                             (self.classname, nodeid, propname))
1278                     l.append(id)
1279                     add.append(id)
1281                 # figure the journal entry
1282                 l = []
1283                 if add:
1284                     l.append(('+', add))
1285                 if remove:
1286                     l.append(('-', remove))
1287                 if l:
1288                     journalvalues[propname] = tuple(l)
1290             elif isinstance(prop, hyperdb.String):
1291                 if value is not None and type(value) != type('') and type(value) != type(u''):
1292                     raise TypeError('new property "%s" not a '
1293                         'string'%propname)
1294                 if prop.indexme:
1295                     self.db.indexer.add_text((self.classname, nodeid, propname),
1296                         value)
1298             elif isinstance(prop, hyperdb.Password):
1299                 if not isinstance(value, password.Password):
1300                     raise TypeError('new property "%s" not a '
1301                         'Password'%propname)
1302                 propvalues[propname] = value
1304             elif value is not None and isinstance(prop, hyperdb.Date):
1305                 if not isinstance(value, date.Date):
1306                     raise TypeError('new property "%s" not a '
1307                         'Date'%propname)
1308                 propvalues[propname] = value
1310             elif value is not None and isinstance(prop, hyperdb.Interval):
1311                 if not isinstance(value, date.Interval):
1312                     raise TypeError('new property "%s" not an '
1313                         'Interval'%propname)
1314                 propvalues[propname] = value
1316             elif value is not None and isinstance(prop, hyperdb.Number):
1317                 try:
1318                     float(value)
1319                 except ValueError:
1320                     raise TypeError('new property "%s" not '
1321                         'numeric'%propname)
1323             elif value is not None and isinstance(prop, hyperdb.Boolean):
1324                 try:
1325                     int(value)
1326                 except ValueError:
1327                     raise TypeError('new property "%s" not '
1328                         'boolean'%propname)
1330             node[propname] = value
1332         # nothing to do?
1333         if not propvalues:
1334             return propvalues
1336         # update the activity time
1337         node['activity'] = date.Date()
1338         node['actor'] = self.db.getuid()
1340         # do the set, and journal it
1341         self.db.setnode(self.classname, nodeid, node)
1343         if self.do_journal:
1344             self.db.addjournal(self.classname, nodeid, 'set', journalvalues)
1346         return propvalues
1348     def retire(self, nodeid):
1349         """Retire a node.
1351         The properties on the node remain available from the get() method,
1352         and the node's id is never reused.
1354         Retired nodes are not returned by the find(), list(), or lookup()
1355         methods, and other nodes may reuse the values of their key properties.
1357         These operations trigger detectors and can be vetoed.  Attempts
1358         to modify the "creation" or "activity" properties cause a KeyError.
1359         """
1360         if self.db.journaltag is None:
1361             raise hyperdb.DatabaseError(_('Database open read-only'))
1363         self.fireAuditors('retire', nodeid, None)
1365         node = self.db.getnode(self.classname, nodeid)
1366         node[self.db.RETIRED_FLAG] = 1
1367         self.db.setnode(self.classname, nodeid, node)
1368         if self.do_journal:
1369             self.db.addjournal(self.classname, nodeid, 'retired', None)
1371         self.fireReactors('retire', nodeid, None)
1373     def restore(self, nodeid):
1374         """Restpre a retired node.
1376         Make node available for all operations like it was before retirement.
1377         """
1378         if self.db.journaltag is None:
1379             raise hyperdb.DatabaseError(_('Database open read-only'))
1381         node = self.db.getnode(self.classname, nodeid)
1382         # check if key property was overrided
1383         key = self.getkey()
1384         try:
1385             id = self.lookup(node[key])
1386         except KeyError:
1387             pass
1388         else:
1389             raise KeyError("Key property (%s) of retired node clashes "
1390                 "with existing one (%s)" % (key, node[key]))
1391         # Now we can safely restore node
1392         self.fireAuditors('restore', nodeid, None)
1393         del node[self.db.RETIRED_FLAG]
1394         self.db.setnode(self.classname, nodeid, node)
1395         if self.do_journal:
1396             self.db.addjournal(self.classname, nodeid, 'restored', None)
1398         self.fireReactors('restore', nodeid, None)
1400     def is_retired(self, nodeid, cldb=None):
1401         """Return true if the node is retired.
1402         """
1403         node = self.db.getnode(self.classname, nodeid, cldb)
1404         if self.db.RETIRED_FLAG in node:
1405             return 1
1406         return 0
1408     def destroy(self, nodeid):
1409         """Destroy a node.
1411         WARNING: this method should never be used except in extremely rare
1412                  situations where there could never be links to the node being
1413                  deleted
1415         WARNING: use retire() instead
1417         WARNING: the properties of this node will not be available ever again
1419         WARNING: really, use retire() instead
1421         Well, I think that's enough warnings. This method exists mostly to
1422         support the session storage of the cgi interface.
1423         """
1424         if self.db.journaltag is None:
1425             raise hyperdb.DatabaseError(_('Database open read-only'))
1426         self.db.destroynode(self.classname, nodeid)
1428     def history(self, nodeid):
1429         """Retrieve the journal of edits on a particular node.
1431         'nodeid' must be the id of an existing node of this class or an
1432         IndexError is raised.
1434         The returned list contains tuples of the form
1436             (nodeid, date, tag, action, params)
1438         'date' is a Timestamp object specifying the time of the change and
1439         'tag' is the journaltag specified when the database was opened.
1440         """
1441         if not self.do_journal:
1442             raise ValueError('Journalling is disabled for this class')
1443         return self.db.getjournal(self.classname, nodeid)
1445     # Locating nodes:
1446     def hasnode(self, nodeid):
1447         """Determine if the given nodeid actually exists
1448         """
1449         return self.db.hasnode(self.classname, nodeid)
1451     def setkey(self, propname):
1452         """Select a String property of this class to be the key property.
1454         'propname' must be the name of a String property of this class or
1455         None, or a TypeError is raised.  The values of the key property on
1456         all existing nodes must be unique or a ValueError is raised. If the
1457         property doesn't exist, KeyError is raised.
1458         """
1459         prop = self.getprops()[propname]
1460         if not isinstance(prop, hyperdb.String):
1461             raise TypeError('key properties must be String')
1462         self.key = propname
1464     def getkey(self):
1465         """Return the name of the key property for this class or None."""
1466         return self.key
1468     # TODO: set up a separate index db file for this? profile?
1469     def lookup(self, keyvalue):
1470         """Locate a particular node by its key property and return its id.
1472         If this class has no key property, a TypeError is raised.  If the
1473         'keyvalue' matches one of the values for the key property among
1474         the nodes in this class, the matching node's id is returned;
1475         otherwise a KeyError is raised.
1476         """
1477         if not self.key:
1478             raise TypeError('No key property set for '
1479                 'class %s'%self.classname)
1480         cldb = self.db.getclassdb(self.classname)
1481         try:
1482             for nodeid in self.getnodeids(cldb):
1483                 node = self.db.getnode(self.classname, nodeid, cldb)
1484                 if self.db.RETIRED_FLAG in node:
1485                     continue
1486                 if self.key not in node:
1487                     continue
1488                 if node[self.key] == keyvalue:
1489                     return nodeid
1490         finally:
1491             cldb.close()
1492         raise KeyError('No key (%s) value "%s" for "%s"'%(self.key,
1493             keyvalue, self.classname))
1495     # change from spec - allows multiple props to match
1496     def find(self, **propspec):
1497         """Get the ids of nodes in this class which link to the given nodes.
1499         'propspec' consists of keyword args propname=nodeid or
1500                    propname={nodeid:1, }
1501         'propname' must be the name of a property in this class, or a
1502                    KeyError is raised.  That property must be a Link or
1503                    Multilink property, or a TypeError is raised.
1505         Any node in this class whose 'propname' property links to any of
1506         the nodeids will be returned. Examples::
1508             db.issue.find(messages='1')
1509             db.issue.find(messages={'1':1,'3':1}, files={'7':1})
1510         """
1511         for propname, itemids in propspec.iteritems():
1512             # check the prop is OK
1513             prop = self.properties[propname]
1514             if not isinstance(prop, hyperdb.Link) and not isinstance(prop, hyperdb.Multilink):
1515                 raise TypeError("'%s' not a Link/Multilink "
1516                     "property"%propname)
1518         # ok, now do the find
1519         cldb = self.db.getclassdb(self.classname)
1520         l = []
1521         try:
1522             for id in self.getnodeids(db=cldb):
1523                 item = self.db.getnode(self.classname, id, db=cldb)
1524                 if self.db.RETIRED_FLAG in item:
1525                     continue
1526                 for propname, itemids in propspec.iteritems():
1527                     if type(itemids) is not type({}):
1528                         itemids = {itemids:1}
1530                     # special case if the item doesn't have this property
1531                     if propname not in item:
1532                         if None in itemids:
1533                             l.append(id)
1534                             break
1535                         continue
1537                     # grab the property definition and its value on this item
1538                     prop = self.properties[propname]
1539                     value = item[propname]
1540                     if isinstance(prop, hyperdb.Link) and value in itemids:
1541                         l.append(id)
1542                         break
1543                     elif isinstance(prop, hyperdb.Multilink):
1544                         hit = 0
1545                         for v in value:
1546                             if v in itemids:
1547                                 l.append(id)
1548                                 hit = 1
1549                                 break
1550                         if hit:
1551                             break
1552         finally:
1553             cldb.close()
1554         return l
1556     def stringFind(self, **requirements):
1557         """Locate a particular node by matching a set of its String
1558         properties in a caseless search.
1560         If the property is not a String property, a TypeError is raised.
1562         The return is a list of the id of all nodes that match.
1563         """
1564         for propname in requirements:
1565             prop = self.properties[propname]
1566             if not isinstance(prop, hyperdb.String):
1567                 raise TypeError("'%s' not a String property"%propname)
1568             requirements[propname] = requirements[propname].lower()
1569         l = []
1570         cldb = self.db.getclassdb(self.classname)
1571         try:
1572             for nodeid in self.getnodeids(cldb):
1573                 node = self.db.getnode(self.classname, nodeid, cldb)
1574                 if self.db.RETIRED_FLAG in node:
1575                     continue
1576                 for key, value in requirements.iteritems():
1577                     if key not in node:
1578                         break
1579                     if node[key] is None or node[key].lower() != value:
1580                         break
1581                 else:
1582                     l.append(nodeid)
1583         finally:
1584             cldb.close()
1585         return l
1587     def list(self):
1588         """ Return a list of the ids of the active nodes in this class.
1589         """
1590         l = []
1591         cn = self.classname
1592         cldb = self.db.getclassdb(cn)
1593         try:
1594             for nodeid in self.getnodeids(cldb):
1595                 node = self.db.getnode(cn, nodeid, cldb)
1596                 if self.db.RETIRED_FLAG in node:
1597                     continue
1598                 l.append(nodeid)
1599         finally:
1600             cldb.close()
1601         l.sort()
1602         return l
1604     def getnodeids(self, db=None, retired=None):
1605         """ Return a list of ALL nodeids
1607             Set retired=None to get all nodes. Otherwise it'll get all the
1608             retired or non-retired nodes, depending on the flag.
1609         """
1610         res = []
1612         # start off with the new nodes
1613         if self.classname in self.db.newnodes:
1614             res.extend(self.db.newnodes[self.classname])
1616         must_close = False
1617         if db is None:
1618             db = self.db.getclassdb(self.classname)
1619             must_close = True
1620         try:
1621             res.extend(db.keys())
1623             # remove the uncommitted, destroyed nodes
1624             if self.classname in self.db.destroyednodes:
1625                 for nodeid in self.db.destroyednodes[self.classname]:
1626                     if key_in(db, nodeid):
1627                         res.remove(nodeid)
1629             # check retired flag
1630             if retired is False or retired is True:
1631                 l = []
1632                 for nodeid in res:
1633                     node = self.db.getnode(self.classname, nodeid, db)
1634                     is_ret = self.db.RETIRED_FLAG in node
1635                     if retired == is_ret:
1636                         l.append(nodeid)
1637                 res = l
1638         finally:
1639             if must_close:
1640                 db.close()
1641         return res
1643     def _filter(self, search_matches, filterspec, proptree,
1644             num_re = re.compile('^\d+$')):
1645         """Return a list of the ids of the active nodes in this class that
1646         match the 'filter' spec, sorted by the group spec and then the
1647         sort spec.
1649         "filterspec" is {propname: value(s)}
1651         "sort" and "group" are (dir, prop) where dir is '+', '-' or None
1652         and prop is a prop name or None
1654         "search_matches" is a sequence type or None
1656         The filter must match all properties specificed. If the property
1657         value to match is a list:
1659         1. String properties must match all elements in the list, and
1660         2. Other properties must match any of the elements in the list.
1661         """
1662         if __debug__:
1663             start_t = time.time()
1665         cn = self.classname
1667         # optimise filterspec
1668         l = []
1669         props = self.getprops()
1670         LINK = 'spec:link'
1671         MULTILINK = 'spec:multilink'
1672         STRING = 'spec:string'
1673         DATE = 'spec:date'
1674         INTERVAL = 'spec:interval'
1675         OTHER = 'spec:other'
1677         for k, v in filterspec.iteritems():
1678             propclass = props[k]
1679             if isinstance(propclass, hyperdb.Link):
1680                 if type(v) is not type([]):
1681                     v = [v]
1682                 u = []
1683                 for entry in v:
1684                     # the value -1 is a special "not set" sentinel
1685                     if entry == '-1':
1686                         entry = None
1687                     u.append(entry)
1688                 l.append((LINK, k, u))
1689             elif isinstance(propclass, hyperdb.Multilink):
1690                 # the value -1 is a special "not set" sentinel
1691                 if v in ('-1', ['-1']):
1692                     v = []
1693                 elif type(v) is not type([]):
1694                     v = [v]
1695                 l.append((MULTILINK, k, v))
1696             elif isinstance(propclass, hyperdb.String) and k != 'id':
1697                 if type(v) is not type([]):
1698                     v = [v]
1699                 for v in v:
1700                     # simple glob searching
1701                     v = re.sub(r'([\|\{\}\\\.\+\[\]\(\)])', r'\\\1', v)
1702                     v = v.replace('?', '.')
1703                     v = v.replace('*', '.*?')
1704                     l.append((STRING, k, re.compile(v, re.I)))
1705             elif isinstance(propclass, hyperdb.Date):
1706                 try:
1707                     date_rng = propclass.range_from_raw(v, self.db)
1708                     l.append((DATE, k, date_rng))
1709                 except ValueError:
1710                     # If range creation fails - ignore that search parameter
1711                     pass
1712             elif isinstance(propclass, hyperdb.Interval):
1713                 try:
1714                     intv_rng = date.Range(v, date.Interval)
1715                     l.append((INTERVAL, k, intv_rng))
1716                 except ValueError:
1717                     # If range creation fails - ignore that search parameter
1718                     pass
1720             elif isinstance(propclass, hyperdb.Boolean):
1721                 if type(v) == type(""):
1722                     v = v.split(',')
1723                 if type(v) != type([]):
1724                     v = [v]
1725                 bv = []
1726                 for val in v:
1727                     if type(val) is type(''):
1728                         bv.append(propclass.from_raw (val))
1729                     else:
1730                         bv.append(val)
1731                 l.append((OTHER, k, bv))
1733             elif k == 'id':
1734                 if type(v) != type([]):
1735                     v = v.split(',')
1736                 l.append((OTHER, k, [str(int(val)) for val in v]))
1738             elif isinstance(propclass, hyperdb.Number):
1739                 if type(v) != type([]):
1740                     try :
1741                         v = v.split(',')
1742                     except AttributeError :
1743                         v = [v]
1744                 l.append((OTHER, k, [float(val) for val in v]))
1746         filterspec = l
1747         
1748         # now, find all the nodes that are active and pass filtering
1749         matches = []
1750         cldb = self.db.getclassdb(cn)
1751         t = 0
1752         try:
1753             # TODO: only full-scan once (use items())
1754             for nodeid in self.getnodeids(cldb):
1755                 node = self.db.getnode(cn, nodeid, cldb)
1756                 if self.db.RETIRED_FLAG in node:
1757                     continue
1758                 # apply filter
1759                 for t, k, v in filterspec:
1760                     # handle the id prop
1761                     if k == 'id':
1762                         if nodeid not in v:
1763                             break
1764                         continue
1766                     # get the node value
1767                     nv = node.get(k, None)
1769                     match = 0
1771                     # now apply the property filter
1772                     if t == LINK:
1773                         # link - if this node's property doesn't appear in the
1774                         # filterspec's nodeid list, skip it
1775                         match = nv in v
1776                     elif t == MULTILINK:
1777                         # multilink - if any of the nodeids required by the
1778                         # filterspec aren't in this node's property, then skip
1779                         # it
1780                         nv = node.get(k, [])
1782                         # check for matching the absence of multilink values
1783                         if not v:
1784                             match = not nv
1785                         else:
1786                             # otherwise, make sure this node has each of the
1787                             # required values
1788                             expr = Expression(v)
1789                             if expr.evaluate(nv): match = 1
1790                     elif t == STRING:
1791                         if nv is None:
1792                             nv = ''
1793                         # RE search
1794                         match = v.search(nv)
1795                     elif t == DATE or t == INTERVAL:
1796                         if nv is None:
1797                             match = v is None
1798                         else:
1799                             if v.to_value:
1800                                 if v.from_value <= nv and v.to_value >= nv:
1801                                     match = 1
1802                             else:
1803                                 if v.from_value <= nv:
1804                                     match = 1
1805                     elif t == OTHER:
1806                         # straight value comparison for the other types
1807                         match = nv in v
1808                     if not match:
1809                         break
1810                 else:
1811                     matches.append([nodeid, node])
1813             # filter based on full text search
1814             if search_matches is not None:
1815                 k = []
1816                 for v in matches:
1817                     if v[0] in search_matches:
1818                         k.append(v)
1819                 matches = k
1821             # add sorting information to the proptree
1822             JPROPS = {'actor':1, 'activity':1, 'creator':1, 'creation':1}
1823             children = []
1824             if proptree:
1825                 children = proptree.sortable_children()
1826             for pt in children:
1827                 dir = pt.sort_direction
1828                 prop = pt.name
1829                 assert (dir and prop)
1830                 propclass = props[prop]
1831                 pt.sort_ids = []
1832                 is_pointer = isinstance(propclass,(hyperdb.Link,
1833                     hyperdb.Multilink))
1834                 if not is_pointer:
1835                     pt.sort_result = []
1836                 try:
1837                     # cache the opened link class db, if needed.
1838                     lcldb = None
1839                     # cache the linked class items too
1840                     lcache = {}
1842                     for entry in matches:
1843                         itemid = entry[-2]
1844                         item = entry[-1]
1845                         # handle the properties that might be "faked"
1846                         # also, handle possible missing properties
1847                         try:
1848                             v = item[prop]
1849                         except KeyError:
1850                             if prop in JPROPS:
1851                                 # force lookup of the special journal prop
1852                                 v = self.get(itemid, prop)
1853                             else:
1854                                 # the node doesn't have a value for this
1855                                 # property
1856                                 v = None
1857                                 if isinstance(propclass, hyperdb.Multilink):
1858                                     v = []
1859                                 if prop == 'id':
1860                                     v = int (itemid)
1861                                 pt.sort_ids.append(v)
1862                                 if not is_pointer:
1863                                     pt.sort_result.append(v)
1864                                 continue
1866                         # missing (None) values are always sorted first
1867                         if v is None:
1868                             pt.sort_ids.append(v)
1869                             if not is_pointer:
1870                                 pt.sort_result.append(v)
1871                             continue
1873                         if isinstance(propclass, hyperdb.Link):
1874                             lcn = propclass.classname
1875                             link = self.db.classes[lcn]
1876                             key = link.orderprop()
1877                             child = pt.propdict[key]
1878                             if key!='id':
1879                                 if v not in lcache:
1880                                     # open the link class db if it's not already
1881                                     if lcldb is None:
1882                                         lcldb = self.db.getclassdb(lcn)
1883                                     lcache[v] = self.db.getnode(lcn, v, lcldb)
1884                                 r = lcache[v][key]
1885                                 child.propdict[key].sort_ids.append(r)
1886                             else:
1887                                 child.propdict[key].sort_ids.append(v)
1888                         pt.sort_ids.append(v)
1889                         if not is_pointer:
1890                             r = propclass.sort_repr(pt.parent.cls, v, pt.name)
1891                             pt.sort_result.append(r)
1892                 finally:
1893                     # if we opened the link class db, close it now
1894                     if lcldb is not None:
1895                         lcldb.close()
1896                 del lcache
1897         finally:
1898             cldb.close()
1900         # pull the id out of the individual entries
1901         matches = [entry[-2] for entry in matches]
1902         if __debug__:
1903             self.db.stats['filtering'] += (time.time() - start_t)
1904         return matches
1906     def count(self):
1907         """Get the number of nodes in this class.
1909         If the returned integer is 'numnodes', the ids of all the nodes
1910         in this class run from 1 to numnodes, and numnodes+1 will be the
1911         id of the next node to be created in this class.
1912         """
1913         return self.db.countnodes(self.classname)
1915     # Manipulating properties:
1917     def getprops(self, protected=1):
1918         """Return a dictionary mapping property names to property objects.
1919            If the "protected" flag is true, we include protected properties -
1920            those which may not be modified.
1922            In addition to the actual properties on the node, these
1923            methods provide the "creation" and "activity" properties. If the
1924            "protected" flag is true, we include protected properties - those
1925            which may not be modified.
1926         """
1927         d = self.properties.copy()
1928         if protected:
1929             d['id'] = hyperdb.String()
1930             d['creation'] = hyperdb.Date()
1931             d['activity'] = hyperdb.Date()
1932             d['creator'] = hyperdb.Link('user')
1933             d['actor'] = hyperdb.Link('user')
1934         return d
1936     def addprop(self, **properties):
1937         """Add properties to this class.
1939         The keyword arguments in 'properties' must map names to property
1940         objects, or a TypeError is raised.  None of the keys in 'properties'
1941         may collide with the names of existing properties, or a ValueError
1942         is raised before any properties have been added.
1943         """
1944         for key in properties:
1945             if key in self.properties:
1946                 raise ValueError(key)
1947         self.properties.update(properties)
1949     def index(self, nodeid):
1950         """ Add (or refresh) the node to search indexes """
1951         # find all the String properties that have indexme
1952         for prop, propclass in self.getprops().iteritems():
1953             if isinstance(propclass, hyperdb.String) and propclass.indexme:
1954                 # index them under (classname, nodeid, property)
1955                 try:
1956                     value = str(self.get(nodeid, prop))
1957                 except IndexError:
1958                     # node has been destroyed
1959                     continue
1960                 self.db.indexer.add_text((self.classname, nodeid, prop), value)
1962     #
1963     # import / export support
1964     #
1965     def export_list(self, propnames, nodeid):
1966         """ Export a node - generate a list of CSV-able data in the order
1967             specified by propnames for the given node.
1968         """
1969         properties = self.getprops()
1970         l = []
1971         for prop in propnames:
1972             proptype = properties[prop]
1973             value = self.get(nodeid, prop)
1974             # "marshal" data where needed
1975             if value is None:
1976                 pass
1977             elif isinstance(proptype, hyperdb.Date):
1978                 value = value.get_tuple()
1979             elif isinstance(proptype, hyperdb.Interval):
1980                 value = value.get_tuple()
1981             elif isinstance(proptype, hyperdb.Password):
1982                 value = str(value)
1983             l.append(repr(value))
1985         # append retired flag
1986         l.append(repr(self.is_retired(nodeid)))
1988         return l
1990     def import_list(self, propnames, proplist):
1991         """ Import a node - all information including "id" is present and
1992             should not be sanity checked. Triggers are not triggered. The
1993             journal should be initialised using the "creator" and "created"
1994             information.
1996             Return the nodeid of the node imported.
1997         """
1998         if self.db.journaltag is None:
1999             raise hyperdb.DatabaseError(_('Database open read-only'))
2000         properties = self.getprops()
2002         # make the new node's property map
2003         d = {}
2004         newid = None
2005         for i in range(len(propnames)):
2006             # Figure the property for this column
2007             propname = propnames[i]
2009             # Use eval to reverse the repr() used to output the CSV
2010             value = eval(proplist[i])
2012             # "unmarshal" where necessary
2013             if propname == 'id':
2014                 newid = value
2015                 continue
2016             elif propname == 'is retired':
2017                 # is the item retired?
2018                 if int(value):
2019                     d[self.db.RETIRED_FLAG] = 1
2020                 continue
2021             elif value is None:
2022                 d[propname] = None
2023                 continue
2025             prop = properties[propname]
2026             if isinstance(prop, hyperdb.Date):
2027                 value = date.Date(value)
2028             elif isinstance(prop, hyperdb.Interval):
2029                 value = date.Interval(value)
2030             elif isinstance(prop, hyperdb.Password):
2031                 pwd = password.Password()
2032                 pwd.unpack(value)
2033                 value = pwd
2034             d[propname] = value
2036         # get a new id if necessary
2037         if newid is None:
2038             newid = self.db.newid(self.classname)
2040         # add the node and journal
2041         self.db.addnode(self.classname, newid, d)
2042         return newid
2044     def export_journals(self):
2045         """Export a class's journal - generate a list of lists of
2046         CSV-able data:
2048             nodeid, date, user, action, params
2050         No heading here - the columns are fixed.
2051         """
2052         properties = self.getprops()
2053         r = []
2054         for nodeid in self.getnodeids():
2055             for nodeid, date, user, action, params in self.history(nodeid):
2056                 date = date.get_tuple()
2057                 if action == 'set':
2058                     export_data = {}
2059                     for propname, value in params.iteritems():
2060                         if propname not in properties:
2061                             # property no longer in the schema
2062                             continue
2064                         prop = properties[propname]
2065                         # make sure the params are eval()'able
2066                         if value is None:
2067                             pass
2068                         elif isinstance(prop, hyperdb.Date):
2069                             # this is a hack - some dates are stored as strings
2070                             if not isinstance(value, type('')):
2071                                 value = value.get_tuple()
2072                         elif isinstance(prop, hyperdb.Interval):
2073                             # hack too - some intervals are stored as strings
2074                             if not isinstance(value, type('')):
2075                                 value = value.get_tuple()
2076                         elif isinstance(prop, hyperdb.Password):
2077                             value = str(value)
2078                         export_data[propname] = value
2079                     params = export_data
2080                 r.append([repr(nodeid), repr(date), repr(user),
2081                     repr(action), repr(params)])
2082         return r
2084 class FileClass(hyperdb.FileClass, Class):
2085     """This class defines a large chunk of data. To support this, it has a
2086        mandatory String property "content" which is typically saved off
2087        externally to the hyperdb.
2089        The default MIME type of this data is defined by the
2090        "default_mime_type" class attribute, which may be overridden by each
2091        node if the class defines a "type" String property.
2092     """
2093     def __init__(self, db, classname, **properties):
2094         """The newly-created class automatically includes the "content"
2095         and "type" properties.
2096         """
2097         if 'content' not in properties:
2098             properties['content'] = hyperdb.String(indexme='yes')
2099         if 'type' not in properties:
2100             properties['type'] = hyperdb.String()
2101         Class.__init__(self, db, classname, **properties)
2103     def create(self, **propvalues):
2104         """ Snarf the "content" propvalue and store in a file
2105         """
2106         # we need to fire the auditors now, or the content property won't
2107         # be in propvalues for the auditors to play with
2108         self.fireAuditors('create', None, propvalues)
2110         # now remove the content property so it's not stored in the db
2111         content = propvalues['content']
2112         del propvalues['content']
2114         # make sure we have a MIME type
2115         mime_type = propvalues.get('type', self.default_mime_type)
2117         # do the database create
2118         newid = self.create_inner(**propvalues)
2120         # store off the content as a file
2121         self.db.storefile(self.classname, newid, None, content)
2123         # fire reactors
2124         self.fireReactors('create', newid, None)
2126         return newid
2128     def get(self, nodeid, propname, default=_marker, cache=1):
2129         """ Trap the content propname and get it from the file
2131         'cache' exists for backwards compatibility, and is not used.
2132         """
2133         poss_msg = 'Possibly an access right configuration problem.'
2134         if propname == 'content':
2135             try:
2136                 return self.db.getfile(self.classname, nodeid, None)
2137             except IOError, strerror:
2138                 # XXX by catching this we don't see an error in the log.
2139                 return 'ERROR reading file: %s%s\n%s\n%s'%(
2140                         self.classname, nodeid, poss_msg, strerror)
2141         if default is not _marker:
2142             return Class.get(self, nodeid, propname, default)
2143         else:
2144             return Class.get(self, nodeid, propname)
2146     def set(self, itemid, **propvalues):
2147         """ Snarf the "content" propvalue and update it in a file
2148         """
2149         self.fireAuditors('set', itemid, propvalues)
2151         # create the oldvalues dict - fill in any missing values
2152         oldvalues = copy.deepcopy(self.db.getnode(self.classname, itemid))
2153         for name, prop in self.getprops(protected=0).iteritems():
2154             if name in oldvalues:
2155                 continue
2156             if isinstance(prop, hyperdb.Multilink):
2157                 oldvalues[name] = []
2158             else:
2159                 oldvalues[name] = None
2161         # now remove the content property so it's not stored in the db
2162         content = None
2163         if 'content' in propvalues:
2164             content = propvalues['content']
2165             del propvalues['content']
2167         # do the database update
2168         propvalues = self.set_inner(itemid, **propvalues)
2170         # do content?
2171         if content:
2172             # store and possibly index
2173             self.db.storefile(self.classname, itemid, None, content)
2174             if self.properties['content'].indexme:
2175                 mime_type = self.get(itemid, 'type', self.default_mime_type)
2176                 self.db.indexer.add_text((self.classname, itemid, 'content'),
2177                     content, mime_type)
2178             propvalues['content'] = content
2180         # fire reactors
2181         self.fireReactors('set', itemid, oldvalues)
2182         return propvalues
2184     def index(self, nodeid):
2185         """ Add (or refresh) the node to search indexes.
2187         Use the content-type property for the content property.
2188         """
2189         # find all the String properties that have indexme
2190         for prop, propclass in self.getprops().iteritems():
2191             if prop == 'content' and propclass.indexme:
2192                 mime_type = self.get(nodeid, 'type', self.default_mime_type)
2193                 self.db.indexer.add_text((self.classname, nodeid, 'content'),
2194                     str(self.get(nodeid, 'content')), mime_type)
2195             elif isinstance(propclass, hyperdb.String) and propclass.indexme:
2196                 # index them under (classname, nodeid, property)
2197                 try:
2198                     value = str(self.get(nodeid, prop))
2199                 except IndexError:
2200                     # node has been destroyed
2201                     continue
2202                 self.db.indexer.add_text((self.classname, nodeid, prop), value)
2204 # deviation from spec - was called ItemClass
2205 class IssueClass(Class, roundupdb.IssueClass):
2206     # Overridden methods:
2207     def __init__(self, db, classname, **properties):
2208         """The newly-created class automatically includes the "messages",
2209         "files", "nosy", and "superseder" properties.  If the 'properties'
2210         dictionary attempts to specify any of these properties or a
2211         "creation" or "activity" property, a ValueError is raised.
2212         """
2213         if 'title' not in properties:
2214             properties['title'] = hyperdb.String(indexme='yes')
2215         if 'messages' not in properties:
2216             properties['messages'] = hyperdb.Multilink("msg")
2217         if 'files' not in properties:
2218             properties['files'] = hyperdb.Multilink("file")
2219         if 'nosy' not in properties:
2220             # note: journalling is turned off as it really just wastes
2221             # space. this behaviour may be overridden in an instance
2222             properties['nosy'] = hyperdb.Multilink("user", do_journal="no")
2223         if 'superseder' not in properties:
2224             properties['superseder'] = hyperdb.Multilink(classname)
2225         Class.__init__(self, db, classname, **properties)
2227 # vim: set et sts=4 sw=4 :