Code

7388dbcb88d6807e35712e3ef049e3b441d04b9e
[roundup.git] / roundup / backends / rdbms_common.py
1 # $Id: rdbms_common.py,v 1.39 2003-03-06 06:03:51 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         # clear the cache
841         self.clearCache()
843     def doSaveNode(self, classname, nodeid, node):
844         ''' dummy that just generates a reindex event
845         '''
846         # return the classname, nodeid so we reindex this content
847         return (classname, nodeid)
849     def close(self):
850         ''' Close off the connection.
851         '''
852         self.conn.close()
853         if self.lockfile is not None:
854             locking.release_lock(self.lockfile)
855         if self.lockfile is not None:
856             self.lockfile.close()
857             self.lockfile = None
860 # The base Class class
862 class Class(hyperdb.Class):
863     ''' The handle to a particular class of nodes in a hyperdatabase.
864         
865         All methods except __repr__ and getnode must be implemented by a
866         concrete backend Class.
867     '''
869     def __init__(self, db, classname, **properties):
870         '''Create a new class with a given name and property specification.
872         'classname' must not collide with the name of an existing class,
873         or a ValueError is raised.  The keyword arguments in 'properties'
874         must map names to property objects, or a TypeError is raised.
875         '''
876         if (properties.has_key('creation') or properties.has_key('activity')
877                 or properties.has_key('creator')):
878             raise ValueError, '"creation", "activity" and "creator" are '\
879                 'reserved'
881         self.classname = classname
882         self.properties = properties
883         self.db = weakref.proxy(db)       # use a weak ref to avoid circularity
884         self.key = ''
886         # should we journal changes (default yes)
887         self.do_journal = 1
889         # do the db-related init stuff
890         db.addclass(self)
892         self.auditors = {'create': [], 'set': [], 'retire': []}
893         self.reactors = {'create': [], 'set': [], 'retire': []}
895     def schema(self):
896         ''' A dumpable version of the schema that we can store in the
897             database
898         '''
899         return (self.key, [(x, repr(y)) for x,y in self.properties.items()])
901     def enableJournalling(self):
902         '''Turn journalling on for this class
903         '''
904         self.do_journal = 1
906     def disableJournalling(self):
907         '''Turn journalling off for this class
908         '''
909         self.do_journal = 0
911     # Editing nodes:
912     def create(self, **propvalues):
913         ''' Create a new node of this class and return its id.
915         The keyword arguments in 'propvalues' map property names to values.
917         The values of arguments must be acceptable for the types of their
918         corresponding properties or a TypeError is raised.
919         
920         If this class has a key property, it must be present and its value
921         must not collide with other key strings or a ValueError is raised.
922         
923         Any other properties on this class that are missing from the
924         'propvalues' dictionary are set to None.
925         
926         If an id in a link or multilink property does not refer to a valid
927         node, an IndexError is raised.
928         '''
929         self.fireAuditors('create', None, propvalues)
930         newid = self.create_inner(**propvalues)
931         self.fireReactors('create', newid, None)
932         return newid
933     
934     def create_inner(self, **propvalues):
935         ''' Called by create, in-between the audit and react calls.
936         '''
937         if propvalues.has_key('id'):
938             raise KeyError, '"id" is reserved'
940         if self.db.journaltag is None:
941             raise DatabaseError, 'Database open read-only'
943         if propvalues.has_key('creation') or propvalues.has_key('activity'):
944             raise KeyError, '"creation" and "activity" are reserved'
946         # new node's id
947         newid = self.db.newid(self.classname)
949         # validate propvalues
950         num_re = re.compile('^\d+$')
951         for key, value in propvalues.items():
952             if key == self.key:
953                 try:
954                     self.lookup(value)
955                 except KeyError:
956                     pass
957                 else:
958                     raise ValueError, 'node with key "%s" exists'%value
960             # try to handle this property
961             try:
962                 prop = self.properties[key]
963             except KeyError:
964                 raise KeyError, '"%s" has no property "%s"'%(self.classname,
965                     key)
967             if value is not None and isinstance(prop, Link):
968                 if type(value) != type(''):
969                     raise ValueError, 'link value must be String'
970                 link_class = self.properties[key].classname
971                 # if it isn't a number, it's a key
972                 if not num_re.match(value):
973                     try:
974                         value = self.db.classes[link_class].lookup(value)
975                     except (TypeError, KeyError):
976                         raise IndexError, 'new property "%s": %s not a %s'%(
977                             key, value, link_class)
978                 elif not self.db.getclass(link_class).hasnode(value):
979                     raise IndexError, '%s has no node %s'%(link_class, value)
981                 # save off the value
982                 propvalues[key] = value
984                 # register the link with the newly linked node
985                 if self.do_journal and self.properties[key].do_journal:
986                     self.db.addjournal(link_class, value, 'link',
987                         (self.classname, newid, key))
989             elif isinstance(prop, Multilink):
990                 if type(value) != type([]):
991                     raise TypeError, 'new property "%s" not a list of ids'%key
993                 # clean up and validate the list of links
994                 link_class = self.properties[key].classname
995                 l = []
996                 for entry in value:
997                     if type(entry) != type(''):
998                         raise ValueError, '"%s" multilink value (%r) '\
999                             'must contain Strings'%(key, value)
1000                     # if it isn't a number, it's a key
1001                     if not num_re.match(entry):
1002                         try:
1003                             entry = self.db.classes[link_class].lookup(entry)
1004                         except (TypeError, KeyError):
1005                             raise IndexError, 'new property "%s": %s not a %s'%(
1006                                 key, entry, self.properties[key].classname)
1007                     l.append(entry)
1008                 value = l
1009                 propvalues[key] = value
1011                 # handle additions
1012                 for nodeid in value:
1013                     if not self.db.getclass(link_class).hasnode(nodeid):
1014                         raise IndexError, '%s has no node %s'%(link_class,
1015                             nodeid)
1016                     # register the link with the newly linked node
1017                     if self.do_journal and self.properties[key].do_journal:
1018                         self.db.addjournal(link_class, nodeid, 'link',
1019                             (self.classname, newid, key))
1021             elif isinstance(prop, String):
1022                 if type(value) != type('') and type(value) != type(u''):
1023                     raise TypeError, 'new property "%s" not a string'%key
1025             elif isinstance(prop, Password):
1026                 if not isinstance(value, password.Password):
1027                     raise TypeError, 'new property "%s" not a Password'%key
1029             elif isinstance(prop, Date):
1030                 if value is not None and not isinstance(value, date.Date):
1031                     raise TypeError, 'new property "%s" not a Date'%key
1033             elif isinstance(prop, Interval):
1034                 if value is not None and not isinstance(value, date.Interval):
1035                     raise TypeError, 'new property "%s" not an Interval'%key
1037             elif value is not None and isinstance(prop, Number):
1038                 try:
1039                     float(value)
1040                 except ValueError:
1041                     raise TypeError, 'new property "%s" not numeric'%key
1043             elif value is not None and isinstance(prop, Boolean):
1044                 try:
1045                     int(value)
1046                 except ValueError:
1047                     raise TypeError, 'new property "%s" not boolean'%key
1049         # make sure there's data where there needs to be
1050         for key, prop in self.properties.items():
1051             if propvalues.has_key(key):
1052                 continue
1053             if key == self.key:
1054                 raise ValueError, 'key property "%s" is required'%key
1055             if isinstance(prop, Multilink):
1056                 propvalues[key] = []
1057             else:
1058                 propvalues[key] = None
1060         # done
1061         self.db.addnode(self.classname, newid, propvalues)
1062         if self.do_journal:
1063             self.db.addjournal(self.classname, newid, 'create', {})
1065         return newid
1067     def export_list(self, propnames, nodeid):
1068         ''' Export a node - generate a list of CSV-able data in the order
1069             specified by propnames for the given node.
1070         '''
1071         properties = self.getprops()
1072         l = []
1073         for prop in propnames:
1074             proptype = properties[prop]
1075             value = self.get(nodeid, prop)
1076             # "marshal" data where needed
1077             if value is None:
1078                 pass
1079             elif isinstance(proptype, hyperdb.Date):
1080                 value = value.get_tuple()
1081             elif isinstance(proptype, hyperdb.Interval):
1082                 value = value.get_tuple()
1083             elif isinstance(proptype, hyperdb.Password):
1084                 value = str(value)
1085             l.append(repr(value))
1086         l.append(self.is_retired(nodeid))
1087         return l
1089     def import_list(self, propnames, proplist):
1090         ''' Import a node - all information including "id" is present and
1091             should not be sanity checked. Triggers are not triggered. The
1092             journal should be initialised using the "creator" and "created"
1093             information.
1095             Return the nodeid of the node imported.
1096         '''
1097         if self.db.journaltag is None:
1098             raise DatabaseError, 'Database open read-only'
1099         properties = self.getprops()
1101         # make the new node's property map
1102         d = {}
1103         for i in range(len(propnames)):
1104             # Use eval to reverse the repr() used to output the CSV
1105             value = eval(proplist[i])
1107             # Figure the property for this column
1108             propname = propnames[i]
1109             prop = properties[propname]
1111             # "unmarshal" where necessary
1112             if propname == 'id':
1113                 newid = value
1114                 continue
1115             elif value is None:
1116                 # don't set Nones
1117                 continue
1118             elif isinstance(prop, hyperdb.Date):
1119                 value = date.Date(value)
1120             elif isinstance(prop, hyperdb.Interval):
1121                 value = date.Interval(value)
1122             elif isinstance(prop, hyperdb.Password):
1123                 pwd = password.Password()
1124                 pwd.unpack(value)
1125                 value = pwd
1126             d[propname] = value
1128         # retire?
1129         if int(proplist[-1]):
1130             # use the arg for __retired__ to cope with any odd database type
1131             # conversion (hello, sqlite)
1132             sql = 'update _%s set __retired__=%s where id=%s'%(self.classname,
1133                 self.db.arg, self.db.arg)
1134             if __debug__:
1135                 print >>hyperdb.DEBUG, 'retire', (self, sql, newid)
1136             self.db.cursor.execute(sql, (1, newid))
1138         # add the node and journal
1139         self.db.addnode(self.classname, newid, d)
1141         # extract the extraneous journalling gumpf and nuke it
1142         if d.has_key('creator'):
1143             creator = d['creator']
1144             del d['creator']
1145         else:
1146             creator = None
1147         if d.has_key('creation'):
1148             creation = d['creation']
1149             del d['creation']
1150         else:
1151             creation = None
1152         if d.has_key('activity'):
1153             del d['activity']
1154         self.db.addjournal(self.classname, newid, 'create', {}, creator,
1155             creation)
1156         return newid
1158     _marker = []
1159     def get(self, nodeid, propname, default=_marker, cache=1):
1160         '''Get the value of a property on an existing node of this class.
1162         'nodeid' must be the id of an existing node of this class or an
1163         IndexError is raised.  'propname' must be the name of a property
1164         of this class or a KeyError is raised.
1166         'cache' indicates whether the transaction cache should be queried
1167         for the node. If the node has been modified and you need to
1168         determine what its values prior to modification are, you need to
1169         set cache=0.
1170         '''
1171         if propname == 'id':
1172             return nodeid
1174         # get the node's dict
1175         d = self.db.getnode(self.classname, nodeid)
1177         if propname == 'creation':
1178             if d.has_key('creation'):
1179                 return d['creation']
1180             else:
1181                 return date.Date()
1182         if propname == 'activity':
1183             if d.has_key('activity'):
1184                 return d['activity']
1185             else:
1186                 return date.Date()
1187         if propname == 'creator':
1188             if d.has_key('creator'):
1189                 return d['creator']
1190             else:
1191                 return self.db.curuserid
1193         # get the property (raises KeyErorr if invalid)
1194         prop = self.properties[propname]
1196         if not d.has_key(propname):
1197             if default is self._marker:
1198                 if isinstance(prop, Multilink):
1199                     return []
1200                 else:
1201                     return None
1202             else:
1203                 return default
1205         # don't pass our list to other code
1206         if isinstance(prop, Multilink):
1207             return d[propname][:]
1209         return d[propname]
1211     def getnode(self, nodeid, cache=1):
1212         ''' Return a convenience wrapper for the node.
1214         'nodeid' must be the id of an existing node of this class or an
1215         IndexError is raised.
1217         'cache' indicates whether the transaction cache should be queried
1218         for the node. If the node has been modified and you need to
1219         determine what its values prior to modification are, you need to
1220         set cache=0.
1221         '''
1222         return Node(self, nodeid, cache=cache)
1224     def set(self, nodeid, **propvalues):
1225         '''Modify a property on an existing node of this class.
1226         
1227         'nodeid' must be the id of an existing node of this class or an
1228         IndexError is raised.
1230         Each key in 'propvalues' must be the name of a property of this
1231         class or a KeyError is raised.
1233         All values in 'propvalues' must be acceptable types for their
1234         corresponding properties or a TypeError is raised.
1236         If the value of the key property is set, it must not collide with
1237         other key strings or a ValueError is raised.
1239         If the value of a Link or Multilink property contains an invalid
1240         node id, a ValueError is raised.
1241         '''
1242         if not propvalues:
1243             return propvalues
1245         if propvalues.has_key('creation') or propvalues.has_key('activity'):
1246             raise KeyError, '"creation" and "activity" are reserved'
1248         if propvalues.has_key('id'):
1249             raise KeyError, '"id" is reserved'
1251         if self.db.journaltag is None:
1252             raise DatabaseError, 'Database open read-only'
1254         self.fireAuditors('set', nodeid, propvalues)
1255         # Take a copy of the node dict so that the subsequent set
1256         # operation doesn't modify the oldvalues structure.
1257         # XXX used to try the cache here first
1258         oldvalues = copy.deepcopy(self.db.getnode(self.classname, nodeid))
1260         node = self.db.getnode(self.classname, nodeid)
1261         if self.is_retired(nodeid):
1262             raise IndexError, 'Requested item is retired'
1263         num_re = re.compile('^\d+$')
1265         # if the journal value is to be different, store it in here
1266         journalvalues = {}
1268         # remember the add/remove stuff for multilinks, making it easier
1269         # for the Database layer to do its stuff
1270         multilink_changes = {}
1272         for propname, value in propvalues.items():
1273             # check to make sure we're not duplicating an existing key
1274             if propname == self.key and node[propname] != value:
1275                 try:
1276                     self.lookup(value)
1277                 except KeyError:
1278                     pass
1279                 else:
1280                     raise ValueError, 'node with key "%s" exists'%value
1282             # this will raise the KeyError if the property isn't valid
1283             # ... we don't use getprops() here because we only care about
1284             # the writeable properties.
1285             try:
1286                 prop = self.properties[propname]
1287             except KeyError:
1288                 raise KeyError, '"%s" has no property named "%s"'%(
1289                     self.classname, propname)
1291             # if the value's the same as the existing value, no sense in
1292             # doing anything
1293             current = node.get(propname, None)
1294             if value == current:
1295                 del propvalues[propname]
1296                 continue
1297             journalvalues[propname] = current
1299             # do stuff based on the prop type
1300             if isinstance(prop, Link):
1301                 link_class = prop.classname
1302                 # if it isn't a number, it's a key
1303                 if value is not None and not isinstance(value, type('')):
1304                     raise ValueError, 'property "%s" link value be a string'%(
1305                         propname)
1306                 if isinstance(value, type('')) and not num_re.match(value):
1307                     try:
1308                         value = self.db.classes[link_class].lookup(value)
1309                     except (TypeError, KeyError):
1310                         raise IndexError, 'new property "%s": %s not a %s'%(
1311                             propname, value, prop.classname)
1313                 if (value is not None and
1314                         not self.db.getclass(link_class).hasnode(value)):
1315                     raise IndexError, '%s has no node %s'%(link_class, value)
1317                 if self.do_journal and prop.do_journal:
1318                     # register the unlink with the old linked node
1319                     if node[propname] is not None:
1320                         self.db.addjournal(link_class, node[propname], 'unlink',
1321                             (self.classname, nodeid, propname))
1323                     # register the link with the newly linked node
1324                     if value is not None:
1325                         self.db.addjournal(link_class, value, 'link',
1326                             (self.classname, nodeid, propname))
1328             elif isinstance(prop, Multilink):
1329                 if type(value) != type([]):
1330                     raise TypeError, 'new property "%s" not a list of'\
1331                         ' ids'%propname
1332                 link_class = self.properties[propname].classname
1333                 l = []
1334                 for entry in value:
1335                     # if it isn't a number, it's a key
1336                     if type(entry) != type(''):
1337                         raise ValueError, 'new property "%s" link value ' \
1338                             'must be a string'%propname
1339                     if not num_re.match(entry):
1340                         try:
1341                             entry = self.db.classes[link_class].lookup(entry)
1342                         except (TypeError, KeyError):
1343                             raise IndexError, 'new property "%s": %s not a %s'%(
1344                                 propname, entry,
1345                                 self.properties[propname].classname)
1346                     l.append(entry)
1347                 value = l
1348                 propvalues[propname] = value
1350                 # figure the journal entry for this property
1351                 add = []
1352                 remove = []
1354                 # handle removals
1355                 if node.has_key(propname):
1356                     l = node[propname]
1357                 else:
1358                     l = []
1359                 for id in l[:]:
1360                     if id in value:
1361                         continue
1362                     # register the unlink with the old linked node
1363                     if self.do_journal and self.properties[propname].do_journal:
1364                         self.db.addjournal(link_class, id, 'unlink',
1365                             (self.classname, nodeid, propname))
1366                     l.remove(id)
1367                     remove.append(id)
1369                 # handle additions
1370                 for id in value:
1371                     if not self.db.getclass(link_class).hasnode(id):
1372                         raise IndexError, '%s has no node %s'%(link_class, id)
1373                     if id in l:
1374                         continue
1375                     # register the link with the newly linked node
1376                     if self.do_journal and self.properties[propname].do_journal:
1377                         self.db.addjournal(link_class, id, 'link',
1378                             (self.classname, nodeid, propname))
1379                     l.append(id)
1380                     add.append(id)
1382                 # figure the journal entry
1383                 l = []
1384                 if add:
1385                     l.append(('+', add))
1386                 if remove:
1387                     l.append(('-', remove))
1388                 multilink_changes[propname] = (add, remove)
1389                 if l:
1390                     journalvalues[propname] = tuple(l)
1392             elif isinstance(prop, String):
1393                 if value is not None and type(value) != type('') and type(value) != type(u''):
1394                     raise TypeError, 'new property "%s" not a string'%propname
1396             elif isinstance(prop, Password):
1397                 if not isinstance(value, password.Password):
1398                     raise TypeError, 'new property "%s" not a Password'%propname
1399                 propvalues[propname] = value
1401             elif value is not None and isinstance(prop, Date):
1402                 if not isinstance(value, date.Date):
1403                     raise TypeError, 'new property "%s" not a Date'% propname
1404                 propvalues[propname] = value
1406             elif value is not None and isinstance(prop, Interval):
1407                 if not isinstance(value, date.Interval):
1408                     raise TypeError, 'new property "%s" not an '\
1409                         'Interval'%propname
1410                 propvalues[propname] = value
1412             elif value is not None and isinstance(prop, Number):
1413                 try:
1414                     float(value)
1415                 except ValueError:
1416                     raise TypeError, 'new property "%s" not numeric'%propname
1418             elif value is not None and isinstance(prop, Boolean):
1419                 try:
1420                     int(value)
1421                 except ValueError:
1422                     raise TypeError, 'new property "%s" not boolean'%propname
1424         # nothing to do?
1425         if not propvalues:
1426             return propvalues
1428         # do the set, and journal it
1429         self.db.setnode(self.classname, nodeid, propvalues, multilink_changes)
1431         if self.do_journal:
1432             self.db.addjournal(self.classname, nodeid, 'set', journalvalues)
1434         self.fireReactors('set', nodeid, oldvalues)
1436         return propvalues        
1438     def retire(self, nodeid):
1439         '''Retire a node.
1440         
1441         The properties on the node remain available from the get() method,
1442         and the node's id is never reused.
1443         
1444         Retired nodes are not returned by the find(), list(), or lookup()
1445         methods, and other nodes may reuse the values of their key properties.
1446         '''
1447         if self.db.journaltag is None:
1448             raise DatabaseError, 'Database open read-only'
1450         self.fireAuditors('retire', nodeid, None)
1452         # use the arg for __retired__ to cope with any odd database type
1453         # conversion (hello, sqlite)
1454         sql = 'update _%s set __retired__=%s where id=%s'%(self.classname,
1455             self.db.arg, self.db.arg)
1456         if __debug__:
1457             print >>hyperdb.DEBUG, 'retire', (self, sql, nodeid)
1458         self.db.cursor.execute(sql, (1, nodeid))
1460         self.fireReactors('retire', nodeid, None)
1462     def is_retired(self, nodeid):
1463         '''Return true if the node is rerired
1464         '''
1465         sql = 'select __retired__ from _%s where id=%s'%(self.classname,
1466             self.db.arg)
1467         if __debug__:
1468             print >>hyperdb.DEBUG, 'is_retired', (self, sql, nodeid)
1469         self.db.cursor.execute(sql, (nodeid,))
1470         return int(self.db.sql_fetchone()[0])
1472     def destroy(self, nodeid):
1473         '''Destroy a node.
1474         
1475         WARNING: this method should never be used except in extremely rare
1476                  situations where there could never be links to the node being
1477                  deleted
1478         WARNING: use retire() instead
1479         WARNING: the properties of this node will not be available ever again
1480         WARNING: really, use retire() instead
1482         Well, I think that's enough warnings. This method exists mostly to
1483         support the session storage of the cgi interface.
1485         The node is completely removed from the hyperdb, including all journal
1486         entries. It will no longer be available, and will generally break code
1487         if there are any references to the node.
1488         '''
1489         if self.db.journaltag is None:
1490             raise DatabaseError, 'Database open read-only'
1491         self.db.destroynode(self.classname, nodeid)
1493     def history(self, nodeid):
1494         '''Retrieve the journal of edits on a particular node.
1496         'nodeid' must be the id of an existing node of this class or an
1497         IndexError is raised.
1499         The returned list contains tuples of the form
1501             (nodeid, date, tag, action, params)
1503         'date' is a Timestamp object specifying the time of the change and
1504         'tag' is the journaltag specified when the database was opened.
1505         '''
1506         if not self.do_journal:
1507             raise ValueError, 'Journalling is disabled for this class'
1508         return self.db.getjournal(self.classname, nodeid)
1510     # Locating nodes:
1511     def hasnode(self, nodeid):
1512         '''Determine if the given nodeid actually exists
1513         '''
1514         return self.db.hasnode(self.classname, nodeid)
1516     def setkey(self, propname):
1517         '''Select a String property of this class to be the key property.
1519         'propname' must be the name of a String property of this class or
1520         None, or a TypeError is raised.  The values of the key property on
1521         all existing nodes must be unique or a ValueError is raised.
1522         '''
1523         # XXX create an index on the key prop column
1524         prop = self.getprops()[propname]
1525         if not isinstance(prop, String):
1526             raise TypeError, 'key properties must be String'
1527         self.key = propname
1529     def getkey(self):
1530         '''Return the name of the key property for this class or None.'''
1531         return self.key
1533     def labelprop(self, default_to_id=0):
1534         ''' Return the property name for a label for the given node.
1536         This method attempts to generate a consistent label for the node.
1537         It tries the following in order:
1538             1. key property
1539             2. "name" property
1540             3. "title" property
1541             4. first property from the sorted property name list
1542         '''
1543         k = self.getkey()
1544         if  k:
1545             return k
1546         props = self.getprops()
1547         if props.has_key('name'):
1548             return 'name'
1549         elif props.has_key('title'):
1550             return 'title'
1551         if default_to_id:
1552             return 'id'
1553         props = props.keys()
1554         props.sort()
1555         return props[0]
1557     def lookup(self, keyvalue):
1558         '''Locate a particular node by its key property and return its id.
1560         If this class has no key property, a TypeError is raised.  If the
1561         'keyvalue' matches one of the values for the key property among
1562         the nodes in this class, the matching node's id is returned;
1563         otherwise a KeyError is raised.
1564         '''
1565         if not self.key:
1566             raise TypeError, 'No key property set for class %s'%self.classname
1568         # use the arg to handle any odd database type conversion (hello,
1569         # sqlite)
1570         sql = "select id from _%s where _%s=%s and __retired__ <> %s"%(
1571             self.classname, self.key, self.db.arg, self.db.arg)
1572         self.db.sql(sql, (keyvalue, 1))
1574         # see if there was a result that's not retired
1575         row = self.db.sql_fetchone()
1576         if not row:
1577             raise KeyError, 'No key (%s) value "%s" for "%s"'%(self.key,
1578                 keyvalue, self.classname)
1580         # return the id
1581         return row[0]
1583     def find(self, **propspec):
1584         '''Get the ids of nodes in this class which link to the given nodes.
1586         'propspec' consists of keyword args propname=nodeid or
1587                    propname={nodeid:1, }
1588         'propname' must be the name of a property in this class, or a
1589         KeyError is raised.  That property must be a Link or Multilink
1590         property, or a TypeError is raised.
1592         Any node in this class whose 'propname' property links to any of the
1593         nodeids will be returned. Used by the full text indexing, which knows
1594         that "foo" occurs in msg1, msg3 and file7, so we have hits on these
1595         issues:
1597             db.issue.find(messages={'1':1,'3':1}, files={'7':1})
1598         '''
1599         if __debug__:
1600             print >>hyperdb.DEBUG, 'find', (self, propspec)
1602         # shortcut
1603         if not propspec:
1604             return []
1606         # validate the args
1607         props = self.getprops()
1608         propspec = propspec.items()
1609         for propname, nodeids in propspec:
1610             # check the prop is OK
1611             prop = props[propname]
1612             if not isinstance(prop, Link) and not isinstance(prop, Multilink):
1613                 raise TypeError, "'%s' not a Link/Multilink property"%propname
1615         # first, links
1616         where = []
1617         allvalues = ()
1618         a = self.db.arg
1619         for prop, values in propspec:
1620             if not isinstance(props[prop], hyperdb.Link):
1621                 continue
1622             if type(values) is type(''):
1623                 allvalues += (values,)
1624                 where.append('_%s = %s'%(prop, a))
1625             else:
1626                 allvalues += tuple(values.keys())
1627                 where.append('_%s in (%s)'%(prop, ','.join([a]*len(values))))
1628         tables = []
1629         if where:
1630             tables.append('select id as nodeid from _%s where %s'%(
1631                 self.classname, ' and '.join(where)))
1633         # now multilinks
1634         for prop, values in propspec:
1635             if not isinstance(props[prop], hyperdb.Multilink):
1636                 continue
1637             if type(values) is type(''):
1638                 allvalues += (values,)
1639                 s = a
1640             else:
1641                 allvalues += tuple(values.keys())
1642                 s = ','.join([a]*len(values))
1643             tables.append('select nodeid from %s_%s where linkid in (%s)'%(
1644                 self.classname, prop, s))
1645         sql = '\nunion\n'.join(tables)
1646         self.db.sql(sql, allvalues)
1647         l = [x[0] for x in self.db.sql_fetchall()]
1648         if __debug__:
1649             print >>hyperdb.DEBUG, 'find ... ', l
1650         return l
1652     def stringFind(self, **requirements):
1653         '''Locate a particular node by matching a set of its String
1654         properties in a caseless search.
1656         If the property is not a String property, a TypeError is raised.
1657         
1658         The return is a list of the id of all nodes that match.
1659         '''
1660         where = []
1661         args = []
1662         for propname in requirements.keys():
1663             prop = self.properties[propname]
1664             if isinstance(not prop, String):
1665                 raise TypeError, "'%s' not a String property"%propname
1666             where.append(propname)
1667             args.append(requirements[propname].lower())
1669         # generate the where clause
1670         s = ' and '.join(['_%s=%s'%(col, self.db.arg) for col in where])
1671         sql = 'select id from _%s where %s'%(self.classname, s)
1672         self.db.sql(sql, tuple(args))
1673         l = [x[0] for x in self.db.sql_fetchall()]
1674         if __debug__:
1675             print >>hyperdb.DEBUG, 'find ... ', l
1676         return l
1678     def list(self):
1679         ''' Return a list of the ids of the active nodes in this class.
1680         '''
1681         return self.getnodeids(retired=0)
1683     def getnodeids(self, retired=None):
1684         ''' Retrieve all the ids of the nodes for a particular Class.
1686             Set retired=None to get all nodes. Otherwise it'll get all the 
1687             retired or non-retired nodes, depending on the flag.
1688         '''
1689         # flip the sense of the flag if we don't want all of them
1690         if retired is not None:
1691             retired = not retired
1692         sql = 'select id from _%s where __retired__ <> %s'%(self.classname,
1693             self.db.arg)
1694         if __debug__:
1695             print >>hyperdb.DEBUG, 'getnodeids', (self, sql, retired)
1696         self.db.cursor.execute(sql, (retired,))
1697         return [x[0] for x in self.db.cursor.fetchall()]
1699     def filter(self, search_matches, filterspec, sort=(None,None),
1700             group=(None,None)):
1701         ''' Return a list of the ids of the active nodes in this class that
1702             match the 'filter' spec, sorted by the group spec and then the
1703             sort spec
1705             "filterspec" is {propname: value(s)}
1706             "sort" and "group" are (dir, prop) where dir is '+', '-' or None
1707                                and prop is a prop name or None
1708             "search_matches" is {nodeid: marker}
1710             The filter must match all properties specificed - but if the
1711             property value to match is a list, any one of the values in the
1712             list may match for that property to match.
1713         '''
1714         # just don't bother if the full-text search matched diddly
1715         if search_matches == {}:
1716             return []
1718         cn = self.classname
1720         # figure the WHERE clause from the filterspec
1721         props = self.getprops()
1722         frum = ['_'+cn]
1723         where = []
1724         args = []
1725         a = self.db.arg
1726         for k, v in filterspec.items():
1727             propclass = props[k]
1728             # now do other where clause stuff
1729             if isinstance(propclass, Multilink):
1730                 tn = '%s_%s'%(cn, k)
1731                 frum.append(tn)
1732                 if isinstance(v, type([])):
1733                     s = ','.join([a for x in v])
1734                     where.append('id=%s.nodeid and %s.linkid in (%s)'%(tn,tn,s))
1735                     args = args + v
1736                 else:
1737                     where.append('id=%s.nodeid and %s.linkid = %s'%(tn, tn, a))
1738                     args.append(v)
1739             elif k == 'id':
1740                 if isinstance(v, type([])):
1741                     s = ','.join([a for x in v])
1742                     where.append('%s in (%s)'%(k, s))
1743                     args = args + v
1744                 else:
1745                     where.append('%s=%s'%(k, a))
1746                     args.append(v)
1747             elif isinstance(propclass, String):
1748                 if not isinstance(v, type([])):
1749                     v = [v]
1751                 # Quote the bits in the string that need it and then embed
1752                 # in a "substring" search. Note - need to quote the '%' so
1753                 # they make it through the python layer happily
1754                 v = ['%%'+self.db.sql_stringquote(s)+'%%' for s in v]
1756                 # now add to the where clause
1757                 where.append(' or '.join(["_%s LIKE '%s'"%(k, s) for s in v]))
1758                 # note: args are embedded in the query string now
1759             elif isinstance(propclass, Link):
1760                 if isinstance(v, type([])):
1761                     if '-1' in v:
1762                         v.remove('-1')
1763                         xtra = ' or _%s is NULL'%k
1764                     else:
1765                         xtra = ''
1766                     if v:
1767                         s = ','.join([a for x in v])
1768                         where.append('(_%s in (%s)%s)'%(k, s, xtra))
1769                         args = args + v
1770                     else:
1771                         where.append('_%s is NULL'%k)
1772                 else:
1773                     if v == '-1':
1774                         v = None
1775                         where.append('_%s is NULL'%k)
1776                     else:
1777                         where.append('_%s=%s'%(k, a))
1778                         args.append(v)
1779             elif isinstance(propclass, Date):
1780                 if isinstance(v, type([])):
1781                     s = ','.join([a for x in v])
1782                     where.append('_%s in (%s)'%(k, s))
1783                     args = args + [date.Date(x).serialise() for x in v]
1784                 else:
1785                     where.append('_%s=%s'%(k, a))
1786                     args.append(date.Date(v).serialise())
1787             elif isinstance(propclass, Interval):
1788                 if isinstance(v, type([])):
1789                     s = ','.join([a for x in v])
1790                     where.append('_%s in (%s)'%(k, s))
1791                     args = args + [date.Interval(x).serialise() for x in v]
1792                 else:
1793                     where.append('_%s=%s'%(k, a))
1794                     args.append(date.Interval(v).serialise())
1795             else:
1796                 if isinstance(v, type([])):
1797                     s = ','.join([a for x in v])
1798                     where.append('_%s in (%s)'%(k, s))
1799                     args = args + v
1800                 else:
1801                     where.append('_%s=%s'%(k, a))
1802                     args.append(v)
1804         # add results of full text search
1805         if search_matches is not None:
1806             v = search_matches.keys()
1807             s = ','.join([a for x in v])
1808             where.append('id in (%s)'%s)
1809             args = args + v
1811         # "grouping" is just the first-order sorting in the SQL fetch
1812         # can modify it...)
1813         orderby = []
1814         ordercols = []
1815         if group[0] is not None and group[1] is not None:
1816             if group[0] != '-':
1817                 orderby.append('_'+group[1])
1818                 ordercols.append('_'+group[1])
1819             else:
1820                 orderby.append('_'+group[1]+' desc')
1821                 ordercols.append('_'+group[1])
1823         # now add in the sorting
1824         group = ''
1825         if sort[0] is not None and sort[1] is not None:
1826             direction, colname = sort
1827             if direction != '-':
1828                 if colname == 'id':
1829                     orderby.append(colname)
1830                 else:
1831                     orderby.append('_'+colname)
1832                     ordercols.append('_'+colname)
1833             else:
1834                 if colname == 'id':
1835                     orderby.append(colname+' desc')
1836                     ordercols.append(colname)
1837                 else:
1838                     orderby.append('_'+colname+' desc')
1839                     ordercols.append('_'+colname)
1841         # construct the SQL
1842         frum = ','.join(frum)
1843         if where:
1844             where = ' where ' + (' and '.join(where))
1845         else:
1846             where = ''
1847         cols = ['id']
1848         if orderby:
1849             cols = cols + ordercols
1850             order = ' order by %s'%(','.join(orderby))
1851         else:
1852             order = ''
1853         cols = ','.join(cols)
1854         sql = 'select %s from %s %s%s%s'%(cols, frum, where, group, order)
1855         args = tuple(args)
1856         if __debug__:
1857             print >>hyperdb.DEBUG, 'filter', (self, sql, args)
1858         self.db.cursor.execute(sql, args)
1859         l = self.db.cursor.fetchall()
1861         # return the IDs (the first column)
1862         # XXX The filter(None, l) bit is sqlite-specific... if there's _NO_
1863         # XXX matches to a fetch, it returns NULL instead of nothing!?!
1864         return filter(None, [row[0] for row in l])
1866     def count(self):
1867         '''Get the number of nodes in this class.
1869         If the returned integer is 'numnodes', the ids of all the nodes
1870         in this class run from 1 to numnodes, and numnodes+1 will be the
1871         id of the next node to be created in this class.
1872         '''
1873         return self.db.countnodes(self.classname)
1875     # Manipulating properties:
1876     def getprops(self, protected=1):
1877         '''Return a dictionary mapping property names to property objects.
1878            If the "protected" flag is true, we include protected properties -
1879            those which may not be modified.
1880         '''
1881         d = self.properties.copy()
1882         if protected:
1883             d['id'] = String()
1884             d['creation'] = hyperdb.Date()
1885             d['activity'] = hyperdb.Date()
1886             d['creator'] = hyperdb.Link('user')
1887         return d
1889     def addprop(self, **properties):
1890         '''Add properties to this class.
1892         The keyword arguments in 'properties' must map names to property
1893         objects, or a TypeError is raised.  None of the keys in 'properties'
1894         may collide with the names of existing properties, or a ValueError
1895         is raised before any properties have been added.
1896         '''
1897         for key in properties.keys():
1898             if self.properties.has_key(key):
1899                 raise ValueError, key
1900         self.properties.update(properties)
1902     def index(self, nodeid):
1903         '''Add (or refresh) the node to search indexes
1904         '''
1905         # find all the String properties that have indexme
1906         for prop, propclass in self.getprops().items():
1907             if isinstance(propclass, String) and propclass.indexme:
1908                 try:
1909                     value = str(self.get(nodeid, prop))
1910                 except IndexError:
1911                     # node no longer exists - entry should be removed
1912                     self.db.indexer.purge_entry((self.classname, nodeid, prop))
1913                 else:
1914                     # and index them under (classname, nodeid, property)
1915                     self.db.indexer.add_text((self.classname, nodeid, prop),
1916                         value)
1919     #
1920     # Detector interface
1921     #
1922     def audit(self, event, detector):
1923         '''Register a detector
1924         '''
1925         l = self.auditors[event]
1926         if detector not in l:
1927             self.auditors[event].append(detector)
1929     def fireAuditors(self, action, nodeid, newvalues):
1930         '''Fire all registered auditors.
1931         '''
1932         for audit in self.auditors[action]:
1933             audit(self.db, self, nodeid, newvalues)
1935     def react(self, event, detector):
1936         '''Register a detector
1937         '''
1938         l = self.reactors[event]
1939         if detector not in l:
1940             self.reactors[event].append(detector)
1942     def fireReactors(self, action, nodeid, oldvalues):
1943         '''Fire all registered reactors.
1944         '''
1945         for react in self.reactors[action]:
1946             react(self.db, self, nodeid, oldvalues)
1948 class FileClass(Class, hyperdb.FileClass):
1949     '''This class defines a large chunk of data. To support this, it has a
1950        mandatory String property "content" which is typically saved off
1951        externally to the hyperdb.
1953        The default MIME type of this data is defined by the
1954        "default_mime_type" class attribute, which may be overridden by each
1955        node if the class defines a "type" String property.
1956     '''
1957     default_mime_type = 'text/plain'
1959     def create(self, **propvalues):
1960         ''' snaffle the file propvalue and store in a file
1961         '''
1962         # we need to fire the auditors now, or the content property won't
1963         # be in propvalues for the auditors to play with
1964         self.fireAuditors('create', None, propvalues)
1966         # now remove the content property so it's not stored in the db
1967         content = propvalues['content']
1968         del propvalues['content']
1970         # do the database create
1971         newid = Class.create_inner(self, **propvalues)
1973         # fire reactors
1974         self.fireReactors('create', newid, None)
1976         # store off the content as a file
1977         self.db.storefile(self.classname, newid, None, content)
1978         return newid
1980     def import_list(self, propnames, proplist):
1981         ''' Trap the "content" property...
1982         '''
1983         # dupe this list so we don't affect others
1984         propnames = propnames[:]
1986         # extract the "content" property from the proplist
1987         i = propnames.index('content')
1988         content = eval(proplist[i])
1989         del propnames[i]
1990         del proplist[i]
1992         # do the normal import
1993         newid = Class.import_list(self, propnames, proplist)
1995         # save off the "content" file
1996         self.db.storefile(self.classname, newid, None, content)
1997         return newid
1999     _marker = []
2000     def get(self, nodeid, propname, default=_marker, cache=1):
2001         ''' trap the content propname and get it from the file
2002         '''
2003         poss_msg = 'Possibly a access right configuration problem.'
2004         if propname == 'content':
2005             try:
2006                 return self.db.getfile(self.classname, nodeid, None)
2007             except IOError, (strerror):
2008                 # BUG: by catching this we donot see an error in the log.
2009                 return 'ERROR reading file: %s%s\n%s\n%s'%(
2010                         self.classname, nodeid, poss_msg, strerror)
2011         if default is not self._marker:
2012             return Class.get(self, nodeid, propname, default, cache=cache)
2013         else:
2014             return Class.get(self, nodeid, propname, cache=cache)
2016     def getprops(self, protected=1):
2017         ''' In addition to the actual properties on the node, these methods
2018             provide the "content" property. If the "protected" flag is true,
2019             we include protected properties - those which may not be
2020             modified.
2021         '''
2022         d = Class.getprops(self, protected=protected).copy()
2023         d['content'] = hyperdb.String()
2024         return d
2026     def index(self, nodeid):
2027         ''' Index the node in the search index.
2029             We want to index the content in addition to the normal String
2030             property indexing.
2031         '''
2032         # perform normal indexing
2033         Class.index(self, nodeid)
2035         # get the content to index
2036         content = self.get(nodeid, 'content')
2038         # figure the mime type
2039         if self.properties.has_key('type'):
2040             mime_type = self.get(nodeid, 'type')
2041         else:
2042             mime_type = self.default_mime_type
2044         # and index!
2045         self.db.indexer.add_text((self.classname, nodeid, 'content'), content,
2046             mime_type)
2048 # XXX deviation from spec - was called ItemClass
2049 class IssueClass(Class, roundupdb.IssueClass):
2050     # Overridden methods:
2051     def __init__(self, db, classname, **properties):
2052         '''The newly-created class automatically includes the "messages",
2053         "files", "nosy", and "superseder" properties.  If the 'properties'
2054         dictionary attempts to specify any of these properties or a
2055         "creation" or "activity" property, a ValueError is raised.
2056         '''
2057         if not properties.has_key('title'):
2058             properties['title'] = hyperdb.String(indexme='yes')
2059         if not properties.has_key('messages'):
2060             properties['messages'] = hyperdb.Multilink("msg")
2061         if not properties.has_key('files'):
2062             properties['files'] = hyperdb.Multilink("file")
2063         if not properties.has_key('nosy'):
2064             # note: journalling is turned off as it really just wastes
2065             # space. this behaviour may be overridden in an instance
2066             properties['nosy'] = hyperdb.Multilink("user", do_journal="no")
2067         if not properties.has_key('superseder'):
2068             properties['superseder'] = hyperdb.Multilink(classname)
2069         Class.__init__(self, db, classname, **properties)