Code

- using Zope3's test runner now, allowing GC checks, nicer controls and
[roundup.git] / roundup / backends / rdbms_common.py
1 # $Id: rdbms_common.py,v 1.66 2003-10-25 22:53:26 richard Exp $
2 ''' Relational database (SQL) backend common code.
4 Basics:
6 - map roundup classes to relational tables
7 - automatically detect schema changes and modify the table schemas
8   appropriately (we store the "database version" of the schema in the
9   database itself as the only row of the "schema" table)
10 - multilinks (which represent a many-to-many relationship) are handled through
11   intermediate tables
12 - journals are stored adjunct to the per-class tables
13 - table names and columns have "_" prepended so the names can't clash with
14   restricted names (like "order")
15 - retirement is determined by the __retired__ column being true
17 Database-specific changes may generally be pushed out to the overridable
18 sql_* methods, since everything else should be fairly generic. There's
19 probably a bit of work to be done if a database is used that actually
20 honors column typing, since the initial databases don't (sqlite stores
21 everything as a string.)
22 '''
24 # standard python modules
25 import sys, os, time, re, errno, weakref, copy
27 # roundup modules
28 from roundup import hyperdb, date, password, roundupdb, security
29 from roundup.hyperdb import String, Password, Date, Interval, Link, \
30     Multilink, DatabaseError, Boolean, Number, Node
31 from roundup.backends import locking
33 # support
34 from blobfiles import FileStorage
35 from roundup.indexer import Indexer
36 from sessions import Sessions, OneTimeKeys
37 from roundup.date import Range
39 # number of rows to keep in memory
40 ROW_CACHE_SIZE = 100
42 class Database(FileStorage, hyperdb.Database, roundupdb.Database):
43     ''' Wrapper around an SQL database that presents a hyperdb interface.
45         - some functionality is specific to the actual SQL database, hence
46           the sql_* methods that are NotImplemented
47         - we keep a cache of the latest ROW_CACHE_SIZE row fetches.
48     '''
49     def __init__(self, config, journaltag=None):
50         ''' Open the database and load the schema from it.
51         '''
52         self.config, self.journaltag = config, journaltag
53         self.dir = config.DATABASE
54         self.classes = {}
55         self.indexer = Indexer(self.dir)
56         self.sessions = Sessions(self.config)
57         self.otks = OneTimeKeys(self.config)
58         self.security = security.Security(self)
60         # additional transaction support for external files and the like
61         self.transactions = []
63         # keep a cache of the N most recently retrieved rows of any kind
64         # (classname, nodeid) = row
65         self.cache = {}
66         self.cache_lru = []
68         # database lock
69         self.lockfile = None
71         # open a connection to the database, creating the "conn" attribute
72         self.open_connection()
74     def clearCache(self):
75         self.cache = {}
76         self.cache_lru = []
78     def open_connection(self):
79         ''' Open a connection to the database, creating it if necessary
80         '''
81         raise NotImplemented
83     def sql(self, sql, args=None):
84         ''' Execute the sql with the optional args.
85         '''
86         if __debug__:
87             print >>hyperdb.DEBUG, (self, sql, args)
88         if args:
89             self.cursor.execute(sql, args)
90         else:
91             self.cursor.execute(sql)
93     def sql_fetchone(self):
94         ''' Fetch a single row. If there's nothing to fetch, return None.
95         '''
96         raise NotImplemented
98     def sql_stringquote(self, value):
99         ''' Quote the string so it's safe to put in the 'sql quotes'
100         '''
101         return re.sub("'", "''", str(value))
103     def save_dbschema(self, schema):
104         ''' Save the schema definition that the database currently implements
105         '''
106         raise NotImplemented
108     def load_dbschema(self):
109         ''' Load the schema definition that the database currently implements
110         '''
111         raise NotImplemented
113     def post_init(self):
114         ''' Called once the schema initialisation has finished.
116             We should now confirm that the schema defined by our "classes"
117             attribute actually matches the schema in the database.
118         '''
119         # now detect changes in the schema
120         save = 0
121         for classname, spec in self.classes.items():
122             if self.database_schema.has_key(classname):
123                 dbspec = self.database_schema[classname]
124                 if self.update_class(spec, dbspec):
125                     self.database_schema[classname] = spec.schema()
126                     save = 1
127             else:
128                 self.create_class(spec)
129                 self.database_schema[classname] = spec.schema()
130                 save = 1
132         for classname, spec in self.database_schema.items():
133             if not self.classes.has_key(classname):
134                 self.drop_class(classname, spec)
135                 del self.database_schema[classname]
136                 save = 1
138         # update the database version of the schema
139         if save:
140             self.sql('delete from schema')
141             self.save_dbschema(self.database_schema)
143         # reindex the db if necessary
144         if self.indexer.should_reindex():
145             self.reindex()
147         # commit
148         self.conn.commit()
150     def refresh_database(self):
151         # now detect changes in the schema
152         for classname, spec in self.classes.items():
153             dbspec = self.database_schema[classname]
154             self.update_class(spec, dbspec, force=1)
155             self.database_schema[classname] = spec.schema()
156         # update the database version of the schema
157         self.sql('delete from schema')
158         self.save_dbschema(self.database_schema)
159         # reindex the db 
160         self.reindex()
161         # commit
162         self.conn.commit()
165     def reindex(self):
166         for klass in self.classes.values():
167             for nodeid in klass.list():
168                 klass.index(nodeid)
169         self.indexer.save_index()
171     def determine_columns(self, properties):
172         ''' Figure the column names and multilink properties from the spec
174             "properties" is a list of (name, prop) where prop may be an
175             instance of a hyperdb "type" _or_ a string repr of that type.
176         '''
177         cols = ['_activity', '_creator', '_creation']
178         mls = []
179         # add the multilinks separately
180         for col, prop in properties:
181             if isinstance(prop, Multilink):
182                 mls.append(col)
183             elif isinstance(prop, type('')) and prop.find('Multilink') != -1:
184                 mls.append(col)
185             else:
186                 cols.append('_'+col)
187         cols.sort()
188         return cols, mls
190     def update_class(self, spec, old_spec, force=0):
191         ''' Determine the differences between the current spec and the
192             database version of the spec, and update where necessary.
193             If 'force' is true, update the database anyway.
194         '''
195         new_has = spec.properties.has_key
196         new_spec = spec.schema()
197         new_spec[1].sort()
198         old_spec[1].sort()
199         if not force and new_spec == old_spec:
200             # no changes
201             return 0
203         if __debug__:
204             print >>hyperdb.DEBUG, 'update_class FIRING'
206         # detect multilinks that have been removed, and drop their table
207         old_has = {}
208         for name,prop in old_spec[1]:
209             old_has[name] = 1
210             if (force or not new_has(name)) and isinstance(prop, Multilink):
211                 # it's a multilink, and it's been removed - drop the old
212                 # table. First drop indexes.
213                 index_sqls = [ 'drop index %s_%s_l_idx'%(spec.classname, ml),
214                                'drop index %s_%s_n_idx'%(spec.classname, ml) ]
215                 for index_sql in index_sqls:
216                     if __debug__:
217                         print >>hyperdb.DEBUG, 'drop_index', (self, index_sql)
218                     try:
219                         self.cursor.execute(index_sql)
220                     except:
221                         # The database may not actually have any indexes.
222                         # assume the worst.
223                         pass
224                 sql = 'drop table %s_%s'%(spec.classname, prop)
225                 if __debug__:
226                     print >>hyperdb.DEBUG, 'update_class', (self, sql)
227                 self.cursor.execute(sql)
228                 continue
229         old_has = old_has.has_key
231         # now figure how we populate the new table
232         fetch = ['_activity', '_creation', '_creator']
233         properties = spec.getprops()
234         for propname,x in new_spec[1]:
235             prop = properties[propname]
236             if isinstance(prop, Multilink):
237                 if force or not old_has(propname):
238                     # we need to create the new table
239                     self.create_multilink_table(spec, propname)
240             elif old_has(propname):
241                 # we copy this col over from the old table
242                 fetch.append('_'+propname)
244         # select the data out of the old table
245         fetch.append('id')
246         fetch.append('__retired__')
247         fetchcols = ','.join(fetch)
248         cn = spec.classname
249         sql = 'select %s from _%s'%(fetchcols, cn)
250         if __debug__:
251             print >>hyperdb.DEBUG, 'update_class', (self, sql)
252         self.cursor.execute(sql)
253         olddata = self.cursor.fetchall()
255         # drop the old table indexes first
256         index_sqls = [ 'drop index _%s_id_idx'%cn,
257                        'drop index _%s_retired_idx'%cn ]
258         if old_spec[0]:
259             index_sqls.append('drop index _%s_%s_idx'%(cn, old_spec[0]))
260         for index_sql in index_sqls:
261             if __debug__:
262                 print >>hyperdb.DEBUG, 'drop_index', (self, index_sql)
263             try:
264                 self.cursor.execute(index_sql)
265             except:
266                 # The database may not actually have any indexes.
267                 # assume the worst.
268                 pass
270         # drop the old table
271         self.cursor.execute('drop table _%s'%cn)
273         # create the new table
274         self.create_class_table(spec)
276         if olddata:
277             # do the insert
278             args = ','.join([self.arg for x in fetch])
279             sql = 'insert into _%s (%s) values (%s)'%(cn, fetchcols, args)
280             if __debug__:
281                 print >>hyperdb.DEBUG, 'update_class', (self, sql, olddata[0])
282             for entry in olddata:
283                 self.cursor.execute(sql, tuple(entry))
285         return 1
287     def create_class_table(self, spec):
288         ''' create the class table for the given spec
289         '''
290         cols, mls = self.determine_columns(spec.properties.items())
292         # add on our special columns
293         cols.append('id')
294         cols.append('__retired__')
296         # create the base table
297         scols = ','.join(['%s varchar'%x for x in cols])
298         sql = 'create table _%s (%s)'%(spec.classname, scols)
299         if __debug__:
300             print >>hyperdb.DEBUG, 'create_class', (self, sql)
301         self.cursor.execute(sql)
303         # create id index
304         index_sql1 = 'create index _%s_id_idx on _%s(id)'%(
305                         spec.classname, spec.classname)
306         if __debug__:
307             print >>hyperdb.DEBUG, 'create_index', (self, index_sql1)
308         self.cursor.execute(index_sql1)
310         # create __retired__ index
311         index_sql2 = 'create index _%s_retired_idx on _%s(__retired__)'%(
312                         spec.classname, spec.classname)
313         if __debug__:
314             print >>hyperdb.DEBUG, 'create_index', (self, index_sql2)
315         self.cursor.execute(index_sql2)
317         # create index for key property
318         if spec.key:
319             if __debug__:
320                 print >>hyperdb.DEBUG, 'update_class setting keyprop %r'% \
321                     spec.key
322             index_sql3 = 'create index _%s_%s_idx on _%s(_%s)'%(
323                         spec.classname, spec.key,
324                         spec.classname, spec.key)
325             if __debug__:
326                 print >>hyperdb.DEBUG, 'create_index', (self, index_sql3)
327             self.cursor.execute(index_sql3)
329         return cols, mls
331     def create_journal_table(self, spec):
332         ''' create the journal table for a class given the spec and 
333             already-determined cols
334         '''
335         # journal table
336         cols = ','.join(['%s varchar'%x
337             for x in 'nodeid date tag action params'.split()])
338         sql = 'create table %s__journal (%s)'%(spec.classname, cols)
339         if __debug__:
340             print >>hyperdb.DEBUG, 'create_class', (self, sql)
341         self.cursor.execute(sql)
343         # index on nodeid
344         index_sql = 'create index %s_journ_idx on %s__journal(nodeid)'%(
345                         spec.classname, spec.classname)
346         if __debug__:
347             print >>hyperdb.DEBUG, 'create_index', (self, index_sql)
348         self.cursor.execute(index_sql)
350     def create_multilink_table(self, spec, ml):
351         ''' Create a multilink table for the "ml" property of the class
352             given by the spec
353         '''
354         # create the table
355         sql = 'create table %s_%s (linkid varchar, nodeid varchar)'%(
356             spec.classname, ml)
357         if __debug__:
358             print >>hyperdb.DEBUG, 'create_class', (self, sql)
359         self.cursor.execute(sql)
361         # create index on linkid
362         index_sql = 'create index %s_%s_l_idx on %s_%s(linkid)'%(
363                         spec.classname, ml, spec.classname, ml)
364         if __debug__:
365             print >>hyperdb.DEBUG, 'create_index', (self, index_sql)
366         self.cursor.execute(index_sql)
368         # create index on nodeid
369         index_sql = 'create index %s_%s_n_idx on %s_%s(nodeid)'%(
370                         spec.classname, ml, spec.classname, ml)
371         if __debug__:
372             print >>hyperdb.DEBUG, 'create_index', (self, index_sql)
373         self.cursor.execute(index_sql)
375     def create_class(self, spec):
376         ''' Create a database table according to the given spec.
377         '''
378         cols, mls = self.create_class_table(spec)
379         self.create_journal_table(spec)
381         # now create the multilink tables
382         for ml in mls:
383             self.create_multilink_table(spec, ml)
385         # ID counter
386         sql = 'insert into ids (name, num) values (%s,%s)'%(self.arg, self.arg)
387         vals = (spec.classname, 1)
388         if __debug__:
389             print >>hyperdb.DEBUG, 'create_class', (self, sql, vals)
390         self.cursor.execute(sql, vals)
392     def drop_class(self, cn, spec):
393         ''' Drop the given table from the database.
395             Drop the journal and multilink tables too.
396         '''
397         properties = spec[1]
398         # figure the multilinks
399         mls = []
400         for propanme, prop in properties:
401             if isinstance(prop, Multilink):
402                 mls.append(propname)
404         index_sqls = [ 'drop index _%s_id_idx'%cn,
405                        'drop index _%s_retired_idx'%cn,
406                        'drop index %s_journ_idx'%cn ]
407         if spec[0]:
408             index_sqls.append('drop index _%s_%s_idx'%(cn, spec[0]))
409         for index_sql in index_sqls:
410             if __debug__:
411                 print >>hyperdb.DEBUG, 'drop_index', (self, index_sql)
412             try:
413                 self.cursor.execute(index_sql)
414             except:
415                 # The database may not actually have any indexes.
416                 # assume the worst.
417                 pass
419         sql = 'drop table _%s'%cn
420         if __debug__:
421             print >>hyperdb.DEBUG, 'drop_class', (self, sql)
422         self.cursor.execute(sql)
424         sql = 'drop table %s__journal'%cn
425         if __debug__:
426             print >>hyperdb.DEBUG, 'drop_class', (self, sql)
427         self.cursor.execute(sql)
429         for ml in mls:
430             index_sqls = [ 
431                 'drop index %s_%s_n_idx'%(cn, ml),
432                 'drop index %s_%s_l_idx'%(cn, ml),
433             ]
434             for index_sql in index_sqls:
435                 if __debug__:
436                     print >>hyperdb.DEBUG, 'drop_index', (self, index_sql)
437                 try:
438                     self.cursor.execute(index_sql)
439                 except:
440                     # The database may not actually have any indexes.
441                     # assume the worst.
442                     pass
443             sql = 'drop table %s_%s'%(spec.classname, ml)
444             if __debug__:
445                 print >>hyperdb.DEBUG, 'drop_class', (self, sql)
446             self.cursor.execute(sql)
448     #
449     # Classes
450     #
451     def __getattr__(self, classname):
452         ''' A convenient way of calling self.getclass(classname).
453         '''
454         if self.classes.has_key(classname):
455             if __debug__:
456                 print >>hyperdb.DEBUG, '__getattr__', (self, classname)
457             return self.classes[classname]
458         raise AttributeError, classname
460     def addclass(self, cl):
461         ''' Add a Class to the hyperdatabase.
462         '''
463         if __debug__:
464             print >>hyperdb.DEBUG, 'addclass', (self, cl)
465         cn = cl.classname
466         if self.classes.has_key(cn):
467             raise ValueError, cn
468         self.classes[cn] = cl
470     def getclasses(self):
471         ''' Return a list of the names of all existing classes.
472         '''
473         if __debug__:
474             print >>hyperdb.DEBUG, 'getclasses', (self,)
475         l = self.classes.keys()
476         l.sort()
477         return l
479     def getclass(self, classname):
480         '''Get the Class object representing a particular class.
482         If 'classname' is not a valid class name, a KeyError is raised.
483         '''
484         if __debug__:
485             print >>hyperdb.DEBUG, 'getclass', (self, classname)
486         try:
487             return self.classes[classname]
488         except KeyError:
489             raise KeyError, 'There is no class called "%s"'%classname
491     def clear(self):
492         ''' Delete all database contents.
494             Note: I don't commit here, which is different behaviour to the
495             "nuke from orbit" behaviour in the *dbms.
496         '''
497         if __debug__:
498             print >>hyperdb.DEBUG, 'clear', (self,)
499         for cn in self.classes.keys():
500             sql = 'delete from _%s'%cn
501             if __debug__:
502                 print >>hyperdb.DEBUG, 'clear', (self, sql)
503             self.cursor.execute(sql)
505     #
506     # Node IDs
507     #
508     def newid(self, classname):
509         ''' Generate a new id for the given class
510         '''
511         # get the next ID
512         sql = 'select num from ids where name=%s'%self.arg
513         if __debug__:
514             print >>hyperdb.DEBUG, 'newid', (self, sql, classname)
515         self.cursor.execute(sql, (classname, ))
516         newid = self.cursor.fetchone()[0]
518         # update the counter
519         sql = 'update ids set num=%s where name=%s'%(self.arg, self.arg)
520         vals = (int(newid)+1, classname)
521         if __debug__:
522             print >>hyperdb.DEBUG, 'newid', (self, sql, vals)
523         self.cursor.execute(sql, vals)
525         # return as string
526         return str(newid)
528     def setid(self, classname, setid):
529         ''' Set the id counter: used during import of database
530         '''
531         sql = 'update ids set num=%s where name=%s'%(self.arg, self.arg)
532         vals = (setid, classname)
533         if __debug__:
534             print >>hyperdb.DEBUG, 'setid', (self, sql, vals)
535         self.cursor.execute(sql, vals)
537     #
538     # Nodes
539     #
540     def addnode(self, classname, nodeid, node):
541         ''' Add the specified node to its class's db.
542         '''
543         if __debug__:
544             print >>hyperdb.DEBUG, 'addnode', (self, classname, nodeid, node)
546         # determine the column definitions and multilink tables
547         cl = self.classes[classname]
548         cols, mls = self.determine_columns(cl.properties.items())
550         # we'll be supplied these props if we're doing an import
551         if not node.has_key('creator'):
552             # add in the "calculated" properties (dupe so we don't affect
553             # calling code's node assumptions)
554             node = node.copy()
555             node['creation'] = node['activity'] = date.Date()
556             node['creator'] = self.getuid()
558         # default the non-multilink columns
559         for col, prop in cl.properties.items():
560             if not node.has_key(col):
561                 if isinstance(prop, Multilink):
562                     node[col] = []
563                 else:
564                     node[col] = None
566         # clear this node out of the cache if it's in there
567         key = (classname, nodeid)
568         if self.cache.has_key(key):
569             del self.cache[key]
570             self.cache_lru.remove(key)
572         # make the node data safe for the DB
573         node = self.serialise(classname, node)
575         # make sure the ordering is correct for column name -> column value
576         vals = tuple([node[col[1:]] for col in cols]) + (nodeid, 0)
577         s = ','.join([self.arg for x in cols]) + ',%s,%s'%(self.arg, self.arg)
578         cols = ','.join(cols) + ',id,__retired__'
580         # perform the inserts
581         sql = 'insert into _%s (%s) values (%s)'%(classname, cols, s)
582         if __debug__:
583             print >>hyperdb.DEBUG, 'addnode', (self, sql, vals)
584         self.cursor.execute(sql, vals)
586         # insert the multilink rows
587         for col in mls:
588             t = '%s_%s'%(classname, col)
589             for entry in node[col]:
590                 sql = 'insert into %s (linkid, nodeid) values (%s,%s)'%(t,
591                     self.arg, self.arg)
592                 self.sql(sql, (entry, nodeid))
594         # make sure we do the commit-time extra stuff for this node
595         self.transactions.append((self.doSaveNode, (classname, nodeid, node)))
597     def setnode(self, classname, nodeid, values, multilink_changes):
598         ''' Change the specified node.
599         '''
600         if __debug__:
601             print >>hyperdb.DEBUG, 'setnode', (self, classname, nodeid, values)
603         # clear this node out of the cache if it's in there
604         key = (classname, nodeid)
605         if self.cache.has_key(key):
606             del self.cache[key]
607             self.cache_lru.remove(key)
609         # add the special props
610         values = values.copy()
611         values['activity'] = date.Date()
613         # make db-friendly
614         values = self.serialise(classname, values)
616         cl = self.classes[classname]
617         cols = []
618         mls = []
619         # add the multilinks separately
620         props = cl.getprops()
621         for col in values.keys():
622             prop = props[col]
623             if isinstance(prop, Multilink):
624                 mls.append(col)
625             else:
626                 cols.append('_'+col)
627         cols.sort()
629         # if there's any updates to regular columns, do them
630         if cols:
631             # make sure the ordering is correct for column name -> column value
632             sqlvals = tuple([values[col[1:]] for col in cols]) + (nodeid,)
633             s = ','.join(['%s=%s'%(x, self.arg) for x in cols])
634             cols = ','.join(cols)
636             # perform the update
637             sql = 'update _%s set %s where id=%s'%(classname, s, self.arg)
638             if __debug__:
639                 print >>hyperdb.DEBUG, 'setnode', (self, sql, sqlvals)
640             self.cursor.execute(sql, sqlvals)
642         # now the fun bit, updating the multilinks ;)
643         for col, (add, remove) in multilink_changes.items():
644             tn = '%s_%s'%(classname, col)
645             if add:
646                 sql = 'insert into %s (nodeid, linkid) values (%s,%s)'%(tn,
647                     self.arg, self.arg)
648                 for addid in add:
649                     self.sql(sql, (nodeid, addid))
650             if remove:
651                 sql = 'delete from %s where nodeid=%s and linkid=%s'%(tn,
652                     self.arg, self.arg)
653                 for removeid in remove:
654                     self.sql(sql, (nodeid, removeid))
656         # make sure we do the commit-time extra stuff for this node
657         self.transactions.append((self.doSaveNode, (classname, nodeid, values)))
659     def getnode(self, classname, nodeid):
660         ''' Get a node from the database.
661         '''
662         if __debug__:
663             print >>hyperdb.DEBUG, 'getnode', (self, classname, nodeid)
665         # see if we have this node cached
666         key = (classname, nodeid)
667         if self.cache.has_key(key):
668             # push us back to the top of the LRU
669             self.cache_lru.remove(key)
670             self.cache_lru.insert(0, key)
671             # return the cached information
672             return self.cache[key]
674         # figure the columns we're fetching
675         cl = self.classes[classname]
676         cols, mls = self.determine_columns(cl.properties.items())
677         scols = ','.join(cols)
679         # perform the basic property fetch
680         sql = 'select %s from _%s where id=%s'%(scols, classname, self.arg)
681         self.sql(sql, (nodeid,))
683         values = self.sql_fetchone()
684         if values is None:
685             raise IndexError, 'no such %s node %s'%(classname, nodeid)
687         # make up the node
688         node = {}
689         for col in range(len(cols)):
690             node[cols[col][1:]] = values[col]
692         # now the multilinks
693         for col in mls:
694             # get the link ids
695             sql = 'select linkid from %s_%s where nodeid=%s'%(classname, col,
696                 self.arg)
697             self.cursor.execute(sql, (nodeid,))
698             # extract the first column from the result
699             node[col] = [x[0] for x in self.cursor.fetchall()]
701         # un-dbificate the node data
702         node = self.unserialise(classname, node)
704         # save off in the cache
705         key = (classname, nodeid)
706         self.cache[key] = node
707         # update the LRU
708         self.cache_lru.insert(0, key)
709         if len(self.cache_lru) > ROW_CACHE_SIZE:
710             del self.cache[self.cache_lru.pop()]
712         return node
714     def destroynode(self, classname, nodeid):
715         '''Remove a node from the database. Called exclusively by the
716            destroy() method on Class.
717         '''
718         if __debug__:
719             print >>hyperdb.DEBUG, 'destroynode', (self, classname, nodeid)
721         # make sure the node exists
722         if not self.hasnode(classname, nodeid):
723             raise IndexError, '%s has no node %s'%(classname, nodeid)
725         # see if we have this node cached
726         if self.cache.has_key((classname, nodeid)):
727             del self.cache[(classname, nodeid)]
729         # see if there's any obvious commit actions that we should get rid of
730         for entry in self.transactions[:]:
731             if entry[1][:2] == (classname, nodeid):
732                 self.transactions.remove(entry)
734         # now do the SQL
735         sql = 'delete from _%s where id=%s'%(classname, self.arg)
736         self.sql(sql, (nodeid,))
738         # remove from multilnks
739         cl = self.getclass(classname)
740         x, mls = self.determine_columns(cl.properties.items())
741         for col in mls:
742             # get the link ids
743             sql = 'delete from %s_%s where nodeid=%s'%(classname, col, self.arg)
744             self.cursor.execute(sql, (nodeid,))
746         # remove journal entries
747         sql = 'delete from %s__journal where nodeid=%s'%(classname, self.arg)
748         self.sql(sql, (nodeid,))
750     def serialise(self, classname, node):
751         '''Copy the node contents, converting non-marshallable data into
752            marshallable data.
753         '''
754         if __debug__:
755             print >>hyperdb.DEBUG, 'serialise', classname, node
756         properties = self.getclass(classname).getprops()
757         d = {}
758         for k, v in node.items():
759             # if the property doesn't exist, or is the "retired" flag then
760             # it won't be in the properties dict
761             if not properties.has_key(k):
762                 d[k] = v
763                 continue
765             # get the property spec
766             prop = properties[k]
768             if isinstance(prop, Password) and v is not None:
769                 d[k] = str(v)
770             elif isinstance(prop, Date) and v is not None:
771                 d[k] = v.serialise()
772             elif isinstance(prop, Interval) and v is not None:
773                 d[k] = v.serialise()
774             else:
775                 d[k] = v
776         return d
778     def unserialise(self, classname, node):
779         '''Decode the marshalled node data
780         '''
781         if __debug__:
782             print >>hyperdb.DEBUG, 'unserialise', classname, node
783         properties = self.getclass(classname).getprops()
784         d = {}
785         for k, v in node.items():
786             # if the property doesn't exist, or is the "retired" flag then
787             # it won't be in the properties dict
788             if not properties.has_key(k):
789                 d[k] = v
790                 continue
792             # get the property spec
793             prop = properties[k]
795             if isinstance(prop, Date) and v is not None:
796                 d[k] = date.Date(v)
797             elif isinstance(prop, Interval) and v is not None:
798                 d[k] = date.Interval(v)
799             elif isinstance(prop, Password) and v is not None:
800                 p = password.Password()
801                 p.unpack(v)
802                 d[k] = p
803             elif (isinstance(prop, Boolean) or isinstance(prop, Number)) and v is not None:
804                 d[k]=float(v)
805             else:
806                 d[k] = v
807         return d
809     def hasnode(self, classname, nodeid):
810         ''' Determine if the database has a given node.
811         '''
812         sql = 'select count(*) from _%s where id=%s'%(classname, self.arg)
813         if __debug__:
814             print >>hyperdb.DEBUG, 'hasnode', (self, sql, nodeid)
815         self.cursor.execute(sql, (nodeid,))
816         return int(self.cursor.fetchone()[0])
818     def countnodes(self, classname):
819         ''' Count the number of nodes that exist for a particular Class.
820         '''
821         sql = 'select count(*) from _%s'%classname
822         if __debug__:
823             print >>hyperdb.DEBUG, 'countnodes', (self, sql)
824         self.cursor.execute(sql)
825         return self.cursor.fetchone()[0]
827     def addjournal(self, classname, nodeid, action, params, creator=None,
828             creation=None):
829         ''' Journal the Action
830         'action' may be:
832             'create' or 'set' -- 'params' is a dictionary of property values
833             'link' or 'unlink' -- 'params' is (classname, nodeid, propname)
834             'retire' -- 'params' is None
835         '''
836         # serialise the parameters now if necessary
837         if isinstance(params, type({})):
838             if action in ('set', 'create'):
839                 params = self.serialise(classname, params)
841         # handle supply of the special journalling parameters (usually
842         # supplied on importing an existing database)
843         if creator:
844             journaltag = creator
845         else:
846             journaltag = self.getuid()
847         if creation:
848             journaldate = creation.serialise()
849         else:
850             journaldate = date.Date().serialise()
852         # create the journal entry
853         cols = ','.join('nodeid date tag action params'.split())
855         if __debug__:
856             print >>hyperdb.DEBUG, 'addjournal', (nodeid, journaldate,
857                 journaltag, action, params)
859         self.save_journal(classname, cols, nodeid, journaldate,
860             journaltag, action, params)
862     def save_journal(self, classname, cols, nodeid, journaldate,
863             journaltag, action, params):
864         ''' Save the journal entry to the database
865         '''
866         raise NotImplemented
868     def getjournal(self, classname, nodeid):
869         ''' get the journal for id
870         '''
871         # make sure the node exists
872         if not self.hasnode(classname, nodeid):
873             raise IndexError, '%s has no node %s'%(classname, nodeid)
875         cols = ','.join('nodeid date tag action params'.split())
876         return self.load_journal(classname, cols, nodeid)
878     def load_journal(self, classname, cols, nodeid):
879         ''' Load the journal from the database
880         '''
881         raise NotImplemented
883     def pack(self, pack_before):
884         ''' Delete all journal entries except "create" before 'pack_before'.
885         '''
886         # get a 'yyyymmddhhmmss' version of the date
887         date_stamp = pack_before.serialise()
889         # do the delete
890         for classname in self.classes.keys():
891             sql = "delete from %s__journal where date<%s and "\
892                 "action<>'create'"%(classname, self.arg)
893             if __debug__:
894                 print >>hyperdb.DEBUG, 'pack', (self, sql, date_stamp)
895             self.cursor.execute(sql, (date_stamp,))
897     def sql_commit(self):
898         ''' Actually commit to the database.
899         '''
900         self.conn.commit()
902     def commit(self):
903         ''' Commit the current transactions.
905         Save all data changed since the database was opened or since the
906         last commit() or rollback().
907         '''
908         if __debug__:
909             print >>hyperdb.DEBUG, 'commit', (self,)
911         # commit the database
912         self.sql_commit()
914         # now, do all the other transaction stuff
915         reindex = {}
916         for method, args in self.transactions:
917             reindex[method(*args)] = 1
919         # reindex the nodes that request it
920         for classname, nodeid in filter(None, reindex.keys()):
921             print >>hyperdb.DEBUG, 'commit.reindex', (classname, nodeid)
922             self.getclass(classname).index(nodeid)
924         # save the indexer state
925         self.indexer.save_index()
927         # clear out the transactions
928         self.transactions = []
930     def rollback(self):
931         ''' Reverse all actions from the current transaction.
933         Undo all the changes made since the database was opened or the last
934         commit() or rollback() was performed.
935         '''
936         if __debug__:
937             print >>hyperdb.DEBUG, 'rollback', (self,)
939         # roll back
940         self.conn.rollback()
942         # roll back "other" transaction stuff
943         for method, args in self.transactions:
944             # delete temporary files
945             if method == self.doStoreFile:
946                 self.rollbackStoreFile(*args)
947         self.transactions = []
949         # clear the cache
950         self.clearCache()
952     def doSaveNode(self, classname, nodeid, node):
953         ''' dummy that just generates a reindex event
954         '''
955         # return the classname, nodeid so we reindex this content
956         return (classname, nodeid)
958     def close(self):
959         ''' Close off the connection.
960         '''
961         self.conn.close()
962         if self.lockfile is not None:
963             locking.release_lock(self.lockfile)
964         if self.lockfile is not None:
965             self.lockfile.close()
966             self.lockfile = None
969 # The base Class class
971 class Class(hyperdb.Class):
972     ''' The handle to a particular class of nodes in a hyperdatabase.
973         
974         All methods except __repr__ and getnode must be implemented by a
975         concrete backend Class.
976     '''
978     def __init__(self, db, classname, **properties):
979         '''Create a new class with a given name and property specification.
981         'classname' must not collide with the name of an existing class,
982         or a ValueError is raised.  The keyword arguments in 'properties'
983         must map names to property objects, or a TypeError is raised.
984         '''
985         if (properties.has_key('creation') or properties.has_key('activity')
986                 or properties.has_key('creator')):
987             raise ValueError, '"creation", "activity" and "creator" are '\
988                 'reserved'
990         self.classname = classname
991         self.properties = properties
992         self.db = weakref.proxy(db)       # use a weak ref to avoid circularity
993         self.key = ''
995         # should we journal changes (default yes)
996         self.do_journal = 1
998         # do the db-related init stuff
999         db.addclass(self)
1001         self.auditors = {'create': [], 'set': [], 'retire': [], 'restore': []}
1002         self.reactors = {'create': [], 'set': [], 'retire': [], 'restore': []}
1004     def schema(self):
1005         ''' A dumpable version of the schema that we can store in the
1006             database
1007         '''
1008         return (self.key, [(x, repr(y)) for x,y in self.properties.items()])
1010     def enableJournalling(self):
1011         '''Turn journalling on for this class
1012         '''
1013         self.do_journal = 1
1015     def disableJournalling(self):
1016         '''Turn journalling off for this class
1017         '''
1018         self.do_journal = 0
1020     # Editing nodes:
1021     def create(self, **propvalues):
1022         ''' Create a new node of this class and return its id.
1024         The keyword arguments in 'propvalues' map property names to values.
1026         The values of arguments must be acceptable for the types of their
1027         corresponding properties or a TypeError is raised.
1028         
1029         If this class has a key property, it must be present and its value
1030         must not collide with other key strings or a ValueError is raised.
1031         
1032         Any other properties on this class that are missing from the
1033         'propvalues' dictionary are set to None.
1034         
1035         If an id in a link or multilink property does not refer to a valid
1036         node, an IndexError is raised.
1037         '''
1038         self.fireAuditors('create', None, propvalues)
1039         newid = self.create_inner(**propvalues)
1040         self.fireReactors('create', newid, None)
1041         return newid
1042     
1043     def create_inner(self, **propvalues):
1044         ''' Called by create, in-between the audit and react calls.
1045         '''
1046         if propvalues.has_key('id'):
1047             raise KeyError, '"id" is reserved'
1049         if self.db.journaltag is None:
1050             raise DatabaseError, 'Database open read-only'
1052         if propvalues.has_key('creation') or propvalues.has_key('activity'):
1053             raise KeyError, '"creation" and "activity" are reserved'
1055         # new node's id
1056         newid = self.db.newid(self.classname)
1058         # validate propvalues
1059         num_re = re.compile('^\d+$')
1060         for key, value in propvalues.items():
1061             if key == self.key:
1062                 try:
1063                     self.lookup(value)
1064                 except KeyError:
1065                     pass
1066                 else:
1067                     raise ValueError, 'node with key "%s" exists'%value
1069             # try to handle this property
1070             try:
1071                 prop = self.properties[key]
1072             except KeyError:
1073                 raise KeyError, '"%s" has no property "%s"'%(self.classname,
1074                     key)
1076             if value is not None and isinstance(prop, Link):
1077                 if type(value) != type(''):
1078                     raise ValueError, 'link value must be String'
1079                 link_class = self.properties[key].classname
1080                 # if it isn't a number, it's a key
1081                 if not num_re.match(value):
1082                     try:
1083                         value = self.db.classes[link_class].lookup(value)
1084                     except (TypeError, KeyError):
1085                         raise IndexError, 'new property "%s": %s not a %s'%(
1086                             key, value, link_class)
1087                 elif not self.db.getclass(link_class).hasnode(value):
1088                     raise IndexError, '%s has no node %s'%(link_class, value)
1090                 # save off the value
1091                 propvalues[key] = value
1093                 # register the link with the newly linked node
1094                 if self.do_journal and self.properties[key].do_journal:
1095                     self.db.addjournal(link_class, value, 'link',
1096                         (self.classname, newid, key))
1098             elif isinstance(prop, Multilink):
1099                 if type(value) != type([]):
1100                     raise TypeError, 'new property "%s" not a list of ids'%key
1102                 # clean up and validate the list of links
1103                 link_class = self.properties[key].classname
1104                 l = []
1105                 for entry in value:
1106                     if type(entry) != type(''):
1107                         raise ValueError, '"%s" multilink value (%r) '\
1108                             'must contain Strings'%(key, value)
1109                     # if it isn't a number, it's a key
1110                     if not num_re.match(entry):
1111                         try:
1112                             entry = self.db.classes[link_class].lookup(entry)
1113                         except (TypeError, KeyError):
1114                             raise IndexError, 'new property "%s": %s not a %s'%(
1115                                 key, entry, self.properties[key].classname)
1116                     l.append(entry)
1117                 value = l
1118                 propvalues[key] = value
1120                 # handle additions
1121                 for nodeid in value:
1122                     if not self.db.getclass(link_class).hasnode(nodeid):
1123                         raise IndexError, '%s has no node %s'%(link_class,
1124                             nodeid)
1125                     # register the link with the newly linked node
1126                     if self.do_journal and self.properties[key].do_journal:
1127                         self.db.addjournal(link_class, nodeid, 'link',
1128                             (self.classname, newid, key))
1130             elif isinstance(prop, String):
1131                 if type(value) != type('') and type(value) != type(u''):
1132                     raise TypeError, 'new property "%s" not a string'%key
1134             elif isinstance(prop, Password):
1135                 if not isinstance(value, password.Password):
1136                     raise TypeError, 'new property "%s" not a Password'%key
1138             elif isinstance(prop, Date):
1139                 if value is not None and not isinstance(value, date.Date):
1140                     raise TypeError, 'new property "%s" not a Date'%key
1142             elif isinstance(prop, Interval):
1143                 if value is not None and not isinstance(value, date.Interval):
1144                     raise TypeError, 'new property "%s" not an Interval'%key
1146             elif value is not None and isinstance(prop, Number):
1147                 try:
1148                     float(value)
1149                 except ValueError:
1150                     raise TypeError, 'new property "%s" not numeric'%key
1152             elif value is not None and isinstance(prop, Boolean):
1153                 try:
1154                     int(value)
1155                 except ValueError:
1156                     raise TypeError, 'new property "%s" not boolean'%key
1158         # make sure there's data where there needs to be
1159         for key, prop in self.properties.items():
1160             if propvalues.has_key(key):
1161                 continue
1162             if key == self.key:
1163                 raise ValueError, 'key property "%s" is required'%key
1164             if isinstance(prop, Multilink):
1165                 propvalues[key] = []
1166             else:
1167                 propvalues[key] = None
1169         # done
1170         self.db.addnode(self.classname, newid, propvalues)
1171         if self.do_journal:
1172             self.db.addjournal(self.classname, newid, 'create', {})
1174         return newid
1176     def export_list(self, propnames, nodeid):
1177         ''' Export a node - generate a list of CSV-able data in the order
1178             specified by propnames for the given node.
1179         '''
1180         properties = self.getprops()
1181         l = []
1182         for prop in propnames:
1183             proptype = properties[prop]
1184             value = self.get(nodeid, prop)
1185             # "marshal" data where needed
1186             if value is None:
1187                 pass
1188             elif isinstance(proptype, hyperdb.Date):
1189                 value = value.get_tuple()
1190             elif isinstance(proptype, hyperdb.Interval):
1191                 value = value.get_tuple()
1192             elif isinstance(proptype, hyperdb.Password):
1193                 value = str(value)
1194             l.append(repr(value))
1195         l.append(self.is_retired(nodeid))
1196         return l
1198     def import_list(self, propnames, proplist):
1199         ''' Import a node - all information including "id" is present and
1200             should not be sanity checked. Triggers are not triggered. The
1201             journal should be initialised using the "creator" and "created"
1202             information.
1204             Return the nodeid of the node imported.
1205         '''
1206         if self.db.journaltag is None:
1207             raise DatabaseError, 'Database open read-only'
1208         properties = self.getprops()
1210         # make the new node's property map
1211         d = {}
1212         retire = 0
1213         newid = None
1214         for i in range(len(propnames)):
1215             # Use eval to reverse the repr() used to output the CSV
1216             value = eval(proplist[i])
1218             # Figure the property for this column
1219             propname = propnames[i]
1221             # "unmarshal" where necessary
1222             if propname == 'id':
1223                 newid = value
1224                 continue
1225             elif propname == 'is retired':
1226                 # is the item retired?
1227                 if int(value):
1228                     retire = 1
1229                 continue
1230             elif value is None:
1231                 d[propname] = None
1232                 continue
1234             prop = properties[propname]
1235             if value is None:
1236                 # don't set Nones
1237                 continue
1238             elif isinstance(prop, hyperdb.Date):
1239                 value = date.Date(value)
1240             elif isinstance(prop, hyperdb.Interval):
1241                 value = date.Interval(value)
1242             elif isinstance(prop, hyperdb.Password):
1243                 pwd = password.Password()
1244                 pwd.unpack(value)
1245                 value = pwd
1246             d[propname] = value
1248         # get a new id if necessary
1249         if newid is None:
1250             newid = self.db.newid(self.classname)
1252         # retire?
1253         if retire:
1254             # use the arg for __retired__ to cope with any odd database type
1255             # conversion (hello, sqlite)
1256             sql = 'update _%s set __retired__=%s where id=%s'%(self.classname,
1257                 self.db.arg, self.db.arg)
1258             if __debug__:
1259                 print >>hyperdb.DEBUG, 'retire', (self, sql, newid)
1260             self.db.cursor.execute(sql, (1, newid))
1262         # add the node and journal
1263         self.db.addnode(self.classname, newid, d)
1265         # extract the extraneous journalling gumpf and nuke it
1266         if d.has_key('creator'):
1267             creator = d['creator']
1268             del d['creator']
1269         else:
1270             creator = None
1271         if d.has_key('creation'):
1272             creation = d['creation']
1273             del d['creation']
1274         else:
1275             creation = None
1276         if d.has_key('activity'):
1277             del d['activity']
1278         self.db.addjournal(self.classname, newid, 'create', {}, creator,
1279             creation)
1280         return newid
1282     _marker = []
1283     def get(self, nodeid, propname, default=_marker, cache=1):
1284         '''Get the value of a property on an existing node of this class.
1286         'nodeid' must be the id of an existing node of this class or an
1287         IndexError is raised.  'propname' must be the name of a property
1288         of this class or a KeyError is raised.
1290         'cache' exists for backwards compatibility, and is not used.
1291         '''
1292         if propname == 'id':
1293             return nodeid
1295         # get the node's dict
1296         d = self.db.getnode(self.classname, nodeid)
1298         if propname == 'creation':
1299             if d.has_key('creation'):
1300                 return d['creation']
1301             else:
1302                 return date.Date()
1303         if propname == 'activity':
1304             if d.has_key('activity'):
1305                 return d['activity']
1306             else:
1307                 return date.Date()
1308         if propname == 'creator':
1309             if d.has_key('creator'):
1310                 return d['creator']
1311             else:
1312                 return self.db.getuid()
1314         # get the property (raises KeyErorr if invalid)
1315         prop = self.properties[propname]
1317         if not d.has_key(propname):
1318             if default is self._marker:
1319                 if isinstance(prop, Multilink):
1320                     return []
1321                 else:
1322                     return None
1323             else:
1324                 return default
1326         # don't pass our list to other code
1327         if isinstance(prop, Multilink):
1328             return d[propname][:]
1330         return d[propname]
1332     def getnode(self, nodeid, cache=1):
1333         ''' Return a convenience wrapper for the node.
1335         'nodeid' must be the id of an existing node of this class or an
1336         IndexError is raised.
1338         'cache' exists for backwards compatibility, and is not used.
1339         '''
1340         return Node(self, nodeid)
1342     def set(self, nodeid, **propvalues):
1343         '''Modify a property on an existing node of this class.
1344         
1345         'nodeid' must be the id of an existing node of this class or an
1346         IndexError is raised.
1348         Each key in 'propvalues' must be the name of a property of this
1349         class or a KeyError is raised.
1351         All values in 'propvalues' must be acceptable types for their
1352         corresponding properties or a TypeError is raised.
1354         If the value of the key property is set, it must not collide with
1355         other key strings or a ValueError is raised.
1357         If the value of a Link or Multilink property contains an invalid
1358         node id, a ValueError is raised.
1359         '''
1360         if not propvalues:
1361             return propvalues
1363         if propvalues.has_key('creation') or propvalues.has_key('activity'):
1364             raise KeyError, '"creation" and "activity" are reserved'
1366         if propvalues.has_key('id'):
1367             raise KeyError, '"id" is reserved'
1369         if self.db.journaltag is None:
1370             raise DatabaseError, 'Database open read-only'
1372         self.fireAuditors('set', nodeid, propvalues)
1373         # Take a copy of the node dict so that the subsequent set
1374         # operation doesn't modify the oldvalues structure.
1375         # XXX used to try the cache here first
1376         oldvalues = copy.deepcopy(self.db.getnode(self.classname, nodeid))
1378         node = self.db.getnode(self.classname, nodeid)
1379         if self.is_retired(nodeid):
1380             raise IndexError, 'Requested item is retired'
1381         num_re = re.compile('^\d+$')
1383         # if the journal value is to be different, store it in here
1384         journalvalues = {}
1386         # remember the add/remove stuff for multilinks, making it easier
1387         # for the Database layer to do its stuff
1388         multilink_changes = {}
1390         for propname, value in propvalues.items():
1391             # check to make sure we're not duplicating an existing key
1392             if propname == self.key and node[propname] != value:
1393                 try:
1394                     self.lookup(value)
1395                 except KeyError:
1396                     pass
1397                 else:
1398                     raise ValueError, 'node with key "%s" exists'%value
1400             # this will raise the KeyError if the property isn't valid
1401             # ... we don't use getprops() here because we only care about
1402             # the writeable properties.
1403             try:
1404                 prop = self.properties[propname]
1405             except KeyError:
1406                 raise KeyError, '"%s" has no property named "%s"'%(
1407                     self.classname, propname)
1409             # if the value's the same as the existing value, no sense in
1410             # doing anything
1411             current = node.get(propname, None)
1412             if value == current:
1413                 del propvalues[propname]
1414                 continue
1415             journalvalues[propname] = current
1417             # do stuff based on the prop type
1418             if isinstance(prop, Link):
1419                 link_class = prop.classname
1420                 # if it isn't a number, it's a key
1421                 if value is not None and not isinstance(value, type('')):
1422                     raise ValueError, 'property "%s" link value be a string'%(
1423                         propname)
1424                 if isinstance(value, type('')) and not num_re.match(value):
1425                     try:
1426                         value = self.db.classes[link_class].lookup(value)
1427                     except (TypeError, KeyError):
1428                         raise IndexError, 'new property "%s": %s not a %s'%(
1429                             propname, value, prop.classname)
1431                 if (value is not None and
1432                         not self.db.getclass(link_class).hasnode(value)):
1433                     raise IndexError, '%s has no node %s'%(link_class, value)
1435                 if self.do_journal and prop.do_journal:
1436                     # register the unlink with the old linked node
1437                     if node[propname] is not None:
1438                         self.db.addjournal(link_class, node[propname], 'unlink',
1439                             (self.classname, nodeid, propname))
1441                     # register the link with the newly linked node
1442                     if value is not None:
1443                         self.db.addjournal(link_class, value, 'link',
1444                             (self.classname, nodeid, propname))
1446             elif isinstance(prop, Multilink):
1447                 if type(value) != type([]):
1448                     raise TypeError, 'new property "%s" not a list of'\
1449                         ' ids'%propname
1450                 link_class = self.properties[propname].classname
1451                 l = []
1452                 for entry in value:
1453                     # if it isn't a number, it's a key
1454                     if type(entry) != type(''):
1455                         raise ValueError, 'new property "%s" link value ' \
1456                             'must be a string'%propname
1457                     if not num_re.match(entry):
1458                         try:
1459                             entry = self.db.classes[link_class].lookup(entry)
1460                         except (TypeError, KeyError):
1461                             raise IndexError, 'new property "%s": %s not a %s'%(
1462                                 propname, entry,
1463                                 self.properties[propname].classname)
1464                     l.append(entry)
1465                 value = l
1466                 propvalues[propname] = value
1468                 # figure the journal entry for this property
1469                 add = []
1470                 remove = []
1472                 # handle removals
1473                 if node.has_key(propname):
1474                     l = node[propname]
1475                 else:
1476                     l = []
1477                 for id in l[:]:
1478                     if id in value:
1479                         continue
1480                     # register the unlink with the old linked node
1481                     if self.do_journal and self.properties[propname].do_journal:
1482                         self.db.addjournal(link_class, id, 'unlink',
1483                             (self.classname, nodeid, propname))
1484                     l.remove(id)
1485                     remove.append(id)
1487                 # handle additions
1488                 for id in value:
1489                     if not self.db.getclass(link_class).hasnode(id):
1490                         raise IndexError, '%s has no node %s'%(link_class, id)
1491                     if id in l:
1492                         continue
1493                     # register the link with the newly linked node
1494                     if self.do_journal and self.properties[propname].do_journal:
1495                         self.db.addjournal(link_class, id, 'link',
1496                             (self.classname, nodeid, propname))
1497                     l.append(id)
1498                     add.append(id)
1500                 # figure the journal entry
1501                 l = []
1502                 if add:
1503                     l.append(('+', add))
1504                 if remove:
1505                     l.append(('-', remove))
1506                 multilink_changes[propname] = (add, remove)
1507                 if l:
1508                     journalvalues[propname] = tuple(l)
1510             elif isinstance(prop, String):
1511                 if value is not None and type(value) != type('') and type(value) != type(u''):
1512                     raise TypeError, 'new property "%s" not a string'%propname
1514             elif isinstance(prop, Password):
1515                 if not isinstance(value, password.Password):
1516                     raise TypeError, 'new property "%s" not a Password'%propname
1517                 propvalues[propname] = value
1519             elif value is not None and isinstance(prop, Date):
1520                 if not isinstance(value, date.Date):
1521                     raise TypeError, 'new property "%s" not a Date'% propname
1522                 propvalues[propname] = value
1524             elif value is not None and isinstance(prop, Interval):
1525                 if not isinstance(value, date.Interval):
1526                     raise TypeError, 'new property "%s" not an '\
1527                         'Interval'%propname
1528                 propvalues[propname] = value
1530             elif value is not None and isinstance(prop, Number):
1531                 try:
1532                     float(value)
1533                 except ValueError:
1534                     raise TypeError, 'new property "%s" not numeric'%propname
1536             elif value is not None and isinstance(prop, Boolean):
1537                 try:
1538                     int(value)
1539                 except ValueError:
1540                     raise TypeError, 'new property "%s" not boolean'%propname
1542         # nothing to do?
1543         if not propvalues:
1544             return propvalues
1546         # do the set, and journal it
1547         self.db.setnode(self.classname, nodeid, propvalues, multilink_changes)
1549         if self.do_journal:
1550             self.db.addjournal(self.classname, nodeid, 'set', journalvalues)
1552         self.fireReactors('set', nodeid, oldvalues)
1554         return propvalues        
1556     def retire(self, nodeid):
1557         '''Retire a node.
1558         
1559         The properties on the node remain available from the get() method,
1560         and the node's id is never reused.
1561         
1562         Retired nodes are not returned by the find(), list(), or lookup()
1563         methods, and other nodes may reuse the values of their key properties.
1564         '''
1565         if self.db.journaltag is None:
1566             raise DatabaseError, 'Database open read-only'
1568         self.fireAuditors('retire', nodeid, None)
1570         # use the arg for __retired__ to cope with any odd database type
1571         # conversion (hello, sqlite)
1572         sql = 'update _%s set __retired__=%s where id=%s'%(self.classname,
1573             self.db.arg, self.db.arg)
1574         if __debug__:
1575             print >>hyperdb.DEBUG, 'retire', (self, sql, nodeid)
1576         self.db.cursor.execute(sql, (1, nodeid))
1577         if self.do_journal:
1578             self.db.addjournal(self.classname, nodeid, 'retired', None)
1580         self.fireReactors('retire', nodeid, None)
1582     def restore(self, nodeid):
1583         '''Restore a retired node.
1585         Make node available for all operations like it was before retirement.
1586         '''
1587         if self.db.journaltag is None:
1588             raise DatabaseError, 'Database open read-only'
1590         node = self.db.getnode(self.classname, nodeid)
1591         # check if key property was overrided
1592         key = self.getkey()
1593         try:
1594             id = self.lookup(node[key])
1595         except KeyError:
1596             pass
1597         else:
1598             raise KeyError, "Key property (%s) of retired node clashes with \
1599                 existing one (%s)" % (key, node[key])
1601         self.fireAuditors('restore', nodeid, None)
1602         # use the arg for __retired__ to cope with any odd database type
1603         # conversion (hello, sqlite)
1604         sql = 'update _%s set __retired__=%s where id=%s'%(self.classname,
1605             self.db.arg, self.db.arg)
1606         if __debug__:
1607             print >>hyperdb.DEBUG, 'restore', (self, sql, nodeid)
1608         self.db.cursor.execute(sql, (0, nodeid))
1609         if self.do_journal:
1610             self.db.addjournal(self.classname, nodeid, 'restored', None)
1612         self.fireReactors('restore', nodeid, None)
1613         
1614     def is_retired(self, nodeid):
1615         '''Return true if the node is rerired
1616         '''
1617         sql = 'select __retired__ from _%s where id=%s'%(self.classname,
1618             self.db.arg)
1619         if __debug__:
1620             print >>hyperdb.DEBUG, 'is_retired', (self, sql, nodeid)
1621         self.db.cursor.execute(sql, (nodeid,))
1622         return int(self.db.sql_fetchone()[0])
1624     def destroy(self, nodeid):
1625         '''Destroy a node.
1626         
1627         WARNING: this method should never be used except in extremely rare
1628                  situations where there could never be links to the node being
1629                  deleted
1630         WARNING: use retire() instead
1631         WARNING: the properties of this node will not be available ever again
1632         WARNING: really, use retire() instead
1634         Well, I think that's enough warnings. This method exists mostly to
1635         support the session storage of the cgi interface.
1637         The node is completely removed from the hyperdb, including all journal
1638         entries. It will no longer be available, and will generally break code
1639         if there are any references to the node.
1640         '''
1641         if self.db.journaltag is None:
1642             raise DatabaseError, 'Database open read-only'
1643         self.db.destroynode(self.classname, nodeid)
1645     def history(self, nodeid):
1646         '''Retrieve the journal of edits on a particular node.
1648         'nodeid' must be the id of an existing node of this class or an
1649         IndexError is raised.
1651         The returned list contains tuples of the form
1653             (nodeid, date, tag, action, params)
1655         'date' is a Timestamp object specifying the time of the change and
1656         'tag' is the journaltag specified when the database was opened.
1657         '''
1658         if not self.do_journal:
1659             raise ValueError, 'Journalling is disabled for this class'
1660         return self.db.getjournal(self.classname, nodeid)
1662     # Locating nodes:
1663     def hasnode(self, nodeid):
1664         '''Determine if the given nodeid actually exists
1665         '''
1666         return self.db.hasnode(self.classname, nodeid)
1668     def setkey(self, propname):
1669         '''Select a String property of this class to be the key property.
1671         'propname' must be the name of a String property of this class or
1672         None, or a TypeError is raised.  The values of the key property on
1673         all existing nodes must be unique or a ValueError is raised.
1674         '''
1675         # XXX create an index on the key prop column. We should also 
1676         # record that we've created this index in the schema somewhere.
1677         prop = self.getprops()[propname]
1678         if not isinstance(prop, String):
1679             raise TypeError, 'key properties must be String'
1680         self.key = propname
1682     def getkey(self):
1683         '''Return the name of the key property for this class or None.'''
1684         return self.key
1686     def labelprop(self, default_to_id=0):
1687         ''' Return the property name for a label for the given node.
1689         This method attempts to generate a consistent label for the node.
1690         It tries the following in order:
1691             1. key property
1692             2. "name" property
1693             3. "title" property
1694             4. first property from the sorted property name list
1695         '''
1696         k = self.getkey()
1697         if  k:
1698             return k
1699         props = self.getprops()
1700         if props.has_key('name'):
1701             return 'name'
1702         elif props.has_key('title'):
1703             return 'title'
1704         if default_to_id:
1705             return 'id'
1706         props = props.keys()
1707         props.sort()
1708         return props[0]
1710     def lookup(self, keyvalue):
1711         '''Locate a particular node by its key property and return its id.
1713         If this class has no key property, a TypeError is raised.  If the
1714         'keyvalue' matches one of the values for the key property among
1715         the nodes in this class, the matching node's id is returned;
1716         otherwise a KeyError is raised.
1717         '''
1718         if not self.key:
1719             raise TypeError, 'No key property set for class %s'%self.classname
1721         # use the arg to handle any odd database type conversion (hello,
1722         # sqlite)
1723         sql = "select id from _%s where _%s=%s and __retired__ <> %s"%(
1724             self.classname, self.key, self.db.arg, self.db.arg)
1725         self.db.sql(sql, (keyvalue, 1))
1727         # see if there was a result that's not retired
1728         row = self.db.sql_fetchone()
1729         if not row:
1730             raise KeyError, 'No key (%s) value "%s" for "%s"'%(self.key,
1731                 keyvalue, self.classname)
1733         # return the id
1734         return row[0]
1736     def find(self, **propspec):
1737         '''Get the ids of nodes in this class which link to the given nodes.
1739         'propspec' consists of keyword args propname=nodeid or
1740                    propname={nodeid:1, }
1741         'propname' must be the name of a property in this class, or a
1742         KeyError is raised.  That property must be a Link or Multilink
1743         property, or a TypeError is raised.
1745         Any node in this class whose 'propname' property links to any of the
1746         nodeids will be returned. Used by the full text indexing, which knows
1747         that "foo" occurs in msg1, msg3 and file7, so we have hits on these
1748         issues:
1750             db.issue.find(messages={'1':1,'3':1}, files={'7':1})
1751         '''
1752         if __debug__:
1753             print >>hyperdb.DEBUG, 'find', (self, propspec)
1755         # shortcut
1756         if not propspec:
1757             return []
1759         # validate the args
1760         props = self.getprops()
1761         propspec = propspec.items()
1762         for propname, nodeids in propspec:
1763             # check the prop is OK
1764             prop = props[propname]
1765             if not isinstance(prop, Link) and not isinstance(prop, Multilink):
1766                 raise TypeError, "'%s' not a Link/Multilink property"%propname
1768         # first, links
1769         where = []
1770         allvalues = ()
1771         a = self.db.arg
1772         for prop, values in propspec:
1773             if not isinstance(props[prop], hyperdb.Link):
1774                 continue
1775             if type(values) is type(''):
1776                 allvalues += (values,)
1777                 where.append('_%s = %s'%(prop, a))
1778             elif values is None:
1779                 where.append('_%s is NULL'%prop)
1780             else:
1781                 allvalues += tuple(values.keys())
1782                 where.append('_%s in (%s)'%(prop, ','.join([a]*len(values))))
1783         tables = []
1784         if where:
1785             tables.append('select id as nodeid from _%s where %s'%(
1786                 self.classname, ' and '.join(where)))
1788         # now multilinks
1789         for prop, values in propspec:
1790             if not isinstance(props[prop], hyperdb.Multilink):
1791                 continue
1792             if type(values) is type(''):
1793                 allvalues += (values,)
1794                 s = a
1795             else:
1796                 allvalues += tuple(values.keys())
1797                 s = ','.join([a]*len(values))
1798             tables.append('select nodeid from %s_%s where linkid in (%s)'%(
1799                 self.classname, prop, s))
1800         sql = '\nunion\n'.join(tables)
1801         self.db.sql(sql, allvalues)
1802         l = [x[0] for x in self.db.sql_fetchall()]
1803         if __debug__:
1804             print >>hyperdb.DEBUG, 'find ... ', l
1805         return l
1807     def stringFind(self, **requirements):
1808         '''Locate a particular node by matching a set of its String
1809         properties in a caseless search.
1811         If the property is not a String property, a TypeError is raised.
1812         
1813         The return is a list of the id of all nodes that match.
1814         '''
1815         where = []
1816         args = []
1817         for propname in requirements.keys():
1818             prop = self.properties[propname]
1819             if isinstance(not prop, String):
1820                 raise TypeError, "'%s' not a String property"%propname
1821             where.append(propname)
1822             args.append(requirements[propname].lower())
1824         # generate the where clause
1825         s = ' and '.join(['lower(_%s)=%s'%(col, self.db.arg) for col in where])
1826         sql = 'select id from _%s where %s'%(self.classname, s)
1827         self.db.sql(sql, tuple(args))
1828         l = [x[0] for x in self.db.sql_fetchall()]
1829         if __debug__:
1830             print >>hyperdb.DEBUG, 'find ... ', l
1831         return l
1833     def list(self):
1834         ''' Return a list of the ids of the active nodes in this class.
1835         '''
1836         return self.getnodeids(retired=0)
1838     def getnodeids(self, retired=None):
1839         ''' Retrieve all the ids of the nodes for a particular Class.
1841             Set retired=None to get all nodes. Otherwise it'll get all the 
1842             retired or non-retired nodes, depending on the flag.
1843         '''
1844         # flip the sense of the 'retired' flag if we don't want all of them
1845         if retired is not None:
1846             if retired:
1847                 args = (0, )
1848             else:
1849                 args = (1, )
1850             sql = 'select id from _%s where __retired__ <> %s'%(self.classname,
1851                 self.db.arg)
1852         else:
1853             args = ()
1854             sql = 'select id from _%s'%self.classname
1855         if __debug__:
1856             print >>hyperdb.DEBUG, 'getnodeids', (self, sql, retired)
1857         self.db.cursor.execute(sql, args)
1858         ids = [x[0] for x in self.db.cursor.fetchall()]
1859         return ids
1861     def filter(self, search_matches, filterspec, sort=(None,None),
1862             group=(None,None)):
1863         ''' Return a list of the ids of the active nodes in this class that
1864             match the 'filter' spec, sorted by the group spec and then the
1865             sort spec
1867             "filterspec" is {propname: value(s)}
1868             "sort" and "group" are (dir, prop) where dir is '+', '-' or None
1869                                and prop is a prop name or None
1870             "search_matches" is {nodeid: marker}
1872             The filter must match all properties specificed - but if the
1873             property value to match is a list, any one of the values in the
1874             list may match for that property to match.
1875         '''
1876         # just don't bother if the full-text search matched diddly
1877         if search_matches == {}:
1878             return []
1880         cn = self.classname
1882         timezone = self.db.getUserTimezone()
1883         
1884         # figure the WHERE clause from the filterspec
1885         props = self.getprops()
1886         frum = ['_'+cn]
1887         where = []
1888         args = []
1889         a = self.db.arg
1890         for k, v in filterspec.items():
1891             propclass = props[k]
1892             # now do other where clause stuff
1893             if isinstance(propclass, Multilink):
1894                 tn = '%s_%s'%(cn, k)
1895                 if v in ('-1', ['-1']):
1896                     # only match rows that have count(linkid)=0 in the
1897                     # corresponding multilink table)
1898                     where.append('id not in (select nodeid from %s)'%tn)
1899                 elif isinstance(v, type([])):
1900                     frum.append(tn)
1901                     s = ','.join([a for x in v])
1902                     where.append('id=%s.nodeid and %s.linkid in (%s)'%(tn,tn,s))
1903                     args = args + v
1904                 else:
1905                     frum.append(tn)
1906                     where.append('id=%s.nodeid and %s.linkid=%s'%(tn, tn, a))
1907                     args.append(v)
1908             elif k == 'id':
1909                 if isinstance(v, type([])):
1910                     s = ','.join([a for x in v])
1911                     where.append('%s in (%s)'%(k, s))
1912                     args = args + v
1913                 else:
1914                     where.append('%s=%s'%(k, a))
1915                     args.append(v)
1916             elif isinstance(propclass, String):
1917                 if not isinstance(v, type([])):
1918                     v = [v]
1920                 # Quote the bits in the string that need it and then embed
1921                 # in a "substring" search. Note - need to quote the '%' so
1922                 # they make it through the python layer happily
1923                 v = ['%%'+self.db.sql_stringquote(s)+'%%' for s in v]
1925                 # now add to the where clause
1926                 where.append(' or '.join(["_%s LIKE '%s'"%(k, s) for s in v]))
1927                 # note: args are embedded in the query string now
1928             elif isinstance(propclass, Link):
1929                 if isinstance(v, type([])):
1930                     if '-1' in v:
1931                         v = v[:]
1932                         v.remove('-1')
1933                         xtra = ' or _%s is NULL'%k
1934                     else:
1935                         xtra = ''
1936                     if v:
1937                         s = ','.join([a for x in v])
1938                         where.append('(_%s in (%s)%s)'%(k, s, xtra))
1939                         args = args + v
1940                     else:
1941                         where.append('_%s is NULL'%k)
1942                 else:
1943                     if v == '-1':
1944                         v = None
1945                         where.append('_%s is NULL'%k)
1946                     else:
1947                         where.append('_%s=%s'%(k, a))
1948                         args.append(v)
1949             elif isinstance(propclass, Date):
1950                 if isinstance(v, type([])):
1951                     s = ','.join([a for x in v])
1952                     where.append('_%s in (%s)'%(k, s))
1953                     args = args + [date.Date(x).serialise() for x in v]
1954                 else:
1955                     try:
1956                         # Try to filter on range of dates
1957                         date_rng = Range(v, date.Date, offset=timezone)
1958                         if (date_rng.from_value):
1959                             where.append('_%s >= %s'%(k, a))                            
1960                             args.append(date_rng.from_value.serialise())
1961                         if (date_rng.to_value):
1962                             where.append('_%s <= %s'%(k, a))
1963                             args.append(date_rng.to_value.serialise())
1964                     except ValueError:
1965                         # If range creation fails - ignore that search parameter
1966                         pass                        
1967             elif isinstance(propclass, Interval):
1968                 if isinstance(v, type([])):
1969                     s = ','.join([a for x in v])
1970                     where.append('_%s in (%s)'%(k, s))
1971                     args = args + [date.Interval(x).serialise() for x in v]
1972                 else:
1973                     try:
1974                         # Try to filter on range of intervals
1975                         date_rng = Range(v, date.Interval)
1976                         if (date_rng.from_value):
1977                             where.append('_%s >= %s'%(k, a))
1978                             args.append(date_rng.from_value.serialise())
1979                         if (date_rng.to_value):
1980                             where.append('_%s <= %s'%(k, a))
1981                             args.append(date_rng.to_value.serialise())
1982                     except ValueError:
1983                         # If range creation fails - ignore that search parameter
1984                         pass                        
1985                     #where.append('_%s=%s'%(k, a))
1986                     #args.append(date.Interval(v).serialise())
1987             else:
1988                 if isinstance(v, type([])):
1989                     s = ','.join([a for x in v])
1990                     where.append('_%s in (%s)'%(k, s))
1991                     args = args + v
1992                 else:
1993                     where.append('_%s=%s'%(k, a))
1994                     args.append(v)
1996         # don't match retired nodes
1997         where.append('__retired__ <> 1')
1999         # add results of full text search
2000         if search_matches is not None:
2001             v = search_matches.keys()
2002             s = ','.join([a for x in v])
2003             where.append('id in (%s)'%s)
2004             args = args + v
2006         # "grouping" is just the first-order sorting in the SQL fetch
2007         # can modify it...)
2008         orderby = []
2009         ordercols = []
2010         if group[0] is not None and group[1] is not None:
2011             if group[0] != '-':
2012                 orderby.append('_'+group[1])
2013                 ordercols.append('_'+group[1])
2014             else:
2015                 orderby.append('_'+group[1]+' desc')
2016                 ordercols.append('_'+group[1])
2018         # now add in the sorting
2019         group = ''
2020         if sort[0] is not None and sort[1] is not None:
2021             direction, colname = sort
2022             if direction != '-':
2023                 if colname == 'id':
2024                     orderby.append(colname)
2025                 else:
2026                     orderby.append('_'+colname)
2027                     ordercols.append('_'+colname)
2028             else:
2029                 if colname == 'id':
2030                     orderby.append(colname+' desc')
2031                     ordercols.append(colname)
2032                 else:
2033                     orderby.append('_'+colname+' desc')
2034                     ordercols.append('_'+colname)
2036         # construct the SQL
2037         frum = ','.join(frum)
2038         if where:
2039             where = ' where ' + (' and '.join(where))
2040         else:
2041             where = ''
2042         cols = ['id']
2043         if orderby:
2044             cols = cols + ordercols
2045             order = ' order by %s'%(','.join(orderby))
2046         else:
2047             order = ''
2048         cols = ','.join(cols)
2049         sql = 'select %s from %s %s%s%s'%(cols, frum, where, group, order)
2050         args = tuple(args)
2051         if __debug__:
2052             print >>hyperdb.DEBUG, 'filter', (self, sql, args)
2053         self.db.cursor.execute(sql, args)
2054         l = self.db.cursor.fetchall()
2056         # return the IDs (the first column)
2057         # XXX The filter(None, l) bit is sqlite-specific... if there's _NO_
2058         # XXX matches to a fetch, it returns NULL instead of nothing!?!
2059         return filter(None, [row[0] for row in l])
2061     def count(self):
2062         '''Get the number of nodes in this class.
2064         If the returned integer is 'numnodes', the ids of all the nodes
2065         in this class run from 1 to numnodes, and numnodes+1 will be the
2066         id of the next node to be created in this class.
2067         '''
2068         return self.db.countnodes(self.classname)
2070     # Manipulating properties:
2071     def getprops(self, protected=1):
2072         '''Return a dictionary mapping property names to property objects.
2073            If the "protected" flag is true, we include protected properties -
2074            those which may not be modified.
2075         '''
2076         d = self.properties.copy()
2077         if protected:
2078             d['id'] = String()
2079             d['creation'] = hyperdb.Date()
2080             d['activity'] = hyperdb.Date()
2081             d['creator'] = hyperdb.Link('user')
2082         return d
2084     def addprop(self, **properties):
2085         '''Add properties to this class.
2087         The keyword arguments in 'properties' must map names to property
2088         objects, or a TypeError is raised.  None of the keys in 'properties'
2089         may collide with the names of existing properties, or a ValueError
2090         is raised before any properties have been added.
2091         '''
2092         for key in properties.keys():
2093             if self.properties.has_key(key):
2094                 raise ValueError, key
2095         self.properties.update(properties)
2097     def index(self, nodeid):
2098         '''Add (or refresh) the node to search indexes
2099         '''
2100         # find all the String properties that have indexme
2101         for prop, propclass in self.getprops().items():
2102             if isinstance(propclass, String) and propclass.indexme:
2103                 try:
2104                     value = str(self.get(nodeid, prop))
2105                 except IndexError:
2106                     # node no longer exists - entry should be removed
2107                     self.db.indexer.purge_entry((self.classname, nodeid, prop))
2108                 else:
2109                     # and index them under (classname, nodeid, property)
2110                     self.db.indexer.add_text((self.classname, nodeid, prop),
2111                         value)
2114     #
2115     # Detector interface
2116     #
2117     def audit(self, event, detector):
2118         '''Register a detector
2119         '''
2120         l = self.auditors[event]
2121         if detector not in l:
2122             self.auditors[event].append(detector)
2124     def fireAuditors(self, action, nodeid, newvalues):
2125         '''Fire all registered auditors.
2126         '''
2127         for audit in self.auditors[action]:
2128             audit(self.db, self, nodeid, newvalues)
2130     def react(self, event, detector):
2131         '''Register a detector
2132         '''
2133         l = self.reactors[event]
2134         if detector not in l:
2135             self.reactors[event].append(detector)
2137     def fireReactors(self, action, nodeid, oldvalues):
2138         '''Fire all registered reactors.
2139         '''
2140         for react in self.reactors[action]:
2141             react(self.db, self, nodeid, oldvalues)
2143 class FileClass(Class, hyperdb.FileClass):
2144     '''This class defines a large chunk of data. To support this, it has a
2145        mandatory String property "content" which is typically saved off
2146        externally to the hyperdb.
2148        The default MIME type of this data is defined by the
2149        "default_mime_type" class attribute, which may be overridden by each
2150        node if the class defines a "type" String property.
2151     '''
2152     default_mime_type = 'text/plain'
2154     def create(self, **propvalues):
2155         ''' snaffle the file propvalue and store in a file
2156         '''
2157         # we need to fire the auditors now, or the content property won't
2158         # be in propvalues for the auditors to play with
2159         self.fireAuditors('create', None, propvalues)
2161         # now remove the content property so it's not stored in the db
2162         content = propvalues['content']
2163         del propvalues['content']
2165         # do the database create
2166         newid = Class.create_inner(self, **propvalues)
2168         # fire reactors
2169         self.fireReactors('create', newid, None)
2171         # store off the content as a file
2172         self.db.storefile(self.classname, newid, None, content)
2173         return newid
2175     def import_list(self, propnames, proplist):
2176         ''' Trap the "content" property...
2177         '''
2178         # dupe this list so we don't affect others
2179         propnames = propnames[:]
2181         # extract the "content" property from the proplist
2182         i = propnames.index('content')
2183         content = eval(proplist[i])
2184         del propnames[i]
2185         del proplist[i]
2187         # do the normal import
2188         newid = Class.import_list(self, propnames, proplist)
2190         # save off the "content" file
2191         self.db.storefile(self.classname, newid, None, content)
2192         return newid
2194     _marker = []
2195     def get(self, nodeid, propname, default=_marker, cache=1):
2196         ''' Trap the content propname and get it from the file
2198         'cache' exists for backwards compatibility, and is not used.
2199         '''
2200         poss_msg = 'Possibly a access right configuration problem.'
2201         if propname == 'content':
2202             try:
2203                 return self.db.getfile(self.classname, nodeid, None)
2204             except IOError, (strerror):
2205                 # BUG: by catching this we donot see an error in the log.
2206                 return 'ERROR reading file: %s%s\n%s\n%s'%(
2207                         self.classname, nodeid, poss_msg, strerror)
2208         if default is not self._marker:
2209             return Class.get(self, nodeid, propname, default)
2210         else:
2211             return Class.get(self, nodeid, propname)
2213     def getprops(self, protected=1):
2214         ''' In addition to the actual properties on the node, these methods
2215             provide the "content" property. If the "protected" flag is true,
2216             we include protected properties - those which may not be
2217             modified.
2218         '''
2219         d = Class.getprops(self, protected=protected).copy()
2220         d['content'] = hyperdb.String()
2221         return d
2223     def index(self, nodeid):
2224         ''' Index the node in the search index.
2226             We want to index the content in addition to the normal String
2227             property indexing.
2228         '''
2229         # perform normal indexing
2230         Class.index(self, nodeid)
2232         # get the content to index
2233         content = self.get(nodeid, 'content')
2235         # figure the mime type
2236         if self.properties.has_key('type'):
2237             mime_type = self.get(nodeid, 'type')
2238         else:
2239             mime_type = self.default_mime_type
2241         # and index!
2242         self.db.indexer.add_text((self.classname, nodeid, 'content'), content,
2243             mime_type)
2245 # XXX deviation from spec - was called ItemClass
2246 class IssueClass(Class, roundupdb.IssueClass):
2247     # Overridden methods:
2248     def __init__(self, db, classname, **properties):
2249         '''The newly-created class automatically includes the "messages",
2250         "files", "nosy", and "superseder" properties.  If the 'properties'
2251         dictionary attempts to specify any of these properties or a
2252         "creation" or "activity" property, a ValueError is raised.
2253         '''
2254         if not properties.has_key('title'):
2255             properties['title'] = hyperdb.String(indexme='yes')
2256         if not properties.has_key('messages'):
2257             properties['messages'] = hyperdb.Multilink("msg")
2258         if not properties.has_key('files'):
2259             properties['files'] = hyperdb.Multilink("file")
2260         if not properties.has_key('nosy'):
2261             # note: journalling is turned off as it really just wastes
2262             # space. this behaviour may be overridden in an instance
2263             properties['nosy'] = hyperdb.Multilink("user", do_journal="no")
2264         if not properties.has_key('superseder'):
2265             properties['superseder'] = hyperdb.Multilink(classname)
2266         Class.__init__(self, db, classname, **properties)