Code

fixes to import/export
[roundup.git] / roundup / backends / rdbms_common.py
1 # $Id: rdbms_common.py,v 1.38 2003-02-28 03:33:46 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 addjournal(self, classname, nodeid, action, params, creator=None,
719             creation=None):
720         ''' Journal the Action
721         'action' may be:
723             'create' or 'set' -- 'params' is a dictionary of property values
724             'link' or 'unlink' -- 'params' is (classname, nodeid, propname)
725             'retire' -- 'params' is None
726         '''
727         # serialise the parameters now if necessary
728         if isinstance(params, type({})):
729             if action in ('set', 'create'):
730                 params = self.serialise(classname, params)
732         # handle supply of the special journalling parameters (usually
733         # supplied on importing an existing database)
734         if creator:
735             journaltag = creator
736         else:
737             journaltag = self.curuserid
738         if creation:
739             journaldate = creation.serialise()
740         else:
741             journaldate = date.Date().serialise()
743         # create the journal entry
744         cols = ','.join('nodeid date tag action params'.split())
746         if __debug__:
747             print >>hyperdb.DEBUG, 'addjournal', (nodeid, journaldate,
748                 journaltag, action, params)
750         self.save_journal(classname, cols, nodeid, journaldate,
751             journaltag, action, params)
753     def save_journal(self, classname, cols, nodeid, journaldate,
754             journaltag, action, params):
755         ''' Save the journal entry to the database
756         '''
757         raise NotImplemented
759     def getjournal(self, classname, nodeid):
760         ''' get the journal for id
761         '''
762         # make sure the node exists
763         if not self.hasnode(classname, nodeid):
764             raise IndexError, '%s has no node %s'%(classname, nodeid)
766         cols = ','.join('nodeid date tag action params'.split())
767         return self.load_journal(classname, cols, nodeid)
769     def load_journal(self, classname, cols, nodeid):
770         ''' Load the journal from the database
771         '''
772         raise NotImplemented
774     def pack(self, pack_before):
775         ''' Delete all journal entries except "create" before 'pack_before'.
776         '''
777         # get a 'yyyymmddhhmmss' version of the date
778         date_stamp = pack_before.serialise()
780         # do the delete
781         for classname in self.classes.keys():
782             sql = "delete from %s__journal where date<%s and "\
783                 "action<>'create'"%(classname, self.arg)
784             if __debug__:
785                 print >>hyperdb.DEBUG, 'pack', (self, sql, date_stamp)
786             self.cursor.execute(sql, (date_stamp,))
788     def sql_commit(self):
789         ''' Actually commit to the database.
790         '''
791         self.conn.commit()
793     def commit(self):
794         ''' Commit the current transactions.
796         Save all data changed since the database was opened or since the
797         last commit() or rollback().
798         '''
799         if __debug__:
800             print >>hyperdb.DEBUG, 'commit', (self,)
802         # commit the database
803         self.sql_commit()
805         # now, do all the other transaction stuff
806         reindex = {}
807         for method, args in self.transactions:
808             reindex[method(*args)] = 1
810         # reindex the nodes that request it
811         for classname, nodeid in filter(None, reindex.keys()):
812             print >>hyperdb.DEBUG, 'commit.reindex', (classname, nodeid)
813             self.getclass(classname).index(nodeid)
815         # save the indexer state
816         self.indexer.save_index()
818         # clear out the transactions
819         self.transactions = []
821     def rollback(self):
822         ''' Reverse all actions from the current transaction.
824         Undo all the changes made since the database was opened or the last
825         commit() or rollback() was performed.
826         '''
827         if __debug__:
828             print >>hyperdb.DEBUG, 'rollback', (self,)
830         # roll back
831         self.conn.rollback()
833         # roll back "other" transaction stuff
834         for method, args in self.transactions:
835             # delete temporary files
836             if method == self.doStoreFile:
837                 self.rollbackStoreFile(*args)
838         self.transactions = []
840     def doSaveNode(self, classname, nodeid, node):
841         ''' dummy that just generates a reindex event
842         '''
843         # return the classname, nodeid so we reindex this content
844         return (classname, nodeid)
846     def close(self):
847         ''' Close off the connection.
848         '''
849         self.conn.close()
850         if self.lockfile is not None:
851             locking.release_lock(self.lockfile)
852         if self.lockfile is not None:
853             self.lockfile.close()
854             self.lockfile = None
857 # The base Class class
859 class Class(hyperdb.Class):
860     ''' The handle to a particular class of nodes in a hyperdatabase.
861         
862         All methods except __repr__ and getnode must be implemented by a
863         concrete backend Class.
864     '''
866     def __init__(self, db, classname, **properties):
867         '''Create a new class with a given name and property specification.
869         'classname' must not collide with the name of an existing class,
870         or a ValueError is raised.  The keyword arguments in 'properties'
871         must map names to property objects, or a TypeError is raised.
872         '''
873         if (properties.has_key('creation') or properties.has_key('activity')
874                 or properties.has_key('creator')):
875             raise ValueError, '"creation", "activity" and "creator" are '\
876                 'reserved'
878         self.classname = classname
879         self.properties = properties
880         self.db = weakref.proxy(db)       # use a weak ref to avoid circularity
881         self.key = ''
883         # should we journal changes (default yes)
884         self.do_journal = 1
886         # do the db-related init stuff
887         db.addclass(self)
889         self.auditors = {'create': [], 'set': [], 'retire': []}
890         self.reactors = {'create': [], 'set': [], 'retire': []}
892     def schema(self):
893         ''' A dumpable version of the schema that we can store in the
894             database
895         '''
896         return (self.key, [(x, repr(y)) for x,y in self.properties.items()])
898     def enableJournalling(self):
899         '''Turn journalling on for this class
900         '''
901         self.do_journal = 1
903     def disableJournalling(self):
904         '''Turn journalling off for this class
905         '''
906         self.do_journal = 0
908     # Editing nodes:
909     def create(self, **propvalues):
910         ''' Create a new node of this class and return its id.
912         The keyword arguments in 'propvalues' map property names to values.
914         The values of arguments must be acceptable for the types of their
915         corresponding properties or a TypeError is raised.
916         
917         If this class has a key property, it must be present and its value
918         must not collide with other key strings or a ValueError is raised.
919         
920         Any other properties on this class that are missing from the
921         'propvalues' dictionary are set to None.
922         
923         If an id in a link or multilink property does not refer to a valid
924         node, an IndexError is raised.
925         '''
926         self.fireAuditors('create', None, propvalues)
927         newid = self.create_inner(**propvalues)
928         self.fireReactors('create', newid, None)
929         return newid
930     
931     def create_inner(self, **propvalues):
932         ''' Called by create, in-between the audit and react calls.
933         '''
934         if propvalues.has_key('id'):
935             raise KeyError, '"id" is reserved'
937         if self.db.journaltag is None:
938             raise DatabaseError, 'Database open read-only'
940         if propvalues.has_key('creation') or propvalues.has_key('activity'):
941             raise KeyError, '"creation" and "activity" are reserved'
943         # new node's id
944         newid = self.db.newid(self.classname)
946         # validate propvalues
947         num_re = re.compile('^\d+$')
948         for key, value in propvalues.items():
949             if key == self.key:
950                 try:
951                     self.lookup(value)
952                 except KeyError:
953                     pass
954                 else:
955                     raise ValueError, 'node with key "%s" exists'%value
957             # try to handle this property
958             try:
959                 prop = self.properties[key]
960             except KeyError:
961                 raise KeyError, '"%s" has no property "%s"'%(self.classname,
962                     key)
964             if value is not None and isinstance(prop, Link):
965                 if type(value) != type(''):
966                     raise ValueError, 'link value must be String'
967                 link_class = self.properties[key].classname
968                 # if it isn't a number, it's a key
969                 if not num_re.match(value):
970                     try:
971                         value = self.db.classes[link_class].lookup(value)
972                     except (TypeError, KeyError):
973                         raise IndexError, 'new property "%s": %s not a %s'%(
974                             key, value, link_class)
975                 elif not self.db.getclass(link_class).hasnode(value):
976                     raise IndexError, '%s has no node %s'%(link_class, value)
978                 # save off the value
979                 propvalues[key] = value
981                 # register the link with the newly linked node
982                 if self.do_journal and self.properties[key].do_journal:
983                     self.db.addjournal(link_class, value, 'link',
984                         (self.classname, newid, key))
986             elif isinstance(prop, Multilink):
987                 if type(value) != type([]):
988                     raise TypeError, 'new property "%s" not a list of ids'%key
990                 # clean up and validate the list of links
991                 link_class = self.properties[key].classname
992                 l = []
993                 for entry in value:
994                     if type(entry) != type(''):
995                         raise ValueError, '"%s" multilink value (%r) '\
996                             'must contain Strings'%(key, value)
997                     # if it isn't a number, it's a key
998                     if not num_re.match(entry):
999                         try:
1000                             entry = self.db.classes[link_class].lookup(entry)
1001                         except (TypeError, KeyError):
1002                             raise IndexError, 'new property "%s": %s not a %s'%(
1003                                 key, entry, self.properties[key].classname)
1004                     l.append(entry)
1005                 value = l
1006                 propvalues[key] = value
1008                 # handle additions
1009                 for nodeid in value:
1010                     if not self.db.getclass(link_class).hasnode(nodeid):
1011                         raise IndexError, '%s has no node %s'%(link_class,
1012                             nodeid)
1013                     # register the link with the newly linked node
1014                     if self.do_journal and self.properties[key].do_journal:
1015                         self.db.addjournal(link_class, nodeid, 'link',
1016                             (self.classname, newid, key))
1018             elif isinstance(prop, String):
1019                 if type(value) != type('') and type(value) != type(u''):
1020                     raise TypeError, 'new property "%s" not a string'%key
1022             elif isinstance(prop, Password):
1023                 if not isinstance(value, password.Password):
1024                     raise TypeError, 'new property "%s" not a Password'%key
1026             elif isinstance(prop, Date):
1027                 if value is not None and not isinstance(value, date.Date):
1028                     raise TypeError, 'new property "%s" not a Date'%key
1030             elif isinstance(prop, Interval):
1031                 if value is not None and not isinstance(value, date.Interval):
1032                     raise TypeError, 'new property "%s" not an Interval'%key
1034             elif value is not None and isinstance(prop, Number):
1035                 try:
1036                     float(value)
1037                 except ValueError:
1038                     raise TypeError, 'new property "%s" not numeric'%key
1040             elif value is not None and isinstance(prop, Boolean):
1041                 try:
1042                     int(value)
1043                 except ValueError:
1044                     raise TypeError, 'new property "%s" not boolean'%key
1046         # make sure there's data where there needs to be
1047         for key, prop in self.properties.items():
1048             if propvalues.has_key(key):
1049                 continue
1050             if key == self.key:
1051                 raise ValueError, 'key property "%s" is required'%key
1052             if isinstance(prop, Multilink):
1053                 propvalues[key] = []
1054             else:
1055                 propvalues[key] = None
1057         # done
1058         self.db.addnode(self.classname, newid, propvalues)
1059         if self.do_journal:
1060             self.db.addjournal(self.classname, newid, 'create', {})
1062         return newid
1064     def export_list(self, propnames, nodeid):
1065         ''' Export a node - generate a list of CSV-able data in the order
1066             specified by propnames for the given node.
1067         '''
1068         properties = self.getprops()
1069         l = []
1070         for prop in propnames:
1071             proptype = properties[prop]
1072             value = self.get(nodeid, prop)
1073             # "marshal" data where needed
1074             if value is None:
1075                 pass
1076             elif isinstance(proptype, hyperdb.Date):
1077                 value = value.get_tuple()
1078             elif isinstance(proptype, hyperdb.Interval):
1079                 value = value.get_tuple()
1080             elif isinstance(proptype, hyperdb.Password):
1081                 value = str(value)
1082             l.append(repr(value))
1083         l.append(self.is_retired(nodeid))
1084         return l
1086     def import_list(self, propnames, proplist):
1087         ''' Import a node - all information including "id" is present and
1088             should not be sanity checked. Triggers are not triggered. The
1089             journal should be initialised using the "creator" and "created"
1090             information.
1092             Return the nodeid of the node imported.
1093         '''
1094         if self.db.journaltag is None:
1095             raise DatabaseError, 'Database open read-only'
1096         properties = self.getprops()
1098         # make the new node's property map
1099         d = {}
1100         for i in range(len(propnames)):
1101             # Use eval to reverse the repr() used to output the CSV
1102             value = eval(proplist[i])
1104             # Figure the property for this column
1105             propname = propnames[i]
1106             prop = properties[propname]
1108             # "unmarshal" where necessary
1109             if propname == 'id':
1110                 newid = value
1111                 continue
1112             elif value is None:
1113                 # don't set Nones
1114                 continue
1115             elif isinstance(prop, hyperdb.Date):
1116                 value = date.Date(value)
1117             elif isinstance(prop, hyperdb.Interval):
1118                 value = date.Interval(value)
1119             elif isinstance(prop, hyperdb.Password):
1120                 pwd = password.Password()
1121                 pwd.unpack(value)
1122                 value = pwd
1123             d[propname] = value
1125         # retire?
1126         if int(proplist[-1]):
1127             # use the arg for __retired__ to cope with any odd database type
1128             # conversion (hello, sqlite)
1129             sql = 'update _%s set __retired__=%s where id=%s'%(self.classname,
1130                 self.db.arg, self.db.arg)
1131             if __debug__:
1132                 print >>hyperdb.DEBUG, 'retire', (self, sql, newid)
1133             self.db.cursor.execute(sql, (1, newid))
1135         # add the node and journal
1136         self.db.addnode(self.classname, newid, d)
1138         # extract the extraneous journalling gumpf and nuke it
1139         if d.has_key('creator'):
1140             creator = d['creator']
1141             del d['creator']
1142         else:
1143             creator = None
1144         if d.has_key('creation'):
1145             creation = d['creation']
1146             del d['creation']
1147         else:
1148             creation = None
1149         if d.has_key('activity'):
1150             del d['activity']
1151         self.db.addjournal(self.classname, newid, 'create', {}, creator,
1152             creation)
1153         return newid
1155     _marker = []
1156     def get(self, nodeid, propname, default=_marker, cache=1):
1157         '''Get the value of a property on an existing node of this class.
1159         'nodeid' must be the id of an existing node of this class or an
1160         IndexError is raised.  'propname' must be the name of a property
1161         of this class or a KeyError is raised.
1163         'cache' indicates whether the transaction cache should be queried
1164         for the node. If the node has been modified and you need to
1165         determine what its values prior to modification are, you need to
1166         set cache=0.
1167         '''
1168         if propname == 'id':
1169             return nodeid
1171         # get the node's dict
1172         d = self.db.getnode(self.classname, nodeid)
1174         if propname == 'creation':
1175             if d.has_key('creation'):
1176                 return d['creation']
1177             else:
1178                 return date.Date()
1179         if propname == 'activity':
1180             if d.has_key('activity'):
1181                 return d['activity']
1182             else:
1183                 return date.Date()
1184         if propname == 'creator':
1185             if d.has_key('creator'):
1186                 return d['creator']
1187             else:
1188                 return self.db.curuserid
1190         # get the property (raises KeyErorr if invalid)
1191         prop = self.properties[propname]
1193         if not d.has_key(propname):
1194             if default is self._marker:
1195                 if isinstance(prop, Multilink):
1196                     return []
1197                 else:
1198                     return None
1199             else:
1200                 return default
1202         # don't pass our list to other code
1203         if isinstance(prop, Multilink):
1204             return d[propname][:]
1206         return d[propname]
1208     def getnode(self, nodeid, cache=1):
1209         ''' Return a convenience wrapper for the node.
1211         'nodeid' must be the id of an existing node of this class or an
1212         IndexError is raised.
1214         'cache' indicates whether the transaction cache should be queried
1215         for the node. If the node has been modified and you need to
1216         determine what its values prior to modification are, you need to
1217         set cache=0.
1218         '''
1219         return Node(self, nodeid, cache=cache)
1221     def set(self, nodeid, **propvalues):
1222         '''Modify a property on an existing node of this class.
1223         
1224         'nodeid' must be the id of an existing node of this class or an
1225         IndexError is raised.
1227         Each key in 'propvalues' must be the name of a property of this
1228         class or a KeyError is raised.
1230         All values in 'propvalues' must be acceptable types for their
1231         corresponding properties or a TypeError is raised.
1233         If the value of the key property is set, it must not collide with
1234         other key strings or a ValueError is raised.
1236         If the value of a Link or Multilink property contains an invalid
1237         node id, a ValueError is raised.
1238         '''
1239         if not propvalues:
1240             return propvalues
1242         if propvalues.has_key('creation') or propvalues.has_key('activity'):
1243             raise KeyError, '"creation" and "activity" are reserved'
1245         if propvalues.has_key('id'):
1246             raise KeyError, '"id" is reserved'
1248         if self.db.journaltag is None:
1249             raise DatabaseError, 'Database open read-only'
1251         self.fireAuditors('set', nodeid, propvalues)
1252         # Take a copy of the node dict so that the subsequent set
1253         # operation doesn't modify the oldvalues structure.
1254         # XXX used to try the cache here first
1255         oldvalues = copy.deepcopy(self.db.getnode(self.classname, nodeid))
1257         node = self.db.getnode(self.classname, nodeid)
1258         if self.is_retired(nodeid):
1259             raise IndexError, 'Requested item is retired'
1260         num_re = re.compile('^\d+$')
1262         # if the journal value is to be different, store it in here
1263         journalvalues = {}
1265         # remember the add/remove stuff for multilinks, making it easier
1266         # for the Database layer to do its stuff
1267         multilink_changes = {}
1269         for propname, value in propvalues.items():
1270             # check to make sure we're not duplicating an existing key
1271             if propname == self.key and node[propname] != value:
1272                 try:
1273                     self.lookup(value)
1274                 except KeyError:
1275                     pass
1276                 else:
1277                     raise ValueError, 'node with key "%s" exists'%value
1279             # this will raise the KeyError if the property isn't valid
1280             # ... we don't use getprops() here because we only care about
1281             # the writeable properties.
1282             try:
1283                 prop = self.properties[propname]
1284             except KeyError:
1285                 raise KeyError, '"%s" has no property named "%s"'%(
1286                     self.classname, propname)
1288             # if the value's the same as the existing value, no sense in
1289             # doing anything
1290             current = node.get(propname, None)
1291             if value == current:
1292                 del propvalues[propname]
1293                 continue
1294             journalvalues[propname] = current
1296             # do stuff based on the prop type
1297             if isinstance(prop, Link):
1298                 link_class = prop.classname
1299                 # if it isn't a number, it's a key
1300                 if value is not None and not isinstance(value, type('')):
1301                     raise ValueError, 'property "%s" link value be a string'%(
1302                         propname)
1303                 if isinstance(value, type('')) and not num_re.match(value):
1304                     try:
1305                         value = self.db.classes[link_class].lookup(value)
1306                     except (TypeError, KeyError):
1307                         raise IndexError, 'new property "%s": %s not a %s'%(
1308                             propname, value, prop.classname)
1310                 if (value is not None and
1311                         not self.db.getclass(link_class).hasnode(value)):
1312                     raise IndexError, '%s has no node %s'%(link_class, value)
1314                 if self.do_journal and prop.do_journal:
1315                     # register the unlink with the old linked node
1316                     if node[propname] is not None:
1317                         self.db.addjournal(link_class, node[propname], 'unlink',
1318                             (self.classname, nodeid, propname))
1320                     # register the link with the newly linked node
1321                     if value is not None:
1322                         self.db.addjournal(link_class, value, 'link',
1323                             (self.classname, nodeid, propname))
1325             elif isinstance(prop, Multilink):
1326                 if type(value) != type([]):
1327                     raise TypeError, 'new property "%s" not a list of'\
1328                         ' ids'%propname
1329                 link_class = self.properties[propname].classname
1330                 l = []
1331                 for entry in value:
1332                     # if it isn't a number, it's a key
1333                     if type(entry) != type(''):
1334                         raise ValueError, 'new property "%s" link value ' \
1335                             'must be a string'%propname
1336                     if not num_re.match(entry):
1337                         try:
1338                             entry = self.db.classes[link_class].lookup(entry)
1339                         except (TypeError, KeyError):
1340                             raise IndexError, 'new property "%s": %s not a %s'%(
1341                                 propname, entry,
1342                                 self.properties[propname].classname)
1343                     l.append(entry)
1344                 value = l
1345                 propvalues[propname] = value
1347                 # figure the journal entry for this property
1348                 add = []
1349                 remove = []
1351                 # handle removals
1352                 if node.has_key(propname):
1353                     l = node[propname]
1354                 else:
1355                     l = []
1356                 for id in l[:]:
1357                     if id in value:
1358                         continue
1359                     # register the unlink with the old linked node
1360                     if self.do_journal and self.properties[propname].do_journal:
1361                         self.db.addjournal(link_class, id, 'unlink',
1362                             (self.classname, nodeid, propname))
1363                     l.remove(id)
1364                     remove.append(id)
1366                 # handle additions
1367                 for id in value:
1368                     if not self.db.getclass(link_class).hasnode(id):
1369                         raise IndexError, '%s has no node %s'%(link_class, id)
1370                     if id in l:
1371                         continue
1372                     # register the link with the newly linked node
1373                     if self.do_journal and self.properties[propname].do_journal:
1374                         self.db.addjournal(link_class, id, 'link',
1375                             (self.classname, nodeid, propname))
1376                     l.append(id)
1377                     add.append(id)
1379                 # figure the journal entry
1380                 l = []
1381                 if add:
1382                     l.append(('+', add))
1383                 if remove:
1384                     l.append(('-', remove))
1385                 multilink_changes[propname] = (add, remove)
1386                 if l:
1387                     journalvalues[propname] = tuple(l)
1389             elif isinstance(prop, String):
1390                 if value is not None and type(value) != type('') and type(value) != type(u''):
1391                     raise TypeError, 'new property "%s" not a string'%propname
1393             elif isinstance(prop, Password):
1394                 if not isinstance(value, password.Password):
1395                     raise TypeError, 'new property "%s" not a Password'%propname
1396                 propvalues[propname] = value
1398             elif value is not None and isinstance(prop, Date):
1399                 if not isinstance(value, date.Date):
1400                     raise TypeError, 'new property "%s" not a Date'% propname
1401                 propvalues[propname] = value
1403             elif value is not None and isinstance(prop, Interval):
1404                 if not isinstance(value, date.Interval):
1405                     raise TypeError, 'new property "%s" not an '\
1406                         'Interval'%propname
1407                 propvalues[propname] = value
1409             elif value is not None and isinstance(prop, Number):
1410                 try:
1411                     float(value)
1412                 except ValueError:
1413                     raise TypeError, 'new property "%s" not numeric'%propname
1415             elif value is not None and isinstance(prop, Boolean):
1416                 try:
1417                     int(value)
1418                 except ValueError:
1419                     raise TypeError, 'new property "%s" not boolean'%propname
1421         # nothing to do?
1422         if not propvalues:
1423             return propvalues
1425         # do the set, and journal it
1426         self.db.setnode(self.classname, nodeid, propvalues, multilink_changes)
1428         if self.do_journal:
1429             self.db.addjournal(self.classname, nodeid, 'set', journalvalues)
1431         self.fireReactors('set', nodeid, oldvalues)
1433         return propvalues        
1435     def retire(self, nodeid):
1436         '''Retire a node.
1437         
1438         The properties on the node remain available from the get() method,
1439         and the node's id is never reused.
1440         
1441         Retired nodes are not returned by the find(), list(), or lookup()
1442         methods, and other nodes may reuse the values of their key properties.
1443         '''
1444         if self.db.journaltag is None:
1445             raise DatabaseError, 'Database open read-only'
1447         self.fireAuditors('retire', nodeid, None)
1449         # use the arg for __retired__ to cope with any odd database type
1450         # conversion (hello, sqlite)
1451         sql = 'update _%s set __retired__=%s where id=%s'%(self.classname,
1452             self.db.arg, self.db.arg)
1453         if __debug__:
1454             print >>hyperdb.DEBUG, 'retire', (self, sql, nodeid)
1455         self.db.cursor.execute(sql, (1, nodeid))
1457         self.fireReactors('retire', nodeid, None)
1459     def is_retired(self, nodeid):
1460         '''Return true if the node is rerired
1461         '''
1462         sql = 'select __retired__ from _%s where id=%s'%(self.classname,
1463             self.db.arg)
1464         if __debug__:
1465             print >>hyperdb.DEBUG, 'is_retired', (self, sql, nodeid)
1466         self.db.cursor.execute(sql, (nodeid,))
1467         return int(self.db.sql_fetchone()[0])
1469     def destroy(self, nodeid):
1470         '''Destroy a node.
1471         
1472         WARNING: this method should never be used except in extremely rare
1473                  situations where there could never be links to the node being
1474                  deleted
1475         WARNING: use retire() instead
1476         WARNING: the properties of this node will not be available ever again
1477         WARNING: really, use retire() instead
1479         Well, I think that's enough warnings. This method exists mostly to
1480         support the session storage of the cgi interface.
1482         The node is completely removed from the hyperdb, including all journal
1483         entries. It will no longer be available, and will generally break code
1484         if there are any references to the node.
1485         '''
1486         if self.db.journaltag is None:
1487             raise DatabaseError, 'Database open read-only'
1488         self.db.destroynode(self.classname, nodeid)
1490     def history(self, nodeid):
1491         '''Retrieve the journal of edits on a particular node.
1493         'nodeid' must be the id of an existing node of this class or an
1494         IndexError is raised.
1496         The returned list contains tuples of the form
1498             (nodeid, date, tag, action, params)
1500         'date' is a Timestamp object specifying the time of the change and
1501         'tag' is the journaltag specified when the database was opened.
1502         '''
1503         if not self.do_journal:
1504             raise ValueError, 'Journalling is disabled for this class'
1505         return self.db.getjournal(self.classname, nodeid)
1507     # Locating nodes:
1508     def hasnode(self, nodeid):
1509         '''Determine if the given nodeid actually exists
1510         '''
1511         return self.db.hasnode(self.classname, nodeid)
1513     def setkey(self, propname):
1514         '''Select a String property of this class to be the key property.
1516         'propname' must be the name of a String property of this class or
1517         None, or a TypeError is raised.  The values of the key property on
1518         all existing nodes must be unique or a ValueError is raised.
1519         '''
1520         # XXX create an index on the key prop column
1521         prop = self.getprops()[propname]
1522         if not isinstance(prop, String):
1523             raise TypeError, 'key properties must be String'
1524         self.key = propname
1526     def getkey(self):
1527         '''Return the name of the key property for this class or None.'''
1528         return self.key
1530     def labelprop(self, default_to_id=0):
1531         ''' Return the property name for a label for the given node.
1533         This method attempts to generate a consistent label for the node.
1534         It tries the following in order:
1535             1. key property
1536             2. "name" property
1537             3. "title" property
1538             4. first property from the sorted property name list
1539         '''
1540         k = self.getkey()
1541         if  k:
1542             return k
1543         props = self.getprops()
1544         if props.has_key('name'):
1545             return 'name'
1546         elif props.has_key('title'):
1547             return 'title'
1548         if default_to_id:
1549             return 'id'
1550         props = props.keys()
1551         props.sort()
1552         return props[0]
1554     def lookup(self, keyvalue):
1555         '''Locate a particular node by its key property and return its id.
1557         If this class has no key property, a TypeError is raised.  If the
1558         'keyvalue' matches one of the values for the key property among
1559         the nodes in this class, the matching node's id is returned;
1560         otherwise a KeyError is raised.
1561         '''
1562         if not self.key:
1563             raise TypeError, 'No key property set for class %s'%self.classname
1565         # use the arg to handle any odd database type conversion (hello,
1566         # sqlite)
1567         sql = "select id from _%s where _%s=%s and __retired__ <> %s"%(
1568             self.classname, self.key, self.db.arg, self.db.arg)
1569         self.db.sql(sql, (keyvalue, 1))
1571         # see if there was a result that's not retired
1572         row = self.db.sql_fetchone()
1573         if not row:
1574             raise KeyError, 'No key (%s) value "%s" for "%s"'%(self.key,
1575                 keyvalue, self.classname)
1577         # return the id
1578         return row[0]
1580     def find(self, **propspec):
1581         '''Get the ids of nodes in this class which link to the given nodes.
1583         'propspec' consists of keyword args propname=nodeid or
1584                    propname={nodeid:1, }
1585         'propname' must be the name of a property in this class, or a
1586         KeyError is raised.  That property must be a Link or Multilink
1587         property, or a TypeError is raised.
1589         Any node in this class whose 'propname' property links to any of the
1590         nodeids will be returned. Used by the full text indexing, which knows
1591         that "foo" occurs in msg1, msg3 and file7, so we have hits on these
1592         issues:
1594             db.issue.find(messages={'1':1,'3':1}, files={'7':1})
1595         '''
1596         if __debug__:
1597             print >>hyperdb.DEBUG, 'find', (self, propspec)
1599         # shortcut
1600         if not propspec:
1601             return []
1603         # validate the args
1604         props = self.getprops()
1605         propspec = propspec.items()
1606         for propname, nodeids in propspec:
1607             # check the prop is OK
1608             prop = props[propname]
1609             if not isinstance(prop, Link) and not isinstance(prop, Multilink):
1610                 raise TypeError, "'%s' not a Link/Multilink property"%propname
1612         # first, links
1613         where = []
1614         allvalues = ()
1615         a = self.db.arg
1616         for prop, values in propspec:
1617             if not isinstance(props[prop], hyperdb.Link):
1618                 continue
1619             if type(values) is type(''):
1620                 allvalues += (values,)
1621                 where.append('_%s = %s'%(prop, a))
1622             else:
1623                 allvalues += tuple(values.keys())
1624                 where.append('_%s in (%s)'%(prop, ','.join([a]*len(values))))
1625         tables = []
1626         if where:
1627             tables.append('select id as nodeid from _%s where %s'%(
1628                 self.classname, ' and '.join(where)))
1630         # now multilinks
1631         for prop, values in propspec:
1632             if not isinstance(props[prop], hyperdb.Multilink):
1633                 continue
1634             if type(values) is type(''):
1635                 allvalues += (values,)
1636                 s = a
1637             else:
1638                 allvalues += tuple(values.keys())
1639                 s = ','.join([a]*len(values))
1640             tables.append('select nodeid from %s_%s where linkid in (%s)'%(
1641                 self.classname, prop, s))
1642         sql = '\nunion\n'.join(tables)
1643         self.db.sql(sql, allvalues)
1644         l = [x[0] for x in self.db.sql_fetchall()]
1645         if __debug__:
1646             print >>hyperdb.DEBUG, 'find ... ', l
1647         return l
1649     def stringFind(self, **requirements):
1650         '''Locate a particular node by matching a set of its String
1651         properties in a caseless search.
1653         If the property is not a String property, a TypeError is raised.
1654         
1655         The return is a list of the id of all nodes that match.
1656         '''
1657         where = []
1658         args = []
1659         for propname in requirements.keys():
1660             prop = self.properties[propname]
1661             if isinstance(not prop, String):
1662                 raise TypeError, "'%s' not a String property"%propname
1663             where.append(propname)
1664             args.append(requirements[propname].lower())
1666         # generate the where clause
1667         s = ' and '.join(['_%s=%s'%(col, self.db.arg) for col in where])
1668         sql = 'select id from _%s where %s'%(self.classname, s)
1669         self.db.sql(sql, tuple(args))
1670         l = [x[0] for x in self.db.sql_fetchall()]
1671         if __debug__:
1672             print >>hyperdb.DEBUG, 'find ... ', l
1673         return l
1675     def list(self):
1676         ''' Return a list of the ids of the active nodes in this class.
1677         '''
1678         return self.getnodeids(retired=0)
1680     def getnodeids(self, retired=None):
1681         ''' Retrieve all the ids of the nodes for a particular Class.
1683             Set retired=None to get all nodes. Otherwise it'll get all the 
1684             retired or non-retired nodes, depending on the flag.
1685         '''
1686         # flip the sense of the flag if we don't want all of them
1687         if retired is not None:
1688             retired = not retired
1689         sql = 'select id from _%s where __retired__ <> %s'%(self.classname,
1690             self.db.arg)
1691         if __debug__:
1692             print >>hyperdb.DEBUG, 'getnodeids', (self, sql, retired)
1693         self.db.cursor.execute(sql, (retired,))
1694         return [x[0] for x in self.db.cursor.fetchall()]
1696     def filter(self, search_matches, filterspec, sort=(None,None),
1697             group=(None,None)):
1698         ''' Return a list of the ids of the active nodes in this class that
1699             match the 'filter' spec, sorted by the group spec and then the
1700             sort spec
1702             "filterspec" is {propname: value(s)}
1703             "sort" and "group" are (dir, prop) where dir is '+', '-' or None
1704                                and prop is a prop name or None
1705             "search_matches" is {nodeid: marker}
1707             The filter must match all properties specificed - but if the
1708             property value to match is a list, any one of the values in the
1709             list may match for that property to match.
1710         '''
1711         # just don't bother if the full-text search matched diddly
1712         if search_matches == {}:
1713             return []
1715         cn = self.classname
1717         # figure the WHERE clause from the filterspec
1718         props = self.getprops()
1719         frum = ['_'+cn]
1720         where = []
1721         args = []
1722         a = self.db.arg
1723         for k, v in filterspec.items():
1724             propclass = props[k]
1725             # now do other where clause stuff
1726             if isinstance(propclass, Multilink):
1727                 tn = '%s_%s'%(cn, k)
1728                 frum.append(tn)
1729                 if isinstance(v, type([])):
1730                     s = ','.join([a for x in v])
1731                     where.append('id=%s.nodeid and %s.linkid in (%s)'%(tn,tn,s))
1732                     args = args + v
1733                 else:
1734                     where.append('id=%s.nodeid and %s.linkid = %s'%(tn, tn, a))
1735                     args.append(v)
1736             elif k == 'id':
1737                 if isinstance(v, type([])):
1738                     s = ','.join([a for x in v])
1739                     where.append('%s in (%s)'%(k, s))
1740                     args = args + v
1741                 else:
1742                     where.append('%s=%s'%(k, a))
1743                     args.append(v)
1744             elif isinstance(propclass, String):
1745                 if not isinstance(v, type([])):
1746                     v = [v]
1748                 # Quote the bits in the string that need it and then embed
1749                 # in a "substring" search. Note - need to quote the '%' so
1750                 # they make it through the python layer happily
1751                 v = ['%%'+self.db.sql_stringquote(s)+'%%' for s in v]
1753                 # now add to the where clause
1754                 where.append(' or '.join(["_%s LIKE '%s'"%(k, s) for s in v]))
1755                 # note: args are embedded in the query string now
1756             elif isinstance(propclass, Link):
1757                 if isinstance(v, type([])):
1758                     if '-1' in v:
1759                         v.remove('-1')
1760                         xtra = ' or _%s is NULL'%k
1761                     else:
1762                         xtra = ''
1763                     if v:
1764                         s = ','.join([a for x in v])
1765                         where.append('(_%s in (%s)%s)'%(k, s, xtra))
1766                         args = args + v
1767                     else:
1768                         where.append('_%s is NULL'%k)
1769                 else:
1770                     if v == '-1':
1771                         v = None
1772                         where.append('_%s is NULL'%k)
1773                     else:
1774                         where.append('_%s=%s'%(k, a))
1775                         args.append(v)
1776             elif isinstance(propclass, Date):
1777                 if isinstance(v, type([])):
1778                     s = ','.join([a for x in v])
1779                     where.append('_%s in (%s)'%(k, s))
1780                     args = args + [date.Date(x).serialise() for x in v]
1781                 else:
1782                     where.append('_%s=%s'%(k, a))
1783                     args.append(date.Date(v).serialise())
1784             elif isinstance(propclass, Interval):
1785                 if isinstance(v, type([])):
1786                     s = ','.join([a for x in v])
1787                     where.append('_%s in (%s)'%(k, s))
1788                     args = args + [date.Interval(x).serialise() for x in v]
1789                 else:
1790                     where.append('_%s=%s'%(k, a))
1791                     args.append(date.Interval(v).serialise())
1792             else:
1793                 if isinstance(v, type([])):
1794                     s = ','.join([a for x in v])
1795                     where.append('_%s in (%s)'%(k, s))
1796                     args = args + v
1797                 else:
1798                     where.append('_%s=%s'%(k, a))
1799                     args.append(v)
1801         # add results of full text search
1802         if search_matches is not None:
1803             v = search_matches.keys()
1804             s = ','.join([a for x in v])
1805             where.append('id in (%s)'%s)
1806             args = args + v
1808         # "grouping" is just the first-order sorting in the SQL fetch
1809         # can modify it...)
1810         orderby = []
1811         ordercols = []
1812         if group[0] is not None and group[1] is not None:
1813             if group[0] != '-':
1814                 orderby.append('_'+group[1])
1815                 ordercols.append('_'+group[1])
1816             else:
1817                 orderby.append('_'+group[1]+' desc')
1818                 ordercols.append('_'+group[1])
1820         # now add in the sorting
1821         group = ''
1822         if sort[0] is not None and sort[1] is not None:
1823             direction, colname = sort
1824             if direction != '-':
1825                 if colname == 'id':
1826                     orderby.append(colname)
1827                 else:
1828                     orderby.append('_'+colname)
1829                     ordercols.append('_'+colname)
1830             else:
1831                 if colname == 'id':
1832                     orderby.append(colname+' desc')
1833                     ordercols.append(colname)
1834                 else:
1835                     orderby.append('_'+colname+' desc')
1836                     ordercols.append('_'+colname)
1838         # construct the SQL
1839         frum = ','.join(frum)
1840         if where:
1841             where = ' where ' + (' and '.join(where))
1842         else:
1843             where = ''
1844         cols = ['id']
1845         if orderby:
1846             cols = cols + ordercols
1847             order = ' order by %s'%(','.join(orderby))
1848         else:
1849             order = ''
1850         cols = ','.join(cols)
1851         sql = 'select %s from %s %s%s%s'%(cols, frum, where, group, order)
1852         args = tuple(args)
1853         if __debug__:
1854             print >>hyperdb.DEBUG, 'filter', (self, sql, args)
1855         self.db.cursor.execute(sql, args)
1856         l = self.db.cursor.fetchall()
1858         # return the IDs (the first column)
1859         # XXX The filter(None, l) bit is sqlite-specific... if there's _NO_
1860         # XXX matches to a fetch, it returns NULL instead of nothing!?!
1861         return filter(None, [row[0] for row in l])
1863     def count(self):
1864         '''Get the number of nodes in this class.
1866         If the returned integer is 'numnodes', the ids of all the nodes
1867         in this class run from 1 to numnodes, and numnodes+1 will be the
1868         id of the next node to be created in this class.
1869         '''
1870         return self.db.countnodes(self.classname)
1872     # Manipulating properties:
1873     def getprops(self, protected=1):
1874         '''Return a dictionary mapping property names to property objects.
1875            If the "protected" flag is true, we include protected properties -
1876            those which may not be modified.
1877         '''
1878         d = self.properties.copy()
1879         if protected:
1880             d['id'] = String()
1881             d['creation'] = hyperdb.Date()
1882             d['activity'] = hyperdb.Date()
1883             d['creator'] = hyperdb.Link('user')
1884         return d
1886     def addprop(self, **properties):
1887         '''Add properties to this class.
1889         The keyword arguments in 'properties' must map names to property
1890         objects, or a TypeError is raised.  None of the keys in 'properties'
1891         may collide with the names of existing properties, or a ValueError
1892         is raised before any properties have been added.
1893         '''
1894         for key in properties.keys():
1895             if self.properties.has_key(key):
1896                 raise ValueError, key
1897         self.properties.update(properties)
1899     def index(self, nodeid):
1900         '''Add (or refresh) the node to search indexes
1901         '''
1902         # find all the String properties that have indexme
1903         for prop, propclass in self.getprops().items():
1904             if isinstance(propclass, String) and propclass.indexme:
1905                 try:
1906                     value = str(self.get(nodeid, prop))
1907                 except IndexError:
1908                     # node no longer exists - entry should be removed
1909                     self.db.indexer.purge_entry((self.classname, nodeid, prop))
1910                 else:
1911                     # and index them under (classname, nodeid, property)
1912                     self.db.indexer.add_text((self.classname, nodeid, prop),
1913                         value)
1916     #
1917     # Detector interface
1918     #
1919     def audit(self, event, detector):
1920         '''Register a detector
1921         '''
1922         l = self.auditors[event]
1923         if detector not in l:
1924             self.auditors[event].append(detector)
1926     def fireAuditors(self, action, nodeid, newvalues):
1927         '''Fire all registered auditors.
1928         '''
1929         for audit in self.auditors[action]:
1930             audit(self.db, self, nodeid, newvalues)
1932     def react(self, event, detector):
1933         '''Register a detector
1934         '''
1935         l = self.reactors[event]
1936         if detector not in l:
1937             self.reactors[event].append(detector)
1939     def fireReactors(self, action, nodeid, oldvalues):
1940         '''Fire all registered reactors.
1941         '''
1942         for react in self.reactors[action]:
1943             react(self.db, self, nodeid, oldvalues)
1945 class FileClass(Class, hyperdb.FileClass):
1946     '''This class defines a large chunk of data. To support this, it has a
1947        mandatory String property "content" which is typically saved off
1948        externally to the hyperdb.
1950        The default MIME type of this data is defined by the
1951        "default_mime_type" class attribute, which may be overridden by each
1952        node if the class defines a "type" String property.
1953     '''
1954     default_mime_type = 'text/plain'
1956     def create(self, **propvalues):
1957         ''' snaffle the file propvalue and store in a file
1958         '''
1959         # we need to fire the auditors now, or the content property won't
1960         # be in propvalues for the auditors to play with
1961         self.fireAuditors('create', None, propvalues)
1963         # now remove the content property so it's not stored in the db
1964         content = propvalues['content']
1965         del propvalues['content']
1967         # do the database create
1968         newid = Class.create_inner(self, **propvalues)
1970         # fire reactors
1971         self.fireReactors('create', newid, None)
1973         # store off the content as a file
1974         self.db.storefile(self.classname, newid, None, content)
1975         return newid
1977     def import_list(self, propnames, proplist):
1978         ''' Trap the "content" property...
1979         '''
1980         # dupe this list so we don't affect others
1981         propnames = propnames[:]
1983         # extract the "content" property from the proplist
1984         i = propnames.index('content')
1985         content = eval(proplist[i])
1986         del propnames[i]
1987         del proplist[i]
1989         # do the normal import
1990         newid = Class.import_list(self, propnames, proplist)
1992         # save off the "content" file
1993         self.db.storefile(self.classname, newid, None, content)
1994         return newid
1996     _marker = []
1997     def get(self, nodeid, propname, default=_marker, cache=1):
1998         ''' trap the content propname and get it from the file
1999         '''
2000         poss_msg = 'Possibly a access right configuration problem.'
2001         if propname == 'content':
2002             try:
2003                 return self.db.getfile(self.classname, nodeid, None)
2004             except IOError, (strerror):
2005                 # BUG: by catching this we donot see an error in the log.
2006                 return 'ERROR reading file: %s%s\n%s\n%s'%(
2007                         self.classname, nodeid, poss_msg, strerror)
2008         if default is not self._marker:
2009             return Class.get(self, nodeid, propname, default, cache=cache)
2010         else:
2011             return Class.get(self, nodeid, propname, cache=cache)
2013     def getprops(self, protected=1):
2014         ''' In addition to the actual properties on the node, these methods
2015             provide the "content" property. If the "protected" flag is true,
2016             we include protected properties - those which may not be
2017             modified.
2018         '''
2019         d = Class.getprops(self, protected=protected).copy()
2020         d['content'] = hyperdb.String()
2021         return d
2023     def index(self, nodeid):
2024         ''' Index the node in the search index.
2026             We want to index the content in addition to the normal String
2027             property indexing.
2028         '''
2029         # perform normal indexing
2030         Class.index(self, nodeid)
2032         # get the content to index
2033         content = self.get(nodeid, 'content')
2035         # figure the mime type
2036         if self.properties.has_key('type'):
2037             mime_type = self.get(nodeid, 'type')
2038         else:
2039             mime_type = self.default_mime_type
2041         # and index!
2042         self.db.indexer.add_text((self.classname, nodeid, 'content'), content,
2043             mime_type)
2045 # XXX deviation from spec - was called ItemClass
2046 class IssueClass(Class, roundupdb.IssueClass):
2047     # Overridden methods:
2048     def __init__(self, db, classname, **properties):
2049         '''The newly-created class automatically includes the "messages",
2050         "files", "nosy", and "superseder" properties.  If the 'properties'
2051         dictionary attempts to specify any of these properties or a
2052         "creation" or "activity" property, a ValueError is raised.
2053         '''
2054         if not properties.has_key('title'):
2055             properties['title'] = hyperdb.String(indexme='yes')
2056         if not properties.has_key('messages'):
2057             properties['messages'] = hyperdb.Multilink("msg")
2058         if not properties.has_key('files'):
2059             properties['files'] = hyperdb.Multilink("file")
2060         if not properties.has_key('nosy'):
2061             # note: journalling is turned off as it really just wastes
2062             # space. this behaviour may be overridden in an instance
2063             properties['nosy'] = hyperdb.Multilink("user", do_journal="no")
2064         if not properties.has_key('superseder'):
2065             properties['superseder'] = hyperdb.Multilink(classname)
2066         Class.__init__(self, db, classname, **properties)