Code

nicer error message for invalid class lookup
[roundup.git] / roundup / backends / back_gadfly.py
1 # $Id: back_gadfly.py,v 1.20 2002-09-15 23:06:20 richard Exp $
2 __doc__ = '''
3 About Gadfly
4 ============
6 Gadfly  is  a  collection  of  python modules that provides relational
7 database  functionality  entirely implemented in Python. It supports a
8 subset  of  the intergalactic standard RDBMS Structured Query Language
9 SQL.
12 Basic Structure
13 ===============
15 We map roundup classes to relational tables. Automatically detect schema
16 changes and modify the gadfly table schemas appropriately. Multilinks
17 (which represent a many-to-many relationship) are handled through
18 intermediate tables.
20 Journals are stored adjunct to the per-class tables.
22 Table names and columns have "_" prepended so the names can't
23 clash with restricted names (like "order"). Retirement is determined by the
24 __retired__ column being true.
26 All columns are defined as VARCHAR, since it really doesn't matter what
27 type they're defined as. We stuff all kinds of data in there ;) [as long as
28 it's marshallable, gadfly doesn't care]
31 Additional Instance Requirements
32 ================================
34 The instance configuration must specify where the database is. It does this
35 with GADFLY_DATABASE, which is used as the arguments to the gadfly.gadfly()
36 method:
38 Using an on-disk database directly (not a good idea):
39   GADFLY_DATABASE = (database name, directory)
41 Using a network database (much better idea):
42   GADFLY_DATABASE = (policy, password, address, port)
44 Because multiple accesses directly to a gadfly database aren't handled, but
45 multiple network accesses are, it's strongly advised that the latter setup be
46 used.
48 '''
50 # standard python modules
51 import sys, os, time, re, errno, weakref, copy
53 # roundup modules
54 from roundup import hyperdb, date, password, roundupdb, security
55 from roundup.hyperdb import String, Password, Date, Interval, Link, \
56     Multilink, DatabaseError, Boolean, Number
58 # the all-important gadfly :)
59 import gadfly
60 import gadfly.client
61 import gadfly.database
63 # support
64 from blobfiles import FileStorage
65 from roundup.indexer import Indexer
66 from sessions import Sessions
68 class Database(FileStorage, hyperdb.Database, roundupdb.Database):
69     # flag to set on retired entries
70     RETIRED_FLAG = '__hyperdb_retired'
72     def __init__(self, config, journaltag=None):
73         ''' Open the database and load the schema from it.
74         '''
75         self.config, self.journaltag = config, journaltag
76         self.dir = config.DATABASE
77         self.classes = {}
78         self.indexer = Indexer(self.dir)
79         self.sessions = Sessions(self.config)
80         self.security = security.Security(self)
82         # additional transaction support for external files and the like
83         self.transactions = []
85         db = getattr(config, 'GADFLY_DATABASE', ('database', self.dir))
86         if len(db) == 2:
87             # ensure files are group readable and writable
88             os.umask(0002)
89             try:
90                 self.conn = gadfly.gadfly(*db)
91             except IOError, error:
92                 if error.errno != errno.ENOENT:
93                     raise
94                 self.database_schema = {}
95                 self.conn = gadfly.gadfly()
96                 self.conn.startup(*db)
97                 cursor = self.conn.cursor()
98                 cursor.execute('create table schema (schema varchar)')
99                 cursor.execute('create table ids (name varchar, num integer)')
100             else:
101                 cursor = self.conn.cursor()
102                 cursor.execute('select schema from schema')
103                 self.database_schema = cursor.fetchone()[0]
104         else:
105             self.conn = gadfly.client.gfclient(*db)
106             cursor = self.conn.cursor()
107             cursor.execute('select schema from schema')
108             self.database_schema = cursor.fetchone()[0]
110     def __repr__(self):
111         return '<roundfly 0x%x>'%id(self)
113     def post_init(self):
114         ''' Called once the schema initialisation has finished.
116             We should now confirm that the schema defined by our "classes"
117             attribute actually matches the schema in the database.
118         '''
119         # now detect changes in the schema
120         for classname, spec in self.classes.items():
121             if self.database_schema.has_key(classname):
122                 dbspec = self.database_schema[classname]
123                 self.update_class(spec, dbspec)
124                 self.database_schema[classname] = spec.schema()
125             else:
126                 self.create_class(spec)
127                 self.database_schema[classname] = spec.schema()
129         for classname in self.database_schema.keys():
130             if not self.classes.has_key(classname):
131                 self.drop_class(classname)
133         # update the database version of the schema
134         cursor = self.conn.cursor()
135         cursor.execute('delete from schema')
136         cursor.execute('insert into schema values (?)', (self.database_schema,))
138         # reindex the db if necessary
139         if self.indexer.should_reindex():
140             self.reindex()
142         # commit
143         self.conn.commit()
145     def reindex(self):
146         for klass in self.classes.values():
147             for nodeid in klass.list():
148                 klass.index(nodeid)
149         self.indexer.save_index()
151     def determine_columns(self, spec):
152         ''' Figure the column names and multilink properties from the spec
153         '''
154         cols = []
155         mls = []
156         # add the multilinks separately
157         for col, prop in spec.properties.items():
158             if isinstance(prop, Multilink):
159                 mls.append(col)
160             else:
161                 cols.append('_'+col)
162         cols.sort()
163         return cols, mls
165     def update_class(self, spec, dbspec):
166         ''' Determine the differences between the current spec and the
167             database version of the spec, and update where necessary
169             NOTE that this doesn't work for adding/deleting properties!
170              ... until gadfly grows an ALTER TABLE command, it's not going to!
171         '''
172         spec_schema = spec.schema()
173         if spec_schema == dbspec:
174             return
175         if __debug__:
176             print >>hyperdb.DEBUG, 'update_class FIRING'
178         # key property changed?
179         if dbspec[0] != spec_schema[0]:
180             if __debug__:
181                 print >>hyperdb.DEBUG, 'update_class setting keyprop', `spec[0]`
182             # XXX turn on indexing for the key property
184         # dict 'em up
185         spec_propnames,spec_props = [],{}
186         for propname,prop in spec_schema[1]:
187             spec_propnames.append(propname)
188             spec_props[propname] = prop
189         dbspec_propnames,dbspec_props = [],{}
190         for propname,prop in dbspec[1]:
191             dbspec_propnames.append(propname)
192             dbspec_props[propname] = prop
194         # we're going to need one of these
195         cursor = self.conn.cursor()
197         # now compare
198         for propname in spec_propnames:
199             prop = spec_props[propname]
200             if __debug__:
201                 print >>hyperdb.DEBUG, 'update_class ...', `prop`
202             if dbspec_props.has_key(propname) and prop==dbspec_props[propname]:
203                 continue
204             if __debug__:
205                 print >>hyperdb.DEBUG, 'update_class', `prop`
207             if not dbspec_props.has_key(propname):
208                 # add the property
209                 if isinstance(prop, Multilink):
210                     sql = 'create table %s_%s (linkid varchar, nodeid '\
211                         'varchar)'%(spec.classname, prop)
212                     if __debug__:
213                         print >>hyperdb.DEBUG, 'update_class', (self, sql)
214                     cursor.execute(sql)
215                 else:
216                     # XXX gadfly doesn't have an ALTER TABLE command
217                     raise NotImplementedError
218                     sql = 'alter table _%s add column (_%s varchar)'%(
219                         spec.classname, propname)
220                     if __debug__:
221                         print >>hyperdb.DEBUG, 'update_class', (self, sql)
222                     cursor.execute(sql)
223             else:
224                 # modify the property
225                 if __debug__:
226                     print >>hyperdb.DEBUG, 'update_class NOOP'
227                 pass  # NOOP in gadfly
229         # and the other way - only worry about deletions here
230         for propname in dbspec_propnames:
231             prop = dbspec_props[propname]
232             if spec_props.has_key(propname):
233                 continue
234             if __debug__:
235                 print >>hyperdb.DEBUG, 'update_class', `prop`
237             # delete the property
238             if isinstance(prop, Multilink):
239                 sql = 'drop table %s_%s'%(spec.classname, prop)
240                 if __debug__:
241                     print >>hyperdb.DEBUG, 'update_class', (self, sql)
242                 cursor.execute(sql)
243             else:
244                 # XXX gadfly doesn't have an ALTER TABLE command
245                 raise NotImplementedError
246                 sql = 'alter table _%s delete column _%s'%(spec.classname,
247                     propname)
248                 if __debug__:
249                     print >>hyperdb.DEBUG, 'update_class', (self, sql)
250                 cursor.execute(sql)
252     def create_class(self, spec):
253         ''' Create a database table according to the given spec.
254         '''
255         cols, mls = self.determine_columns(spec)
257         # add on our special columns
258         cols.append('id')
259         cols.append('__retired__')
261         cursor = self.conn.cursor()
263         # create the base table
264         cols = ','.join(['%s varchar'%x for x in cols])
265         sql = 'create table _%s (%s)'%(spec.classname, cols)
266         if __debug__:
267             print >>hyperdb.DEBUG, 'create_class', (self, sql)
268         cursor.execute(sql)
270         # journal table
271         cols = ','.join(['%s varchar'%x
272             for x in 'nodeid date tag action params'.split()])
273         sql = 'create table %s__journal (%s)'%(spec.classname, cols)
274         if __debug__:
275             print >>hyperdb.DEBUG, 'create_class', (self, sql)
276         cursor.execute(sql)
278         # now create the multilink tables
279         for ml in mls:
280             sql = 'create table %s_%s (linkid varchar, nodeid varchar)'%(
281                 spec.classname, ml)
282             if __debug__:
283                 print >>hyperdb.DEBUG, 'create_class', (self, sql)
284             cursor.execute(sql)
286         # ID counter
287         sql = 'insert into ids (name, num) values (?,?)'
288         vals = (spec.classname, 1)
289         if __debug__:
290             print >>hyperdb.DEBUG, 'create_class', (self, sql, vals)
291         cursor.execute(sql, vals)
293     def drop_class(self, spec):
294         ''' Drop the given table from the database.
296             Drop the journal and multilink tables too.
297         '''
298         # figure the multilinks
299         mls = []
300         for col, prop in spec.properties.items():
301             if isinstance(prop, Multilink):
302                 mls.append(col)
303         cursor = self.conn.cursor()
305         sql = 'drop table _%s'%spec.classname
306         if __debug__:
307             print >>hyperdb.DEBUG, 'drop_class', (self, sql)
308         cursor.execute(sql)
310         sql = 'drop table %s__journal'%spec.classname
311         if __debug__:
312             print >>hyperdb.DEBUG, 'drop_class', (self, sql)
313         cursor.execute(sql)
315         for ml in mls:
316             sql = 'drop table %s_%s'%(spec.classname, ml)
317             if __debug__:
318                 print >>hyperdb.DEBUG, 'drop_class', (self, sql)
319             cursor.execute(sql)
321     #
322     # Classes
323     #
324     def __getattr__(self, classname):
325         ''' A convenient way of calling self.getclass(classname).
326         '''
327         if self.classes.has_key(classname):
328             if __debug__:
329                 print >>hyperdb.DEBUG, '__getattr__', (self, classname)
330             return self.classes[classname]
331         raise AttributeError, classname
333     def addclass(self, cl):
334         ''' Add a Class to the hyperdatabase.
335         '''
336         if __debug__:
337             print >>hyperdb.DEBUG, 'addclass', (self, cl)
338         cn = cl.classname
339         if self.classes.has_key(cn):
340             raise ValueError, cn
341         self.classes[cn] = cl
343     def getclasses(self):
344         ''' Return a list of the names of all existing classes.
345         '''
346         if __debug__:
347             print >>hyperdb.DEBUG, 'getclasses', (self,)
348         l = self.classes.keys()
349         l.sort()
350         return l
352     def getclass(self, classname):
353         '''Get the Class object representing a particular class.
355         If 'classname' is not a valid class name, a KeyError is raised.
356         '''
357         if __debug__:
358             print >>hyperdb.DEBUG, 'getclass', (self, classname)
359         try:
360             return self.classes[classname]
361         except KeyError:
362             raise KeyError, 'There is no class called "%s"'%classname
364     def clear(self):
365         ''' Delete all database contents.
367             Note: I don't commit here, which is different behaviour to the
368             "nuke from orbit" behaviour in the *dbms.
369         '''
370         if __debug__:
371             print >>hyperdb.DEBUG, 'clear', (self,)
372         cursor = self.conn.cursor()
373         for cn in self.classes.keys():
374             sql = 'delete from _%s'%cn
375             if __debug__:
376                 print >>hyperdb.DEBUG, 'clear', (self, sql)
377             cursor.execute(sql)
379     #
380     # Node IDs
381     #
382     def newid(self, classname):
383         ''' Generate a new id for the given class
384         '''
385         # get the next ID
386         cursor = self.conn.cursor()
387         sql = 'select num from ids where name=?'
388         if __debug__:
389             print >>hyperdb.DEBUG, 'newid', (self, sql, classname)
390         cursor.execute(sql, (classname, ))
391         newid = cursor.fetchone()[0]
393         # update the counter
394         sql = 'update ids set num=? where name=?'
395         vals = (newid+1, classname)
396         if __debug__:
397             print >>hyperdb.DEBUG, 'newid', (self, sql, vals)
398         cursor.execute(sql, vals)
400         # return as string
401         return str(newid)
403     def setid(self, classname, setid):
404         ''' Set the id counter: used during import of database
405         '''
406         cursor = self.conn.cursor()
407         sql = 'update ids set num=? where name=?'
408         vals = (setid, spec.classname)
409         if __debug__:
410             print >>hyperdb.DEBUG, 'setid', (self, sql, vals)
411         cursor.execute(sql, vals)
413     #
414     # Nodes
415     #
417     def addnode(self, classname, nodeid, node):
418         ''' Add the specified node to its class's db.
419         '''
420         if __debug__:
421             print >>hyperdb.DEBUG, 'addnode', (self, classname, nodeid, node)
422         # gadfly requires values for all non-multilink columns
423         cl = self.classes[classname]
424         cols, mls = self.determine_columns(cl)
426         # default the non-multilink columns
427         for col, prop in cl.properties.items():
428             if not isinstance(col, Multilink):
429                 if not node.has_key(col):
430                     node[col] = None
432         node = self.serialise(classname, node)
434         # make sure the ordering is correct for column name -> column value
435         vals = tuple([node[col[1:]] for col in cols]) + (nodeid, 0)
436         s = ','.join(['?' for x in cols]) + ',?,?'
437         cols = ','.join(cols) + ',id,__retired__'
439         # perform the inserts
440         cursor = self.conn.cursor()
441         sql = 'insert into _%s (%s) values (%s)'%(classname, cols, s)
442         if __debug__:
443             print >>hyperdb.DEBUG, 'addnode', (self, sql, vals)
444         cursor.execute(sql, vals)
446         # insert the multilink rows
447         for col in mls:
448             t = '%s_%s'%(classname, col)
449             for entry in node[col]:
450                 sql = 'insert into %s (linkid, nodeid) values (?,?)'%t
451                 vals = (entry, nodeid)
452                 if __debug__:
453                     print >>hyperdb.DEBUG, 'addnode', (self, sql, vals)
454                 cursor.execute(sql, vals)
456         # make sure we do the commit-time extra stuff for this node
457         self.transactions.append((self.doSaveNode, (classname, nodeid, node)))
459     def setnode(self, classname, nodeid, node, multilink_changes):
460         ''' Change the specified node.
461         '''
462         if __debug__:
463             print >>hyperdb.DEBUG, 'setnode', (self, classname, nodeid, node)
464         node = self.serialise(classname, node)
466         cl = self.classes[classname]
467         cols = []
468         mls = []
469         # add the multilinks separately
470         for col in node.keys():
471             prop = cl.properties[col]
472             if isinstance(prop, Multilink):
473                 mls.append(col)
474             else:
475                 cols.append('_'+col)
476         cols.sort()
478         # make sure the ordering is correct for column name -> column value
479         vals = tuple([node[col[1:]] for col in cols])
480         s = ','.join(['%s=?'%x for x in cols])
481         cols = ','.join(cols)
483         # perform the update
484         cursor = self.conn.cursor()
485         sql = 'update _%s set %s'%(classname, s)
486         if __debug__:
487             print >>hyperdb.DEBUG, 'setnode', (self, sql, vals)
488         cursor.execute(sql, vals)
490         # now the fun bit, updating the multilinks ;)
491         for col, (add, remove) in multilink_changes.items():
492             tn = '%s_%s'%(classname, col)
493             if add:
494                 sql = 'insert into %s (nodeid, linkid) values (?,?)'%tn
495                 vals = [(nodeid, addid) for addid in add]
496                 if __debug__:
497                     print >>hyperdb.DEBUG, 'setnode (add)', (self, sql, vals)
498                 cursor.execute(sql, vals)
499             if remove:
500                 sql = 'delete from %s where nodeid=? and linkid=?'%tn
501                 vals = [(nodeid, removeid) for removeid in remove]
502                 if __debug__:
503                     print >>hyperdb.DEBUG, 'setnode (rem)', (self, sql, vals)
504                 cursor.execute(sql, vals)
506         # make sure we do the commit-time extra stuff for this node
507         self.transactions.append((self.doSaveNode, (classname, nodeid, node)))
509     def getnode(self, classname, nodeid):
510         ''' Get a node from the database.
511         '''
512         if __debug__:
513             print >>hyperdb.DEBUG, 'getnode', (self, classname, nodeid)
514         # figure the columns we're fetching
515         cl = self.classes[classname]
516         cols, mls = self.determine_columns(cl)
517         scols = ','.join(cols)
519         # perform the basic property fetch
520         cursor = self.conn.cursor()
521         sql = 'select %s from _%s where id=?'%(scols, classname)
522         if __debug__:
523             print >>hyperdb.DEBUG, 'getnode', (self, sql, nodeid)
524         cursor.execute(sql, (nodeid,))
525         try:
526             values = cursor.fetchone()
527         except gadfly.database.error, message:
528             if message == 'no more results':
529                 raise IndexError, 'no such %s node %s'%(classname, nodeid)
530             raise
532         # make up the node
533         node = {}
534         for col in range(len(cols)):
535             node[cols[col][1:]] = values[col]
537         # now the multilinks
538         for col in mls:
539             # get the link ids
540             sql = 'select linkid from %s_%s where nodeid=?'%(classname, col)
541             if __debug__:
542                 print >>hyperdb.DEBUG, 'getnode', (self, sql, nodeid)
543             cursor.execute(sql, (nodeid,))
544             # extract the first column from the result
545             node[col] = [x[0] for x in cursor.fetchall()]
547         return self.unserialise(classname, node)
549     def destroynode(self, classname, nodeid):
550         '''Remove a node from the database. Called exclusively by the
551            destroy() method on Class.
552         '''
553         if __debug__:
554             print >>hyperdb.DEBUG, 'destroynode', (self, classname, nodeid)
556         # make sure the node exists
557         if not self.hasnode(classname, nodeid):
558             raise IndexError, '%s has no node %s'%(classname, nodeid)
560         # see if there's any obvious commit actions that we should get rid of
561         for entry in self.transactions[:]:
562             if entry[1][:2] == (classname, nodeid):
563                 self.transactions.remove(entry)
565         # now do the SQL
566         cursor = self.conn.cursor()
567         sql = 'delete from _%s where id=?'%(classname)
568         if __debug__:
569             print >>hyperdb.DEBUG, 'destroynode', (self, sql, nodeid)
570         cursor.execute(sql, (nodeid,))
572     def serialise(self, classname, node):
573         '''Copy the node contents, converting non-marshallable data into
574            marshallable data.
575         '''
576         if __debug__:
577             print >>hyperdb.DEBUG, 'serialise', classname, node
578         properties = self.getclass(classname).getprops()
579         d = {}
580         for k, v in node.items():
581             # if the property doesn't exist, or is the "retired" flag then
582             # it won't be in the properties dict
583             if not properties.has_key(k):
584                 d[k] = v
585                 continue
587             # get the property spec
588             prop = properties[k]
590             if isinstance(prop, Password):
591                 d[k] = str(v)
592             elif isinstance(prop, Date) and v is not None:
593                 d[k] = v.serialise()
594             elif isinstance(prop, Interval) and v is not None:
595                 d[k] = v.serialise()
596             else:
597                 d[k] = v
598         return d
600     def unserialise(self, classname, node):
601         '''Decode the marshalled node data
602         '''
603         if __debug__:
604             print >>hyperdb.DEBUG, 'unserialise', classname, node
605         properties = self.getclass(classname).getprops()
606         d = {}
607         for k, v in node.items():
608             # if the property doesn't exist, or is the "retired" flag then
609             # it won't be in the properties dict
610             if not properties.has_key(k):
611                 d[k] = v
612                 continue
614             # get the property spec
615             prop = properties[k]
617             if isinstance(prop, Date) and v is not None:
618                 d[k] = date.Date(v)
619             elif isinstance(prop, Interval) and v is not None:
620                 d[k] = date.Interval(v)
621             elif isinstance(prop, Password):
622                 p = password.Password()
623                 p.unpack(v)
624                 d[k] = p
625             else:
626                 d[k] = v
627         return d
629     def hasnode(self, classname, nodeid):
630         ''' Determine if the database has a given node.
631         '''
632         cursor = self.conn.cursor()
633         sql = 'select count(*) from _%s where id=?'%classname
634         if __debug__:
635             print >>hyperdb.DEBUG, 'hasnode', (self, sql, nodeid)
636         cursor.execute(sql, (nodeid,))
637         return cursor.fetchone()[0]
639     def countnodes(self, classname):
640         ''' Count the number of nodes that exist for a particular Class.
641         '''
642         cursor = self.conn.cursor()
643         sql = 'select count(*) from _%s'%classname
644         if __debug__:
645             print >>hyperdb.DEBUG, 'countnodes', (self, sql)
646         cursor.execute(sql)
647         return cursor.fetchone()[0]
649     def getnodeids(self, classname, retired=0):
650         ''' Retrieve all the ids of the nodes for a particular Class.
652             Set retired=None to get all nodes. Otherwise it'll get all the 
653             retired or non-retired nodes, depending on the flag.
654         '''
655         cursor = self.conn.cursor()
656         # flip the sense of the flag if we don't want all of them
657         if retired is not None:
658             retired = not retired
659         sql = 'select id from _%s where __retired__ <> ?'%classname
660         if __debug__:
661             print >>hyperdb.DEBUG, 'getnodeids', (self, sql, retired)
662         cursor.execute(sql, (retired,))
663         return [x[0] for x in cursor.fetchall()]
665     def addjournal(self, classname, nodeid, action, params, creator=None,
666             creation=None):
667         ''' Journal the Action
668         'action' may be:
670             'create' or 'set' -- 'params' is a dictionary of property values
671             'link' or 'unlink' -- 'params' is (classname, nodeid, propname)
672             'retire' -- 'params' is None
673         '''
674         # serialise the parameters now if necessary
675         if isinstance(params, type({})):
676             if action in ('set', 'create'):
677                 params = self.serialise(classname, params)
679         # handle supply of the special journalling parameters (usually
680         # supplied on importing an existing database)
681         if creator:
682             journaltag = creator
683         else:
684             journaltag = self.journaltag
685         if creation:
686             journaldate = creation.serialise()
687         else:
688             journaldate = date.Date().serialise()
690         # create the journal entry
691         cols = ','.join('nodeid date tag action params'.split())
692         entry = (nodeid, journaldate, journaltag, action, params)
694         if __debug__:
695             print >>hyperdb.DEBUG, 'addjournal', entry
697         # do the insert
698         cursor = self.conn.cursor()
699         sql = 'insert into %s__journal (%s) values (?,?,?,?,?)'%(classname,
700             cols)
701         if __debug__:
702             print >>hyperdb.DEBUG, 'addjournal', (self, sql, entry)
703         cursor.execute(sql, entry)
705     def getjournal(self, classname, nodeid):
706         ''' get the journal for id
707         '''
708         # make sure the node exists
709         if not self.hasnode(classname, nodeid):
710             raise IndexError, '%s has no node %s'%(classname, nodeid)
712         # now get the journal entries
713         cols = ','.join('nodeid date tag action params'.split())
714         cursor = self.conn.cursor()
715         sql = 'select %s from %s__journal where nodeid=?'%(cols, classname)
716         if __debug__:
717             print >>hyperdb.DEBUG, 'getjournal', (self, sql, nodeid)
718         cursor.execute(sql, (nodeid,))
719         res = []
720         for nodeid, date_stamp, user, action, params in cursor.fetchall():
721             res.append((nodeid, date.Date(date_stamp), user, action, params))
722         return res
724     def pack(self, pack_before):
725         ''' Delete all journal entries except "create" before 'pack_before'.
726         '''
727         # get a 'yyyymmddhhmmss' version of the date
728         date_stamp = pack_before.serialise()
730         # do the delete
731         cursor = self.conn.cursor()
732         for classname in self.classes.keys():
733             sql = "delete from %s__journal where date<? and "\
734                 "action<>'create'"%classname
735             if __debug__:
736                 print >>hyperdb.DEBUG, 'pack', (self, sql, date_stamp)
737             cursor.execute(sql, (date_stamp,))
739     def commit(self):
740         ''' Commit the current transactions.
742         Save all data changed since the database was opened or since the
743         last commit() or rollback().
744         '''
745         if __debug__:
746             print >>hyperdb.DEBUG, 'commit', (self,)
748         # commit gadfly
749         self.conn.commit()
751         # now, do all the other transaction stuff
752         reindex = {}
753         for method, args in self.transactions:
754             reindex[method(*args)] = 1
756         # reindex the nodes that request it
757         for classname, nodeid in filter(None, reindex.keys()):
758             print >>hyperdb.DEBUG, 'commit.reindex', (classname, nodeid)
759             self.getclass(classname).index(nodeid)
761         # save the indexer state
762         self.indexer.save_index()
764         # clear out the transactions
765         self.transactions = []
767     def rollback(self):
768         ''' Reverse all actions from the current transaction.
770         Undo all the changes made since the database was opened or the last
771         commit() or rollback() was performed.
772         '''
773         if __debug__:
774             print >>hyperdb.DEBUG, 'rollback', (self,)
776         # roll back gadfly
777         self.conn.rollback()
779         # roll back "other" transaction stuff
780         for method, args in self.transactions:
781             # delete temporary files
782             if method == self.doStoreFile:
783                 self.rollbackStoreFile(*args)
784         self.transactions = []
786     def doSaveNode(self, classname, nodeid, node):
787         ''' dummy that just generates a reindex event
788         '''
789         # return the classname, nodeid so we reindex this content
790         return (classname, nodeid)
792     def close(self):
793         ''' Close off the connection.
794         '''
795         self.conn.close()
798 # The base Class class
800 class Class(hyperdb.Class):
801     ''' The handle to a particular class of nodes in a hyperdatabase.
802         
803         All methods except __repr__ and getnode must be implemented by a
804         concrete backend Class.
805     '''
807     def __init__(self, db, classname, **properties):
808         '''Create a new class with a given name and property specification.
810         'classname' must not collide with the name of an existing class,
811         or a ValueError is raised.  The keyword arguments in 'properties'
812         must map names to property objects, or a TypeError is raised.
813         '''
814         if (properties.has_key('creation') or properties.has_key('activity')
815                 or properties.has_key('creator')):
816             raise ValueError, '"creation", "activity" and "creator" are '\
817                 'reserved'
819         self.classname = classname
820         self.properties = properties
821         self.db = weakref.proxy(db)       # use a weak ref to avoid circularity
822         self.key = ''
824         # should we journal changes (default yes)
825         self.do_journal = 1
827         # do the db-related init stuff
828         db.addclass(self)
830         self.auditors = {'create': [], 'set': [], 'retire': []}
831         self.reactors = {'create': [], 'set': [], 'retire': []}
833     def schema(self):
834         ''' A dumpable version of the schema that we can store in the
835             database
836         '''
837         return (self.key, [(x, repr(y)) for x,y in self.properties.items()])
839     def enableJournalling(self):
840         '''Turn journalling on for this class
841         '''
842         self.do_journal = 1
844     def disableJournalling(self):
845         '''Turn journalling off for this class
846         '''
847         self.do_journal = 0
849     # Editing nodes:
850     def create(self, **propvalues):
851         ''' Create a new node of this class and return its id.
853         The keyword arguments in 'propvalues' map property names to values.
855         The values of arguments must be acceptable for the types of their
856         corresponding properties or a TypeError is raised.
857         
858         If this class has a key property, it must be present and its value
859         must not collide with other key strings or a ValueError is raised.
860         
861         Any other properties on this class that are missing from the
862         'propvalues' dictionary are set to None.
863         
864         If an id in a link or multilink property does not refer to a valid
865         node, an IndexError is raised.
866         '''
867         if propvalues.has_key('id'):
868             raise KeyError, '"id" is reserved'
870         if self.db.journaltag is None:
871             raise DatabaseError, 'Database open read-only'
873         if propvalues.has_key('creation') or propvalues.has_key('activity'):
874             raise KeyError, '"creation" and "activity" are reserved'
876         self.fireAuditors('create', None, propvalues)
878         # new node's id
879         newid = self.db.newid(self.classname)
881         # validate propvalues
882         num_re = re.compile('^\d+$')
883         for key, value in propvalues.items():
884             if key == self.key:
885                 try:
886                     self.lookup(value)
887                 except KeyError:
888                     pass
889                 else:
890                     raise ValueError, 'node with key "%s" exists'%value
892             # try to handle this property
893             try:
894                 prop = self.properties[key]
895             except KeyError:
896                 raise KeyError, '"%s" has no property "%s"'%(self.classname,
897                     key)
899             if value is not None and isinstance(prop, Link):
900                 if type(value) != type(''):
901                     raise ValueError, 'link value must be String'
902                 link_class = self.properties[key].classname
903                 # if it isn't a number, it's a key
904                 if not num_re.match(value):
905                     try:
906                         value = self.db.classes[link_class].lookup(value)
907                     except (TypeError, KeyError):
908                         raise IndexError, 'new property "%s": %s not a %s'%(
909                             key, value, link_class)
910                 elif not self.db.getclass(link_class).hasnode(value):
911                     raise IndexError, '%s has no node %s'%(link_class, value)
913                 # save off the value
914                 propvalues[key] = value
916                 # register the link with the newly linked node
917                 if self.do_journal and self.properties[key].do_journal:
918                     self.db.addjournal(link_class, value, 'link',
919                         (self.classname, newid, key))
921             elif isinstance(prop, Multilink):
922                 if type(value) != type([]):
923                     raise TypeError, 'new property "%s" not a list of ids'%key
925                 # clean up and validate the list of links
926                 link_class = self.properties[key].classname
927                 l = []
928                 for entry in value:
929                     if type(entry) != type(''):
930                         raise ValueError, '"%s" multilink value (%r) '\
931                             'must contain Strings'%(key, value)
932                     # if it isn't a number, it's a key
933                     if not num_re.match(entry):
934                         try:
935                             entry = self.db.classes[link_class].lookup(entry)
936                         except (TypeError, KeyError):
937                             raise IndexError, 'new property "%s": %s not a %s'%(
938                                 key, entry, self.properties[key].classname)
939                     l.append(entry)
940                 value = l
941                 propvalues[key] = value
943                 # handle additions
944                 for nodeid in value:
945                     if not self.db.getclass(link_class).hasnode(nodeid):
946                         raise IndexError, '%s has no node %s'%(link_class,
947                             nodeid)
948                     # register the link with the newly linked node
949                     if self.do_journal and self.properties[key].do_journal:
950                         self.db.addjournal(link_class, nodeid, 'link',
951                             (self.classname, newid, key))
953             elif isinstance(prop, String):
954                 if type(value) != type(''):
955                     raise TypeError, 'new property "%s" not a string'%key
957             elif isinstance(prop, Password):
958                 if not isinstance(value, password.Password):
959                     raise TypeError, 'new property "%s" not a Password'%key
961             elif isinstance(prop, Date):
962                 if value is not None and not isinstance(value, date.Date):
963                     raise TypeError, 'new property "%s" not a Date'%key
965             elif isinstance(prop, Interval):
966                 if value is not None and not isinstance(value, date.Interval):
967                     raise TypeError, 'new property "%s" not an Interval'%key
969             elif value is not None and isinstance(prop, Number):
970                 try:
971                     float(value)
972                 except ValueError:
973                     raise TypeError, 'new property "%s" not numeric'%key
975             elif value is not None and isinstance(prop, Boolean):
976                 try:
977                     int(value)
978                 except ValueError:
979                     raise TypeError, 'new property "%s" not boolean'%key
981         # make sure there's data where there needs to be
982         for key, prop in self.properties.items():
983             if propvalues.has_key(key):
984                 continue
985             if key == self.key:
986                 raise ValueError, 'key property "%s" is required'%key
987             if isinstance(prop, Multilink):
988                 propvalues[key] = []
989             else:
990                 propvalues[key] = None
992         # done
993         self.db.addnode(self.classname, newid, propvalues)
994         if self.do_journal:
995             self.db.addjournal(self.classname, newid, 'create', propvalues)
997         self.fireReactors('create', newid, None)
999         return newid
1001     def export_list(self, propnames, nodeid):
1002         ''' Export a node - generate a list of CSV-able data in the order
1003             specified by propnames for the given node.
1004         '''
1005         properties = self.getprops()
1006         l = []
1007         for prop in propnames:
1008             proptype = properties[prop]
1009             value = self.get(nodeid, prop)
1010             # "marshal" data where needed
1011             if value is None:
1012                 pass
1013             elif isinstance(proptype, hyperdb.Date):
1014                 value = value.get_tuple()
1015             elif isinstance(proptype, hyperdb.Interval):
1016                 value = value.get_tuple()
1017             elif isinstance(proptype, hyperdb.Password):
1018                 value = str(value)
1019             l.append(repr(value))
1020         return l
1022     def import_list(self, propnames, proplist):
1023         ''' Import a node - all information including "id" is present and
1024             should not be sanity checked. Triggers are not triggered. The
1025             journal should be initialised using the "creator" and "created"
1026             information.
1028             Return the nodeid of the node imported.
1029         '''
1030         if self.db.journaltag is None:
1031             raise DatabaseError, 'Database open read-only'
1032         properties = self.getprops()
1034         # make the new node's property map
1035         d = {}
1036         for i in range(len(propnames)):
1037             # Use eval to reverse the repr() used to output the CSV
1038             value = eval(proplist[i])
1040             # Figure the property for this column
1041             propname = propnames[i]
1042             prop = properties[propname]
1044             # "unmarshal" where necessary
1045             if propname == 'id':
1046                 newid = value
1047                 continue
1048             elif value is None:
1049                 # don't set Nones
1050                 continue
1051             elif isinstance(prop, hyperdb.Date):
1052                 value = date.Date(value)
1053             elif isinstance(prop, hyperdb.Interval):
1054                 value = date.Interval(value)
1055             elif isinstance(prop, hyperdb.Password):
1056                 pwd = password.Password()
1057                 pwd.unpack(value)
1058                 value = pwd
1059             d[propname] = value
1061         # extract the extraneous journalling gumpf and nuke it
1062         if d.has_key('creator'):
1063             creator = d['creator']
1064             del d['creator']
1065         if d.has_key('creation'):
1066             creation = d['creation']
1067             del d['creation']
1068         if d.has_key('activity'):
1069             del d['activity']
1071         # add the node and journal
1072         self.db.addnode(self.classname, newid, d)
1073         self.db.addjournal(self.classname, newid, 'create', d, creator,
1074             creation)
1075         return newid
1077     _marker = []
1078     def get(self, nodeid, propname, default=_marker, cache=1):
1079         '''Get the value of a property on an existing node of this class.
1081         'nodeid' must be the id of an existing node of this class or an
1082         IndexError is raised.  'propname' must be the name of a property
1083         of this class or a KeyError is raised.
1085         'cache' indicates whether the transaction cache should be queried
1086         for the node. If the node has been modified and you need to
1087         determine what its values prior to modification are, you need to
1088         set cache=0.
1089         '''
1090         if propname == 'id':
1091             return nodeid
1093         if propname == 'creation':
1094             if not self.do_journal:
1095                 raise ValueError, 'Journalling is disabled for this class'
1096             journal = self.db.getjournal(self.classname, nodeid)
1097             if journal:
1098                 return self.db.getjournal(self.classname, nodeid)[0][1]
1099             else:
1100                 # on the strange chance that there's no journal
1101                 return date.Date()
1102         if propname == 'activity':
1103             if not self.do_journal:
1104                 raise ValueError, 'Journalling is disabled for this class'
1105             journal = self.db.getjournal(self.classname, nodeid)
1106             if journal:
1107                 return self.db.getjournal(self.classname, nodeid)[-1][1]
1108             else:
1109                 # on the strange chance that there's no journal
1110                 return date.Date()
1111         if propname == 'creator':
1112             if not self.do_journal:
1113                 raise ValueError, 'Journalling is disabled for this class'
1114             journal = self.db.getjournal(self.classname, nodeid)
1115             if journal:
1116                 name = self.db.getjournal(self.classname, nodeid)[0][2]
1117             else:
1118                 return None
1119             try:
1120                 return self.db.user.lookup(name)
1121             except KeyError:
1122                 # the journaltag user doesn't exist any more
1123                 return None
1125         # get the property (raises KeyErorr if invalid)
1126         prop = self.properties[propname]
1128         # get the node's dict
1129         d = self.db.getnode(self.classname, nodeid) #, cache=cache)
1131         if not d.has_key(propname):
1132             if default is self._marker:
1133                 if isinstance(prop, Multilink):
1134                     return []
1135                 else:
1136                     return None
1137             else:
1138                 return default
1140         # don't pass our list to other code
1141         if isinstance(prop, Multilink):
1142             return d[propname][:]
1144         return d[propname]
1146     def getnode(self, nodeid, cache=1):
1147         ''' Return a convenience wrapper for the node.
1149         'nodeid' must be the id of an existing node of this class or an
1150         IndexError is raised.
1152         'cache' indicates whether the transaction cache should be queried
1153         for the node. If the node has been modified and you need to
1154         determine what its values prior to modification are, you need to
1155         set cache=0.
1156         '''
1157         return Node(self, nodeid, cache=cache)
1159     def set(self, nodeid, **propvalues):
1160         '''Modify a property on an existing node of this class.
1161         
1162         'nodeid' must be the id of an existing node of this class or an
1163         IndexError is raised.
1165         Each key in 'propvalues' must be the name of a property of this
1166         class or a KeyError is raised.
1168         All values in 'propvalues' must be acceptable types for their
1169         corresponding properties or a TypeError is raised.
1171         If the value of the key property is set, it must not collide with
1172         other key strings or a ValueError is raised.
1174         If the value of a Link or Multilink property contains an invalid
1175         node id, a ValueError is raised.
1176         '''
1177         if not propvalues:
1178             return propvalues
1180         if propvalues.has_key('creation') or propvalues.has_key('activity'):
1181             raise KeyError, '"creation" and "activity" are reserved'
1183         if propvalues.has_key('id'):
1184             raise KeyError, '"id" is reserved'
1186         if self.db.journaltag is None:
1187             raise DatabaseError, 'Database open read-only'
1189         self.fireAuditors('set', nodeid, propvalues)
1190         # Take a copy of the node dict so that the subsequent set
1191         # operation doesn't modify the oldvalues structure.
1192         # XXX used to try the cache here first
1193         oldvalues = copy.deepcopy(self.db.getnode(self.classname, nodeid))
1195         node = self.db.getnode(self.classname, nodeid)
1196         if self.is_retired(nodeid):
1197             raise IndexError
1198         num_re = re.compile('^\d+$')
1200         # if the journal value is to be different, store it in here
1201         journalvalues = {}
1203         # remember the add/remove stuff for multilinks, making it easier
1204         # for the Database layer to do its stuff
1205         multilink_changes = {}
1207         for propname, value in propvalues.items():
1208             # check to make sure we're not duplicating an existing key
1209             if propname == self.key and node[propname] != value:
1210                 try:
1211                     self.lookup(value)
1212                 except KeyError:
1213                     pass
1214                 else:
1215                     raise ValueError, 'node with key "%s" exists'%value
1217             # this will raise the KeyError if the property isn't valid
1218             # ... we don't use getprops() here because we only care about
1219             # the writeable properties.
1220             prop = self.properties[propname]
1222             # if the value's the same as the existing value, no sense in
1223             # doing anything
1224             if node.has_key(propname) and value == node[propname]:
1225                 del propvalues[propname]
1226                 continue
1228             # do stuff based on the prop type
1229             if isinstance(prop, Link):
1230                 link_class = prop.classname
1231                 # if it isn't a number, it's a key
1232                 if value is not None and not isinstance(value, type('')):
1233                     raise ValueError, 'property "%s" link value be a string'%(
1234                         propname)
1235                 if isinstance(value, type('')) and not num_re.match(value):
1236                     try:
1237                         value = self.db.classes[link_class].lookup(value)
1238                     except (TypeError, KeyError):
1239                         raise IndexError, 'new property "%s": %s not a %s'%(
1240                             propname, value, prop.classname)
1242                 if (value is not None and
1243                         not self.db.getclass(link_class).hasnode(value)):
1244                     raise IndexError, '%s has no node %s'%(link_class, value)
1246                 if self.do_journal and prop.do_journal:
1247                     # register the unlink with the old linked node
1248                     if node[propname] is not None:
1249                         self.db.addjournal(link_class, node[propname], 'unlink',
1250                             (self.classname, nodeid, propname))
1252                     # register the link with the newly linked node
1253                     if value is not None:
1254                         self.db.addjournal(link_class, value, 'link',
1255                             (self.classname, nodeid, propname))
1257             elif isinstance(prop, Multilink):
1258                 if type(value) != type([]):
1259                     raise TypeError, 'new property "%s" not a list of'\
1260                         ' ids'%propname
1261                 link_class = self.properties[propname].classname
1262                 l = []
1263                 for entry in value:
1264                     # if it isn't a number, it's a key
1265                     if type(entry) != type(''):
1266                         raise ValueError, 'new property "%s" link value ' \
1267                             'must be a string'%propname
1268                     if not num_re.match(entry):
1269                         try:
1270                             entry = self.db.classes[link_class].lookup(entry)
1271                         except (TypeError, KeyError):
1272                             raise IndexError, 'new property "%s": %s not a %s'%(
1273                                 propname, entry,
1274                                 self.properties[propname].classname)
1275                     l.append(entry)
1276                 value = l
1277                 propvalues[propname] = value
1279                 # figure the journal entry for this property
1280                 add = []
1281                 remove = []
1283                 # handle removals
1284                 if node.has_key(propname):
1285                     l = node[propname]
1286                 else:
1287                     l = []
1288                 for id in l[:]:
1289                     if id in value:
1290                         continue
1291                     # register the unlink with the old linked node
1292                     if self.do_journal and self.properties[propname].do_journal:
1293                         self.db.addjournal(link_class, id, 'unlink',
1294                             (self.classname, nodeid, propname))
1295                     l.remove(id)
1296                     remove.append(id)
1298                 # handle additions
1299                 for id in value:
1300                     if not self.db.getclass(link_class).hasnode(id):
1301                         raise IndexError, '%s has no node %s'%(link_class, id)
1302                     if id in l:
1303                         continue
1304                     # register the link with the newly linked node
1305                     if self.do_journal and self.properties[propname].do_journal:
1306                         self.db.addjournal(link_class, id, 'link',
1307                             (self.classname, nodeid, propname))
1308                     l.append(id)
1309                     add.append(id)
1311                 # figure the journal entry
1312                 l = []
1313                 if add:
1314                     l.append(('+', add))
1315                 if remove:
1316                     l.append(('-', remove))
1317                 multilink_changes[propname] = (add, remove)
1318                 if l:
1319                     journalvalues[propname] = tuple(l)
1321             elif isinstance(prop, String):
1322                 if value is not None and type(value) != type(''):
1323                     raise TypeError, 'new property "%s" not a string'%propname
1325             elif isinstance(prop, Password):
1326                 if not isinstance(value, password.Password):
1327                     raise TypeError, 'new property "%s" not a Password'%propname
1328                 propvalues[propname] = value
1330             elif value is not None and isinstance(prop, Date):
1331                 if not isinstance(value, date.Date):
1332                     raise TypeError, 'new property "%s" not a Date'% propname
1333                 propvalues[propname] = value
1335             elif value is not None and isinstance(prop, Interval):
1336                 if not isinstance(value, date.Interval):
1337                     raise TypeError, 'new property "%s" not an '\
1338                         'Interval'%propname
1339                 propvalues[propname] = value
1341             elif value is not None and isinstance(prop, Number):
1342                 try:
1343                     float(value)
1344                 except ValueError:
1345                     raise TypeError, 'new property "%s" not numeric'%propname
1347             elif value is not None and isinstance(prop, Boolean):
1348                 try:
1349                     int(value)
1350                 except ValueError:
1351                     raise TypeError, 'new property "%s" not boolean'%propname
1353             node[propname] = value
1355         # nothing to do?
1356         if not propvalues:
1357             return propvalues
1359         # do the set, and journal it
1360         self.db.setnode(self.classname, nodeid, node, multilink_changes)
1362         if self.do_journal:
1363             propvalues.update(journalvalues)
1364             self.db.addjournal(self.classname, nodeid, 'set', propvalues)
1366         self.fireReactors('set', nodeid, oldvalues)
1368         return propvalues        
1370     def retire(self, nodeid):
1371         '''Retire a node.
1372         
1373         The properties on the node remain available from the get() method,
1374         and the node's id is never reused.
1375         
1376         Retired nodes are not returned by the find(), list(), or lookup()
1377         methods, and other nodes may reuse the values of their key properties.
1378         '''
1379         if self.db.journaltag is None:
1380             raise DatabaseError, 'Database open read-only'
1382         cursor = self.db.conn.cursor()
1383         sql = 'update _%s set __retired__=1 where id=?'%self.classname
1384         if __debug__:
1385             print >>hyperdb.DEBUG, 'retire', (self, sql, nodeid)
1386         cursor.execute(sql, (nodeid,))
1388     def is_retired(self, nodeid):
1389         '''Return true if the node is rerired
1390         '''
1391         cursor = self.db.conn.cursor()
1392         sql = 'select __retired__ from _%s where id=?'%self.classname
1393         if __debug__:
1394             print >>hyperdb.DEBUG, 'is_retired', (self, sql, nodeid)
1395         cursor.execute(sql, (nodeid,))
1396         return cursor.fetchone()[0]
1398     def destroy(self, nodeid):
1399         '''Destroy a node.
1400         
1401         WARNING: this method should never be used except in extremely rare
1402                  situations where there could never be links to the node being
1403                  deleted
1404         WARNING: use retire() instead
1405         WARNING: the properties of this node will not be available ever again
1406         WARNING: really, use retire() instead
1408         Well, I think that's enough warnings. This method exists mostly to
1409         support the session storage of the cgi interface.
1411         The node is completely removed from the hyperdb, including all journal
1412         entries. It will no longer be available, and will generally break code
1413         if there are any references to the node.
1414         '''
1415         if self.db.journaltag is None:
1416             raise DatabaseError, 'Database open read-only'
1417         self.db.destroynode(self.classname, nodeid)
1419     def history(self, nodeid):
1420         '''Retrieve the journal of edits on a particular node.
1422         'nodeid' must be the id of an existing node of this class or an
1423         IndexError is raised.
1425         The returned list contains tuples of the form
1427             (date, tag, action, params)
1429         'date' is a Timestamp object specifying the time of the change and
1430         'tag' is the journaltag specified when the database was opened.
1431         '''
1432         if not self.do_journal:
1433             raise ValueError, 'Journalling is disabled for this class'
1434         return self.db.getjournal(self.classname, nodeid)
1436     # Locating nodes:
1437     def hasnode(self, nodeid):
1438         '''Determine if the given nodeid actually exists
1439         '''
1440         return self.db.hasnode(self.classname, nodeid)
1442     def setkey(self, propname):
1443         '''Select a String property of this class to be the key property.
1445         'propname' must be the name of a String property of this class or
1446         None, or a TypeError is raised.  The values of the key property on
1447         all existing nodes must be unique or a ValueError is raised.
1448         '''
1449         # XXX create an index on the key prop column
1450         prop = self.getprops()[propname]
1451         if not isinstance(prop, String):
1452             raise TypeError, 'key properties must be String'
1453         self.key = propname
1455     def getkey(self):
1456         '''Return the name of the key property for this class or None.'''
1457         return self.key
1459     def labelprop(self, default_to_id=0):
1460         ''' Return the property name for a label for the given node.
1462         This method attempts to generate a consistent label for the node.
1463         It tries the following in order:
1464             1. key property
1465             2. "name" property
1466             3. "title" property
1467             4. first property from the sorted property name list
1468         '''
1469         k = self.getkey()
1470         if  k:
1471             return k
1472         props = self.getprops()
1473         if props.has_key('name'):
1474             return 'name'
1475         elif props.has_key('title'):
1476             return 'title'
1477         if default_to_id:
1478             return 'id'
1479         props = props.keys()
1480         props.sort()
1481         return props[0]
1483     def lookup(self, keyvalue):
1484         '''Locate a particular node by its key property and return its id.
1486         If this class has no key property, a TypeError is raised.  If the
1487         'keyvalue' matches one of the values for the key property among
1488         the nodes in this class, the matching node's id is returned;
1489         otherwise a KeyError is raised.
1490         '''
1491         if not self.key:
1492             raise TypeError, 'No key property set for class %s'%self.classname
1494         cursor = self.db.conn.cursor()
1495         sql = 'select id from _%s where _%s=?'%(self.classname, self.key)
1496         if __debug__:
1497             print >>hyperdb.DEBUG, 'lookup', (self, sql, keyvalue)
1498         cursor.execute(sql, (keyvalue,))
1500         # see if there was a result
1501         l = cursor.fetchall()
1502         if not l:
1503             raise KeyError, keyvalue
1505         # return the id
1506         return l[0][0]
1508     def find(self, **propspec):
1509         '''Get the ids of nodes in this class which link to the given nodes.
1511         'propspec' consists of keyword args propname={nodeid:1,}   
1512         'propname' must be the name of a property in this class, or a
1513         KeyError is raised.  That property must be a Link or Multilink
1514         property, or a TypeError is raised.
1516         Any node in this class whose 'propname' property links to any of the
1517         nodeids will be returned. Used by the full text indexing, which knows
1518         that "foo" occurs in msg1, msg3 and file7, so we have hits on these
1519         issues:
1521             db.issue.find(messages={'1':1,'3':1}, files={'7':1})
1522         '''
1523         if __debug__:
1524             print >>hyperdb.DEBUG, 'find', (self, propspec)
1525         if not propspec:
1526             return []
1527         queries = []
1528         tables = []
1529         allvalues = ()
1530         for prop, values in propspec.items():
1531             allvalues += tuple(values.keys())
1532             tables.append('select nodeid from %s_%s where linkid in (%s)'%(
1533                 self.classname, prop, ','.join(['?' for x in values.keys()])))
1534         sql = '\nintersect\n'.join(tables)
1535         if __debug__:
1536             print >>hyperdb.DEBUG, 'find', (self, sql, allvalues)
1537         cursor = self.db.conn.cursor()
1538         cursor.execute(sql, allvalues)
1539         try:
1540             l = [x[0] for x in cursor.fetchall()]
1541         except gadfly.database.error, message:
1542             if message == 'no more results':
1543                 l = []
1544             raise
1545         if __debug__:
1546             print >>hyperdb.DEBUG, 'find ... ', l
1547         return l
1549     def list(self):
1550         ''' Return a list of the ids of the active nodes in this class.
1551         '''
1552         return self.db.getnodeids(self.classname, retired=0)
1554     def filter(self, search_matches, filterspec, sort, group):
1555         ''' Return a list of the ids of the active nodes in this class that
1556             match the 'filter' spec, sorted by the group spec and then the
1557             sort spec
1559             "filterspec" is {propname: value(s)}
1560             "sort" and "group" are (dir, prop) where dir is '+', '-' or None
1561                                and prop is a prop name or None
1562             "search_matches" is {nodeid: marker}
1563         '''
1564         cn = self.classname
1566         # figure the WHERE clause from the filterspec
1567         props = self.getprops()
1568         frum = ['_'+cn]
1569         where = []
1570         args = []
1571         for k, v in filterspec.items():
1572             propclass = props[k]
1573             if isinstance(propclass, Multilink):
1574                 tn = '%s_%s'%(cn, k)
1575                 frum.append(tn)
1576                 if isinstance(v, type([])):
1577                     s = ','.join(['?' for x in v])
1578                     where.append('id=%s.nodeid and %s.linkid in (%s)'%(tn,tn,s))
1579                     args = args + v
1580                 else:
1581                     where.append('id=%s.nodeid and %s.linkid = ?'%(tn, tn))
1582                     args.append(v)
1583             else:
1584                 if isinstance(v, type([])):
1585                     s = ','.join(['?' for x in v])
1586                     where.append('_%s in (%s)'%(k, s))
1587                     args = args + v
1588                 else:
1589                     where.append('_%s=?'%k)
1590                     args.append(v)
1592         # add results of full text search
1593         if search_matches is not None:
1594             v = search_matches.keys()
1595             s = ','.join(['?' for x in v])
1596             where.append('id in (%s)'%s)
1597             args = args + v
1599         # figure the order by clause
1600         orderby = []
1601         ordercols = []
1602         if sort[0] is not None and sort[1] is not None:
1603             if sort[0] != '-':
1604                 orderby.append('_'+sort[1])
1605                 ordercols.append(sort[1])
1606             else:
1607                 orderby.append('_'+sort[1]+' desc')
1608                 ordercols.append(sort[1])
1610         # figure the group by clause
1611         groupby = []
1612         groupcols = []
1613         if group[0] is not None and group[1] is not None:
1614             if group[0] != '-':
1615                 groupby.append('_'+group[1])
1616                 groupcols.append(group[1])
1617             else:
1618                 groupby.append('_'+group[1]+' desc')
1619                 groupcols.append(group[1])
1621         # construct the SQL
1622         frum = ','.join(frum)
1623         where = ' and '.join(where)
1624         cols = ['id']
1625         if orderby:
1626             cols = cols + ordercols
1627             order = ' order by %s'%(','.join(orderby))
1628         else:
1629             order = ''
1630         if groupby:
1631             cols = cols + groupcols
1632             group = ' group by %s'%(','.join(groupby))
1633         else:
1634             group = ''
1635         cols = ','.join(cols)
1636         sql = 'select %s from %s where %s%s%s'%(cols, frum, where, order,
1637             group)
1638         args = tuple(args)
1639         if __debug__:
1640             print >>hyperdb.DEBUG, 'find', (self, sql, args)
1641         cursor = self.db.conn.cursor()
1642         cursor.execute(sql, args)
1644     def count(self):
1645         '''Get the number of nodes in this class.
1647         If the returned integer is 'numnodes', the ids of all the nodes
1648         in this class run from 1 to numnodes, and numnodes+1 will be the
1649         id of the next node to be created in this class.
1650         '''
1651         return self.db.countnodes(self.classname)
1653     # Manipulating properties:
1654     def getprops(self, protected=1):
1655         '''Return a dictionary mapping property names to property objects.
1656            If the "protected" flag is true, we include protected properties -
1657            those which may not be modified.
1658         '''
1659         d = self.properties.copy()
1660         if protected:
1661             d['id'] = String()
1662             d['creation'] = hyperdb.Date()
1663             d['activity'] = hyperdb.Date()
1664             d['creator'] = hyperdb.Link("user")
1665         return d
1667     def addprop(self, **properties):
1668         '''Add properties to this class.
1670         The keyword arguments in 'properties' must map names to property
1671         objects, or a TypeError is raised.  None of the keys in 'properties'
1672         may collide with the names of existing properties, or a ValueError
1673         is raised before any properties have been added.
1674         '''
1675         for key in properties.keys():
1676             if self.properties.has_key(key):
1677                 raise ValueError, key
1678         self.properties.update(properties)
1680     def index(self, nodeid):
1681         '''Add (or refresh) the node to search indexes
1682         '''
1683         # find all the String properties that have indexme
1684         for prop, propclass in self.getprops().items():
1685             if isinstance(propclass, String) and propclass.indexme:
1686                 try:
1687                     value = str(self.get(nodeid, prop))
1688                 except IndexError:
1689                     # node no longer exists - entry should be removed
1690                     self.db.indexer.purge_entry((self.classname, nodeid, prop))
1691                 else:
1692                     # and index them under (classname, nodeid, property)
1693                     self.db.indexer.add_text((self.classname, nodeid, prop),
1694                         value)
1697     #
1698     # Detector interface
1699     #
1700     def audit(self, event, detector):
1701         '''Register a detector
1702         '''
1703         l = self.auditors[event]
1704         if detector not in l:
1705             self.auditors[event].append(detector)
1707     def fireAuditors(self, action, nodeid, newvalues):
1708         '''Fire all registered auditors.
1709         '''
1710         for audit in self.auditors[action]:
1711             audit(self.db, self, nodeid, newvalues)
1713     def react(self, event, detector):
1714         '''Register a detector
1715         '''
1716         l = self.reactors[event]
1717         if detector not in l:
1718             self.reactors[event].append(detector)
1720     def fireReactors(self, action, nodeid, oldvalues):
1721         '''Fire all registered reactors.
1722         '''
1723         for react in self.reactors[action]:
1724             react(self.db, self, nodeid, oldvalues)
1726 class FileClass(Class):
1727     '''This class defines a large chunk of data. To support this, it has a
1728        mandatory String property "content" which is typically saved off
1729        externally to the hyperdb.
1731        The default MIME type of this data is defined by the
1732        "default_mime_type" class attribute, which may be overridden by each
1733        node if the class defines a "type" String property.
1734     '''
1735     default_mime_type = 'text/plain'
1737     def create(self, **propvalues):
1738         ''' snaffle the file propvalue and store in a file
1739         '''
1740         content = propvalues['content']
1741         del propvalues['content']
1742         newid = Class.create(self, **propvalues)
1743         self.db.storefile(self.classname, newid, None, content)
1744         return newid
1746     def import_list(self, propnames, proplist):
1747         ''' Trap the "content" property...
1748         '''
1749         # dupe this list so we don't affect others
1750         propnames = propnames[:]
1752         # extract the "content" property from the proplist
1753         i = propnames.index('content')
1754         content = eval(proplist[i])
1755         del propnames[i]
1756         del proplist[i]
1758         # do the normal import
1759         newid = Class.import_list(self, propnames, proplist)
1761         # save off the "content" file
1762         self.db.storefile(self.classname, newid, None, content)
1763         return newid
1765     _marker = []
1766     def get(self, nodeid, propname, default=_marker, cache=1):
1767         ''' trap the content propname and get it from the file
1768         '''
1770         poss_msg = 'Possibly a access right configuration problem.'
1771         if propname == 'content':
1772             try:
1773                 return self.db.getfile(self.classname, nodeid, None)
1774             except IOError, (strerror):
1775                 # BUG: by catching this we donot see an error in the log.
1776                 return 'ERROR reading file: %s%s\n%s\n%s'%(
1777                         self.classname, nodeid, poss_msg, strerror)
1778         if default is not self._marker:
1779             return Class.get(self, nodeid, propname, default, cache=cache)
1780         else:
1781             return Class.get(self, nodeid, propname, cache=cache)
1783     def getprops(self, protected=1):
1784         ''' In addition to the actual properties on the node, these methods
1785             provide the "content" property. If the "protected" flag is true,
1786             we include protected properties - those which may not be
1787             modified.
1788         '''
1789         d = Class.getprops(self, protected=protected).copy()
1790         if protected:
1791             d['content'] = hyperdb.String()
1792         return d
1794     def index(self, nodeid):
1795         ''' Index the node in the search index.
1797             We want to index the content in addition to the normal String
1798             property indexing.
1799         '''
1800         # perform normal indexing
1801         Class.index(self, nodeid)
1803         # get the content to index
1804         content = self.get(nodeid, 'content')
1806         # figure the mime type
1807         if self.properties.has_key('type'):
1808             mime_type = self.get(nodeid, 'type')
1809         else:
1810             mime_type = self.default_mime_type
1812         # and index!
1813         self.db.indexer.add_text((self.classname, nodeid, 'content'), content,
1814             mime_type)
1816 # XXX deviation from spec - was called ItemClass
1817 class IssueClass(Class, roundupdb.IssueClass):
1818     # Overridden methods:
1819     def __init__(self, db, classname, **properties):
1820         '''The newly-created class automatically includes the "messages",
1821         "files", "nosy", and "superseder" properties.  If the 'properties'
1822         dictionary attempts to specify any of these properties or a
1823         "creation" or "activity" property, a ValueError is raised.
1824         '''
1825         if not properties.has_key('title'):
1826             properties['title'] = hyperdb.String(indexme='yes')
1827         if not properties.has_key('messages'):
1828             properties['messages'] = hyperdb.Multilink("msg")
1829         if not properties.has_key('files'):
1830             properties['files'] = hyperdb.Multilink("file")
1831         if not properties.has_key('nosy'):
1832             # note: journalling is turned off as it really just wastes
1833             # space. this behaviour may be overridden in an instance
1834             properties['nosy'] = hyperdb.Multilink("user", do_journal="no")
1835         if not properties.has_key('superseder'):
1836             properties['superseder'] = hyperdb.Multilink(classname)
1837         Class.__init__(self, db, classname, **properties)