Code

include detectors in distro
[roundup.git] / roundup / backends / rdbms_common.py
1 # $Id: rdbms_common.py,v 1.35 2003-02-25 10:19:32 richard 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, Node
31 from roundup.backends import locking
33 # support
34 from blobfiles import FileStorage
35 from roundup.indexer import Indexer
36 from sessions import Sessions, OneTimeKeys
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.otks = OneTimeKeys(self.config)
57         self.security = security.Security(self)
59         # additional transaction support for external files and the like
60         self.transactions = []
62         # keep a cache of the N most recently retrieved rows of any kind
63         # (classname, nodeid) = row
64         self.cache = {}
65         self.cache_lru = []
67         # database lock
68         self.lockfile = None
70         # open a connection to the database, creating the "conn" attribute
71         self.open_connection()
73     def clearCache(self):
74         self.cache = {}
75         self.cache_lru = []
77     def open_connection(self):
78         ''' Open a connection to the database, creating it if necessary
79         '''
80         raise NotImplemented
82     def sql(self, sql, args=None):
83         ''' Execute the sql with the optional args.
84         '''
85         if __debug__:
86             print >>hyperdb.DEBUG, (self, sql, args)
87         if args:
88             self.cursor.execute(sql, args)
89         else:
90             self.cursor.execute(sql)
92     def sql_fetchone(self):
93         ''' Fetch a single row. If there's nothing to fetch, return None.
94         '''
95         raise NotImplemented
97     def sql_stringquote(self, value):
98         ''' Quote the string so it's safe to put in the 'sql quotes'
99         '''
100         return re.sub("'", "''", str(value))
102     def save_dbschema(self, schema):
103         ''' Save the schema definition that the database currently implements
104         '''
105         raise NotImplemented
107     def load_dbschema(self):
108         ''' Load the schema definition that the database currently implements
109         '''
110         raise NotImplemented
112     def post_init(self):
113         ''' Called once the schema initialisation has finished.
115             We should now confirm that the schema defined by our "classes"
116             attribute actually matches the schema in the database.
117         '''
118         # now detect changes in the schema
119         save = 0
120         for classname, spec in self.classes.items():
121             if self.database_schema.has_key(classname):
122                 dbspec = self.database_schema[classname]
123                 if self.update_class(spec, dbspec):
124                     self.database_schema[classname] = spec.schema()
125                     save = 1
126             else:
127                 self.create_class(spec)
128                 self.database_schema[classname] = spec.schema()
129                 save = 1
131         for classname in self.database_schema.keys():
132             if not self.classes.has_key(classname):
133                 self.drop_class(classname)
135         # update the database version of the schema
136         if save:
137             self.sql('delete from schema')
138             self.save_dbschema(self.database_schema)
140         # reindex the db if necessary
141         if self.indexer.should_reindex():
142             self.reindex()
144         # commit
145         self.conn.commit()
147         # figure the "curuserid"
148         if self.journaltag is None:
149             self.curuserid = None
150         elif self.journaltag == 'admin':
151             # admin user may not exist, but always has ID 1
152             self.curuserid = '1'
153         else:
154             self.curuserid = self.user.lookup(self.journaltag)
156     def reindex(self):
157         for klass in self.classes.values():
158             for nodeid in klass.list():
159                 klass.index(nodeid)
160         self.indexer.save_index()
162     def determine_columns(self, properties):
163         ''' Figure the column names and multilink properties from the spec
165             "properties" is a list of (name, prop) where prop may be an
166             instance of a hyperdb "type" _or_ a string repr of that type.
167         '''
168         cols = ['_activity', '_creator', '_creation']
169         mls = []
170         # add the multilinks separately
171         for col, prop in properties:
172             if isinstance(prop, Multilink):
173                 mls.append(col)
174             elif isinstance(prop, type('')) and prop.find('Multilink') != -1:
175                 mls.append(col)
176             else:
177                 cols.append('_'+col)
178         cols.sort()
179         return cols, mls
181     def update_class(self, spec, dbspec):
182         ''' Determine the differences between the current spec and the
183             database version of the spec, and update where necessary
184         '''
185         spec_schema = spec.schema()
186         if spec_schema == dbspec:
187             # no save needed for this one
188             return 0
189         if __debug__:
190             print >>hyperdb.DEBUG, 'update_class FIRING'
192         # key property changed?
193         if dbspec[0] != spec_schema[0]:
194             if __debug__:
195                 print >>hyperdb.DEBUG, 'update_class setting keyprop', `spec[0]`
196             # XXX turn on indexing for the key property
198         # dict 'em up
199         spec_propnames,spec_props = [],{}
200         for propname,prop in spec_schema[1]:
201             spec_propnames.append(propname)
202             spec_props[propname] = prop
203         dbspec_propnames,dbspec_props = [],{}
204         for propname,prop in dbspec[1]:
205             dbspec_propnames.append(propname)
206             dbspec_props[propname] = prop
208         # now compare
209         for propname in spec_propnames:
210             prop = spec_props[propname]
211             if dbspec_props.has_key(propname) and prop==dbspec_props[propname]:
212                 continue
213             if __debug__:
214                 print >>hyperdb.DEBUG, 'update_class ADD', (propname, prop)
216             if not dbspec_props.has_key(propname):
217                 # add the property
218                 if isinstance(prop, Multilink):
219                     # all we have to do here is create a new table, easy!
220                     self.create_multilink_table(spec, propname)
221                     continue
223                 # no ALTER TABLE, so we:
224                 # 1. pull out the data, including an extra None column
225                 oldcols, x = self.determine_columns(dbspec[1])
226                 oldcols.append('id')
227                 oldcols.append('__retired__')
228                 cn = spec.classname
229                 sql = 'select %s,%s from _%s'%(','.join(oldcols), self.arg, cn)
230                 if __debug__:
231                     print >>hyperdb.DEBUG, 'update_class', (self, sql, None)
232                 self.cursor.execute(sql, (None,))
233                 olddata = self.cursor.fetchall()
235                 # 2. drop the old table
236                 self.cursor.execute('drop table _%s'%cn)
238                 # 3. create the new table
239                 cols, mls = self.create_class_table(spec)
240                 # ensure the new column is last
241                 cols.remove('_'+propname)
242                 assert oldcols == cols, "Column lists don't match!"
243                 cols.append('_'+propname)
245                 # 4. populate with the data from step one
246                 s = ','.join([self.arg for x in cols])
247                 scols = ','.join(cols)
248                 sql = 'insert into _%s (%s) values (%s)'%(cn, scols, s)
250                 # GAH, nothing had better go wrong from here on in... but
251                 # we have to commit the drop...
252                 # XXX this isn't necessary in sqlite :(
253                 self.conn.commit()
255                 # do the insert
256                 for row in olddata:
257                     self.sql(sql, tuple(row))
259             else:
260                 # modify the property
261                 if __debug__:
262                     print >>hyperdb.DEBUG, 'update_class NOOP'
263                 pass  # NOOP in gadfly
265         # and the other way - only worry about deletions here
266         for propname in dbspec_propnames:
267             prop = dbspec_props[propname]
268             if spec_props.has_key(propname):
269                 continue
270             if __debug__:
271                 print >>hyperdb.DEBUG, 'update_class REMOVE', `prop`
273             # delete the property
274             if isinstance(prop, Multilink):
275                 sql = 'drop table %s_%s'%(spec.classname, prop)
276                 if __debug__:
277                     print >>hyperdb.DEBUG, 'update_class', (self, sql)
278                 self.cursor.execute(sql)
279             else:
280                 # no ALTER TABLE, so we:
281                 # 1. pull out the data, excluding the removed column
282                 oldcols, x = self.determine_columns(spec.properties.items())
283                 oldcols.append('id')
284                 oldcols.append('__retired__')
285                 # remove the missing column
286                 oldcols.remove('_'+propname)
287                 cn = spec.classname
288                 sql = 'select %s from _%s'%(','.join(oldcols), cn)
289                 self.cursor.execute(sql, (None,))
290                 olddata = sql.fetchall()
292                 # 2. drop the old table
293                 self.cursor.execute('drop table _%s'%cn)
295                 # 3. create the new table
296                 cols, mls = self.create_class_table(self, spec)
297                 assert oldcols != cols, "Column lists don't match!"
299                 # 4. populate with the data from step one
300                 qs = ','.join([self.arg for x in cols])
301                 sql = 'insert into _%s values (%s)'%(cn, s)
302                 self.cursor.execute(sql, olddata)
303         return 1
305     def create_class_table(self, spec):
306         ''' create the class table for the given spec
307         '''
308         cols, mls = self.determine_columns(spec.properties.items())
310         # add on our special columns
311         cols.append('id')
312         cols.append('__retired__')
314         # create the base table
315         scols = ','.join(['%s varchar'%x for x in cols])
316         sql = 'create table _%s (%s)'%(spec.classname, scols)
317         if __debug__:
318             print >>hyperdb.DEBUG, 'create_class', (self, sql)
319         self.cursor.execute(sql)
321         return cols, mls
323     def create_journal_table(self, spec):
324         ''' create the journal table for a class given the spec and 
325             already-determined cols
326         '''
327         # journal table
328         cols = ','.join(['%s varchar'%x
329             for x in 'nodeid date tag action params'.split()])
330         sql = 'create table %s__journal (%s)'%(spec.classname, cols)
331         if __debug__:
332             print >>hyperdb.DEBUG, 'create_class', (self, sql)
333         self.cursor.execute(sql)
335     def create_multilink_table(self, spec, ml):
336         ''' Create a multilink table for the "ml" property of the class
337             given by the spec
338         '''
339         sql = 'create table %s_%s (linkid varchar, nodeid varchar)'%(
340             spec.classname, ml)
341         if __debug__:
342             print >>hyperdb.DEBUG, 'create_class', (self, sql)
343         self.cursor.execute(sql)
345     def create_class(self, spec):
346         ''' Create a database table according to the given spec.
347         '''
348         cols, mls = self.create_class_table(spec)
349         self.create_journal_table(spec)
351         # now create the multilink tables
352         for ml in mls:
353             self.create_multilink_table(spec, ml)
355         # ID counter
356         sql = 'insert into ids (name, num) values (%s,%s)'%(self.arg, self.arg)
357         vals = (spec.classname, 1)
358         if __debug__:
359             print >>hyperdb.DEBUG, 'create_class', (self, sql, vals)
360         self.cursor.execute(sql, vals)
362     def drop_class(self, spec):
363         ''' Drop the given table from the database.
365             Drop the journal and multilink tables too.
366         '''
367         # figure the multilinks
368         mls = []
369         for col, prop in spec.properties.items():
370             if isinstance(prop, Multilink):
371                 mls.append(col)
373         sql = 'drop table _%s'%spec.classname
374         if __debug__:
375             print >>hyperdb.DEBUG, 'drop_class', (self, sql)
376         self.cursor.execute(sql)
378         sql = 'drop table %s__journal'%spec.classname
379         if __debug__:
380             print >>hyperdb.DEBUG, 'drop_class', (self, sql)
381         self.cursor.execute(sql)
383         for ml in mls:
384             sql = 'drop table %s_%s'%(spec.classname, ml)
385             if __debug__:
386                 print >>hyperdb.DEBUG, 'drop_class', (self, sql)
387             self.cursor.execute(sql)
389     #
390     # Classes
391     #
392     def __getattr__(self, classname):
393         ''' A convenient way of calling self.getclass(classname).
394         '''
395         if self.classes.has_key(classname):
396             if __debug__:
397                 print >>hyperdb.DEBUG, '__getattr__', (self, classname)
398             return self.classes[classname]
399         raise AttributeError, classname
401     def addclass(self, cl):
402         ''' Add a Class to the hyperdatabase.
403         '''
404         if __debug__:
405             print >>hyperdb.DEBUG, 'addclass', (self, cl)
406         cn = cl.classname
407         if self.classes.has_key(cn):
408             raise ValueError, cn
409         self.classes[cn] = cl
411     def getclasses(self):
412         ''' Return a list of the names of all existing classes.
413         '''
414         if __debug__:
415             print >>hyperdb.DEBUG, 'getclasses', (self,)
416         l = self.classes.keys()
417         l.sort()
418         return l
420     def getclass(self, classname):
421         '''Get the Class object representing a particular class.
423         If 'classname' is not a valid class name, a KeyError is raised.
424         '''
425         if __debug__:
426             print >>hyperdb.DEBUG, 'getclass', (self, classname)
427         try:
428             return self.classes[classname]
429         except KeyError:
430             raise KeyError, 'There is no class called "%s"'%classname
432     def clear(self):
433         ''' Delete all database contents.
435             Note: I don't commit here, which is different behaviour to the
436             "nuke from orbit" behaviour in the *dbms.
437         '''
438         if __debug__:
439             print >>hyperdb.DEBUG, 'clear', (self,)
440         for cn in self.classes.keys():
441             sql = 'delete from _%s'%cn
442             if __debug__:
443                 print >>hyperdb.DEBUG, 'clear', (self, sql)
444             self.cursor.execute(sql)
446     #
447     # Node IDs
448     #
449     def newid(self, classname):
450         ''' Generate a new id for the given class
451         '''
452         # get the next ID
453         sql = 'select num from ids where name=%s'%self.arg
454         if __debug__:
455             print >>hyperdb.DEBUG, 'newid', (self, sql, classname)
456         self.cursor.execute(sql, (classname, ))
457         newid = self.cursor.fetchone()[0]
459         # update the counter
460         sql = 'update ids set num=%s where name=%s'%(self.arg, self.arg)
461         vals = (int(newid)+1, classname)
462         if __debug__:
463             print >>hyperdb.DEBUG, 'newid', (self, sql, vals)
464         self.cursor.execute(sql, vals)
466         # return as string
467         return str(newid)
469     def setid(self, classname, setid):
470         ''' Set the id counter: used during import of database
471         '''
472         sql = 'update ids set num=%s where name=%s'%(self.arg, self.arg)
473         vals = (setid, classname)
474         if __debug__:
475             print >>hyperdb.DEBUG, 'setid', (self, sql, vals)
476         self.cursor.execute(sql, vals)
478     #
479     # Nodes
480     #
482     def addnode(self, classname, nodeid, node):
483         ''' Add the specified node to its class's db.
484         '''
485         if __debug__:
486             print >>hyperdb.DEBUG, 'addnode', (self, classname, nodeid, node)
487         # gadfly requires values for all non-multilink columns
488         cl = self.classes[classname]
489         cols, mls = self.determine_columns(cl.properties.items())
491         # we'll be supplied these props if we're doing an import
492         if not node.has_key('creator'):
493             # add in the "calculated" properties (dupe so we don't affect
494             # calling code's node assumptions)
495             node = node.copy()
496             node['creation'] = node['activity'] = date.Date()
497             node['creator'] = self.curuserid
499         # default the non-multilink columns
500         for col, prop in cl.properties.items():
501             if not isinstance(col, Multilink):
502                 if not node.has_key(col):
503                     node[col] = None
505         # clear this node out of the cache if it's in there
506         key = (classname, nodeid)
507         if self.cache.has_key(key):
508             del self.cache[key]
509             self.cache_lru.remove(key)
511         # make the node data safe for the DB
512         node = self.serialise(classname, node)
514         # make sure the ordering is correct for column name -> column value
515         vals = tuple([node[col[1:]] for col in cols]) + (nodeid, 0)
516         s = ','.join([self.arg for x in cols]) + ',%s,%s'%(self.arg, self.arg)
517         cols = ','.join(cols) + ',id,__retired__'
519         # perform the inserts
520         sql = 'insert into _%s (%s) values (%s)'%(classname, cols, s)
521         if __debug__:
522             print >>hyperdb.DEBUG, 'addnode', (self, sql, vals)
523         self.cursor.execute(sql, vals)
525         # insert the multilink rows
526         for col in mls:
527             t = '%s_%s'%(classname, col)
528             for entry in node[col]:
529                 sql = 'insert into %s (linkid, nodeid) values (%s,%s)'%(t,
530                     self.arg, self.arg)
531                 self.sql(sql, (entry, nodeid))
533         # make sure we do the commit-time extra stuff for this node
534         self.transactions.append((self.doSaveNode, (classname, nodeid, node)))
536     def setnode(self, classname, nodeid, values, multilink_changes):
537         ''' Change the specified node.
538         '''
539         if __debug__:
540             print >>hyperdb.DEBUG, 'setnode', (self, classname, nodeid, values)
542         # clear this node out of the cache if it's in there
543         key = (classname, nodeid)
544         if self.cache.has_key(key):
545             del self.cache[key]
546             self.cache_lru.remove(key)
548         # add the special props
549         values = values.copy()
550         values['activity'] = date.Date()
552         # make db-friendly
553         values = self.serialise(classname, values)
555         cl = self.classes[classname]
556         cols = []
557         mls = []
558         # add the multilinks separately
559         props = cl.getprops()
560         for col in values.keys():
561             prop = props[col]
562             if isinstance(prop, Multilink):
563                 mls.append(col)
564             else:
565                 cols.append('_'+col)
566         cols.sort()
568         # if there's any updates to regular columns, do them
569         if cols:
570             # make sure the ordering is correct for column name -> column value
571             sqlvals = tuple([values[col[1:]] for col in cols]) + (nodeid,)
572             s = ','.join(['%s=%s'%(x, self.arg) for x in cols])
573             cols = ','.join(cols)
575             # perform the update
576             sql = 'update _%s set %s where id=%s'%(classname, s, self.arg)
577             if __debug__:
578                 print >>hyperdb.DEBUG, 'setnode', (self, sql, sqlvals)
579             self.cursor.execute(sql, sqlvals)
581         # now the fun bit, updating the multilinks ;)
582         for col, (add, remove) in multilink_changes.items():
583             tn = '%s_%s'%(classname, col)
584             if add:
585                 sql = 'insert into %s (nodeid, linkid) values (%s,%s)'%(tn,
586                     self.arg, self.arg)
587                 for addid in add:
588                     self.sql(sql, (nodeid, addid))
589             if remove:
590                 sql = 'delete from %s where nodeid=%s and linkid=%s'%(tn,
591                     self.arg, self.arg)
592                 for removeid in remove:
593                     self.sql(sql, (nodeid, removeid))
595         # make sure we do the commit-time extra stuff for this node
596         self.transactions.append((self.doSaveNode, (classname, nodeid, values)))
598     def getnode(self, classname, nodeid):
599         ''' Get a node from the database.
600         '''
601         if __debug__:
602             print >>hyperdb.DEBUG, 'getnode', (self, classname, nodeid)
604         # see if we have this node cached
605         key = (classname, nodeid)
606         if self.cache.has_key(key):
607             # push us back to the top of the LRU
608             self.cache_lru.remove(key)
609             self.cache_lru.insert(0, key)
610             # return the cached information
611             return self.cache[key]
613         # figure the columns we're fetching
614         cl = self.classes[classname]
615         cols, mls = self.determine_columns(cl.properties.items())
616         scols = ','.join(cols)
618         # perform the basic property fetch
619         sql = 'select %s from _%s where id=%s'%(scols, classname, self.arg)
620         self.sql(sql, (nodeid,))
622         values = self.sql_fetchone()
623         if values is None:
624             raise IndexError, 'no such %s node %s'%(classname, nodeid)
626         # make up the node
627         node = {}
628         for col in range(len(cols)):
629             node[cols[col][1:]] = values[col]
631         # now the multilinks
632         for col in mls:
633             # get the link ids
634             sql = 'select linkid from %s_%s where nodeid=%s'%(classname, col,
635                 self.arg)
636             self.cursor.execute(sql, (nodeid,))
637             # extract the first column from the result
638             node[col] = [x[0] for x in self.cursor.fetchall()]
640         # un-dbificate the node data
641         node = self.unserialise(classname, node)
643         # save off in the cache
644         key = (classname, nodeid)
645         self.cache[key] = node
646         # update the LRU
647         self.cache_lru.insert(0, key)
648         if len(self.cache_lru) > ROW_CACHE_SIZE:
649             del self.cache[self.cache_lru.pop()]
651         return node
653     def destroynode(self, classname, nodeid):
654         '''Remove a node from the database. Called exclusively by the
655            destroy() method on Class.
656         '''
657         if __debug__:
658             print >>hyperdb.DEBUG, 'destroynode', (self, classname, nodeid)
660         # make sure the node exists
661         if not self.hasnode(classname, nodeid):
662             raise IndexError, '%s has no node %s'%(classname, nodeid)
664         # see if we have this node cached
665         if self.cache.has_key((classname, nodeid)):
666             del self.cache[(classname, nodeid)]
668         # see if there's any obvious commit actions that we should get rid of
669         for entry in self.transactions[:]:
670             if entry[1][:2] == (classname, nodeid):
671                 self.transactions.remove(entry)
673         # now do the SQL
674         sql = 'delete from _%s where id=%s'%(classname, self.arg)
675         self.sql(sql, (nodeid,))
677         # remove from multilnks
678         cl = self.getclass(classname)
679         x, mls = self.determine_columns(cl.properties.items())
680         for col in mls:
681             # get the link ids
682             sql = 'delete from %s_%s where nodeid=%s'%(classname, col, self.arg)
683             self.cursor.execute(sql, (nodeid,))
685         # remove journal entries
686         sql = 'delete from %s__journal where nodeid=%s'%(classname, self.arg)
687         self.sql(sql, (nodeid,))
689     def serialise(self, classname, node):
690         '''Copy the node contents, converting non-marshallable data into
691            marshallable data.
692         '''
693         if __debug__:
694             print >>hyperdb.DEBUG, 'serialise', classname, node
695         properties = self.getclass(classname).getprops()
696         d = {}
697         for k, v in node.items():
698             # if the property doesn't exist, or is the "retired" flag then
699             # it won't be in the properties dict
700             if not properties.has_key(k):
701                 d[k] = v
702                 continue
704             # get the property spec
705             prop = properties[k]
707             if isinstance(prop, Password) and v is not None:
708                 d[k] = str(v)
709             elif isinstance(prop, Date) and v is not None:
710                 d[k] = v.serialise()
711             elif isinstance(prop, Interval) and v is not None:
712                 d[k] = v.serialise()
713             else:
714                 d[k] = v
715         return d
717     def unserialise(self, classname, node):
718         '''Decode the marshalled node data
719         '''
720         if __debug__:
721             print >>hyperdb.DEBUG, 'unserialise', classname, node
722         properties = self.getclass(classname).getprops()
723         d = {}
724         for k, v in node.items():
725             # if the property doesn't exist, or is the "retired" flag then
726             # it won't be in the properties dict
727             if not properties.has_key(k):
728                 d[k] = v
729                 continue
731             # get the property spec
732             prop = properties[k]
734             if isinstance(prop, Date) and v is not None:
735                 d[k] = date.Date(v)
736             elif isinstance(prop, Interval) and v is not None:
737                 d[k] = date.Interval(v)
738             elif isinstance(prop, Password) and v is not None:
739                 p = password.Password()
740                 p.unpack(v)
741                 d[k] = p
742             elif (isinstance(prop, Boolean) or isinstance(prop, Number)) and v is not None:
743                 d[k]=float(v)
744             else:
745                 d[k] = v
746         return d
748     def hasnode(self, classname, nodeid):
749         ''' Determine if the database has a given node.
750         '''
751         sql = 'select count(*) from _%s where id=%s'%(classname, self.arg)
752         if __debug__:
753             print >>hyperdb.DEBUG, 'hasnode', (self, sql, nodeid)
754         self.cursor.execute(sql, (nodeid,))
755         return int(self.cursor.fetchone()[0])
757     def countnodes(self, classname):
758         ''' Count the number of nodes that exist for a particular Class.
759         '''
760         sql = 'select count(*) from _%s'%classname
761         if __debug__:
762             print >>hyperdb.DEBUG, 'countnodes', (self, sql)
763         self.cursor.execute(sql)
764         return self.cursor.fetchone()[0]
766     def getnodeids(self, classname, retired=0):
767         ''' Retrieve all the ids of the nodes for a particular Class.
769             Set retired=None to get all nodes. Otherwise it'll get all the 
770             retired or non-retired nodes, depending on the flag.
771         '''
772         # flip the sense of the flag if we don't want all of them
773         if retired is not None:
774             retired = not retired
775         sql = 'select id from _%s where __retired__ <> %s'%(classname, self.arg)
776         if __debug__:
777             print >>hyperdb.DEBUG, 'getnodeids', (self, sql, retired)
778         self.cursor.execute(sql, (retired,))
779         return [x[0] for x in self.cursor.fetchall()]
781     def addjournal(self, classname, nodeid, action, params, creator=None,
782             creation=None):
783         ''' Journal the Action
784         'action' may be:
786             'create' or 'set' -- 'params' is a dictionary of property values
787             'link' or 'unlink' -- 'params' is (classname, nodeid, propname)
788             'retire' -- 'params' is None
789         '''
790         # serialise the parameters now if necessary
791         if isinstance(params, type({})):
792             if action in ('set', 'create'):
793                 params = self.serialise(classname, params)
795         # handle supply of the special journalling parameters (usually
796         # supplied on importing an existing database)
797         if creator:
798             journaltag = creator
799         else:
800             journaltag = self.curuserid
801         if creation:
802             journaldate = creation.serialise()
803         else:
804             journaldate = date.Date().serialise()
806         # create the journal entry
807         cols = ','.join('nodeid date tag action params'.split())
809         if __debug__:
810             print >>hyperdb.DEBUG, 'addjournal', (nodeid, journaldate,
811                 journaltag, action, params)
813         self.save_journal(classname, cols, nodeid, journaldate,
814             journaltag, action, params)
816     def save_journal(self, classname, cols, nodeid, journaldate,
817             journaltag, action, params):
818         ''' Save the journal entry to the database
819         '''
820         raise NotImplemented
822     def getjournal(self, classname, nodeid):
823         ''' get the journal for id
824         '''
825         # make sure the node exists
826         if not self.hasnode(classname, nodeid):
827             raise IndexError, '%s has no node %s'%(classname, nodeid)
829         cols = ','.join('nodeid date tag action params'.split())
830         return self.load_journal(classname, cols, nodeid)
832     def load_journal(self, classname, cols, nodeid):
833         ''' Load the journal from the database
834         '''
835         raise NotImplemented
837     def pack(self, pack_before):
838         ''' Delete all journal entries except "create" before 'pack_before'.
839         '''
840         # get a 'yyyymmddhhmmss' version of the date
841         date_stamp = pack_before.serialise()
843         # do the delete
844         for classname in self.classes.keys():
845             sql = "delete from %s__journal where date<%s and "\
846                 "action<>'create'"%(classname, self.arg)
847             if __debug__:
848                 print >>hyperdb.DEBUG, 'pack', (self, sql, date_stamp)
849             self.cursor.execute(sql, (date_stamp,))
851     def sql_commit(self):
852         ''' Actually commit to the database.
853         '''
854         self.conn.commit()
856     def commit(self):
857         ''' Commit the current transactions.
859         Save all data changed since the database was opened or since the
860         last commit() or rollback().
861         '''
862         if __debug__:
863             print >>hyperdb.DEBUG, 'commit', (self,)
865         # commit the database
866         self.sql_commit()
868         # now, do all the other transaction stuff
869         reindex = {}
870         for method, args in self.transactions:
871             reindex[method(*args)] = 1
873         # reindex the nodes that request it
874         for classname, nodeid in filter(None, reindex.keys()):
875             print >>hyperdb.DEBUG, 'commit.reindex', (classname, nodeid)
876             self.getclass(classname).index(nodeid)
878         # save the indexer state
879         self.indexer.save_index()
881         # clear out the transactions
882         self.transactions = []
884     def rollback(self):
885         ''' Reverse all actions from the current transaction.
887         Undo all the changes made since the database was opened or the last
888         commit() or rollback() was performed.
889         '''
890         if __debug__:
891             print >>hyperdb.DEBUG, 'rollback', (self,)
893         # roll back
894         self.conn.rollback()
896         # roll back "other" transaction stuff
897         for method, args in self.transactions:
898             # delete temporary files
899             if method == self.doStoreFile:
900                 self.rollbackStoreFile(*args)
901         self.transactions = []
903     def doSaveNode(self, classname, nodeid, node):
904         ''' dummy that just generates a reindex event
905         '''
906         # return the classname, nodeid so we reindex this content
907         return (classname, nodeid)
909     def close(self):
910         ''' Close off the connection.
911         '''
912         self.conn.close()
913         if self.lockfile is not None:
914             locking.release_lock(self.lockfile)
915         if self.lockfile is not None:
916             self.lockfile.close()
917             self.lockfile = None
920 # The base Class class
922 class Class(hyperdb.Class):
923     ''' The handle to a particular class of nodes in a hyperdatabase.
924         
925         All methods except __repr__ and getnode must be implemented by a
926         concrete backend Class.
927     '''
929     def __init__(self, db, classname, **properties):
930         '''Create a new class with a given name and property specification.
932         'classname' must not collide with the name of an existing class,
933         or a ValueError is raised.  The keyword arguments in 'properties'
934         must map names to property objects, or a TypeError is raised.
935         '''
936         if (properties.has_key('creation') or properties.has_key('activity')
937                 or properties.has_key('creator')):
938             raise ValueError, '"creation", "activity" and "creator" are '\
939                 'reserved'
941         self.classname = classname
942         self.properties = properties
943         self.db = weakref.proxy(db)       # use a weak ref to avoid circularity
944         self.key = ''
946         # should we journal changes (default yes)
947         self.do_journal = 1
949         # do the db-related init stuff
950         db.addclass(self)
952         self.auditors = {'create': [], 'set': [], 'retire': []}
953         self.reactors = {'create': [], 'set': [], 'retire': []}
955     def schema(self):
956         ''' A dumpable version of the schema that we can store in the
957             database
958         '''
959         return (self.key, [(x, repr(y)) for x,y in self.properties.items()])
961     def enableJournalling(self):
962         '''Turn journalling on for this class
963         '''
964         self.do_journal = 1
966     def disableJournalling(self):
967         '''Turn journalling off for this class
968         '''
969         self.do_journal = 0
971     # Editing nodes:
972     def create(self, **propvalues):
973         ''' Create a new node of this class and return its id.
975         The keyword arguments in 'propvalues' map property names to values.
977         The values of arguments must be acceptable for the types of their
978         corresponding properties or a TypeError is raised.
979         
980         If this class has a key property, it must be present and its value
981         must not collide with other key strings or a ValueError is raised.
982         
983         Any other properties on this class that are missing from the
984         'propvalues' dictionary are set to None.
985         
986         If an id in a link or multilink property does not refer to a valid
987         node, an IndexError is raised.
988         '''
989         self.fireAuditors('create', None, propvalues)
990         newid = self.create_inner(**propvalues)
991         self.fireReactors('create', newid, None)
992         return newid
993     
994     def create_inner(self, **propvalues):
995         ''' Called by create, in-between the audit and react calls.
996         '''
997         if propvalues.has_key('id'):
998             raise KeyError, '"id" is reserved'
1000         if self.db.journaltag is None:
1001             raise DatabaseError, 'Database open read-only'
1003         if propvalues.has_key('creation') or propvalues.has_key('activity'):
1004             raise KeyError, '"creation" and "activity" are reserved'
1006         # new node's id
1007         newid = self.db.newid(self.classname)
1009         # validate propvalues
1010         num_re = re.compile('^\d+$')
1011         for key, value in propvalues.items():
1012             if key == self.key:
1013                 try:
1014                     self.lookup(value)
1015                 except KeyError:
1016                     pass
1017                 else:
1018                     raise ValueError, 'node with key "%s" exists'%value
1020             # try to handle this property
1021             try:
1022                 prop = self.properties[key]
1023             except KeyError:
1024                 raise KeyError, '"%s" has no property "%s"'%(self.classname,
1025                     key)
1027             if value is not None and isinstance(prop, Link):
1028                 if type(value) != type(''):
1029                     raise ValueError, 'link value must be String'
1030                 link_class = self.properties[key].classname
1031                 # if it isn't a number, it's a key
1032                 if not num_re.match(value):
1033                     try:
1034                         value = self.db.classes[link_class].lookup(value)
1035                     except (TypeError, KeyError):
1036                         raise IndexError, 'new property "%s": %s not a %s'%(
1037                             key, value, link_class)
1038                 elif not self.db.getclass(link_class).hasnode(value):
1039                     raise IndexError, '%s has no node %s'%(link_class, value)
1041                 # save off the value
1042                 propvalues[key] = value
1044                 # register the link with the newly linked node
1045                 if self.do_journal and self.properties[key].do_journal:
1046                     self.db.addjournal(link_class, value, 'link',
1047                         (self.classname, newid, key))
1049             elif isinstance(prop, Multilink):
1050                 if type(value) != type([]):
1051                     raise TypeError, 'new property "%s" not a list of ids'%key
1053                 # clean up and validate the list of links
1054                 link_class = self.properties[key].classname
1055                 l = []
1056                 for entry in value:
1057                     if type(entry) != type(''):
1058                         raise ValueError, '"%s" multilink value (%r) '\
1059                             'must contain Strings'%(key, value)
1060                     # if it isn't a number, it's a key
1061                     if not num_re.match(entry):
1062                         try:
1063                             entry = self.db.classes[link_class].lookup(entry)
1064                         except (TypeError, KeyError):
1065                             raise IndexError, 'new property "%s": %s not a %s'%(
1066                                 key, entry, self.properties[key].classname)
1067                     l.append(entry)
1068                 value = l
1069                 propvalues[key] = value
1071                 # handle additions
1072                 for nodeid in value:
1073                     if not self.db.getclass(link_class).hasnode(nodeid):
1074                         raise IndexError, '%s has no node %s'%(link_class,
1075                             nodeid)
1076                     # register the link with the newly linked node
1077                     if self.do_journal and self.properties[key].do_journal:
1078                         self.db.addjournal(link_class, nodeid, 'link',
1079                             (self.classname, newid, key))
1081             elif isinstance(prop, String):
1082                 if type(value) != type('') and type(value) != type(u''):
1083                     raise TypeError, 'new property "%s" not a string'%key
1085             elif isinstance(prop, Password):
1086                 if not isinstance(value, password.Password):
1087                     raise TypeError, 'new property "%s" not a Password'%key
1089             elif isinstance(prop, Date):
1090                 if value is not None and not isinstance(value, date.Date):
1091                     raise TypeError, 'new property "%s" not a Date'%key
1093             elif isinstance(prop, Interval):
1094                 if value is not None and not isinstance(value, date.Interval):
1095                     raise TypeError, 'new property "%s" not an Interval'%key
1097             elif value is not None and isinstance(prop, Number):
1098                 try:
1099                     float(value)
1100                 except ValueError:
1101                     raise TypeError, 'new property "%s" not numeric'%key
1103             elif value is not None and isinstance(prop, Boolean):
1104                 try:
1105                     int(value)
1106                 except ValueError:
1107                     raise TypeError, 'new property "%s" not boolean'%key
1109         # make sure there's data where there needs to be
1110         for key, prop in self.properties.items():
1111             if propvalues.has_key(key):
1112                 continue
1113             if key == self.key:
1114                 raise ValueError, 'key property "%s" is required'%key
1115             if isinstance(prop, Multilink):
1116                 propvalues[key] = []
1117             else:
1118                 propvalues[key] = None
1120         # done
1121         self.db.addnode(self.classname, newid, propvalues)
1122         if self.do_journal:
1123             self.db.addjournal(self.classname, newid, 'create', {})
1125         return newid
1127     def export_list(self, propnames, nodeid):
1128         ''' Export a node - generate a list of CSV-able data in the order
1129             specified by propnames for the given node.
1130         '''
1131         properties = self.getprops()
1132         l = []
1133         for prop in propnames:
1134             proptype = properties[prop]
1135             value = self.get(nodeid, prop)
1136             # "marshal" data where needed
1137             if value is None:
1138                 pass
1139             elif isinstance(proptype, hyperdb.Date):
1140                 value = value.get_tuple()
1141             elif isinstance(proptype, hyperdb.Interval):
1142                 value = value.get_tuple()
1143             elif isinstance(proptype, hyperdb.Password):
1144                 value = str(value)
1145             l.append(repr(value))
1146         return l
1148     def import_list(self, propnames, proplist):
1149         ''' Import a node - all information including "id" is present and
1150             should not be sanity checked. Triggers are not triggered. The
1151             journal should be initialised using the "creator" and "created"
1152             information.
1154             Return the nodeid of the node imported.
1155         '''
1156         if self.db.journaltag is None:
1157             raise DatabaseError, 'Database open read-only'
1158         properties = self.getprops()
1160         # make the new node's property map
1161         d = {}
1162         for i in range(len(propnames)):
1163             # Use eval to reverse the repr() used to output the CSV
1164             value = eval(proplist[i])
1166             # Figure the property for this column
1167             propname = propnames[i]
1168             prop = properties[propname]
1170             # "unmarshal" where necessary
1171             if propname == 'id':
1172                 newid = value
1173                 continue
1174             elif value is None:
1175                 # don't set Nones
1176                 continue
1177             elif isinstance(prop, hyperdb.Date):
1178                 value = date.Date(value)
1179             elif isinstance(prop, hyperdb.Interval):
1180                 value = date.Interval(value)
1181             elif isinstance(prop, hyperdb.Password):
1182                 pwd = password.Password()
1183                 pwd.unpack(value)
1184                 value = pwd
1185             d[propname] = value
1187         # add the node and journal
1188         self.db.addnode(self.classname, newid, d)
1190         # extract the extraneous journalling gumpf and nuke it
1191         if d.has_key('creator'):
1192             creator = d['creator']
1193             del d['creator']
1194         else:
1195             creator = None
1196         if d.has_key('creation'):
1197             creation = d['creation']
1198             del d['creation']
1199         else:
1200             creation = None
1201         if d.has_key('activity'):
1202             del d['activity']
1203         self.db.addjournal(self.classname, newid, 'create', {}, creator,
1204             creation)
1205         return newid
1207     _marker = []
1208     def get(self, nodeid, propname, default=_marker, cache=1):
1209         '''Get the value of a property on an existing node of this class.
1211         'nodeid' must be the id of an existing node of this class or an
1212         IndexError is raised.  'propname' must be the name of a property
1213         of this class or a KeyError is raised.
1215         'cache' indicates whether the transaction cache should be queried
1216         for the node. If the node has been modified and you need to
1217         determine what its values prior to modification are, you need to
1218         set cache=0.
1219         '''
1220         if propname == 'id':
1221             return nodeid
1223         # get the node's dict
1224         d = self.db.getnode(self.classname, nodeid)
1226         if propname == 'creation':
1227             if d.has_key('creation'):
1228                 return d['creation']
1229             else:
1230                 return date.Date()
1231         if propname == 'activity':
1232             if d.has_key('activity'):
1233                 return d['activity']
1234             else:
1235                 return date.Date()
1236         if propname == 'creator':
1237             if d.has_key('creator'):
1238                 return d['creator']
1239             else:
1240                 return self.db.curuserid
1242         # get the property (raises KeyErorr if invalid)
1243         prop = self.properties[propname]
1245         if not d.has_key(propname):
1246             if default is self._marker:
1247                 if isinstance(prop, Multilink):
1248                     return []
1249                 else:
1250                     return None
1251             else:
1252                 return default
1254         # don't pass our list to other code
1255         if isinstance(prop, Multilink):
1256             return d[propname][:]
1258         return d[propname]
1260     def getnode(self, nodeid, cache=1):
1261         ''' Return a convenience wrapper for the node.
1263         'nodeid' must be the id of an existing node of this class or an
1264         IndexError is raised.
1266         'cache' indicates whether the transaction cache should be queried
1267         for the node. If the node has been modified and you need to
1268         determine what its values prior to modification are, you need to
1269         set cache=0.
1270         '''
1271         return Node(self, nodeid, cache=cache)
1273     def set(self, nodeid, **propvalues):
1274         '''Modify a property on an existing node of this class.
1275         
1276         'nodeid' must be the id of an existing node of this class or an
1277         IndexError is raised.
1279         Each key in 'propvalues' must be the name of a property of this
1280         class or a KeyError is raised.
1282         All values in 'propvalues' must be acceptable types for their
1283         corresponding properties or a TypeError is raised.
1285         If the value of the key property is set, it must not collide with
1286         other key strings or a ValueError is raised.
1288         If the value of a Link or Multilink property contains an invalid
1289         node id, a ValueError is raised.
1290         '''
1291         if not propvalues:
1292             return propvalues
1294         if propvalues.has_key('creation') or propvalues.has_key('activity'):
1295             raise KeyError, '"creation" and "activity" are reserved'
1297         if propvalues.has_key('id'):
1298             raise KeyError, '"id" is reserved'
1300         if self.db.journaltag is None:
1301             raise DatabaseError, 'Database open read-only'
1303         self.fireAuditors('set', nodeid, propvalues)
1304         # Take a copy of the node dict so that the subsequent set
1305         # operation doesn't modify the oldvalues structure.
1306         # XXX used to try the cache here first
1307         oldvalues = copy.deepcopy(self.db.getnode(self.classname, nodeid))
1309         node = self.db.getnode(self.classname, nodeid)
1310         if self.is_retired(nodeid):
1311             raise IndexError, 'Requested item is retired'
1312         num_re = re.compile('^\d+$')
1314         # if the journal value is to be different, store it in here
1315         journalvalues = {}
1317         # remember the add/remove stuff for multilinks, making it easier
1318         # for the Database layer to do its stuff
1319         multilink_changes = {}
1321         for propname, value in propvalues.items():
1322             # check to make sure we're not duplicating an existing key
1323             if propname == self.key and node[propname] != value:
1324                 try:
1325                     self.lookup(value)
1326                 except KeyError:
1327                     pass
1328                 else:
1329                     raise ValueError, 'node with key "%s" exists'%value
1331             # this will raise the KeyError if the property isn't valid
1332             # ... we don't use getprops() here because we only care about
1333             # the writeable properties.
1334             try:
1335                 prop = self.properties[propname]
1336             except KeyError:
1337                 raise KeyError, '"%s" has no property named "%s"'%(
1338                     self.classname, propname)
1340             # if the value's the same as the existing value, no sense in
1341             # doing anything
1342             current = node.get(propname, None)
1343             if value == current:
1344                 del propvalues[propname]
1345                 continue
1346             journalvalues[propname] = current
1348             # do stuff based on the prop type
1349             if isinstance(prop, Link):
1350                 link_class = prop.classname
1351                 # if it isn't a number, it's a key
1352                 if value is not None and not isinstance(value, type('')):
1353                     raise ValueError, 'property "%s" link value be a string'%(
1354                         propname)
1355                 if isinstance(value, type('')) and not num_re.match(value):
1356                     try:
1357                         value = self.db.classes[link_class].lookup(value)
1358                     except (TypeError, KeyError):
1359                         raise IndexError, 'new property "%s": %s not a %s'%(
1360                             propname, value, prop.classname)
1362                 if (value is not None and
1363                         not self.db.getclass(link_class).hasnode(value)):
1364                     raise IndexError, '%s has no node %s'%(link_class, value)
1366                 if self.do_journal and prop.do_journal:
1367                     # register the unlink with the old linked node
1368                     if node[propname] is not None:
1369                         self.db.addjournal(link_class, node[propname], 'unlink',
1370                             (self.classname, nodeid, propname))
1372                     # register the link with the newly linked node
1373                     if value is not None:
1374                         self.db.addjournal(link_class, value, 'link',
1375                             (self.classname, nodeid, propname))
1377             elif isinstance(prop, Multilink):
1378                 if type(value) != type([]):
1379                     raise TypeError, 'new property "%s" not a list of'\
1380                         ' ids'%propname
1381                 link_class = self.properties[propname].classname
1382                 l = []
1383                 for entry in value:
1384                     # if it isn't a number, it's a key
1385                     if type(entry) != type(''):
1386                         raise ValueError, 'new property "%s" link value ' \
1387                             'must be a string'%propname
1388                     if not num_re.match(entry):
1389                         try:
1390                             entry = self.db.classes[link_class].lookup(entry)
1391                         except (TypeError, KeyError):
1392                             raise IndexError, 'new property "%s": %s not a %s'%(
1393                                 propname, entry,
1394                                 self.properties[propname].classname)
1395                     l.append(entry)
1396                 value = l
1397                 propvalues[propname] = value
1399                 # figure the journal entry for this property
1400                 add = []
1401                 remove = []
1403                 # handle removals
1404                 if node.has_key(propname):
1405                     l = node[propname]
1406                 else:
1407                     l = []
1408                 for id in l[:]:
1409                     if id in value:
1410                         continue
1411                     # register the unlink with the old linked node
1412                     if self.do_journal and self.properties[propname].do_journal:
1413                         self.db.addjournal(link_class, id, 'unlink',
1414                             (self.classname, nodeid, propname))
1415                     l.remove(id)
1416                     remove.append(id)
1418                 # handle additions
1419                 for id in value:
1420                     if not self.db.getclass(link_class).hasnode(id):
1421                         raise IndexError, '%s has no node %s'%(link_class, id)
1422                     if id in l:
1423                         continue
1424                     # register the link with the newly linked node
1425                     if self.do_journal and self.properties[propname].do_journal:
1426                         self.db.addjournal(link_class, id, 'link',
1427                             (self.classname, nodeid, propname))
1428                     l.append(id)
1429                     add.append(id)
1431                 # figure the journal entry
1432                 l = []
1433                 if add:
1434                     l.append(('+', add))
1435                 if remove:
1436                     l.append(('-', remove))
1437                 multilink_changes[propname] = (add, remove)
1438                 if l:
1439                     journalvalues[propname] = tuple(l)
1441             elif isinstance(prop, String):
1442                 if value is not None and type(value) != type('') and type(value) != type(u''):
1443                     raise TypeError, 'new property "%s" not a string'%propname
1445             elif isinstance(prop, Password):
1446                 if not isinstance(value, password.Password):
1447                     raise TypeError, 'new property "%s" not a Password'%propname
1448                 propvalues[propname] = value
1450             elif value is not None and isinstance(prop, Date):
1451                 if not isinstance(value, date.Date):
1452                     raise TypeError, 'new property "%s" not a Date'% propname
1453                 propvalues[propname] = value
1455             elif value is not None and isinstance(prop, Interval):
1456                 if not isinstance(value, date.Interval):
1457                     raise TypeError, 'new property "%s" not an '\
1458                         'Interval'%propname
1459                 propvalues[propname] = value
1461             elif value is not None and isinstance(prop, Number):
1462                 try:
1463                     float(value)
1464                 except ValueError:
1465                     raise TypeError, 'new property "%s" not numeric'%propname
1467             elif value is not None and isinstance(prop, Boolean):
1468                 try:
1469                     int(value)
1470                 except ValueError:
1471                     raise TypeError, 'new property "%s" not boolean'%propname
1473         # nothing to do?
1474         if not propvalues:
1475             return propvalues
1477         # do the set, and journal it
1478         self.db.setnode(self.classname, nodeid, propvalues, multilink_changes)
1480         if self.do_journal:
1481             self.db.addjournal(self.classname, nodeid, 'set', journalvalues)
1483         self.fireReactors('set', nodeid, oldvalues)
1485         return propvalues        
1487     def retire(self, nodeid):
1488         '''Retire a node.
1489         
1490         The properties on the node remain available from the get() method,
1491         and the node's id is never reused.
1492         
1493         Retired nodes are not returned by the find(), list(), or lookup()
1494         methods, and other nodes may reuse the values of their key properties.
1495         '''
1496         if self.db.journaltag is None:
1497             raise DatabaseError, 'Database open read-only'
1499         self.fireAuditors('retire', nodeid, None)
1501         # use the arg for __retired__ to cope with any odd database type
1502         # conversion (hello, sqlite)
1503         sql = 'update _%s set __retired__=%s where id=%s'%(self.classname,
1504             self.db.arg, self.db.arg)
1505         if __debug__:
1506             print >>hyperdb.DEBUG, 'retire', (self, sql, nodeid)
1507         self.db.cursor.execute(sql, (1, nodeid))
1509         self.fireReactors('retire', nodeid, None)
1511     def is_retired(self, nodeid):
1512         '''Return true if the node is rerired
1513         '''
1514         sql = 'select __retired__ from _%s where id=%s'%(self.classname,
1515             self.db.arg)
1516         if __debug__:
1517             print >>hyperdb.DEBUG, 'is_retired', (self, sql, nodeid)
1518         self.db.cursor.execute(sql, (nodeid,))
1519         return int(self.db.sql_fetchone()[0])
1521     def destroy(self, nodeid):
1522         '''Destroy a node.
1523         
1524         WARNING: this method should never be used except in extremely rare
1525                  situations where there could never be links to the node being
1526                  deleted
1527         WARNING: use retire() instead
1528         WARNING: the properties of this node will not be available ever again
1529         WARNING: really, use retire() instead
1531         Well, I think that's enough warnings. This method exists mostly to
1532         support the session storage of the cgi interface.
1534         The node is completely removed from the hyperdb, including all journal
1535         entries. It will no longer be available, and will generally break code
1536         if there are any references to the node.
1537         '''
1538         if self.db.journaltag is None:
1539             raise DatabaseError, 'Database open read-only'
1540         self.db.destroynode(self.classname, nodeid)
1542     def history(self, nodeid):
1543         '''Retrieve the journal of edits on a particular node.
1545         'nodeid' must be the id of an existing node of this class or an
1546         IndexError is raised.
1548         The returned list contains tuples of the form
1550             (nodeid, date, tag, action, params)
1552         'date' is a Timestamp object specifying the time of the change and
1553         'tag' is the journaltag specified when the database was opened.
1554         '''
1555         if not self.do_journal:
1556             raise ValueError, 'Journalling is disabled for this class'
1557         return self.db.getjournal(self.classname, nodeid)
1559     # Locating nodes:
1560     def hasnode(self, nodeid):
1561         '''Determine if the given nodeid actually exists
1562         '''
1563         return self.db.hasnode(self.classname, nodeid)
1565     def setkey(self, propname):
1566         '''Select a String property of this class to be the key property.
1568         'propname' must be the name of a String property of this class or
1569         None, or a TypeError is raised.  The values of the key property on
1570         all existing nodes must be unique or a ValueError is raised.
1571         '''
1572         # XXX create an index on the key prop column
1573         prop = self.getprops()[propname]
1574         if not isinstance(prop, String):
1575             raise TypeError, 'key properties must be String'
1576         self.key = propname
1578     def getkey(self):
1579         '''Return the name of the key property for this class or None.'''
1580         return self.key
1582     def labelprop(self, default_to_id=0):
1583         ''' Return the property name for a label for the given node.
1585         This method attempts to generate a consistent label for the node.
1586         It tries the following in order:
1587             1. key property
1588             2. "name" property
1589             3. "title" property
1590             4. first property from the sorted property name list
1591         '''
1592         k = self.getkey()
1593         if  k:
1594             return k
1595         props = self.getprops()
1596         if props.has_key('name'):
1597             return 'name'
1598         elif props.has_key('title'):
1599             return 'title'
1600         if default_to_id:
1601             return 'id'
1602         props = props.keys()
1603         props.sort()
1604         return props[0]
1606     def lookup(self, keyvalue):
1607         '''Locate a particular node by its key property and return its id.
1609         If this class has no key property, a TypeError is raised.  If the
1610         'keyvalue' matches one of the values for the key property among
1611         the nodes in this class, the matching node's id is returned;
1612         otherwise a KeyError is raised.
1613         '''
1614         if not self.key:
1615             raise TypeError, 'No key property set for class %s'%self.classname
1617         # use the arg to handle any odd database type conversion (hello,
1618         # sqlite)
1619         sql = "select id from _%s where _%s=%s and __retired__ <> %s"%(
1620             self.classname, self.key, self.db.arg, self.db.arg)
1621         self.db.sql(sql, (keyvalue, 1))
1623         # see if there was a result that's not retired
1624         row = self.db.sql_fetchone()
1625         if not row:
1626             raise KeyError, 'No key (%s) value "%s" for "%s"'%(self.key,
1627                 keyvalue, self.classname)
1629         # return the id
1630         return row[0]
1632     def find(self, **propspec):
1633         '''Get the ids of nodes in this class which link to the given nodes.
1635         'propspec' consists of keyword args propname=nodeid or
1636                    propname={nodeid:1, }
1637         'propname' must be the name of a property in this class, or a
1638         KeyError is raised.  That property must be a Link or Multilink
1639         property, or a TypeError is raised.
1641         Any node in this class whose 'propname' property links to any of the
1642         nodeids will be returned. Used by the full text indexing, which knows
1643         that "foo" occurs in msg1, msg3 and file7, so we have hits on these
1644         issues:
1646             db.issue.find(messages={'1':1,'3':1}, files={'7':1})
1647         '''
1648         if __debug__:
1649             print >>hyperdb.DEBUG, 'find', (self, propspec)
1651         # shortcut
1652         if not propspec:
1653             return []
1655         # validate the args
1656         props = self.getprops()
1657         propspec = propspec.items()
1658         for propname, nodeids in propspec:
1659             # check the prop is OK
1660             prop = props[propname]
1661             if not isinstance(prop, Link) and not isinstance(prop, Multilink):
1662                 raise TypeError, "'%s' not a Link/Multilink property"%propname
1664         # first, links
1665         where = []
1666         allvalues = ()
1667         a = self.db.arg
1668         for prop, values in propspec:
1669             if not isinstance(props[prop], hyperdb.Link):
1670                 continue
1671             if type(values) is type(''):
1672                 allvalues += (values,)
1673                 where.append('_%s = %s'%(prop, a))
1674             else:
1675                 allvalues += tuple(values.keys())
1676                 where.append('_%s in (%s)'%(prop, ','.join([a]*len(values))))
1677         tables = []
1678         if where:
1679             tables.append('select id as nodeid from _%s where %s'%(
1680                 self.classname, ' and '.join(where)))
1682         # now multilinks
1683         for prop, values in propspec:
1684             if not isinstance(props[prop], hyperdb.Multilink):
1685                 continue
1686             if type(values) is type(''):
1687                 allvalues += (values,)
1688                 s = a
1689             else:
1690                 allvalues += tuple(values.keys())
1691                 s = ','.join([a]*len(values))
1692             tables.append('select nodeid from %s_%s where linkid in (%s)'%(
1693                 self.classname, prop, s))
1694         sql = '\nunion\n'.join(tables)
1695         self.db.sql(sql, allvalues)
1696         l = [x[0] for x in self.db.sql_fetchall()]
1697         if __debug__:
1698             print >>hyperdb.DEBUG, 'find ... ', l
1699         return l
1701     def stringFind(self, **requirements):
1702         '''Locate a particular node by matching a set of its String
1703         properties in a caseless search.
1705         If the property is not a String property, a TypeError is raised.
1706         
1707         The return is a list of the id of all nodes that match.
1708         '''
1709         where = []
1710         args = []
1711         for propname in requirements.keys():
1712             prop = self.properties[propname]
1713             if isinstance(not prop, String):
1714                 raise TypeError, "'%s' not a String property"%propname
1715             where.append(propname)
1716             args.append(requirements[propname].lower())
1718         # generate the where clause
1719         s = ' and '.join(['_%s=%s'%(col, self.db.arg) for col in where])
1720         sql = 'select id from _%s where %s'%(self.classname, s)
1721         self.db.sql(sql, tuple(args))
1722         l = [x[0] for x in self.db.sql_fetchall()]
1723         if __debug__:
1724             print >>hyperdb.DEBUG, 'find ... ', l
1725         return l
1727     def list(self):
1728         ''' Return a list of the ids of the active nodes in this class.
1729         '''
1730         return self.db.getnodeids(self.classname, retired=0)
1732     def filter(self, search_matches, filterspec, sort=(None,None),
1733             group=(None,None)):
1734         ''' Return a list of the ids of the active nodes in this class that
1735             match the 'filter' spec, sorted by the group spec and then the
1736             sort spec
1738             "filterspec" is {propname: value(s)}
1739             "sort" and "group" are (dir, prop) where dir is '+', '-' or None
1740                                and prop is a prop name or None
1741             "search_matches" is {nodeid: marker}
1743             The filter must match all properties specificed - but if the
1744             property value to match is a list, any one of the values in the
1745             list may match for that property to match.
1746         '''
1747         # just don't bother if the full-text search matched diddly
1748         if search_matches == {}:
1749             return []
1751         cn = self.classname
1753         # figure the WHERE clause from the filterspec
1754         props = self.getprops()
1755         frum = ['_'+cn]
1756         where = []
1757         args = []
1758         a = self.db.arg
1759         for k, v in filterspec.items():
1760             propclass = props[k]
1761             # now do other where clause stuff
1762             if isinstance(propclass, Multilink):
1763                 tn = '%s_%s'%(cn, k)
1764                 frum.append(tn)
1765                 if isinstance(v, type([])):
1766                     s = ','.join([a for x in v])
1767                     where.append('id=%s.nodeid and %s.linkid in (%s)'%(tn,tn,s))
1768                     args = args + v
1769                 else:
1770                     where.append('id=%s.nodeid and %s.linkid = %s'%(tn, tn, a))
1771                     args.append(v)
1772             elif k == 'id':
1773                 if isinstance(v, type([])):
1774                     s = ','.join([a for x in v])
1775                     where.append('%s in (%s)'%(k, s))
1776                     args = args + v
1777                 else:
1778                     where.append('%s=%s'%(k, a))
1779                     args.append(v)
1780             elif isinstance(propclass, String):
1781                 if not isinstance(v, type([])):
1782                     v = [v]
1784                 # Quote the bits in the string that need it and then embed
1785                 # in a "substring" search. Note - need to quote the '%' so
1786                 # they make it through the python layer happily
1787                 v = ['%%'+self.db.sql_stringquote(s)+'%%' for s in v]
1789                 # now add to the where clause
1790                 where.append(' or '.join(["_%s LIKE '%s'"%(k, s) for s in v]))
1791                 # note: args are embedded in the query string now
1792             elif isinstance(propclass, Link):
1793                 if isinstance(v, type([])):
1794                     if '-1' in v:
1795                         v.remove('-1')
1796                         xtra = ' or _%s is NULL'%k
1797                     else:
1798                         xtra = ''
1799                     if v:
1800                         s = ','.join([a for x in v])
1801                         where.append('(_%s in (%s)%s)'%(k, s, xtra))
1802                         args = args + v
1803                     else:
1804                         where.append('_%s is NULL'%k)
1805                 else:
1806                     if v == '-1':
1807                         v = None
1808                         where.append('_%s is NULL'%k)
1809                     else:
1810                         where.append('_%s=%s'%(k, a))
1811                         args.append(v)
1812             elif isinstance(propclass, Date):
1813                 if isinstance(v, type([])):
1814                     s = ','.join([a for x in v])
1815                     where.append('_%s in (%s)'%(k, s))
1816                     args = args + [date.Date(x).serialise() for x in v]
1817                 else:
1818                     where.append('_%s=%s'%(k, a))
1819                     args.append(date.Date(v).serialise())
1820             elif isinstance(propclass, Interval):
1821                 if isinstance(v, type([])):
1822                     s = ','.join([a for x in v])
1823                     where.append('_%s in (%s)'%(k, s))
1824                     args = args + [date.Interval(x).serialise() for x in v]
1825                 else:
1826                     where.append('_%s=%s'%(k, a))
1827                     args.append(date.Interval(v).serialise())
1828             else:
1829                 if isinstance(v, type([])):
1830                     s = ','.join([a for x in v])
1831                     where.append('_%s in (%s)'%(k, s))
1832                     args = args + v
1833                 else:
1834                     where.append('_%s=%s'%(k, a))
1835                     args.append(v)
1837         # add results of full text search
1838         if search_matches is not None:
1839             v = search_matches.keys()
1840             s = ','.join([a for x in v])
1841             where.append('id in (%s)'%s)
1842             args = args + v
1844         # "grouping" is just the first-order sorting in the SQL fetch
1845         # can modify it...)
1846         orderby = []
1847         ordercols = []
1848         if group[0] is not None and group[1] is not None:
1849             if group[0] != '-':
1850                 orderby.append('_'+group[1])
1851                 ordercols.append('_'+group[1])
1852             else:
1853                 orderby.append('_'+group[1]+' desc')
1854                 ordercols.append('_'+group[1])
1856         # now add in the sorting
1857         group = ''
1858         if sort[0] is not None and sort[1] is not None:
1859             direction, colname = sort
1860             if direction != '-':
1861                 if colname == 'id':
1862                     orderby.append(colname)
1863                 else:
1864                     orderby.append('_'+colname)
1865                     ordercols.append('_'+colname)
1866             else:
1867                 if colname == 'id':
1868                     orderby.append(colname+' desc')
1869                     ordercols.append(colname)
1870                 else:
1871                     orderby.append('_'+colname+' desc')
1872                     ordercols.append('_'+colname)
1874         # construct the SQL
1875         frum = ','.join(frum)
1876         if where:
1877             where = ' where ' + (' and '.join(where))
1878         else:
1879             where = ''
1880         cols = ['id']
1881         if orderby:
1882             cols = cols + ordercols
1883             order = ' order by %s'%(','.join(orderby))
1884         else:
1885             order = ''
1886         cols = ','.join(cols)
1887         sql = 'select %s from %s %s%s%s'%(cols, frum, where, group, order)
1888         args = tuple(args)
1889         if __debug__:
1890             print >>hyperdb.DEBUG, 'filter', (self, sql, args)
1891         self.db.cursor.execute(sql, args)
1892         l = self.db.cursor.fetchall()
1894         # return the IDs (the first column)
1895         # XXX The filter(None, l) bit is sqlite-specific... if there's _NO_
1896         # XXX matches to a fetch, it returns NULL instead of nothing!?!
1897         return filter(None, [row[0] for row in l])
1899     def count(self):
1900         '''Get the number of nodes in this class.
1902         If the returned integer is 'numnodes', the ids of all the nodes
1903         in this class run from 1 to numnodes, and numnodes+1 will be the
1904         id of the next node to be created in this class.
1905         '''
1906         return self.db.countnodes(self.classname)
1908     # Manipulating properties:
1909     def getprops(self, protected=1):
1910         '''Return a dictionary mapping property names to property objects.
1911            If the "protected" flag is true, we include protected properties -
1912            those which may not be modified.
1913         '''
1914         d = self.properties.copy()
1915         if protected:
1916             d['id'] = String()
1917             d['creation'] = hyperdb.Date()
1918             d['activity'] = hyperdb.Date()
1919             d['creator'] = hyperdb.Link('user')
1920         return d
1922     def addprop(self, **properties):
1923         '''Add properties to this class.
1925         The keyword arguments in 'properties' must map names to property
1926         objects, or a TypeError is raised.  None of the keys in 'properties'
1927         may collide with the names of existing properties, or a ValueError
1928         is raised before any properties have been added.
1929         '''
1930         for key in properties.keys():
1931             if self.properties.has_key(key):
1932                 raise ValueError, key
1933         self.properties.update(properties)
1935     def index(self, nodeid):
1936         '''Add (or refresh) the node to search indexes
1937         '''
1938         # find all the String properties that have indexme
1939         for prop, propclass in self.getprops().items():
1940             if isinstance(propclass, String) and propclass.indexme:
1941                 try:
1942                     value = str(self.get(nodeid, prop))
1943                 except IndexError:
1944                     # node no longer exists - entry should be removed
1945                     self.db.indexer.purge_entry((self.classname, nodeid, prop))
1946                 else:
1947                     # and index them under (classname, nodeid, property)
1948                     self.db.indexer.add_text((self.classname, nodeid, prop),
1949                         value)
1952     #
1953     # Detector interface
1954     #
1955     def audit(self, event, detector):
1956         '''Register a detector
1957         '''
1958         l = self.auditors[event]
1959         if detector not in l:
1960             self.auditors[event].append(detector)
1962     def fireAuditors(self, action, nodeid, newvalues):
1963         '''Fire all registered auditors.
1964         '''
1965         for audit in self.auditors[action]:
1966             audit(self.db, self, nodeid, newvalues)
1968     def react(self, event, detector):
1969         '''Register a detector
1970         '''
1971         l = self.reactors[event]
1972         if detector not in l:
1973             self.reactors[event].append(detector)
1975     def fireReactors(self, action, nodeid, oldvalues):
1976         '''Fire all registered reactors.
1977         '''
1978         for react in self.reactors[action]:
1979             react(self.db, self, nodeid, oldvalues)
1981 class FileClass(Class, hyperdb.FileClass):
1982     '''This class defines a large chunk of data. To support this, it has a
1983        mandatory String property "content" which is typically saved off
1984        externally to the hyperdb.
1986        The default MIME type of this data is defined by the
1987        "default_mime_type" class attribute, which may be overridden by each
1988        node if the class defines a "type" String property.
1989     '''
1990     default_mime_type = 'text/plain'
1992     def create(self, **propvalues):
1993         ''' snaffle the file propvalue and store in a file
1994         '''
1995         # we need to fire the auditors now, or the content property won't
1996         # be in propvalues for the auditors to play with
1997         self.fireAuditors('create', None, propvalues)
1999         # now remove the content property so it's not stored in the db
2000         content = propvalues['content']
2001         del propvalues['content']
2003         # do the database create
2004         newid = Class.create_inner(self, **propvalues)
2006         # fire reactors
2007         self.fireReactors('create', newid, None)
2009         # store off the content as a file
2010         self.db.storefile(self.classname, newid, None, content)
2011         return newid
2013     def import_list(self, propnames, proplist):
2014         ''' Trap the "content" property...
2015         '''
2016         # dupe this list so we don't affect others
2017         propnames = propnames[:]
2019         # extract the "content" property from the proplist
2020         i = propnames.index('content')
2021         content = eval(proplist[i])
2022         del propnames[i]
2023         del proplist[i]
2025         # do the normal import
2026         newid = Class.import_list(self, propnames, proplist)
2028         # save off the "content" file
2029         self.db.storefile(self.classname, newid, None, content)
2030         return newid
2032     _marker = []
2033     def get(self, nodeid, propname, default=_marker, cache=1):
2034         ''' trap the content propname and get it from the file
2035         '''
2036         poss_msg = 'Possibly a access right configuration problem.'
2037         if propname == 'content':
2038             try:
2039                 return self.db.getfile(self.classname, nodeid, None)
2040             except IOError, (strerror):
2041                 # BUG: by catching this we donot see an error in the log.
2042                 return 'ERROR reading file: %s%s\n%s\n%s'%(
2043                         self.classname, nodeid, poss_msg, strerror)
2044         if default is not self._marker:
2045             return Class.get(self, nodeid, propname, default, cache=cache)
2046         else:
2047             return Class.get(self, nodeid, propname, cache=cache)
2049     def getprops(self, protected=1):
2050         ''' In addition to the actual properties on the node, these methods
2051             provide the "content" property. If the "protected" flag is true,
2052             we include protected properties - those which may not be
2053             modified.
2054         '''
2055         d = Class.getprops(self, protected=protected).copy()
2056         d['content'] = hyperdb.String()
2057         return d
2059     def index(self, nodeid):
2060         ''' Index the node in the search index.
2062             We want to index the content in addition to the normal String
2063             property indexing.
2064         '''
2065         # perform normal indexing
2066         Class.index(self, nodeid)
2068         # get the content to index
2069         content = self.get(nodeid, 'content')
2071         # figure the mime type
2072         if self.properties.has_key('type'):
2073             mime_type = self.get(nodeid, 'type')
2074         else:
2075             mime_type = self.default_mime_type
2077         # and index!
2078         self.db.indexer.add_text((self.classname, nodeid, 'content'), content,
2079             mime_type)
2081 # XXX deviation from spec - was called ItemClass
2082 class IssueClass(Class, roundupdb.IssueClass):
2083     # Overridden methods:
2084     def __init__(self, db, classname, **properties):
2085         '''The newly-created class automatically includes the "messages",
2086         "files", "nosy", and "superseder" properties.  If the 'properties'
2087         dictionary attempts to specify any of these properties or a
2088         "creation" or "activity" property, a ValueError is raised.
2089         '''
2090         if not properties.has_key('title'):
2091             properties['title'] = hyperdb.String(indexme='yes')
2092         if not properties.has_key('messages'):
2093             properties['messages'] = hyperdb.Multilink("msg")
2094         if not properties.has_key('files'):
2095             properties['files'] = hyperdb.Multilink("file")
2096         if not properties.has_key('nosy'):
2097             # note: journalling is turned off as it really just wastes
2098             # space. this behaviour may be overridden in an instance
2099             properties['nosy'] = hyperdb.Multilink("user", do_journal="no")
2100         if not properties.has_key('superseder'):
2101             properties['superseder'] = hyperdb.Multilink(classname)
2102         Class.__init__(self, db, classname, **properties)