Code

adding the "minimal" template
[roundup.git] / roundup / backends / rdbms_common.py
1 # $Id: rdbms_common.py,v 1.19 2002-09-26 03:04:24 richard Exp $
3 # standard python modules
4 import sys, os, time, re, errno, weakref, copy
6 # roundup modules
7 from roundup import hyperdb, date, password, roundupdb, security
8 from roundup.hyperdb import String, Password, Date, Interval, Link, \
9     Multilink, DatabaseError, Boolean, Number
11 # support
12 from blobfiles import FileStorage
13 from roundup.indexer import Indexer
14 from sessions import Sessions
16 # number of rows to keep in memory
17 ROW_CACHE_SIZE = 100
19 class Database(FileStorage, hyperdb.Database, roundupdb.Database):
20     ''' Wrapper around an SQL database that presents a hyperdb interface.
22         - some functionality is specific to the actual SQL database, hence
23           the sql_* methods that are NotImplemented
24         - we keep a cache of the latest ROW_CACHE_SIZE row fetches.
25     '''
26     def __init__(self, config, journaltag=None):
27         ''' Open the database and load the schema from it.
28         '''
29         self.config, self.journaltag = config, journaltag
30         self.dir = config.DATABASE
31         self.classes = {}
32         self.indexer = Indexer(self.dir)
33         self.sessions = Sessions(self.config)
34         self.security = security.Security(self)
36         # additional transaction support for external files and the like
37         self.transactions = []
39         # keep a cache of the N most recently retrieved rows of any kind
40         # (classname, nodeid) = row
41         self.cache = {}
42         self.cache_lru = []
44         # open a connection to the database, creating the "conn" attribute
45         self.open_connection()
47     def clearCache(self):
48         self.cache = {}
49         self.cache_lru = []
51     def open_connection(self):
52         ''' Open a connection to the database, creating it if necessary
53         '''
54         raise NotImplemented
56     def sql(self, sql, args=None):
57         ''' Execute the sql with the optional args.
58         '''
59         if __debug__:
60             print >>hyperdb.DEBUG, (self, sql, args)
61         if args:
62             self.cursor.execute(sql, args)
63         else:
64             self.cursor.execute(sql)
66     def sql_fetchone(self):
67         ''' Fetch a single row. If there's nothing to fetch, return None.
68         '''
69         raise NotImplemented
71     def sql_stringquote(self, value):
72         ''' Quote the string so it's safe to put in the 'sql quotes'
73         '''
74         return re.sub("'", "''", str(value))
76     def save_dbschema(self, schema):
77         ''' Save the schema definition that the database currently implements
78         '''
79         raise NotImplemented
81     def load_dbschema(self):
82         ''' Load the schema definition that the database currently implements
83         '''
84         raise NotImplemented
86     def post_init(self):
87         ''' Called once the schema initialisation has finished.
89             We should now confirm that the schema defined by our "classes"
90             attribute actually matches the schema in the database.
91         '''
92         # now detect changes in the schema
93         save = 0
94         for classname, spec in self.classes.items():
95             if self.database_schema.has_key(classname):
96                 dbspec = self.database_schema[classname]
97                 if self.update_class(spec, dbspec):
98                     self.database_schema[classname] = spec.schema()
99                     save = 1
100             else:
101                 self.create_class(spec)
102                 self.database_schema[classname] = spec.schema()
103                 save = 1
105         for classname in self.database_schema.keys():
106             if not self.classes.has_key(classname):
107                 self.drop_class(classname)
109         # update the database version of the schema
110         if save:
111             self.sql('delete from schema')
112             self.save_dbschema(self.database_schema)
114         # reindex the db if necessary
115         if self.indexer.should_reindex():
116             self.reindex()
118         # commit
119         self.conn.commit()
121         # figure the "curuserid"
122         if self.journaltag is None:
123             self.curuserid = None
124         elif self.journaltag == 'admin':
125             # admin user may not exist, but always has ID 1
126             self.curuserid = '1'
127         else:
128             self.curuserid = self.user.lookup(self.journaltag)
130     def reindex(self):
131         for klass in self.classes.values():
132             for nodeid in klass.list():
133                 klass.index(nodeid)
134         self.indexer.save_index()
136     def determine_columns(self, properties):
137         ''' Figure the column names and multilink properties from the spec
139             "properties" is a list of (name, prop) where prop may be an
140             instance of a hyperdb "type" _or_ a string repr of that type.
141         '''
142         cols = ['_activity', '_creator', '_creation']
143         mls = []
144         # add the multilinks separately
145         for col, prop in properties:
146             if isinstance(prop, Multilink):
147                 mls.append(col)
148             elif isinstance(prop, type('')) and prop.find('Multilink') != -1:
149                 mls.append(col)
150             else:
151                 cols.append('_'+col)
152         cols.sort()
153         return cols, mls
155     def update_class(self, spec, dbspec):
156         ''' Determine the differences between the current spec and the
157             database version of the spec, and update where necessary
158         '''
159         spec_schema = spec.schema()
160         if spec_schema == dbspec:
161             # no save needed for this one
162             return 0
163         if __debug__:
164             print >>hyperdb.DEBUG, 'update_class FIRING'
166         # key property changed?
167         if dbspec[0] != spec_schema[0]:
168             if __debug__:
169                 print >>hyperdb.DEBUG, 'update_class setting keyprop', `spec[0]`
170             # XXX turn on indexing for the key property
172         # dict 'em up
173         spec_propnames,spec_props = [],{}
174         for propname,prop in spec_schema[1]:
175             spec_propnames.append(propname)
176             spec_props[propname] = prop
177         dbspec_propnames,dbspec_props = [],{}
178         for propname,prop in dbspec[1]:
179             dbspec_propnames.append(propname)
180             dbspec_props[propname] = prop
182         # now compare
183         for propname in spec_propnames:
184             prop = spec_props[propname]
185             if dbspec_props.has_key(propname) and prop==dbspec_props[propname]:
186                 continue
187             if __debug__:
188                 print >>hyperdb.DEBUG, 'update_class ADD', (propname, prop)
190             if not dbspec_props.has_key(propname):
191                 # add the property
192                 if isinstance(prop, Multilink):
193                     # all we have to do here is create a new table, easy!
194                     self.create_multilink_table(spec, propname)
195                     continue
197                 # no ALTER TABLE, so we:
198                 # 1. pull out the data, including an extra None column
199                 oldcols, x = self.determine_columns(dbspec[1])
200                 oldcols.append('id')
201                 oldcols.append('__retired__')
202                 cn = spec.classname
203                 sql = 'select %s,%s from _%s'%(','.join(oldcols), self.arg, cn)
204                 if __debug__:
205                     print >>hyperdb.DEBUG, 'update_class', (self, sql, None)
206                 self.cursor.execute(sql, (None,))
207                 olddata = self.cursor.fetchall()
209                 # 2. drop the old table
210                 self.cursor.execute('drop table _%s'%cn)
212                 # 3. create the new table
213                 cols, mls = self.create_class_table(spec)
214                 # ensure the new column is last
215                 cols.remove('_'+propname)
216                 assert oldcols == cols, "Column lists don't match!"
217                 cols.append('_'+propname)
219                 # 4. populate with the data from step one
220                 s = ','.join([self.arg for x in cols])
221                 scols = ','.join(cols)
222                 sql = 'insert into _%s (%s) values (%s)'%(cn, scols, s)
224                 # GAH, nothing had better go wrong from here on in... but
225                 # we have to commit the drop...
226                 # XXX this isn't necessary in sqlite :(
227                 self.conn.commit()
229                 # do the insert
230                 for row in olddata:
231                     self.sql(sql, tuple(row))
233             else:
234                 # modify the property
235                 if __debug__:
236                     print >>hyperdb.DEBUG, 'update_class NOOP'
237                 pass  # NOOP in gadfly
239         # and the other way - only worry about deletions here
240         for propname in dbspec_propnames:
241             prop = dbspec_props[propname]
242             if spec_props.has_key(propname):
243                 continue
244             if __debug__:
245                 print >>hyperdb.DEBUG, 'update_class REMOVE', `prop`
247             # delete the property
248             if isinstance(prop, Multilink):
249                 sql = 'drop table %s_%s'%(spec.classname, prop)
250                 if __debug__:
251                     print >>hyperdb.DEBUG, 'update_class', (self, sql)
252                 self.cursor.execute(sql)
253             else:
254                 # no ALTER TABLE, so we:
255                 # 1. pull out the data, excluding the removed column
256                 oldcols, x = self.determine_columns(spec.properties.items())
257                 oldcols.append('id')
258                 oldcols.append('__retired__')
259                 # remove the missing column
260                 oldcols.remove('_'+propname)
261                 cn = spec.classname
262                 sql = 'select %s from _%s'%(','.join(oldcols), cn)
263                 self.cursor.execute(sql, (None,))
264                 olddata = sql.fetchall()
266                 # 2. drop the old table
267                 self.cursor.execute('drop table _%s'%cn)
269                 # 3. create the new table
270                 cols, mls = self.create_class_table(self, spec)
271                 assert oldcols != cols, "Column lists don't match!"
273                 # 4. populate with the data from step one
274                 qs = ','.join([self.arg for x in cols])
275                 sql = 'insert into _%s values (%s)'%(cn, s)
276                 self.cursor.execute(sql, olddata)
277         return 1
279     def create_class_table(self, spec):
280         ''' create the class table for the given spec
281         '''
282         cols, mls = self.determine_columns(spec.properties.items())
284         # add on our special columns
285         cols.append('id')
286         cols.append('__retired__')
288         # create the base table
289         scols = ','.join(['%s varchar'%x for x in cols])
290         sql = 'create table _%s (%s)'%(spec.classname, scols)
291         if __debug__:
292             print >>hyperdb.DEBUG, 'create_class', (self, sql)
293         self.cursor.execute(sql)
295         return cols, mls
297     def create_journal_table(self, spec):
298         ''' create the journal table for a class given the spec and 
299             already-determined cols
300         '''
301         # journal table
302         cols = ','.join(['%s varchar'%x
303             for x in 'nodeid date tag action params'.split()])
304         sql = 'create table %s__journal (%s)'%(spec.classname, cols)
305         if __debug__:
306             print >>hyperdb.DEBUG, 'create_class', (self, sql)
307         self.cursor.execute(sql)
309     def create_multilink_table(self, spec, ml):
310         ''' Create a multilink table for the "ml" property of the class
311             given by the spec
312         '''
313         sql = 'create table %s_%s (linkid varchar, nodeid varchar)'%(
314             spec.classname, ml)
315         if __debug__:
316             print >>hyperdb.DEBUG, 'create_class', (self, sql)
317         self.cursor.execute(sql)
319     def create_class(self, spec):
320         ''' Create a database table according to the given spec.
321         '''
322         cols, mls = self.create_class_table(spec)
323         self.create_journal_table(spec)
325         # now create the multilink tables
326         for ml in mls:
327             self.create_multilink_table(spec, ml)
329         # ID counter
330         sql = 'insert into ids (name, num) values (%s,%s)'%(self.arg, self.arg)
331         vals = (spec.classname, 1)
332         if __debug__:
333             print >>hyperdb.DEBUG, 'create_class', (self, sql, vals)
334         self.cursor.execute(sql, vals)
336     def drop_class(self, spec):
337         ''' Drop the given table from the database.
339             Drop the journal and multilink tables too.
340         '''
341         # figure the multilinks
342         mls = []
343         for col, prop in spec.properties.items():
344             if isinstance(prop, Multilink):
345                 mls.append(col)
347         sql = 'drop table _%s'%spec.classname
348         if __debug__:
349             print >>hyperdb.DEBUG, 'drop_class', (self, sql)
350         self.cursor.execute(sql)
352         sql = 'drop table %s__journal'%spec.classname
353         if __debug__:
354             print >>hyperdb.DEBUG, 'drop_class', (self, sql)
355         self.cursor.execute(sql)
357         for ml in mls:
358             sql = 'drop table %s_%s'%(spec.classname, ml)
359             if __debug__:
360                 print >>hyperdb.DEBUG, 'drop_class', (self, sql)
361             self.cursor.execute(sql)
363     #
364     # Classes
365     #
366     def __getattr__(self, classname):
367         ''' A convenient way of calling self.getclass(classname).
368         '''
369         if self.classes.has_key(classname):
370             if __debug__:
371                 print >>hyperdb.DEBUG, '__getattr__', (self, classname)
372             return self.classes[classname]
373         raise AttributeError, classname
375     def addclass(self, cl):
376         ''' Add a Class to the hyperdatabase.
377         '''
378         if __debug__:
379             print >>hyperdb.DEBUG, 'addclass', (self, cl)
380         cn = cl.classname
381         if self.classes.has_key(cn):
382             raise ValueError, cn
383         self.classes[cn] = cl
385     def getclasses(self):
386         ''' Return a list of the names of all existing classes.
387         '''
388         if __debug__:
389             print >>hyperdb.DEBUG, 'getclasses', (self,)
390         l = self.classes.keys()
391         l.sort()
392         return l
394     def getclass(self, classname):
395         '''Get the Class object representing a particular class.
397         If 'classname' is not a valid class name, a KeyError is raised.
398         '''
399         if __debug__:
400             print >>hyperdb.DEBUG, 'getclass', (self, classname)
401         try:
402             return self.classes[classname]
403         except KeyError:
404             raise KeyError, 'There is no class called "%s"'%classname
406     def clear(self):
407         ''' Delete all database contents.
409             Note: I don't commit here, which is different behaviour to the
410             "nuke from orbit" behaviour in the *dbms.
411         '''
412         if __debug__:
413             print >>hyperdb.DEBUG, 'clear', (self,)
414         for cn in self.classes.keys():
415             sql = 'delete from _%s'%cn
416             if __debug__:
417                 print >>hyperdb.DEBUG, 'clear', (self, sql)
418             self.cursor.execute(sql)
420     #
421     # Node IDs
422     #
423     def newid(self, classname):
424         ''' Generate a new id for the given class
425         '''
426         # get the next ID
427         sql = 'select num from ids where name=%s'%self.arg
428         if __debug__:
429             print >>hyperdb.DEBUG, 'newid', (self, sql, classname)
430         self.cursor.execute(sql, (classname, ))
431         newid = self.cursor.fetchone()[0]
433         # update the counter
434         sql = 'update ids set num=%s where name=%s'%(self.arg, self.arg)
435         vals = (int(newid)+1, classname)
436         if __debug__:
437             print >>hyperdb.DEBUG, 'newid', (self, sql, vals)
438         self.cursor.execute(sql, vals)
440         # return as string
441         return str(newid)
443     def setid(self, classname, setid):
444         ''' Set the id counter: used during import of database
445         '''
446         sql = 'update ids set num=%s where name=%s'%(self.arg, self.arg)
447         vals = (setid, classname)
448         if __debug__:
449             print >>hyperdb.DEBUG, 'setid', (self, sql, vals)
450         self.cursor.execute(sql, vals)
452     #
453     # Nodes
454     #
456     def addnode(self, classname, nodeid, node):
457         ''' Add the specified node to its class's db.
458         '''
459         if __debug__:
460             print >>hyperdb.DEBUG, 'addnode', (self, classname, nodeid, node)
461         # gadfly requires values for all non-multilink columns
462         cl = self.classes[classname]
463         cols, mls = self.determine_columns(cl.properties.items())
465         # we'll be supplied these props if we're doing an import
466         if not node.has_key('creator'):
467             # add in the "calculated" properties (dupe so we don't affect
468             # calling code's node assumptions)
469             node = node.copy()
470             node['creation'] = node['activity'] = date.Date()
471             node['creator'] = self.curuserid
473         # default the non-multilink columns
474         for col, prop in cl.properties.items():
475             if not isinstance(col, Multilink):
476                 if not node.has_key(col):
477                     node[col] = None
479         # clear this node out of the cache if it's in there
480         key = (classname, nodeid)
481         if self.cache.has_key(key):
482             del self.cache[key]
483             self.cache_lru.remove(key)
485         # make the node data safe for the DB
486         node = self.serialise(classname, node)
488         # make sure the ordering is correct for column name -> column value
489         vals = tuple([node[col[1:]] for col in cols]) + (nodeid, 0)
490         s = ','.join([self.arg for x in cols]) + ',%s,%s'%(self.arg, self.arg)
491         cols = ','.join(cols) + ',id,__retired__'
493         # perform the inserts
494         sql = 'insert into _%s (%s) values (%s)'%(classname, cols, s)
495         if __debug__:
496             print >>hyperdb.DEBUG, 'addnode', (self, sql, vals)
497         self.cursor.execute(sql, vals)
499         # insert the multilink rows
500         for col in mls:
501             t = '%s_%s'%(classname, col)
502             for entry in node[col]:
503                 sql = 'insert into %s (linkid, nodeid) values (%s,%s)'%(t,
504                     self.arg, self.arg)
505                 self.sql(sql, (entry, nodeid))
507         # make sure we do the commit-time extra stuff for this node
508         self.transactions.append((self.doSaveNode, (classname, nodeid, node)))
510     def setnode(self, classname, nodeid, values, multilink_changes):
511         ''' Change the specified node.
512         '''
513         if __debug__:
514             print >>hyperdb.DEBUG, 'setnode', (self, classname, nodeid, values)
516         # clear this node out of the cache if it's in there
517         key = (classname, nodeid)
518         if self.cache.has_key(key):
519             del self.cache[key]
520             self.cache_lru.remove(key)
522         # add the special props
523         values = values.copy()
524         values['activity'] = date.Date()
526         # make db-friendly
527         values = self.serialise(classname, values)
529         cl = self.classes[classname]
530         cols = []
531         mls = []
532         # add the multilinks separately
533         props = cl.getprops()
534         for col in values.keys():
535             prop = props[col]
536             if isinstance(prop, Multilink):
537                 mls.append(col)
538             else:
539                 cols.append('_'+col)
540         cols.sort()
542         # if there's any updates to regular columns, do them
543         if cols:
544             # make sure the ordering is correct for column name -> column value
545             sqlvals = tuple([values[col[1:]] for col in cols]) + (nodeid,)
546             s = ','.join(['%s=%s'%(x, self.arg) for x in cols])
547             cols = ','.join(cols)
549             # perform the update
550             sql = 'update _%s set %s where id=%s'%(classname, s, self.arg)
551             if __debug__:
552                 print >>hyperdb.DEBUG, 'setnode', (self, sql, sqlvals)
553             self.cursor.execute(sql, sqlvals)
555         # now the fun bit, updating the multilinks ;)
556         for col, (add, remove) in multilink_changes.items():
557             tn = '%s_%s'%(classname, col)
558             if add:
559                 sql = 'insert into %s (nodeid, linkid) values (%s,%s)'%(tn,
560                     self.arg, self.arg)
561                 for addid in add:
562                     self.sql(sql, (nodeid, addid))
563             if remove:
564                 sql = 'delete from %s where nodeid=%s and linkid=%s'%(tn,
565                     self.arg, self.arg)
566                 for removeid in remove:
567                     self.sql(sql, (nodeid, removeid))
569         # make sure we do the commit-time extra stuff for this node
570         self.transactions.append((self.doSaveNode, (classname, nodeid, values)))
572     def getnode(self, classname, nodeid):
573         ''' Get a node from the database.
574         '''
575         if __debug__:
576             print >>hyperdb.DEBUG, 'getnode', (self, classname, nodeid)
578         # see if we have this node cached
579         key = (classname, nodeid)
580         if self.cache.has_key(key):
581             # push us back to the top of the LRU
582             self.cache_lru.remove(key)
583             self.cache_lru.insert(0, key)
584             # return the cached information
585             return self.cache[key]
587         # figure the columns we're fetching
588         cl = self.classes[classname]
589         cols, mls = self.determine_columns(cl.properties.items())
590         scols = ','.join(cols)
592         # perform the basic property fetch
593         sql = 'select %s from _%s where id=%s'%(scols, classname, self.arg)
594         self.sql(sql, (nodeid,))
596         values = self.sql_fetchone()
597         if values is None:
598             raise IndexError, 'no such %s node %s'%(classname, nodeid)
600         # make up the node
601         node = {}
602         for col in range(len(cols)):
603             node[cols[col][1:]] = values[col]
605         # now the multilinks
606         for col in mls:
607             # get the link ids
608             sql = 'select linkid from %s_%s where nodeid=%s'%(classname, col,
609                 self.arg)
610             self.cursor.execute(sql, (nodeid,))
611             # extract the first column from the result
612             node[col] = [x[0] for x in self.cursor.fetchall()]
614         # un-dbificate the node data
615         node = self.unserialise(classname, node)
617         # save off in the cache
618         key = (classname, nodeid)
619         self.cache[key] = node
620         # update the LRU
621         self.cache_lru.insert(0, key)
622         if len(self.cache_lru) > ROW_CACHE_SIZE:
623             del self.cache[self.cache_lru.pop()]
625         return node
627     def destroynode(self, classname, nodeid):
628         '''Remove a node from the database. Called exclusively by the
629            destroy() method on Class.
630         '''
631         if __debug__:
632             print >>hyperdb.DEBUG, 'destroynode', (self, classname, nodeid)
634         # make sure the node exists
635         if not self.hasnode(classname, nodeid):
636             raise IndexError, '%s has no node %s'%(classname, nodeid)
638         # see if we have this node cached
639         if self.cache.has_key((classname, nodeid)):
640             del self.cache[(classname, nodeid)]
642         # see if there's any obvious commit actions that we should get rid of
643         for entry in self.transactions[:]:
644             if entry[1][:2] == (classname, nodeid):
645                 self.transactions.remove(entry)
647         # now do the SQL
648         sql = 'delete from _%s where id=%s'%(classname, self.arg)
649         self.sql(sql, (nodeid,))
651         # remove from multilnks
652         cl = self.getclass(classname)
653         x, mls = self.determine_columns(cl.properties.items())
654         for col in mls:
655             # get the link ids
656             sql = 'delete from %s_%s where nodeid=%s'%(classname, col, self.arg)
657             self.cursor.execute(sql, (nodeid,))
659         # remove journal entries
660         sql = 'delete from %s__journal where nodeid=%s'%(classname, self.arg)
661         self.sql(sql, (nodeid,))
663     def serialise(self, classname, node):
664         '''Copy the node contents, converting non-marshallable data into
665            marshallable data.
666         '''
667         if __debug__:
668             print >>hyperdb.DEBUG, 'serialise', classname, node
669         properties = self.getclass(classname).getprops()
670         d = {}
671         for k, v in node.items():
672             # if the property doesn't exist, or is the "retired" flag then
673             # it won't be in the properties dict
674             if not properties.has_key(k):
675                 d[k] = v
676                 continue
678             # get the property spec
679             prop = properties[k]
681             if isinstance(prop, Password):
682                 d[k] = str(v)
683             elif isinstance(prop, Date) and v is not None:
684                 d[k] = v.serialise()
685             elif isinstance(prop, Interval) and v is not None:
686                 d[k] = v.serialise()
687             else:
688                 d[k] = v
689         return d
691     def unserialise(self, classname, node):
692         '''Decode the marshalled node data
693         '''
694         if __debug__:
695             print >>hyperdb.DEBUG, 'unserialise', classname, node
696         properties = self.getclass(classname).getprops()
697         d = {}
698         for k, v in node.items():
699             # if the property doesn't exist, or is the "retired" flag then
700             # it won't be in the properties dict
701             if not properties.has_key(k):
702                 d[k] = v
703                 continue
705             # get the property spec
706             prop = properties[k]
708             if isinstance(prop, Date) and v is not None:
709                 d[k] = date.Date(v)
710             elif isinstance(prop, Interval) and v is not None:
711                 d[k] = date.Interval(v)
712             elif isinstance(prop, Password):
713                 p = password.Password()
714                 p.unpack(v)
715                 d[k] = p
716             else:
717                 d[k] = v
718         return d
720     def hasnode(self, classname, nodeid):
721         ''' Determine if the database has a given node.
722         '''
723         sql = 'select count(*) from _%s where id=%s'%(classname, self.arg)
724         if __debug__:
725             print >>hyperdb.DEBUG, 'hasnode', (self, sql, nodeid)
726         self.cursor.execute(sql, (nodeid,))
727         return int(self.cursor.fetchone()[0])
729     def countnodes(self, classname):
730         ''' Count the number of nodes that exist for a particular Class.
731         '''
732         sql = 'select count(*) from _%s'%classname
733         if __debug__:
734             print >>hyperdb.DEBUG, 'countnodes', (self, sql)
735         self.cursor.execute(sql)
736         return self.cursor.fetchone()[0]
738     def getnodeids(self, classname, retired=0):
739         ''' Retrieve all the ids of the nodes for a particular Class.
741             Set retired=None to get all nodes. Otherwise it'll get all the 
742             retired or non-retired nodes, depending on the flag.
743         '''
744         # flip the sense of the flag if we don't want all of them
745         if retired is not None:
746             retired = not retired
747         sql = 'select id from _%s where __retired__ <> %s'%(classname, self.arg)
748         if __debug__:
749             print >>hyperdb.DEBUG, 'getnodeids', (self, sql, retired)
750         self.cursor.execute(sql, (retired,))
751         return [x[0] for x in self.cursor.fetchall()]
753     def addjournal(self, classname, nodeid, action, params, creator=None,
754             creation=None):
755         ''' Journal the Action
756         'action' may be:
758             'create' or 'set' -- 'params' is a dictionary of property values
759             'link' or 'unlink' -- 'params' is (classname, nodeid, propname)
760             'retire' -- 'params' is None
761         '''
762         # serialise the parameters now if necessary
763         if isinstance(params, type({})):
764             if action in ('set', 'create'):
765                 params = self.serialise(classname, params)
767         # handle supply of the special journalling parameters (usually
768         # supplied on importing an existing database)
769         if creator:
770             journaltag = creator
771         else:
772             journaltag = self.curuserid
773         if creation:
774             journaldate = creation.serialise()
775         else:
776             journaldate = date.Date().serialise()
778         # create the journal entry
779         cols = ','.join('nodeid date tag action params'.split())
781         if __debug__:
782             print >>hyperdb.DEBUG, 'addjournal', (nodeid, journaldate,
783                 journaltag, action, params)
785         self.save_journal(classname, cols, nodeid, journaldate,
786             journaltag, action, params)
788     def save_journal(self, classname, cols, nodeid, journaldate,
789             journaltag, action, params):
790         ''' Save the journal entry to the database
791         '''
792         raise NotImplemented
794     def getjournal(self, classname, nodeid):
795         ''' get the journal for id
796         '''
797         # make sure the node exists
798         if not self.hasnode(classname, nodeid):
799             raise IndexError, '%s has no node %s'%(classname, nodeid)
801         cols = ','.join('nodeid date tag action params'.split())
802         return self.load_journal(classname, cols, nodeid)
804     def load_journal(self, classname, cols, nodeid):
805         ''' Load the journal from the database
806         '''
807         raise NotImplemented
809     def pack(self, pack_before):
810         ''' Delete all journal entries except "create" before 'pack_before'.
811         '''
812         # get a 'yyyymmddhhmmss' version of the date
813         date_stamp = pack_before.serialise()
815         # do the delete
816         for classname in self.classes.keys():
817             sql = "delete from %s__journal where date<%s and "\
818                 "action<>'create'"%(classname, self.arg)
819             if __debug__:
820                 print >>hyperdb.DEBUG, 'pack', (self, sql, date_stamp)
821             self.cursor.execute(sql, (date_stamp,))
823     def sql_commit(self):
824         ''' Actually commit to the database.
825         '''
826         self.conn.commit()
828     def commit(self):
829         ''' Commit the current transactions.
831         Save all data changed since the database was opened or since the
832         last commit() or rollback().
833         '''
834         if __debug__:
835             print >>hyperdb.DEBUG, 'commit', (self,)
837         # commit the database
838         self.sql_commit()
840         # now, do all the other transaction stuff
841         reindex = {}
842         for method, args in self.transactions:
843             reindex[method(*args)] = 1
845         # reindex the nodes that request it
846         for classname, nodeid in filter(None, reindex.keys()):
847             print >>hyperdb.DEBUG, 'commit.reindex', (classname, nodeid)
848             self.getclass(classname).index(nodeid)
850         # save the indexer state
851         self.indexer.save_index()
853         # clear out the transactions
854         self.transactions = []
856     def rollback(self):
857         ''' Reverse all actions from the current transaction.
859         Undo all the changes made since the database was opened or the last
860         commit() or rollback() was performed.
861         '''
862         if __debug__:
863             print >>hyperdb.DEBUG, 'rollback', (self,)
865         # roll back
866         self.conn.rollback()
868         # roll back "other" transaction stuff
869         for method, args in self.transactions:
870             # delete temporary files
871             if method == self.doStoreFile:
872                 self.rollbackStoreFile(*args)
873         self.transactions = []
875     def doSaveNode(self, classname, nodeid, node):
876         ''' dummy that just generates a reindex event
877         '''
878         # return the classname, nodeid so we reindex this content
879         return (classname, nodeid)
881     def close(self):
882         ''' Close off the connection.
883         '''
884         self.conn.close()
887 # The base Class class
889 class Class(hyperdb.Class):
890     ''' The handle to a particular class of nodes in a hyperdatabase.
891         
892         All methods except __repr__ and getnode must be implemented by a
893         concrete backend Class.
894     '''
896     def __init__(self, db, classname, **properties):
897         '''Create a new class with a given name and property specification.
899         'classname' must not collide with the name of an existing class,
900         or a ValueError is raised.  The keyword arguments in 'properties'
901         must map names to property objects, or a TypeError is raised.
902         '''
903         if (properties.has_key('creation') or properties.has_key('activity')
904                 or properties.has_key('creator')):
905             raise ValueError, '"creation", "activity" and "creator" are '\
906                 'reserved'
908         self.classname = classname
909         self.properties = properties
910         self.db = weakref.proxy(db)       # use a weak ref to avoid circularity
911         self.key = ''
913         # should we journal changes (default yes)
914         self.do_journal = 1
916         # do the db-related init stuff
917         db.addclass(self)
919         self.auditors = {'create': [], 'set': [], 'retire': []}
920         self.reactors = {'create': [], 'set': [], 'retire': []}
922     def schema(self):
923         ''' A dumpable version of the schema that we can store in the
924             database
925         '''
926         return (self.key, [(x, repr(y)) for x,y in self.properties.items()])
928     def enableJournalling(self):
929         '''Turn journalling on for this class
930         '''
931         self.do_journal = 1
933     def disableJournalling(self):
934         '''Turn journalling off for this class
935         '''
936         self.do_journal = 0
938     # Editing nodes:
939     def create(self, **propvalues):
940         ''' Create a new node of this class and return its id.
942         The keyword arguments in 'propvalues' map property names to values.
944         The values of arguments must be acceptable for the types of their
945         corresponding properties or a TypeError is raised.
946         
947         If this class has a key property, it must be present and its value
948         must not collide with other key strings or a ValueError is raised.
949         
950         Any other properties on this class that are missing from the
951         'propvalues' dictionary are set to None.
952         
953         If an id in a link or multilink property does not refer to a valid
954         node, an IndexError is raised.
955         '''
956         if propvalues.has_key('id'):
957             raise KeyError, '"id" is reserved'
959         if self.db.journaltag is None:
960             raise DatabaseError, 'Database open read-only'
962         if propvalues.has_key('creation') or propvalues.has_key('activity'):
963             raise KeyError, '"creation" and "activity" are reserved'
965         self.fireAuditors('create', None, propvalues)
967         # new node's id
968         newid = self.db.newid(self.classname)
970         # validate propvalues
971         num_re = re.compile('^\d+$')
972         for key, value in propvalues.items():
973             if key == self.key:
974                 try:
975                     self.lookup(value)
976                 except KeyError:
977                     pass
978                 else:
979                     raise ValueError, 'node with key "%s" exists'%value
981             # try to handle this property
982             try:
983                 prop = self.properties[key]
984             except KeyError:
985                 raise KeyError, '"%s" has no property "%s"'%(self.classname,
986                     key)
988             if value is not None and isinstance(prop, Link):
989                 if type(value) != type(''):
990                     raise ValueError, 'link value must be String'
991                 link_class = self.properties[key].classname
992                 # if it isn't a number, it's a key
993                 if not num_re.match(value):
994                     try:
995                         value = self.db.classes[link_class].lookup(value)
996                     except (TypeError, KeyError):
997                         raise IndexError, 'new property "%s": %s not a %s'%(
998                             key, value, link_class)
999                 elif not self.db.getclass(link_class).hasnode(value):
1000                     raise IndexError, '%s has no node %s'%(link_class, value)
1002                 # save off the value
1003                 propvalues[key] = value
1005                 # register the link with the newly linked node
1006                 if self.do_journal and self.properties[key].do_journal:
1007                     self.db.addjournal(link_class, value, 'link',
1008                         (self.classname, newid, key))
1010             elif isinstance(prop, Multilink):
1011                 if type(value) != type([]):
1012                     raise TypeError, 'new property "%s" not a list of ids'%key
1014                 # clean up and validate the list of links
1015                 link_class = self.properties[key].classname
1016                 l = []
1017                 for entry in value:
1018                     if type(entry) != type(''):
1019                         raise ValueError, '"%s" multilink value (%r) '\
1020                             'must contain Strings'%(key, value)
1021                     # if it isn't a number, it's a key
1022                     if not num_re.match(entry):
1023                         try:
1024                             entry = self.db.classes[link_class].lookup(entry)
1025                         except (TypeError, KeyError):
1026                             raise IndexError, 'new property "%s": %s not a %s'%(
1027                                 key, entry, self.properties[key].classname)
1028                     l.append(entry)
1029                 value = l
1030                 propvalues[key] = value
1032                 # handle additions
1033                 for nodeid in value:
1034                     if not self.db.getclass(link_class).hasnode(nodeid):
1035                         raise IndexError, '%s has no node %s'%(link_class,
1036                             nodeid)
1037                     # register the link with the newly linked node
1038                     if self.do_journal and self.properties[key].do_journal:
1039                         self.db.addjournal(link_class, nodeid, 'link',
1040                             (self.classname, newid, key))
1042             elif isinstance(prop, String):
1043                 if type(value) != type(''):
1044                     raise TypeError, 'new property "%s" not a string'%key
1046             elif isinstance(prop, Password):
1047                 if not isinstance(value, password.Password):
1048                     raise TypeError, 'new property "%s" not a Password'%key
1050             elif isinstance(prop, Date):
1051                 if value is not None and not isinstance(value, date.Date):
1052                     raise TypeError, 'new property "%s" not a Date'%key
1054             elif isinstance(prop, Interval):
1055                 if value is not None and not isinstance(value, date.Interval):
1056                     raise TypeError, 'new property "%s" not an Interval'%key
1058             elif value is not None and isinstance(prop, Number):
1059                 try:
1060                     float(value)
1061                 except ValueError:
1062                     raise TypeError, 'new property "%s" not numeric'%key
1064             elif value is not None and isinstance(prop, Boolean):
1065                 try:
1066                     int(value)
1067                 except ValueError:
1068                     raise TypeError, 'new property "%s" not boolean'%key
1070         # make sure there's data where there needs to be
1071         for key, prop in self.properties.items():
1072             if propvalues.has_key(key):
1073                 continue
1074             if key == self.key:
1075                 raise ValueError, 'key property "%s" is required'%key
1076             if isinstance(prop, Multilink):
1077                 propvalues[key] = []
1078             else:
1079                 propvalues[key] = None
1081         # done
1082         self.db.addnode(self.classname, newid, propvalues)
1083         if self.do_journal:
1084             self.db.addjournal(self.classname, newid, 'create', propvalues)
1086         self.fireReactors('create', newid, None)
1088         return newid
1090     def export_list(self, propnames, nodeid):
1091         ''' Export a node - generate a list of CSV-able data in the order
1092             specified by propnames for the given node.
1093         '''
1094         properties = self.getprops()
1095         l = []
1096         for prop in propnames:
1097             proptype = properties[prop]
1098             value = self.get(nodeid, prop)
1099             # "marshal" data where needed
1100             if value is None:
1101                 pass
1102             elif isinstance(proptype, hyperdb.Date):
1103                 value = value.get_tuple()
1104             elif isinstance(proptype, hyperdb.Interval):
1105                 value = value.get_tuple()
1106             elif isinstance(proptype, hyperdb.Password):
1107                 value = str(value)
1108             l.append(repr(value))
1109         return l
1111     def import_list(self, propnames, proplist):
1112         ''' Import a node - all information including "id" is present and
1113             should not be sanity checked. Triggers are not triggered. The
1114             journal should be initialised using the "creator" and "created"
1115             information.
1117             Return the nodeid of the node imported.
1118         '''
1119         if self.db.journaltag is None:
1120             raise DatabaseError, 'Database open read-only'
1121         properties = self.getprops()
1123         # make the new node's property map
1124         d = {}
1125         for i in range(len(propnames)):
1126             # Use eval to reverse the repr() used to output the CSV
1127             value = eval(proplist[i])
1129             # Figure the property for this column
1130             propname = propnames[i]
1131             prop = properties[propname]
1133             # "unmarshal" where necessary
1134             if propname == 'id':
1135                 newid = value
1136                 continue
1137             elif value is None:
1138                 # don't set Nones
1139                 continue
1140             elif isinstance(prop, hyperdb.Date):
1141                 value = date.Date(value)
1142             elif isinstance(prop, hyperdb.Interval):
1143                 value = date.Interval(value)
1144             elif isinstance(prop, hyperdb.Password):
1145                 pwd = password.Password()
1146                 pwd.unpack(value)
1147                 value = pwd
1148             d[propname] = value
1150         # add the node and journal
1151         self.db.addnode(self.classname, newid, d)
1153         # extract the extraneous journalling gumpf and nuke it
1154         if d.has_key('creator'):
1155             creator = d['creator']
1156             del d['creator']
1157         else:
1158             creator = None
1159         if d.has_key('creation'):
1160             creation = d['creation']
1161             del d['creation']
1162         else:
1163             creation = None
1164         if d.has_key('activity'):
1165             del d['activity']
1166         self.db.addjournal(self.classname, newid, 'create', d, creator,
1167             creation)
1168         return newid
1170     _marker = []
1171     def get(self, nodeid, propname, default=_marker, cache=1):
1172         '''Get the value of a property on an existing node of this class.
1174         'nodeid' must be the id of an existing node of this class or an
1175         IndexError is raised.  'propname' must be the name of a property
1176         of this class or a KeyError is raised.
1178         'cache' indicates whether the transaction cache should be queried
1179         for the node. If the node has been modified and you need to
1180         determine what its values prior to modification are, you need to
1181         set cache=0.
1182         '''
1183         if propname == 'id':
1184             return nodeid
1186         # get the node's dict
1187         d = self.db.getnode(self.classname, nodeid)
1189         if propname == 'creation':
1190             if d.has_key('creation'):
1191                 return d['creation']
1192             else:
1193                 return date.Date()
1194         if propname == 'activity':
1195             if d.has_key('activity'):
1196                 return d['activity']
1197             else:
1198                 return date.Date()
1199         if propname == 'creator':
1200             if d.has_key('creator'):
1201                 return d['creator']
1202             else:
1203                 return self.db.curuserid
1205         # get the property (raises KeyErorr if invalid)
1206         prop = self.properties[propname]
1208         if not d.has_key(propname):
1209             if default is self._marker:
1210                 if isinstance(prop, Multilink):
1211                     return []
1212                 else:
1213                     return None
1214             else:
1215                 return default
1217         # don't pass our list to other code
1218         if isinstance(prop, Multilink):
1219             return d[propname][:]
1221         return d[propname]
1223     def getnode(self, nodeid, cache=1):
1224         ''' Return a convenience wrapper for the node.
1226         'nodeid' must be the id of an existing node of this class or an
1227         IndexError is raised.
1229         'cache' indicates whether the transaction cache should be queried
1230         for the node. If the node has been modified and you need to
1231         determine what its values prior to modification are, you need to
1232         set cache=0.
1233         '''
1234         return Node(self, nodeid, cache=cache)
1236     def set(self, nodeid, **propvalues):
1237         '''Modify a property on an existing node of this class.
1238         
1239         'nodeid' must be the id of an existing node of this class or an
1240         IndexError is raised.
1242         Each key in 'propvalues' must be the name of a property of this
1243         class or a KeyError is raised.
1245         All values in 'propvalues' must be acceptable types for their
1246         corresponding properties or a TypeError is raised.
1248         If the value of the key property is set, it must not collide with
1249         other key strings or a ValueError is raised.
1251         If the value of a Link or Multilink property contains an invalid
1252         node id, a ValueError is raised.
1253         '''
1254         if not propvalues:
1255             return propvalues
1257         if propvalues.has_key('creation') or propvalues.has_key('activity'):
1258             raise KeyError, '"creation" and "activity" are reserved'
1260         if propvalues.has_key('id'):
1261             raise KeyError, '"id" is reserved'
1263         if self.db.journaltag is None:
1264             raise DatabaseError, 'Database open read-only'
1266         self.fireAuditors('set', nodeid, propvalues)
1267         # Take a copy of the node dict so that the subsequent set
1268         # operation doesn't modify the oldvalues structure.
1269         # XXX used to try the cache here first
1270         oldvalues = copy.deepcopy(self.db.getnode(self.classname, nodeid))
1272         node = self.db.getnode(self.classname, nodeid)
1273         if self.is_retired(nodeid):
1274             raise IndexError, 'Requested item is retired'
1275         num_re = re.compile('^\d+$')
1277         # if the journal value is to be different, store it in here
1278         journalvalues = {}
1280         # remember the add/remove stuff for multilinks, making it easier
1281         # for the Database layer to do its stuff
1282         multilink_changes = {}
1284         for propname, value in propvalues.items():
1285             # check to make sure we're not duplicating an existing key
1286             if propname == self.key and node[propname] != value:
1287                 try:
1288                     self.lookup(value)
1289                 except KeyError:
1290                     pass
1291                 else:
1292                     raise ValueError, 'node with key "%s" exists'%value
1294             # this will raise the KeyError if the property isn't valid
1295             # ... we don't use getprops() here because we only care about
1296             # the writeable properties.
1297             try:
1298                 prop = self.properties[propname]
1299             except KeyError:
1300                 raise KeyError, '"%s" has no property named "%s"'%(
1301                     self.classname, propname)
1303             # if the value's the same as the existing value, no sense in
1304             # doing anything
1305             if node.has_key(propname) and value == node[propname]:
1306                 del propvalues[propname]
1307                 continue
1309             # do stuff based on the prop type
1310             if isinstance(prop, Link):
1311                 link_class = prop.classname
1312                 # if it isn't a number, it's a key
1313                 if value is not None and not isinstance(value, type('')):
1314                     raise ValueError, 'property "%s" link value be a string'%(
1315                         propname)
1316                 if isinstance(value, type('')) and not num_re.match(value):
1317                     try:
1318                         value = self.db.classes[link_class].lookup(value)
1319                     except (TypeError, KeyError):
1320                         raise IndexError, 'new property "%s": %s not a %s'%(
1321                             propname, value, prop.classname)
1323                 if (value is not None and
1324                         not self.db.getclass(link_class).hasnode(value)):
1325                     raise IndexError, '%s has no node %s'%(link_class, value)
1327                 if self.do_journal and prop.do_journal:
1328                     # register the unlink with the old linked node
1329                     if node[propname] is not None:
1330                         self.db.addjournal(link_class, node[propname], 'unlink',
1331                             (self.classname, nodeid, propname))
1333                     # register the link with the newly linked node
1334                     if value is not None:
1335                         self.db.addjournal(link_class, value, 'link',
1336                             (self.classname, nodeid, propname))
1338             elif isinstance(prop, Multilink):
1339                 if type(value) != type([]):
1340                     raise TypeError, 'new property "%s" not a list of'\
1341                         ' ids'%propname
1342                 link_class = self.properties[propname].classname
1343                 l = []
1344                 for entry in value:
1345                     # if it isn't a number, it's a key
1346                     if type(entry) != type(''):
1347                         raise ValueError, 'new property "%s" link value ' \
1348                             'must be a string'%propname
1349                     if not num_re.match(entry):
1350                         try:
1351                             entry = self.db.classes[link_class].lookup(entry)
1352                         except (TypeError, KeyError):
1353                             raise IndexError, 'new property "%s": %s not a %s'%(
1354                                 propname, entry,
1355                                 self.properties[propname].classname)
1356                     l.append(entry)
1357                 value = l
1358                 propvalues[propname] = value
1360                 # figure the journal entry for this property
1361                 add = []
1362                 remove = []
1364                 # handle removals
1365                 if node.has_key(propname):
1366                     l = node[propname]
1367                 else:
1368                     l = []
1369                 for id in l[:]:
1370                     if id in value:
1371                         continue
1372                     # register the unlink with the old linked node
1373                     if self.do_journal and self.properties[propname].do_journal:
1374                         self.db.addjournal(link_class, id, 'unlink',
1375                             (self.classname, nodeid, propname))
1376                     l.remove(id)
1377                     remove.append(id)
1379                 # handle additions
1380                 for id in value:
1381                     if not self.db.getclass(link_class).hasnode(id):
1382                         raise IndexError, '%s has no node %s'%(link_class, id)
1383                     if id in l:
1384                         continue
1385                     # register the link with the newly linked node
1386                     if self.do_journal and self.properties[propname].do_journal:
1387                         self.db.addjournal(link_class, id, 'link',
1388                             (self.classname, nodeid, propname))
1389                     l.append(id)
1390                     add.append(id)
1392                 # figure the journal entry
1393                 l = []
1394                 if add:
1395                     l.append(('+', add))
1396                 if remove:
1397                     l.append(('-', remove))
1398                 multilink_changes[propname] = (add, remove)
1399                 if l:
1400                     journalvalues[propname] = tuple(l)
1402             elif isinstance(prop, String):
1403                 if value is not None and type(value) != type(''):
1404                     raise TypeError, 'new property "%s" not a string'%propname
1406             elif isinstance(prop, Password):
1407                 if not isinstance(value, password.Password):
1408                     raise TypeError, 'new property "%s" not a Password'%propname
1409                 propvalues[propname] = value
1411             elif value is not None and isinstance(prop, Date):
1412                 if not isinstance(value, date.Date):
1413                     raise TypeError, 'new property "%s" not a Date'% propname
1414                 propvalues[propname] = value
1416             elif value is not None and isinstance(prop, Interval):
1417                 if not isinstance(value, date.Interval):
1418                     raise TypeError, 'new property "%s" not an '\
1419                         'Interval'%propname
1420                 propvalues[propname] = value
1422             elif value is not None and isinstance(prop, Number):
1423                 try:
1424                     float(value)
1425                 except ValueError:
1426                     raise TypeError, 'new property "%s" not numeric'%propname
1428             elif value is not None and isinstance(prop, Boolean):
1429                 try:
1430                     int(value)
1431                 except ValueError:
1432                     raise TypeError, 'new property "%s" not boolean'%propname
1434         # nothing to do?
1435         if not propvalues:
1436             return propvalues
1438         # do the set, and journal it
1439         self.db.setnode(self.classname, nodeid, propvalues, multilink_changes)
1441         if self.do_journal:
1442             propvalues.update(journalvalues)
1443             self.db.addjournal(self.classname, nodeid, 'set', propvalues)
1445         self.fireReactors('set', nodeid, oldvalues)
1447         return propvalues        
1449     def retire(self, nodeid):
1450         '''Retire a node.
1451         
1452         The properties on the node remain available from the get() method,
1453         and the node's id is never reused.
1454         
1455         Retired nodes are not returned by the find(), list(), or lookup()
1456         methods, and other nodes may reuse the values of their key properties.
1457         '''
1458         if self.db.journaltag is None:
1459             raise DatabaseError, 'Database open read-only'
1461         # use the arg for __retired__ to cope with any odd database type
1462         # conversion (hello, sqlite)
1463         sql = 'update _%s set __retired__=%s where id=%s'%(self.classname,
1464             self.db.arg, self.db.arg)
1465         if __debug__:
1466             print >>hyperdb.DEBUG, 'retire', (self, sql, nodeid)
1467         self.db.cursor.execute(sql, (1, nodeid))
1469     def is_retired(self, nodeid):
1470         '''Return true if the node is rerired
1471         '''
1472         sql = 'select __retired__ from _%s where id=%s'%(self.classname,
1473             self.db.arg)
1474         if __debug__:
1475             print >>hyperdb.DEBUG, 'is_retired', (self, sql, nodeid)
1476         self.db.cursor.execute(sql, (nodeid,))
1477         return int(self.db.sql_fetchone()[0])
1479     def destroy(self, nodeid):
1480         '''Destroy a node.
1481         
1482         WARNING: this method should never be used except in extremely rare
1483                  situations where there could never be links to the node being
1484                  deleted
1485         WARNING: use retire() instead
1486         WARNING: the properties of this node will not be available ever again
1487         WARNING: really, use retire() instead
1489         Well, I think that's enough warnings. This method exists mostly to
1490         support the session storage of the cgi interface.
1492         The node is completely removed from the hyperdb, including all journal
1493         entries. It will no longer be available, and will generally break code
1494         if there are any references to the node.
1495         '''
1496         if self.db.journaltag is None:
1497             raise DatabaseError, 'Database open read-only'
1498         self.db.destroynode(self.classname, nodeid)
1500     def history(self, nodeid):
1501         '''Retrieve the journal of edits on a particular node.
1503         'nodeid' must be the id of an existing node of this class or an
1504         IndexError is raised.
1506         The returned list contains tuples of the form
1508             (date, tag, action, params)
1510         'date' is a Timestamp object specifying the time of the change and
1511         'tag' is the journaltag specified when the database was opened.
1512         '''
1513         if not self.do_journal:
1514             raise ValueError, 'Journalling is disabled for this class'
1515         return self.db.getjournal(self.classname, nodeid)
1517     # Locating nodes:
1518     def hasnode(self, nodeid):
1519         '''Determine if the given nodeid actually exists
1520         '''
1521         return self.db.hasnode(self.classname, nodeid)
1523     def setkey(self, propname):
1524         '''Select a String property of this class to be the key property.
1526         'propname' must be the name of a String property of this class or
1527         None, or a TypeError is raised.  The values of the key property on
1528         all existing nodes must be unique or a ValueError is raised.
1529         '''
1530         # XXX create an index on the key prop column
1531         prop = self.getprops()[propname]
1532         if not isinstance(prop, String):
1533             raise TypeError, 'key properties must be String'
1534         self.key = propname
1536     def getkey(self):
1537         '''Return the name of the key property for this class or None.'''
1538         return self.key
1540     def labelprop(self, default_to_id=0):
1541         ''' Return the property name for a label for the given node.
1543         This method attempts to generate a consistent label for the node.
1544         It tries the following in order:
1545             1. key property
1546             2. "name" property
1547             3. "title" property
1548             4. first property from the sorted property name list
1549         '''
1550         k = self.getkey()
1551         if  k:
1552             return k
1553         props = self.getprops()
1554         if props.has_key('name'):
1555             return 'name'
1556         elif props.has_key('title'):
1557             return 'title'
1558         if default_to_id:
1559             return 'id'
1560         props = props.keys()
1561         props.sort()
1562         return props[0]
1564     def lookup(self, keyvalue):
1565         '''Locate a particular node by its key property and return its id.
1567         If this class has no key property, a TypeError is raised.  If the
1568         'keyvalue' matches one of the values for the key property among
1569         the nodes in this class, the matching node's id is returned;
1570         otherwise a KeyError is raised.
1571         '''
1572         if not self.key:
1573             raise TypeError, 'No key property set for class %s'%self.classname
1575         # use the arg to handle any odd database type conversion (hello,
1576         # sqlite)
1577         sql = "select id from _%s where _%s=%s and __retired__ <> %s"%(
1578             self.classname, self.key, self.db.arg, self.db.arg)
1579         self.db.sql(sql, (keyvalue, 1))
1581         # see if there was a result that's not retired
1582         row = self.db.sql_fetchone()
1583         if not row:
1584             raise KeyError, 'No key (%s) value "%s" for "%s"'%(self.key,
1585                 keyvalue, self.classname)
1587         # return the id
1588         return row[0]
1590     def find(self, **propspec):
1591         '''Get the ids of nodes in this class which link to the given nodes.
1593         'propspec' consists of keyword args propname=nodeid or
1594                    propname={nodeid:1, }
1595         'propname' must be the name of a property in this class, or a
1596         KeyError is raised.  That property must be a Link or Multilink
1597         property, or a TypeError is raised.
1599         Any node in this class whose 'propname' property links to any of the
1600         nodeids will be returned. Used by the full text indexing, which knows
1601         that "foo" occurs in msg1, msg3 and file7, so we have hits on these
1602         issues:
1604             db.issue.find(messages={'1':1,'3':1}, files={'7':1})
1605         '''
1606         if __debug__:
1607             print >>hyperdb.DEBUG, 'find', (self, propspec)
1609         # shortcut
1610         if not propspec:
1611             return []
1613         # validate the args
1614         props = self.getprops()
1615         propspec = propspec.items()
1616         for propname, nodeids in propspec:
1617             # check the prop is OK
1618             prop = props[propname]
1619             if not isinstance(prop, Link) and not isinstance(prop, Multilink):
1620                 raise TypeError, "'%s' not a Link/Multilink property"%propname
1622         # first, links
1623         where = []
1624         allvalues = ()
1625         a = self.db.arg
1626         for prop, values in propspec:
1627             if not isinstance(props[prop], hyperdb.Link):
1628                 continue
1629             if type(values) is type(''):
1630                 allvalues += (values,)
1631                 where.append('_%s = %s'%(prop, a))
1632             else:
1633                 allvalues += tuple(values.keys())
1634                 where.append('_%s in (%s)'%(prop, ','.join([a]*len(values))))
1635         tables = []
1636         if where:
1637             tables.append('select id as nodeid from _%s where %s'%(
1638                 self.classname, ' and '.join(where)))
1640         # now multilinks
1641         for prop, values in propspec:
1642             if not isinstance(props[prop], hyperdb.Multilink):
1643                 continue
1644             if type(values) is type(''):
1645                 allvalues += (values,)
1646                 s = a
1647             else:
1648                 allvalues += tuple(values.keys())
1649                 s = ','.join([a]*len(values))
1650             tables.append('select nodeid from %s_%s where linkid in (%s)'%(
1651                 self.classname, prop, s))
1652         sql = '\nunion\n'.join(tables)
1653         self.db.sql(sql, allvalues)
1654         l = [x[0] for x in self.db.sql_fetchall()]
1655         if __debug__:
1656             print >>hyperdb.DEBUG, 'find ... ', l
1657         return l
1659     def stringFind(self, **requirements):
1660         '''Locate a particular node by matching a set of its String
1661         properties in a caseless search.
1663         If the property is not a String property, a TypeError is raised.
1664         
1665         The return is a list of the id of all nodes that match.
1666         '''
1667         where = []
1668         args = []
1669         for propname in requirements.keys():
1670             prop = self.properties[propname]
1671             if isinstance(not prop, String):
1672                 raise TypeError, "'%s' not a String property"%propname
1673             where.append(propname)
1674             args.append(requirements[propname].lower())
1676         # generate the where clause
1677         s = ' and '.join(['_%s=%s'%(col, self.db.arg) for col in where])
1678         sql = 'select id from _%s where %s'%(self.classname, s)
1679         self.db.sql(sql, tuple(args))
1680         l = [x[0] for x in self.db.sql_fetchall()]
1681         if __debug__:
1682             print >>hyperdb.DEBUG, 'find ... ', l
1683         return l
1685     def list(self):
1686         ''' Return a list of the ids of the active nodes in this class.
1687         '''
1688         return self.db.getnodeids(self.classname, retired=0)
1690     def filter(self, search_matches, filterspec, sort, group):
1691         ''' Return a list of the ids of the active nodes in this class that
1692             match the 'filter' spec, sorted by the group spec and then the
1693             sort spec
1695             "filterspec" is {propname: value(s)}
1696             "sort" and "group" are (dir, prop) where dir is '+', '-' or None
1697                                and prop is a prop name or None
1698             "search_matches" is {nodeid: marker}
1700             The filter must match all properties specificed - but if the
1701             property value to match is a list, any one of the values in the
1702             list may match for that property to match.
1703         '''
1704         # just don't bother if the full-text search matched diddly
1705         if search_matches == {}:
1706             return []
1708         cn = self.classname
1710         # figure the WHERE clause from the filterspec
1711         props = self.getprops()
1712         frum = ['_'+cn]
1713         where = []
1714         args = []
1715         a = self.db.arg
1716         for k, v in filterspec.items():
1717             propclass = props[k]
1718             # now do other where clause stuff
1719             if isinstance(propclass, Multilink):
1720                 tn = '%s_%s'%(cn, k)
1721                 frum.append(tn)
1722                 if isinstance(v, type([])):
1723                     s = ','.join([a for x in v])
1724                     where.append('id=%s.nodeid and %s.linkid in (%s)'%(tn,tn,s))
1725                     args = args + v
1726                 else:
1727                     where.append('id=%s.nodeid and %s.linkid = %s'%(tn, tn, a))
1728                     args.append(v)
1729             elif isinstance(propclass, String):
1730                 if not isinstance(v, type([])):
1731                     v = [v]
1733                 # Quote the bits in the string that need it and then embed
1734                 # in a "substring" search. Note - need to quote the '%' so
1735                 # they make it through the python layer happily
1736                 v = ['%%'+self.db.sql_stringquote(s)+'%%' for s in v]
1738                 # now add to the where clause
1739                 where.append(' or '.join(["_%s LIKE '%s'"%(k, s) for s in v]))
1740                 # note: args are embedded in the query string now
1741             elif isinstance(propclass, Link):
1742                 if isinstance(v, type([])):
1743                     if '-1' in v:
1744                         v.remove('-1')
1745                         xtra = ' or _%s is NULL'%k
1746                     else:
1747                         xtra = ''
1748                     s = ','.join([a for x in v])
1749                     where.append('(_%s in (%s)%s)'%(k, s, xtra))
1750                     args = args + v
1751                 else:
1752                     if v == '-1':
1753                         v = None
1754                         where.append('_%s is NULL'%k)
1755                     else:
1756                         where.append('_%s=%s'%(k, a))
1757                         args.append(v)
1758             else:
1759                 if isinstance(v, type([])):
1760                     s = ','.join([a for x in v])
1761                     where.append('_%s in (%s)'%(k, s))
1762                     args = args + v
1763                 else:
1764                     where.append('_%s=%s'%(k, a))
1765                     args.append(v)
1767         # add results of full text search
1768         if search_matches is not None:
1769             v = search_matches.keys()
1770             s = ','.join([a for x in v])
1771             where.append('id in (%s)'%s)
1772             args = args + v
1774         # "grouping" is just the first-order sorting in the SQL fetch
1775         # can modify it...)
1776         orderby = []
1777         ordercols = []
1778         if group[0] is not None and group[1] is not None:
1779             if group[0] != '-':
1780                 orderby.append('_'+group[1])
1781                 ordercols.append('_'+group[1])
1782             else:
1783                 orderby.append('_'+group[1]+' desc')
1784                 ordercols.append('_'+group[1])
1786         # now add in the sorting
1787         group = ''
1788         if sort[0] is not None and sort[1] is not None:
1789             direction, colname = sort
1790             if direction != '-':
1791                 if colname == 'id':
1792                     orderby.append(colname)
1793                 else:
1794                     orderby.append('_'+colname)
1795                     ordercols.append('_'+colname)
1796             else:
1797                 if colname == 'id':
1798                     orderby.append(colname+' desc')
1799                     ordercols.append(colname)
1800                 else:
1801                     orderby.append('_'+colname+' desc')
1802                     ordercols.append('_'+colname)
1804         # construct the SQL
1805         frum = ','.join(frum)
1806         if where:
1807             where = ' where ' + (' and '.join(where))
1808         else:
1809             where = ''
1810         cols = ['id']
1811         if orderby:
1812             cols = cols + ordercols
1813             order = ' order by %s'%(','.join(orderby))
1814         else:
1815             order = ''
1816         cols = ','.join(cols)
1817         sql = 'select %s from %s %s%s%s'%(cols, frum, where, group, order)
1818         args = tuple(args)
1819         if __debug__:
1820             print >>hyperdb.DEBUG, 'filter', (self, sql, args)
1821         self.db.cursor.execute(sql, args)
1822         l = self.db.cursor.fetchall()
1824         # return the IDs (the first column)
1825         # XXX The filter(None, l) bit is sqlite-specific... if there's _NO_
1826         # XXX matches to a fetch, it returns NULL instead of nothing!?!
1827         return filter(None, [row[0] for row in l])
1829     def count(self):
1830         '''Get the number of nodes in this class.
1832         If the returned integer is 'numnodes', the ids of all the nodes
1833         in this class run from 1 to numnodes, and numnodes+1 will be the
1834         id of the next node to be created in this class.
1835         '''
1836         return self.db.countnodes(self.classname)
1838     # Manipulating properties:
1839     def getprops(self, protected=1):
1840         '''Return a dictionary mapping property names to property objects.
1841            If the "protected" flag is true, we include protected properties -
1842            those which may not be modified.
1843         '''
1844         d = self.properties.copy()
1845         if protected:
1846             d['id'] = String()
1847             d['creation'] = hyperdb.Date()
1848             d['activity'] = hyperdb.Date()
1849             d['creator'] = hyperdb.Link('user')
1850         return d
1852     def addprop(self, **properties):
1853         '''Add properties to this class.
1855         The keyword arguments in 'properties' must map names to property
1856         objects, or a TypeError is raised.  None of the keys in 'properties'
1857         may collide with the names of existing properties, or a ValueError
1858         is raised before any properties have been added.
1859         '''
1860         for key in properties.keys():
1861             if self.properties.has_key(key):
1862                 raise ValueError, key
1863         self.properties.update(properties)
1865     def index(self, nodeid):
1866         '''Add (or refresh) the node to search indexes
1867         '''
1868         # find all the String properties that have indexme
1869         for prop, propclass in self.getprops().items():
1870             if isinstance(propclass, String) and propclass.indexme:
1871                 try:
1872                     value = str(self.get(nodeid, prop))
1873                 except IndexError:
1874                     # node no longer exists - entry should be removed
1875                     self.db.indexer.purge_entry((self.classname, nodeid, prop))
1876                 else:
1877                     # and index them under (classname, nodeid, property)
1878                     self.db.indexer.add_text((self.classname, nodeid, prop),
1879                         value)
1882     #
1883     # Detector interface
1884     #
1885     def audit(self, event, detector):
1886         '''Register a detector
1887         '''
1888         l = self.auditors[event]
1889         if detector not in l:
1890             self.auditors[event].append(detector)
1892     def fireAuditors(self, action, nodeid, newvalues):
1893         '''Fire all registered auditors.
1894         '''
1895         for audit in self.auditors[action]:
1896             audit(self.db, self, nodeid, newvalues)
1898     def react(self, event, detector):
1899         '''Register a detector
1900         '''
1901         l = self.reactors[event]
1902         if detector not in l:
1903             self.reactors[event].append(detector)
1905     def fireReactors(self, action, nodeid, oldvalues):
1906         '''Fire all registered reactors.
1907         '''
1908         for react in self.reactors[action]:
1909             react(self.db, self, nodeid, oldvalues)
1911 class FileClass(Class):
1912     '''This class defines a large chunk of data. To support this, it has a
1913        mandatory String property "content" which is typically saved off
1914        externally to the hyperdb.
1916        The default MIME type of this data is defined by the
1917        "default_mime_type" class attribute, which may be overridden by each
1918        node if the class defines a "type" String property.
1919     '''
1920     default_mime_type = 'text/plain'
1922     def create(self, **propvalues):
1923         ''' snaffle the file propvalue and store in a file
1924         '''
1925         content = propvalues['content']
1926         del propvalues['content']
1927         newid = Class.create(self, **propvalues)
1928         self.db.storefile(self.classname, newid, None, content)
1929         return newid
1931     def import_list(self, propnames, proplist):
1932         ''' Trap the "content" property...
1933         '''
1934         # dupe this list so we don't affect others
1935         propnames = propnames[:]
1937         # extract the "content" property from the proplist
1938         i = propnames.index('content')
1939         content = eval(proplist[i])
1940         del propnames[i]
1941         del proplist[i]
1943         # do the normal import
1944         newid = Class.import_list(self, propnames, proplist)
1946         # save off the "content" file
1947         self.db.storefile(self.classname, newid, None, content)
1948         return newid
1950     _marker = []
1951     def get(self, nodeid, propname, default=_marker, cache=1):
1952         ''' trap the content propname and get it from the file
1953         '''
1955         poss_msg = 'Possibly a access right configuration problem.'
1956         if propname == 'content':
1957             try:
1958                 return self.db.getfile(self.classname, nodeid, None)
1959             except IOError, (strerror):
1960                 # BUG: by catching this we donot see an error in the log.
1961                 return 'ERROR reading file: %s%s\n%s\n%s'%(
1962                         self.classname, nodeid, poss_msg, strerror)
1963         if default is not self._marker:
1964             return Class.get(self, nodeid, propname, default, cache=cache)
1965         else:
1966             return Class.get(self, nodeid, propname, cache=cache)
1968     def getprops(self, protected=1):
1969         ''' In addition to the actual properties on the node, these methods
1970             provide the "content" property. If the "protected" flag is true,
1971             we include protected properties - those which may not be
1972             modified.
1973         '''
1974         d = Class.getprops(self, protected=protected).copy()
1975         d['content'] = hyperdb.String()
1976         return d
1978     def index(self, nodeid):
1979         ''' Index the node in the search index.
1981             We want to index the content in addition to the normal String
1982             property indexing.
1983         '''
1984         # perform normal indexing
1985         Class.index(self, nodeid)
1987         # get the content to index
1988         content = self.get(nodeid, 'content')
1990         # figure the mime type
1991         if self.properties.has_key('type'):
1992             mime_type = self.get(nodeid, 'type')
1993         else:
1994             mime_type = self.default_mime_type
1996         # and index!
1997         self.db.indexer.add_text((self.classname, nodeid, 'content'), content,
1998             mime_type)
2000 # XXX deviation from spec - was called ItemClass
2001 class IssueClass(Class, roundupdb.IssueClass):
2002     # Overridden methods:
2003     def __init__(self, db, classname, **properties):
2004         '''The newly-created class automatically includes the "messages",
2005         "files", "nosy", and "superseder" properties.  If the 'properties'
2006         dictionary attempts to specify any of these properties or a
2007         "creation" or "activity" property, a ValueError is raised.
2008         '''
2009         if not properties.has_key('title'):
2010             properties['title'] = hyperdb.String(indexme='yes')
2011         if not properties.has_key('messages'):
2012             properties['messages'] = hyperdb.Multilink("msg")
2013         if not properties.has_key('files'):
2014             properties['files'] = hyperdb.Multilink("file")
2015         if not properties.has_key('nosy'):
2016             # note: journalling is turned off as it really just wastes
2017             # space. this behaviour may be overridden in an instance
2018             properties['nosy'] = hyperdb.Multilink("user", do_journal="no")
2019         if not properties.has_key('superseder'):
2020             properties['superseder'] = hyperdb.Multilink(classname)
2021         Class.__init__(self, db, classname, **properties)