Code

Importing wasn't setting None values explicitly when it should have been
[roundup.git] / roundup / backends / rdbms_common.py
1 # $Id: rdbms_common.py,v 1.59 2003-08-26 00:06:56 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.)
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
37 from roundup.date import Range
39 # number of rows to keep in memory
40 ROW_CACHE_SIZE = 100
42 class Database(FileStorage, hyperdb.Database, roundupdb.Database):
43     ''' Wrapper around an SQL database that presents a hyperdb interface.
45         - some functionality is specific to the actual SQL database, hence
46           the sql_* methods that are NotImplemented
47         - we keep a cache of the latest ROW_CACHE_SIZE row fetches.
48     '''
49     def __init__(self, config, journaltag=None):
50         ''' Open the database and load the schema from it.
51         '''
52         self.config, self.journaltag = config, journaltag
53         self.dir = config.DATABASE
54         self.classes = {}
55         self.indexer = Indexer(self.dir)
56         self.sessions = Sessions(self.config)
57         self.otks = OneTimeKeys(self.config)
58         self.security = security.Security(self)
60         # additional transaction support for external files and the like
61         self.transactions = []
63         # keep a cache of the N most recently retrieved rows of any kind
64         # (classname, nodeid) = row
65         self.cache = {}
66         self.cache_lru = []
68         # database lock
69         self.lockfile = None
71         # open a connection to the database, creating the "conn" attribute
72         self.open_connection()
74     def clearCache(self):
75         self.cache = {}
76         self.cache_lru = []
78     def open_connection(self):
79         ''' Open a connection to the database, creating it if necessary
80         '''
81         raise NotImplemented
83     def sql(self, sql, args=None):
84         ''' Execute the sql with the optional args.
85         '''
86         if __debug__:
87             print >>hyperdb.DEBUG, (self, sql, args)
88         if args:
89             self.cursor.execute(sql, args)
90         else:
91             self.cursor.execute(sql)
93     def sql_fetchone(self):
94         ''' Fetch a single row. If there's nothing to fetch, return None.
95         '''
96         raise NotImplemented
98     def sql_stringquote(self, value):
99         ''' Quote the string so it's safe to put in the 'sql quotes'
100         '''
101         return re.sub("'", "''", str(value))
103     def save_dbschema(self, schema):
104         ''' Save the schema definition that the database currently implements
105         '''
106         raise NotImplemented
108     def load_dbschema(self):
109         ''' Load the schema definition that the database currently implements
110         '''
111         raise NotImplemented
113     def post_init(self):
114         ''' Called once the schema initialisation has finished.
116             We should now confirm that the schema defined by our "classes"
117             attribute actually matches the schema in the database.
118         '''
119         # now detect changes in the schema
120         save = 0
121         for classname, spec in self.classes.items():
122             if self.database_schema.has_key(classname):
123                 dbspec = self.database_schema[classname]
124                 if self.update_class(spec, dbspec):
125                     self.database_schema[classname] = spec.schema()
126                     save = 1
127             else:
128                 self.create_class(spec)
129                 self.database_schema[classname] = spec.schema()
130                 save = 1
132         for classname in self.database_schema.keys():
133             if not self.classes.has_key(classname):
134                 self.drop_class(classname)
136         # update the database version of the schema
137         if save:
138             self.sql('delete from schema')
139             self.save_dbschema(self.database_schema)
141         # reindex the db if necessary
142         if self.indexer.should_reindex():
143             self.reindex()
145         # commit
146         self.conn.commit()
148         # figure the "curuserid"
149         if self.journaltag is None:
150             self.curuserid = None
151         elif self.journaltag == 'admin':
152             # admin user may not exist, but always has ID 1
153             self.curuserid = '1'
154         else:
155             self.curuserid = self.user.lookup(self.journaltag)
157     def reindex(self):
158         for klass in self.classes.values():
159             for nodeid in klass.list():
160                 klass.index(nodeid)
161         self.indexer.save_index()
163     def determine_columns(self, properties):
164         ''' Figure the column names and multilink properties from the spec
166             "properties" is a list of (name, prop) where prop may be an
167             instance of a hyperdb "type" _or_ a string repr of that type.
168         '''
169         cols = ['_activity', '_creator', '_creation']
170         mls = []
171         # add the multilinks separately
172         for col, prop in properties:
173             if isinstance(prop, Multilink):
174                 mls.append(col)
175             elif isinstance(prop, type('')) and prop.find('Multilink') != -1:
176                 mls.append(col)
177             else:
178                 cols.append('_'+col)
179         cols.sort()
180         return cols, mls
182     def update_class(self, spec, old_spec):
183         ''' Determine the differences between the current spec and the
184             database version of the spec, and update where necessary
185         '''
186         new_spec = spec
187         new_has = new_spec.properties.has_key
189         new_spec = new_spec.schema()
190         new_spec[1].sort()
191         old_spec[1].sort()
192         if new_spec == old_spec:
193             # no changes
194             return 0
196         if __debug__:
197             print >>hyperdb.DEBUG, 'update_class FIRING'
199         # key property changed?
200         if old_spec[0] != new_spec[0]:
201             if __debug__:
202                 print >>hyperdb.DEBUG, 'update_class setting keyprop', `spec[0]`
203             # XXX turn on indexing for the key property
205         # detect multilinks that have been removed, and drop their table
206         old_has = {}
207         for name,prop in old_spec[1]:
208             old_has[name] = 1
209             if not new_has(name) and isinstance(prop, Multilink):
210                 # it's a multilink, and it's been removed - drop the old
211                 # table
212                 sql = 'drop table %s_%s'%(spec.classname, prop)
213                 if __debug__:
214                     print >>hyperdb.DEBUG, 'update_class', (self, sql)
215                 self.cursor.execute(sql)
216                 continue
217         old_has = old_has.has_key
219         # now figure how we populate the new table
220         fetch = ['_activity', '_creation', '_creator']
221         properties = spec.getprops()
222         for propname,x in new_spec[1]:
223             prop = properties[propname]
224             if isinstance(prop, Multilink):
225                 if not old_has(propname):
226                     # we need to create the new table
227                     self.create_multilink_table(spec, propname)
228             elif old_has(propname):
229                 # we copy this col over from the old table
230                 fetch.append('_'+propname)
232         # select the data out of the old table
233         fetch.append('id')
234         fetch.append('__retired__')
235         fetchcols = ','.join(fetch)
236         cn = spec.classname
237         sql = 'select %s from _%s'%(fetchcols, cn)
238         if __debug__:
239             print >>hyperdb.DEBUG, 'update_class', (self, sql)
240         self.cursor.execute(sql)
241         olddata = self.cursor.fetchall()
243         # drop the old table
244         self.cursor.execute('drop table _%s'%cn)
246         # create the new table
247         self.create_class_table(spec)
249         if olddata:
250             # do the insert
251             args = ','.join([self.arg for x in fetch])
252             sql = 'insert into _%s (%s) values (%s)'%(cn, fetchcols, args)
253             if __debug__:
254                 print >>hyperdb.DEBUG, 'update_class', (self, sql, olddata[0])
255             for entry in olddata:
256                 self.cursor.execute(sql, tuple(entry))
258         return 1
260     def create_class_table(self, spec):
261         ''' create the class table for the given spec
262         '''
263         cols, mls = self.determine_columns(spec.properties.items())
265         # add on our special columns
266         cols.append('id')
267         cols.append('__retired__')
269         # create the base table
270         scols = ','.join(['%s varchar'%x for x in cols])
271         sql = 'create table _%s (%s)'%(spec.classname, scols)
272         if __debug__:
273             print >>hyperdb.DEBUG, 'create_class', (self, sql)
274         self.cursor.execute(sql)
276         return cols, mls
278     def create_journal_table(self, spec):
279         ''' create the journal table for a class given the spec and 
280             already-determined cols
281         '''
282         # journal table
283         cols = ','.join(['%s varchar'%x
284             for x in 'nodeid date tag action params'.split()])
285         sql = 'create table %s__journal (%s)'%(spec.classname, cols)
286         if __debug__:
287             print >>hyperdb.DEBUG, 'create_class', (self, sql)
288         self.cursor.execute(sql)
290     def create_multilink_table(self, spec, ml):
291         ''' Create a multilink table for the "ml" property of the class
292             given by the spec
293         '''
294         sql = 'create table %s_%s (linkid varchar, nodeid varchar)'%(
295             spec.classname, ml)
296         if __debug__:
297             print >>hyperdb.DEBUG, 'create_class', (self, sql)
298         self.cursor.execute(sql)
300     def create_class(self, spec):
301         ''' Create a database table according to the given spec.
302         '''
303         cols, mls = self.create_class_table(spec)
304         self.create_journal_table(spec)
306         # now create the multilink tables
307         for ml in mls:
308             self.create_multilink_table(spec, ml)
310         # ID counter
311         sql = 'insert into ids (name, num) values (%s,%s)'%(self.arg, self.arg)
312         vals = (spec.classname, 1)
313         if __debug__:
314             print >>hyperdb.DEBUG, 'create_class', (self, sql, vals)
315         self.cursor.execute(sql, vals)
317     def drop_class(self, spec):
318         ''' Drop the given table from the database.
320             Drop the journal and multilink tables too.
321         '''
322         # figure the multilinks
323         mls = []
324         for col, prop in spec.properties.items():
325             if isinstance(prop, Multilink):
326                 mls.append(col)
328         sql = 'drop table _%s'%spec.classname
329         if __debug__:
330             print >>hyperdb.DEBUG, 'drop_class', (self, sql)
331         self.cursor.execute(sql)
333         sql = 'drop table %s__journal'%spec.classname
334         if __debug__:
335             print >>hyperdb.DEBUG, 'drop_class', (self, sql)
336         self.cursor.execute(sql)
338         for ml in mls:
339             sql = 'drop table %s_%s'%(spec.classname, ml)
340             if __debug__:
341                 print >>hyperdb.DEBUG, 'drop_class', (self, sql)
342             self.cursor.execute(sql)
344     #
345     # Classes
346     #
347     def __getattr__(self, classname):
348         ''' A convenient way of calling self.getclass(classname).
349         '''
350         if self.classes.has_key(classname):
351             if __debug__:
352                 print >>hyperdb.DEBUG, '__getattr__', (self, classname)
353             return self.classes[classname]
354         raise AttributeError, classname
356     def addclass(self, cl):
357         ''' Add a Class to the hyperdatabase.
358         '''
359         if __debug__:
360             print >>hyperdb.DEBUG, 'addclass', (self, cl)
361         cn = cl.classname
362         if self.classes.has_key(cn):
363             raise ValueError, cn
364         self.classes[cn] = cl
366     def getclasses(self):
367         ''' Return a list of the names of all existing classes.
368         '''
369         if __debug__:
370             print >>hyperdb.DEBUG, 'getclasses', (self,)
371         l = self.classes.keys()
372         l.sort()
373         return l
375     def getclass(self, classname):
376         '''Get the Class object representing a particular class.
378         If 'classname' is not a valid class name, a KeyError is raised.
379         '''
380         if __debug__:
381             print >>hyperdb.DEBUG, 'getclass', (self, classname)
382         try:
383             return self.classes[classname]
384         except KeyError:
385             raise KeyError, 'There is no class called "%s"'%classname
387     def clear(self):
388         ''' Delete all database contents.
390             Note: I don't commit here, which is different behaviour to the
391             "nuke from orbit" behaviour in the *dbms.
392         '''
393         if __debug__:
394             print >>hyperdb.DEBUG, 'clear', (self,)
395         for cn in self.classes.keys():
396             sql = 'delete from _%s'%cn
397             if __debug__:
398                 print >>hyperdb.DEBUG, 'clear', (self, sql)
399             self.cursor.execute(sql)
401     #
402     # Node IDs
403     #
404     def newid(self, classname):
405         ''' Generate a new id for the given class
406         '''
407         # get the next ID
408         sql = 'select num from ids where name=%s'%self.arg
409         if __debug__:
410             print >>hyperdb.DEBUG, 'newid', (self, sql, classname)
411         self.cursor.execute(sql, (classname, ))
412         newid = self.cursor.fetchone()[0]
414         # update the counter
415         sql = 'update ids set num=%s where name=%s'%(self.arg, self.arg)
416         vals = (int(newid)+1, classname)
417         if __debug__:
418             print >>hyperdb.DEBUG, 'newid', (self, sql, vals)
419         self.cursor.execute(sql, vals)
421         # return as string
422         return str(newid)
424     def setid(self, classname, setid):
425         ''' Set the id counter: used during import of database
426         '''
427         sql = 'update ids set num=%s where name=%s'%(self.arg, self.arg)
428         vals = (setid, classname)
429         if __debug__:
430             print >>hyperdb.DEBUG, 'setid', (self, sql, vals)
431         self.cursor.execute(sql, vals)
433     #
434     # Nodes
435     #
436     def addnode(self, classname, nodeid, node):
437         ''' Add the specified node to its class's db.
438         '''
439         if __debug__:
440             print >>hyperdb.DEBUG, 'addnode', (self, classname, nodeid, node)
442         # determine the column definitions and multilink tables
443         cl = self.classes[classname]
444         cols, mls = self.determine_columns(cl.properties.items())
446         # we'll be supplied these props if we're doing an import
447         if not node.has_key('creator'):
448             # add in the "calculated" properties (dupe so we don't affect
449             # calling code's node assumptions)
450             node = node.copy()
451             node['creation'] = node['activity'] = date.Date()
452             node['creator'] = self.curuserid
454         # default the non-multilink columns
455         for col, prop in cl.properties.items():
456             if not node.has_key(col):
457                 if isinstance(prop, Multilink):
458                     node[col] = []
459                 else:
460                     node[col] = None
462         # clear this node out of the cache if it's in there
463         key = (classname, nodeid)
464         if self.cache.has_key(key):
465             del self.cache[key]
466             self.cache_lru.remove(key)
468         # make the node data safe for the DB
469         node = self.serialise(classname, node)
471         # make sure the ordering is correct for column name -> column value
472         vals = tuple([node[col[1:]] for col in cols]) + (nodeid, 0)
473         s = ','.join([self.arg for x in cols]) + ',%s,%s'%(self.arg, self.arg)
474         cols = ','.join(cols) + ',id,__retired__'
476         # perform the inserts
477         sql = 'insert into _%s (%s) values (%s)'%(classname, cols, s)
478         if __debug__:
479             print >>hyperdb.DEBUG, 'addnode', (self, sql, vals)
480         self.cursor.execute(sql, vals)
482         # insert the multilink rows
483         for col in mls:
484             t = '%s_%s'%(classname, col)
485             for entry in node[col]:
486                 sql = 'insert into %s (linkid, nodeid) values (%s,%s)'%(t,
487                     self.arg, self.arg)
488                 self.sql(sql, (entry, nodeid))
490         # make sure we do the commit-time extra stuff for this node
491         self.transactions.append((self.doSaveNode, (classname, nodeid, node)))
493     def setnode(self, classname, nodeid, values, multilink_changes):
494         ''' Change the specified node.
495         '''
496         if __debug__:
497             print >>hyperdb.DEBUG, 'setnode', (self, classname, nodeid, values)
499         # clear this node out of the cache if it's in there
500         key = (classname, nodeid)
501         if self.cache.has_key(key):
502             del self.cache[key]
503             self.cache_lru.remove(key)
505         # add the special props
506         values = values.copy()
507         values['activity'] = date.Date()
509         # make db-friendly
510         values = self.serialise(classname, values)
512         cl = self.classes[classname]
513         cols = []
514         mls = []
515         # add the multilinks separately
516         props = cl.getprops()
517         for col in values.keys():
518             prop = props[col]
519             if isinstance(prop, Multilink):
520                 mls.append(col)
521             else:
522                 cols.append('_'+col)
523         cols.sort()
525         # if there's any updates to regular columns, do them
526         if cols:
527             # make sure the ordering is correct for column name -> column value
528             sqlvals = tuple([values[col[1:]] for col in cols]) + (nodeid,)
529             s = ','.join(['%s=%s'%(x, self.arg) for x in cols])
530             cols = ','.join(cols)
532             # perform the update
533             sql = 'update _%s set %s where id=%s'%(classname, s, self.arg)
534             if __debug__:
535                 print >>hyperdb.DEBUG, 'setnode', (self, sql, sqlvals)
536             self.cursor.execute(sql, sqlvals)
538         # now the fun bit, updating the multilinks ;)
539         for col, (add, remove) in multilink_changes.items():
540             tn = '%s_%s'%(classname, col)
541             if add:
542                 sql = 'insert into %s (nodeid, linkid) values (%s,%s)'%(tn,
543                     self.arg, self.arg)
544                 for addid in add:
545                     self.sql(sql, (nodeid, addid))
546             if remove:
547                 sql = 'delete from %s where nodeid=%s and linkid=%s'%(tn,
548                     self.arg, self.arg)
549                 for removeid in remove:
550                     self.sql(sql, (nodeid, removeid))
552         # make sure we do the commit-time extra stuff for this node
553         self.transactions.append((self.doSaveNode, (classname, nodeid, values)))
555     def getnode(self, classname, nodeid):
556         ''' Get a node from the database.
557         '''
558         if __debug__:
559             print >>hyperdb.DEBUG, 'getnode', (self, classname, nodeid)
561         # see if we have this node cached
562         key = (classname, nodeid)
563         if self.cache.has_key(key):
564             # push us back to the top of the LRU
565             self.cache_lru.remove(key)
566             self.cache_lru.insert(0, key)
567             # return the cached information
568             return self.cache[key]
570         # figure the columns we're fetching
571         cl = self.classes[classname]
572         cols, mls = self.determine_columns(cl.properties.items())
573         scols = ','.join(cols)
575         # perform the basic property fetch
576         sql = 'select %s from _%s where id=%s'%(scols, classname, self.arg)
577         self.sql(sql, (nodeid,))
579         values = self.sql_fetchone()
580         if values is None:
581             raise IndexError, 'no such %s node %s'%(classname, nodeid)
583         # make up the node
584         node = {}
585         for col in range(len(cols)):
586             node[cols[col][1:]] = values[col]
588         # now the multilinks
589         for col in mls:
590             # get the link ids
591             sql = 'select linkid from %s_%s where nodeid=%s'%(classname, col,
592                 self.arg)
593             self.cursor.execute(sql, (nodeid,))
594             # extract the first column from the result
595             node[col] = [x[0] for x in self.cursor.fetchall()]
597         # un-dbificate the node data
598         node = self.unserialise(classname, node)
600         # save off in the cache
601         key = (classname, nodeid)
602         self.cache[key] = node
603         # update the LRU
604         self.cache_lru.insert(0, key)
605         if len(self.cache_lru) > ROW_CACHE_SIZE:
606             del self.cache[self.cache_lru.pop()]
608         return node
610     def destroynode(self, classname, nodeid):
611         '''Remove a node from the database. Called exclusively by the
612            destroy() method on Class.
613         '''
614         if __debug__:
615             print >>hyperdb.DEBUG, 'destroynode', (self, classname, nodeid)
617         # make sure the node exists
618         if not self.hasnode(classname, nodeid):
619             raise IndexError, '%s has no node %s'%(classname, nodeid)
621         # see if we have this node cached
622         if self.cache.has_key((classname, nodeid)):
623             del self.cache[(classname, nodeid)]
625         # see if there's any obvious commit actions that we should get rid of
626         for entry in self.transactions[:]:
627             if entry[1][:2] == (classname, nodeid):
628                 self.transactions.remove(entry)
630         # now do the SQL
631         sql = 'delete from _%s where id=%s'%(classname, self.arg)
632         self.sql(sql, (nodeid,))
634         # remove from multilnks
635         cl = self.getclass(classname)
636         x, mls = self.determine_columns(cl.properties.items())
637         for col in mls:
638             # get the link ids
639             sql = 'delete from %s_%s where nodeid=%s'%(classname, col, self.arg)
640             self.cursor.execute(sql, (nodeid,))
642         # remove journal entries
643         sql = 'delete from %s__journal where nodeid=%s'%(classname, self.arg)
644         self.sql(sql, (nodeid,))
646     def serialise(self, classname, node):
647         '''Copy the node contents, converting non-marshallable data into
648            marshallable data.
649         '''
650         if __debug__:
651             print >>hyperdb.DEBUG, 'serialise', classname, node
652         properties = self.getclass(classname).getprops()
653         d = {}
654         for k, v in node.items():
655             # if the property doesn't exist, or is the "retired" flag then
656             # it won't be in the properties dict
657             if not properties.has_key(k):
658                 d[k] = v
659                 continue
661             # get the property spec
662             prop = properties[k]
664             if isinstance(prop, Password) and v is not None:
665                 d[k] = str(v)
666             elif isinstance(prop, Date) and v is not None:
667                 d[k] = v.serialise()
668             elif isinstance(prop, Interval) and v is not None:
669                 d[k] = v.serialise()
670             else:
671                 d[k] = v
672         return d
674     def unserialise(self, classname, node):
675         '''Decode the marshalled node data
676         '''
677         if __debug__:
678             print >>hyperdb.DEBUG, 'unserialise', classname, node
679         properties = self.getclass(classname).getprops()
680         d = {}
681         for k, v in node.items():
682             # if the property doesn't exist, or is the "retired" flag then
683             # it won't be in the properties dict
684             if not properties.has_key(k):
685                 d[k] = v
686                 continue
688             # get the property spec
689             prop = properties[k]
691             if isinstance(prop, Date) and v is not None:
692                 d[k] = date.Date(v)
693             elif isinstance(prop, Interval) and v is not None:
694                 d[k] = date.Interval(v)
695             elif isinstance(prop, Password) and v is not None:
696                 p = password.Password()
697                 p.unpack(v)
698                 d[k] = p
699             elif (isinstance(prop, Boolean) or isinstance(prop, Number)) and v is not None:
700                 d[k]=float(v)
701             else:
702                 d[k] = v
703         return d
705     def hasnode(self, classname, nodeid):
706         ''' Determine if the database has a given node.
707         '''
708         sql = 'select count(*) from _%s where id=%s'%(classname, self.arg)
709         if __debug__:
710             print >>hyperdb.DEBUG, 'hasnode', (self, sql, nodeid)
711         self.cursor.execute(sql, (nodeid,))
712         return int(self.cursor.fetchone()[0])
714     def countnodes(self, classname):
715         ''' Count the number of nodes that exist for a particular Class.
716         '''
717         sql = 'select count(*) from _%s'%classname
718         if __debug__:
719             print >>hyperdb.DEBUG, 'countnodes', (self, sql)
720         self.cursor.execute(sql)
721         return self.cursor.fetchone()[0]
723     def addjournal(self, classname, nodeid, action, params, creator=None,
724             creation=None):
725         ''' Journal the Action
726         'action' may be:
728             'create' or 'set' -- 'params' is a dictionary of property values
729             'link' or 'unlink' -- 'params' is (classname, nodeid, propname)
730             'retire' -- 'params' is None
731         '''
732         # serialise the parameters now if necessary
733         if isinstance(params, type({})):
734             if action in ('set', 'create'):
735                 params = self.serialise(classname, params)
737         # handle supply of the special journalling parameters (usually
738         # supplied on importing an existing database)
739         if creator:
740             journaltag = creator
741         else:
742             journaltag = self.curuserid
743         if creation:
744             journaldate = creation.serialise()
745         else:
746             journaldate = date.Date().serialise()
748         # create the journal entry
749         cols = ','.join('nodeid date tag action params'.split())
751         if __debug__:
752             print >>hyperdb.DEBUG, 'addjournal', (nodeid, journaldate,
753                 journaltag, action, params)
755         self.save_journal(classname, cols, nodeid, journaldate,
756             journaltag, action, params)
758     def save_journal(self, classname, cols, nodeid, journaldate,
759             journaltag, action, params):
760         ''' Save the journal entry to the database
761         '''
762         raise NotImplemented
764     def getjournal(self, classname, nodeid):
765         ''' get the journal for id
766         '''
767         # make sure the node exists
768         if not self.hasnode(classname, nodeid):
769             raise IndexError, '%s has no node %s'%(classname, nodeid)
771         cols = ','.join('nodeid date tag action params'.split())
772         return self.load_journal(classname, cols, nodeid)
774     def load_journal(self, classname, cols, nodeid):
775         ''' Load the journal from the database
776         '''
777         raise NotImplemented
779     def pack(self, pack_before):
780         ''' Delete all journal entries except "create" before 'pack_before'.
781         '''
782         # get a 'yyyymmddhhmmss' version of the date
783         date_stamp = pack_before.serialise()
785         # do the delete
786         for classname in self.classes.keys():
787             sql = "delete from %s__journal where date<%s and "\
788                 "action<>'create'"%(classname, self.arg)
789             if __debug__:
790                 print >>hyperdb.DEBUG, 'pack', (self, sql, date_stamp)
791             self.cursor.execute(sql, (date_stamp,))
793     def sql_commit(self):
794         ''' Actually commit to the database.
795         '''
796         self.conn.commit()
798     def commit(self):
799         ''' Commit the current transactions.
801         Save all data changed since the database was opened or since the
802         last commit() or rollback().
803         '''
804         if __debug__:
805             print >>hyperdb.DEBUG, 'commit', (self,)
807         # commit the database
808         self.sql_commit()
810         # now, do all the other transaction stuff
811         reindex = {}
812         for method, args in self.transactions:
813             reindex[method(*args)] = 1
815         # reindex the nodes that request it
816         for classname, nodeid in filter(None, reindex.keys()):
817             print >>hyperdb.DEBUG, 'commit.reindex', (classname, nodeid)
818             self.getclass(classname).index(nodeid)
820         # save the indexer state
821         self.indexer.save_index()
823         # clear out the transactions
824         self.transactions = []
826     def rollback(self):
827         ''' Reverse all actions from the current transaction.
829         Undo all the changes made since the database was opened or the last
830         commit() or rollback() was performed.
831         '''
832         if __debug__:
833             print >>hyperdb.DEBUG, 'rollback', (self,)
835         # roll back
836         self.conn.rollback()
838         # roll back "other" transaction stuff
839         for method, args in self.transactions:
840             # delete temporary files
841             if method == self.doStoreFile:
842                 self.rollbackStoreFile(*args)
843         self.transactions = []
845         # clear the cache
846         self.clearCache()
848     def doSaveNode(self, classname, nodeid, node):
849         ''' dummy that just generates a reindex event
850         '''
851         # return the classname, nodeid so we reindex this content
852         return (classname, nodeid)
854     def close(self):
855         ''' Close off the connection.
856         '''
857         self.conn.close()
858         if self.lockfile is not None:
859             locking.release_lock(self.lockfile)
860         if self.lockfile is not None:
861             self.lockfile.close()
862             self.lockfile = None
865 # The base Class class
867 class Class(hyperdb.Class):
868     ''' The handle to a particular class of nodes in a hyperdatabase.
869         
870         All methods except __repr__ and getnode must be implemented by a
871         concrete backend Class.
872     '''
874     def __init__(self, db, classname, **properties):
875         '''Create a new class with a given name and property specification.
877         'classname' must not collide with the name of an existing class,
878         or a ValueError is raised.  The keyword arguments in 'properties'
879         must map names to property objects, or a TypeError is raised.
880         '''
881         if (properties.has_key('creation') or properties.has_key('activity')
882                 or properties.has_key('creator')):
883             raise ValueError, '"creation", "activity" and "creator" are '\
884                 'reserved'
886         self.classname = classname
887         self.properties = properties
888         self.db = weakref.proxy(db)       # use a weak ref to avoid circularity
889         self.key = ''
891         # should we journal changes (default yes)
892         self.do_journal = 1
894         # do the db-related init stuff
895         db.addclass(self)
897         self.auditors = {'create': [], 'set': [], 'retire': [], 'restore': []}
898         self.reactors = {'create': [], 'set': [], 'retire': [], 'restore': []}
900     def schema(self):
901         ''' A dumpable version of the schema that we can store in the
902             database
903         '''
904         return (self.key, [(x, repr(y)) for x,y in self.properties.items()])
906     def enableJournalling(self):
907         '''Turn journalling on for this class
908         '''
909         self.do_journal = 1
911     def disableJournalling(self):
912         '''Turn journalling off for this class
913         '''
914         self.do_journal = 0
916     # Editing nodes:
917     def create(self, **propvalues):
918         ''' Create a new node of this class and return its id.
920         The keyword arguments in 'propvalues' map property names to values.
922         The values of arguments must be acceptable for the types of their
923         corresponding properties or a TypeError is raised.
924         
925         If this class has a key property, it must be present and its value
926         must not collide with other key strings or a ValueError is raised.
927         
928         Any other properties on this class that are missing from the
929         'propvalues' dictionary are set to None.
930         
931         If an id in a link or multilink property does not refer to a valid
932         node, an IndexError is raised.
933         '''
934         self.fireAuditors('create', None, propvalues)
935         newid = self.create_inner(**propvalues)
936         self.fireReactors('create', newid, None)
937         return newid
938     
939     def create_inner(self, **propvalues):
940         ''' Called by create, in-between the audit and react calls.
941         '''
942         if propvalues.has_key('id'):
943             raise KeyError, '"id" is reserved'
945         if self.db.journaltag is None:
946             raise DatabaseError, 'Database open read-only'
948         if propvalues.has_key('creation') or propvalues.has_key('activity'):
949             raise KeyError, '"creation" and "activity" are reserved'
951         # new node's id
952         newid = self.db.newid(self.classname)
954         # validate propvalues
955         num_re = re.compile('^\d+$')
956         for key, value in propvalues.items():
957             if key == self.key:
958                 try:
959                     self.lookup(value)
960                 except KeyError:
961                     pass
962                 else:
963                     raise ValueError, 'node with key "%s" exists'%value
965             # try to handle this property
966             try:
967                 prop = self.properties[key]
968             except KeyError:
969                 raise KeyError, '"%s" has no property "%s"'%(self.classname,
970                     key)
972             if value is not None and isinstance(prop, Link):
973                 if type(value) != type(''):
974                     raise ValueError, 'link value must be String'
975                 link_class = self.properties[key].classname
976                 # if it isn't a number, it's a key
977                 if not num_re.match(value):
978                     try:
979                         value = self.db.classes[link_class].lookup(value)
980                     except (TypeError, KeyError):
981                         raise IndexError, 'new property "%s": %s not a %s'%(
982                             key, value, link_class)
983                 elif not self.db.getclass(link_class).hasnode(value):
984                     raise IndexError, '%s has no node %s'%(link_class, value)
986                 # save off the value
987                 propvalues[key] = value
989                 # register the link with the newly linked node
990                 if self.do_journal and self.properties[key].do_journal:
991                     self.db.addjournal(link_class, value, 'link',
992                         (self.classname, newid, key))
994             elif isinstance(prop, Multilink):
995                 if type(value) != type([]):
996                     raise TypeError, 'new property "%s" not a list of ids'%key
998                 # clean up and validate the list of links
999                 link_class = self.properties[key].classname
1000                 l = []
1001                 for entry in value:
1002                     if type(entry) != type(''):
1003                         raise ValueError, '"%s" multilink value (%r) '\
1004                             'must contain Strings'%(key, value)
1005                     # if it isn't a number, it's a key
1006                     if not num_re.match(entry):
1007                         try:
1008                             entry = self.db.classes[link_class].lookup(entry)
1009                         except (TypeError, KeyError):
1010                             raise IndexError, 'new property "%s": %s not a %s'%(
1011                                 key, entry, self.properties[key].classname)
1012                     l.append(entry)
1013                 value = l
1014                 propvalues[key] = value
1016                 # handle additions
1017                 for nodeid in value:
1018                     if not self.db.getclass(link_class).hasnode(nodeid):
1019                         raise IndexError, '%s has no node %s'%(link_class,
1020                             nodeid)
1021                     # register the link with the newly linked node
1022                     if self.do_journal and self.properties[key].do_journal:
1023                         self.db.addjournal(link_class, nodeid, 'link',
1024                             (self.classname, newid, key))
1026             elif isinstance(prop, String):
1027                 if type(value) != type('') and type(value) != type(u''):
1028                     raise TypeError, 'new property "%s" not a string'%key
1030             elif isinstance(prop, Password):
1031                 if not isinstance(value, password.Password):
1032                     raise TypeError, 'new property "%s" not a Password'%key
1034             elif isinstance(prop, Date):
1035                 if value is not None and not isinstance(value, date.Date):
1036                     raise TypeError, 'new property "%s" not a Date'%key
1038             elif isinstance(prop, Interval):
1039                 if value is not None and not isinstance(value, date.Interval):
1040                     raise TypeError, 'new property "%s" not an Interval'%key
1042             elif value is not None and isinstance(prop, Number):
1043                 try:
1044                     float(value)
1045                 except ValueError:
1046                     raise TypeError, 'new property "%s" not numeric'%key
1048             elif value is not None and isinstance(prop, Boolean):
1049                 try:
1050                     int(value)
1051                 except ValueError:
1052                     raise TypeError, 'new property "%s" not boolean'%key
1054         # make sure there's data where there needs to be
1055         for key, prop in self.properties.items():
1056             if propvalues.has_key(key):
1057                 continue
1058             if key == self.key:
1059                 raise ValueError, 'key property "%s" is required'%key
1060             if isinstance(prop, Multilink):
1061                 propvalues[key] = []
1062             else:
1063                 propvalues[key] = None
1065         # done
1066         self.db.addnode(self.classname, newid, propvalues)
1067         if self.do_journal:
1068             self.db.addjournal(self.classname, newid, 'create', {})
1070         return newid
1072     def export_list(self, propnames, nodeid):
1073         ''' Export a node - generate a list of CSV-able data in the order
1074             specified by propnames for the given node.
1075         '''
1076         properties = self.getprops()
1077         l = []
1078         for prop in propnames:
1079             proptype = properties[prop]
1080             value = self.get(nodeid, prop)
1081             # "marshal" data where needed
1082             if value is None:
1083                 pass
1084             elif isinstance(proptype, hyperdb.Date):
1085                 value = value.get_tuple()
1086             elif isinstance(proptype, hyperdb.Interval):
1087                 value = value.get_tuple()
1088             elif isinstance(proptype, hyperdb.Password):
1089                 value = str(value)
1090             l.append(repr(value))
1091         l.append(self.is_retired(nodeid))
1092         return l
1094     def import_list(self, propnames, proplist):
1095         ''' Import a node - all information including "id" is present and
1096             should not be sanity checked. Triggers are not triggered. The
1097             journal should be initialised using the "creator" and "created"
1098             information.
1100             Return the nodeid of the node imported.
1101         '''
1102         if self.db.journaltag is None:
1103             raise DatabaseError, 'Database open read-only'
1104         properties = self.getprops()
1106         # make the new node's property map
1107         d = {}
1108         retire = 0
1109         newid = None
1110         for i in range(len(propnames)):
1111             # Use eval to reverse the repr() used to output the CSV
1112             value = eval(proplist[i])
1114             # Figure the property for this column
1115             propname = propnames[i]
1117             # "unmarshal" where necessary
1118             if propname == 'id':
1119                 newid = value
1120                 continue
1121             elif propname == 'is retired':
1122                 # is the item retired?
1123                 if int(value):
1124                     retire = 1
1125                 continue
1126             elif value is None:
1127                 d[propname] = None
1128                 continue
1130             prop = properties[propname]
1131             if value is None:
1132                 # don't set Nones
1133                 continue
1134             elif isinstance(prop, hyperdb.Date):
1135                 value = date.Date(value)
1136             elif isinstance(prop, hyperdb.Interval):
1137                 value = date.Interval(value)
1138             elif isinstance(prop, hyperdb.Password):
1139                 pwd = password.Password()
1140                 pwd.unpack(value)
1141                 value = pwd
1142             d[propname] = value
1144         # get a new id if necessary
1145         if newid is None:
1146             newid = self.db.newid(self.classname)
1148         # retire?
1149         if retire:
1150             # use the arg for __retired__ to cope with any odd database type
1151             # conversion (hello, sqlite)
1152             sql = 'update _%s set __retired__=%s where id=%s'%(self.classname,
1153                 self.db.arg, self.db.arg)
1154             if __debug__:
1155                 print >>hyperdb.DEBUG, 'retire', (self, sql, newid)
1156             self.db.cursor.execute(sql, (1, newid))
1158         # add the node and journal
1159         self.db.addnode(self.classname, newid, d)
1161         # extract the extraneous journalling gumpf and nuke it
1162         if d.has_key('creator'):
1163             creator = d['creator']
1164             del d['creator']
1165         else:
1166             creator = None
1167         if d.has_key('creation'):
1168             creation = d['creation']
1169             del d['creation']
1170         else:
1171             creation = None
1172         if d.has_key('activity'):
1173             del d['activity']
1174         self.db.addjournal(self.classname, newid, 'create', {}, creator,
1175             creation)
1176         return newid
1178     _marker = []
1179     def get(self, nodeid, propname, default=_marker, cache=1):
1180         '''Get the value of a property on an existing node of this class.
1182         'nodeid' must be the id of an existing node of this class or an
1183         IndexError is raised.  'propname' must be the name of a property
1184         of this class or a KeyError is raised.
1186         'cache' indicates whether the transaction cache should be queried
1187         for the node. If the node has been modified and you need to
1188         determine what its values prior to modification are, you need to
1189         set cache=0.
1190         '''
1191         if propname == 'id':
1192             return nodeid
1194         # get the node's dict
1195         d = self.db.getnode(self.classname, nodeid)
1197         if propname == 'creation':
1198             if d.has_key('creation'):
1199                 return d['creation']
1200             else:
1201                 return date.Date()
1202         if propname == 'activity':
1203             if d.has_key('activity'):
1204                 return d['activity']
1205             else:
1206                 return date.Date()
1207         if propname == 'creator':
1208             if d.has_key('creator'):
1209                 return d['creator']
1210             else:
1211                 return self.db.curuserid
1213         # get the property (raises KeyErorr if invalid)
1214         prop = self.properties[propname]
1216         if not d.has_key(propname):
1217             if default is self._marker:
1218                 if isinstance(prop, Multilink):
1219                     return []
1220                 else:
1221                     return None
1222             else:
1223                 return default
1225         # don't pass our list to other code
1226         if isinstance(prop, Multilink):
1227             return d[propname][:]
1229         return d[propname]
1231     def getnode(self, nodeid, cache=1):
1232         ''' Return a convenience wrapper for the node.
1234         'nodeid' must be the id of an existing node of this class or an
1235         IndexError is raised.
1237         'cache' indicates whether the transaction cache should be queried
1238         for the node. If the node has been modified and you need to
1239         determine what its values prior to modification are, you need to
1240         set cache=0.
1241         '''
1242         return Node(self, nodeid, cache=cache)
1244     def set(self, nodeid, **propvalues):
1245         '''Modify a property on an existing node of this class.
1246         
1247         'nodeid' must be the id of an existing node of this class or an
1248         IndexError is raised.
1250         Each key in 'propvalues' must be the name of a property of this
1251         class or a KeyError is raised.
1253         All values in 'propvalues' must be acceptable types for their
1254         corresponding properties or a TypeError is raised.
1256         If the value of the key property is set, it must not collide with
1257         other key strings or a ValueError is raised.
1259         If the value of a Link or Multilink property contains an invalid
1260         node id, a ValueError is raised.
1261         '''
1262         if not propvalues:
1263             return propvalues
1265         if propvalues.has_key('creation') or propvalues.has_key('activity'):
1266             raise KeyError, '"creation" and "activity" are reserved'
1268         if propvalues.has_key('id'):
1269             raise KeyError, '"id" is reserved'
1271         if self.db.journaltag is None:
1272             raise DatabaseError, 'Database open read-only'
1274         self.fireAuditors('set', nodeid, propvalues)
1275         # Take a copy of the node dict so that the subsequent set
1276         # operation doesn't modify the oldvalues structure.
1277         # XXX used to try the cache here first
1278         oldvalues = copy.deepcopy(self.db.getnode(self.classname, nodeid))
1280         node = self.db.getnode(self.classname, nodeid)
1281         if self.is_retired(nodeid):
1282             raise IndexError, 'Requested item is retired'
1283         num_re = re.compile('^\d+$')
1285         # if the journal value is to be different, store it in here
1286         journalvalues = {}
1288         # remember the add/remove stuff for multilinks, making it easier
1289         # for the Database layer to do its stuff
1290         multilink_changes = {}
1292         for propname, value in propvalues.items():
1293             # check to make sure we're not duplicating an existing key
1294             if propname == self.key and node[propname] != value:
1295                 try:
1296                     self.lookup(value)
1297                 except KeyError:
1298                     pass
1299                 else:
1300                     raise ValueError, 'node with key "%s" exists'%value
1302             # this will raise the KeyError if the property isn't valid
1303             # ... we don't use getprops() here because we only care about
1304             # the writeable properties.
1305             try:
1306                 prop = self.properties[propname]
1307             except KeyError:
1308                 raise KeyError, '"%s" has no property named "%s"'%(
1309                     self.classname, propname)
1311             # if the value's the same as the existing value, no sense in
1312             # doing anything
1313             current = node.get(propname, None)
1314             if value == current:
1315                 del propvalues[propname]
1316                 continue
1317             journalvalues[propname] = current
1319             # do stuff based on the prop type
1320             if isinstance(prop, Link):
1321                 link_class = prop.classname
1322                 # if it isn't a number, it's a key
1323                 if value is not None and not isinstance(value, type('')):
1324                     raise ValueError, 'property "%s" link value be a string'%(
1325                         propname)
1326                 if isinstance(value, type('')) and not num_re.match(value):
1327                     try:
1328                         value = self.db.classes[link_class].lookup(value)
1329                     except (TypeError, KeyError):
1330                         raise IndexError, 'new property "%s": %s not a %s'%(
1331                             propname, value, prop.classname)
1333                 if (value is not None and
1334                         not self.db.getclass(link_class).hasnode(value)):
1335                     raise IndexError, '%s has no node %s'%(link_class, value)
1337                 if self.do_journal and prop.do_journal:
1338                     # register the unlink with the old linked node
1339                     if node[propname] is not None:
1340                         self.db.addjournal(link_class, node[propname], 'unlink',
1341                             (self.classname, nodeid, propname))
1343                     # register the link with the newly linked node
1344                     if value is not None:
1345                         self.db.addjournal(link_class, value, 'link',
1346                             (self.classname, nodeid, propname))
1348             elif isinstance(prop, Multilink):
1349                 if type(value) != type([]):
1350                     raise TypeError, 'new property "%s" not a list of'\
1351                         ' ids'%propname
1352                 link_class = self.properties[propname].classname
1353                 l = []
1354                 for entry in value:
1355                     # if it isn't a number, it's a key
1356                     if type(entry) != type(''):
1357                         raise ValueError, 'new property "%s" link value ' \
1358                             'must be a string'%propname
1359                     if not num_re.match(entry):
1360                         try:
1361                             entry = self.db.classes[link_class].lookup(entry)
1362                         except (TypeError, KeyError):
1363                             raise IndexError, 'new property "%s": %s not a %s'%(
1364                                 propname, entry,
1365                                 self.properties[propname].classname)
1366                     l.append(entry)
1367                 value = l
1368                 propvalues[propname] = value
1370                 # figure the journal entry for this property
1371                 add = []
1372                 remove = []
1374                 # handle removals
1375                 if node.has_key(propname):
1376                     l = node[propname]
1377                 else:
1378                     l = []
1379                 for id in l[:]:
1380                     if id in value:
1381                         continue
1382                     # register the unlink with the old linked node
1383                     if self.do_journal and self.properties[propname].do_journal:
1384                         self.db.addjournal(link_class, id, 'unlink',
1385                             (self.classname, nodeid, propname))
1386                     l.remove(id)
1387                     remove.append(id)
1389                 # handle additions
1390                 for id in value:
1391                     if not self.db.getclass(link_class).hasnode(id):
1392                         raise IndexError, '%s has no node %s'%(link_class, id)
1393                     if id in l:
1394                         continue
1395                     # register the link with the newly linked node
1396                     if self.do_journal and self.properties[propname].do_journal:
1397                         self.db.addjournal(link_class, id, 'link',
1398                             (self.classname, nodeid, propname))
1399                     l.append(id)
1400                     add.append(id)
1402                 # figure the journal entry
1403                 l = []
1404                 if add:
1405                     l.append(('+', add))
1406                 if remove:
1407                     l.append(('-', remove))
1408                 multilink_changes[propname] = (add, remove)
1409                 if l:
1410                     journalvalues[propname] = tuple(l)
1412             elif isinstance(prop, String):
1413                 if value is not None and type(value) != type('') and type(value) != type(u''):
1414                     raise TypeError, 'new property "%s" not a string'%propname
1416             elif isinstance(prop, Password):
1417                 if not isinstance(value, password.Password):
1418                     raise TypeError, 'new property "%s" not a Password'%propname
1419                 propvalues[propname] = value
1421             elif value is not None and isinstance(prop, Date):
1422                 if not isinstance(value, date.Date):
1423                     raise TypeError, 'new property "%s" not a Date'% propname
1424                 propvalues[propname] = value
1426             elif value is not None and isinstance(prop, Interval):
1427                 if not isinstance(value, date.Interval):
1428                     raise TypeError, 'new property "%s" not an '\
1429                         'Interval'%propname
1430                 propvalues[propname] = value
1432             elif value is not None and isinstance(prop, Number):
1433                 try:
1434                     float(value)
1435                 except ValueError:
1436                     raise TypeError, 'new property "%s" not numeric'%propname
1438             elif value is not None and isinstance(prop, Boolean):
1439                 try:
1440                     int(value)
1441                 except ValueError:
1442                     raise TypeError, 'new property "%s" not boolean'%propname
1444         # nothing to do?
1445         if not propvalues:
1446             return propvalues
1448         # do the set, and journal it
1449         self.db.setnode(self.classname, nodeid, propvalues, multilink_changes)
1451         if self.do_journal:
1452             self.db.addjournal(self.classname, nodeid, 'set', journalvalues)
1454         self.fireReactors('set', nodeid, oldvalues)
1456         return propvalues        
1458     def retire(self, nodeid):
1459         '''Retire a node.
1460         
1461         The properties on the node remain available from the get() method,
1462         and the node's id is never reused.
1463         
1464         Retired nodes are not returned by the find(), list(), or lookup()
1465         methods, and other nodes may reuse the values of their key properties.
1466         '''
1467         if self.db.journaltag is None:
1468             raise DatabaseError, 'Database open read-only'
1470         self.fireAuditors('retire', nodeid, None)
1472         # use the arg for __retired__ to cope with any odd database type
1473         # conversion (hello, sqlite)
1474         sql = 'update _%s set __retired__=%s where id=%s'%(self.classname,
1475             self.db.arg, self.db.arg)
1476         if __debug__:
1477             print >>hyperdb.DEBUG, 'retire', (self, sql, nodeid)
1478         self.db.cursor.execute(sql, (1, nodeid))
1479         if self.do_journal:
1480             self.db.addjournal(self.classname, nodeid, 'retired', None)
1482         self.fireReactors('retire', nodeid, None)
1484     def restore(self, nodeid):
1485         '''Restore a retired node.
1487         Make node available for all operations like it was before retirement.
1488         '''
1489         if self.db.journaltag is None:
1490             raise DatabaseError, 'Database open read-only'
1492         node = self.db.getnode(self.classname, nodeid)
1493         # check if key property was overrided
1494         key = self.getkey()
1495         try:
1496             id = self.lookup(node[key])
1497         except KeyError:
1498             pass
1499         else:
1500             raise KeyError, "Key property (%s) of retired node clashes with \
1501                 existing one (%s)" % (key, node[key])
1503         self.fireAuditors('restore', nodeid, None)
1504         # use the arg for __retired__ to cope with any odd database type
1505         # conversion (hello, sqlite)
1506         sql = 'update _%s set __retired__=%s where id=%s'%(self.classname,
1507             self.db.arg, self.db.arg)
1508         if __debug__:
1509             print >>hyperdb.DEBUG, 'restore', (self, sql, nodeid)
1510         self.db.cursor.execute(sql, (0, nodeid))
1511         if self.do_journal:
1512             self.db.addjournal(self.classname, nodeid, 'restored', None)
1514         self.fireReactors('restore', nodeid, None)
1515         
1516     def is_retired(self, nodeid):
1517         '''Return true if the node is rerired
1518         '''
1519         sql = 'select __retired__ from _%s where id=%s'%(self.classname,
1520             self.db.arg)
1521         if __debug__:
1522             print >>hyperdb.DEBUG, 'is_retired', (self, sql, nodeid)
1523         self.db.cursor.execute(sql, (nodeid,))
1524         return int(self.db.sql_fetchone()[0])
1526     def destroy(self, nodeid):
1527         '''Destroy a node.
1528         
1529         WARNING: this method should never be used except in extremely rare
1530                  situations where there could never be links to the node being
1531                  deleted
1532         WARNING: use retire() instead
1533         WARNING: the properties of this node will not be available ever again
1534         WARNING: really, use retire() instead
1536         Well, I think that's enough warnings. This method exists mostly to
1537         support the session storage of the cgi interface.
1539         The node is completely removed from the hyperdb, including all journal
1540         entries. It will no longer be available, and will generally break code
1541         if there are any references to the node.
1542         '''
1543         if self.db.journaltag is None:
1544             raise DatabaseError, 'Database open read-only'
1545         self.db.destroynode(self.classname, nodeid)
1547     def history(self, nodeid):
1548         '''Retrieve the journal of edits on a particular node.
1550         'nodeid' must be the id of an existing node of this class or an
1551         IndexError is raised.
1553         The returned list contains tuples of the form
1555             (nodeid, date, tag, action, params)
1557         'date' is a Timestamp object specifying the time of the change and
1558         'tag' is the journaltag specified when the database was opened.
1559         '''
1560         if not self.do_journal:
1561             raise ValueError, 'Journalling is disabled for this class'
1562         return self.db.getjournal(self.classname, nodeid)
1564     # Locating nodes:
1565     def hasnode(self, nodeid):
1566         '''Determine if the given nodeid actually exists
1567         '''
1568         return self.db.hasnode(self.classname, nodeid)
1570     def setkey(self, propname):
1571         '''Select a String property of this class to be the key property.
1573         'propname' must be the name of a String property of this class or
1574         None, or a TypeError is raised.  The values of the key property on
1575         all existing nodes must be unique or a ValueError is raised.
1576         '''
1577         # XXX create an index on the key prop column
1578         prop = self.getprops()[propname]
1579         if not isinstance(prop, String):
1580             raise TypeError, 'key properties must be String'
1581         self.key = propname
1583     def getkey(self):
1584         '''Return the name of the key property for this class or None.'''
1585         return self.key
1587     def labelprop(self, default_to_id=0):
1588         ''' Return the property name for a label for the given node.
1590         This method attempts to generate a consistent label for the node.
1591         It tries the following in order:
1592             1. key property
1593             2. "name" property
1594             3. "title" property
1595             4. first property from the sorted property name list
1596         '''
1597         k = self.getkey()
1598         if  k:
1599             return k
1600         props = self.getprops()
1601         if props.has_key('name'):
1602             return 'name'
1603         elif props.has_key('title'):
1604             return 'title'
1605         if default_to_id:
1606             return 'id'
1607         props = props.keys()
1608         props.sort()
1609         return props[0]
1611     def lookup(self, keyvalue):
1612         '''Locate a particular node by its key property and return its id.
1614         If this class has no key property, a TypeError is raised.  If the
1615         'keyvalue' matches one of the values for the key property among
1616         the nodes in this class, the matching node's id is returned;
1617         otherwise a KeyError is raised.
1618         '''
1619         if not self.key:
1620             raise TypeError, 'No key property set for class %s'%self.classname
1622         # use the arg to handle any odd database type conversion (hello,
1623         # sqlite)
1624         sql = "select id from _%s where _%s=%s and __retired__ <> %s"%(
1625             self.classname, self.key, self.db.arg, self.db.arg)
1626         self.db.sql(sql, (keyvalue, 1))
1628         # see if there was a result that's not retired
1629         row = self.db.sql_fetchone()
1630         if not row:
1631             raise KeyError, 'No key (%s) value "%s" for "%s"'%(self.key,
1632                 keyvalue, self.classname)
1634         # return the id
1635         return row[0]
1637     def find(self, **propspec):
1638         '''Get the ids of nodes in this class which link to the given nodes.
1640         'propspec' consists of keyword args propname=nodeid or
1641                    propname={nodeid:1, }
1642         'propname' must be the name of a property in this class, or a
1643         KeyError is raised.  That property must be a Link or Multilink
1644         property, or a TypeError is raised.
1646         Any node in this class whose 'propname' property links to any of the
1647         nodeids will be returned. Used by the full text indexing, which knows
1648         that "foo" occurs in msg1, msg3 and file7, so we have hits on these
1649         issues:
1651             db.issue.find(messages={'1':1,'3':1}, files={'7':1})
1652         '''
1653         if __debug__:
1654             print >>hyperdb.DEBUG, 'find', (self, propspec)
1656         # shortcut
1657         if not propspec:
1658             return []
1660         # validate the args
1661         props = self.getprops()
1662         propspec = propspec.items()
1663         for propname, nodeids in propspec:
1664             # check the prop is OK
1665             prop = props[propname]
1666             if not isinstance(prop, Link) and not isinstance(prop, Multilink):
1667                 raise TypeError, "'%s' not a Link/Multilink property"%propname
1669         # first, links
1670         where = []
1671         allvalues = ()
1672         a = self.db.arg
1673         for prop, values in propspec:
1674             if not isinstance(props[prop], hyperdb.Link):
1675                 continue
1676             if type(values) is type(''):
1677                 allvalues += (values,)
1678                 where.append('_%s = %s'%(prop, a))
1679             elif values is None:
1680                 where.append('_%s is NULL'%prop)
1681             else:
1682                 allvalues += tuple(values.keys())
1683                 where.append('_%s in (%s)'%(prop, ','.join([a]*len(values))))
1684         tables = []
1685         if where:
1686             tables.append('select id as nodeid from _%s where %s'%(
1687                 self.classname, ' and '.join(where)))
1689         # now multilinks
1690         for prop, values in propspec:
1691             if not isinstance(props[prop], hyperdb.Multilink):
1692                 continue
1693             if type(values) is type(''):
1694                 allvalues += (values,)
1695                 s = a
1696             else:
1697                 allvalues += tuple(values.keys())
1698                 s = ','.join([a]*len(values))
1699             tables.append('select nodeid from %s_%s where linkid in (%s)'%(
1700                 self.classname, prop, s))
1701         sql = '\nunion\n'.join(tables)
1702         self.db.sql(sql, allvalues)
1703         l = [x[0] for x in self.db.sql_fetchall()]
1704         if __debug__:
1705             print >>hyperdb.DEBUG, 'find ... ', l
1706         return l
1708     def stringFind(self, **requirements):
1709         '''Locate a particular node by matching a set of its String
1710         properties in a caseless search.
1712         If the property is not a String property, a TypeError is raised.
1713         
1714         The return is a list of the id of all nodes that match.
1715         '''
1716         where = []
1717         args = []
1718         for propname in requirements.keys():
1719             prop = self.properties[propname]
1720             if isinstance(not prop, String):
1721                 raise TypeError, "'%s' not a String property"%propname
1722             where.append(propname)
1723             args.append(requirements[propname].lower())
1725         # generate the where clause
1726         s = ' and '.join(['lower(_%s)=%s'%(col, self.db.arg) for col in where])
1727         sql = 'select id from _%s where %s'%(self.classname, s)
1728         self.db.sql(sql, tuple(args))
1729         l = [x[0] for x in self.db.sql_fetchall()]
1730         if __debug__:
1731             print >>hyperdb.DEBUG, 'find ... ', l
1732         return l
1734     def list(self):
1735         ''' Return a list of the ids of the active nodes in this class.
1736         '''
1737         return self.getnodeids(retired=0)
1739     def getnodeids(self, retired=None):
1740         ''' Retrieve all the ids of the nodes for a particular Class.
1742             Set retired=None to get all nodes. Otherwise it'll get all the 
1743             retired or non-retired nodes, depending on the flag.
1744         '''
1745         # flip the sense of the 'retired' flag if we don't want all of them
1746         if retired is not None:
1747             if retired:
1748                 args = (0, )
1749             else:
1750                 args = (1, )
1751             sql = 'select id from _%s where __retired__ <> %s'%(self.classname,
1752                 self.db.arg)
1753         else:
1754             args = ()
1755             sql = 'select id from _%s'%self.classname
1756         if __debug__:
1757             print >>hyperdb.DEBUG, 'getnodeids', (self, sql, retired)
1758         self.db.cursor.execute(sql, args)
1759         ids = [x[0] for x in self.db.cursor.fetchall()]
1760         return ids
1762     def filter(self, search_matches, filterspec, sort=(None,None),
1763             group=(None,None)):
1764         ''' Return a list of the ids of the active nodes in this class that
1765             match the 'filter' spec, sorted by the group spec and then the
1766             sort spec
1768             "filterspec" is {propname: value(s)}
1769             "sort" and "group" are (dir, prop) where dir is '+', '-' or None
1770                                and prop is a prop name or None
1771             "search_matches" is {nodeid: marker}
1773             The filter must match all properties specificed - but if the
1774             property value to match is a list, any one of the values in the
1775             list may match for that property to match.
1776         '''
1777         # just don't bother if the full-text search matched diddly
1778         if search_matches == {}:
1779             return []
1781         cn = self.classname
1783         timezone = self.db.getUserTimezone()
1784         
1785         # figure the WHERE clause from the filterspec
1786         props = self.getprops()
1787         frum = ['_'+cn]
1788         where = []
1789         args = []
1790         a = self.db.arg
1791         for k, v in filterspec.items():
1792             propclass = props[k]
1793             # now do other where clause stuff
1794             if isinstance(propclass, Multilink):
1795                 tn = '%s_%s'%(cn, k)
1796                 if v in ('-1', ['-1']):
1797                     # only match rows that have count(linkid)=0 in the
1798                     # corresponding multilink table)
1799                     where.append('id not in (select nodeid from %s)'%tn)
1800                 elif isinstance(v, type([])):
1801                     frum.append(tn)
1802                     s = ','.join([a for x in v])
1803                     where.append('id=%s.nodeid and %s.linkid in (%s)'%(tn,tn,s))
1804                     args = args + v
1805                 else:
1806                     frum.append(tn)
1807                     where.append('id=%s.nodeid and %s.linkid=%s'%(tn, tn, a))
1808                     args.append(v)
1809             elif k == 'id':
1810                 if isinstance(v, type([])):
1811                     s = ','.join([a for x in v])
1812                     where.append('%s in (%s)'%(k, s))
1813                     args = args + v
1814                 else:
1815                     where.append('%s=%s'%(k, a))
1816                     args.append(v)
1817             elif isinstance(propclass, String):
1818                 if not isinstance(v, type([])):
1819                     v = [v]
1821                 # Quote the bits in the string that need it and then embed
1822                 # in a "substring" search. Note - need to quote the '%' so
1823                 # they make it through the python layer happily
1824                 v = ['%%'+self.db.sql_stringquote(s)+'%%' for s in v]
1826                 # now add to the where clause
1827                 where.append(' or '.join(["_%s LIKE '%s'"%(k, s) for s in v]))
1828                 # note: args are embedded in the query string now
1829             elif isinstance(propclass, Link):
1830                 if isinstance(v, type([])):
1831                     if '-1' in v:
1832                         v = v[:]
1833                         v.remove('-1')
1834                         xtra = ' or _%s is NULL'%k
1835                     else:
1836                         xtra = ''
1837                     if v:
1838                         s = ','.join([a for x in v])
1839                         where.append('(_%s in (%s)%s)'%(k, s, xtra))
1840                         args = args + v
1841                     else:
1842                         where.append('_%s is NULL'%k)
1843                 else:
1844                     if v == '-1':
1845                         v = None
1846                         where.append('_%s is NULL'%k)
1847                     else:
1848                         where.append('_%s=%s'%(k, a))
1849                         args.append(v)
1850             elif isinstance(propclass, Date):
1851                 if isinstance(v, type([])):
1852                     s = ','.join([a for x in v])
1853                     where.append('_%s in (%s)'%(k, s))
1854                     args = args + [date.Date(x).serialise() for x in v]
1855                 else:
1856                     try:
1857                         # Try to filter on range of dates
1858                         date_rng = Range(v, date.Date, offset=timezone)
1859                         if (date_rng.from_value):
1860                             where.append('_%s >= %s'%(k, a))                            
1861                             args.append(date_rng.from_value.serialise())
1862                         if (date_rng.to_value):
1863                             where.append('_%s <= %s'%(k, a))
1864                             args.append(date_rng.to_value.serialise())
1865                     except ValueError:
1866                         # If range creation fails - ignore that search parameter
1867                         pass                        
1868             elif isinstance(propclass, Interval):
1869                 if isinstance(v, type([])):
1870                     s = ','.join([a for x in v])
1871                     where.append('_%s in (%s)'%(k, s))
1872                     args = args + [date.Interval(x).serialise() for x in v]
1873                 else:
1874                     try:
1875                         # Try to filter on range of intervals
1876                         date_rng = Range(v, date.Interval)
1877                         if (date_rng.from_value):
1878                             where.append('_%s >= %s'%(k, a))
1879                             args.append(date_rng.from_value.serialise())
1880                         if (date_rng.to_value):
1881                             where.append('_%s <= %s'%(k, a))
1882                             args.append(date_rng.to_value.serialise())
1883                     except ValueError:
1884                         # If range creation fails - ignore that search parameter
1885                         pass                        
1886                     #where.append('_%s=%s'%(k, a))
1887                     #args.append(date.Interval(v).serialise())
1888             else:
1889                 if isinstance(v, type([])):
1890                     s = ','.join([a for x in v])
1891                     where.append('_%s in (%s)'%(k, s))
1892                     args = args + v
1893                 else:
1894                     where.append('_%s=%s'%(k, a))
1895                     args.append(v)
1897         # don't match retired nodes
1898         where.append('__retired__ <> 1')
1900         # add results of full text search
1901         if search_matches is not None:
1902             v = search_matches.keys()
1903             s = ','.join([a for x in v])
1904             where.append('id in (%s)'%s)
1905             args = args + v
1907         # "grouping" is just the first-order sorting in the SQL fetch
1908         # can modify it...)
1909         orderby = []
1910         ordercols = []
1911         if group[0] is not None and group[1] is not None:
1912             if group[0] != '-':
1913                 orderby.append('_'+group[1])
1914                 ordercols.append('_'+group[1])
1915             else:
1916                 orderby.append('_'+group[1]+' desc')
1917                 ordercols.append('_'+group[1])
1919         # now add in the sorting
1920         group = ''
1921         if sort[0] is not None and sort[1] is not None:
1922             direction, colname = sort
1923             if direction != '-':
1924                 if colname == 'id':
1925                     orderby.append(colname)
1926                 else:
1927                     orderby.append('_'+colname)
1928                     ordercols.append('_'+colname)
1929             else:
1930                 if colname == 'id':
1931                     orderby.append(colname+' desc')
1932                     ordercols.append(colname)
1933                 else:
1934                     orderby.append('_'+colname+' desc')
1935                     ordercols.append('_'+colname)
1937         # construct the SQL
1938         frum = ','.join(frum)
1939         if where:
1940             where = ' where ' + (' and '.join(where))
1941         else:
1942             where = ''
1943         cols = ['id']
1944         if orderby:
1945             cols = cols + ordercols
1946             order = ' order by %s'%(','.join(orderby))
1947         else:
1948             order = ''
1949         cols = ','.join(cols)
1950         sql = 'select %s from %s %s%s%s'%(cols, frum, where, group, order)
1951         args = tuple(args)
1952         if __debug__:
1953             print >>hyperdb.DEBUG, 'filter', (self, sql, args)
1954         self.db.cursor.execute(sql, args)
1955         l = self.db.cursor.fetchall()
1957         # return the IDs (the first column)
1958         # XXX The filter(None, l) bit is sqlite-specific... if there's _NO_
1959         # XXX matches to a fetch, it returns NULL instead of nothing!?!
1960         return filter(None, [row[0] for row in l])
1962     def count(self):
1963         '''Get the number of nodes in this class.
1965         If the returned integer is 'numnodes', the ids of all the nodes
1966         in this class run from 1 to numnodes, and numnodes+1 will be the
1967         id of the next node to be created in this class.
1968         '''
1969         return self.db.countnodes(self.classname)
1971     # Manipulating properties:
1972     def getprops(self, protected=1):
1973         '''Return a dictionary mapping property names to property objects.
1974            If the "protected" flag is true, we include protected properties -
1975            those which may not be modified.
1976         '''
1977         d = self.properties.copy()
1978         if protected:
1979             d['id'] = String()
1980             d['creation'] = hyperdb.Date()
1981             d['activity'] = hyperdb.Date()
1982             d['creator'] = hyperdb.Link('user')
1983         return d
1985     def addprop(self, **properties):
1986         '''Add properties to this class.
1988         The keyword arguments in 'properties' must map names to property
1989         objects, or a TypeError is raised.  None of the keys in 'properties'
1990         may collide with the names of existing properties, or a ValueError
1991         is raised before any properties have been added.
1992         '''
1993         for key in properties.keys():
1994             if self.properties.has_key(key):
1995                 raise ValueError, key
1996         self.properties.update(properties)
1998     def index(self, nodeid):
1999         '''Add (or refresh) the node to search indexes
2000         '''
2001         # find all the String properties that have indexme
2002         for prop, propclass in self.getprops().items():
2003             if isinstance(propclass, String) and propclass.indexme:
2004                 try:
2005                     value = str(self.get(nodeid, prop))
2006                 except IndexError:
2007                     # node no longer exists - entry should be removed
2008                     self.db.indexer.purge_entry((self.classname, nodeid, prop))
2009                 else:
2010                     # and index them under (classname, nodeid, property)
2011                     self.db.indexer.add_text((self.classname, nodeid, prop),
2012                         value)
2015     #
2016     # Detector interface
2017     #
2018     def audit(self, event, detector):
2019         '''Register a detector
2020         '''
2021         l = self.auditors[event]
2022         if detector not in l:
2023             self.auditors[event].append(detector)
2025     def fireAuditors(self, action, nodeid, newvalues):
2026         '''Fire all registered auditors.
2027         '''
2028         for audit in self.auditors[action]:
2029             audit(self.db, self, nodeid, newvalues)
2031     def react(self, event, detector):
2032         '''Register a detector
2033         '''
2034         l = self.reactors[event]
2035         if detector not in l:
2036             self.reactors[event].append(detector)
2038     def fireReactors(self, action, nodeid, oldvalues):
2039         '''Fire all registered reactors.
2040         '''
2041         for react in self.reactors[action]:
2042             react(self.db, self, nodeid, oldvalues)
2044 class FileClass(Class, hyperdb.FileClass):
2045     '''This class defines a large chunk of data. To support this, it has a
2046        mandatory String property "content" which is typically saved off
2047        externally to the hyperdb.
2049        The default MIME type of this data is defined by the
2050        "default_mime_type" class attribute, which may be overridden by each
2051        node if the class defines a "type" String property.
2052     '''
2053     default_mime_type = 'text/plain'
2055     def create(self, **propvalues):
2056         ''' snaffle the file propvalue and store in a file
2057         '''
2058         # we need to fire the auditors now, or the content property won't
2059         # be in propvalues for the auditors to play with
2060         self.fireAuditors('create', None, propvalues)
2062         # now remove the content property so it's not stored in the db
2063         content = propvalues['content']
2064         del propvalues['content']
2066         # do the database create
2067         newid = Class.create_inner(self, **propvalues)
2069         # fire reactors
2070         self.fireReactors('create', newid, None)
2072         # store off the content as a file
2073         self.db.storefile(self.classname, newid, None, content)
2074         return newid
2076     def import_list(self, propnames, proplist):
2077         ''' Trap the "content" property...
2078         '''
2079         # dupe this list so we don't affect others
2080         propnames = propnames[:]
2082         # extract the "content" property from the proplist
2083         i = propnames.index('content')
2084         content = eval(proplist[i])
2085         del propnames[i]
2086         del proplist[i]
2088         # do the normal import
2089         newid = Class.import_list(self, propnames, proplist)
2091         # save off the "content" file
2092         self.db.storefile(self.classname, newid, None, content)
2093         return newid
2095     _marker = []
2096     def get(self, nodeid, propname, default=_marker, cache=1):
2097         ''' trap the content propname and get it from the file
2098         '''
2099         poss_msg = 'Possibly a access right configuration problem.'
2100         if propname == 'content':
2101             try:
2102                 return self.db.getfile(self.classname, nodeid, None)
2103             except IOError, (strerror):
2104                 # BUG: by catching this we donot see an error in the log.
2105                 return 'ERROR reading file: %s%s\n%s\n%s'%(
2106                         self.classname, nodeid, poss_msg, strerror)
2107         if default is not self._marker:
2108             return Class.get(self, nodeid, propname, default, cache=cache)
2109         else:
2110             return Class.get(self, nodeid, propname, cache=cache)
2112     def getprops(self, protected=1):
2113         ''' In addition to the actual properties on the node, these methods
2114             provide the "content" property. If the "protected" flag is true,
2115             we include protected properties - those which may not be
2116             modified.
2117         '''
2118         d = Class.getprops(self, protected=protected).copy()
2119         d['content'] = hyperdb.String()
2120         return d
2122     def index(self, nodeid):
2123         ''' Index the node in the search index.
2125             We want to index the content in addition to the normal String
2126             property indexing.
2127         '''
2128         # perform normal indexing
2129         Class.index(self, nodeid)
2131         # get the content to index
2132         content = self.get(nodeid, 'content')
2134         # figure the mime type
2135         if self.properties.has_key('type'):
2136             mime_type = self.get(nodeid, 'type')
2137         else:
2138             mime_type = self.default_mime_type
2140         # and index!
2141         self.db.indexer.add_text((self.classname, nodeid, 'content'), content,
2142             mime_type)
2144 # XXX deviation from spec - was called ItemClass
2145 class IssueClass(Class, roundupdb.IssueClass):
2146     # Overridden methods:
2147     def __init__(self, db, classname, **properties):
2148         '''The newly-created class automatically includes the "messages",
2149         "files", "nosy", and "superseder" properties.  If the 'properties'
2150         dictionary attempts to specify any of these properties or a
2151         "creation" or "activity" property, a ValueError is raised.
2152         '''
2153         if not properties.has_key('title'):
2154             properties['title'] = hyperdb.String(indexme='yes')
2155         if not properties.has_key('messages'):
2156             properties['messages'] = hyperdb.Multilink("msg")
2157         if not properties.has_key('files'):
2158             properties['files'] = hyperdb.Multilink("file")
2159         if not properties.has_key('nosy'):
2160             # note: journalling is turned off as it really just wastes
2161             # space. this behaviour may be overridden in an instance
2162             properties['nosy'] = hyperdb.Multilink("user", do_journal="no")
2163         if not properties.has_key('superseder'):
2164             properties['superseder'] = hyperdb.Multilink(classname)
2165         Class.__init__(self, db, classname, **properties)