Code

- Remove implementations of Class.getnode from back_anydbm and rdbms_common,
[roundup.git] / roundup / backends / rdbms_common.py
1 # $Id: rdbms_common.py,v 1.71 2003-11-16 18:41:40 jlgijsbers 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.sql_open_connection()
74     def clearCache(self):
75         self.cache = {}
76         self.cache_lru = []
78     def sql_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         return self.cursor.fetchone()
98     def sql_fetchall(self):
99         ''' Fetch all rows. If there's nothing to fetch, return [].
100         '''
101         return self.cursor.fetchall()
103     def sql_stringquote(self, value):
104         ''' Quote the string so it's safe to put in the 'sql quotes'
105         '''
106         return re.sub("'", "''", str(value))
108     def save_dbschema(self, schema):
109         ''' Save the schema definition that the database currently implements
110         '''
111         s = repr(self.database_schema)
112         self.sql('insert into schema values (%s)', (s,))
114     def load_dbschema(self):
115         ''' Load the schema definition that the database currently implements
116         '''
117         self.cursor.execute('select schema from schema')
118         return eval(self.cursor.fetchone()[0])
120     def post_init(self):
121         ''' Called once the schema initialisation has finished.
123             We should now confirm that the schema defined by our "classes"
124             attribute actually matches the schema in the database.
125         '''
126         # now detect changes in the schema
127         save = 0
128         for classname, spec in self.classes.items():
129             if self.database_schema.has_key(classname):
130                 dbspec = self.database_schema[classname]
131                 if self.update_class(spec, dbspec):
132                     self.database_schema[classname] = spec.schema()
133                     save = 1
134             else:
135                 self.create_class(spec)
136                 self.database_schema[classname] = spec.schema()
137                 save = 1
139         for classname, spec in self.database_schema.items():
140             if not self.classes.has_key(classname):
141                 self.drop_class(classname, spec)
142                 del self.database_schema[classname]
143                 save = 1
145         # update the database version of the schema
146         if save:
147             self.sql('delete from schema')
148             self.save_dbschema(self.database_schema)
150         # reindex the db if necessary
151         if self.indexer.should_reindex():
152             self.reindex()
154         # commit
155         self.conn.commit()
157     def refresh_database(self):
158         # now detect changes in the schema
159         for classname, spec in self.classes.items():
160             dbspec = self.database_schema[classname]
161             self.update_class(spec, dbspec, force=1)
162             self.database_schema[classname] = spec.schema()
163         # update the database version of the schema
164         self.sql('delete from schema')
165         self.save_dbschema(self.database_schema)
166         # reindex the db 
167         self.reindex()
168         # commit
169         self.conn.commit()
172     def reindex(self):
173         for klass in self.classes.values():
174             for nodeid in klass.list():
175                 klass.index(nodeid)
176         self.indexer.save_index()
178     def determine_columns(self, properties):
179         ''' Figure the column names and multilink properties from the spec
181             "properties" is a list of (name, prop) where prop may be an
182             instance of a hyperdb "type" _or_ a string repr of that type.
183         '''
184         cols = ['_activity', '_creator', '_creation']
185         mls = []
186         # add the multilinks separately
187         for col, prop in properties:
188             if isinstance(prop, Multilink):
189                 mls.append(col)
190             elif isinstance(prop, type('')) and prop.find('Multilink') != -1:
191                 mls.append(col)
192             else:
193                 cols.append('_'+col)
194         cols.sort()
195         return cols, mls
197     def update_class(self, spec, old_spec, force=0):
198         ''' Determine the differences between the current spec and the
199             database version of the spec, and update where necessary.
201             If 'force' is true, update the database anyway.
202         '''
203         new_has = spec.properties.has_key
204         new_spec = spec.schema()
205         new_spec[1].sort()
206         old_spec[1].sort()
207         if not force and new_spec == old_spec:
208             # no changes
209             return 0
211         if __debug__:
212             print >>hyperdb.DEBUG, 'update_class FIRING'
214         # detect multilinks that have been removed, and drop their table
215         old_has = {}
216         for name,prop in old_spec[1]:
217             old_has[name] = 1
218             if new_has(name) or not isinstance(prop, Multilink):
219                 continue
220             # it's a multilink, and it's been removed - drop the old
221             # table. First drop indexes.
222             self.drop_multilink_table_indexes(spec.classname, ml)
223             sql = 'drop table %s_%s'%(spec.classname, prop)
224             if __debug__:
225                 print >>hyperdb.DEBUG, 'update_class', (self, sql)
226             self.cursor.execute(sql)
227         old_has = old_has.has_key
229         # now figure how we populate the new table
230         fetch = ['_activity', '_creation', '_creator']
231         properties = spec.getprops()
232         for propname,x in new_spec[1]:
233             prop = properties[propname]
234             if isinstance(prop, Multilink):
235                 if force or not old_has(propname):
236                     # we need to create the new table
237                     self.create_multilink_table(spec, propname)
238             elif old_has(propname):
239                 # we copy this col over from the old table
240                 fetch.append('_'+propname)
242         # select the data out of the old table
243         fetch.append('id')
244         fetch.append('__retired__')
245         fetchcols = ','.join(fetch)
246         cn = spec.classname
247         sql = 'select %s from _%s'%(fetchcols, cn)
248         if __debug__:
249             print >>hyperdb.DEBUG, 'update_class', (self, sql)
250         self.cursor.execute(sql)
251         olddata = self.cursor.fetchall()
253         # TODO: update all the other index dropping code
254         self.drop_class_table_indexes(cn, old_spec[0])
256         # drop the old table
257         self.cursor.execute('drop table _%s'%cn)
259         # create the new table
260         self.create_class_table(spec)
262         if olddata:
263             # do the insert
264             args = ','.join([self.arg for x in fetch])
265             sql = 'insert into _%s (%s) values (%s)'%(cn, fetchcols, args)
266             if __debug__:
267                 print >>hyperdb.DEBUG, 'update_class', (self, sql, olddata[0])
268             for entry in olddata:
269                 self.cursor.execute(sql, tuple(entry))
271         return 1
273     def create_class_table(self, spec):
274         ''' create the class table for the given spec
275         '''
276         cols, mls = self.determine_columns(spec.properties.items())
278         # add on our special columns
279         cols.append('id')
280         cols.append('__retired__')
282         # create the base table
283         scols = ','.join(['%s varchar'%x for x in cols])
284         sql = 'create table _%s (%s)'%(spec.classname, scols)
285         if __debug__:
286             print >>hyperdb.DEBUG, 'create_class', (self, sql)
287         self.cursor.execute(sql)
289         self.create_class_table_indexes(spec)
291         return cols, mls
293     def create_class_table_indexes(self, spec):
294         ''' create the class table for the given spec
295         '''
296         # create id index
297         index_sql1 = 'create index _%s_id_idx on _%s(id)'%(
298                         spec.classname, spec.classname)
299         if __debug__:
300             print >>hyperdb.DEBUG, 'create_index', (self, index_sql1)
301         self.cursor.execute(index_sql1)
303         # create __retired__ index
304         index_sql2 = 'create index _%s_retired_idx on _%s(__retired__)'%(
305                         spec.classname, spec.classname)
306         if __debug__:
307             print >>hyperdb.DEBUG, 'create_index', (self, index_sql2)
308         self.cursor.execute(index_sql2)
310         # create index for key property
311         if spec.key:
312             if __debug__:
313                 print >>hyperdb.DEBUG, 'update_class setting keyprop %r'% \
314                     spec.key
315             index_sql3 = 'create index _%s_%s_idx on _%s(_%s)'%(
316                         spec.classname, spec.key,
317                         spec.classname, spec.key)
318             if __debug__:
319                 print >>hyperdb.DEBUG, 'create_index', (self, index_sql3)
320             self.cursor.execute(index_sql3)
322     def drop_class_table_indexes(self, cn, key):
323         # drop the old table indexes first
324         l = ['_%s_id_idx'%cn, '_%s_retired_idx'%cn]
325         if key:
326             # key prop too?
327             l.append('_%s_%s_idx'%(cn, key))
329         # TODO: update all the other index dropping code
330         table_name = '_%s'%cn
331         for index_name in l:
332             if not self.sql_index_exists(table_name, index_name):
333                 continue
334             index_sql = 'drop index '+index_name
335             if __debug__:
336                 print >>hyperdb.DEBUG, 'drop_index', (self, index_sql)
337             self.cursor.execute(index_sql)
339     def create_journal_table(self, spec):
340         ''' create the journal table for a class given the spec and 
341             already-determined cols
342         '''
343         # journal table
344         cols = ','.join(['%s varchar'%x
345             for x in 'nodeid date tag action params'.split()])
346         sql = 'create table %s__journal (%s)'%(spec.classname, cols)
347         if __debug__:
348             print >>hyperdb.DEBUG, 'create_class', (self, sql)
349         self.cursor.execute(sql)
350         self.create_journal_table_indexes(spec)
352     def create_journal_table_indexes(self, spec):
353         # index on nodeid
354         index_sql = 'create index %s_journ_idx on %s__journal(nodeid)'%(
355                         spec.classname, spec.classname)
356         if __debug__:
357             print >>hyperdb.DEBUG, 'create_index', (self, index_sql)
358         self.cursor.execute(index_sql)
360     def drop_journal_table_indexes(self, classname):
361         index_name = '%s_journ_idx'%classname
362         if not self.sql_index_exists('%s__journal'%classname, index_name):
363             return
364         index_sql = 'drop index '+index_name
365         if __debug__:
366             print >>hyperdb.DEBUG, 'drop_index', (self, index_sql)
367         self.cursor.execute(index_sql)
369     def create_multilink_table(self, spec, ml):
370         ''' Create a multilink table for the "ml" property of the class
371             given by the spec
372         '''
373         # create the table
374         sql = 'create table %s_%s (linkid varchar, nodeid varchar)'%(
375             spec.classname, ml)
376         if __debug__:
377             print >>hyperdb.DEBUG, 'create_class', (self, sql)
378         self.cursor.execute(sql)
379         self.create_multilink_table_indexes(spec, ml)
381     def create_multilink_table_indexes(self, spec, ml):
382         # create index on linkid
383         index_sql = 'create index %s_%s_l_idx on %s_%s(linkid)'%(
384                         spec.classname, ml, spec.classname, ml)
385         if __debug__:
386             print >>hyperdb.DEBUG, 'create_index', (self, index_sql)
387         self.cursor.execute(index_sql)
389         # create index on nodeid
390         index_sql = 'create index %s_%s_n_idx on %s_%s(nodeid)'%(
391                         spec.classname, ml, spec.classname, ml)
392         if __debug__:
393             print >>hyperdb.DEBUG, 'create_index', (self, index_sql)
394         self.cursor.execute(index_sql)
396     def drop_multilink_table_indexes(self, classname, ml):
397         l = [
398             '%s_%s_l_idx'%(classname, ml),
399             '%s_%s_n_idx'%(classname, ml)
400         ]
401         table_name = '%s_%s'%(classname, ml)
402         for index_name in l:
403             if not self.sql_index_exists(table_name, index_name):
404                 continue
405             index_sql = 'drop index %s'%index_name
406             if __debug__:
407                 print >>hyperdb.DEBUG, 'drop_index', (self, index_sql)
408             self.cursor.execute(index_sql)
410     def create_class(self, spec):
411         ''' Create a database table according to the given spec.
412         '''
413         cols, mls = self.create_class_table(spec)
414         self.create_journal_table(spec)
416         # now create the multilink tables
417         for ml in mls:
418             self.create_multilink_table(spec, ml)
420         # ID counter
421         sql = 'insert into ids (name, num) values (%s,%s)'%(self.arg, self.arg)
422         vals = (spec.classname, 1)
423         if __debug__:
424             print >>hyperdb.DEBUG, 'create_class', (self, sql, vals)
425         self.cursor.execute(sql, vals)
427     def drop_class(self, cn, spec):
428         ''' Drop the given table from the database.
430             Drop the journal and multilink tables too.
431         '''
432         properties = spec[1]
433         # figure the multilinks
434         mls = []
435         for propanme, prop in properties:
436             if isinstance(prop, Multilink):
437                 mls.append(propname)
439         # drop class table and indexes
440         self.drop_class_table_indexes(cn, spec[0])
441         sql = 'drop table _%s'%cn
442         if __debug__:
443             print >>hyperdb.DEBUG, 'drop_class', (self, sql)
444         self.cursor.execute(sql)
446         # drop journal table and indexes
447         self.drop_journal_table_indexes(cn)
448         sql = 'drop table %s__journal'%cn
449         if __debug__:
450             print >>hyperdb.DEBUG, 'drop_class', (self, sql)
451         self.cursor.execute(sql)
453         for ml in mls:
454             # drop multilink table and indexes
455             self.drop_multilink_table_indexes(cn, ml)
456             sql = 'drop table %s_%s'%(spec.classname, ml)
457             if __debug__:
458                 print >>hyperdb.DEBUG, 'drop_class', (self, sql)
459             self.cursor.execute(sql)
461     #
462     # Classes
463     #
464     def __getattr__(self, classname):
465         ''' A convenient way of calling self.getclass(classname).
466         '''
467         if self.classes.has_key(classname):
468             if __debug__:
469                 print >>hyperdb.DEBUG, '__getattr__', (self, classname)
470             return self.classes[classname]
471         raise AttributeError, classname
473     def addclass(self, cl):
474         ''' Add a Class to the hyperdatabase.
475         '''
476         if __debug__:
477             print >>hyperdb.DEBUG, 'addclass', (self, cl)
478         cn = cl.classname
479         if self.classes.has_key(cn):
480             raise ValueError, cn
481         self.classes[cn] = cl
483     def getclasses(self):
484         ''' Return a list of the names of all existing classes.
485         '''
486         if __debug__:
487             print >>hyperdb.DEBUG, 'getclasses', (self,)
488         l = self.classes.keys()
489         l.sort()
490         return l
492     def getclass(self, classname):
493         '''Get the Class object representing a particular class.
495         If 'classname' is not a valid class name, a KeyError is raised.
496         '''
497         if __debug__:
498             print >>hyperdb.DEBUG, 'getclass', (self, classname)
499         try:
500             return self.classes[classname]
501         except KeyError:
502             raise KeyError, 'There is no class called "%s"'%classname
504     def clear(self):
505         ''' Delete all database contents.
507             Note: I don't commit here, which is different behaviour to the
508             "nuke from orbit" behaviour in the *dbms.
509         '''
510         if __debug__:
511             print >>hyperdb.DEBUG, 'clear', (self,)
512         for cn in self.classes.keys():
513             sql = 'delete from _%s'%cn
514             if __debug__:
515                 print >>hyperdb.DEBUG, 'clear', (self, sql)
516             self.cursor.execute(sql)
518     #
519     # Node IDs
520     #
521     def newid(self, classname):
522         ''' Generate a new id for the given class
523         '''
524         # get the next ID
525         sql = 'select num from ids where name=%s'%self.arg
526         if __debug__:
527             print >>hyperdb.DEBUG, 'newid', (self, sql, classname)
528         self.cursor.execute(sql, (classname, ))
529         newid = self.cursor.fetchone()[0]
531         # update the counter
532         sql = 'update ids set num=%s where name=%s'%(self.arg, self.arg)
533         vals = (int(newid)+1, classname)
534         if __debug__:
535             print >>hyperdb.DEBUG, 'newid', (self, sql, vals)
536         self.cursor.execute(sql, vals)
538         # return as string
539         return str(newid)
541     def setid(self, classname, setid):
542         ''' Set the id counter: used during import of database
543         '''
544         sql = 'update ids set num=%s where name=%s'%(self.arg, self.arg)
545         vals = (setid, classname)
546         if __debug__:
547             print >>hyperdb.DEBUG, 'setid', (self, sql, vals)
548         self.cursor.execute(sql, vals)
550     #
551     # Nodes
552     #
553     def addnode(self, classname, nodeid, node):
554         ''' Add the specified node to its class's db.
555         '''
556         if __debug__:
557             print >>hyperdb.DEBUG, 'addnode', (self, classname, nodeid, node)
559         # determine the column definitions and multilink tables
560         cl = self.classes[classname]
561         cols, mls = self.determine_columns(cl.properties.items())
563         # we'll be supplied these props if we're doing an import
564         if not node.has_key('creator'):
565             # add in the "calculated" properties (dupe so we don't affect
566             # calling code's node assumptions)
567             node = node.copy()
568             node['creation'] = node['activity'] = date.Date()
569             node['creator'] = self.getuid()
571         # default the non-multilink columns
572         for col, prop in cl.properties.items():
573             if not node.has_key(col):
574                 if isinstance(prop, Multilink):
575                     node[col] = []
576                 else:
577                     node[col] = None
579         # clear this node out of the cache if it's in there
580         key = (classname, nodeid)
581         if self.cache.has_key(key):
582             del self.cache[key]
583             self.cache_lru.remove(key)
585         # make the node data safe for the DB
586         node = self.serialise(classname, node)
588         # make sure the ordering is correct for column name -> column value
589         vals = tuple([node[col[1:]] for col in cols]) + (nodeid, 0)
590         s = ','.join([self.arg for x in cols]) + ',%s,%s'%(self.arg, self.arg)
591         cols = ','.join(cols) + ',id,__retired__'
593         # perform the inserts
594         sql = 'insert into _%s (%s) values (%s)'%(classname, cols, s)
595         if __debug__:
596             print >>hyperdb.DEBUG, 'addnode', (self, sql, vals)
597         self.cursor.execute(sql, vals)
599         # insert the multilink rows
600         for col in mls:
601             t = '%s_%s'%(classname, col)
602             for entry in node[col]:
603                 sql = 'insert into %s (linkid, nodeid) values (%s,%s)'%(t,
604                     self.arg, self.arg)
605                 self.sql(sql, (entry, nodeid))
607         # make sure we do the commit-time extra stuff for this node
608         self.transactions.append((self.doSaveNode, (classname, nodeid, node)))
610     def setnode(self, classname, nodeid, values, multilink_changes):
611         ''' Change the specified node.
612         '''
613         if __debug__:
614             print >>hyperdb.DEBUG, 'setnode', (self, classname, nodeid, values)
616         # clear this node out of the cache if it's in there
617         key = (classname, nodeid)
618         if self.cache.has_key(key):
619             del self.cache[key]
620             self.cache_lru.remove(key)
622         # add the special props
623         values = values.copy()
624         values['activity'] = date.Date()
626         # make db-friendly
627         values = self.serialise(classname, values)
629         cl = self.classes[classname]
630         cols = []
631         mls = []
632         # add the multilinks separately
633         props = cl.getprops()
634         for col in values.keys():
635             prop = props[col]
636             if isinstance(prop, Multilink):
637                 mls.append(col)
638             else:
639                 cols.append('_'+col)
640         cols.sort()
642         # if there's any updates to regular columns, do them
643         if cols:
644             # make sure the ordering is correct for column name -> column value
645             sqlvals = tuple([values[col[1:]] for col in cols]) + (nodeid,)
646             s = ','.join(['%s=%s'%(x, self.arg) for x in cols])
647             cols = ','.join(cols)
649             # perform the update
650             sql = 'update _%s set %s where id=%s'%(classname, s, self.arg)
651             if __debug__:
652                 print >>hyperdb.DEBUG, 'setnode', (self, sql, sqlvals)
653             self.cursor.execute(sql, sqlvals)
655         # now the fun bit, updating the multilinks ;)
656         for col, (add, remove) in multilink_changes.items():
657             tn = '%s_%s'%(classname, col)
658             if add:
659                 sql = 'insert into %s (nodeid, linkid) values (%s,%s)'%(tn,
660                     self.arg, self.arg)
661                 for addid in add:
662                     self.sql(sql, (nodeid, addid))
663             if remove:
664                 sql = 'delete from %s where nodeid=%s and linkid=%s'%(tn,
665                     self.arg, self.arg)
666                 for removeid in remove:
667                     self.sql(sql, (nodeid, removeid))
669         # make sure we do the commit-time extra stuff for this node
670         self.transactions.append((self.doSaveNode, (classname, nodeid, values)))
672     def getnode(self, classname, nodeid):
673         ''' Get a node from the database.
674         '''
675         if __debug__:
676             print >>hyperdb.DEBUG, 'getnode', (self, classname, nodeid)
678         # see if we have this node cached
679         key = (classname, nodeid)
680         if self.cache.has_key(key):
681             # push us back to the top of the LRU
682             self.cache_lru.remove(key)
683             self.cache_lru.insert(0, key)
684             # return the cached information
685             return self.cache[key]
687         # figure the columns we're fetching
688         cl = self.classes[classname]
689         cols, mls = self.determine_columns(cl.properties.items())
690         scols = ','.join(cols)
692         # perform the basic property fetch
693         sql = 'select %s from _%s where id=%s'%(scols, classname, self.arg)
694         self.sql(sql, (nodeid,))
696         values = self.sql_fetchone()
697         if values is None:
698             raise IndexError, 'no such %s node %s'%(classname, nodeid)
700         # make up the node
701         node = {}
702         for col in range(len(cols)):
703             node[cols[col][1:]] = values[col]
705         # now the multilinks
706         for col in mls:
707             # get the link ids
708             sql = 'select linkid from %s_%s where nodeid=%s'%(classname, col,
709                 self.arg)
710             self.cursor.execute(sql, (nodeid,))
711             # extract the first column from the result
712             node[col] = [x[0] for x in self.cursor.fetchall()]
714         # un-dbificate the node data
715         node = self.unserialise(classname, node)
717         # save off in the cache
718         key = (classname, nodeid)
719         self.cache[key] = node
720         # update the LRU
721         self.cache_lru.insert(0, key)
722         if len(self.cache_lru) > ROW_CACHE_SIZE:
723             del self.cache[self.cache_lru.pop()]
725         return node
727     def destroynode(self, classname, nodeid):
728         '''Remove a node from the database. Called exclusively by the
729            destroy() method on Class.
730         '''
731         if __debug__:
732             print >>hyperdb.DEBUG, 'destroynode', (self, classname, nodeid)
734         # make sure the node exists
735         if not self.hasnode(classname, nodeid):
736             raise IndexError, '%s has no node %s'%(classname, nodeid)
738         # see if we have this node cached
739         if self.cache.has_key((classname, nodeid)):
740             del self.cache[(classname, nodeid)]
742         # see if there's any obvious commit actions that we should get rid of
743         for entry in self.transactions[:]:
744             if entry[1][:2] == (classname, nodeid):
745                 self.transactions.remove(entry)
747         # now do the SQL
748         sql = 'delete from _%s where id=%s'%(classname, self.arg)
749         self.sql(sql, (nodeid,))
751         # remove from multilnks
752         cl = self.getclass(classname)
753         x, mls = self.determine_columns(cl.properties.items())
754         for col in mls:
755             # get the link ids
756             sql = 'delete from %s_%s where nodeid=%s'%(classname, col, self.arg)
757             self.sql(sql, (nodeid,))
759         # remove journal entries
760         sql = 'delete from %s__journal where nodeid=%s'%(classname, self.arg)
761         self.sql(sql, (nodeid,))
763     def serialise(self, classname, node):
764         '''Copy the node contents, converting non-marshallable data into
765            marshallable data.
766         '''
767         if __debug__:
768             print >>hyperdb.DEBUG, 'serialise', classname, node
769         properties = self.getclass(classname).getprops()
770         d = {}
771         for k, v in node.items():
772             # if the property doesn't exist, or is the "retired" flag then
773             # it won't be in the properties dict
774             if not properties.has_key(k):
775                 d[k] = v
776                 continue
778             # get the property spec
779             prop = properties[k]
781             if isinstance(prop, Password) and v is not None:
782                 d[k] = str(v)
783             elif isinstance(prop, Date) and v is not None:
784                 d[k] = v.serialise()
785             elif isinstance(prop, Interval) and v is not None:
786                 d[k] = v.serialise()
787             else:
788                 d[k] = v
789         return d
791     def unserialise(self, classname, node):
792         '''Decode the marshalled node data
793         '''
794         if __debug__:
795             print >>hyperdb.DEBUG, 'unserialise', classname, node
796         properties = self.getclass(classname).getprops()
797         d = {}
798         for k, v in node.items():
799             # if the property doesn't exist, or is the "retired" flag then
800             # it won't be in the properties dict
801             if not properties.has_key(k):
802                 d[k] = v
803                 continue
805             # get the property spec
806             prop = properties[k]
808             if isinstance(prop, Date) and v is not None:
809                 d[k] = date.Date(v)
810             elif isinstance(prop, Interval) and v is not None:
811                 d[k] = date.Interval(v)
812             elif isinstance(prop, Password) and v is not None:
813                 p = password.Password()
814                 p.unpack(v)
815                 d[k] = p
816             elif isinstance(prop, Boolean) and v is not None:
817                 d[k] = int(v)
818             elif isinstance(prop, Number) and v is not None:
819                 # try int first, then assume it's a float
820                 try:
821                     d[k] = int(v)
822                 except ValueError:
823                     d[k] = float(v)
824             else:
825                 d[k] = v
826         return d
828     def hasnode(self, classname, nodeid):
829         ''' Determine if the database has a given node.
830         '''
831         sql = 'select count(*) from _%s where id=%s'%(classname, self.arg)
832         if __debug__:
833             print >>hyperdb.DEBUG, 'hasnode', (self, sql, nodeid)
834         self.cursor.execute(sql, (nodeid,))
835         return int(self.cursor.fetchone()[0])
837     def countnodes(self, classname):
838         ''' Count the number of nodes that exist for a particular Class.
839         '''
840         sql = 'select count(*) from _%s'%classname
841         if __debug__:
842             print >>hyperdb.DEBUG, 'countnodes', (self, sql)
843         self.cursor.execute(sql)
844         return self.cursor.fetchone()[0]
846     def addjournal(self, classname, nodeid, action, params, creator=None,
847             creation=None):
848         ''' Journal the Action
849         'action' may be:
851             'create' or 'set' -- 'params' is a dictionary of property values
852             'link' or 'unlink' -- 'params' is (classname, nodeid, propname)
853             'retire' -- 'params' is None
854         '''
855         # serialise the parameters now if necessary
856         if isinstance(params, type({})):
857             if action in ('set', 'create'):
858                 params = self.serialise(classname, params)
860         # handle supply of the special journalling parameters (usually
861         # supplied on importing an existing database)
862         if creator:
863             journaltag = creator
864         else:
865             journaltag = self.getuid()
866         if creation:
867             journaldate = creation.serialise()
868         else:
869             journaldate = date.Date().serialise()
871         # create the journal entry
872         cols = ','.join('nodeid date tag action params'.split())
874         if __debug__:
875             print >>hyperdb.DEBUG, 'addjournal', (nodeid, journaldate,
876                 journaltag, action, params)
878         self.save_journal(classname, cols, nodeid, journaldate,
879             journaltag, action, params)
881     def getjournal(self, classname, nodeid):
882         ''' get the journal for id
883         '''
884         # make sure the node exists
885         if not self.hasnode(classname, nodeid):
886             raise IndexError, '%s has no node %s'%(classname, nodeid)
888         cols = ','.join('nodeid date tag action params'.split())
889         return self.load_journal(classname, cols, nodeid)
891     def save_journal(self, classname, cols, nodeid, journaldate,
892             journaltag, action, params):
893         ''' Save the journal entry to the database
894         '''
895         # make the params db-friendly
896         params = repr(params)
897         entry = (nodeid, journaldate, journaltag, action, params)
899         # do the insert
900         a = self.arg
901         sql = 'insert into %s__journal (%s) values (%s,%s,%s,%s,%s)'%(classname,
902             cols, a, a, a, a, a)
903         if __debug__:
904             print >>hyperdb.DEBUG, 'addjournal', (self, sql, entry)
905         self.cursor.execute(sql, entry)
907     def load_journal(self, classname, cols, nodeid):
908         ''' Load the journal from the database
909         '''
910         # now get the journal entries
911         sql = 'select %s from %s__journal where nodeid=%s'%(cols, classname,
912             self.arg)
913         if __debug__:
914             print >>hyperdb.DEBUG, 'load_journal', (self, sql, nodeid)
915         self.cursor.execute(sql, (nodeid,))
916         res = []
917         for nodeid, date_stamp, user, action, params in self.cursor.fetchall():
918             params = eval(params)
919             res.append((nodeid, date.Date(date_stamp), user, action, params))
920         return res
922     def pack(self, pack_before):
923         ''' Delete all journal entries except "create" before 'pack_before'.
924         '''
925         # get a 'yyyymmddhhmmss' version of the date
926         date_stamp = pack_before.serialise()
928         # do the delete
929         for classname in self.classes.keys():
930             sql = "delete from %s__journal where date<%s and "\
931                 "action<>'create'"%(classname, self.arg)
932             if __debug__:
933                 print >>hyperdb.DEBUG, 'pack', (self, sql, date_stamp)
934             self.cursor.execute(sql, (date_stamp,))
936     def sql_commit(self):
937         ''' Actually commit to the database.
938         '''
939         self.conn.commit()
941     def commit(self):
942         ''' Commit the current transactions.
944         Save all data changed since the database was opened or since the
945         last commit() or rollback().
946         '''
947         if __debug__:
948             print >>hyperdb.DEBUG, 'commit', (self,)
950         # commit the database
951         self.sql_commit()
953         # now, do all the other transaction stuff
954         reindex = {}
955         for method, args in self.transactions:
956             reindex[method(*args)] = 1
958         # reindex the nodes that request it
959         for classname, nodeid in filter(None, reindex.keys()):
960             print >>hyperdb.DEBUG, 'commit.reindex', (classname, nodeid)
961             self.getclass(classname).index(nodeid)
963         # save the indexer state
964         self.indexer.save_index()
966         # clear out the transactions
967         self.transactions = []
969     def sql_rollback(self):
970         self.conn.rollback()
972     def rollback(self):
973         ''' Reverse all actions from the current transaction.
975         Undo all the changes made since the database was opened or the last
976         commit() or rollback() was performed.
977         '''
978         if __debug__:
979             print >>hyperdb.DEBUG, 'rollback', (self,)
981         self.sql_rollback()
983         # roll back "other" transaction stuff
984         for method, args in self.transactions:
985             # delete temporary files
986             if method == self.doStoreFile:
987                 self.rollbackStoreFile(*args)
988         self.transactions = []
990         # clear the cache
991         self.clearCache()
993     def doSaveNode(self, classname, nodeid, node):
994         ''' dummy that just generates a reindex event
995         '''
996         # return the classname, nodeid so we reindex this content
997         return (classname, nodeid)
999     def sql_close(self):
1000         self.conn.close()
1002     def close(self):
1003         ''' Close off the connection.
1004         '''
1005         self.sql_close()
1006         if self.lockfile is not None:
1007             locking.release_lock(self.lockfile)
1008         if self.lockfile is not None:
1009             self.lockfile.close()
1010             self.lockfile = None
1013 # The base Class class
1015 class Class(hyperdb.Class):
1016     ''' The handle to a particular class of nodes in a hyperdatabase.
1017         
1018         All methods except __repr__ and getnode must be implemented by a
1019         concrete backend Class.
1020     '''
1022     def __init__(self, db, classname, **properties):
1023         '''Create a new class with a given name and property specification.
1025         'classname' must not collide with the name of an existing class,
1026         or a ValueError is raised.  The keyword arguments in 'properties'
1027         must map names to property objects, or a TypeError is raised.
1028         '''
1029         if (properties.has_key('creation') or properties.has_key('activity')
1030                 or properties.has_key('creator')):
1031             raise ValueError, '"creation", "activity" and "creator" are '\
1032                 'reserved'
1034         self.classname = classname
1035         self.properties = properties
1036         self.db = weakref.proxy(db)       # use a weak ref to avoid circularity
1037         self.key = ''
1039         # should we journal changes (default yes)
1040         self.do_journal = 1
1042         # do the db-related init stuff
1043         db.addclass(self)
1045         self.auditors = {'create': [], 'set': [], 'retire': [], 'restore': []}
1046         self.reactors = {'create': [], 'set': [], 'retire': [], 'restore': []}
1048     def schema(self):
1049         ''' A dumpable version of the schema that we can store in the
1050             database
1051         '''
1052         return (self.key, [(x, repr(y)) for x,y in self.properties.items()])
1054     def enableJournalling(self):
1055         '''Turn journalling on for this class
1056         '''
1057         self.do_journal = 1
1059     def disableJournalling(self):
1060         '''Turn journalling off for this class
1061         '''
1062         self.do_journal = 0
1064     # Editing nodes:
1065     def create(self, **propvalues):
1066         ''' Create a new node of this class and return its id.
1068         The keyword arguments in 'propvalues' map property names to values.
1070         The values of arguments must be acceptable for the types of their
1071         corresponding properties or a TypeError is raised.
1072         
1073         If this class has a key property, it must be present and its value
1074         must not collide with other key strings or a ValueError is raised.
1075         
1076         Any other properties on this class that are missing from the
1077         'propvalues' dictionary are set to None.
1078         
1079         If an id in a link or multilink property does not refer to a valid
1080         node, an IndexError is raised.
1081         '''
1082         self.fireAuditors('create', None, propvalues)
1083         newid = self.create_inner(**propvalues)
1084         self.fireReactors('create', newid, None)
1085         return newid
1086     
1087     def create_inner(self, **propvalues):
1088         ''' Called by create, in-between the audit and react calls.
1089         '''
1090         if propvalues.has_key('id'):
1091             raise KeyError, '"id" is reserved'
1093         if self.db.journaltag is None:
1094             raise DatabaseError, 'Database open read-only'
1096         if propvalues.has_key('creation') or propvalues.has_key('activity'):
1097             raise KeyError, '"creation" and "activity" are reserved'
1099         # new node's id
1100         newid = self.db.newid(self.classname)
1102         # validate propvalues
1103         num_re = re.compile('^\d+$')
1104         for key, value in propvalues.items():
1105             if key == self.key:
1106                 try:
1107                     self.lookup(value)
1108                 except KeyError:
1109                     pass
1110                 else:
1111                     raise ValueError, 'node with key "%s" exists'%value
1113             # try to handle this property
1114             try:
1115                 prop = self.properties[key]
1116             except KeyError:
1117                 raise KeyError, '"%s" has no property "%s"'%(self.classname,
1118                     key)
1120             if value is not None and isinstance(prop, Link):
1121                 if type(value) != type(''):
1122                     raise ValueError, 'link value must be String'
1123                 link_class = self.properties[key].classname
1124                 # if it isn't a number, it's a key
1125                 if not num_re.match(value):
1126                     try:
1127                         value = self.db.classes[link_class].lookup(value)
1128                     except (TypeError, KeyError):
1129                         raise IndexError, 'new property "%s": %s not a %s'%(
1130                             key, value, link_class)
1131                 elif not self.db.getclass(link_class).hasnode(value):
1132                     raise IndexError, '%s has no node %s'%(link_class, value)
1134                 # save off the value
1135                 propvalues[key] = value
1137                 # register the link with the newly linked node
1138                 if self.do_journal and self.properties[key].do_journal:
1139                     self.db.addjournal(link_class, value, 'link',
1140                         (self.classname, newid, key))
1142             elif isinstance(prop, Multilink):
1143                 if type(value) != type([]):
1144                     raise TypeError, 'new property "%s" not a list of ids'%key
1146                 # clean up and validate the list of links
1147                 link_class = self.properties[key].classname
1148                 l = []
1149                 for entry in value:
1150                     if type(entry) != type(''):
1151                         raise ValueError, '"%s" multilink value (%r) '\
1152                             'must contain Strings'%(key, value)
1153                     # if it isn't a number, it's a key
1154                     if not num_re.match(entry):
1155                         try:
1156                             entry = self.db.classes[link_class].lookup(entry)
1157                         except (TypeError, KeyError):
1158                             raise IndexError, 'new property "%s": %s not a %s'%(
1159                                 key, entry, self.properties[key].classname)
1160                     l.append(entry)
1161                 value = l
1162                 propvalues[key] = value
1164                 # handle additions
1165                 for nodeid in value:
1166                     if not self.db.getclass(link_class).hasnode(nodeid):
1167                         raise IndexError, '%s has no node %s'%(link_class,
1168                             nodeid)
1169                     # register the link with the newly linked node
1170                     if self.do_journal and self.properties[key].do_journal:
1171                         self.db.addjournal(link_class, nodeid, 'link',
1172                             (self.classname, newid, key))
1174             elif isinstance(prop, String):
1175                 if type(value) != type('') and type(value) != type(u''):
1176                     raise TypeError, 'new property "%s" not a string'%key
1178             elif isinstance(prop, Password):
1179                 if not isinstance(value, password.Password):
1180                     raise TypeError, 'new property "%s" not a Password'%key
1182             elif isinstance(prop, Date):
1183                 if value is not None and not isinstance(value, date.Date):
1184                     raise TypeError, 'new property "%s" not a Date'%key
1186             elif isinstance(prop, Interval):
1187                 if value is not None and not isinstance(value, date.Interval):
1188                     raise TypeError, 'new property "%s" not an Interval'%key
1190             elif value is not None and isinstance(prop, Number):
1191                 try:
1192                     float(value)
1193                 except ValueError:
1194                     raise TypeError, 'new property "%s" not numeric'%key
1196             elif value is not None and isinstance(prop, Boolean):
1197                 try:
1198                     int(value)
1199                 except ValueError:
1200                     raise TypeError, 'new property "%s" not boolean'%key
1202         # make sure there's data where there needs to be
1203         for key, prop in self.properties.items():
1204             if propvalues.has_key(key):
1205                 continue
1206             if key == self.key:
1207                 raise ValueError, 'key property "%s" is required'%key
1208             if isinstance(prop, Multilink):
1209                 propvalues[key] = []
1210             else:
1211                 propvalues[key] = None
1213         # done
1214         self.db.addnode(self.classname, newid, propvalues)
1215         if self.do_journal:
1216             self.db.addjournal(self.classname, newid, 'create', {})
1218         return newid
1220     def export_list(self, propnames, nodeid):
1221         ''' Export a node - generate a list of CSV-able data in the order
1222             specified by propnames for the given node.
1223         '''
1224         properties = self.getprops()
1225         l = []
1226         for prop in propnames:
1227             proptype = properties[prop]
1228             value = self.get(nodeid, prop)
1229             # "marshal" data where needed
1230             if value is None:
1231                 pass
1232             elif isinstance(proptype, hyperdb.Date):
1233                 value = value.get_tuple()
1234             elif isinstance(proptype, hyperdb.Interval):
1235                 value = value.get_tuple()
1236             elif isinstance(proptype, hyperdb.Password):
1237                 value = str(value)
1238             l.append(repr(value))
1239         l.append(repr(self.is_retired(nodeid)))
1240         return l
1242     def import_list(self, propnames, proplist):
1243         ''' Import a node - all information including "id" is present and
1244             should not be sanity checked. Triggers are not triggered. The
1245             journal should be initialised using the "creator" and "created"
1246             information.
1248             Return the nodeid of the node imported.
1249         '''
1250         if self.db.journaltag is None:
1251             raise DatabaseError, 'Database open read-only'
1252         properties = self.getprops()
1254         # make the new node's property map
1255         d = {}
1256         retire = 0
1257         newid = None
1258         for i in range(len(propnames)):
1259             # Use eval to reverse the repr() used to output the CSV
1260             value = eval(proplist[i])
1262             # Figure the property for this column
1263             propname = propnames[i]
1265             # "unmarshal" where necessary
1266             if propname == 'id':
1267                 newid = value
1268                 continue
1269             elif propname == 'is retired':
1270                 # is the item retired?
1271                 if int(value):
1272                     retire = 1
1273                 continue
1274             elif value is None:
1275                 d[propname] = None
1276                 continue
1278             prop = properties[propname]
1279             if value is None:
1280                 # don't set Nones
1281                 continue
1282             elif isinstance(prop, hyperdb.Date):
1283                 value = date.Date(value)
1284             elif isinstance(prop, hyperdb.Interval):
1285                 value = date.Interval(value)
1286             elif isinstance(prop, hyperdb.Password):
1287                 pwd = password.Password()
1288                 pwd.unpack(value)
1289                 value = pwd
1290             d[propname] = value
1292         # get a new id if necessary
1293         if newid is None:
1294             newid = self.db.newid(self.classname)
1296         # add the node and journal
1297         self.db.addnode(self.classname, newid, d)
1299         # retire?
1300         if retire:
1301             # use the arg for __retired__ to cope with any odd database type
1302             # conversion (hello, sqlite)
1303             sql = 'update _%s set __retired__=%s where id=%s'%(self.classname,
1304                 self.db.arg, self.db.arg)
1305             if __debug__:
1306                 print >>hyperdb.DEBUG, 'retire', (self, sql, newid)
1307             self.db.cursor.execute(sql, (1, newid))
1309         # extract the extraneous journalling gumpf and nuke it
1310         if d.has_key('creator'):
1311             creator = d['creator']
1312             del d['creator']
1313         else:
1314             creator = None
1315         if d.has_key('creation'):
1316             creation = d['creation']
1317             del d['creation']
1318         else:
1319             creation = None
1320         if d.has_key('activity'):
1321             del d['activity']
1322         self.db.addjournal(self.classname, newid, 'create', {}, creator,
1323             creation)
1324         return newid
1326     _marker = []
1327     def get(self, nodeid, propname, default=_marker, cache=1):
1328         '''Get the value of a property on an existing node of this class.
1330         'nodeid' must be the id of an existing node of this class or an
1331         IndexError is raised.  'propname' must be the name of a property
1332         of this class or a KeyError is raised.
1334         'cache' exists for backwards compatibility, and is not used.
1335         '''
1336         if propname == 'id':
1337             return nodeid
1339         # get the node's dict
1340         d = self.db.getnode(self.classname, nodeid)
1342         if propname == 'creation':
1343             if d.has_key('creation'):
1344                 return d['creation']
1345             else:
1346                 return date.Date()
1347         if propname == 'activity':
1348             if d.has_key('activity'):
1349                 return d['activity']
1350             else:
1351                 return date.Date()
1352         if propname == 'creator':
1353             if d.has_key('creator'):
1354                 return d['creator']
1355             else:
1356                 return self.db.getuid()
1358         # get the property (raises KeyErorr if invalid)
1359         prop = self.properties[propname]
1361         if not d.has_key(propname):
1362             if default is self._marker:
1363                 if isinstance(prop, Multilink):
1364                     return []
1365                 else:
1366                     return None
1367             else:
1368                 return default
1370         # don't pass our list to other code
1371         if isinstance(prop, Multilink):
1372             return d[propname][:]
1374         return d[propname]
1376     def set(self, nodeid, **propvalues):
1377         '''Modify a property on an existing node of this class.
1378         
1379         'nodeid' must be the id of an existing node of this class or an
1380         IndexError is raised.
1382         Each key in 'propvalues' must be the name of a property of this
1383         class or a KeyError is raised.
1385         All values in 'propvalues' must be acceptable types for their
1386         corresponding properties or a TypeError is raised.
1388         If the value of the key property is set, it must not collide with
1389         other key strings or a ValueError is raised.
1391         If the value of a Link or Multilink property contains an invalid
1392         node id, a ValueError is raised.
1393         '''
1394         if not propvalues:
1395             return propvalues
1397         if propvalues.has_key('creation') or propvalues.has_key('activity'):
1398             raise KeyError, '"creation" and "activity" are reserved'
1400         if propvalues.has_key('id'):
1401             raise KeyError, '"id" is reserved'
1403         if self.db.journaltag is None:
1404             raise DatabaseError, 'Database open read-only'
1406         self.fireAuditors('set', nodeid, propvalues)
1407         # Take a copy of the node dict so that the subsequent set
1408         # operation doesn't modify the oldvalues structure.
1409         # XXX used to try the cache here first
1410         oldvalues = copy.deepcopy(self.db.getnode(self.classname, nodeid))
1412         node = self.db.getnode(self.classname, nodeid)
1413         if self.is_retired(nodeid):
1414             raise IndexError, 'Requested item is retired'
1415         num_re = re.compile('^\d+$')
1417         # if the journal value is to be different, store it in here
1418         journalvalues = {}
1420         # remember the add/remove stuff for multilinks, making it easier
1421         # for the Database layer to do its stuff
1422         multilink_changes = {}
1424         for propname, value in propvalues.items():
1425             # check to make sure we're not duplicating an existing key
1426             if propname == self.key and node[propname] != value:
1427                 try:
1428                     self.lookup(value)
1429                 except KeyError:
1430                     pass
1431                 else:
1432                     raise ValueError, 'node with key "%s" exists'%value
1434             # this will raise the KeyError if the property isn't valid
1435             # ... we don't use getprops() here because we only care about
1436             # the writeable properties.
1437             try:
1438                 prop = self.properties[propname]
1439             except KeyError:
1440                 raise KeyError, '"%s" has no property named "%s"'%(
1441                     self.classname, propname)
1443             # if the value's the same as the existing value, no sense in
1444             # doing anything
1445             current = node.get(propname, None)
1446             if value == current:
1447                 del propvalues[propname]
1448                 continue
1449             journalvalues[propname] = current
1451             # do stuff based on the prop type
1452             if isinstance(prop, Link):
1453                 link_class = prop.classname
1454                 # if it isn't a number, it's a key
1455                 if value is not None and not isinstance(value, type('')):
1456                     raise ValueError, 'property "%s" link value be a string'%(
1457                         propname)
1458                 if isinstance(value, type('')) and not num_re.match(value):
1459                     try:
1460                         value = self.db.classes[link_class].lookup(value)
1461                     except (TypeError, KeyError):
1462                         raise IndexError, 'new property "%s": %s not a %s'%(
1463                             propname, value, prop.classname)
1465                 if (value is not None and
1466                         not self.db.getclass(link_class).hasnode(value)):
1467                     raise IndexError, '%s has no node %s'%(link_class, value)
1469                 if self.do_journal and prop.do_journal:
1470                     # register the unlink with the old linked node
1471                     if node[propname] is not None:
1472                         self.db.addjournal(link_class, node[propname], 'unlink',
1473                             (self.classname, nodeid, propname))
1475                     # register the link with the newly linked node
1476                     if value is not None:
1477                         self.db.addjournal(link_class, value, 'link',
1478                             (self.classname, nodeid, propname))
1480             elif isinstance(prop, Multilink):
1481                 if type(value) != type([]):
1482                     raise TypeError, 'new property "%s" not a list of'\
1483                         ' ids'%propname
1484                 link_class = self.properties[propname].classname
1485                 l = []
1486                 for entry in value:
1487                     # if it isn't a number, it's a key
1488                     if type(entry) != type(''):
1489                         raise ValueError, 'new property "%s" link value ' \
1490                             'must be a string'%propname
1491                     if not num_re.match(entry):
1492                         try:
1493                             entry = self.db.classes[link_class].lookup(entry)
1494                         except (TypeError, KeyError):
1495                             raise IndexError, 'new property "%s": %s not a %s'%(
1496                                 propname, entry,
1497                                 self.properties[propname].classname)
1498                     l.append(entry)
1499                 value = l
1500                 propvalues[propname] = value
1502                 # figure the journal entry for this property
1503                 add = []
1504                 remove = []
1506                 # handle removals
1507                 if node.has_key(propname):
1508                     l = node[propname]
1509                 else:
1510                     l = []
1511                 for id in l[:]:
1512                     if id in value:
1513                         continue
1514                     # register the unlink with the old linked node
1515                     if self.do_journal and self.properties[propname].do_journal:
1516                         self.db.addjournal(link_class, id, 'unlink',
1517                             (self.classname, nodeid, propname))
1518                     l.remove(id)
1519                     remove.append(id)
1521                 # handle additions
1522                 for id in value:
1523                     if not self.db.getclass(link_class).hasnode(id):
1524                         raise IndexError, '%s has no node %s'%(link_class, id)
1525                     if id in l:
1526                         continue
1527                     # register the link with the newly linked node
1528                     if self.do_journal and self.properties[propname].do_journal:
1529                         self.db.addjournal(link_class, id, 'link',
1530                             (self.classname, nodeid, propname))
1531                     l.append(id)
1532                     add.append(id)
1534                 # figure the journal entry
1535                 l = []
1536                 if add:
1537                     l.append(('+', add))
1538                 if remove:
1539                     l.append(('-', remove))
1540                 multilink_changes[propname] = (add, remove)
1541                 if l:
1542                     journalvalues[propname] = tuple(l)
1544             elif isinstance(prop, String):
1545                 if value is not None and type(value) != type('') and type(value) != type(u''):
1546                     raise TypeError, 'new property "%s" not a string'%propname
1548             elif isinstance(prop, Password):
1549                 if not isinstance(value, password.Password):
1550                     raise TypeError, 'new property "%s" not a Password'%propname
1551                 propvalues[propname] = value
1553             elif value is not None and isinstance(prop, Date):
1554                 if not isinstance(value, date.Date):
1555                     raise TypeError, 'new property "%s" not a Date'% propname
1556                 propvalues[propname] = value
1558             elif value is not None and isinstance(prop, Interval):
1559                 if not isinstance(value, date.Interval):
1560                     raise TypeError, 'new property "%s" not an '\
1561                         'Interval'%propname
1562                 propvalues[propname] = value
1564             elif value is not None and isinstance(prop, Number):
1565                 try:
1566                     float(value)
1567                 except ValueError:
1568                     raise TypeError, 'new property "%s" not numeric'%propname
1570             elif value is not None and isinstance(prop, Boolean):
1571                 try:
1572                     int(value)
1573                 except ValueError:
1574                     raise TypeError, 'new property "%s" not boolean'%propname
1576         # nothing to do?
1577         if not propvalues:
1578             return propvalues
1580         # do the set, and journal it
1581         self.db.setnode(self.classname, nodeid, propvalues, multilink_changes)
1583         if self.do_journal:
1584             self.db.addjournal(self.classname, nodeid, 'set', journalvalues)
1586         self.fireReactors('set', nodeid, oldvalues)
1588         return propvalues        
1590     def retire(self, nodeid):
1591         '''Retire a node.
1592         
1593         The properties on the node remain available from the get() method,
1594         and the node's id is never reused.
1595         
1596         Retired nodes are not returned by the find(), list(), or lookup()
1597         methods, and other nodes may reuse the values of their key properties.
1598         '''
1599         if self.db.journaltag is None:
1600             raise DatabaseError, 'Database open read-only'
1602         self.fireAuditors('retire', nodeid, None)
1604         # use the arg for __retired__ to cope with any odd database type
1605         # conversion (hello, sqlite)
1606         sql = 'update _%s set __retired__=%s where id=%s'%(self.classname,
1607             self.db.arg, self.db.arg)
1608         if __debug__:
1609             print >>hyperdb.DEBUG, 'retire', (self, sql, nodeid)
1610         self.db.cursor.execute(sql, (1, nodeid))
1611         if self.do_journal:
1612             self.db.addjournal(self.classname, nodeid, 'retired', None)
1614         self.fireReactors('retire', nodeid, None)
1616     def restore(self, nodeid):
1617         '''Restore a retired node.
1619         Make node available for all operations like it was before retirement.
1620         '''
1621         if self.db.journaltag is None:
1622             raise DatabaseError, 'Database open read-only'
1624         node = self.db.getnode(self.classname, nodeid)
1625         # check if key property was overrided
1626         key = self.getkey()
1627         try:
1628             id = self.lookup(node[key])
1629         except KeyError:
1630             pass
1631         else:
1632             raise KeyError, "Key property (%s) of retired node clashes with \
1633                 existing one (%s)" % (key, node[key])
1635         self.fireAuditors('restore', nodeid, None)
1636         # use the arg for __retired__ to cope with any odd database type
1637         # conversion (hello, sqlite)
1638         sql = 'update _%s set __retired__=%s where id=%s'%(self.classname,
1639             self.db.arg, self.db.arg)
1640         if __debug__:
1641             print >>hyperdb.DEBUG, 'restore', (self, sql, nodeid)
1642         self.db.cursor.execute(sql, (0, nodeid))
1643         if self.do_journal:
1644             self.db.addjournal(self.classname, nodeid, 'restored', None)
1646         self.fireReactors('restore', nodeid, None)
1647         
1648     def is_retired(self, nodeid):
1649         '''Return true if the node is rerired
1650         '''
1651         sql = 'select __retired__ from _%s where id=%s'%(self.classname,
1652             self.db.arg)
1653         if __debug__:
1654             print >>hyperdb.DEBUG, 'is_retired', (self, sql, nodeid)
1655         self.db.cursor.execute(sql, (nodeid,))
1656         return int(self.db.sql_fetchone()[0])
1658     def destroy(self, nodeid):
1659         '''Destroy a node.
1660         
1661         WARNING: this method should never be used except in extremely rare
1662                  situations where there could never be links to the node being
1663                  deleted
1664         WARNING: use retire() instead
1665         WARNING: the properties of this node will not be available ever again
1666         WARNING: really, use retire() instead
1668         Well, I think that's enough warnings. This method exists mostly to
1669         support the session storage of the cgi interface.
1671         The node is completely removed from the hyperdb, including all journal
1672         entries. It will no longer be available, and will generally break code
1673         if there are any references to the node.
1674         '''
1675         if self.db.journaltag is None:
1676             raise DatabaseError, 'Database open read-only'
1677         self.db.destroynode(self.classname, nodeid)
1679     def history(self, nodeid):
1680         '''Retrieve the journal of edits on a particular node.
1682         'nodeid' must be the id of an existing node of this class or an
1683         IndexError is raised.
1685         The returned list contains tuples of the form
1687             (nodeid, date, tag, action, params)
1689         'date' is a Timestamp object specifying the time of the change and
1690         'tag' is the journaltag specified when the database was opened.
1691         '''
1692         if not self.do_journal:
1693             raise ValueError, 'Journalling is disabled for this class'
1694         return self.db.getjournal(self.classname, nodeid)
1696     # Locating nodes:
1697     def hasnode(self, nodeid):
1698         '''Determine if the given nodeid actually exists
1699         '''
1700         return self.db.hasnode(self.classname, nodeid)
1702     def setkey(self, propname):
1703         '''Select a String property of this class to be the key property.
1705         'propname' must be the name of a String property of this class or
1706         None, or a TypeError is raised.  The values of the key property on
1707         all existing nodes must be unique or a ValueError is raised.
1708         '''
1709         # XXX create an index on the key prop column. We should also 
1710         # record that we've created this index in the schema somewhere.
1711         prop = self.getprops()[propname]
1712         if not isinstance(prop, String):
1713             raise TypeError, 'key properties must be String'
1714         self.key = propname
1716     def getkey(self):
1717         '''Return the name of the key property for this class or None.'''
1718         return self.key
1720     def labelprop(self, default_to_id=0):
1721         ''' Return the property name for a label for the given node.
1723         This method attempts to generate a consistent label for the node.
1724         It tries the following in order:
1725             1. key property
1726             2. "name" property
1727             3. "title" property
1728             4. first property from the sorted property name list
1729         '''
1730         k = self.getkey()
1731         if  k:
1732             return k
1733         props = self.getprops()
1734         if props.has_key('name'):
1735             return 'name'
1736         elif props.has_key('title'):
1737             return 'title'
1738         if default_to_id:
1739             return 'id'
1740         props = props.keys()
1741         props.sort()
1742         return props[0]
1744     def lookup(self, keyvalue):
1745         '''Locate a particular node by its key property and return its id.
1747         If this class has no key property, a TypeError is raised.  If the
1748         'keyvalue' matches one of the values for the key property among
1749         the nodes in this class, the matching node's id is returned;
1750         otherwise a KeyError is raised.
1751         '''
1752         if not self.key:
1753             raise TypeError, 'No key property set for class %s'%self.classname
1755         # use the arg to handle any odd database type conversion (hello,
1756         # sqlite)
1757         sql = "select id from _%s where _%s=%s and __retired__ <> %s"%(
1758             self.classname, self.key, self.db.arg, self.db.arg)
1759         self.db.sql(sql, (keyvalue, 1))
1761         # see if there was a result that's not retired
1762         row = self.db.sql_fetchone()
1763         if not row:
1764             raise KeyError, 'No key (%s) value "%s" for "%s"'%(self.key,
1765                 keyvalue, self.classname)
1767         # return the id
1768         return row[0]
1770     def find(self, **propspec):
1771         '''Get the ids of nodes in this class which link to the given nodes.
1773         'propspec' consists of keyword args propname=nodeid or
1774                    propname={nodeid:1, }
1775         'propname' must be the name of a property in this class, or a
1776                    KeyError is raised.  That property must be a Link or
1777                    Multilink property, or a TypeError is raised.
1779         Any node in this class whose 'propname' property links to any of the
1780         nodeids will be returned. Used by the full text indexing, which knows
1781         that "foo" occurs in msg1, msg3 and file7, so we have hits on these
1782         issues:
1784             db.issue.find(messages={'1':1,'3':1}, files={'7':1})
1785         '''
1786         if __debug__:
1787             print >>hyperdb.DEBUG, 'find', (self, propspec)
1789         # shortcut
1790         if not propspec:
1791             return []
1793         # validate the args
1794         props = self.getprops()
1795         propspec = propspec.items()
1796         for propname, nodeids in propspec:
1797             # check the prop is OK
1798             prop = props[propname]
1799             if not isinstance(prop, Link) and not isinstance(prop, Multilink):
1800                 raise TypeError, "'%s' not a Link/Multilink property"%propname
1802         # first, links
1803         where = []
1804         allvalues = ()
1805         a = self.db.arg
1806         for prop, values in propspec:
1807             if not isinstance(props[prop], hyperdb.Link):
1808                 continue
1809             if type(values) is type({}) and len(values) == 1:
1810                 values = values.keys()[0]
1811             if type(values) is type(''):
1812                 allvalues += (values,)
1813                 where.append('_%s = %s'%(prop, a))
1814             elif values is None:
1815                 where.append('_%s is NULL'%prop)
1816             else:
1817                 allvalues += tuple(values.keys())
1818                 where.append('_%s in (%s)'%(prop, ','.join([a]*len(values))))
1819         tables = []
1820         if where:
1821             tables.append('select id as nodeid from _%s where %s'%(
1822                 self.classname, ' and '.join(where)))
1824         # now multilinks
1825         for prop, values in propspec:
1826             if not isinstance(props[prop], hyperdb.Multilink):
1827                 continue
1828             if type(values) is type(''):
1829                 allvalues += (values,)
1830                 s = a
1831             else:
1832                 allvalues += tuple(values.keys())
1833                 s = ','.join([a]*len(values))
1834             tables.append('select nodeid from %s_%s where linkid in (%s)'%(
1835                 self.classname, prop, s))
1836         sql = '\nunion\n'.join(tables)
1837         self.db.sql(sql, allvalues)
1838         l = [x[0] for x in self.db.sql_fetchall()]
1839         if __debug__:
1840             print >>hyperdb.DEBUG, 'find ... ', l
1841         return l
1843     def stringFind(self, **requirements):
1844         '''Locate a particular node by matching a set of its String
1845         properties in a caseless search.
1847         If the property is not a String property, a TypeError is raised.
1848         
1849         The return is a list of the id of all nodes that match.
1850         '''
1851         where = []
1852         args = []
1853         for propname in requirements.keys():
1854             prop = self.properties[propname]
1855             if isinstance(not prop, String):
1856                 raise TypeError, "'%s' not a String property"%propname
1857             where.append(propname)
1858             args.append(requirements[propname].lower())
1860         # generate the where clause
1861         s = ' and '.join(['lower(_%s)=%s'%(col, self.db.arg) for col in where])
1862         sql = 'select id from _%s where %s'%(self.classname, s)
1863         self.db.sql(sql, tuple(args))
1864         l = [x[0] for x in self.db.sql_fetchall()]
1865         if __debug__:
1866             print >>hyperdb.DEBUG, 'find ... ', l
1867         return l
1869     def list(self):
1870         ''' Return a list of the ids of the active nodes in this class.
1871         '''
1872         return self.getnodeids(retired=0)
1874     def getnodeids(self, retired=None):
1875         ''' Retrieve all the ids of the nodes for a particular Class.
1877             Set retired=None to get all nodes. Otherwise it'll get all the 
1878             retired or non-retired nodes, depending on the flag.
1879         '''
1880         # flip the sense of the 'retired' flag if we don't want all of them
1881         if retired is not None:
1882             if retired:
1883                 args = (0, )
1884             else:
1885                 args = (1, )
1886             sql = 'select id from _%s where __retired__ <> %s'%(self.classname,
1887                 self.db.arg)
1888         else:
1889             args = ()
1890             sql = 'select id from _%s'%self.classname
1891         if __debug__:
1892             print >>hyperdb.DEBUG, 'getnodeids', (self, sql, retired)
1893         self.db.cursor.execute(sql, args)
1894         ids = [x[0] for x in self.db.cursor.fetchall()]
1895         return ids
1897     def filter(self, search_matches, filterspec, sort=(None,None),
1898             group=(None,None)):
1899         ''' Return a list of the ids of the active nodes in this class that
1900             match the 'filter' spec, sorted by the group spec and then the
1901             sort spec
1903             "filterspec" is {propname: value(s)}
1904             "sort" and "group" are (dir, prop) where dir is '+', '-' or None
1905                                and prop is a prop name or None
1906             "search_matches" is {nodeid: marker}
1908             The filter must match all properties specificed - but if the
1909             property value to match is a list, any one of the values in the
1910             list may match for that property to match.
1911         '''
1912         # just don't bother if the full-text search matched diddly
1913         if search_matches == {}:
1914             return []
1916         cn = self.classname
1918         timezone = self.db.getUserTimezone()
1919         
1920         # figure the WHERE clause from the filterspec
1921         props = self.getprops()
1922         frum = ['_'+cn]
1923         where = []
1924         args = []
1925         a = self.db.arg
1926         for k, v in filterspec.items():
1927             propclass = props[k]
1928             # now do other where clause stuff
1929             if isinstance(propclass, Multilink):
1930                 tn = '%s_%s'%(cn, k)
1931                 if v in ('-1', ['-1']):
1932                     # only match rows that have count(linkid)=0 in the
1933                     # corresponding multilink table)
1934                     where.append('id not in (select nodeid from %s)'%tn)
1935                 elif isinstance(v, type([])):
1936                     frum.append(tn)
1937                     s = ','.join([a for x in v])
1938                     where.append('id=%s.nodeid and %s.linkid in (%s)'%(tn,tn,s))
1939                     args = args + v
1940                 else:
1941                     frum.append(tn)
1942                     where.append('id=%s.nodeid and %s.linkid=%s'%(tn, tn, a))
1943                     args.append(v)
1944             elif k == 'id':
1945                 if isinstance(v, type([])):
1946                     s = ','.join([a for x in v])
1947                     where.append('%s in (%s)'%(k, s))
1948                     args = args + v
1949                 else:
1950                     where.append('%s=%s'%(k, a))
1951                     args.append(v)
1952             elif isinstance(propclass, String):
1953                 if not isinstance(v, type([])):
1954                     v = [v]
1956                 # Quote the bits in the string that need it and then embed
1957                 # in a "substring" search. Note - need to quote the '%' so
1958                 # they make it through the python layer happily
1959                 v = ['%%'+self.db.sql_stringquote(s)+'%%' for s in v]
1961                 # now add to the where clause
1962                 where.append(' or '.join(["_%s LIKE '%s'"%(k, s) for s in v]))
1963                 # note: args are embedded in the query string now
1964             elif isinstance(propclass, Link):
1965                 if isinstance(v, type([])):
1966                     if '-1' in v:
1967                         v = v[:]
1968                         v.remove('-1')
1969                         xtra = ' or _%s is NULL'%k
1970                     else:
1971                         xtra = ''
1972                     if v:
1973                         s = ','.join([a for x in v])
1974                         where.append('(_%s in (%s)%s)'%(k, s, xtra))
1975                         args = args + v
1976                     else:
1977                         where.append('_%s is NULL'%k)
1978                 else:
1979                     if v == '-1':
1980                         v = None
1981                         where.append('_%s is NULL'%k)
1982                     else:
1983                         where.append('_%s=%s'%(k, a))
1984                         args.append(v)
1985             elif isinstance(propclass, Date):
1986                 if isinstance(v, type([])):
1987                     s = ','.join([a for x in v])
1988                     where.append('_%s in (%s)'%(k, s))
1989                     args = args + [date.Date(x).serialise() for x in v]
1990                 else:
1991                     try:
1992                         # Try to filter on range of dates
1993                         date_rng = Range(v, date.Date, offset=timezone)
1994                         if (date_rng.from_value):
1995                             where.append('_%s >= %s'%(k, a))                            
1996                             args.append(date_rng.from_value.serialise())
1997                         if (date_rng.to_value):
1998                             where.append('_%s <= %s'%(k, a))
1999                             args.append(date_rng.to_value.serialise())
2000                     except ValueError:
2001                         # If range creation fails - ignore that search parameter
2002                         pass                        
2003             elif isinstance(propclass, Interval):
2004                 if isinstance(v, type([])):
2005                     s = ','.join([a for x in v])
2006                     where.append('_%s in (%s)'%(k, s))
2007                     args = args + [date.Interval(x).serialise() for x in v]
2008                 else:
2009                     try:
2010                         # Try to filter on range of intervals
2011                         date_rng = Range(v, date.Interval)
2012                         if (date_rng.from_value):
2013                             where.append('_%s >= %s'%(k, a))
2014                             args.append(date_rng.from_value.serialise())
2015                         if (date_rng.to_value):
2016                             where.append('_%s <= %s'%(k, a))
2017                             args.append(date_rng.to_value.serialise())
2018                     except ValueError:
2019                         # If range creation fails - ignore that search parameter
2020                         pass                        
2021                     #where.append('_%s=%s'%(k, a))
2022                     #args.append(date.Interval(v).serialise())
2023             else:
2024                 if isinstance(v, type([])):
2025                     s = ','.join([a for x in v])
2026                     where.append('_%s in (%s)'%(k, s))
2027                     args = args + v
2028                 else:
2029                     where.append('_%s=%s'%(k, a))
2030                     args.append(v)
2032         # don't match retired nodes
2033         where.append('__retired__ <> 1')
2035         # add results of full text search
2036         if search_matches is not None:
2037             v = search_matches.keys()
2038             s = ','.join([a for x in v])
2039             where.append('id in (%s)'%s)
2040             args = args + v
2042         # "grouping" is just the first-order sorting in the SQL fetch
2043         # can modify it...)
2044         orderby = []
2045         ordercols = []
2046         if group[0] is not None and group[1] is not None:
2047             if group[0] != '-':
2048                 orderby.append('_'+group[1])
2049                 ordercols.append('_'+group[1])
2050             else:
2051                 orderby.append('_'+group[1]+' desc')
2052                 ordercols.append('_'+group[1])
2054         # now add in the sorting
2055         group = ''
2056         if sort[0] is not None and sort[1] is not None:
2057             direction, colname = sort
2058             if direction != '-':
2059                 if colname == 'id':
2060                     orderby.append(colname)
2061                 else:
2062                     orderby.append('_'+colname)
2063                     ordercols.append('_'+colname)
2064             else:
2065                 if colname == 'id':
2066                     orderby.append(colname+' desc')
2067                     ordercols.append(colname)
2068                 else:
2069                     orderby.append('_'+colname+' desc')
2070                     ordercols.append('_'+colname)
2072         # construct the SQL
2073         frum = ','.join(frum)
2074         if where:
2075             where = ' where ' + (' and '.join(where))
2076         else:
2077             where = ''
2078         cols = ['id']
2079         if orderby:
2080             cols = cols + ordercols
2081             order = ' order by %s'%(','.join(orderby))
2082         else:
2083             order = ''
2084         cols = ','.join(cols)
2085         sql = 'select %s from %s %s%s%s'%(cols, frum, where, group, order)
2086         args = tuple(args)
2087         if __debug__:
2088             print >>hyperdb.DEBUG, 'filter', (self, sql, args)
2089         if args:
2090             self.db.cursor.execute(sql, args)
2091         else:
2092             # psycopg doesn't like empty args
2093             self.db.cursor.execute(sql)
2094         l = self.db.sql_fetchall()
2096         # return the IDs (the first column)
2097         return [row[0] for row in l]
2099     def count(self):
2100         '''Get the number of nodes in this class.
2102         If the returned integer is 'numnodes', the ids of all the nodes
2103         in this class run from 1 to numnodes, and numnodes+1 will be the
2104         id of the next node to be created in this class.
2105         '''
2106         return self.db.countnodes(self.classname)
2108     # Manipulating properties:
2109     def getprops(self, protected=1):
2110         '''Return a dictionary mapping property names to property objects.
2111            If the "protected" flag is true, we include protected properties -
2112            those which may not be modified.
2113         '''
2114         d = self.properties.copy()
2115         if protected:
2116             d['id'] = String()
2117             d['creation'] = hyperdb.Date()
2118             d['activity'] = hyperdb.Date()
2119             d['creator'] = hyperdb.Link('user')
2120         return d
2122     def addprop(self, **properties):
2123         '''Add properties to this class.
2125         The keyword arguments in 'properties' must map names to property
2126         objects, or a TypeError is raised.  None of the keys in 'properties'
2127         may collide with the names of existing properties, or a ValueError
2128         is raised before any properties have been added.
2129         '''
2130         for key in properties.keys():
2131             if self.properties.has_key(key):
2132                 raise ValueError, key
2133         self.properties.update(properties)
2135     def index(self, nodeid):
2136         '''Add (or refresh) the node to search indexes
2137         '''
2138         # find all the String properties that have indexme
2139         for prop, propclass in self.getprops().items():
2140             if isinstance(propclass, String) and propclass.indexme:
2141                 try:
2142                     value = str(self.get(nodeid, prop))
2143                 except IndexError:
2144                     # node no longer exists - entry should be removed
2145                     self.db.indexer.purge_entry((self.classname, nodeid, prop))
2146                 else:
2147                     # and index them under (classname, nodeid, property)
2148                     self.db.indexer.add_text((self.classname, nodeid, prop),
2149                         value)
2152     #
2153     # Detector interface
2154     #
2155     def audit(self, event, detector):
2156         '''Register a detector
2157         '''
2158         l = self.auditors[event]
2159         if detector not in l:
2160             self.auditors[event].append(detector)
2162     def fireAuditors(self, action, nodeid, newvalues):
2163         '''Fire all registered auditors.
2164         '''
2165         for audit in self.auditors[action]:
2166             audit(self.db, self, nodeid, newvalues)
2168     def react(self, event, detector):
2169         '''Register a detector
2170         '''
2171         l = self.reactors[event]
2172         if detector not in l:
2173             self.reactors[event].append(detector)
2175     def fireReactors(self, action, nodeid, oldvalues):
2176         '''Fire all registered reactors.
2177         '''
2178         for react in self.reactors[action]:
2179             react(self.db, self, nodeid, oldvalues)
2181 class FileClass(Class, hyperdb.FileClass):
2182     '''This class defines a large chunk of data. To support this, it has a
2183        mandatory String property "content" which is typically saved off
2184        externally to the hyperdb.
2186        The default MIME type of this data is defined by the
2187        "default_mime_type" class attribute, which may be overridden by each
2188        node if the class defines a "type" String property.
2189     '''
2190     default_mime_type = 'text/plain'
2192     def create(self, **propvalues):
2193         ''' snaffle the file propvalue and store in a file
2194         '''
2195         # we need to fire the auditors now, or the content property won't
2196         # be in propvalues for the auditors to play with
2197         self.fireAuditors('create', None, propvalues)
2199         # now remove the content property so it's not stored in the db
2200         content = propvalues['content']
2201         del propvalues['content']
2203         # do the database create
2204         newid = Class.create_inner(self, **propvalues)
2206         # fire reactors
2207         self.fireReactors('create', newid, None)
2209         # store off the content as a file
2210         self.db.storefile(self.classname, newid, None, content)
2211         return newid
2213     def import_list(self, propnames, proplist):
2214         ''' Trap the "content" property...
2215         '''
2216         # dupe this list so we don't affect others
2217         propnames = propnames[:]
2219         # extract the "content" property from the proplist
2220         i = propnames.index('content')
2221         content = eval(proplist[i])
2222         del propnames[i]
2223         del proplist[i]
2225         # do the normal import
2226         newid = Class.import_list(self, propnames, proplist)
2228         # save off the "content" file
2229         self.db.storefile(self.classname, newid, None, content)
2230         return newid
2232     _marker = []
2233     def get(self, nodeid, propname, default=_marker, cache=1):
2234         ''' Trap the content propname and get it from the file
2236         'cache' exists for backwards compatibility, and is not used.
2237         '''
2238         poss_msg = 'Possibly a access right configuration problem.'
2239         if propname == 'content':
2240             try:
2241                 return self.db.getfile(self.classname, nodeid, None)
2242             except IOError, (strerror):
2243                 # BUG: by catching this we donot see an error in the log.
2244                 return 'ERROR reading file: %s%s\n%s\n%s'%(
2245                         self.classname, nodeid, poss_msg, strerror)
2246         if default is not self._marker:
2247             return Class.get(self, nodeid, propname, default)
2248         else:
2249             return Class.get(self, nodeid, propname)
2251     def getprops(self, protected=1):
2252         ''' In addition to the actual properties on the node, these methods
2253             provide the "content" property. If the "protected" flag is true,
2254             we include protected properties - those which may not be
2255             modified.
2256         '''
2257         d = Class.getprops(self, protected=protected).copy()
2258         d['content'] = hyperdb.String()
2259         return d
2261     def index(self, nodeid):
2262         ''' Index the node in the search index.
2264             We want to index the content in addition to the normal String
2265             property indexing.
2266         '''
2267         # perform normal indexing
2268         Class.index(self, nodeid)
2270         # get the content to index
2271         content = self.get(nodeid, 'content')
2273         # figure the mime type
2274         if self.properties.has_key('type'):
2275             mime_type = self.get(nodeid, 'type')
2276         else:
2277             mime_type = self.default_mime_type
2279         # and index!
2280         self.db.indexer.add_text((self.classname, nodeid, 'content'), content,
2281             mime_type)
2283 # XXX deviation from spec - was called ItemClass
2284 class IssueClass(Class, roundupdb.IssueClass):
2285     # Overridden methods:
2286     def __init__(self, db, classname, **properties):
2287         '''The newly-created class automatically includes the "messages",
2288         "files", "nosy", and "superseder" properties.  If the 'properties'
2289         dictionary attempts to specify any of these properties or a
2290         "creation" or "activity" property, a ValueError is raised.
2291         '''
2292         if not properties.has_key('title'):
2293             properties['title'] = hyperdb.String(indexme='yes')
2294         if not properties.has_key('messages'):
2295             properties['messages'] = hyperdb.Multilink("msg")
2296         if not properties.has_key('files'):
2297             properties['files'] = hyperdb.Multilink("file")
2298         if not properties.has_key('nosy'):
2299             # note: journalling is turned off as it really just wastes
2300             # space. this behaviour may be overridden in an instance
2301             properties['nosy'] = hyperdb.Multilink("user", do_journal="no")
2302         if not properties.has_key('superseder'):
2303             properties['superseder'] = hyperdb.Multilink(classname)
2304         Class.__init__(self, db, classname, **properties)