Code

fixed rdbms mutation of properties
[roundup.git] / roundup / backends / rdbms_common.py
1 # $Id: rdbms_common.py,v 1.37 2003-02-27 11:07:36 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, old_spec):
182         ''' Determine the differences between the current spec and the
183             database version of the spec, and update where necessary
184         '''
185         new_spec = spec
186         new_has = new_spec.properties.has_key
188         new_spec = new_spec.schema()
189         if new_spec == old_spec:
190             # no changes
191             return 0
193         if __debug__:
194             print >>hyperdb.DEBUG, 'update_class FIRING'
196         # key property changed?
197         if old_spec[0] != new_spec[0]:
198             if __debug__:
199                 print >>hyperdb.DEBUG, 'update_class setting keyprop', `spec[0]`
200             # XXX turn on indexing for the key property
202         # detect multilinks that have been removed, and drop their table
203         old_has = {}
204         for name,prop in old_spec[1]:
205             old_has[name] = 1
206             if not new_has(name) and isinstance(prop, Multilink):
207                 # it's a multilink, and it's been removed - drop the old
208                 # table
209                 sql = 'drop table %s_%s'%(spec.classname, prop)
210                 if __debug__:
211                     print >>hyperdb.DEBUG, 'update_class', (self, sql)
212                 self.cursor.execute(sql)
213                 continue
214         old_has = old_has.has_key
216         # now figure how we populate the new table
217         fetch = []      # fetch these from the old table
218         properties = spec.getprops()
219         for propname,x in new_spec[1]:
220             prop = properties[propname]
221             if isinstance(prop, Multilink):
222                 if not old_has(propname):
223                     # we need to create the new table
224                     self.create_multilink_table(spec, propname)
225             elif old_has(propname):
226                 # we copy this col over from the old table
227                 fetch.append('_'+propname)
229         # select the data out of the old table
230         fetch.append('id')
231         fetch.append('__retired__')
232         fetchcols = ','.join(fetch)
233         cn = spec.classname
234         sql = 'select %s from _%s'%(fetchcols, cn)
235         if __debug__:
236             print >>hyperdb.DEBUG, 'update_class', (self, sql)
237         self.cursor.execute(sql)
238         olddata = self.cursor.fetchall()
240         # drop the old table
241         self.cursor.execute('drop table _%s'%cn)
243         # create the new table
244         self.create_class_table(spec)
246         if olddata:
247             # do the insert
248             args = ','.join([self.arg for x in fetch])
249             sql = 'insert into _%s (%s) values (%s)'%(cn, fetchcols, args)
250             if __debug__:
251                 print >>hyperdb.DEBUG, 'update_class', (self, sql, olddata[0])
252             for entry in olddata:
253                 self.cursor.execute(sql, *entry)
255         return 1
257     def create_class_table(self, spec):
258         ''' create the class table for the given spec
259         '''
260         cols, mls = self.determine_columns(spec.properties.items())
262         # add on our special columns
263         cols.append('id')
264         cols.append('__retired__')
266         # create the base table
267         scols = ','.join(['%s varchar'%x for x in cols])
268         sql = 'create table _%s (%s)'%(spec.classname, scols)
269         if __debug__:
270             print >>hyperdb.DEBUG, 'create_class', (self, sql)
271         self.cursor.execute(sql)
273         return cols, mls
275     def create_journal_table(self, spec):
276         ''' create the journal table for a class given the spec and 
277             already-determined cols
278         '''
279         # journal table
280         cols = ','.join(['%s varchar'%x
281             for x in 'nodeid date tag action params'.split()])
282         sql = 'create table %s__journal (%s)'%(spec.classname, cols)
283         if __debug__:
284             print >>hyperdb.DEBUG, 'create_class', (self, sql)
285         self.cursor.execute(sql)
287     def create_multilink_table(self, spec, ml):
288         ''' Create a multilink table for the "ml" property of the class
289             given by the spec
290         '''
291         sql = 'create table %s_%s (linkid varchar, nodeid varchar)'%(
292             spec.classname, ml)
293         if __debug__:
294             print >>hyperdb.DEBUG, 'create_class', (self, sql)
295         self.cursor.execute(sql)
297     def create_class(self, spec):
298         ''' Create a database table according to the given spec.
299         '''
300         cols, mls = self.create_class_table(spec)
301         self.create_journal_table(spec)
303         # now create the multilink tables
304         for ml in mls:
305             self.create_multilink_table(spec, ml)
307         # ID counter
308         sql = 'insert into ids (name, num) values (%s,%s)'%(self.arg, self.arg)
309         vals = (spec.classname, 1)
310         if __debug__:
311             print >>hyperdb.DEBUG, 'create_class', (self, sql, vals)
312         self.cursor.execute(sql, vals)
314     def drop_class(self, spec):
315         ''' Drop the given table from the database.
317             Drop the journal and multilink tables too.
318         '''
319         # figure the multilinks
320         mls = []
321         for col, prop in spec.properties.items():
322             if isinstance(prop, Multilink):
323                 mls.append(col)
325         sql = 'drop table _%s'%spec.classname
326         if __debug__:
327             print >>hyperdb.DEBUG, 'drop_class', (self, sql)
328         self.cursor.execute(sql)
330         sql = 'drop table %s__journal'%spec.classname
331         if __debug__:
332             print >>hyperdb.DEBUG, 'drop_class', (self, sql)
333         self.cursor.execute(sql)
335         for ml in mls:
336             sql = 'drop table %s_%s'%(spec.classname, ml)
337             if __debug__:
338                 print >>hyperdb.DEBUG, 'drop_class', (self, sql)
339             self.cursor.execute(sql)
341     #
342     # Classes
343     #
344     def __getattr__(self, classname):
345         ''' A convenient way of calling self.getclass(classname).
346         '''
347         if self.classes.has_key(classname):
348             if __debug__:
349                 print >>hyperdb.DEBUG, '__getattr__', (self, classname)
350             return self.classes[classname]
351         raise AttributeError, classname
353     def addclass(self, cl):
354         ''' Add a Class to the hyperdatabase.
355         '''
356         if __debug__:
357             print >>hyperdb.DEBUG, 'addclass', (self, cl)
358         cn = cl.classname
359         if self.classes.has_key(cn):
360             raise ValueError, cn
361         self.classes[cn] = cl
363     def getclasses(self):
364         ''' Return a list of the names of all existing classes.
365         '''
366         if __debug__:
367             print >>hyperdb.DEBUG, 'getclasses', (self,)
368         l = self.classes.keys()
369         l.sort()
370         return l
372     def getclass(self, classname):
373         '''Get the Class object representing a particular class.
375         If 'classname' is not a valid class name, a KeyError is raised.
376         '''
377         if __debug__:
378             print >>hyperdb.DEBUG, 'getclass', (self, classname)
379         try:
380             return self.classes[classname]
381         except KeyError:
382             raise KeyError, 'There is no class called "%s"'%classname
384     def clear(self):
385         ''' Delete all database contents.
387             Note: I don't commit here, which is different behaviour to the
388             "nuke from orbit" behaviour in the *dbms.
389         '''
390         if __debug__:
391             print >>hyperdb.DEBUG, 'clear', (self,)
392         for cn in self.classes.keys():
393             sql = 'delete from _%s'%cn
394             if __debug__:
395                 print >>hyperdb.DEBUG, 'clear', (self, sql)
396             self.cursor.execute(sql)
398     #
399     # Node IDs
400     #
401     def newid(self, classname):
402         ''' Generate a new id for the given class
403         '''
404         # get the next ID
405         sql = 'select num from ids where name=%s'%self.arg
406         if __debug__:
407             print >>hyperdb.DEBUG, 'newid', (self, sql, classname)
408         self.cursor.execute(sql, (classname, ))
409         newid = self.cursor.fetchone()[0]
411         # update the counter
412         sql = 'update ids set num=%s where name=%s'%(self.arg, self.arg)
413         vals = (int(newid)+1, classname)
414         if __debug__:
415             print >>hyperdb.DEBUG, 'newid', (self, sql, vals)
416         self.cursor.execute(sql, vals)
418         # return as string
419         return str(newid)
421     def setid(self, classname, setid):
422         ''' Set the id counter: used during import of database
423         '''
424         sql = 'update ids set num=%s where name=%s'%(self.arg, self.arg)
425         vals = (setid, classname)
426         if __debug__:
427             print >>hyperdb.DEBUG, 'setid', (self, sql, vals)
428         self.cursor.execute(sql, vals)
430     #
431     # Nodes
432     #
434     def addnode(self, classname, nodeid, node):
435         ''' Add the specified node to its class's db.
436         '''
437         if __debug__:
438             print >>hyperdb.DEBUG, 'addnode', (self, classname, nodeid, node)
439         # gadfly requires values for all non-multilink columns
440         cl = self.classes[classname]
441         cols, mls = self.determine_columns(cl.properties.items())
443         # we'll be supplied these props if we're doing an import
444         if not node.has_key('creator'):
445             # add in the "calculated" properties (dupe so we don't affect
446             # calling code's node assumptions)
447             node = node.copy()
448             node['creation'] = node['activity'] = date.Date()
449             node['creator'] = self.curuserid
451         # default the non-multilink columns
452         for col, prop in cl.properties.items():
453             if not isinstance(col, Multilink):
454                 if not node.has_key(col):
455                     node[col] = None
457         # clear this node out of the cache if it's in there
458         key = (classname, nodeid)
459         if self.cache.has_key(key):
460             del self.cache[key]
461             self.cache_lru.remove(key)
463         # make the node data safe for the DB
464         node = self.serialise(classname, node)
466         # make sure the ordering is correct for column name -> column value
467         vals = tuple([node[col[1:]] for col in cols]) + (nodeid, 0)
468         s = ','.join([self.arg for x in cols]) + ',%s,%s'%(self.arg, self.arg)
469         cols = ','.join(cols) + ',id,__retired__'
471         # perform the inserts
472         sql = 'insert into _%s (%s) values (%s)'%(classname, cols, s)
473         if __debug__:
474             print >>hyperdb.DEBUG, 'addnode', (self, sql, vals)
475         self.cursor.execute(sql, vals)
477         # insert the multilink rows
478         for col in mls:
479             t = '%s_%s'%(classname, col)
480             for entry in node[col]:
481                 sql = 'insert into %s (linkid, nodeid) values (%s,%s)'%(t,
482                     self.arg, self.arg)
483                 self.sql(sql, (entry, nodeid))
485         # make sure we do the commit-time extra stuff for this node
486         self.transactions.append((self.doSaveNode, (classname, nodeid, node)))
488     def setnode(self, classname, nodeid, values, multilink_changes):
489         ''' Change the specified node.
490         '''
491         if __debug__:
492             print >>hyperdb.DEBUG, 'setnode', (self, classname, nodeid, values)
494         # clear this node out of the cache if it's in there
495         key = (classname, nodeid)
496         if self.cache.has_key(key):
497             del self.cache[key]
498             self.cache_lru.remove(key)
500         # add the special props
501         values = values.copy()
502         values['activity'] = date.Date()
504         # make db-friendly
505         values = self.serialise(classname, values)
507         cl = self.classes[classname]
508         cols = []
509         mls = []
510         # add the multilinks separately
511         props = cl.getprops()
512         for col in values.keys():
513             prop = props[col]
514             if isinstance(prop, Multilink):
515                 mls.append(col)
516             else:
517                 cols.append('_'+col)
518         cols.sort()
520         # if there's any updates to regular columns, do them
521         if cols:
522             # make sure the ordering is correct for column name -> column value
523             sqlvals = tuple([values[col[1:]] for col in cols]) + (nodeid,)
524             s = ','.join(['%s=%s'%(x, self.arg) for x in cols])
525             cols = ','.join(cols)
527             # perform the update
528             sql = 'update _%s set %s where id=%s'%(classname, s, self.arg)
529             if __debug__:
530                 print >>hyperdb.DEBUG, 'setnode', (self, sql, sqlvals)
531             self.cursor.execute(sql, sqlvals)
533         # now the fun bit, updating the multilinks ;)
534         for col, (add, remove) in multilink_changes.items():
535             tn = '%s_%s'%(classname, col)
536             if add:
537                 sql = 'insert into %s (nodeid, linkid) values (%s,%s)'%(tn,
538                     self.arg, self.arg)
539                 for addid in add:
540                     self.sql(sql, (nodeid, addid))
541             if remove:
542                 sql = 'delete from %s where nodeid=%s and linkid=%s'%(tn,
543                     self.arg, self.arg)
544                 for removeid in remove:
545                     self.sql(sql, (nodeid, removeid))
547         # make sure we do the commit-time extra stuff for this node
548         self.transactions.append((self.doSaveNode, (classname, nodeid, values)))
550     def getnode(self, classname, nodeid):
551         ''' Get a node from the database.
552         '''
553         if __debug__:
554             print >>hyperdb.DEBUG, 'getnode', (self, classname, nodeid)
556         # see if we have this node cached
557         key = (classname, nodeid)
558         if self.cache.has_key(key):
559             # push us back to the top of the LRU
560             self.cache_lru.remove(key)
561             self.cache_lru.insert(0, key)
562             # return the cached information
563             return self.cache[key]
565         # figure the columns we're fetching
566         cl = self.classes[classname]
567         cols, mls = self.determine_columns(cl.properties.items())
568         scols = ','.join(cols)
570         # perform the basic property fetch
571         sql = 'select %s from _%s where id=%s'%(scols, classname, self.arg)
572         self.sql(sql, (nodeid,))
574         values = self.sql_fetchone()
575         if values is None:
576             raise IndexError, 'no such %s node %s'%(classname, nodeid)
578         # make up the node
579         node = {}
580         for col in range(len(cols)):
581             node[cols[col][1:]] = values[col]
583         # now the multilinks
584         for col in mls:
585             # get the link ids
586             sql = 'select linkid from %s_%s where nodeid=%s'%(classname, col,
587                 self.arg)
588             self.cursor.execute(sql, (nodeid,))
589             # extract the first column from the result
590             node[col] = [x[0] for x in self.cursor.fetchall()]
592         # un-dbificate the node data
593         node = self.unserialise(classname, node)
595         # save off in the cache
596         key = (classname, nodeid)
597         self.cache[key] = node
598         # update the LRU
599         self.cache_lru.insert(0, key)
600         if len(self.cache_lru) > ROW_CACHE_SIZE:
601             del self.cache[self.cache_lru.pop()]
603         return node
605     def destroynode(self, classname, nodeid):
606         '''Remove a node from the database. Called exclusively by the
607            destroy() method on Class.
608         '''
609         if __debug__:
610             print >>hyperdb.DEBUG, 'destroynode', (self, classname, nodeid)
612         # make sure the node exists
613         if not self.hasnode(classname, nodeid):
614             raise IndexError, '%s has no node %s'%(classname, nodeid)
616         # see if we have this node cached
617         if self.cache.has_key((classname, nodeid)):
618             del self.cache[(classname, nodeid)]
620         # see if there's any obvious commit actions that we should get rid of
621         for entry in self.transactions[:]:
622             if entry[1][:2] == (classname, nodeid):
623                 self.transactions.remove(entry)
625         # now do the SQL
626         sql = 'delete from _%s where id=%s'%(classname, self.arg)
627         self.sql(sql, (nodeid,))
629         # remove from multilnks
630         cl = self.getclass(classname)
631         x, mls = self.determine_columns(cl.properties.items())
632         for col in mls:
633             # get the link ids
634             sql = 'delete from %s_%s where nodeid=%s'%(classname, col, self.arg)
635             self.cursor.execute(sql, (nodeid,))
637         # remove journal entries
638         sql = 'delete from %s__journal where nodeid=%s'%(classname, self.arg)
639         self.sql(sql, (nodeid,))
641     def serialise(self, classname, node):
642         '''Copy the node contents, converting non-marshallable data into
643            marshallable data.
644         '''
645         if __debug__:
646             print >>hyperdb.DEBUG, 'serialise', classname, node
647         properties = self.getclass(classname).getprops()
648         d = {}
649         for k, v in node.items():
650             # if the property doesn't exist, or is the "retired" flag then
651             # it won't be in the properties dict
652             if not properties.has_key(k):
653                 d[k] = v
654                 continue
656             # get the property spec
657             prop = properties[k]
659             if isinstance(prop, Password) and v is not None:
660                 d[k] = str(v)
661             elif isinstance(prop, Date) and v is not None:
662                 d[k] = v.serialise()
663             elif isinstance(prop, Interval) and v is not None:
664                 d[k] = v.serialise()
665             else:
666                 d[k] = v
667         return d
669     def unserialise(self, classname, node):
670         '''Decode the marshalled node data
671         '''
672         if __debug__:
673             print >>hyperdb.DEBUG, 'unserialise', classname, node
674         properties = self.getclass(classname).getprops()
675         d = {}
676         for k, v in node.items():
677             # if the property doesn't exist, or is the "retired" flag then
678             # it won't be in the properties dict
679             if not properties.has_key(k):
680                 d[k] = v
681                 continue
683             # get the property spec
684             prop = properties[k]
686             if isinstance(prop, Date) and v is not None:
687                 d[k] = date.Date(v)
688             elif isinstance(prop, Interval) and v is not None:
689                 d[k] = date.Interval(v)
690             elif isinstance(prop, Password) and v is not None:
691                 p = password.Password()
692                 p.unpack(v)
693                 d[k] = p
694             elif (isinstance(prop, Boolean) or isinstance(prop, Number)) and v is not None:
695                 d[k]=float(v)
696             else:
697                 d[k] = v
698         return d
700     def hasnode(self, classname, nodeid):
701         ''' Determine if the database has a given node.
702         '''
703         sql = 'select count(*) from _%s where id=%s'%(classname, self.arg)
704         if __debug__:
705             print >>hyperdb.DEBUG, 'hasnode', (self, sql, nodeid)
706         self.cursor.execute(sql, (nodeid,))
707         return int(self.cursor.fetchone()[0])
709     def countnodes(self, classname):
710         ''' Count the number of nodes that exist for a particular Class.
711         '''
712         sql = 'select count(*) from _%s'%classname
713         if __debug__:
714             print >>hyperdb.DEBUG, 'countnodes', (self, sql)
715         self.cursor.execute(sql)
716         return self.cursor.fetchone()[0]
718     def getnodeids(self, classname, retired=0):
719         ''' Retrieve all the ids of the nodes for a particular Class.
721             Set retired=None to get all nodes. Otherwise it'll get all the 
722             retired or non-retired nodes, depending on the flag.
723         '''
724         # flip the sense of the flag if we don't want all of them
725         if retired is not None:
726             retired = not retired
727         sql = 'select id from _%s where __retired__ <> %s'%(classname, self.arg)
728         if __debug__:
729             print >>hyperdb.DEBUG, 'getnodeids', (self, sql, retired)
730         self.cursor.execute(sql, (retired,))
731         return [x[0] for x in self.cursor.fetchall()]
733     def addjournal(self, classname, nodeid, action, params, creator=None,
734             creation=None):
735         ''' Journal the Action
736         'action' may be:
738             'create' or 'set' -- 'params' is a dictionary of property values
739             'link' or 'unlink' -- 'params' is (classname, nodeid, propname)
740             'retire' -- 'params' is None
741         '''
742         # serialise the parameters now if necessary
743         if isinstance(params, type({})):
744             if action in ('set', 'create'):
745                 params = self.serialise(classname, params)
747         # handle supply of the special journalling parameters (usually
748         # supplied on importing an existing database)
749         if creator:
750             journaltag = creator
751         else:
752             journaltag = self.curuserid
753         if creation:
754             journaldate = creation.serialise()
755         else:
756             journaldate = date.Date().serialise()
758         # create the journal entry
759         cols = ','.join('nodeid date tag action params'.split())
761         if __debug__:
762             print >>hyperdb.DEBUG, 'addjournal', (nodeid, journaldate,
763                 journaltag, action, params)
765         self.save_journal(classname, cols, nodeid, journaldate,
766             journaltag, action, params)
768     def save_journal(self, classname, cols, nodeid, journaldate,
769             journaltag, action, params):
770         ''' Save the journal entry to the database
771         '''
772         raise NotImplemented
774     def getjournal(self, classname, nodeid):
775         ''' get the journal for id
776         '''
777         # make sure the node exists
778         if not self.hasnode(classname, nodeid):
779             raise IndexError, '%s has no node %s'%(classname, nodeid)
781         cols = ','.join('nodeid date tag action params'.split())
782         return self.load_journal(classname, cols, nodeid)
784     def load_journal(self, classname, cols, nodeid):
785         ''' Load the journal from the database
786         '''
787         raise NotImplemented
789     def pack(self, pack_before):
790         ''' Delete all journal entries except "create" before 'pack_before'.
791         '''
792         # get a 'yyyymmddhhmmss' version of the date
793         date_stamp = pack_before.serialise()
795         # do the delete
796         for classname in self.classes.keys():
797             sql = "delete from %s__journal where date<%s and "\
798                 "action<>'create'"%(classname, self.arg)
799             if __debug__:
800                 print >>hyperdb.DEBUG, 'pack', (self, sql, date_stamp)
801             self.cursor.execute(sql, (date_stamp,))
803     def sql_commit(self):
804         ''' Actually commit to the database.
805         '''
806         self.conn.commit()
808     def commit(self):
809         ''' Commit the current transactions.
811         Save all data changed since the database was opened or since the
812         last commit() or rollback().
813         '''
814         if __debug__:
815             print >>hyperdb.DEBUG, 'commit', (self,)
817         # commit the database
818         self.sql_commit()
820         # now, do all the other transaction stuff
821         reindex = {}
822         for method, args in self.transactions:
823             reindex[method(*args)] = 1
825         # reindex the nodes that request it
826         for classname, nodeid in filter(None, reindex.keys()):
827             print >>hyperdb.DEBUG, 'commit.reindex', (classname, nodeid)
828             self.getclass(classname).index(nodeid)
830         # save the indexer state
831         self.indexer.save_index()
833         # clear out the transactions
834         self.transactions = []
836     def rollback(self):
837         ''' Reverse all actions from the current transaction.
839         Undo all the changes made since the database was opened or the last
840         commit() or rollback() was performed.
841         '''
842         if __debug__:
843             print >>hyperdb.DEBUG, 'rollback', (self,)
845         # roll back
846         self.conn.rollback()
848         # roll back "other" transaction stuff
849         for method, args in self.transactions:
850             # delete temporary files
851             if method == self.doStoreFile:
852                 self.rollbackStoreFile(*args)
853         self.transactions = []
855     def doSaveNode(self, classname, nodeid, node):
856         ''' dummy that just generates a reindex event
857         '''
858         # return the classname, nodeid so we reindex this content
859         return (classname, nodeid)
861     def close(self):
862         ''' Close off the connection.
863         '''
864         self.conn.close()
865         if self.lockfile is not None:
866             locking.release_lock(self.lockfile)
867         if self.lockfile is not None:
868             self.lockfile.close()
869             self.lockfile = None
872 # The base Class class
874 class Class(hyperdb.Class):
875     ''' The handle to a particular class of nodes in a hyperdatabase.
876         
877         All methods except __repr__ and getnode must be implemented by a
878         concrete backend Class.
879     '''
881     def __init__(self, db, classname, **properties):
882         '''Create a new class with a given name and property specification.
884         'classname' must not collide with the name of an existing class,
885         or a ValueError is raised.  The keyword arguments in 'properties'
886         must map names to property objects, or a TypeError is raised.
887         '''
888         if (properties.has_key('creation') or properties.has_key('activity')
889                 or properties.has_key('creator')):
890             raise ValueError, '"creation", "activity" and "creator" are '\
891                 'reserved'
893         self.classname = classname
894         self.properties = properties
895         self.db = weakref.proxy(db)       # use a weak ref to avoid circularity
896         self.key = ''
898         # should we journal changes (default yes)
899         self.do_journal = 1
901         # do the db-related init stuff
902         db.addclass(self)
904         self.auditors = {'create': [], 'set': [], 'retire': []}
905         self.reactors = {'create': [], 'set': [], 'retire': []}
907     def schema(self):
908         ''' A dumpable version of the schema that we can store in the
909             database
910         '''
911         return (self.key, [(x, repr(y)) for x,y in self.properties.items()])
913     def enableJournalling(self):
914         '''Turn journalling on for this class
915         '''
916         self.do_journal = 1
918     def disableJournalling(self):
919         '''Turn journalling off for this class
920         '''
921         self.do_journal = 0
923     # Editing nodes:
924     def create(self, **propvalues):
925         ''' Create a new node of this class and return its id.
927         The keyword arguments in 'propvalues' map property names to values.
929         The values of arguments must be acceptable for the types of their
930         corresponding properties or a TypeError is raised.
931         
932         If this class has a key property, it must be present and its value
933         must not collide with other key strings or a ValueError is raised.
934         
935         Any other properties on this class that are missing from the
936         'propvalues' dictionary are set to None.
937         
938         If an id in a link or multilink property does not refer to a valid
939         node, an IndexError is raised.
940         '''
941         self.fireAuditors('create', None, propvalues)
942         newid = self.create_inner(**propvalues)
943         self.fireReactors('create', newid, None)
944         return newid
945     
946     def create_inner(self, **propvalues):
947         ''' Called by create, in-between the audit and react calls.
948         '''
949         if propvalues.has_key('id'):
950             raise KeyError, '"id" is reserved'
952         if self.db.journaltag is None:
953             raise DatabaseError, 'Database open read-only'
955         if propvalues.has_key('creation') or propvalues.has_key('activity'):
956             raise KeyError, '"creation" and "activity" are reserved'
958         # new node's id
959         newid = self.db.newid(self.classname)
961         # validate propvalues
962         num_re = re.compile('^\d+$')
963         for key, value in propvalues.items():
964             if key == self.key:
965                 try:
966                     self.lookup(value)
967                 except KeyError:
968                     pass
969                 else:
970                     raise ValueError, 'node with key "%s" exists'%value
972             # try to handle this property
973             try:
974                 prop = self.properties[key]
975             except KeyError:
976                 raise KeyError, '"%s" has no property "%s"'%(self.classname,
977                     key)
979             if value is not None and isinstance(prop, Link):
980                 if type(value) != type(''):
981                     raise ValueError, 'link value must be String'
982                 link_class = self.properties[key].classname
983                 # if it isn't a number, it's a key
984                 if not num_re.match(value):
985                     try:
986                         value = self.db.classes[link_class].lookup(value)
987                     except (TypeError, KeyError):
988                         raise IndexError, 'new property "%s": %s not a %s'%(
989                             key, value, link_class)
990                 elif not self.db.getclass(link_class).hasnode(value):
991                     raise IndexError, '%s has no node %s'%(link_class, value)
993                 # save off the value
994                 propvalues[key] = value
996                 # register the link with the newly linked node
997                 if self.do_journal and self.properties[key].do_journal:
998                     self.db.addjournal(link_class, value, 'link',
999                         (self.classname, newid, key))
1001             elif isinstance(prop, Multilink):
1002                 if type(value) != type([]):
1003                     raise TypeError, 'new property "%s" not a list of ids'%key
1005                 # clean up and validate the list of links
1006                 link_class = self.properties[key].classname
1007                 l = []
1008                 for entry in value:
1009                     if type(entry) != type(''):
1010                         raise ValueError, '"%s" multilink value (%r) '\
1011                             'must contain Strings'%(key, value)
1012                     # if it isn't a number, it's a key
1013                     if not num_re.match(entry):
1014                         try:
1015                             entry = self.db.classes[link_class].lookup(entry)
1016                         except (TypeError, KeyError):
1017                             raise IndexError, 'new property "%s": %s not a %s'%(
1018                                 key, entry, self.properties[key].classname)
1019                     l.append(entry)
1020                 value = l
1021                 propvalues[key] = value
1023                 # handle additions
1024                 for nodeid in value:
1025                     if not self.db.getclass(link_class).hasnode(nodeid):
1026                         raise IndexError, '%s has no node %s'%(link_class,
1027                             nodeid)
1028                     # register the link with the newly linked node
1029                     if self.do_journal and self.properties[key].do_journal:
1030                         self.db.addjournal(link_class, nodeid, 'link',
1031                             (self.classname, newid, key))
1033             elif isinstance(prop, String):
1034                 if type(value) != type('') and type(value) != type(u''):
1035                     raise TypeError, 'new property "%s" not a string'%key
1037             elif isinstance(prop, Password):
1038                 if not isinstance(value, password.Password):
1039                     raise TypeError, 'new property "%s" not a Password'%key
1041             elif isinstance(prop, Date):
1042                 if value is not None and not isinstance(value, date.Date):
1043                     raise TypeError, 'new property "%s" not a Date'%key
1045             elif isinstance(prop, Interval):
1046                 if value is not None and not isinstance(value, date.Interval):
1047                     raise TypeError, 'new property "%s" not an Interval'%key
1049             elif value is not None and isinstance(prop, Number):
1050                 try:
1051                     float(value)
1052                 except ValueError:
1053                     raise TypeError, 'new property "%s" not numeric'%key
1055             elif value is not None and isinstance(prop, Boolean):
1056                 try:
1057                     int(value)
1058                 except ValueError:
1059                     raise TypeError, 'new property "%s" not boolean'%key
1061         # make sure there's data where there needs to be
1062         for key, prop in self.properties.items():
1063             if propvalues.has_key(key):
1064                 continue
1065             if key == self.key:
1066                 raise ValueError, 'key property "%s" is required'%key
1067             if isinstance(prop, Multilink):
1068                 propvalues[key] = []
1069             else:
1070                 propvalues[key] = None
1072         # done
1073         self.db.addnode(self.classname, newid, propvalues)
1074         if self.do_journal:
1075             self.db.addjournal(self.classname, newid, 'create', {})
1077         return newid
1079     def export_list(self, propnames, nodeid):
1080         ''' Export a node - generate a list of CSV-able data in the order
1081             specified by propnames for the given node.
1082         '''
1083         properties = self.getprops()
1084         l = []
1085         for prop in propnames:
1086             proptype = properties[prop]
1087             value = self.get(nodeid, prop)
1088             # "marshal" data where needed
1089             if value is None:
1090                 pass
1091             elif isinstance(proptype, hyperdb.Date):
1092                 value = value.get_tuple()
1093             elif isinstance(proptype, hyperdb.Interval):
1094                 value = value.get_tuple()
1095             elif isinstance(proptype, hyperdb.Password):
1096                 value = str(value)
1097             l.append(repr(value))
1098         l.append(self.is_retired(nodeid))
1099         return l
1101     def import_list(self, propnames, proplist):
1102         ''' Import a node - all information including "id" is present and
1103             should not be sanity checked. Triggers are not triggered. The
1104             journal should be initialised using the "creator" and "created"
1105             information.
1107             Return the nodeid of the node imported.
1108         '''
1109         if self.db.journaltag is None:
1110             raise DatabaseError, 'Database open read-only'
1111         properties = self.getprops()
1113         # make the new node's property map
1114         d = {}
1115         for i in range(len(propnames)):
1116             # Use eval to reverse the repr() used to output the CSV
1117             value = eval(proplist[i])
1119             # Figure the property for this column
1120             propname = propnames[i]
1121             prop = properties[propname]
1123             # "unmarshal" where necessary
1124             if propname == 'id':
1125                 newid = value
1126                 continue
1127             elif value is None:
1128                 # don't set Nones
1129                 continue
1130             elif isinstance(prop, hyperdb.Date):
1131                 value = date.Date(value)
1132             elif isinstance(prop, hyperdb.Interval):
1133                 value = date.Interval(value)
1134             elif isinstance(prop, hyperdb.Password):
1135                 pwd = password.Password()
1136                 pwd.unpack(value)
1137                 value = pwd
1138             d[propname] = value
1140         # retire?
1141         if int(proplist[-1]):
1142             # use the arg for __retired__ to cope with any odd database type
1143             # conversion (hello, sqlite)
1144             sql = 'update _%s set __retired__=%s where id=%s'%(self.classname,
1145                 self.db.arg, self.db.arg)
1146             if __debug__:
1147                 print >>hyperdb.DEBUG, 'retire', (self, sql, newid)
1148             self.db.cursor.execute(sql, (1, newid))
1150         # add the node and journal
1151         self.db.addnode(self.classname, newid, d)
1153         # extract the extraneous journalling gumpf and nuke it
1154         if d.has_key('creator'):
1155             creator = d['creator']
1156             del d['creator']
1157         else:
1158             creator = None
1159         if d.has_key('creation'):
1160             creation = d['creation']
1161             del d['creation']
1162         else:
1163             creation = None
1164         if d.has_key('activity'):
1165             del d['activity']
1166         self.db.addjournal(self.classname, newid, 'create', {}, creator,
1167             creation)
1168         return newid
1170     _marker = []
1171     def get(self, nodeid, propname, default=_marker, cache=1):
1172         '''Get the value of a property on an existing node of this class.
1174         'nodeid' must be the id of an existing node of this class or an
1175         IndexError is raised.  'propname' must be the name of a property
1176         of this class or a KeyError is raised.
1178         'cache' indicates whether the transaction cache should be queried
1179         for the node. If the node has been modified and you need to
1180         determine what its values prior to modification are, you need to
1181         set cache=0.
1182         '''
1183         if propname == 'id':
1184             return nodeid
1186         # get the node's dict
1187         d = self.db.getnode(self.classname, nodeid)
1189         if propname == 'creation':
1190             if d.has_key('creation'):
1191                 return d['creation']
1192             else:
1193                 return date.Date()
1194         if propname == 'activity':
1195             if d.has_key('activity'):
1196                 return d['activity']
1197             else:
1198                 return date.Date()
1199         if propname == 'creator':
1200             if d.has_key('creator'):
1201                 return d['creator']
1202             else:
1203                 return self.db.curuserid
1205         # get the property (raises KeyErorr if invalid)
1206         prop = self.properties[propname]
1208         if not d.has_key(propname):
1209             if default is self._marker:
1210                 if isinstance(prop, Multilink):
1211                     return []
1212                 else:
1213                     return None
1214             else:
1215                 return default
1217         # don't pass our list to other code
1218         if isinstance(prop, Multilink):
1219             return d[propname][:]
1221         return d[propname]
1223     def getnode(self, nodeid, cache=1):
1224         ''' Return a convenience wrapper for the node.
1226         'nodeid' must be the id of an existing node of this class or an
1227         IndexError is raised.
1229         'cache' indicates whether the transaction cache should be queried
1230         for the node. If the node has been modified and you need to
1231         determine what its values prior to modification are, you need to
1232         set cache=0.
1233         '''
1234         return Node(self, nodeid, cache=cache)
1236     def set(self, nodeid, **propvalues):
1237         '''Modify a property on an existing node of this class.
1238         
1239         'nodeid' must be the id of an existing node of this class or an
1240         IndexError is raised.
1242         Each key in 'propvalues' must be the name of a property of this
1243         class or a KeyError is raised.
1245         All values in 'propvalues' must be acceptable types for their
1246         corresponding properties or a TypeError is raised.
1248         If the value of the key property is set, it must not collide with
1249         other key strings or a ValueError is raised.
1251         If the value of a Link or Multilink property contains an invalid
1252         node id, a ValueError is raised.
1253         '''
1254         if not propvalues:
1255             return propvalues
1257         if propvalues.has_key('creation') or propvalues.has_key('activity'):
1258             raise KeyError, '"creation" and "activity" are reserved'
1260         if propvalues.has_key('id'):
1261             raise KeyError, '"id" is reserved'
1263         if self.db.journaltag is None:
1264             raise DatabaseError, 'Database open read-only'
1266         self.fireAuditors('set', nodeid, propvalues)
1267         # Take a copy of the node dict so that the subsequent set
1268         # operation doesn't modify the oldvalues structure.
1269         # XXX used to try the cache here first
1270         oldvalues = copy.deepcopy(self.db.getnode(self.classname, nodeid))
1272         node = self.db.getnode(self.classname, nodeid)
1273         if self.is_retired(nodeid):
1274             raise IndexError, 'Requested item is retired'
1275         num_re = re.compile('^\d+$')
1277         # if the journal value is to be different, store it in here
1278         journalvalues = {}
1280         # remember the add/remove stuff for multilinks, making it easier
1281         # for the Database layer to do its stuff
1282         multilink_changes = {}
1284         for propname, value in propvalues.items():
1285             # check to make sure we're not duplicating an existing key
1286             if propname == self.key and node[propname] != value:
1287                 try:
1288                     self.lookup(value)
1289                 except KeyError:
1290                     pass
1291                 else:
1292                     raise ValueError, 'node with key "%s" exists'%value
1294             # this will raise the KeyError if the property isn't valid
1295             # ... we don't use getprops() here because we only care about
1296             # the writeable properties.
1297             try:
1298                 prop = self.properties[propname]
1299             except KeyError:
1300                 raise KeyError, '"%s" has no property named "%s"'%(
1301                     self.classname, propname)
1303             # if the value's the same as the existing value, no sense in
1304             # doing anything
1305             current = node.get(propname, None)
1306             if value == current:
1307                 del propvalues[propname]
1308                 continue
1309             journalvalues[propname] = current
1311             # do stuff based on the prop type
1312             if isinstance(prop, Link):
1313                 link_class = prop.classname
1314                 # if it isn't a number, it's a key
1315                 if value is not None and not isinstance(value, type('')):
1316                     raise ValueError, 'property "%s" link value be a string'%(
1317                         propname)
1318                 if isinstance(value, type('')) and not num_re.match(value):
1319                     try:
1320                         value = self.db.classes[link_class].lookup(value)
1321                     except (TypeError, KeyError):
1322                         raise IndexError, 'new property "%s": %s not a %s'%(
1323                             propname, value, prop.classname)
1325                 if (value is not None and
1326                         not self.db.getclass(link_class).hasnode(value)):
1327                     raise IndexError, '%s has no node %s'%(link_class, value)
1329                 if self.do_journal and prop.do_journal:
1330                     # register the unlink with the old linked node
1331                     if node[propname] is not None:
1332                         self.db.addjournal(link_class, node[propname], 'unlink',
1333                             (self.classname, nodeid, propname))
1335                     # register the link with the newly linked node
1336                     if value is not None:
1337                         self.db.addjournal(link_class, value, 'link',
1338                             (self.classname, nodeid, propname))
1340             elif isinstance(prop, Multilink):
1341                 if type(value) != type([]):
1342                     raise TypeError, 'new property "%s" not a list of'\
1343                         ' ids'%propname
1344                 link_class = self.properties[propname].classname
1345                 l = []
1346                 for entry in value:
1347                     # if it isn't a number, it's a key
1348                     if type(entry) != type(''):
1349                         raise ValueError, 'new property "%s" link value ' \
1350                             'must be a string'%propname
1351                     if not num_re.match(entry):
1352                         try:
1353                             entry = self.db.classes[link_class].lookup(entry)
1354                         except (TypeError, KeyError):
1355                             raise IndexError, 'new property "%s": %s not a %s'%(
1356                                 propname, entry,
1357                                 self.properties[propname].classname)
1358                     l.append(entry)
1359                 value = l
1360                 propvalues[propname] = value
1362                 # figure the journal entry for this property
1363                 add = []
1364                 remove = []
1366                 # handle removals
1367                 if node.has_key(propname):
1368                     l = node[propname]
1369                 else:
1370                     l = []
1371                 for id in l[:]:
1372                     if id in value:
1373                         continue
1374                     # register the unlink with the old linked node
1375                     if self.do_journal and self.properties[propname].do_journal:
1376                         self.db.addjournal(link_class, id, 'unlink',
1377                             (self.classname, nodeid, propname))
1378                     l.remove(id)
1379                     remove.append(id)
1381                 # handle additions
1382                 for id in value:
1383                     if not self.db.getclass(link_class).hasnode(id):
1384                         raise IndexError, '%s has no node %s'%(link_class, id)
1385                     if id in l:
1386                         continue
1387                     # register the link with the newly linked node
1388                     if self.do_journal and self.properties[propname].do_journal:
1389                         self.db.addjournal(link_class, id, 'link',
1390                             (self.classname, nodeid, propname))
1391                     l.append(id)
1392                     add.append(id)
1394                 # figure the journal entry
1395                 l = []
1396                 if add:
1397                     l.append(('+', add))
1398                 if remove:
1399                     l.append(('-', remove))
1400                 multilink_changes[propname] = (add, remove)
1401                 if l:
1402                     journalvalues[propname] = tuple(l)
1404             elif isinstance(prop, String):
1405                 if value is not None and type(value) != type('') and type(value) != type(u''):
1406                     raise TypeError, 'new property "%s" not a string'%propname
1408             elif isinstance(prop, Password):
1409                 if not isinstance(value, password.Password):
1410                     raise TypeError, 'new property "%s" not a Password'%propname
1411                 propvalues[propname] = value
1413             elif value is not None and isinstance(prop, Date):
1414                 if not isinstance(value, date.Date):
1415                     raise TypeError, 'new property "%s" not a Date'% propname
1416                 propvalues[propname] = value
1418             elif value is not None and isinstance(prop, Interval):
1419                 if not isinstance(value, date.Interval):
1420                     raise TypeError, 'new property "%s" not an '\
1421                         'Interval'%propname
1422                 propvalues[propname] = value
1424             elif value is not None and isinstance(prop, Number):
1425                 try:
1426                     float(value)
1427                 except ValueError:
1428                     raise TypeError, 'new property "%s" not numeric'%propname
1430             elif value is not None and isinstance(prop, Boolean):
1431                 try:
1432                     int(value)
1433                 except ValueError:
1434                     raise TypeError, 'new property "%s" not boolean'%propname
1436         # nothing to do?
1437         if not propvalues:
1438             return propvalues
1440         # do the set, and journal it
1441         self.db.setnode(self.classname, nodeid, propvalues, multilink_changes)
1443         if self.do_journal:
1444             self.db.addjournal(self.classname, nodeid, 'set', journalvalues)
1446         self.fireReactors('set', nodeid, oldvalues)
1448         return propvalues        
1450     def retire(self, nodeid):
1451         '''Retire a node.
1452         
1453         The properties on the node remain available from the get() method,
1454         and the node's id is never reused.
1455         
1456         Retired nodes are not returned by the find(), list(), or lookup()
1457         methods, and other nodes may reuse the values of their key properties.
1458         '''
1459         if self.db.journaltag is None:
1460             raise DatabaseError, 'Database open read-only'
1462         self.fireAuditors('retire', nodeid, None)
1464         # use the arg for __retired__ to cope with any odd database type
1465         # conversion (hello, sqlite)
1466         sql = 'update _%s set __retired__=%s where id=%s'%(self.classname,
1467             self.db.arg, self.db.arg)
1468         if __debug__:
1469             print >>hyperdb.DEBUG, 'retire', (self, sql, nodeid)
1470         self.db.cursor.execute(sql, (1, nodeid))
1472         self.fireReactors('retire', nodeid, None)
1474     def is_retired(self, nodeid):
1475         '''Return true if the node is rerired
1476         '''
1477         sql = 'select __retired__ from _%s where id=%s'%(self.classname,
1478             self.db.arg)
1479         if __debug__:
1480             print >>hyperdb.DEBUG, 'is_retired', (self, sql, nodeid)
1481         self.db.cursor.execute(sql, (nodeid,))
1482         return int(self.db.sql_fetchone()[0])
1484     def destroy(self, nodeid):
1485         '''Destroy a node.
1486         
1487         WARNING: this method should never be used except in extremely rare
1488                  situations where there could never be links to the node being
1489                  deleted
1490         WARNING: use retire() instead
1491         WARNING: the properties of this node will not be available ever again
1492         WARNING: really, use retire() instead
1494         Well, I think that's enough warnings. This method exists mostly to
1495         support the session storage of the cgi interface.
1497         The node is completely removed from the hyperdb, including all journal
1498         entries. It will no longer be available, and will generally break code
1499         if there are any references to the node.
1500         '''
1501         if self.db.journaltag is None:
1502             raise DatabaseError, 'Database open read-only'
1503         self.db.destroynode(self.classname, nodeid)
1505     def history(self, nodeid):
1506         '''Retrieve the journal of edits on a particular node.
1508         'nodeid' must be the id of an existing node of this class or an
1509         IndexError is raised.
1511         The returned list contains tuples of the form
1513             (nodeid, date, tag, action, params)
1515         'date' is a Timestamp object specifying the time of the change and
1516         'tag' is the journaltag specified when the database was opened.
1517         '''
1518         if not self.do_journal:
1519             raise ValueError, 'Journalling is disabled for this class'
1520         return self.db.getjournal(self.classname, nodeid)
1522     # Locating nodes:
1523     def hasnode(self, nodeid):
1524         '''Determine if the given nodeid actually exists
1525         '''
1526         return self.db.hasnode(self.classname, nodeid)
1528     def setkey(self, propname):
1529         '''Select a String property of this class to be the key property.
1531         'propname' must be the name of a String property of this class or
1532         None, or a TypeError is raised.  The values of the key property on
1533         all existing nodes must be unique or a ValueError is raised.
1534         '''
1535         # XXX create an index on the key prop column
1536         prop = self.getprops()[propname]
1537         if not isinstance(prop, String):
1538             raise TypeError, 'key properties must be String'
1539         self.key = propname
1541     def getkey(self):
1542         '''Return the name of the key property for this class or None.'''
1543         return self.key
1545     def labelprop(self, default_to_id=0):
1546         ''' Return the property name for a label for the given node.
1548         This method attempts to generate a consistent label for the node.
1549         It tries the following in order:
1550             1. key property
1551             2. "name" property
1552             3. "title" property
1553             4. first property from the sorted property name list
1554         '''
1555         k = self.getkey()
1556         if  k:
1557             return k
1558         props = self.getprops()
1559         if props.has_key('name'):
1560             return 'name'
1561         elif props.has_key('title'):
1562             return 'title'
1563         if default_to_id:
1564             return 'id'
1565         props = props.keys()
1566         props.sort()
1567         return props[0]
1569     def lookup(self, keyvalue):
1570         '''Locate a particular node by its key property and return its id.
1572         If this class has no key property, a TypeError is raised.  If the
1573         'keyvalue' matches one of the values for the key property among
1574         the nodes in this class, the matching node's id is returned;
1575         otherwise a KeyError is raised.
1576         '''
1577         if not self.key:
1578             raise TypeError, 'No key property set for class %s'%self.classname
1580         # use the arg to handle any odd database type conversion (hello,
1581         # sqlite)
1582         sql = "select id from _%s where _%s=%s and __retired__ <> %s"%(
1583             self.classname, self.key, self.db.arg, self.db.arg)
1584         self.db.sql(sql, (keyvalue, 1))
1586         # see if there was a result that's not retired
1587         row = self.db.sql_fetchone()
1588         if not row:
1589             raise KeyError, 'No key (%s) value "%s" for "%s"'%(self.key,
1590                 keyvalue, self.classname)
1592         # return the id
1593         return row[0]
1595     def find(self, **propspec):
1596         '''Get the ids of nodes in this class which link to the given nodes.
1598         'propspec' consists of keyword args propname=nodeid or
1599                    propname={nodeid:1, }
1600         'propname' must be the name of a property in this class, or a
1601         KeyError is raised.  That property must be a Link or Multilink
1602         property, or a TypeError is raised.
1604         Any node in this class whose 'propname' property links to any of the
1605         nodeids will be returned. Used by the full text indexing, which knows
1606         that "foo" occurs in msg1, msg3 and file7, so we have hits on these
1607         issues:
1609             db.issue.find(messages={'1':1,'3':1}, files={'7':1})
1610         '''
1611         if __debug__:
1612             print >>hyperdb.DEBUG, 'find', (self, propspec)
1614         # shortcut
1615         if not propspec:
1616             return []
1618         # validate the args
1619         props = self.getprops()
1620         propspec = propspec.items()
1621         for propname, nodeids in propspec:
1622             # check the prop is OK
1623             prop = props[propname]
1624             if not isinstance(prop, Link) and not isinstance(prop, Multilink):
1625                 raise TypeError, "'%s' not a Link/Multilink property"%propname
1627         # first, links
1628         where = []
1629         allvalues = ()
1630         a = self.db.arg
1631         for prop, values in propspec:
1632             if not isinstance(props[prop], hyperdb.Link):
1633                 continue
1634             if type(values) is type(''):
1635                 allvalues += (values,)
1636                 where.append('_%s = %s'%(prop, a))
1637             else:
1638                 allvalues += tuple(values.keys())
1639                 where.append('_%s in (%s)'%(prop, ','.join([a]*len(values))))
1640         tables = []
1641         if where:
1642             tables.append('select id as nodeid from _%s where %s'%(
1643                 self.classname, ' and '.join(where)))
1645         # now multilinks
1646         for prop, values in propspec:
1647             if not isinstance(props[prop], hyperdb.Multilink):
1648                 continue
1649             if type(values) is type(''):
1650                 allvalues += (values,)
1651                 s = a
1652             else:
1653                 allvalues += tuple(values.keys())
1654                 s = ','.join([a]*len(values))
1655             tables.append('select nodeid from %s_%s where linkid in (%s)'%(
1656                 self.classname, prop, s))
1657         sql = '\nunion\n'.join(tables)
1658         self.db.sql(sql, allvalues)
1659         l = [x[0] for x in self.db.sql_fetchall()]
1660         if __debug__:
1661             print >>hyperdb.DEBUG, 'find ... ', l
1662         return l
1664     def stringFind(self, **requirements):
1665         '''Locate a particular node by matching a set of its String
1666         properties in a caseless search.
1668         If the property is not a String property, a TypeError is raised.
1669         
1670         The return is a list of the id of all nodes that match.
1671         '''
1672         where = []
1673         args = []
1674         for propname in requirements.keys():
1675             prop = self.properties[propname]
1676             if isinstance(not prop, String):
1677                 raise TypeError, "'%s' not a String property"%propname
1678             where.append(propname)
1679             args.append(requirements[propname].lower())
1681         # generate the where clause
1682         s = ' and '.join(['_%s=%s'%(col, self.db.arg) for col in where])
1683         sql = 'select id from _%s where %s'%(self.classname, s)
1684         self.db.sql(sql, tuple(args))
1685         l = [x[0] for x in self.db.sql_fetchall()]
1686         if __debug__:
1687             print >>hyperdb.DEBUG, 'find ... ', l
1688         return l
1690     def list(self):
1691         ''' Return a list of the ids of the active nodes in this class.
1692         '''
1693         return self.db.getnodeids(self.classname, retired=0)
1695     def filter(self, search_matches, filterspec, sort=(None,None),
1696             group=(None,None)):
1697         ''' Return a list of the ids of the active nodes in this class that
1698             match the 'filter' spec, sorted by the group spec and then the
1699             sort spec
1701             "filterspec" is {propname: value(s)}
1702             "sort" and "group" are (dir, prop) where dir is '+', '-' or None
1703                                and prop is a prop name or None
1704             "search_matches" is {nodeid: marker}
1706             The filter must match all properties specificed - but if the
1707             property value to match is a list, any one of the values in the
1708             list may match for that property to match.
1709         '''
1710         # just don't bother if the full-text search matched diddly
1711         if search_matches == {}:
1712             return []
1714         cn = self.classname
1716         # figure the WHERE clause from the filterspec
1717         props = self.getprops()
1718         frum = ['_'+cn]
1719         where = []
1720         args = []
1721         a = self.db.arg
1722         for k, v in filterspec.items():
1723             propclass = props[k]
1724             # now do other where clause stuff
1725             if isinstance(propclass, Multilink):
1726                 tn = '%s_%s'%(cn, k)
1727                 frum.append(tn)
1728                 if isinstance(v, type([])):
1729                     s = ','.join([a for x in v])
1730                     where.append('id=%s.nodeid and %s.linkid in (%s)'%(tn,tn,s))
1731                     args = args + v
1732                 else:
1733                     where.append('id=%s.nodeid and %s.linkid = %s'%(tn, tn, a))
1734                     args.append(v)
1735             elif k == 'id':
1736                 if isinstance(v, type([])):
1737                     s = ','.join([a for x in v])
1738                     where.append('%s in (%s)'%(k, s))
1739                     args = args + v
1740                 else:
1741                     where.append('%s=%s'%(k, a))
1742                     args.append(v)
1743             elif isinstance(propclass, String):
1744                 if not isinstance(v, type([])):
1745                     v = [v]
1747                 # Quote the bits in the string that need it and then embed
1748                 # in a "substring" search. Note - need to quote the '%' so
1749                 # they make it through the python layer happily
1750                 v = ['%%'+self.db.sql_stringquote(s)+'%%' for s in v]
1752                 # now add to the where clause
1753                 where.append(' or '.join(["_%s LIKE '%s'"%(k, s) for s in v]))
1754                 # note: args are embedded in the query string now
1755             elif isinstance(propclass, Link):
1756                 if isinstance(v, type([])):
1757                     if '-1' in v:
1758                         v.remove('-1')
1759                         xtra = ' or _%s is NULL'%k
1760                     else:
1761                         xtra = ''
1762                     if v:
1763                         s = ','.join([a for x in v])
1764                         where.append('(_%s in (%s)%s)'%(k, s, xtra))
1765                         args = args + v
1766                     else:
1767                         where.append('_%s is NULL'%k)
1768                 else:
1769                     if v == '-1':
1770                         v = None
1771                         where.append('_%s is NULL'%k)
1772                     else:
1773                         where.append('_%s=%s'%(k, a))
1774                         args.append(v)
1775             elif isinstance(propclass, Date):
1776                 if isinstance(v, type([])):
1777                     s = ','.join([a for x in v])
1778                     where.append('_%s in (%s)'%(k, s))
1779                     args = args + [date.Date(x).serialise() for x in v]
1780                 else:
1781                     where.append('_%s=%s'%(k, a))
1782                     args.append(date.Date(v).serialise())
1783             elif isinstance(propclass, Interval):
1784                 if isinstance(v, type([])):
1785                     s = ','.join([a for x in v])
1786                     where.append('_%s in (%s)'%(k, s))
1787                     args = args + [date.Interval(x).serialise() for x in v]
1788                 else:
1789                     where.append('_%s=%s'%(k, a))
1790                     args.append(date.Interval(v).serialise())
1791             else:
1792                 if isinstance(v, type([])):
1793                     s = ','.join([a for x in v])
1794                     where.append('_%s in (%s)'%(k, s))
1795                     args = args + v
1796                 else:
1797                     where.append('_%s=%s'%(k, a))
1798                     args.append(v)
1800         # add results of full text search
1801         if search_matches is not None:
1802             v = search_matches.keys()
1803             s = ','.join([a for x in v])
1804             where.append('id in (%s)'%s)
1805             args = args + v
1807         # "grouping" is just the first-order sorting in the SQL fetch
1808         # can modify it...)
1809         orderby = []
1810         ordercols = []
1811         if group[0] is not None and group[1] is not None:
1812             if group[0] != '-':
1813                 orderby.append('_'+group[1])
1814                 ordercols.append('_'+group[1])
1815             else:
1816                 orderby.append('_'+group[1]+' desc')
1817                 ordercols.append('_'+group[1])
1819         # now add in the sorting
1820         group = ''
1821         if sort[0] is not None and sort[1] is not None:
1822             direction, colname = sort
1823             if direction != '-':
1824                 if colname == 'id':
1825                     orderby.append(colname)
1826                 else:
1827                     orderby.append('_'+colname)
1828                     ordercols.append('_'+colname)
1829             else:
1830                 if colname == 'id':
1831                     orderby.append(colname+' desc')
1832                     ordercols.append(colname)
1833                 else:
1834                     orderby.append('_'+colname+' desc')
1835                     ordercols.append('_'+colname)
1837         # construct the SQL
1838         frum = ','.join(frum)
1839         if where:
1840             where = ' where ' + (' and '.join(where))
1841         else:
1842             where = ''
1843         cols = ['id']
1844         if orderby:
1845             cols = cols + ordercols
1846             order = ' order by %s'%(','.join(orderby))
1847         else:
1848             order = ''
1849         cols = ','.join(cols)
1850         sql = 'select %s from %s %s%s%s'%(cols, frum, where, group, order)
1851         args = tuple(args)
1852         if __debug__:
1853             print >>hyperdb.DEBUG, 'filter', (self, sql, args)
1854         self.db.cursor.execute(sql, args)
1855         l = self.db.cursor.fetchall()
1857         # return the IDs (the first column)
1858         # XXX The filter(None, l) bit is sqlite-specific... if there's _NO_
1859         # XXX matches to a fetch, it returns NULL instead of nothing!?!
1860         return filter(None, [row[0] for row in l])
1862     def count(self):
1863         '''Get the number of nodes in this class.
1865         If the returned integer is 'numnodes', the ids of all the nodes
1866         in this class run from 1 to numnodes, and numnodes+1 will be the
1867         id of the next node to be created in this class.
1868         '''
1869         return self.db.countnodes(self.classname)
1871     # Manipulating properties:
1872     def getprops(self, protected=1):
1873         '''Return a dictionary mapping property names to property objects.
1874            If the "protected" flag is true, we include protected properties -
1875            those which may not be modified.
1876         '''
1877         d = self.properties.copy()
1878         if protected:
1879             d['id'] = String()
1880             d['creation'] = hyperdb.Date()
1881             d['activity'] = hyperdb.Date()
1882             d['creator'] = hyperdb.Link('user')
1883         return d
1885     def addprop(self, **properties):
1886         '''Add properties to this class.
1888         The keyword arguments in 'properties' must map names to property
1889         objects, or a TypeError is raised.  None of the keys in 'properties'
1890         may collide with the names of existing properties, or a ValueError
1891         is raised before any properties have been added.
1892         '''
1893         for key in properties.keys():
1894             if self.properties.has_key(key):
1895                 raise ValueError, key
1896         self.properties.update(properties)
1898     def index(self, nodeid):
1899         '''Add (or refresh) the node to search indexes
1900         '''
1901         # find all the String properties that have indexme
1902         for prop, propclass in self.getprops().items():
1903             if isinstance(propclass, String) and propclass.indexme:
1904                 try:
1905                     value = str(self.get(nodeid, prop))
1906                 except IndexError:
1907                     # node no longer exists - entry should be removed
1908                     self.db.indexer.purge_entry((self.classname, nodeid, prop))
1909                 else:
1910                     # and index them under (classname, nodeid, property)
1911                     self.db.indexer.add_text((self.classname, nodeid, prop),
1912                         value)
1915     #
1916     # Detector interface
1917     #
1918     def audit(self, event, detector):
1919         '''Register a detector
1920         '''
1921         l = self.auditors[event]
1922         if detector not in l:
1923             self.auditors[event].append(detector)
1925     def fireAuditors(self, action, nodeid, newvalues):
1926         '''Fire all registered auditors.
1927         '''
1928         for audit in self.auditors[action]:
1929             audit(self.db, self, nodeid, newvalues)
1931     def react(self, event, detector):
1932         '''Register a detector
1933         '''
1934         l = self.reactors[event]
1935         if detector not in l:
1936             self.reactors[event].append(detector)
1938     def fireReactors(self, action, nodeid, oldvalues):
1939         '''Fire all registered reactors.
1940         '''
1941         for react in self.reactors[action]:
1942             react(self.db, self, nodeid, oldvalues)
1944 class FileClass(Class, hyperdb.FileClass):
1945     '''This class defines a large chunk of data. To support this, it has a
1946        mandatory String property "content" which is typically saved off
1947        externally to the hyperdb.
1949        The default MIME type of this data is defined by the
1950        "default_mime_type" class attribute, which may be overridden by each
1951        node if the class defines a "type" String property.
1952     '''
1953     default_mime_type = 'text/plain'
1955     def create(self, **propvalues):
1956         ''' snaffle the file propvalue and store in a file
1957         '''
1958         # we need to fire the auditors now, or the content property won't
1959         # be in propvalues for the auditors to play with
1960         self.fireAuditors('create', None, propvalues)
1962         # now remove the content property so it's not stored in the db
1963         content = propvalues['content']
1964         del propvalues['content']
1966         # do the database create
1967         newid = Class.create_inner(self, **propvalues)
1969         # fire reactors
1970         self.fireReactors('create', newid, None)
1972         # store off the content as a file
1973         self.db.storefile(self.classname, newid, None, content)
1974         return newid
1976     def import_list(self, propnames, proplist):
1977         ''' Trap the "content" property...
1978         '''
1979         # dupe this list so we don't affect others
1980         propnames = propnames[:]
1982         # extract the "content" property from the proplist
1983         i = propnames.index('content')
1984         content = eval(proplist[i])
1985         del propnames[i]
1986         del proplist[i]
1988         # do the normal import
1989         newid = Class.import_list(self, propnames, proplist)
1991         # save off the "content" file
1992         self.db.storefile(self.classname, newid, None, content)
1993         return newid
1995     _marker = []
1996     def get(self, nodeid, propname, default=_marker, cache=1):
1997         ''' trap the content propname and get it from the file
1998         '''
1999         poss_msg = 'Possibly a access right configuration problem.'
2000         if propname == 'content':
2001             try:
2002                 return self.db.getfile(self.classname, nodeid, None)
2003             except IOError, (strerror):
2004                 # BUG: by catching this we donot see an error in the log.
2005                 return 'ERROR reading file: %s%s\n%s\n%s'%(
2006                         self.classname, nodeid, poss_msg, strerror)
2007         if default is not self._marker:
2008             return Class.get(self, nodeid, propname, default, cache=cache)
2009         else:
2010             return Class.get(self, nodeid, propname, cache=cache)
2012     def getprops(self, protected=1):
2013         ''' In addition to the actual properties on the node, these methods
2014             provide the "content" property. If the "protected" flag is true,
2015             we include protected properties - those which may not be
2016             modified.
2017         '''
2018         d = Class.getprops(self, protected=protected).copy()
2019         d['content'] = hyperdb.String()
2020         return d
2022     def index(self, nodeid):
2023         ''' Index the node in the search index.
2025             We want to index the content in addition to the normal String
2026             property indexing.
2027         '''
2028         # perform normal indexing
2029         Class.index(self, nodeid)
2031         # get the content to index
2032         content = self.get(nodeid, 'content')
2034         # figure the mime type
2035         if self.properties.has_key('type'):
2036             mime_type = self.get(nodeid, 'type')
2037         else:
2038             mime_type = self.default_mime_type
2040         # and index!
2041         self.db.indexer.add_text((self.classname, nodeid, 'content'), content,
2042             mime_type)
2044 # XXX deviation from spec - was called ItemClass
2045 class IssueClass(Class, roundupdb.IssueClass):
2046     # Overridden methods:
2047     def __init__(self, db, classname, **properties):
2048         '''The newly-created class automatically includes the "messages",
2049         "files", "nosy", and "superseder" properties.  If the 'properties'
2050         dictionary attempts to specify any of these properties or a
2051         "creation" or "activity" property, a ValueError is raised.
2052         '''
2053         if not properties.has_key('title'):
2054             properties['title'] = hyperdb.String(indexme='yes')
2055         if not properties.has_key('messages'):
2056             properties['messages'] = hyperdb.Multilink("msg")
2057         if not properties.has_key('files'):
2058             properties['files'] = hyperdb.Multilink("file")
2059         if not properties.has_key('nosy'):
2060             # note: journalling is turned off as it really just wastes
2061             # space. this behaviour may be overridden in an instance
2062             properties['nosy'] = hyperdb.Multilink("user", do_journal="no")
2063         if not properties.has_key('superseder'):
2064             properties['superseder'] = hyperdb.Multilink(classname)
2065         Class.__init__(self, db, classname, **properties)