Code

fixed ZRoundup - mostly changes to classic template
[roundup.git] / roundup / backends / rdbms_common.py
1 # $Id: rdbms_common.py,v 1.68 2003-11-12 01:00:58 richard Exp $
2 ''' Relational database (SQL) backend common code.
4 Basics:
6 - map roundup classes to relational tables
7 - automatically detect schema changes and modify the table schemas
8   appropriately (we store the "database version" of the schema in the
9   database itself as the only row of the "schema" table)
10 - multilinks (which represent a many-to-many relationship) are handled through
11   intermediate tables
12 - journals are stored adjunct to the per-class tables
13 - table names and columns have "_" prepended so the names can't clash with
14   restricted names (like "order")
15 - retirement is determined by the __retired__ column being true
17 Database-specific changes may generally be pushed out to the overridable
18 sql_* methods, since everything else should be fairly generic. There's
19 probably a bit of work to be done if a database is used that actually
20 honors column typing, since the initial databases don't (sqlite stores
21 everything as a string.)
22 '''
24 # standard python modules
25 import sys, os, time, re, errno, weakref, copy
27 # roundup modules
28 from roundup import hyperdb, date, password, roundupdb, security
29 from roundup.hyperdb import String, Password, Date, Interval, Link, \
30     Multilink, DatabaseError, Boolean, Number, Node
31 from roundup.backends import locking
33 # support
34 from blobfiles import FileStorage
35 from roundup.indexer import Indexer
36 from sessions import Sessions, OneTimeKeys
37 from roundup.date import Range
39 # number of rows to keep in memory
40 ROW_CACHE_SIZE = 100
42 class Database(FileStorage, hyperdb.Database, roundupdb.Database):
43     ''' Wrapper around an SQL database that presents a hyperdb interface.
45         - some functionality is specific to the actual SQL database, hence
46           the sql_* methods that are NotImplemented
47         - we keep a cache of the latest ROW_CACHE_SIZE row fetches.
48     '''
49     def __init__(self, config, journaltag=None):
50         ''' Open the database and load the schema from it.
51         '''
52         self.config, self.journaltag = config, journaltag
53         self.dir = config.DATABASE
54         self.classes = {}
55         self.indexer = Indexer(self.dir)
56         self.sessions = Sessions(self.config)
57         self.otks = OneTimeKeys(self.config)
58         self.security = security.Security(self)
60         # additional transaction support for external files and the like
61         self.transactions = []
63         # keep a cache of the N most recently retrieved rows of any kind
64         # (classname, nodeid) = row
65         self.cache = {}
66         self.cache_lru = []
68         # database lock
69         self.lockfile = None
71         # open a connection to the database, creating the "conn" attribute
72         self.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(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         # retire?
1297         if retire:
1298             # use the arg for __retired__ to cope with any odd database type
1299             # conversion (hello, sqlite)
1300             sql = 'update _%s set __retired__=%s where id=%s'%(self.classname,
1301                 self.db.arg, self.db.arg)
1302             if __debug__:
1303                 print >>hyperdb.DEBUG, 'retire', (self, sql, newid)
1304             self.db.cursor.execute(sql, (1, newid))
1306         # add the node and journal
1307         self.db.addnode(self.classname, newid, d)
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 getnode(self, nodeid, cache=1):
1377         ''' Return a convenience wrapper for the node.
1379         'nodeid' must be the id of an existing node of this class or an
1380         IndexError is raised.
1382         'cache' exists for backwards compatibility, and is not used.
1383         '''
1384         return Node(self, nodeid)
1386     def set(self, nodeid, **propvalues):
1387         '''Modify a property on an existing node of this class.
1388         
1389         'nodeid' must be the id of an existing node of this class or an
1390         IndexError is raised.
1392         Each key in 'propvalues' must be the name of a property of this
1393         class or a KeyError is raised.
1395         All values in 'propvalues' must be acceptable types for their
1396         corresponding properties or a TypeError is raised.
1398         If the value of the key property is set, it must not collide with
1399         other key strings or a ValueError is raised.
1401         If the value of a Link or Multilink property contains an invalid
1402         node id, a ValueError is raised.
1403         '''
1404         if not propvalues:
1405             return propvalues
1407         if propvalues.has_key('creation') or propvalues.has_key('activity'):
1408             raise KeyError, '"creation" and "activity" are reserved'
1410         if propvalues.has_key('id'):
1411             raise KeyError, '"id" is reserved'
1413         if self.db.journaltag is None:
1414             raise DatabaseError, 'Database open read-only'
1416         self.fireAuditors('set', nodeid, propvalues)
1417         # Take a copy of the node dict so that the subsequent set
1418         # operation doesn't modify the oldvalues structure.
1419         # XXX used to try the cache here first
1420         oldvalues = copy.deepcopy(self.db.getnode(self.classname, nodeid))
1422         node = self.db.getnode(self.classname, nodeid)
1423         if self.is_retired(nodeid):
1424             raise IndexError, 'Requested item is retired'
1425         num_re = re.compile('^\d+$')
1427         # if the journal value is to be different, store it in here
1428         journalvalues = {}
1430         # remember the add/remove stuff for multilinks, making it easier
1431         # for the Database layer to do its stuff
1432         multilink_changes = {}
1434         for propname, value in propvalues.items():
1435             # check to make sure we're not duplicating an existing key
1436             if propname == self.key and node[propname] != value:
1437                 try:
1438                     self.lookup(value)
1439                 except KeyError:
1440                     pass
1441                 else:
1442                     raise ValueError, 'node with key "%s" exists'%value
1444             # this will raise the KeyError if the property isn't valid
1445             # ... we don't use getprops() here because we only care about
1446             # the writeable properties.
1447             try:
1448                 prop = self.properties[propname]
1449             except KeyError:
1450                 raise KeyError, '"%s" has no property named "%s"'%(
1451                     self.classname, propname)
1453             # if the value's the same as the existing value, no sense in
1454             # doing anything
1455             current = node.get(propname, None)
1456             if value == current:
1457                 del propvalues[propname]
1458                 continue
1459             journalvalues[propname] = current
1461             # do stuff based on the prop type
1462             if isinstance(prop, Link):
1463                 link_class = prop.classname
1464                 # if it isn't a number, it's a key
1465                 if value is not None and not isinstance(value, type('')):
1466                     raise ValueError, 'property "%s" link value be a string'%(
1467                         propname)
1468                 if isinstance(value, type('')) and not num_re.match(value):
1469                     try:
1470                         value = self.db.classes[link_class].lookup(value)
1471                     except (TypeError, KeyError):
1472                         raise IndexError, 'new property "%s": %s not a %s'%(
1473                             propname, value, prop.classname)
1475                 if (value is not None and
1476                         not self.db.getclass(link_class).hasnode(value)):
1477                     raise IndexError, '%s has no node %s'%(link_class, value)
1479                 if self.do_journal and prop.do_journal:
1480                     # register the unlink with the old linked node
1481                     if node[propname] is not None:
1482                         self.db.addjournal(link_class, node[propname], 'unlink',
1483                             (self.classname, nodeid, propname))
1485                     # register the link with the newly linked node
1486                     if value is not None:
1487                         self.db.addjournal(link_class, value, 'link',
1488                             (self.classname, nodeid, propname))
1490             elif isinstance(prop, Multilink):
1491                 if type(value) != type([]):
1492                     raise TypeError, 'new property "%s" not a list of'\
1493                         ' ids'%propname
1494                 link_class = self.properties[propname].classname
1495                 l = []
1496                 for entry in value:
1497                     # if it isn't a number, it's a key
1498                     if type(entry) != type(''):
1499                         raise ValueError, 'new property "%s" link value ' \
1500                             'must be a string'%propname
1501                     if not num_re.match(entry):
1502                         try:
1503                             entry = self.db.classes[link_class].lookup(entry)
1504                         except (TypeError, KeyError):
1505                             raise IndexError, 'new property "%s": %s not a %s'%(
1506                                 propname, entry,
1507                                 self.properties[propname].classname)
1508                     l.append(entry)
1509                 value = l
1510                 propvalues[propname] = value
1512                 # figure the journal entry for this property
1513                 add = []
1514                 remove = []
1516                 # handle removals
1517                 if node.has_key(propname):
1518                     l = node[propname]
1519                 else:
1520                     l = []
1521                 for id in l[:]:
1522                     if id in value:
1523                         continue
1524                     # register the unlink with the old linked node
1525                     if self.do_journal and self.properties[propname].do_journal:
1526                         self.db.addjournal(link_class, id, 'unlink',
1527                             (self.classname, nodeid, propname))
1528                     l.remove(id)
1529                     remove.append(id)
1531                 # handle additions
1532                 for id in value:
1533                     if not self.db.getclass(link_class).hasnode(id):
1534                         raise IndexError, '%s has no node %s'%(link_class, id)
1535                     if id in l:
1536                         continue
1537                     # register the link with the newly linked node
1538                     if self.do_journal and self.properties[propname].do_journal:
1539                         self.db.addjournal(link_class, id, 'link',
1540                             (self.classname, nodeid, propname))
1541                     l.append(id)
1542                     add.append(id)
1544                 # figure the journal entry
1545                 l = []
1546                 if add:
1547                     l.append(('+', add))
1548                 if remove:
1549                     l.append(('-', remove))
1550                 multilink_changes[propname] = (add, remove)
1551                 if l:
1552                     journalvalues[propname] = tuple(l)
1554             elif isinstance(prop, String):
1555                 if value is not None and type(value) != type('') and type(value) != type(u''):
1556                     raise TypeError, 'new property "%s" not a string'%propname
1558             elif isinstance(prop, Password):
1559                 if not isinstance(value, password.Password):
1560                     raise TypeError, 'new property "%s" not a Password'%propname
1561                 propvalues[propname] = value
1563             elif value is not None and isinstance(prop, Date):
1564                 if not isinstance(value, date.Date):
1565                     raise TypeError, 'new property "%s" not a Date'% propname
1566                 propvalues[propname] = value
1568             elif value is not None and isinstance(prop, Interval):
1569                 if not isinstance(value, date.Interval):
1570                     raise TypeError, 'new property "%s" not an '\
1571                         'Interval'%propname
1572                 propvalues[propname] = value
1574             elif value is not None and isinstance(prop, Number):
1575                 try:
1576                     float(value)
1577                 except ValueError:
1578                     raise TypeError, 'new property "%s" not numeric'%propname
1580             elif value is not None and isinstance(prop, Boolean):
1581                 try:
1582                     int(value)
1583                 except ValueError:
1584                     raise TypeError, 'new property "%s" not boolean'%propname
1586         # nothing to do?
1587         if not propvalues:
1588             return propvalues
1590         # do the set, and journal it
1591         self.db.setnode(self.classname, nodeid, propvalues, multilink_changes)
1593         if self.do_journal:
1594             self.db.addjournal(self.classname, nodeid, 'set', journalvalues)
1596         self.fireReactors('set', nodeid, oldvalues)
1598         return propvalues        
1600     def retire(self, nodeid):
1601         '''Retire a node.
1602         
1603         The properties on the node remain available from the get() method,
1604         and the node's id is never reused.
1605         
1606         Retired nodes are not returned by the find(), list(), or lookup()
1607         methods, and other nodes may reuse the values of their key properties.
1608         '''
1609         if self.db.journaltag is None:
1610             raise DatabaseError, 'Database open read-only'
1612         self.fireAuditors('retire', nodeid, None)
1614         # use the arg for __retired__ to cope with any odd database type
1615         # conversion (hello, sqlite)
1616         sql = 'update _%s set __retired__=%s where id=%s'%(self.classname,
1617             self.db.arg, self.db.arg)
1618         if __debug__:
1619             print >>hyperdb.DEBUG, 'retire', (self, sql, nodeid)
1620         self.db.cursor.execute(sql, (1, nodeid))
1621         if self.do_journal:
1622             self.db.addjournal(self.classname, nodeid, 'retired', None)
1624         self.fireReactors('retire', nodeid, None)
1626     def restore(self, nodeid):
1627         '''Restore a retired node.
1629         Make node available for all operations like it was before retirement.
1630         '''
1631         if self.db.journaltag is None:
1632             raise DatabaseError, 'Database open read-only'
1634         node = self.db.getnode(self.classname, nodeid)
1635         # check if key property was overrided
1636         key = self.getkey()
1637         try:
1638             id = self.lookup(node[key])
1639         except KeyError:
1640             pass
1641         else:
1642             raise KeyError, "Key property (%s) of retired node clashes with \
1643                 existing one (%s)" % (key, node[key])
1645         self.fireAuditors('restore', nodeid, None)
1646         # use the arg for __retired__ to cope with any odd database type
1647         # conversion (hello, sqlite)
1648         sql = 'update _%s set __retired__=%s where id=%s'%(self.classname,
1649             self.db.arg, self.db.arg)
1650         if __debug__:
1651             print >>hyperdb.DEBUG, 'restore', (self, sql, nodeid)
1652         self.db.cursor.execute(sql, (0, nodeid))
1653         if self.do_journal:
1654             self.db.addjournal(self.classname, nodeid, 'restored', None)
1656         self.fireReactors('restore', nodeid, None)
1657         
1658     def is_retired(self, nodeid):
1659         '''Return true if the node is rerired
1660         '''
1661         sql = 'select __retired__ from _%s where id=%s'%(self.classname,
1662             self.db.arg)
1663         if __debug__:
1664             print >>hyperdb.DEBUG, 'is_retired', (self, sql, nodeid)
1665         self.db.cursor.execute(sql, (nodeid,))
1666         return int(self.db.sql_fetchone()[0])
1668     def destroy(self, nodeid):
1669         '''Destroy a node.
1670         
1671         WARNING: this method should never be used except in extremely rare
1672                  situations where there could never be links to the node being
1673                  deleted
1674         WARNING: use retire() instead
1675         WARNING: the properties of this node will not be available ever again
1676         WARNING: really, use retire() instead
1678         Well, I think that's enough warnings. This method exists mostly to
1679         support the session storage of the cgi interface.
1681         The node is completely removed from the hyperdb, including all journal
1682         entries. It will no longer be available, and will generally break code
1683         if there are any references to the node.
1684         '''
1685         if self.db.journaltag is None:
1686             raise DatabaseError, 'Database open read-only'
1687         self.db.destroynode(self.classname, nodeid)
1689     def history(self, nodeid):
1690         '''Retrieve the journal of edits on a particular node.
1692         'nodeid' must be the id of an existing node of this class or an
1693         IndexError is raised.
1695         The returned list contains tuples of the form
1697             (nodeid, date, tag, action, params)
1699         'date' is a Timestamp object specifying the time of the change and
1700         'tag' is the journaltag specified when the database was opened.
1701         '''
1702         if not self.do_journal:
1703             raise ValueError, 'Journalling is disabled for this class'
1704         return self.db.getjournal(self.classname, nodeid)
1706     # Locating nodes:
1707     def hasnode(self, nodeid):
1708         '''Determine if the given nodeid actually exists
1709         '''
1710         return self.db.hasnode(self.classname, nodeid)
1712     def setkey(self, propname):
1713         '''Select a String property of this class to be the key property.
1715         'propname' must be the name of a String property of this class or
1716         None, or a TypeError is raised.  The values of the key property on
1717         all existing nodes must be unique or a ValueError is raised.
1718         '''
1719         # XXX create an index on the key prop column. We should also 
1720         # record that we've created this index in the schema somewhere.
1721         prop = self.getprops()[propname]
1722         if not isinstance(prop, String):
1723             raise TypeError, 'key properties must be String'
1724         self.key = propname
1726     def getkey(self):
1727         '''Return the name of the key property for this class or None.'''
1728         return self.key
1730     def labelprop(self, default_to_id=0):
1731         ''' Return the property name for a label for the given node.
1733         This method attempts to generate a consistent label for the node.
1734         It tries the following in order:
1735             1. key property
1736             2. "name" property
1737             3. "title" property
1738             4. first property from the sorted property name list
1739         '''
1740         k = self.getkey()
1741         if  k:
1742             return k
1743         props = self.getprops()
1744         if props.has_key('name'):
1745             return 'name'
1746         elif props.has_key('title'):
1747             return 'title'
1748         if default_to_id:
1749             return 'id'
1750         props = props.keys()
1751         props.sort()
1752         return props[0]
1754     def lookup(self, keyvalue):
1755         '''Locate a particular node by its key property and return its id.
1757         If this class has no key property, a TypeError is raised.  If the
1758         'keyvalue' matches one of the values for the key property among
1759         the nodes in this class, the matching node's id is returned;
1760         otherwise a KeyError is raised.
1761         '''
1762         if not self.key:
1763             raise TypeError, 'No key property set for class %s'%self.classname
1765         # use the arg to handle any odd database type conversion (hello,
1766         # sqlite)
1767         sql = "select id from _%s where _%s=%s and __retired__ <> %s"%(
1768             self.classname, self.key, self.db.arg, self.db.arg)
1769         self.db.sql(sql, (keyvalue, 1))
1771         # see if there was a result that's not retired
1772         row = self.db.sql_fetchone()
1773         if not row:
1774             raise KeyError, 'No key (%s) value "%s" for "%s"'%(self.key,
1775                 keyvalue, self.classname)
1777         # return the id
1778         return row[0]
1780     def find(self, **propspec):
1781         '''Get the ids of nodes in this class which link to the given nodes.
1783         'propspec' consists of keyword args propname=nodeid or
1784                    propname={nodeid:1, }
1785         'propname' must be the name of a property in this class, or a
1786         KeyError is raised.  That property must be a Link or Multilink
1787         property, or a TypeError is raised.
1789         Any node in this class whose 'propname' property links to any of the
1790         nodeids will be returned. Used by the full text indexing, which knows
1791         that "foo" occurs in msg1, msg3 and file7, so we have hits on these
1792         issues:
1794             db.issue.find(messages={'1':1,'3':1}, files={'7':1})
1795         '''
1796         if __debug__:
1797             print >>hyperdb.DEBUG, 'find', (self, propspec)
1799         # shortcut
1800         if not propspec:
1801             return []
1803         # validate the args
1804         props = self.getprops()
1805         propspec = propspec.items()
1806         for propname, nodeids in propspec:
1807             # check the prop is OK
1808             prop = props[propname]
1809             if not isinstance(prop, Link) and not isinstance(prop, Multilink):
1810                 raise TypeError, "'%s' not a Link/Multilink property"%propname
1812         # first, links
1813         where = []
1814         allvalues = ()
1815         a = self.db.arg
1816         for prop, values in propspec:
1817             if not isinstance(props[prop], hyperdb.Link):
1818                 continue
1819             if type(values) is type(''):
1820                 allvalues += (values,)
1821                 where.append('_%s = %s'%(prop, a))
1822             elif values is None:
1823                 where.append('_%s is NULL'%prop)
1824             else:
1825                 allvalues += tuple(values.keys())
1826                 where.append('_%s in (%s)'%(prop, ','.join([a]*len(values))))
1827         tables = []
1828         if where:
1829             tables.append('select id as nodeid from _%s where %s'%(
1830                 self.classname, ' and '.join(where)))
1832         # now multilinks
1833         for prop, values in propspec:
1834             if not isinstance(props[prop], hyperdb.Multilink):
1835                 continue
1836             if type(values) is type(''):
1837                 allvalues += (values,)
1838                 s = a
1839             else:
1840                 allvalues += tuple(values.keys())
1841                 s = ','.join([a]*len(values))
1842             tables.append('select nodeid from %s_%s where linkid in (%s)'%(
1843                 self.classname, prop, s))
1844         sql = '\nunion\n'.join(tables)
1845         self.db.sql(sql, allvalues)
1846         l = [x[0] for x in self.db.sql_fetchall()]
1847         if __debug__:
1848             print >>hyperdb.DEBUG, 'find ... ', l
1849         return l
1851     def stringFind(self, **requirements):
1852         '''Locate a particular node by matching a set of its String
1853         properties in a caseless search.
1855         If the property is not a String property, a TypeError is raised.
1856         
1857         The return is a list of the id of all nodes that match.
1858         '''
1859         where = []
1860         args = []
1861         for propname in requirements.keys():
1862             prop = self.properties[propname]
1863             if isinstance(not prop, String):
1864                 raise TypeError, "'%s' not a String property"%propname
1865             where.append(propname)
1866             args.append(requirements[propname].lower())
1868         # generate the where clause
1869         s = ' and '.join(['lower(_%s)=%s'%(col, self.db.arg) for col in where])
1870         sql = 'select id from _%s where %s'%(self.classname, s)
1871         self.db.sql(sql, tuple(args))
1872         l = [x[0] for x in self.db.sql_fetchall()]
1873         if __debug__:
1874             print >>hyperdb.DEBUG, 'find ... ', l
1875         return l
1877     def list(self):
1878         ''' Return a list of the ids of the active nodes in this class.
1879         '''
1880         return self.getnodeids(retired=0)
1882     def getnodeids(self, retired=None):
1883         ''' Retrieve all the ids of the nodes for a particular Class.
1885             Set retired=None to get all nodes. Otherwise it'll get all the 
1886             retired or non-retired nodes, depending on the flag.
1887         '''
1888         # flip the sense of the 'retired' flag if we don't want all of them
1889         if retired is not None:
1890             if retired:
1891                 args = (0, )
1892             else:
1893                 args = (1, )
1894             sql = 'select id from _%s where __retired__ <> %s'%(self.classname,
1895                 self.db.arg)
1896         else:
1897             args = ()
1898             sql = 'select id from _%s'%self.classname
1899         if __debug__:
1900             print >>hyperdb.DEBUG, 'getnodeids', (self, sql, retired)
1901         self.db.cursor.execute(sql, args)
1902         ids = [x[0] for x in self.db.cursor.fetchall()]
1903         return ids
1905     def filter(self, search_matches, filterspec, sort=(None,None),
1906             group=(None,None)):
1907         ''' Return a list of the ids of the active nodes in this class that
1908             match the 'filter' spec, sorted by the group spec and then the
1909             sort spec
1911             "filterspec" is {propname: value(s)}
1912             "sort" and "group" are (dir, prop) where dir is '+', '-' or None
1913                                and prop is a prop name or None
1914             "search_matches" is {nodeid: marker}
1916             The filter must match all properties specificed - but if the
1917             property value to match is a list, any one of the values in the
1918             list may match for that property to match.
1919         '''
1920         # just don't bother if the full-text search matched diddly
1921         if search_matches == {}:
1922             return []
1924         cn = self.classname
1926         timezone = self.db.getUserTimezone()
1927         
1928         # figure the WHERE clause from the filterspec
1929         props = self.getprops()
1930         frum = ['_'+cn]
1931         where = []
1932         args = []
1933         a = self.db.arg
1934         for k, v in filterspec.items():
1935             propclass = props[k]
1936             # now do other where clause stuff
1937             if isinstance(propclass, Multilink):
1938                 tn = '%s_%s'%(cn, k)
1939                 if v in ('-1', ['-1']):
1940                     # only match rows that have count(linkid)=0 in the
1941                     # corresponding multilink table)
1942                     where.append('id not in (select nodeid from %s)'%tn)
1943                 elif isinstance(v, type([])):
1944                     frum.append(tn)
1945                     s = ','.join([a for x in v])
1946                     where.append('id=%s.nodeid and %s.linkid in (%s)'%(tn,tn,s))
1947                     args = args + v
1948                 else:
1949                     frum.append(tn)
1950                     where.append('id=%s.nodeid and %s.linkid=%s'%(tn, tn, a))
1951                     args.append(v)
1952             elif k == 'id':
1953                 if isinstance(v, type([])):
1954                     s = ','.join([a for x in v])
1955                     where.append('%s in (%s)'%(k, s))
1956                     args = args + v
1957                 else:
1958                     where.append('%s=%s'%(k, a))
1959                     args.append(v)
1960             elif isinstance(propclass, String):
1961                 if not isinstance(v, type([])):
1962                     v = [v]
1964                 # Quote the bits in the string that need it and then embed
1965                 # in a "substring" search. Note - need to quote the '%' so
1966                 # they make it through the python layer happily
1967                 v = ['%%'+self.db.sql_stringquote(s)+'%%' for s in v]
1969                 # now add to the where clause
1970                 where.append(' or '.join(["_%s LIKE '%s'"%(k, s) for s in v]))
1971                 # note: args are embedded in the query string now
1972             elif isinstance(propclass, Link):
1973                 if isinstance(v, type([])):
1974                     if '-1' in v:
1975                         v = v[:]
1976                         v.remove('-1')
1977                         xtra = ' or _%s is NULL'%k
1978                     else:
1979                         xtra = ''
1980                     if v:
1981                         s = ','.join([a for x in v])
1982                         where.append('(_%s in (%s)%s)'%(k, s, xtra))
1983                         args = args + v
1984                     else:
1985                         where.append('_%s is NULL'%k)
1986                 else:
1987                     if v == '-1':
1988                         v = None
1989                         where.append('_%s is NULL'%k)
1990                     else:
1991                         where.append('_%s=%s'%(k, a))
1992                         args.append(v)
1993             elif isinstance(propclass, Date):
1994                 if isinstance(v, type([])):
1995                     s = ','.join([a for x in v])
1996                     where.append('_%s in (%s)'%(k, s))
1997                     args = args + [date.Date(x).serialise() for x in v]
1998                 else:
1999                     try:
2000                         # Try to filter on range of dates
2001                         date_rng = Range(v, date.Date, offset=timezone)
2002                         if (date_rng.from_value):
2003                             where.append('_%s >= %s'%(k, a))                            
2004                             args.append(date_rng.from_value.serialise())
2005                         if (date_rng.to_value):
2006                             where.append('_%s <= %s'%(k, a))
2007                             args.append(date_rng.to_value.serialise())
2008                     except ValueError:
2009                         # If range creation fails - ignore that search parameter
2010                         pass                        
2011             elif isinstance(propclass, Interval):
2012                 if isinstance(v, type([])):
2013                     s = ','.join([a for x in v])
2014                     where.append('_%s in (%s)'%(k, s))
2015                     args = args + [date.Interval(x).serialise() for x in v]
2016                 else:
2017                     try:
2018                         # Try to filter on range of intervals
2019                         date_rng = Range(v, date.Interval)
2020                         if (date_rng.from_value):
2021                             where.append('_%s >= %s'%(k, a))
2022                             args.append(date_rng.from_value.serialise())
2023                         if (date_rng.to_value):
2024                             where.append('_%s <= %s'%(k, a))
2025                             args.append(date_rng.to_value.serialise())
2026                     except ValueError:
2027                         # If range creation fails - ignore that search parameter
2028                         pass                        
2029                     #where.append('_%s=%s'%(k, a))
2030                     #args.append(date.Interval(v).serialise())
2031             else:
2032                 if isinstance(v, type([])):
2033                     s = ','.join([a for x in v])
2034                     where.append('_%s in (%s)'%(k, s))
2035                     args = args + v
2036                 else:
2037                     where.append('_%s=%s'%(k, a))
2038                     args.append(v)
2040         # don't match retired nodes
2041         where.append('__retired__ <> 1')
2043         # add results of full text search
2044         if search_matches is not None:
2045             v = search_matches.keys()
2046             s = ','.join([a for x in v])
2047             where.append('id in (%s)'%s)
2048             args = args + v
2050         # "grouping" is just the first-order sorting in the SQL fetch
2051         # can modify it...)
2052         orderby = []
2053         ordercols = []
2054         if group[0] is not None and group[1] is not None:
2055             if group[0] != '-':
2056                 orderby.append('_'+group[1])
2057                 ordercols.append('_'+group[1])
2058             else:
2059                 orderby.append('_'+group[1]+' desc')
2060                 ordercols.append('_'+group[1])
2062         # now add in the sorting
2063         group = ''
2064         if sort[0] is not None and sort[1] is not None:
2065             direction, colname = sort
2066             if direction != '-':
2067                 if colname == 'id':
2068                     orderby.append(colname)
2069                 else:
2070                     orderby.append('_'+colname)
2071                     ordercols.append('_'+colname)
2072             else:
2073                 if colname == 'id':
2074                     orderby.append(colname+' desc')
2075                     ordercols.append(colname)
2076                 else:
2077                     orderby.append('_'+colname+' desc')
2078                     ordercols.append('_'+colname)
2080         # construct the SQL
2081         frum = ','.join(frum)
2082         if where:
2083             where = ' where ' + (' and '.join(where))
2084         else:
2085             where = ''
2086         cols = ['id']
2087         if orderby:
2088             cols = cols + ordercols
2089             order = ' order by %s'%(','.join(orderby))
2090         else:
2091             order = ''
2092         cols = ','.join(cols)
2093         sql = 'select %s from %s %s%s%s'%(cols, frum, where, group, order)
2094         args = tuple(args)
2095         if __debug__:
2096             print >>hyperdb.DEBUG, 'filter', (self, sql, args)
2097         if args:
2098             self.db.cursor.execute(sql, args)
2099         else:
2100             # psycopg doesn't like empty args
2101             self.db.cursor.execute(sql)
2102         l = self.db.sql_fetchall()
2104         # return the IDs (the first column)
2105         return [row[0] for row in l]
2107     def count(self):
2108         '''Get the number of nodes in this class.
2110         If the returned integer is 'numnodes', the ids of all the nodes
2111         in this class run from 1 to numnodes, and numnodes+1 will be the
2112         id of the next node to be created in this class.
2113         '''
2114         return self.db.countnodes(self.classname)
2116     # Manipulating properties:
2117     def getprops(self, protected=1):
2118         '''Return a dictionary mapping property names to property objects.
2119            If the "protected" flag is true, we include protected properties -
2120            those which may not be modified.
2121         '''
2122         d = self.properties.copy()
2123         if protected:
2124             d['id'] = String()
2125             d['creation'] = hyperdb.Date()
2126             d['activity'] = hyperdb.Date()
2127             d['creator'] = hyperdb.Link('user')
2128         return d
2130     def addprop(self, **properties):
2131         '''Add properties to this class.
2133         The keyword arguments in 'properties' must map names to property
2134         objects, or a TypeError is raised.  None of the keys in 'properties'
2135         may collide with the names of existing properties, or a ValueError
2136         is raised before any properties have been added.
2137         '''
2138         for key in properties.keys():
2139             if self.properties.has_key(key):
2140                 raise ValueError, key
2141         self.properties.update(properties)
2143     def index(self, nodeid):
2144         '''Add (or refresh) the node to search indexes
2145         '''
2146         # find all the String properties that have indexme
2147         for prop, propclass in self.getprops().items():
2148             if isinstance(propclass, String) and propclass.indexme:
2149                 try:
2150                     value = str(self.get(nodeid, prop))
2151                 except IndexError:
2152                     # node no longer exists - entry should be removed
2153                     self.db.indexer.purge_entry((self.classname, nodeid, prop))
2154                 else:
2155                     # and index them under (classname, nodeid, property)
2156                     self.db.indexer.add_text((self.classname, nodeid, prop),
2157                         value)
2160     #
2161     # Detector interface
2162     #
2163     def audit(self, event, detector):
2164         '''Register a detector
2165         '''
2166         l = self.auditors[event]
2167         if detector not in l:
2168             self.auditors[event].append(detector)
2170     def fireAuditors(self, action, nodeid, newvalues):
2171         '''Fire all registered auditors.
2172         '''
2173         for audit in self.auditors[action]:
2174             audit(self.db, self, nodeid, newvalues)
2176     def react(self, event, detector):
2177         '''Register a detector
2178         '''
2179         l = self.reactors[event]
2180         if detector not in l:
2181             self.reactors[event].append(detector)
2183     def fireReactors(self, action, nodeid, oldvalues):
2184         '''Fire all registered reactors.
2185         '''
2186         for react in self.reactors[action]:
2187             react(self.db, self, nodeid, oldvalues)
2189 class FileClass(Class, hyperdb.FileClass):
2190     '''This class defines a large chunk of data. To support this, it has a
2191        mandatory String property "content" which is typically saved off
2192        externally to the hyperdb.
2194        The default MIME type of this data is defined by the
2195        "default_mime_type" class attribute, which may be overridden by each
2196        node if the class defines a "type" String property.
2197     '''
2198     default_mime_type = 'text/plain'
2200     def create(self, **propvalues):
2201         ''' snaffle the file propvalue and store in a file
2202         '''
2203         # we need to fire the auditors now, or the content property won't
2204         # be in propvalues for the auditors to play with
2205         self.fireAuditors('create', None, propvalues)
2207         # now remove the content property so it's not stored in the db
2208         content = propvalues['content']
2209         del propvalues['content']
2211         # do the database create
2212         newid = Class.create_inner(self, **propvalues)
2214         # fire reactors
2215         self.fireReactors('create', newid, None)
2217         # store off the content as a file
2218         self.db.storefile(self.classname, newid, None, content)
2219         return newid
2221     def import_list(self, propnames, proplist):
2222         ''' Trap the "content" property...
2223         '''
2224         # dupe this list so we don't affect others
2225         propnames = propnames[:]
2227         # extract the "content" property from the proplist
2228         i = propnames.index('content')
2229         content = eval(proplist[i])
2230         del propnames[i]
2231         del proplist[i]
2233         # do the normal import
2234         newid = Class.import_list(self, propnames, proplist)
2236         # save off the "content" file
2237         self.db.storefile(self.classname, newid, None, content)
2238         return newid
2240     _marker = []
2241     def get(self, nodeid, propname, default=_marker, cache=1):
2242         ''' Trap the content propname and get it from the file
2244         'cache' exists for backwards compatibility, and is not used.
2245         '''
2246         poss_msg = 'Possibly a access right configuration problem.'
2247         if propname == 'content':
2248             try:
2249                 return self.db.getfile(self.classname, nodeid, None)
2250             except IOError, (strerror):
2251                 # BUG: by catching this we donot see an error in the log.
2252                 return 'ERROR reading file: %s%s\n%s\n%s'%(
2253                         self.classname, nodeid, poss_msg, strerror)
2254         if default is not self._marker:
2255             return Class.get(self, nodeid, propname, default)
2256         else:
2257             return Class.get(self, nodeid, propname)
2259     def getprops(self, protected=1):
2260         ''' In addition to the actual properties on the node, these methods
2261             provide the "content" property. If the "protected" flag is true,
2262             we include protected properties - those which may not be
2263             modified.
2264         '''
2265         d = Class.getprops(self, protected=protected).copy()
2266         d['content'] = hyperdb.String()
2267         return d
2269     def index(self, nodeid):
2270         ''' Index the node in the search index.
2272             We want to index the content in addition to the normal String
2273             property indexing.
2274         '''
2275         # perform normal indexing
2276         Class.index(self, nodeid)
2278         # get the content to index
2279         content = self.get(nodeid, 'content')
2281         # figure the mime type
2282         if self.properties.has_key('type'):
2283             mime_type = self.get(nodeid, 'type')
2284         else:
2285             mime_type = self.default_mime_type
2287         # and index!
2288         self.db.indexer.add_text((self.classname, nodeid, 'content'), content,
2289             mime_type)
2291 # XXX deviation from spec - was called ItemClass
2292 class IssueClass(Class, roundupdb.IssueClass):
2293     # Overridden methods:
2294     def __init__(self, db, classname, **properties):
2295         '''The newly-created class automatically includes the "messages",
2296         "files", "nosy", and "superseder" properties.  If the 'properties'
2297         dictionary attempts to specify any of these properties or a
2298         "creation" or "activity" property, a ValueError is raised.
2299         '''
2300         if not properties.has_key('title'):
2301             properties['title'] = hyperdb.String(indexme='yes')
2302         if not properties.has_key('messages'):
2303             properties['messages'] = hyperdb.Multilink("msg")
2304         if not properties.has_key('files'):
2305             properties['files'] = hyperdb.Multilink("file")
2306         if not properties.has_key('nosy'):
2307             # note: journalling is turned off as it really just wastes
2308             # space. this behaviour may be overridden in an instance
2309             properties['nosy'] = hyperdb.Multilink("user", do_journal="no")
2310         if not properties.has_key('superseder'):
2311             properties['superseder'] = hyperdb.Multilink(classname)
2312         Class.__init__(self, db, classname, **properties)