Code

fix to SQL filtering
[roundup.git] / roundup / backends / rdbms_common.py
1 # $Id: rdbms_common.py,v 1.17 2002-09-25 04:56:21 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         sql = 'update _%s set __retired__=1 where id=%s'%(self.classname,
1462             self.db.arg)
1463         if __debug__:
1464             print >>hyperdb.DEBUG, 'retire', (self, sql, nodeid)
1465         self.db.cursor.execute(sql, (nodeid,))
1467     def is_retired(self, nodeid):
1468         '''Return true if the node is rerired
1469         '''
1470         sql = 'select __retired__ from _%s where id=%s'%(self.classname,
1471             self.db.arg)
1472         if __debug__:
1473             print >>hyperdb.DEBUG, 'is_retired', (self, sql, nodeid)
1474         self.db.cursor.execute(sql, (nodeid,))
1475         return int(self.db.sql_fetchone()[0])
1477     def destroy(self, nodeid):
1478         '''Destroy a node.
1479         
1480         WARNING: this method should never be used except in extremely rare
1481                  situations where there could never be links to the node being
1482                  deleted
1483         WARNING: use retire() instead
1484         WARNING: the properties of this node will not be available ever again
1485         WARNING: really, use retire() instead
1487         Well, I think that's enough warnings. This method exists mostly to
1488         support the session storage of the cgi interface.
1490         The node is completely removed from the hyperdb, including all journal
1491         entries. It will no longer be available, and will generally break code
1492         if there are any references to the node.
1493         '''
1494         if self.db.journaltag is None:
1495             raise DatabaseError, 'Database open read-only'
1496         self.db.destroynode(self.classname, nodeid)
1498     def history(self, nodeid):
1499         '''Retrieve the journal of edits on a particular node.
1501         'nodeid' must be the id of an existing node of this class or an
1502         IndexError is raised.
1504         The returned list contains tuples of the form
1506             (date, tag, action, params)
1508         'date' is a Timestamp object specifying the time of the change and
1509         'tag' is the journaltag specified when the database was opened.
1510         '''
1511         if not self.do_journal:
1512             raise ValueError, 'Journalling is disabled for this class'
1513         return self.db.getjournal(self.classname, nodeid)
1515     # Locating nodes:
1516     def hasnode(self, nodeid):
1517         '''Determine if the given nodeid actually exists
1518         '''
1519         return self.db.hasnode(self.classname, nodeid)
1521     def setkey(self, propname):
1522         '''Select a String property of this class to be the key property.
1524         'propname' must be the name of a String property of this class or
1525         None, or a TypeError is raised.  The values of the key property on
1526         all existing nodes must be unique or a ValueError is raised.
1527         '''
1528         # XXX create an index on the key prop column
1529         prop = self.getprops()[propname]
1530         if not isinstance(prop, String):
1531             raise TypeError, 'key properties must be String'
1532         self.key = propname
1534     def getkey(self):
1535         '''Return the name of the key property for this class or None.'''
1536         return self.key
1538     def labelprop(self, default_to_id=0):
1539         ''' Return the property name for a label for the given node.
1541         This method attempts to generate a consistent label for the node.
1542         It tries the following in order:
1543             1. key property
1544             2. "name" property
1545             3. "title" property
1546             4. first property from the sorted property name list
1547         '''
1548         k = self.getkey()
1549         if  k:
1550             return k
1551         props = self.getprops()
1552         if props.has_key('name'):
1553             return 'name'
1554         elif props.has_key('title'):
1555             return 'title'
1556         if default_to_id:
1557             return 'id'
1558         props = props.keys()
1559         props.sort()
1560         return props[0]
1562     def lookup(self, keyvalue):
1563         '''Locate a particular node by its key property and return its id.
1565         If this class has no key property, a TypeError is raised.  If the
1566         'keyvalue' matches one of the values for the key property among
1567         the nodes in this class, the matching node's id is returned;
1568         otherwise a KeyError is raised.
1569         '''
1570         if not self.key:
1571             raise TypeError, 'No key property set for class %s'%self.classname
1573         sql = '''select id from _%s where _%s=%s
1574             and __retired__ != '1' '''%(self.classname, self.key,
1575             self.db.arg)
1576         self.db.sql(sql, (keyvalue,))
1578         # see if there was a result that's not retired
1579         row = self.db.sql_fetchone()
1580         if not row:
1581             raise KeyError, 'No key (%s) value "%s" for "%s"'%(self.key,
1582                 keyvalue, self.classname)
1584         # return the id
1585         return row[0]
1587     def find(self, **propspec):
1588         '''Get the ids of nodes in this class which link to the given nodes.
1590         'propspec' consists of keyword args propname={nodeid:1,}   
1591         'propname' must be the name of a property in this class, or a
1592         KeyError is raised.  That property must be a Link or Multilink
1593         property, or a TypeError is raised.
1595         Any node in this class whose 'propname' property links to any of the
1596         nodeids will be returned. Used by the full text indexing, which knows
1597         that "foo" occurs in msg1, msg3 and file7, so we have hits on these
1598         issues:
1600             db.issue.find(messages={'1':1,'3':1}, files={'7':1})
1601         '''
1602         if __debug__:
1603             print >>hyperdb.DEBUG, 'find', (self, propspec)
1604         if not propspec:
1605             return []
1606         queries = []
1607         tables = []
1608         allvalues = ()
1609         for prop, values in propspec.items():
1610             allvalues += tuple(values.keys())
1611             a = self.db.arg
1612             tables.append('select nodeid from %s_%s where linkid in (%s)'%(
1613                 self.classname, prop, ','.join([a for x in values.keys()])))
1614         sql = '\nintersect\n'.join(tables)
1615         self.db.sql(sql, allvalues)
1616         l = [x[0] for x in self.db.sql_fetchall()]
1617         if __debug__:
1618             print >>hyperdb.DEBUG, 'find ... ', l
1619         return l
1621     def stringFind(self, **requirements):
1622         '''Locate a particular node by matching a set of its String
1623         properties in a caseless search.
1625         If the property is not a String property, a TypeError is raised.
1626         
1627         The return is a list of the id of all nodes that match.
1628         '''
1629         where = []
1630         args = []
1631         for propname in requirements.keys():
1632             prop = self.properties[propname]
1633             if isinstance(not prop, String):
1634                 raise TypeError, "'%s' not a String property"%propname
1635             where.append(propname)
1636             args.append(requirements[propname].lower())
1638         # generate the where clause
1639         s = ' and '.join(['_%s=%s'%(col, self.db.arg) for col in where])
1640         sql = 'select id from _%s where %s'%(self.classname, s)
1641         self.db.sql(sql, tuple(args))
1642         l = [x[0] for x in self.db.sql_fetchall()]
1643         if __debug__:
1644             print >>hyperdb.DEBUG, 'find ... ', l
1645         return l
1647     def list(self):
1648         ''' Return a list of the ids of the active nodes in this class.
1649         '''
1650         return self.db.getnodeids(self.classname, retired=0)
1652     def filter(self, search_matches, filterspec, sort, group):
1653         ''' Return a list of the ids of the active nodes in this class that
1654             match the 'filter' spec, sorted by the group spec and then the
1655             sort spec
1657             "filterspec" is {propname: value(s)}
1658             "sort" and "group" are (dir, prop) where dir is '+', '-' or None
1659                                and prop is a prop name or None
1660             "search_matches" is {nodeid: marker}
1662             The filter must match all properties specificed - but if the
1663             property value to match is a list, any one of the values in the
1664             list may match for that property to match.
1665         '''
1666         # just don't bother if the full-text search matched diddly
1667         if search_matches == {}:
1668             return []
1670         cn = self.classname
1672         # figure the WHERE clause from the filterspec
1673         props = self.getprops()
1674         frum = ['_'+cn]
1675         where = []
1676         args = []
1677         a = self.db.arg
1678         for k, v in filterspec.items():
1679             propclass = props[k]
1680             # now do other where clause stuff
1681             if isinstance(propclass, Multilink):
1682                 tn = '%s_%s'%(cn, k)
1683                 frum.append(tn)
1684                 if isinstance(v, type([])):
1685                     s = ','.join([a for x in v])
1686                     where.append('id=%s.nodeid and %s.linkid in (%s)'%(tn,tn,s))
1687                     args = args + v
1688                 else:
1689                     where.append('id=%s.nodeid and %s.linkid = %s'%(tn, tn, a))
1690                     args.append(v)
1691             elif isinstance(propclass, String):
1692                 if not isinstance(v, type([])):
1693                     v = [v]
1695                 # Quote the bits in the string that need it and then embed
1696                 # in a "substring" search. Note - need to quote the '%' so
1697                 # they make it through the python layer happily
1698                 v = ['%%'+self.db.sql_stringquote(s)+'%%' for s in v]
1700                 # now add to the where clause
1701                 where.append(' or '.join(["_%s LIKE '%s'"%(k, s) for s in v]))
1702                 # note: args are embedded in the query string now
1703             elif isinstance(propclass, Link):
1704                 if isinstance(v, type([])):
1705                     if '-1' in v:
1706                         v.remove('-1')
1707                         xtra = ' or _%s is NULL'%k
1708                     else:
1709                         xtra = ''
1710                     s = ','.join([a for x in v])
1711                     where.append('(_%s in (%s)%s)'%(k, s, xtra))
1712                     args = args + v
1713                 else:
1714                     if v == '-1':
1715                         v = None
1716                         where.append('_%s is NULL'%k)
1717                     else:
1718                         where.append('_%s=%s'%(k, a))
1719                         args.append(v)
1720             else:
1721                 if isinstance(v, type([])):
1722                     s = ','.join([a for x in v])
1723                     where.append('_%s in (%s)'%(k, s))
1724                     args = args + v
1725                 else:
1726                     where.append('_%s=%s'%(k, a))
1727                     args.append(v)
1729         # add results of full text search
1730         if search_matches is not None:
1731             v = search_matches.keys()
1732             s = ','.join([a for x in v])
1733             where.append('id in (%s)'%s)
1734             args = args + v
1736         # "grouping" is just the first-order sorting in the SQL fetch
1737         # can modify it...)
1738         orderby = []
1739         ordercols = []
1740         if group[0] is not None and group[1] is not None:
1741             if group[0] != '-':
1742                 orderby.append('_'+group[1])
1743                 ordercols.append('_'+group[1])
1744             else:
1745                 orderby.append('_'+group[1]+' desc')
1746                 ordercols.append('_'+group[1])
1748         # now add in the sorting
1749         group = ''
1750         if sort[0] is not None and sort[1] is not None:
1751             direction, colname = sort
1752             if direction != '-':
1753                 if colname == 'id':
1754                     orderby.append(colname)
1755                 else:
1756                     orderby.append('_'+colname)
1757                     ordercols.append('_'+colname)
1758             else:
1759                 if colname == 'id':
1760                     orderby.append(colname+' desc')
1761                     ordercols.append(colname)
1762                 else:
1763                     orderby.append('_'+colname+' desc')
1764                     ordercols.append('_'+colname)
1766         # construct the SQL
1767         frum = ','.join(frum)
1768         if where:
1769             where = ' where ' + (' and '.join(where))
1770         else:
1771             where = ''
1772         cols = ['id']
1773         if orderby:
1774             cols = cols + ordercols
1775             order = ' order by %s'%(','.join(orderby))
1776         else:
1777             order = ''
1778         cols = ','.join(cols)
1779         sql = 'select %s from %s %s%s%s'%(cols, frum, where, group, order)
1780         args = tuple(args)
1781         if __debug__:
1782             print >>hyperdb.DEBUG, 'filter', (self, sql, args)
1783         print sql
1784         self.db.cursor.execute(sql, args)
1785         l = self.db.cursor.fetchall()
1787         # return the IDs (the first column)
1788         # XXX The filter(None, l) bit is sqlite-specific... if there's _NO_
1789         # XXX matches to a fetch, it returns NULL instead of nothing!?!
1790         return filter(None, [row[0] for row in l])
1792     def count(self):
1793         '''Get the number of nodes in this class.
1795         If the returned integer is 'numnodes', the ids of all the nodes
1796         in this class run from 1 to numnodes, and numnodes+1 will be the
1797         id of the next node to be created in this class.
1798         '''
1799         return self.db.countnodes(self.classname)
1801     # Manipulating properties:
1802     def getprops(self, protected=1):
1803         '''Return a dictionary mapping property names to property objects.
1804            If the "protected" flag is true, we include protected properties -
1805            those which may not be modified.
1806         '''
1807         d = self.properties.copy()
1808         if protected:
1809             d['id'] = String()
1810             d['creation'] = hyperdb.Date()
1811             d['activity'] = hyperdb.Date()
1812             d['creator'] = hyperdb.Link('user')
1813         return d
1815     def addprop(self, **properties):
1816         '''Add properties to this class.
1818         The keyword arguments in 'properties' must map names to property
1819         objects, or a TypeError is raised.  None of the keys in 'properties'
1820         may collide with the names of existing properties, or a ValueError
1821         is raised before any properties have been added.
1822         '''
1823         for key in properties.keys():
1824             if self.properties.has_key(key):
1825                 raise ValueError, key
1826         self.properties.update(properties)
1828     def index(self, nodeid):
1829         '''Add (or refresh) the node to search indexes
1830         '''
1831         # find all the String properties that have indexme
1832         for prop, propclass in self.getprops().items():
1833             if isinstance(propclass, String) and propclass.indexme:
1834                 try:
1835                     value = str(self.get(nodeid, prop))
1836                 except IndexError:
1837                     # node no longer exists - entry should be removed
1838                     self.db.indexer.purge_entry((self.classname, nodeid, prop))
1839                 else:
1840                     # and index them under (classname, nodeid, property)
1841                     self.db.indexer.add_text((self.classname, nodeid, prop),
1842                         value)
1845     #
1846     # Detector interface
1847     #
1848     def audit(self, event, detector):
1849         '''Register a detector
1850         '''
1851         l = self.auditors[event]
1852         if detector not in l:
1853             self.auditors[event].append(detector)
1855     def fireAuditors(self, action, nodeid, newvalues):
1856         '''Fire all registered auditors.
1857         '''
1858         for audit in self.auditors[action]:
1859             audit(self.db, self, nodeid, newvalues)
1861     def react(self, event, detector):
1862         '''Register a detector
1863         '''
1864         l = self.reactors[event]
1865         if detector not in l:
1866             self.reactors[event].append(detector)
1868     def fireReactors(self, action, nodeid, oldvalues):
1869         '''Fire all registered reactors.
1870         '''
1871         for react in self.reactors[action]:
1872             react(self.db, self, nodeid, oldvalues)
1874 class FileClass(Class):
1875     '''This class defines a large chunk of data. To support this, it has a
1876        mandatory String property "content" which is typically saved off
1877        externally to the hyperdb.
1879        The default MIME type of this data is defined by the
1880        "default_mime_type" class attribute, which may be overridden by each
1881        node if the class defines a "type" String property.
1882     '''
1883     default_mime_type = 'text/plain'
1885     def create(self, **propvalues):
1886         ''' snaffle the file propvalue and store in a file
1887         '''
1888         content = propvalues['content']
1889         del propvalues['content']
1890         newid = Class.create(self, **propvalues)
1891         self.db.storefile(self.classname, newid, None, content)
1892         return newid
1894     def import_list(self, propnames, proplist):
1895         ''' Trap the "content" property...
1896         '''
1897         # dupe this list so we don't affect others
1898         propnames = propnames[:]
1900         # extract the "content" property from the proplist
1901         i = propnames.index('content')
1902         content = eval(proplist[i])
1903         del propnames[i]
1904         del proplist[i]
1906         # do the normal import
1907         newid = Class.import_list(self, propnames, proplist)
1909         # save off the "content" file
1910         self.db.storefile(self.classname, newid, None, content)
1911         return newid
1913     _marker = []
1914     def get(self, nodeid, propname, default=_marker, cache=1):
1915         ''' trap the content propname and get it from the file
1916         '''
1918         poss_msg = 'Possibly a access right configuration problem.'
1919         if propname == 'content':
1920             try:
1921                 return self.db.getfile(self.classname, nodeid, None)
1922             except IOError, (strerror):
1923                 # BUG: by catching this we donot see an error in the log.
1924                 return 'ERROR reading file: %s%s\n%s\n%s'%(
1925                         self.classname, nodeid, poss_msg, strerror)
1926         if default is not self._marker:
1927             return Class.get(self, nodeid, propname, default, cache=cache)
1928         else:
1929             return Class.get(self, nodeid, propname, cache=cache)
1931     def getprops(self, protected=1):
1932         ''' In addition to the actual properties on the node, these methods
1933             provide the "content" property. If the "protected" flag is true,
1934             we include protected properties - those which may not be
1935             modified.
1936         '''
1937         d = Class.getprops(self, protected=protected).copy()
1938         d['content'] = hyperdb.String()
1939         return d
1941     def index(self, nodeid):
1942         ''' Index the node in the search index.
1944             We want to index the content in addition to the normal String
1945             property indexing.
1946         '''
1947         # perform normal indexing
1948         Class.index(self, nodeid)
1950         # get the content to index
1951         content = self.get(nodeid, 'content')
1953         # figure the mime type
1954         if self.properties.has_key('type'):
1955             mime_type = self.get(nodeid, 'type')
1956         else:
1957             mime_type = self.default_mime_type
1959         # and index!
1960         self.db.indexer.add_text((self.classname, nodeid, 'content'), content,
1961             mime_type)
1963 # XXX deviation from spec - was called ItemClass
1964 class IssueClass(Class, roundupdb.IssueClass):
1965     # Overridden methods:
1966     def __init__(self, db, classname, **properties):
1967         '''The newly-created class automatically includes the "messages",
1968         "files", "nosy", and "superseder" properties.  If the 'properties'
1969         dictionary attempts to specify any of these properties or a
1970         "creation" or "activity" property, a ValueError is raised.
1971         '''
1972         if not properties.has_key('title'):
1973             properties['title'] = hyperdb.String(indexme='yes')
1974         if not properties.has_key('messages'):
1975             properties['messages'] = hyperdb.Multilink("msg")
1976         if not properties.has_key('files'):
1977             properties['files'] = hyperdb.Multilink("file")
1978         if not properties.has_key('nosy'):
1979             # note: journalling is turned off as it really just wastes
1980             # space. this behaviour may be overridden in an instance
1981             properties['nosy'] = hyperdb.Multilink("user", do_journal="no")
1982         if not properties.has_key('superseder'):
1983             properties['superseder'] = hyperdb.Multilink(classname)
1984         Class.__init__(self, db, classname, **properties)