Code

mysql backend passes all tests (at last!)
[roundup.git] / roundup / backends / rdbms_common.py
1 # $Id: rdbms_common.py,v 1.31 2003-02-08 15:31:28 kedder Exp $
2 ''' Relational database (SQL) backend common code.
4 Basics:
6 - map roundup classes to relational tables
7 - automatically detect schema changes and modify the table schemas
8   appropriately (we store the "database version" of the schema in the
9   database itself as the only row of the "schema" table)
10 - multilinks (which represent a many-to-many relationship) are handled through
11   intermediate tables
12 - journals are stored adjunct to the per-class tables
13 - table names and columns have "_" prepended so the names can't clash with
14   restricted names (like "order")
15 - retirement is determined by the __retired__ column being true
17 Database-specific changes may generally be pushed out to the overridable
18 sql_* methods, since everything else should be fairly generic. There's
19 probably a bit of work to be done if a database is used that actually
20 honors column typing, since the initial databases don't (sqlite stores
21 everything as a string, and gadfly stores anything that's marsallable).
22 '''
24 # standard python modules
25 import sys, os, time, re, errno, weakref, copy
27 # roundup modules
28 from roundup import hyperdb, date, password, roundupdb, security
29 from roundup.hyperdb import String, Password, Date, Interval, Link, \
30     Multilink, DatabaseError, Boolean, Number
31 from roundup.backends import locking
33 # support
34 from blobfiles import FileStorage
35 from roundup.indexer import Indexer
36 from sessions import Sessions
38 # number of rows to keep in memory
39 ROW_CACHE_SIZE = 100
41 class Database(FileStorage, hyperdb.Database, roundupdb.Database):
42     ''' Wrapper around an SQL database that presents a hyperdb interface.
44         - some functionality is specific to the actual SQL database, hence
45           the sql_* methods that are NotImplemented
46         - we keep a cache of the latest ROW_CACHE_SIZE row fetches.
47     '''
48     def __init__(self, config, journaltag=None):
49         ''' Open the database and load the schema from it.
50         '''
51         self.config, self.journaltag = config, journaltag
52         self.dir = config.DATABASE
53         self.classes = {}
54         self.indexer = Indexer(self.dir)
55         self.sessions = Sessions(self.config)
56         self.security = security.Security(self)
58         # additional transaction support for external files and the like
59         self.transactions = []
61         # keep a cache of the N most recently retrieved rows of any kind
62         # (classname, nodeid) = row
63         self.cache = {}
64         self.cache_lru = []
66         # database lock
67         self.lockfile = None
69         # open a connection to the database, creating the "conn" attribute
70         self.open_connection()
72     def clearCache(self):
73         self.cache = {}
74         self.cache_lru = []
76     def open_connection(self):
77         ''' Open a connection to the database, creating it if necessary
78         '''
79         raise NotImplemented
81     def sql(self, sql, args=None):
82         ''' Execute the sql with the optional args.
83         '''
84         if __debug__:
85             print >>hyperdb.DEBUG, (self, sql, args)
86         if args:
87             self.cursor.execute(sql, args)
88         else:
89             self.cursor.execute(sql)
91     def sql_fetchone(self):
92         ''' Fetch a single row. If there's nothing to fetch, return None.
93         '''
94         raise NotImplemented
96     def sql_stringquote(self, value):
97         ''' Quote the string so it's safe to put in the 'sql quotes'
98         '''
99         return re.sub("'", "''", str(value))
101     def save_dbschema(self, schema):
102         ''' Save the schema definition that the database currently implements
103         '''
104         raise NotImplemented
106     def load_dbschema(self):
107         ''' Load the schema definition that the database currently implements
108         '''
109         raise NotImplemented
111     def post_init(self):
112         ''' Called once the schema initialisation has finished.
114             We should now confirm that the schema defined by our "classes"
115             attribute actually matches the schema in the database.
116         '''
117         # now detect changes in the schema
118         save = 0
119         for classname, spec in self.classes.items():
120             if self.database_schema.has_key(classname):
121                 dbspec = self.database_schema[classname]
122                 if self.update_class(spec, dbspec):
123                     self.database_schema[classname] = spec.schema()
124                     save = 1
125             else:
126                 self.create_class(spec)
127                 self.database_schema[classname] = spec.schema()
128                 save = 1
130         for classname in self.database_schema.keys():
131             if not self.classes.has_key(classname):
132                 self.drop_class(classname)
134         # update the database version of the schema
135         if save:
136             self.sql('delete from schema')
137             self.save_dbschema(self.database_schema)
139         # reindex the db if necessary
140         if self.indexer.should_reindex():
141             self.reindex()
143         # commit
144         self.conn.commit()
146         # figure the "curuserid"
147         if self.journaltag is None:
148             self.curuserid = None
149         elif self.journaltag == 'admin':
150             # admin user may not exist, but always has ID 1
151             self.curuserid = '1'
152         else:
153             self.curuserid = self.user.lookup(self.journaltag)
155     def reindex(self):
156         for klass in self.classes.values():
157             for nodeid in klass.list():
158                 klass.index(nodeid)
159         self.indexer.save_index()
161     def determine_columns(self, properties):
162         ''' Figure the column names and multilink properties from the spec
164             "properties" is a list of (name, prop) where prop may be an
165             instance of a hyperdb "type" _or_ a string repr of that type.
166         '''
167         cols = ['_activity', '_creator', '_creation']
168         mls = []
169         # add the multilinks separately
170         for col, prop in properties:
171             if isinstance(prop, Multilink):
172                 mls.append(col)
173             elif isinstance(prop, type('')) and prop.find('Multilink') != -1:
174                 mls.append(col)
175             else:
176                 cols.append('_'+col)
177         cols.sort()
178         return cols, mls
180     def update_class(self, spec, dbspec):
181         ''' Determine the differences between the current spec and the
182             database version of the spec, and update where necessary
183         '''
184         spec_schema = spec.schema()
185         if spec_schema == dbspec:
186             # no save needed for this one
187             return 0
188         if __debug__:
189             print >>hyperdb.DEBUG, 'update_class FIRING'
191         # key property changed?
192         if dbspec[0] != spec_schema[0]:
193             if __debug__:
194                 print >>hyperdb.DEBUG, 'update_class setting keyprop', `spec[0]`
195             # XXX turn on indexing for the key property
197         # dict 'em up
198         spec_propnames,spec_props = [],{}
199         for propname,prop in spec_schema[1]:
200             spec_propnames.append(propname)
201             spec_props[propname] = prop
202         dbspec_propnames,dbspec_props = [],{}
203         for propname,prop in dbspec[1]:
204             dbspec_propnames.append(propname)
205             dbspec_props[propname] = prop
207         # now compare
208         for propname in spec_propnames:
209             prop = spec_props[propname]
210             if dbspec_props.has_key(propname) and prop==dbspec_props[propname]:
211                 continue
212             if __debug__:
213                 print >>hyperdb.DEBUG, 'update_class ADD', (propname, prop)
215             if not dbspec_props.has_key(propname):
216                 # add the property
217                 if isinstance(prop, Multilink):
218                     # all we have to do here is create a new table, easy!
219                     self.create_multilink_table(spec, propname)
220                     continue
222                 # no ALTER TABLE, so we:
223                 # 1. pull out the data, including an extra None column
224                 oldcols, x = self.determine_columns(dbspec[1])
225                 oldcols.append('id')
226                 oldcols.append('__retired__')
227                 cn = spec.classname
228                 sql = 'select %s,%s from _%s'%(','.join(oldcols), self.arg, cn)
229                 if __debug__:
230                     print >>hyperdb.DEBUG, 'update_class', (self, sql, None)
231                 self.cursor.execute(sql, (None,))
232                 olddata = self.cursor.fetchall()
234                 # 2. drop the old table
235                 self.cursor.execute('drop table _%s'%cn)
237                 # 3. create the new table
238                 cols, mls = self.create_class_table(spec)
239                 # ensure the new column is last
240                 cols.remove('_'+propname)
241                 assert oldcols == cols, "Column lists don't match!"
242                 cols.append('_'+propname)
244                 # 4. populate with the data from step one
245                 s = ','.join([self.arg for x in cols])
246                 scols = ','.join(cols)
247                 sql = 'insert into _%s (%s) values (%s)'%(cn, scols, s)
249                 # GAH, nothing had better go wrong from here on in... but
250                 # we have to commit the drop...
251                 # XXX this isn't necessary in sqlite :(
252                 self.conn.commit()
254                 # do the insert
255                 for row in olddata:
256                     self.sql(sql, tuple(row))
258             else:
259                 # modify the property
260                 if __debug__:
261                     print >>hyperdb.DEBUG, 'update_class NOOP'
262                 pass  # NOOP in gadfly
264         # and the other way - only worry about deletions here
265         for propname in dbspec_propnames:
266             prop = dbspec_props[propname]
267             if spec_props.has_key(propname):
268                 continue
269             if __debug__:
270                 print >>hyperdb.DEBUG, 'update_class REMOVE', `prop`
272             # delete the property
273             if isinstance(prop, Multilink):
274                 sql = 'drop table %s_%s'%(spec.classname, prop)
275                 if __debug__:
276                     print >>hyperdb.DEBUG, 'update_class', (self, sql)
277                 self.cursor.execute(sql)
278             else:
279                 # no ALTER TABLE, so we:
280                 # 1. pull out the data, excluding the removed column
281                 oldcols, x = self.determine_columns(spec.properties.items())
282                 oldcols.append('id')
283                 oldcols.append('__retired__')
284                 # remove the missing column
285                 oldcols.remove('_'+propname)
286                 cn = spec.classname
287                 sql = 'select %s from _%s'%(','.join(oldcols), cn)
288                 self.cursor.execute(sql, (None,))
289                 olddata = sql.fetchall()
291                 # 2. drop the old table
292                 self.cursor.execute('drop table _%s'%cn)
294                 # 3. create the new table
295                 cols, mls = self.create_class_table(self, spec)
296                 assert oldcols != cols, "Column lists don't match!"
298                 # 4. populate with the data from step one
299                 qs = ','.join([self.arg for x in cols])
300                 sql = 'insert into _%s values (%s)'%(cn, s)
301                 self.cursor.execute(sql, olddata)
302         return 1
304     def create_class_table(self, spec):
305         ''' create the class table for the given spec
306         '''
307         cols, mls = self.determine_columns(spec.properties.items())
309         # add on our special columns
310         cols.append('id')
311         cols.append('__retired__')
313         # create the base table
314         scols = ','.join(['%s varchar'%x for x in cols])
315         sql = 'create table _%s (%s)'%(spec.classname, scols)
316         if __debug__:
317             print >>hyperdb.DEBUG, 'create_class', (self, sql)
318         self.cursor.execute(sql)
320         return cols, mls
322     def create_journal_table(self, spec):
323         ''' create the journal table for a class given the spec and 
324             already-determined cols
325         '''
326         # journal table
327         cols = ','.join(['%s varchar'%x
328             for x in 'nodeid date tag action params'.split()])
329         sql = 'create table %s__journal (%s)'%(spec.classname, cols)
330         if __debug__:
331             print >>hyperdb.DEBUG, 'create_class', (self, sql)
332         self.cursor.execute(sql)
334     def create_multilink_table(self, spec, ml):
335         ''' Create a multilink table for the "ml" property of the class
336             given by the spec
337         '''
338         sql = 'create table %s_%s (linkid varchar, nodeid varchar)'%(
339             spec.classname, ml)
340         if __debug__:
341             print >>hyperdb.DEBUG, 'create_class', (self, sql)
342         self.cursor.execute(sql)
344     def create_class(self, spec):
345         ''' Create a database table according to the given spec.
346         '''
347         cols, mls = self.create_class_table(spec)
348         self.create_journal_table(spec)
350         # now create the multilink tables
351         for ml in mls:
352             self.create_multilink_table(spec, ml)
354         # ID counter
355         sql = 'insert into ids (name, num) values (%s,%s)'%(self.arg, self.arg)
356         vals = (spec.classname, 1)
357         if __debug__:
358             print >>hyperdb.DEBUG, 'create_class', (self, sql, vals)
359         self.cursor.execute(sql, vals)
361     def drop_class(self, spec):
362         ''' Drop the given table from the database.
364             Drop the journal and multilink tables too.
365         '''
366         # figure the multilinks
367         mls = []
368         for col, prop in spec.properties.items():
369             if isinstance(prop, Multilink):
370                 mls.append(col)
372         sql = 'drop table _%s'%spec.classname
373         if __debug__:
374             print >>hyperdb.DEBUG, 'drop_class', (self, sql)
375         self.cursor.execute(sql)
377         sql = 'drop table %s__journal'%spec.classname
378         if __debug__:
379             print >>hyperdb.DEBUG, 'drop_class', (self, sql)
380         self.cursor.execute(sql)
382         for ml in mls:
383             sql = 'drop table %s_%s'%(spec.classname, ml)
384             if __debug__:
385                 print >>hyperdb.DEBUG, 'drop_class', (self, sql)
386             self.cursor.execute(sql)
388     #
389     # Classes
390     #
391     def __getattr__(self, classname):
392         ''' A convenient way of calling self.getclass(classname).
393         '''
394         if self.classes.has_key(classname):
395             if __debug__:
396                 print >>hyperdb.DEBUG, '__getattr__', (self, classname)
397             return self.classes[classname]
398         raise AttributeError, classname
400     def addclass(self, cl):
401         ''' Add a Class to the hyperdatabase.
402         '''
403         if __debug__:
404             print >>hyperdb.DEBUG, 'addclass', (self, cl)
405         cn = cl.classname
406         if self.classes.has_key(cn):
407             raise ValueError, cn
408         self.classes[cn] = cl
410     def getclasses(self):
411         ''' Return a list of the names of all existing classes.
412         '''
413         if __debug__:
414             print >>hyperdb.DEBUG, 'getclasses', (self,)
415         l = self.classes.keys()
416         l.sort()
417         return l
419     def getclass(self, classname):
420         '''Get the Class object representing a particular class.
422         If 'classname' is not a valid class name, a KeyError is raised.
423         '''
424         if __debug__:
425             print >>hyperdb.DEBUG, 'getclass', (self, classname)
426         try:
427             return self.classes[classname]
428         except KeyError:
429             raise KeyError, 'There is no class called "%s"'%classname
431     def clear(self):
432         ''' Delete all database contents.
434             Note: I don't commit here, which is different behaviour to the
435             "nuke from orbit" behaviour in the *dbms.
436         '''
437         if __debug__:
438             print >>hyperdb.DEBUG, 'clear', (self,)
439         for cn in self.classes.keys():
440             sql = 'delete from _%s'%cn
441             if __debug__:
442                 print >>hyperdb.DEBUG, 'clear', (self, sql)
443             self.cursor.execute(sql)
445     #
446     # Node IDs
447     #
448     def newid(self, classname):
449         ''' Generate a new id for the given class
450         '''
451         # get the next ID
452         sql = 'select num from ids where name=%s'%self.arg
453         if __debug__:
454             print >>hyperdb.DEBUG, 'newid', (self, sql, classname)
455         self.cursor.execute(sql, (classname, ))
456         newid = self.cursor.fetchone()[0]
458         # update the counter
459         sql = 'update ids set num=%s where name=%s'%(self.arg, self.arg)
460         vals = (int(newid)+1, classname)
461         if __debug__:
462             print >>hyperdb.DEBUG, 'newid', (self, sql, vals)
463         self.cursor.execute(sql, vals)
465         # return as string
466         return str(newid)
468     def setid(self, classname, setid):
469         ''' Set the id counter: used during import of database
470         '''
471         sql = 'update ids set num=%s where name=%s'%(self.arg, self.arg)
472         vals = (setid, classname)
473         if __debug__:
474             print >>hyperdb.DEBUG, 'setid', (self, sql, vals)
475         self.cursor.execute(sql, vals)
477     #
478     # Nodes
479     #
481     def addnode(self, classname, nodeid, node):
482         ''' Add the specified node to its class's db.
483         '''
484         if __debug__:
485             print >>hyperdb.DEBUG, 'addnode', (self, classname, nodeid, node)
486         # gadfly requires values for all non-multilink columns
487         cl = self.classes[classname]
488         cols, mls = self.determine_columns(cl.properties.items())
490         # we'll be supplied these props if we're doing an import
491         if not node.has_key('creator'):
492             # add in the "calculated" properties (dupe so we don't affect
493             # calling code's node assumptions)
494             node = node.copy()
495             node['creation'] = node['activity'] = date.Date()
496             node['creator'] = self.curuserid
498         # default the non-multilink columns
499         for col, prop in cl.properties.items():
500             if not isinstance(col, Multilink):
501                 if not node.has_key(col):
502                     node[col] = None
504         # clear this node out of the cache if it's in there
505         key = (classname, nodeid)
506         if self.cache.has_key(key):
507             del self.cache[key]
508             self.cache_lru.remove(key)
510         # make the node data safe for the DB
511         node = self.serialise(classname, node)
513         # make sure the ordering is correct for column name -> column value
514         vals = tuple([node[col[1:]] for col in cols]) + (nodeid, 0)
515         s = ','.join([self.arg for x in cols]) + ',%s,%s'%(self.arg, self.arg)
516         cols = ','.join(cols) + ',id,__retired__'
518         # perform the inserts
519         sql = 'insert into _%s (%s) values (%s)'%(classname, cols, s)
520         if __debug__:
521             print >>hyperdb.DEBUG, 'addnode', (self, sql, vals)
522         self.cursor.execute(sql, vals)
524         # insert the multilink rows
525         for col in mls:
526             t = '%s_%s'%(classname, col)
527             for entry in node[col]:
528                 sql = 'insert into %s (linkid, nodeid) values (%s,%s)'%(t,
529                     self.arg, self.arg)
530                 self.sql(sql, (entry, nodeid))
532         # make sure we do the commit-time extra stuff for this node
533         self.transactions.append((self.doSaveNode, (classname, nodeid, node)))
535     def setnode(self, classname, nodeid, values, multilink_changes):
536         ''' Change the specified node.
537         '''
538         if __debug__:
539             print >>hyperdb.DEBUG, 'setnode', (self, classname, nodeid, values)
541         # clear this node out of the cache if it's in there
542         key = (classname, nodeid)
543         if self.cache.has_key(key):
544             del self.cache[key]
545             self.cache_lru.remove(key)
547         # add the special props
548         values = values.copy()
549         values['activity'] = date.Date()
551         # make db-friendly
552         values = self.serialise(classname, values)
554         cl = self.classes[classname]
555         cols = []
556         mls = []
557         # add the multilinks separately
558         props = cl.getprops()
559         for col in values.keys():
560             prop = props[col]
561             if isinstance(prop, Multilink):
562                 mls.append(col)
563             else:
564                 cols.append('_'+col)
565         cols.sort()
567         # if there's any updates to regular columns, do them
568         if cols:
569             # make sure the ordering is correct for column name -> column value
570             sqlvals = tuple([values[col[1:]] for col in cols]) + (nodeid,)
571             s = ','.join(['%s=%s'%(x, self.arg) for x in cols])
572             cols = ','.join(cols)
574             # perform the update
575             sql = 'update _%s set %s where id=%s'%(classname, s, self.arg)
576             if __debug__:
577                 print >>hyperdb.DEBUG, 'setnode', (self, sql, sqlvals)
578             self.cursor.execute(sql, sqlvals)
580         # now the fun bit, updating the multilinks ;)
581         for col, (add, remove) in multilink_changes.items():
582             tn = '%s_%s'%(classname, col)
583             if add:
584                 sql = 'insert into %s (nodeid, linkid) values (%s,%s)'%(tn,
585                     self.arg, self.arg)
586                 for addid in add:
587                     self.sql(sql, (nodeid, addid))
588             if remove:
589                 sql = 'delete from %s where nodeid=%s and linkid=%s'%(tn,
590                     self.arg, self.arg)
591                 for removeid in remove:
592                     self.sql(sql, (nodeid, removeid))
594         # make sure we do the commit-time extra stuff for this node
595         self.transactions.append((self.doSaveNode, (classname, nodeid, values)))
597     def getnode(self, classname, nodeid):
598         ''' Get a node from the database.
599         '''
600         if __debug__:
601             print >>hyperdb.DEBUG, 'getnode', (self, classname, nodeid)
603         # see if we have this node cached
604         key = (classname, nodeid)
605         if self.cache.has_key(key):
606             # push us back to the top of the LRU
607             self.cache_lru.remove(key)
608             self.cache_lru.insert(0, key)
609             # return the cached information
610             return self.cache[key]
612         # figure the columns we're fetching
613         cl = self.classes[classname]
614         cols, mls = self.determine_columns(cl.properties.items())
615         scols = ','.join(cols)
617         # perform the basic property fetch
618         sql = 'select %s from _%s where id=%s'%(scols, classname, self.arg)
619         self.sql(sql, (nodeid,))
621         values = self.sql_fetchone()
622         if values is None:
623             raise IndexError, 'no such %s node %s'%(classname, nodeid)
625         # make up the node
626         node = {}
627         for col in range(len(cols)):
628             node[cols[col][1:]] = values[col]
630         # now the multilinks
631         for col in mls:
632             # get the link ids
633             sql = 'select linkid from %s_%s where nodeid=%s'%(classname, col,
634                 self.arg)
635             self.cursor.execute(sql, (nodeid,))
636             # extract the first column from the result
637             node[col] = [x[0] for x in self.cursor.fetchall()]
639         # un-dbificate the node data
640         node = self.unserialise(classname, node)
642         # save off in the cache
643         key = (classname, nodeid)
644         self.cache[key] = node
645         # update the LRU
646         self.cache_lru.insert(0, key)
647         if len(self.cache_lru) > ROW_CACHE_SIZE:
648             del self.cache[self.cache_lru.pop()]
650         return node
652     def destroynode(self, classname, nodeid):
653         '''Remove a node from the database. Called exclusively by the
654            destroy() method on Class.
655         '''
656         if __debug__:
657             print >>hyperdb.DEBUG, 'destroynode', (self, classname, nodeid)
659         # make sure the node exists
660         if not self.hasnode(classname, nodeid):
661             raise IndexError, '%s has no node %s'%(classname, nodeid)
663         # see if we have this node cached
664         if self.cache.has_key((classname, nodeid)):
665             del self.cache[(classname, nodeid)]
667         # see if there's any obvious commit actions that we should get rid of
668         for entry in self.transactions[:]:
669             if entry[1][:2] == (classname, nodeid):
670                 self.transactions.remove(entry)
672         # now do the SQL
673         sql = 'delete from _%s where id=%s'%(classname, self.arg)
674         self.sql(sql, (nodeid,))
676         # remove from multilnks
677         cl = self.getclass(classname)
678         x, mls = self.determine_columns(cl.properties.items())
679         for col in mls:
680             # get the link ids
681             sql = 'delete from %s_%s where nodeid=%s'%(classname, col, self.arg)
682             self.cursor.execute(sql, (nodeid,))
684         # remove journal entries
685         sql = 'delete from %s__journal where nodeid=%s'%(classname, self.arg)
686         self.sql(sql, (nodeid,))
688     def serialise(self, classname, node):
689         '''Copy the node contents, converting non-marshallable data into
690            marshallable data.
691         '''
692         if __debug__:
693             print >>hyperdb.DEBUG, 'serialise', classname, node
694         properties = self.getclass(classname).getprops()
695         d = {}
696         for k, v in node.items():
697             # if the property doesn't exist, or is the "retired" flag then
698             # it won't be in the properties dict
699             if not properties.has_key(k):
700                 d[k] = v
701                 continue
703             # get the property spec
704             prop = properties[k]
706             if isinstance(prop, Password) and v is not None:
707                 d[k] = str(v)
708             elif isinstance(prop, Date) and v is not None:
709                 d[k] = v.serialise()
710             elif isinstance(prop, Interval) and v is not None:
711                 d[k] = v.serialise()
712             else:
713                 d[k] = v
714         return d
716     def unserialise(self, classname, node):
717         '''Decode the marshalled node data
718         '''
719         if __debug__:
720             print >>hyperdb.DEBUG, 'unserialise', classname, node
721         properties = self.getclass(classname).getprops()
722         d = {}
723         for k, v in node.items():
724             # if the property doesn't exist, or is the "retired" flag then
725             # it won't be in the properties dict
726             if not properties.has_key(k):
727                 d[k] = v
728                 continue
730             # get the property spec
731             prop = properties[k]
733             if isinstance(prop, Date) and v is not None:
734                 d[k] = date.Date(v)
735             elif isinstance(prop, Interval) and v is not None:
736                 d[k] = date.Interval(v)
737             elif isinstance(prop, Password) and v is not None:
738                 p = password.Password()
739                 p.unpack(v)
740                 d[k] = p
741             elif (isinstance(prop, Boolean) or isinstance(prop, Number)) and v is not None:
742                 d[k]=float(v)
743             else:
744                 d[k] = v
745         return d
747     def hasnode(self, classname, nodeid):
748         ''' Determine if the database has a given node.
749         '''
750         sql = 'select count(*) from _%s where id=%s'%(classname, self.arg)
751         if __debug__:
752             print >>hyperdb.DEBUG, 'hasnode', (self, sql, nodeid)
753         self.cursor.execute(sql, (nodeid,))
754         return int(self.cursor.fetchone()[0])
756     def countnodes(self, classname):
757         ''' Count the number of nodes that exist for a particular Class.
758         '''
759         sql = 'select count(*) from _%s'%classname
760         if __debug__:
761             print >>hyperdb.DEBUG, 'countnodes', (self, sql)
762         self.cursor.execute(sql)
763         return self.cursor.fetchone()[0]
765     def getnodeids(self, classname, retired=0):
766         ''' Retrieve all the ids of the nodes for a particular Class.
768             Set retired=None to get all nodes. Otherwise it'll get all the 
769             retired or non-retired nodes, depending on the flag.
770         '''
771         # flip the sense of the flag if we don't want all of them
772         if retired is not None:
773             retired = not retired
774         sql = 'select id from _%s where __retired__ <> %s'%(classname, self.arg)
775         if __debug__:
776             print >>hyperdb.DEBUG, 'getnodeids', (self, sql, retired)
777         self.cursor.execute(sql, (retired,))
778         return [x[0] for x in self.cursor.fetchall()]
780     def addjournal(self, classname, nodeid, action, params, creator=None,
781             creation=None):
782         ''' Journal the Action
783         'action' may be:
785             'create' or 'set' -- 'params' is a dictionary of property values
786             'link' or 'unlink' -- 'params' is (classname, nodeid, propname)
787             'retire' -- 'params' is None
788         '''
789         # serialise the parameters now if necessary
790         if isinstance(params, type({})):
791             if action in ('set', 'create'):
792                 params = self.serialise(classname, params)
794         # handle supply of the special journalling parameters (usually
795         # supplied on importing an existing database)
796         if creator:
797             journaltag = creator
798         else:
799             journaltag = self.curuserid
800         if creation:
801             journaldate = creation.serialise()
802         else:
803             journaldate = date.Date().serialise()
805         # create the journal entry
806         cols = ','.join('nodeid date tag action params'.split())
808         if __debug__:
809             print >>hyperdb.DEBUG, 'addjournal', (nodeid, journaldate,
810                 journaltag, action, params)
812         self.save_journal(classname, cols, nodeid, journaldate,
813             journaltag, action, params)
815     def save_journal(self, classname, cols, nodeid, journaldate,
816             journaltag, action, params):
817         ''' Save the journal entry to the database
818         '''
819         raise NotImplemented
821     def getjournal(self, classname, nodeid):
822         ''' get the journal for id
823         '''
824         # make sure the node exists
825         if not self.hasnode(classname, nodeid):
826             raise IndexError, '%s has no node %s'%(classname, nodeid)
828         cols = ','.join('nodeid date tag action params'.split())
829         return self.load_journal(classname, cols, nodeid)
831     def load_journal(self, classname, cols, nodeid):
832         ''' Load the journal from the database
833         '''
834         raise NotImplemented
836     def pack(self, pack_before):
837         ''' Delete all journal entries except "create" before 'pack_before'.
838         '''
839         # get a 'yyyymmddhhmmss' version of the date
840         date_stamp = pack_before.serialise()
842         # do the delete
843         for classname in self.classes.keys():
844             sql = "delete from %s__journal where date<%s and "\
845                 "action<>'create'"%(classname, self.arg)
846             if __debug__:
847                 print >>hyperdb.DEBUG, 'pack', (self, sql, date_stamp)
848             self.cursor.execute(sql, (date_stamp,))
850     def sql_commit(self):
851         ''' Actually commit to the database.
852         '''
853         self.conn.commit()
855     def commit(self):
856         ''' Commit the current transactions.
858         Save all data changed since the database was opened or since the
859         last commit() or rollback().
860         '''
861         if __debug__:
862             print >>hyperdb.DEBUG, 'commit', (self,)
864         # commit the database
865         self.sql_commit()
867         # now, do all the other transaction stuff
868         reindex = {}
869         for method, args in self.transactions:
870             reindex[method(*args)] = 1
872         # reindex the nodes that request it
873         for classname, nodeid in filter(None, reindex.keys()):
874             print >>hyperdb.DEBUG, 'commit.reindex', (classname, nodeid)
875             self.getclass(classname).index(nodeid)
877         # save the indexer state
878         self.indexer.save_index()
880         # clear out the transactions
881         self.transactions = []
883     def rollback(self):
884         ''' Reverse all actions from the current transaction.
886         Undo all the changes made since the database was opened or the last
887         commit() or rollback() was performed.
888         '''
889         if __debug__:
890             print >>hyperdb.DEBUG, 'rollback', (self,)
892         # roll back
893         self.conn.rollback()
895         # roll back "other" transaction stuff
896         for method, args in self.transactions:
897             # delete temporary files
898             if method == self.doStoreFile:
899                 self.rollbackStoreFile(*args)
900         self.transactions = []
902     def doSaveNode(self, classname, nodeid, node):
903         ''' dummy that just generates a reindex event
904         '''
905         # return the classname, nodeid so we reindex this content
906         return (classname, nodeid)
908     def close(self):
909         ''' Close off the connection.
910         '''
911         self.conn.close()
912         if self.lockfile is not None:
913             locking.release_lock(self.lockfile)
914         if self.lockfile is not None:
915             self.lockfile.close()
916             self.lockfile = None
919 # The base Class class
921 class Class(hyperdb.Class):
922     ''' The handle to a particular class of nodes in a hyperdatabase.
923         
924         All methods except __repr__ and getnode must be implemented by a
925         concrete backend Class.
926     '''
928     def __init__(self, db, classname, **properties):
929         '''Create a new class with a given name and property specification.
931         'classname' must not collide with the name of an existing class,
932         or a ValueError is raised.  The keyword arguments in 'properties'
933         must map names to property objects, or a TypeError is raised.
934         '''
935         if (properties.has_key('creation') or properties.has_key('activity')
936                 or properties.has_key('creator')):
937             raise ValueError, '"creation", "activity" and "creator" are '\
938                 'reserved'
940         self.classname = classname
941         self.properties = properties
942         self.db = weakref.proxy(db)       # use a weak ref to avoid circularity
943         self.key = ''
945         # should we journal changes (default yes)
946         self.do_journal = 1
948         # do the db-related init stuff
949         db.addclass(self)
951         self.auditors = {'create': [], 'set': [], 'retire': []}
952         self.reactors = {'create': [], 'set': [], 'retire': []}
954     def schema(self):
955         ''' A dumpable version of the schema that we can store in the
956             database
957         '''
958         return (self.key, [(x, repr(y)) for x,y in self.properties.items()])
960     def enableJournalling(self):
961         '''Turn journalling on for this class
962         '''
963         self.do_journal = 1
965     def disableJournalling(self):
966         '''Turn journalling off for this class
967         '''
968         self.do_journal = 0
970     # Editing nodes:
971     def create(self, **propvalues):
972         ''' Create a new node of this class and return its id.
974         The keyword arguments in 'propvalues' map property names to values.
976         The values of arguments must be acceptable for the types of their
977         corresponding properties or a TypeError is raised.
978         
979         If this class has a key property, it must be present and its value
980         must not collide with other key strings or a ValueError is raised.
981         
982         Any other properties on this class that are missing from the
983         'propvalues' dictionary are set to None.
984         
985         If an id in a link or multilink property does not refer to a valid
986         node, an IndexError is raised.
987         '''
988         if propvalues.has_key('id'):
989             raise KeyError, '"id" is reserved'
991         if self.db.journaltag is None:
992             raise DatabaseError, 'Database open read-only'
994         if propvalues.has_key('creation') or propvalues.has_key('activity'):
995             raise KeyError, '"creation" and "activity" are reserved'
997         self.fireAuditors('create', None, propvalues)
999         # new node's id
1000         newid = self.db.newid(self.classname)
1002         # validate propvalues
1003         num_re = re.compile('^\d+$')
1004         for key, value in propvalues.items():
1005             if key == self.key:
1006                 try:
1007                     self.lookup(value)
1008                 except KeyError:
1009                     pass
1010                 else:
1011                     raise ValueError, 'node with key "%s" exists'%value
1013             # try to handle this property
1014             try:
1015                 prop = self.properties[key]
1016             except KeyError:
1017                 raise KeyError, '"%s" has no property "%s"'%(self.classname,
1018                     key)
1020             if value is not None and isinstance(prop, Link):
1021                 if type(value) != type(''):
1022                     raise ValueError, 'link value must be String'
1023                 link_class = self.properties[key].classname
1024                 # if it isn't a number, it's a key
1025                 if not num_re.match(value):
1026                     try:
1027                         value = self.db.classes[link_class].lookup(value)
1028                     except (TypeError, KeyError):
1029                         raise IndexError, 'new property "%s": %s not a %s'%(
1030                             key, value, link_class)
1031                 elif not self.db.getclass(link_class).hasnode(value):
1032                     raise IndexError, '%s has no node %s'%(link_class, value)
1034                 # save off the value
1035                 propvalues[key] = value
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, value, 'link',
1040                         (self.classname, newid, key))
1042             elif isinstance(prop, Multilink):
1043                 if type(value) != type([]):
1044                     raise TypeError, 'new property "%s" not a list of ids'%key
1046                 # clean up and validate the list of links
1047                 link_class = self.properties[key].classname
1048                 l = []
1049                 for entry in value:
1050                     if type(entry) != type(''):
1051                         raise ValueError, '"%s" multilink value (%r) '\
1052                             'must contain Strings'%(key, value)
1053                     # if it isn't a number, it's a key
1054                     if not num_re.match(entry):
1055                         try:
1056                             entry = self.db.classes[link_class].lookup(entry)
1057                         except (TypeError, KeyError):
1058                             raise IndexError, 'new property "%s": %s not a %s'%(
1059                                 key, entry, self.properties[key].classname)
1060                     l.append(entry)
1061                 value = l
1062                 propvalues[key] = value
1064                 # handle additions
1065                 for nodeid in value:
1066                     if not self.db.getclass(link_class).hasnode(nodeid):
1067                         raise IndexError, '%s has no node %s'%(link_class,
1068                             nodeid)
1069                     # register the link with the newly linked node
1070                     if self.do_journal and self.properties[key].do_journal:
1071                         self.db.addjournal(link_class, nodeid, 'link',
1072                             (self.classname, newid, key))
1074             elif isinstance(prop, String):
1075                 if type(value) != type('') and type(value) != type(u''):
1076                     raise TypeError, 'new property "%s" not a string'%key
1078             elif isinstance(prop, Password):
1079                 if not isinstance(value, password.Password):
1080                     raise TypeError, 'new property "%s" not a Password'%key
1082             elif isinstance(prop, Date):
1083                 if value is not None and not isinstance(value, date.Date):
1084                     raise TypeError, 'new property "%s" not a Date'%key
1086             elif isinstance(prop, Interval):
1087                 if value is not None and not isinstance(value, date.Interval):
1088                     raise TypeError, 'new property "%s" not an Interval'%key
1090             elif value is not None and isinstance(prop, Number):
1091                 try:
1092                     float(value)
1093                 except ValueError:
1094                     raise TypeError, 'new property "%s" not numeric'%key
1096             elif value is not None and isinstance(prop, Boolean):
1097                 try:
1098                     int(value)
1099                 except ValueError:
1100                     raise TypeError, 'new property "%s" not boolean'%key
1102         # make sure there's data where there needs to be
1103         for key, prop in self.properties.items():
1104             if propvalues.has_key(key):
1105                 continue
1106             if key == self.key:
1107                 raise ValueError, 'key property "%s" is required'%key
1108             if isinstance(prop, Multilink):
1109                 propvalues[key] = []
1110             else:
1111                 propvalues[key] = None
1113         # done
1114         self.db.addnode(self.classname, newid, propvalues)
1115         if self.do_journal:
1116             self.db.addjournal(self.classname, newid, 'create', {})
1118         self.fireReactors('create', newid, None)
1120         return newid
1122     def export_list(self, propnames, nodeid):
1123         ''' Export a node - generate a list of CSV-able data in the order
1124             specified by propnames for the given node.
1125         '''
1126         properties = self.getprops()
1127         l = []
1128         for prop in propnames:
1129             proptype = properties[prop]
1130             value = self.get(nodeid, prop)
1131             # "marshal" data where needed
1132             if value is None:
1133                 pass
1134             elif isinstance(proptype, hyperdb.Date):
1135                 value = value.get_tuple()
1136             elif isinstance(proptype, hyperdb.Interval):
1137                 value = value.get_tuple()
1138             elif isinstance(proptype, hyperdb.Password):
1139                 value = str(value)
1140             l.append(repr(value))
1141         return l
1143     def import_list(self, propnames, proplist):
1144         ''' Import a node - all information including "id" is present and
1145             should not be sanity checked. Triggers are not triggered. The
1146             journal should be initialised using the "creator" and "created"
1147             information.
1149             Return the nodeid of the node imported.
1150         '''
1151         if self.db.journaltag is None:
1152             raise DatabaseError, 'Database open read-only'
1153         properties = self.getprops()
1155         # make the new node's property map
1156         d = {}
1157         for i in range(len(propnames)):
1158             # Use eval to reverse the repr() used to output the CSV
1159             value = eval(proplist[i])
1161             # Figure the property for this column
1162             propname = propnames[i]
1163             prop = properties[propname]
1165             # "unmarshal" where necessary
1166             if propname == 'id':
1167                 newid = value
1168                 continue
1169             elif value is None:
1170                 # don't set Nones
1171                 continue
1172             elif isinstance(prop, hyperdb.Date):
1173                 value = date.Date(value)
1174             elif isinstance(prop, hyperdb.Interval):
1175                 value = date.Interval(value)
1176             elif isinstance(prop, hyperdb.Password):
1177                 pwd = password.Password()
1178                 pwd.unpack(value)
1179                 value = pwd
1180             d[propname] = value
1182         # add the node and journal
1183         self.db.addnode(self.classname, newid, d)
1185         # extract the extraneous journalling gumpf and nuke it
1186         if d.has_key('creator'):
1187             creator = d['creator']
1188             del d['creator']
1189         else:
1190             creator = None
1191         if d.has_key('creation'):
1192             creation = d['creation']
1193             del d['creation']
1194         else:
1195             creation = None
1196         if d.has_key('activity'):
1197             del d['activity']
1198         self.db.addjournal(self.classname, newid, 'create', {}, creator,
1199             creation)
1200         return newid
1202     _marker = []
1203     def get(self, nodeid, propname, default=_marker, cache=1):
1204         '''Get the value of a property on an existing node of this class.
1206         'nodeid' must be the id of an existing node of this class or an
1207         IndexError is raised.  'propname' must be the name of a property
1208         of this class or a KeyError is raised.
1210         'cache' indicates whether the transaction cache should be queried
1211         for the node. If the node has been modified and you need to
1212         determine what its values prior to modification are, you need to
1213         set cache=0.
1214         '''
1215         if propname == 'id':
1216             return nodeid
1218         # get the node's dict
1219         d = self.db.getnode(self.classname, nodeid)
1221         if propname == 'creation':
1222             if d.has_key('creation'):
1223                 return d['creation']
1224             else:
1225                 return date.Date()
1226         if propname == 'activity':
1227             if d.has_key('activity'):
1228                 return d['activity']
1229             else:
1230                 return date.Date()
1231         if propname == 'creator':
1232             if d.has_key('creator'):
1233                 return d['creator']
1234             else:
1235                 return self.db.curuserid
1237         # get the property (raises KeyErorr if invalid)
1238         prop = self.properties[propname]
1240         if not d.has_key(propname):
1241             if default is self._marker:
1242                 if isinstance(prop, Multilink):
1243                     return []
1244                 else:
1245                     return None
1246             else:
1247                 return default
1249         # don't pass our list to other code
1250         if isinstance(prop, Multilink):
1251             return d[propname][:]
1253         return d[propname]
1255     def getnode(self, nodeid, cache=1):
1256         ''' Return a convenience wrapper for the node.
1258         'nodeid' must be the id of an existing node of this class or an
1259         IndexError is raised.
1261         'cache' indicates whether the transaction cache should be queried
1262         for the node. If the node has been modified and you need to
1263         determine what its values prior to modification are, you need to
1264         set cache=0.
1265         '''
1266         return Node(self, nodeid, cache=cache)
1268     def set(self, nodeid, **propvalues):
1269         '''Modify a property on an existing node of this class.
1270         
1271         'nodeid' must be the id of an existing node of this class or an
1272         IndexError is raised.
1274         Each key in 'propvalues' must be the name of a property of this
1275         class or a KeyError is raised.
1277         All values in 'propvalues' must be acceptable types for their
1278         corresponding properties or a TypeError is raised.
1280         If the value of the key property is set, it must not collide with
1281         other key strings or a ValueError is raised.
1283         If the value of a Link or Multilink property contains an invalid
1284         node id, a ValueError is raised.
1285         '''
1286         if not propvalues:
1287             return propvalues
1289         if propvalues.has_key('creation') or propvalues.has_key('activity'):
1290             raise KeyError, '"creation" and "activity" are reserved'
1292         if propvalues.has_key('id'):
1293             raise KeyError, '"id" is reserved'
1295         if self.db.journaltag is None:
1296             raise DatabaseError, 'Database open read-only'
1298         self.fireAuditors('set', nodeid, propvalues)
1299         # Take a copy of the node dict so that the subsequent set
1300         # operation doesn't modify the oldvalues structure.
1301         # XXX used to try the cache here first
1302         oldvalues = copy.deepcopy(self.db.getnode(self.classname, nodeid))
1304         node = self.db.getnode(self.classname, nodeid)
1305         if self.is_retired(nodeid):
1306             raise IndexError, 'Requested item is retired'
1307         num_re = re.compile('^\d+$')
1309         # if the journal value is to be different, store it in here
1310         journalvalues = {}
1312         # remember the add/remove stuff for multilinks, making it easier
1313         # for the Database layer to do its stuff
1314         multilink_changes = {}
1316         for propname, value in propvalues.items():
1317             # check to make sure we're not duplicating an existing key
1318             if propname == self.key and node[propname] != value:
1319                 try:
1320                     self.lookup(value)
1321                 except KeyError:
1322                     pass
1323                 else:
1324                     raise ValueError, 'node with key "%s" exists'%value
1326             # this will raise the KeyError if the property isn't valid
1327             # ... we don't use getprops() here because we only care about
1328             # the writeable properties.
1329             try:
1330                 prop = self.properties[propname]
1331             except KeyError:
1332                 raise KeyError, '"%s" has no property named "%s"'%(
1333                     self.classname, propname)
1335             # if the value's the same as the existing value, no sense in
1336             # doing anything
1337             current = node.get(propname, None)
1338             if value == current:
1339                 del propvalues[propname]
1340                 continue
1341             journalvalues[propname] = current
1343             # do stuff based on the prop type
1344             if isinstance(prop, Link):
1345                 link_class = prop.classname
1346                 # if it isn't a number, it's a key
1347                 if value is not None and not isinstance(value, type('')):
1348                     raise ValueError, 'property "%s" link value be a string'%(
1349                         propname)
1350                 if isinstance(value, type('')) and not num_re.match(value):
1351                     try:
1352                         value = self.db.classes[link_class].lookup(value)
1353                     except (TypeError, KeyError):
1354                         raise IndexError, 'new property "%s": %s not a %s'%(
1355                             propname, value, prop.classname)
1357                 if (value is not None and
1358                         not self.db.getclass(link_class).hasnode(value)):
1359                     raise IndexError, '%s has no node %s'%(link_class, value)
1361                 if self.do_journal and prop.do_journal:
1362                     # register the unlink with the old linked node
1363                     if node[propname] is not None:
1364                         self.db.addjournal(link_class, node[propname], 'unlink',
1365                             (self.classname, nodeid, propname))
1367                     # register the link with the newly linked node
1368                     if value is not None:
1369                         self.db.addjournal(link_class, value, 'link',
1370                             (self.classname, nodeid, propname))
1372             elif isinstance(prop, Multilink):
1373                 if type(value) != type([]):
1374                     raise TypeError, 'new property "%s" not a list of'\
1375                         ' ids'%propname
1376                 link_class = self.properties[propname].classname
1377                 l = []
1378                 for entry in value:
1379                     # if it isn't a number, it's a key
1380                     if type(entry) != type(''):
1381                         raise ValueError, 'new property "%s" link value ' \
1382                             'must be a string'%propname
1383                     if not num_re.match(entry):
1384                         try:
1385                             entry = self.db.classes[link_class].lookup(entry)
1386                         except (TypeError, KeyError):
1387                             raise IndexError, 'new property "%s": %s not a %s'%(
1388                                 propname, entry,
1389                                 self.properties[propname].classname)
1390                     l.append(entry)
1391                 value = l
1392                 propvalues[propname] = value
1394                 # figure the journal entry for this property
1395                 add = []
1396                 remove = []
1398                 # handle removals
1399                 if node.has_key(propname):
1400                     l = node[propname]
1401                 else:
1402                     l = []
1403                 for id in l[:]:
1404                     if id in value:
1405                         continue
1406                     # register the unlink with the old linked node
1407                     if self.do_journal and self.properties[propname].do_journal:
1408                         self.db.addjournal(link_class, id, 'unlink',
1409                             (self.classname, nodeid, propname))
1410                     l.remove(id)
1411                     remove.append(id)
1413                 # handle additions
1414                 for id in value:
1415                     if not self.db.getclass(link_class).hasnode(id):
1416                         raise IndexError, '%s has no node %s'%(link_class, id)
1417                     if id in l:
1418                         continue
1419                     # register the link with the newly linked node
1420                     if self.do_journal and self.properties[propname].do_journal:
1421                         self.db.addjournal(link_class, id, 'link',
1422                             (self.classname, nodeid, propname))
1423                     l.append(id)
1424                     add.append(id)
1426                 # figure the journal entry
1427                 l = []
1428                 if add:
1429                     l.append(('+', add))
1430                 if remove:
1431                     l.append(('-', remove))
1432                 multilink_changes[propname] = (add, remove)
1433                 if l:
1434                     journalvalues[propname] = tuple(l)
1436             elif isinstance(prop, String):
1437                 if value is not None and type(value) != type('') and type(value) != type(u''):
1438                     raise TypeError, 'new property "%s" not a string'%propname
1440             elif isinstance(prop, Password):
1441                 if not isinstance(value, password.Password):
1442                     raise TypeError, 'new property "%s" not a Password'%propname
1443                 propvalues[propname] = value
1445             elif value is not None and isinstance(prop, Date):
1446                 if not isinstance(value, date.Date):
1447                     raise TypeError, 'new property "%s" not a Date'% propname
1448                 propvalues[propname] = value
1450             elif value is not None and isinstance(prop, Interval):
1451                 if not isinstance(value, date.Interval):
1452                     raise TypeError, 'new property "%s" not an '\
1453                         'Interval'%propname
1454                 propvalues[propname] = value
1456             elif value is not None and isinstance(prop, Number):
1457                 try:
1458                     float(value)
1459                 except ValueError:
1460                     raise TypeError, 'new property "%s" not numeric'%propname
1462             elif value is not None and isinstance(prop, Boolean):
1463                 try:
1464                     int(value)
1465                 except ValueError:
1466                     raise TypeError, 'new property "%s" not boolean'%propname
1468         # nothing to do?
1469         if not propvalues:
1470             return propvalues
1472         # do the set, and journal it
1473         self.db.setnode(self.classname, nodeid, propvalues, multilink_changes)
1475         if self.do_journal:
1476             self.db.addjournal(self.classname, nodeid, 'set', journalvalues)
1478         self.fireReactors('set', nodeid, oldvalues)
1480         return propvalues        
1482     def retire(self, nodeid):
1483         '''Retire a node.
1484         
1485         The properties on the node remain available from the get() method,
1486         and the node's id is never reused.
1487         
1488         Retired nodes are not returned by the find(), list(), or lookup()
1489         methods, and other nodes may reuse the values of their key properties.
1490         '''
1491         if self.db.journaltag is None:
1492             raise DatabaseError, 'Database open read-only'
1494         self.fireAuditors('retire', nodeid, None)
1496         # use the arg for __retired__ to cope with any odd database type
1497         # conversion (hello, sqlite)
1498         sql = 'update _%s set __retired__=%s where id=%s'%(self.classname,
1499             self.db.arg, self.db.arg)
1500         if __debug__:
1501             print >>hyperdb.DEBUG, 'retire', (self, sql, nodeid)
1502         self.db.cursor.execute(sql, (1, nodeid))
1504         self.fireReactors('retire', nodeid, None)
1506     def is_retired(self, nodeid):
1507         '''Return true if the node is rerired
1508         '''
1509         sql = 'select __retired__ from _%s where id=%s'%(self.classname,
1510             self.db.arg)
1511         if __debug__:
1512             print >>hyperdb.DEBUG, 'is_retired', (self, sql, nodeid)
1513         self.db.cursor.execute(sql, (nodeid,))
1514         return int(self.db.sql_fetchone()[0])
1516     def destroy(self, nodeid):
1517         '''Destroy a node.
1518         
1519         WARNING: this method should never be used except in extremely rare
1520                  situations where there could never be links to the node being
1521                  deleted
1522         WARNING: use retire() instead
1523         WARNING: the properties of this node will not be available ever again
1524         WARNING: really, use retire() instead
1526         Well, I think that's enough warnings. This method exists mostly to
1527         support the session storage of the cgi interface.
1529         The node is completely removed from the hyperdb, including all journal
1530         entries. It will no longer be available, and will generally break code
1531         if there are any references to the node.
1532         '''
1533         if self.db.journaltag is None:
1534             raise DatabaseError, 'Database open read-only'
1535         self.db.destroynode(self.classname, nodeid)
1537     def history(self, nodeid):
1538         '''Retrieve the journal of edits on a particular node.
1540         'nodeid' must be the id of an existing node of this class or an
1541         IndexError is raised.
1543         The returned list contains tuples of the form
1545             (nodeid, date, tag, action, params)
1547         'date' is a Timestamp object specifying the time of the change and
1548         'tag' is the journaltag specified when the database was opened.
1549         '''
1550         if not self.do_journal:
1551             raise ValueError, 'Journalling is disabled for this class'
1552         return self.db.getjournal(self.classname, nodeid)
1554     # Locating nodes:
1555     def hasnode(self, nodeid):
1556         '''Determine if the given nodeid actually exists
1557         '''
1558         return self.db.hasnode(self.classname, nodeid)
1560     def setkey(self, propname):
1561         '''Select a String property of this class to be the key property.
1563         'propname' must be the name of a String property of this class or
1564         None, or a TypeError is raised.  The values of the key property on
1565         all existing nodes must be unique or a ValueError is raised.
1566         '''
1567         # XXX create an index on the key prop column
1568         prop = self.getprops()[propname]
1569         if not isinstance(prop, String):
1570             raise TypeError, 'key properties must be String'
1571         self.key = propname
1573     def getkey(self):
1574         '''Return the name of the key property for this class or None.'''
1575         return self.key
1577     def labelprop(self, default_to_id=0):
1578         ''' Return the property name for a label for the given node.
1580         This method attempts to generate a consistent label for the node.
1581         It tries the following in order:
1582             1. key property
1583             2. "name" property
1584             3. "title" property
1585             4. first property from the sorted property name list
1586         '''
1587         k = self.getkey()
1588         if  k:
1589             return k
1590         props = self.getprops()
1591         if props.has_key('name'):
1592             return 'name'
1593         elif props.has_key('title'):
1594             return 'title'
1595         if default_to_id:
1596             return 'id'
1597         props = props.keys()
1598         props.sort()
1599         return props[0]
1601     def lookup(self, keyvalue):
1602         '''Locate a particular node by its key property and return its id.
1604         If this class has no key property, a TypeError is raised.  If the
1605         'keyvalue' matches one of the values for the key property among
1606         the nodes in this class, the matching node's id is returned;
1607         otherwise a KeyError is raised.
1608         '''
1609         if not self.key:
1610             raise TypeError, 'No key property set for class %s'%self.classname
1612         # use the arg to handle any odd database type conversion (hello,
1613         # sqlite)
1614         sql = "select id from _%s where _%s=%s and __retired__ <> %s"%(
1615             self.classname, self.key, self.db.arg, self.db.arg)
1616         self.db.sql(sql, (keyvalue, 1))
1618         # see if there was a result that's not retired
1619         row = self.db.sql_fetchone()
1620         if not row:
1621             raise KeyError, 'No key (%s) value "%s" for "%s"'%(self.key,
1622                 keyvalue, self.classname)
1624         # return the id
1625         return row[0]
1627     def find(self, **propspec):
1628         '''Get the ids of nodes in this class which link to the given nodes.
1630         'propspec' consists of keyword args propname=nodeid or
1631                    propname={nodeid:1, }
1632         'propname' must be the name of a property in this class, or a
1633         KeyError is raised.  That property must be a Link or Multilink
1634         property, or a TypeError is raised.
1636         Any node in this class whose 'propname' property links to any of the
1637         nodeids will be returned. Used by the full text indexing, which knows
1638         that "foo" occurs in msg1, msg3 and file7, so we have hits on these
1639         issues:
1641             db.issue.find(messages={'1':1,'3':1}, files={'7':1})
1642         '''
1643         if __debug__:
1644             print >>hyperdb.DEBUG, 'find', (self, propspec)
1646         # shortcut
1647         if not propspec:
1648             return []
1650         # validate the args
1651         props = self.getprops()
1652         propspec = propspec.items()
1653         for propname, nodeids in propspec:
1654             # check the prop is OK
1655             prop = props[propname]
1656             if not isinstance(prop, Link) and not isinstance(prop, Multilink):
1657                 raise TypeError, "'%s' not a Link/Multilink property"%propname
1659         # first, links
1660         where = []
1661         allvalues = ()
1662         a = self.db.arg
1663         for prop, values in propspec:
1664             if not isinstance(props[prop], hyperdb.Link):
1665                 continue
1666             if type(values) is type(''):
1667                 allvalues += (values,)
1668                 where.append('_%s = %s'%(prop, a))
1669             else:
1670                 allvalues += tuple(values.keys())
1671                 where.append('_%s in (%s)'%(prop, ','.join([a]*len(values))))
1672         tables = []
1673         if where:
1674             tables.append('select id as nodeid from _%s where %s'%(
1675                 self.classname, ' and '.join(where)))
1677         # now multilinks
1678         for prop, values in propspec:
1679             if not isinstance(props[prop], hyperdb.Multilink):
1680                 continue
1681             if type(values) is type(''):
1682                 allvalues += (values,)
1683                 s = a
1684             else:
1685                 allvalues += tuple(values.keys())
1686                 s = ','.join([a]*len(values))
1687             tables.append('select nodeid from %s_%s where linkid in (%s)'%(
1688                 self.classname, prop, s))
1689         sql = '\nunion\n'.join(tables)
1690         self.db.sql(sql, allvalues)
1691         l = [x[0] for x in self.db.sql_fetchall()]
1692         if __debug__:
1693             print >>hyperdb.DEBUG, 'find ... ', l
1694         return l
1696     def stringFind(self, **requirements):
1697         '''Locate a particular node by matching a set of its String
1698         properties in a caseless search.
1700         If the property is not a String property, a TypeError is raised.
1701         
1702         The return is a list of the id of all nodes that match.
1703         '''
1704         where = []
1705         args = []
1706         for propname in requirements.keys():
1707             prop = self.properties[propname]
1708             if isinstance(not prop, String):
1709                 raise TypeError, "'%s' not a String property"%propname
1710             where.append(propname)
1711             args.append(requirements[propname].lower())
1713         # generate the where clause
1714         s = ' and '.join(['_%s=%s'%(col, self.db.arg) for col in where])
1715         sql = 'select id from _%s where %s'%(self.classname, s)
1716         self.db.sql(sql, tuple(args))
1717         l = [x[0] for x in self.db.sql_fetchall()]
1718         if __debug__:
1719             print >>hyperdb.DEBUG, 'find ... ', l
1720         return l
1722     def list(self):
1723         ''' Return a list of the ids of the active nodes in this class.
1724         '''
1725         return self.db.getnodeids(self.classname, retired=0)
1727     def filter(self, search_matches, filterspec, sort=(None,None),
1728             group=(None,None)):
1729         ''' Return a list of the ids of the active nodes in this class that
1730             match the 'filter' spec, sorted by the group spec and then the
1731             sort spec
1733             "filterspec" is {propname: value(s)}
1734             "sort" and "group" are (dir, prop) where dir is '+', '-' or None
1735                                and prop is a prop name or None
1736             "search_matches" is {nodeid: marker}
1738             The filter must match all properties specificed - but if the
1739             property value to match is a list, any one of the values in the
1740             list may match for that property to match.
1741         '''
1742         # just don't bother if the full-text search matched diddly
1743         if search_matches == {}:
1744             return []
1746         cn = self.classname
1748         # figure the WHERE clause from the filterspec
1749         props = self.getprops()
1750         frum = ['_'+cn]
1751         where = []
1752         args = []
1753         a = self.db.arg
1754         for k, v in filterspec.items():
1755             propclass = props[k]
1756             # now do other where clause stuff
1757             if isinstance(propclass, Multilink):
1758                 tn = '%s_%s'%(cn, k)
1759                 frum.append(tn)
1760                 if isinstance(v, type([])):
1761                     s = ','.join([a for x in v])
1762                     where.append('id=%s.nodeid and %s.linkid in (%s)'%(tn,tn,s))
1763                     args = args + v
1764                 else:
1765                     where.append('id=%s.nodeid and %s.linkid = %s'%(tn, tn, a))
1766                     args.append(v)
1767             elif k == 'id':
1768                 if isinstance(v, type([])):
1769                     s = ','.join([a for x in v])
1770                     where.append('%s in (%s)'%(k, s))
1771                     args = args + v
1772                 else:
1773                     where.append('%s=%s'%(k, a))
1774                     args.append(v)
1775             elif isinstance(propclass, String):
1776                 if not isinstance(v, type([])):
1777                     v = [v]
1779                 # Quote the bits in the string that need it and then embed
1780                 # in a "substring" search. Note - need to quote the '%' so
1781                 # they make it through the python layer happily
1782                 v = ['%%'+self.db.sql_stringquote(s)+'%%' for s in v]
1784                 # now add to the where clause
1785                 where.append(' or '.join(["_%s LIKE '%s'"%(k, s) for s in v]))
1786                 # note: args are embedded in the query string now
1787             elif isinstance(propclass, Link):
1788                 if isinstance(v, type([])):
1789                     if '-1' in v:
1790                         v.remove('-1')
1791                         xtra = ' or _%s is NULL'%k
1792                     else:
1793                         xtra = ''
1794                     if v:
1795                         s = ','.join([a for x in v])
1796                         where.append('(_%s in (%s)%s)'%(k, s, xtra))
1797                         args = args + v
1798                     else:
1799                         where.append('_%s is NULL'%k)
1800                 else:
1801                     if v == '-1':
1802                         v = None
1803                         where.append('_%s is NULL'%k)
1804                     else:
1805                         where.append('_%s=%s'%(k, a))
1806                         args.append(v)
1807             elif isinstance(propclass, Date):
1808                 if isinstance(v, type([])):
1809                     s = ','.join([a for x in v])
1810                     where.append('_%s in (%s)'%(k, s))
1811                     args = args + [date.Date(x).serialise() for x in v]
1812                 else:
1813                     where.append('_%s=%s'%(k, a))
1814                     args.append(date.Date(v).serialise())
1815             elif isinstance(propclass, Interval):
1816                 if isinstance(v, type([])):
1817                     s = ','.join([a for x in v])
1818                     where.append('_%s in (%s)'%(k, s))
1819                     args = args + [date.Interval(x).serialise() for x in v]
1820                 else:
1821                     where.append('_%s=%s'%(k, a))
1822                     args.append(date.Interval(v).serialise())
1823             else:
1824                 if isinstance(v, type([])):
1825                     s = ','.join([a for x in v])
1826                     where.append('_%s in (%s)'%(k, s))
1827                     args = args + v
1828                 else:
1829                     where.append('_%s=%s'%(k, a))
1830                     args.append(v)
1832         # add results of full text search
1833         if search_matches is not None:
1834             v = search_matches.keys()
1835             s = ','.join([a for x in v])
1836             where.append('id in (%s)'%s)
1837             args = args + v
1839         # "grouping" is just the first-order sorting in the SQL fetch
1840         # can modify it...)
1841         orderby = []
1842         ordercols = []
1843         if group[0] is not None and group[1] is not None:
1844             if group[0] != '-':
1845                 orderby.append('_'+group[1])
1846                 ordercols.append('_'+group[1])
1847             else:
1848                 orderby.append('_'+group[1]+' desc')
1849                 ordercols.append('_'+group[1])
1851         # now add in the sorting
1852         group = ''
1853         if sort[0] is not None and sort[1] is not None:
1854             direction, colname = sort
1855             if direction != '-':
1856                 if colname == 'id':
1857                     orderby.append(colname)
1858                 else:
1859                     orderby.append('_'+colname)
1860                     ordercols.append('_'+colname)
1861             else:
1862                 if colname == 'id':
1863                     orderby.append(colname+' desc')
1864                     ordercols.append(colname)
1865                 else:
1866                     orderby.append('_'+colname+' desc')
1867                     ordercols.append('_'+colname)
1869         # construct the SQL
1870         frum = ','.join(frum)
1871         if where:
1872             where = ' where ' + (' and '.join(where))
1873         else:
1874             where = ''
1875         cols = ['id']
1876         if orderby:
1877             cols = cols + ordercols
1878             order = ' order by %s'%(','.join(orderby))
1879         else:
1880             order = ''
1881         cols = ','.join(cols)
1882         sql = 'select %s from %s %s%s%s'%(cols, frum, where, group, order)
1883         args = tuple(args)
1884         if __debug__:
1885             print >>hyperdb.DEBUG, 'filter', (self, sql, args)
1886         self.db.cursor.execute(sql, args)
1887         l = self.db.cursor.fetchall()
1889         # return the IDs (the first column)
1890         # XXX The filter(None, l) bit is sqlite-specific... if there's _NO_
1891         # XXX matches to a fetch, it returns NULL instead of nothing!?!
1892         return filter(None, [row[0] for row in l])
1894     def count(self):
1895         '''Get the number of nodes in this class.
1897         If the returned integer is 'numnodes', the ids of all the nodes
1898         in this class run from 1 to numnodes, and numnodes+1 will be the
1899         id of the next node to be created in this class.
1900         '''
1901         return self.db.countnodes(self.classname)
1903     # Manipulating properties:
1904     def getprops(self, protected=1):
1905         '''Return a dictionary mapping property names to property objects.
1906            If the "protected" flag is true, we include protected properties -
1907            those which may not be modified.
1908         '''
1909         d = self.properties.copy()
1910         if protected:
1911             d['id'] = String()
1912             d['creation'] = hyperdb.Date()
1913             d['activity'] = hyperdb.Date()
1914             d['creator'] = hyperdb.Link('user')
1915         return d
1917     def addprop(self, **properties):
1918         '''Add properties to this class.
1920         The keyword arguments in 'properties' must map names to property
1921         objects, or a TypeError is raised.  None of the keys in 'properties'
1922         may collide with the names of existing properties, or a ValueError
1923         is raised before any properties have been added.
1924         '''
1925         for key in properties.keys():
1926             if self.properties.has_key(key):
1927                 raise ValueError, key
1928         self.properties.update(properties)
1930     def index(self, nodeid):
1931         '''Add (or refresh) the node to search indexes
1932         '''
1933         # find all the String properties that have indexme
1934         for prop, propclass in self.getprops().items():
1935             if isinstance(propclass, String) and propclass.indexme:
1936                 try:
1937                     value = str(self.get(nodeid, prop))
1938                 except IndexError:
1939                     # node no longer exists - entry should be removed
1940                     self.db.indexer.purge_entry((self.classname, nodeid, prop))
1941                 else:
1942                     # and index them under (classname, nodeid, property)
1943                     self.db.indexer.add_text((self.classname, nodeid, prop),
1944                         value)
1947     #
1948     # Detector interface
1949     #
1950     def audit(self, event, detector):
1951         '''Register a detector
1952         '''
1953         l = self.auditors[event]
1954         if detector not in l:
1955             self.auditors[event].append(detector)
1957     def fireAuditors(self, action, nodeid, newvalues):
1958         '''Fire all registered auditors.
1959         '''
1960         for audit in self.auditors[action]:
1961             audit(self.db, self, nodeid, newvalues)
1963     def react(self, event, detector):
1964         '''Register a detector
1965         '''
1966         l = self.reactors[event]
1967         if detector not in l:
1968             self.reactors[event].append(detector)
1970     def fireReactors(self, action, nodeid, oldvalues):
1971         '''Fire all registered reactors.
1972         '''
1973         for react in self.reactors[action]:
1974             react(self.db, self, nodeid, oldvalues)
1976 class FileClass(Class):
1977     '''This class defines a large chunk of data. To support this, it has a
1978        mandatory String property "content" which is typically saved off
1979        externally to the hyperdb.
1981        The default MIME type of this data is defined by the
1982        "default_mime_type" class attribute, which may be overridden by each
1983        node if the class defines a "type" String property.
1984     '''
1985     default_mime_type = 'text/plain'
1987     def create(self, **propvalues):
1988         ''' snaffle the file propvalue and store in a file
1989         '''
1990         content = propvalues['content']
1991         del propvalues['content']
1992         newid = Class.create(self, **propvalues)
1993         self.db.storefile(self.classname, newid, None, content)
1994         return newid
1996     def import_list(self, propnames, proplist):
1997         ''' Trap the "content" property...
1998         '''
1999         # dupe this list so we don't affect others
2000         propnames = propnames[:]
2002         # extract the "content" property from the proplist
2003         i = propnames.index('content')
2004         content = eval(proplist[i])
2005         del propnames[i]
2006         del proplist[i]
2008         # do the normal import
2009         newid = Class.import_list(self, propnames, proplist)
2011         # save off the "content" file
2012         self.db.storefile(self.classname, newid, None, content)
2013         return newid
2015     _marker = []
2016     def get(self, nodeid, propname, default=_marker, cache=1):
2017         ''' trap the content propname and get it from the file
2018         '''
2020         poss_msg = 'Possibly a access right configuration problem.'
2021         if propname == 'content':
2022             try:
2023                 return self.db.getfile(self.classname, nodeid, None)
2024             except IOError, (strerror):
2025                 # BUG: by catching this we donot see an error in the log.
2026                 return 'ERROR reading file: %s%s\n%s\n%s'%(
2027                         self.classname, nodeid, poss_msg, strerror)
2028         if default is not self._marker:
2029             return Class.get(self, nodeid, propname, default, cache=cache)
2030         else:
2031             return Class.get(self, nodeid, propname, cache=cache)
2033     def getprops(self, protected=1):
2034         ''' In addition to the actual properties on the node, these methods
2035             provide the "content" property. If the "protected" flag is true,
2036             we include protected properties - those which may not be
2037             modified.
2038         '''
2039         d = Class.getprops(self, protected=protected).copy()
2040         d['content'] = hyperdb.String()
2041         return d
2043     def index(self, nodeid):
2044         ''' Index the node in the search index.
2046             We want to index the content in addition to the normal String
2047             property indexing.
2048         '''
2049         # perform normal indexing
2050         Class.index(self, nodeid)
2052         # get the content to index
2053         content = self.get(nodeid, 'content')
2055         # figure the mime type
2056         if self.properties.has_key('type'):
2057             mime_type = self.get(nodeid, 'type')
2058         else:
2059             mime_type = self.default_mime_type
2061         # and index!
2062         self.db.indexer.add_text((self.classname, nodeid, 'content'), content,
2063             mime_type)
2065 # XXX deviation from spec - was called ItemClass
2066 class IssueClass(Class, roundupdb.IssueClass):
2067     # Overridden methods:
2068     def __init__(self, db, classname, **properties):
2069         '''The newly-created class automatically includes the "messages",
2070         "files", "nosy", and "superseder" properties.  If the 'properties'
2071         dictionary attempts to specify any of these properties or a
2072         "creation" or "activity" property, a ValueError is raised.
2073         '''
2074         if not properties.has_key('title'):
2075             properties['title'] = hyperdb.String(indexme='yes')
2076         if not properties.has_key('messages'):
2077             properties['messages'] = hyperdb.Multilink("msg")
2078         if not properties.has_key('files'):
2079             properties['files'] = hyperdb.Multilink("file")
2080         if not properties.has_key('nosy'):
2081             # note: journalling is turned off as it really just wastes
2082             # space. this behaviour may be overridden in an instance
2083             properties['nosy'] = hyperdb.Multilink("user", do_journal="no")
2084         if not properties.has_key('superseder'):
2085             properties['superseder'] = hyperdb.Multilink(classname)
2086         Class.__init__(self, db, classname, **properties)