Code

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