Code

16392c68aba7f229095fb0044a34bb525f5a0c03
[roundup.git] / roundup / backends / rdbms_common.py
1 # $Id: rdbms_common.py,v 1.76 2004-03-05 00:08:09 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.)
23 The schema of the hyperdb being mapped to the database is stored in the
24 database itself as a repr()'ed dictionary of information about each Class
25 that maps to a table. If that information differs from the hyperdb schema,
26 then we update it. We also store in the schema dict a __version__ which
27 allows us to upgrade the database schema when necessary. See upgrade_db().
28 '''
29 __docformat__ = 'restructuredtext'
31 # standard python modules
32 import sys, os, time, re, errno, weakref, copy
34 # roundup modules
35 from roundup import hyperdb, date, password, roundupdb, security
36 from roundup.hyperdb import String, Password, Date, Interval, Link, \
37     Multilink, DatabaseError, Boolean, Number, Node
38 from roundup.backends import locking
40 # support
41 from blobfiles import FileStorage
42 from roundup.indexer import Indexer
43 from sessions import Sessions, OneTimeKeys
44 from roundup.date import Range
46 # number of rows to keep in memory
47 ROW_CACHE_SIZE = 100
49 class Database(FileStorage, hyperdb.Database, roundupdb.Database):
50     ''' Wrapper around an SQL database that presents a hyperdb interface.
52         - some functionality is specific to the actual SQL database, hence
53           the sql_* methods that are NotImplemented
54         - we keep a cache of the latest ROW_CACHE_SIZE row fetches.
55     '''
56     def __init__(self, config, journaltag=None):
57         ''' Open the database and load the schema from it.
58         '''
59         self.config, self.journaltag = config, journaltag
60         self.dir = config.DATABASE
61         self.classes = {}
62         self.indexer = Indexer(self.dir)
63         self.sessions = Sessions(self.config)
64         self.otks = OneTimeKeys(self.config)
65         self.security = security.Security(self)
67         # additional transaction support for external files and the like
68         self.transactions = []
70         # keep a cache of the N most recently retrieved rows of any kind
71         # (classname, nodeid) = row
72         self.cache = {}
73         self.cache_lru = []
75         # database lock
76         self.lockfile = None
78         # open a connection to the database, creating the "conn" attribute
79         self.sql_open_connection()
81     def clearCache(self):
82         self.cache = {}
83         self.cache_lru = []
85     def sql_open_connection(self):
86         ''' Open a connection to the database, creating it if necessary.
88             Must call self.load_dbschema()
89         '''
90         raise NotImplemented
92     def sql(self, sql, args=None):
93         ''' Execute the sql with the optional args.
94         '''
95         if __debug__:
96             print >>hyperdb.DEBUG, (self, sql, args)
97         if args:
98             self.cursor.execute(sql, args)
99         else:
100             self.cursor.execute(sql)
102     def sql_fetchone(self):
103         ''' Fetch a single row. If there's nothing to fetch, return None.
104         '''
105         return self.cursor.fetchone()
107     def sql_fetchall(self):
108         ''' Fetch all rows. If there's nothing to fetch, return [].
109         '''
110         return self.cursor.fetchall()
112     def sql_stringquote(self, value):
113         ''' Quote the string so it's safe to put in the 'sql quotes'
114         '''
115         return re.sub("'", "''", str(value))
117     def load_dbschema(self):
118         ''' Load the schema definition that the database currently implements
119         '''
120         self.cursor.execute('select schema from schema')
121         self.database_schema = eval(self.cursor.fetchone()[0])
123     def save_dbschema(self, schema):
124         ''' Save the schema definition that the database currently implements
125         '''
126         s = repr(self.database_schema)
127         self.sql('insert into schema values (%s)', (s,))
129     def post_init(self):
130         ''' Called once the schema initialisation has finished.
132             We should now confirm that the schema defined by our "classes"
133             attribute actually matches the schema in the database.
134         '''
135         self.upgrade_db()
137         # now detect changes in the schema
138         save = 0
139         for classname, spec in self.classes.items():
140             if self.database_schema.has_key(classname):
141                 dbspec = self.database_schema[classname]
142                 if self.update_class(spec, dbspec):
143                     self.database_schema[classname] = spec.schema()
144                     save = 1
145             else:
146                 self.create_class(spec)
147                 self.database_schema[classname] = spec.schema()
148                 save = 1
150         for classname, spec in self.database_schema.items():
151             if not self.classes.has_key(classname):
152                 self.drop_class(classname, spec)
153                 del self.database_schema[classname]
154                 save = 1
156         # update the database version of the schema
157         if save:
158             self.sql('delete from schema')
159             self.save_dbschema(self.database_schema)
161         # reindex the db if necessary
162         if self.indexer.should_reindex():
163             self.reindex()
165         # commit
166         self.conn.commit()
168     # update this number when we need to make changes to the SQL structure
169     # of the backen database
170     current_db_version = 2
171     def upgrade_db(self):
172         ''' Update the SQL database to reflect changes in the backend code.
173         '''
174         version = self.database_schema.get('__version', 1)
175         if version == 1:
176             # version 1 doesn't have the OTK, session and indexing in the
177             # database
178             self.create_version_2_tables()
180         self.database_schema['__version'] = self.current_db_version
183     def refresh_database(self):
184         self.post_init()
186     def reindex(self):
187         for klass in self.classes.values():
188             for nodeid in klass.list():
189                 klass.index(nodeid)
190         self.indexer.save_index()
192     def determine_columns(self, properties):
193         ''' Figure the column names and multilink properties from the spec
195             "properties" is a list of (name, prop) where prop may be an
196             instance of a hyperdb "type" _or_ a string repr of that type.
197         '''
198         cols = ['_activity', '_creator', '_creation']
199         mls = []
200         # add the multilinks separately
201         for col, prop in properties:
202             if isinstance(prop, Multilink):
203                 mls.append(col)
204             elif isinstance(prop, type('')) and prop.find('Multilink') != -1:
205                 mls.append(col)
206             else:
207                 cols.append('_'+col)
208         cols.sort()
209         return cols, mls
211     def update_class(self, spec, old_spec, force=0):
212         ''' Determine the differences between the current spec and the
213             database version of the spec, and update where necessary.
215             If 'force' is true, update the database anyway.
216         '''
217         new_has = spec.properties.has_key
218         new_spec = spec.schema()
219         new_spec[1].sort()
220         old_spec[1].sort()
221         if not force and new_spec == old_spec:
222             # no changes
223             return 0
225         if __debug__:
226             print >>hyperdb.DEBUG, 'update_class FIRING'
228         # detect multilinks that have been removed, and drop their table
229         old_has = {}
230         for name,prop in old_spec[1]:
231             old_has[name] = 1
232             if new_has(name) or not isinstance(prop, Multilink):
233                 continue
234             # it's a multilink, and it's been removed - drop the old
235             # table. First drop indexes.
236             self.drop_multilink_table_indexes(spec.classname, ml)
237             sql = 'drop table %s_%s'%(spec.classname, prop)
238             if __debug__:
239                 print >>hyperdb.DEBUG, 'update_class', (self, sql)
240             self.cursor.execute(sql)
241         old_has = old_has.has_key
243         # now figure how we populate the new table
244         fetch = ['_activity', '_creation', '_creator']
245         properties = spec.getprops()
246         for propname,x in new_spec[1]:
247             prop = properties[propname]
248             if isinstance(prop, Multilink):
249                 if force or not old_has(propname):
250                     # we need to create the new table
251                     self.create_multilink_table(spec, propname)
252             elif old_has(propname):
253                 # we copy this col over from the old table
254                 fetch.append('_'+propname)
256         # select the data out of the old table
257         fetch.append('id')
258         fetch.append('__retired__')
259         fetchcols = ','.join(fetch)
260         cn = spec.classname
261         sql = 'select %s from _%s'%(fetchcols, cn)
262         if __debug__:
263             print >>hyperdb.DEBUG, 'update_class', (self, sql)
264         self.cursor.execute(sql)
265         olddata = self.cursor.fetchall()
267         # TODO: update all the other index dropping code
268         self.drop_class_table_indexes(cn, old_spec[0])
270         # drop the old table
271         self.cursor.execute('drop table _%s'%cn)
273         # create the new table
274         self.create_class_table(spec)
276         if olddata:
277             # do the insert
278             args = ','.join([self.arg for x in fetch])
279             sql = 'insert into _%s (%s) values (%s)'%(cn, fetchcols, args)
280             if __debug__:
281                 print >>hyperdb.DEBUG, 'update_class', (self, sql, olddata[0])
282             for entry in olddata:
283                 self.cursor.execute(sql, tuple(entry))
285         return 1
287     def create_class_table(self, spec):
288         ''' create the class table for the given spec
289         '''
290         cols, mls = self.determine_columns(spec.properties.items())
292         # add on our special columns
293         cols.append('id')
294         cols.append('__retired__')
296         # create the base table
297         scols = ','.join(['%s varchar'%x for x in cols])
298         sql = 'create table _%s (%s)'%(spec.classname, scols)
299         if __debug__:
300             print >>hyperdb.DEBUG, 'create_class', (self, sql)
301         self.cursor.execute(sql)
303         self.create_class_table_indexes(spec)
305         return cols, mls
307     def create_class_table_indexes(self, spec):
308         ''' create the class table for the given spec
309         '''
310         # create id index
311         index_sql1 = 'create index _%s_id_idx on _%s(id)'%(
312                         spec.classname, spec.classname)
313         if __debug__:
314             print >>hyperdb.DEBUG, 'create_index', (self, index_sql1)
315         self.cursor.execute(index_sql1)
317         # create __retired__ index
318         index_sql2 = 'create index _%s_retired_idx on _%s(__retired__)'%(
319                         spec.classname, spec.classname)
320         if __debug__:
321             print >>hyperdb.DEBUG, 'create_index', (self, index_sql2)
322         self.cursor.execute(index_sql2)
324         # create index for key property
325         if spec.key:
326             if __debug__:
327                 print >>hyperdb.DEBUG, 'update_class setting keyprop %r'% \
328                     spec.key
329             index_sql3 = 'create index _%s_%s_idx on _%s(_%s)'%(
330                         spec.classname, spec.key,
331                         spec.classname, spec.key)
332             if __debug__:
333                 print >>hyperdb.DEBUG, 'create_index', (self, index_sql3)
334             self.cursor.execute(index_sql3)
336     def drop_class_table_indexes(self, cn, key):
337         # drop the old table indexes first
338         l = ['_%s_id_idx'%cn, '_%s_retired_idx'%cn]
339         if key:
340             # key prop too?
341             l.append('_%s_%s_idx'%(cn, key))
343         # TODO: update all the other index dropping code
344         table_name = '_%s'%cn
345         for index_name in l:
346             if not self.sql_index_exists(table_name, index_name):
347                 continue
348             index_sql = 'drop index '+index_name
349             if __debug__:
350                 print >>hyperdb.DEBUG, 'drop_index', (self, index_sql)
351             self.cursor.execute(index_sql)
353     def create_journal_table(self, spec):
354         ''' create the journal table for a class given the spec and 
355             already-determined cols
356         '''
357         # journal table
358         cols = ','.join(['%s varchar'%x
359             for x in 'nodeid date tag action params'.split()])
360         sql = 'create table %s__journal (%s)'%(spec.classname, cols)
361         if __debug__:
362             print >>hyperdb.DEBUG, 'create_class', (self, sql)
363         self.cursor.execute(sql)
364         self.create_journal_table_indexes(spec)
366     def create_journal_table_indexes(self, spec):
367         # index on nodeid
368         index_sql = 'create index %s_journ_idx on %s__journal(nodeid)'%(
369                         spec.classname, spec.classname)
370         if __debug__:
371             print >>hyperdb.DEBUG, 'create_index', (self, index_sql)
372         self.cursor.execute(index_sql)
374     def drop_journal_table_indexes(self, classname):
375         index_name = '%s_journ_idx'%classname
376         if not self.sql_index_exists('%s__journal'%classname, index_name):
377             return
378         index_sql = 'drop index '+index_name
379         if __debug__:
380             print >>hyperdb.DEBUG, 'drop_index', (self, index_sql)
381         self.cursor.execute(index_sql)
383     def create_multilink_table(self, spec, ml):
384         ''' Create a multilink table for the "ml" property of the class
385             given by the spec
386         '''
387         # create the table
388         sql = 'create table %s_%s (linkid varchar, nodeid varchar)'%(
389             spec.classname, ml)
390         if __debug__:
391             print >>hyperdb.DEBUG, 'create_class', (self, sql)
392         self.cursor.execute(sql)
393         self.create_multilink_table_indexes(spec, ml)
395     def create_multilink_table_indexes(self, spec, ml):
396         # create index on linkid
397         index_sql = 'create index %s_%s_l_idx on %s_%s(linkid)'%(
398                         spec.classname, ml, spec.classname, ml)
399         if __debug__:
400             print >>hyperdb.DEBUG, 'create_index', (self, index_sql)
401         self.cursor.execute(index_sql)
403         # create index on nodeid
404         index_sql = 'create index %s_%s_n_idx on %s_%s(nodeid)'%(
405                         spec.classname, ml, spec.classname, ml)
406         if __debug__:
407             print >>hyperdb.DEBUG, 'create_index', (self, index_sql)
408         self.cursor.execute(index_sql)
410     def drop_multilink_table_indexes(self, classname, ml):
411         l = [
412             '%s_%s_l_idx'%(classname, ml),
413             '%s_%s_n_idx'%(classname, ml)
414         ]
415         table_name = '%s_%s'%(classname, ml)
416         for index_name in l:
417             if not self.sql_index_exists(table_name, index_name):
418                 continue
419             index_sql = 'drop index %s'%index_name
420             if __debug__:
421                 print >>hyperdb.DEBUG, 'drop_index', (self, index_sql)
422             self.cursor.execute(index_sql)
424     def create_class(self, spec):
425         ''' Create a database table according to the given spec.
426         '''
427         cols, mls = self.create_class_table(spec)
428         self.create_journal_table(spec)
430         # now create the multilink tables
431         for ml in mls:
432             self.create_multilink_table(spec, ml)
434         # ID counter
435         sql = 'insert into ids (name, num) values (%s,%s)'%(self.arg, self.arg)
436         vals = (spec.classname, 1)
437         if __debug__:
438             print >>hyperdb.DEBUG, 'create_class', (self, sql, vals)
439         self.cursor.execute(sql, vals)
441     def drop_class(self, cn, spec):
442         ''' Drop the given table from the database.
444             Drop the journal and multilink tables too.
445         '''
446         properties = spec[1]
447         # figure the multilinks
448         mls = []
449         for propanme, prop in properties:
450             if isinstance(prop, Multilink):
451                 mls.append(propname)
453         # drop class table and indexes
454         self.drop_class_table_indexes(cn, spec[0])
455         sql = 'drop table _%s'%cn
456         if __debug__:
457             print >>hyperdb.DEBUG, 'drop_class', (self, sql)
458         self.cursor.execute(sql)
460         # drop journal table and indexes
461         self.drop_journal_table_indexes(cn)
462         sql = 'drop table %s__journal'%cn
463         if __debug__:
464             print >>hyperdb.DEBUG, 'drop_class', (self, sql)
465         self.cursor.execute(sql)
467         for ml in mls:
468             # drop multilink table and indexes
469             self.drop_multilink_table_indexes(cn, ml)
470             sql = 'drop table %s_%s'%(spec.classname, ml)
471             if __debug__:
472                 print >>hyperdb.DEBUG, 'drop_class', (self, sql)
473             self.cursor.execute(sql)
475     #
476     # Classes
477     #
478     def __getattr__(self, classname):
479         ''' A convenient way of calling self.getclass(classname).
480         '''
481         if self.classes.has_key(classname):
482             if __debug__:
483                 print >>hyperdb.DEBUG, '__getattr__', (self, classname)
484             return self.classes[classname]
485         raise AttributeError, classname
487     def addclass(self, cl):
488         ''' Add a Class to the hyperdatabase.
489         '''
490         if __debug__:
491             print >>hyperdb.DEBUG, 'addclass', (self, cl)
492         cn = cl.classname
493         if self.classes.has_key(cn):
494             raise ValueError, cn
495         self.classes[cn] = cl
497     def getclasses(self):
498         ''' Return a list of the names of all existing classes.
499         '''
500         if __debug__:
501             print >>hyperdb.DEBUG, 'getclasses', (self,)
502         l = self.classes.keys()
503         l.sort()
504         return l
506     def getclass(self, classname):
507         '''Get the Class object representing a particular class.
509         If 'classname' is not a valid class name, a KeyError is raised.
510         '''
511         if __debug__:
512             print >>hyperdb.DEBUG, 'getclass', (self, classname)
513         try:
514             return self.classes[classname]
515         except KeyError:
516             raise KeyError, 'There is no class called "%s"'%classname
518     def clear(self):
519         '''Delete all database contents.
521         Note: I don't commit here, which is different behaviour to the
522               "nuke from orbit" behaviour in the dbs.
523         '''
524         if __debug__:
525             print >>hyperdb.DEBUG, 'clear', (self,)
526         for cn in self.classes.keys():
527             sql = 'delete from _%s'%cn
528             if __debug__:
529                 print >>hyperdb.DEBUG, 'clear', (self, sql)
530             self.cursor.execute(sql)
532     #
533     # Node IDs
534     #
535     def newid(self, classname):
536         ''' Generate a new id for the given class
537         '''
538         # get the next ID
539         sql = 'select num from ids where name=%s'%self.arg
540         if __debug__:
541             print >>hyperdb.DEBUG, 'newid', (self, sql, classname)
542         self.cursor.execute(sql, (classname, ))
543         newid = self.cursor.fetchone()[0]
545         # update the counter
546         sql = 'update ids set num=%s where name=%s'%(self.arg, self.arg)
547         vals = (int(newid)+1, classname)
548         if __debug__:
549             print >>hyperdb.DEBUG, 'newid', (self, sql, vals)
550         self.cursor.execute(sql, vals)
552         # return as string
553         return str(newid)
555     def setid(self, classname, setid):
556         ''' Set the id counter: used during import of database
557         '''
558         sql = 'update ids set num=%s where name=%s'%(self.arg, self.arg)
559         vals = (setid, classname)
560         if __debug__:
561             print >>hyperdb.DEBUG, 'setid', (self, sql, vals)
562         self.cursor.execute(sql, vals)
564     #
565     # Nodes
566     #
567     def addnode(self, classname, nodeid, node):
568         ''' Add the specified node to its class's db.
569         '''
570         if __debug__:
571             print >>hyperdb.DEBUG, 'addnode', (self, classname, nodeid, node)
573         # determine the column definitions and multilink tables
574         cl = self.classes[classname]
575         cols, mls = self.determine_columns(cl.properties.items())
577         # we'll be supplied these props if we're doing an import
578         if not node.has_key('creator'):
579             # add in the "calculated" properties (dupe so we don't affect
580             # calling code's node assumptions)
581             node = node.copy()
582             node['creation'] = node['activity'] = date.Date()
583             node['creator'] = self.getuid()
585         # default the non-multilink columns
586         for col, prop in cl.properties.items():
587             if not node.has_key(col):
588                 if isinstance(prop, Multilink):
589                     node[col] = []
590                 else:
591                     node[col] = None
593         # clear this node out of the cache if it's in there
594         key = (classname, nodeid)
595         if self.cache.has_key(key):
596             del self.cache[key]
597             self.cache_lru.remove(key)
599         # make the node data safe for the DB
600         node = self.serialise(classname, node)
602         # make sure the ordering is correct for column name -> column value
603         vals = tuple([node[col[1:]] for col in cols]) + (nodeid, 0)
604         s = ','.join([self.arg for x in cols]) + ',%s,%s'%(self.arg, self.arg)
605         cols = ','.join(cols) + ',id,__retired__'
607         # perform the inserts
608         sql = 'insert into _%s (%s) values (%s)'%(classname, cols, s)
609         if __debug__:
610             print >>hyperdb.DEBUG, 'addnode', (self, sql, vals)
611         self.cursor.execute(sql, vals)
613         # insert the multilink rows
614         for col in mls:
615             t = '%s_%s'%(classname, col)
616             for entry in node[col]:
617                 sql = 'insert into %s (linkid, nodeid) values (%s,%s)'%(t,
618                     self.arg, self.arg)
619                 self.sql(sql, (entry, nodeid))
621         # make sure we do the commit-time extra stuff for this node
622         self.transactions.append((self.doSaveNode, (classname, nodeid, node)))
624     def setnode(self, classname, nodeid, values, multilink_changes):
625         ''' Change the specified node.
626         '''
627         if __debug__:
628             print >>hyperdb.DEBUG, 'setnode', (self, classname, nodeid, values)
630         # clear this node out of the cache if it's in there
631         key = (classname, nodeid)
632         if self.cache.has_key(key):
633             del self.cache[key]
634             self.cache_lru.remove(key)
636         # add the special props
637         values = values.copy()
638         values['activity'] = date.Date()
640         # make db-friendly
641         values = self.serialise(classname, values)
643         cl = self.classes[classname]
644         cols = []
645         mls = []
646         # add the multilinks separately
647         props = cl.getprops()
648         for col in values.keys():
649             prop = props[col]
650             if isinstance(prop, Multilink):
651                 mls.append(col)
652             else:
653                 cols.append('_'+col)
654         cols.sort()
656         # if there's any updates to regular columns, do them
657         if cols:
658             # make sure the ordering is correct for column name -> column value
659             sqlvals = tuple([values[col[1:]] for col in cols]) + (nodeid,)
660             s = ','.join(['%s=%s'%(x, self.arg) for x in cols])
661             cols = ','.join(cols)
663             # perform the update
664             sql = 'update _%s set %s where id=%s'%(classname, s, self.arg)
665             if __debug__:
666                 print >>hyperdb.DEBUG, 'setnode', (self, sql, sqlvals)
667             self.cursor.execute(sql, sqlvals)
669         # now the fun bit, updating the multilinks ;)
670         for col, (add, remove) in multilink_changes.items():
671             tn = '%s_%s'%(classname, col)
672             if add:
673                 sql = 'insert into %s (nodeid, linkid) values (%s,%s)'%(tn,
674                     self.arg, self.arg)
675                 for addid in add:
676                     self.sql(sql, (nodeid, addid))
677             if remove:
678                 sql = 'delete from %s where nodeid=%s and linkid=%s'%(tn,
679                     self.arg, self.arg)
680                 for removeid in remove:
681                     self.sql(sql, (nodeid, removeid))
683         # make sure we do the commit-time extra stuff for this node
684         self.transactions.append((self.doSaveNode, (classname, nodeid, values)))
686     def getnode(self, classname, nodeid):
687         ''' Get a node from the database.
688         '''
689         if __debug__:
690             print >>hyperdb.DEBUG, 'getnode', (self, classname, nodeid)
692         # see if we have this node cached
693         key = (classname, nodeid)
694         if self.cache.has_key(key):
695             # push us back to the top of the LRU
696             self.cache_lru.remove(key)
697             self.cache_lru.insert(0, key)
698             # return the cached information
699             return self.cache[key]
701         # figure the columns we're fetching
702         cl = self.classes[classname]
703         cols, mls = self.determine_columns(cl.properties.items())
704         scols = ','.join(cols)
706         # perform the basic property fetch
707         sql = 'select %s from _%s where id=%s'%(scols, classname, self.arg)
708         self.sql(sql, (nodeid,))
710         values = self.sql_fetchone()
711         if values is None:
712             raise IndexError, 'no such %s node %s'%(classname, nodeid)
714         # make up the node
715         node = {}
716         for col in range(len(cols)):
717             node[cols[col][1:]] = values[col]
719         # now the multilinks
720         for col in mls:
721             # get the link ids
722             sql = 'select linkid from %s_%s where nodeid=%s'%(classname, col,
723                 self.arg)
724             self.cursor.execute(sql, (nodeid,))
725             # extract the first column from the result
726             node[col] = [x[0] for x in self.cursor.fetchall()]
728         # un-dbificate the node data
729         node = self.unserialise(classname, node)
731         # save off in the cache
732         key = (classname, nodeid)
733         self.cache[key] = node
734         # update the LRU
735         self.cache_lru.insert(0, key)
736         if len(self.cache_lru) > ROW_CACHE_SIZE:
737             del self.cache[self.cache_lru.pop()]
739         return node
741     def destroynode(self, classname, nodeid):
742         '''Remove a node from the database. Called exclusively by the
743            destroy() method on Class.
744         '''
745         if __debug__:
746             print >>hyperdb.DEBUG, 'destroynode', (self, classname, nodeid)
748         # make sure the node exists
749         if not self.hasnode(classname, nodeid):
750             raise IndexError, '%s has no node %s'%(classname, nodeid)
752         # see if we have this node cached
753         if self.cache.has_key((classname, nodeid)):
754             del self.cache[(classname, nodeid)]
756         # see if there's any obvious commit actions that we should get rid of
757         for entry in self.transactions[:]:
758             if entry[1][:2] == (classname, nodeid):
759                 self.transactions.remove(entry)
761         # now do the SQL
762         sql = 'delete from _%s where id=%s'%(classname, self.arg)
763         self.sql(sql, (nodeid,))
765         # remove from multilnks
766         cl = self.getclass(classname)
767         x, mls = self.determine_columns(cl.properties.items())
768         for col in mls:
769             # get the link ids
770             sql = 'delete from %s_%s where nodeid=%s'%(classname, col, self.arg)
771             self.sql(sql, (nodeid,))
773         # remove journal entries
774         sql = 'delete from %s__journal where nodeid=%s'%(classname, self.arg)
775         self.sql(sql, (nodeid,))
777     def serialise(self, classname, node):
778         '''Copy the node contents, converting non-marshallable data into
779            marshallable data.
780         '''
781         if __debug__:
782             print >>hyperdb.DEBUG, 'serialise', classname, node
783         properties = self.getclass(classname).getprops()
784         d = {}
785         for k, v in node.items():
786             # if the property doesn't exist, or is the "retired" flag then
787             # it won't be in the properties dict
788             if not properties.has_key(k):
789                 d[k] = v
790                 continue
792             # get the property spec
793             prop = properties[k]
795             if isinstance(prop, Password) and v is not None:
796                 d[k] = str(v)
797             elif isinstance(prop, Date) and v is not None:
798                 d[k] = v.serialise()
799             elif isinstance(prop, Interval) and v is not None:
800                 d[k] = v.serialise()
801             else:
802                 d[k] = v
803         return d
805     def unserialise(self, classname, node):
806         '''Decode the marshalled node data
807         '''
808         if __debug__:
809             print >>hyperdb.DEBUG, 'unserialise', classname, node
810         properties = self.getclass(classname).getprops()
811         d = {}
812         for k, v in node.items():
813             # if the property doesn't exist, or is the "retired" flag then
814             # it won't be in the properties dict
815             if not properties.has_key(k):
816                 d[k] = v
817                 continue
819             # get the property spec
820             prop = properties[k]
822             if isinstance(prop, Date) and v is not None:
823                 d[k] = date.Date(v)
824             elif isinstance(prop, Interval) and v is not None:
825                 d[k] = date.Interval(v)
826             elif isinstance(prop, Password) and v is not None:
827                 p = password.Password()
828                 p.unpack(v)
829                 d[k] = p
830             elif isinstance(prop, Boolean) and v is not None:
831                 d[k] = int(v)
832             elif isinstance(prop, Number) and v is not None:
833                 # try int first, then assume it's a float
834                 try:
835                     d[k] = int(v)
836                 except ValueError:
837                     d[k] = float(v)
838             else:
839                 d[k] = v
840         return d
842     def hasnode(self, classname, nodeid):
843         ''' Determine if the database has a given node.
844         '''
845         sql = 'select count(*) from _%s where id=%s'%(classname, self.arg)
846         if __debug__:
847             print >>hyperdb.DEBUG, 'hasnode', (self, sql, nodeid)
848         self.cursor.execute(sql, (nodeid,))
849         return int(self.cursor.fetchone()[0])
851     def countnodes(self, classname):
852         ''' Count the number of nodes that exist for a particular Class.
853         '''
854         sql = 'select count(*) from _%s'%classname
855         if __debug__:
856             print >>hyperdb.DEBUG, 'countnodes', (self, sql)
857         self.cursor.execute(sql)
858         return self.cursor.fetchone()[0]
860     def addjournal(self, classname, nodeid, action, params, creator=None,
861             creation=None):
862         ''' Journal the Action
863         'action' may be:
865             'create' or 'set' -- 'params' is a dictionary of property values
866             'link' or 'unlink' -- 'params' is (classname, nodeid, propname)
867             'retire' -- 'params' is None
868         '''
869         # serialise the parameters now if necessary
870         if isinstance(params, type({})):
871             if action in ('set', 'create'):
872                 params = self.serialise(classname, params)
874         # handle supply of the special journalling parameters (usually
875         # supplied on importing an existing database)
876         if creator:
877             journaltag = creator
878         else:
879             journaltag = self.getuid()
880         if creation:
881             journaldate = creation.serialise()
882         else:
883             journaldate = date.Date().serialise()
885         # create the journal entry
886         cols = ','.join('nodeid date tag action params'.split())
888         if __debug__:
889             print >>hyperdb.DEBUG, 'addjournal', (nodeid, journaldate,
890                 journaltag, action, params)
892         self.save_journal(classname, cols, nodeid, journaldate,
893             journaltag, action, params)
895     def getjournal(self, classname, nodeid):
896         ''' get the journal for id
897         '''
898         # make sure the node exists
899         if not self.hasnode(classname, nodeid):
900             raise IndexError, '%s has no node %s'%(classname, nodeid)
902         cols = ','.join('nodeid date tag action params'.split())
903         return self.load_journal(classname, cols, nodeid)
905     def save_journal(self, classname, cols, nodeid, journaldate,
906             journaltag, action, params):
907         ''' Save the journal entry to the database
908         '''
909         # make the params db-friendly
910         params = repr(params)
911         entry = (nodeid, journaldate, journaltag, action, params)
913         # do the insert
914         a = self.arg
915         sql = 'insert into %s__journal (%s) values (%s,%s,%s,%s,%s)'%(classname,
916             cols, a, a, a, a, a)
917         if __debug__:
918             print >>hyperdb.DEBUG, 'addjournal', (self, sql, entry)
919         self.cursor.execute(sql, entry)
921     def load_journal(self, classname, cols, nodeid):
922         ''' Load the journal from the database
923         '''
924         # now get the journal entries
925         sql = 'select %s from %s__journal where nodeid=%s'%(cols, classname,
926             self.arg)
927         if __debug__:
928             print >>hyperdb.DEBUG, 'load_journal', (self, sql, nodeid)
929         self.cursor.execute(sql, (nodeid,))
930         res = []
931         for nodeid, date_stamp, user, action, params in self.cursor.fetchall():
932             params = eval(params)
933             res.append((nodeid, date.Date(date_stamp), user, action, params))
934         return res
936     def pack(self, pack_before):
937         ''' Delete all journal entries except "create" before 'pack_before'.
938         '''
939         # get a 'yyyymmddhhmmss' version of the date
940         date_stamp = pack_before.serialise()
942         # do the delete
943         for classname in self.classes.keys():
944             sql = "delete from %s__journal where date<%s and "\
945                 "action<>'create'"%(classname, self.arg)
946             if __debug__:
947                 print >>hyperdb.DEBUG, 'pack', (self, sql, date_stamp)
948             self.cursor.execute(sql, (date_stamp,))
950     def sql_commit(self):
951         ''' Actually commit to the database.
952         '''
953         self.conn.commit()
955     def commit(self):
956         ''' Commit the current transactions.
958         Save all data changed since the database was opened or since the
959         last commit() or rollback().
960         '''
961         if __debug__:
962             print >>hyperdb.DEBUG, 'commit', (self,)
964         # commit the database
965         self.sql_commit()
967         # now, do all the other transaction stuff
968         reindex = {}
969         for method, args in self.transactions:
970             reindex[method(*args)] = 1
972         # reindex the nodes that request it
973         for classname, nodeid in filter(None, reindex.keys()):
974             print >>hyperdb.DEBUG, 'commit.reindex', (classname, nodeid)
975             self.getclass(classname).index(nodeid)
977         # save the indexer state
978         self.indexer.save_index()
980         # clear out the transactions
981         self.transactions = []
983     def sql_rollback(self):
984         self.conn.rollback()
986     def rollback(self):
987         ''' Reverse all actions from the current transaction.
989         Undo all the changes made since the database was opened or the last
990         commit() or rollback() was performed.
991         '''
992         if __debug__:
993             print >>hyperdb.DEBUG, 'rollback', (self,)
995         self.sql_rollback()
997         # roll back "other" transaction stuff
998         for method, args in self.transactions:
999             # delete temporary files
1000             if method == self.doStoreFile:
1001                 self.rollbackStoreFile(*args)
1002         self.transactions = []
1004         # clear the cache
1005         self.clearCache()
1007     def doSaveNode(self, classname, nodeid, node):
1008         ''' dummy that just generates a reindex event
1009         '''
1010         # return the classname, nodeid so we reindex this content
1011         return (classname, nodeid)
1013     def sql_close(self):
1014         self.conn.close()
1016     def close(self):
1017         ''' Close off the connection.
1018         '''
1019         self.sql_close()
1020         if self.lockfile is not None:
1021             locking.release_lock(self.lockfile)
1022         if self.lockfile is not None:
1023             self.lockfile.close()
1024             self.lockfile = None
1027 # The base Class class
1029 class Class(hyperdb.Class):
1030     ''' The handle to a particular class of nodes in a hyperdatabase.
1031         
1032         All methods except __repr__ and getnode must be implemented by a
1033         concrete backend Class.
1034     '''
1036     def __init__(self, db, classname, **properties):
1037         '''Create a new class with a given name and property specification.
1039         'classname' must not collide with the name of an existing class,
1040         or a ValueError is raised.  The keyword arguments in 'properties'
1041         must map names to property objects, or a TypeError is raised.
1042         '''
1043         if (properties.has_key('creation') or properties.has_key('activity')
1044                 or properties.has_key('creator')):
1045             raise ValueError, '"creation", "activity" and "creator" are '\
1046                 'reserved'
1048         self.classname = classname
1049         self.properties = properties
1050         self.db = weakref.proxy(db)       # use a weak ref to avoid circularity
1051         self.key = ''
1053         # should we journal changes (default yes)
1054         self.do_journal = 1
1056         # do the db-related init stuff
1057         db.addclass(self)
1059         self.auditors = {'create': [], 'set': [], 'retire': [], 'restore': []}
1060         self.reactors = {'create': [], 'set': [], 'retire': [], 'restore': []}
1062     def schema(self):
1063         ''' A dumpable version of the schema that we can store in the
1064             database
1065         '''
1066         return (self.key, [(x, repr(y)) for x,y in self.properties.items()])
1068     def enableJournalling(self):
1069         '''Turn journalling on for this class
1070         '''
1071         self.do_journal = 1
1073     def disableJournalling(self):
1074         '''Turn journalling off for this class
1075         '''
1076         self.do_journal = 0
1078     # Editing nodes:
1079     def create(self, **propvalues):
1080         ''' Create a new node of this class and return its id.
1082         The keyword arguments in 'propvalues' map property names to values.
1084         The values of arguments must be acceptable for the types of their
1085         corresponding properties or a TypeError is raised.
1086         
1087         If this class has a key property, it must be present and its value
1088         must not collide with other key strings or a ValueError is raised.
1089         
1090         Any other properties on this class that are missing from the
1091         'propvalues' dictionary are set to None.
1092         
1093         If an id in a link or multilink property does not refer to a valid
1094         node, an IndexError is raised.
1095         '''
1096         self.fireAuditors('create', None, propvalues)
1097         newid = self.create_inner(**propvalues)
1098         self.fireReactors('create', newid, None)
1099         return newid
1100     
1101     def create_inner(self, **propvalues):
1102         ''' Called by create, in-between the audit and react calls.
1103         '''
1104         if propvalues.has_key('id'):
1105             raise KeyError, '"id" is reserved'
1107         if self.db.journaltag is None:
1108             raise DatabaseError, 'Database open read-only'
1110         if propvalues.has_key('creation') or propvalues.has_key('activity'):
1111             raise KeyError, '"creation" and "activity" are reserved'
1113         # new node's id
1114         newid = self.db.newid(self.classname)
1116         # validate propvalues
1117         num_re = re.compile('^\d+$')
1118         for key, value in propvalues.items():
1119             if key == self.key:
1120                 try:
1121                     self.lookup(value)
1122                 except KeyError:
1123                     pass
1124                 else:
1125                     raise ValueError, 'node with key "%s" exists'%value
1127             # try to handle this property
1128             try:
1129                 prop = self.properties[key]
1130             except KeyError:
1131                 raise KeyError, '"%s" has no property "%s"'%(self.classname,
1132                     key)
1134             if value is not None and isinstance(prop, Link):
1135                 if type(value) != type(''):
1136                     raise ValueError, 'link value must be String'
1137                 link_class = self.properties[key].classname
1138                 # if it isn't a number, it's a key
1139                 if not num_re.match(value):
1140                     try:
1141                         value = self.db.classes[link_class].lookup(value)
1142                     except (TypeError, KeyError):
1143                         raise IndexError, 'new property "%s": %s not a %s'%(
1144                             key, value, link_class)
1145                 elif not self.db.getclass(link_class).hasnode(value):
1146                     raise IndexError, '%s has no node %s'%(link_class, value)
1148                 # save off the value
1149                 propvalues[key] = value
1151                 # register the link with the newly linked node
1152                 if self.do_journal and self.properties[key].do_journal:
1153                     self.db.addjournal(link_class, value, 'link',
1154                         (self.classname, newid, key))
1156             elif isinstance(prop, Multilink):
1157                 if type(value) != type([]):
1158                     raise TypeError, 'new property "%s" not a list of ids'%key
1160                 # clean up and validate the list of links
1161                 link_class = self.properties[key].classname
1162                 l = []
1163                 for entry in value:
1164                     if type(entry) != type(''):
1165                         raise ValueError, '"%s" multilink value (%r) '\
1166                             'must contain Strings'%(key, value)
1167                     # if it isn't a number, it's a key
1168                     if not num_re.match(entry):
1169                         try:
1170                             entry = self.db.classes[link_class].lookup(entry)
1171                         except (TypeError, KeyError):
1172                             raise IndexError, 'new property "%s": %s not a %s'%(
1173                                 key, entry, self.properties[key].classname)
1174                     l.append(entry)
1175                 value = l
1176                 propvalues[key] = value
1178                 # handle additions
1179                 for nodeid in value:
1180                     if not self.db.getclass(link_class).hasnode(nodeid):
1181                         raise IndexError, '%s has no node %s'%(link_class,
1182                             nodeid)
1183                     # register the link with the newly linked node
1184                     if self.do_journal and self.properties[key].do_journal:
1185                         self.db.addjournal(link_class, nodeid, 'link',
1186                             (self.classname, newid, key))
1188             elif isinstance(prop, String):
1189                 if type(value) != type('') and type(value) != type(u''):
1190                     raise TypeError, 'new property "%s" not a string'%key
1192             elif isinstance(prop, Password):
1193                 if not isinstance(value, password.Password):
1194                     raise TypeError, 'new property "%s" not a Password'%key
1196             elif isinstance(prop, Date):
1197                 if value is not None and not isinstance(value, date.Date):
1198                     raise TypeError, 'new property "%s" not a Date'%key
1200             elif isinstance(prop, Interval):
1201                 if value is not None and not isinstance(value, date.Interval):
1202                     raise TypeError, 'new property "%s" not an Interval'%key
1204             elif value is not None and isinstance(prop, Number):
1205                 try:
1206                     float(value)
1207                 except ValueError:
1208                     raise TypeError, 'new property "%s" not numeric'%key
1210             elif value is not None and isinstance(prop, Boolean):
1211                 try:
1212                     int(value)
1213                 except ValueError:
1214                     raise TypeError, 'new property "%s" not boolean'%key
1216         # make sure there's data where there needs to be
1217         for key, prop in self.properties.items():
1218             if propvalues.has_key(key):
1219                 continue
1220             if key == self.key:
1221                 raise ValueError, 'key property "%s" is required'%key
1222             if isinstance(prop, Multilink):
1223                 propvalues[key] = []
1224             else:
1225                 propvalues[key] = None
1227         # done
1228         self.db.addnode(self.classname, newid, propvalues)
1229         if self.do_journal:
1230             self.db.addjournal(self.classname, newid, 'create', {})
1232         return newid
1234     def export_list(self, propnames, nodeid):
1235         ''' Export a node - generate a list of CSV-able data in the order
1236             specified by propnames for the given node.
1237         '''
1238         properties = self.getprops()
1239         l = []
1240         for prop in propnames:
1241             proptype = properties[prop]
1242             value = self.get(nodeid, prop)
1243             # "marshal" data where needed
1244             if value is None:
1245                 pass
1246             elif isinstance(proptype, hyperdb.Date):
1247                 value = value.get_tuple()
1248             elif isinstance(proptype, hyperdb.Interval):
1249                 value = value.get_tuple()
1250             elif isinstance(proptype, hyperdb.Password):
1251                 value = str(value)
1252             l.append(repr(value))
1253         l.append(repr(self.is_retired(nodeid)))
1254         return l
1256     def import_list(self, propnames, proplist):
1257         ''' Import a node - all information including "id" is present and
1258             should not be sanity checked. Triggers are not triggered. The
1259             journal should be initialised using the "creator" and "created"
1260             information.
1262             Return the nodeid of the node imported.
1263         '''
1264         if self.db.journaltag is None:
1265             raise DatabaseError, 'Database open read-only'
1266         properties = self.getprops()
1268         # make the new node's property map
1269         d = {}
1270         retire = 0
1271         newid = None
1272         for i in range(len(propnames)):
1273             # Use eval to reverse the repr() used to output the CSV
1274             value = eval(proplist[i])
1276             # Figure the property for this column
1277             propname = propnames[i]
1279             # "unmarshal" where necessary
1280             if propname == 'id':
1281                 newid = value
1282                 continue
1283             elif propname == 'is retired':
1284                 # is the item retired?
1285                 if int(value):
1286                     retire = 1
1287                 continue
1288             elif value is None:
1289                 d[propname] = None
1290                 continue
1292             prop = properties[propname]
1293             if value is None:
1294                 # don't set Nones
1295                 continue
1296             elif isinstance(prop, hyperdb.Date):
1297                 value = date.Date(value)
1298             elif isinstance(prop, hyperdb.Interval):
1299                 value = date.Interval(value)
1300             elif isinstance(prop, hyperdb.Password):
1301                 pwd = password.Password()
1302                 pwd.unpack(value)
1303                 value = pwd
1304             d[propname] = value
1306         # get a new id if necessary
1307         if newid is None:
1308             newid = self.db.newid(self.classname)
1310         # add the node and journal
1311         self.db.addnode(self.classname, newid, d)
1313         # retire?
1314         if retire:
1315             # use the arg for __retired__ to cope with any odd database type
1316             # conversion (hello, sqlite)
1317             sql = 'update _%s set __retired__=%s where id=%s'%(self.classname,
1318                 self.db.arg, self.db.arg)
1319             if __debug__:
1320                 print >>hyperdb.DEBUG, 'retire', (self, sql, newid)
1321             self.db.cursor.execute(sql, (1, newid))
1323         # extract the extraneous journalling gumpf and nuke it
1324         if d.has_key('creator'):
1325             creator = d['creator']
1326             del d['creator']
1327         else:
1328             creator = None
1329         if d.has_key('creation'):
1330             creation = d['creation']
1331             del d['creation']
1332         else:
1333             creation = None
1334         if d.has_key('activity'):
1335             del d['activity']
1336         self.db.addjournal(self.classname, newid, 'create', {}, creator,
1337             creation)
1338         return newid
1340     _marker = []
1341     def get(self, nodeid, propname, default=_marker, cache=1):
1342         '''Get the value of a property on an existing node of this class.
1344         'nodeid' must be the id of an existing node of this class or an
1345         IndexError is raised.  'propname' must be the name of a property
1346         of this class or a KeyError is raised.
1348         'cache' exists for backwards compatibility, and is not used.
1349         '''
1350         if propname == 'id':
1351             return nodeid
1353         # get the node's dict
1354         d = self.db.getnode(self.classname, nodeid)
1356         if propname == 'creation':
1357             if d.has_key('creation'):
1358                 return d['creation']
1359             else:
1360                 return date.Date()
1361         if propname == 'activity':
1362             if d.has_key('activity'):
1363                 return d['activity']
1364             else:
1365                 return date.Date()
1366         if propname == 'creator':
1367             if d.has_key('creator'):
1368                 return d['creator']
1369             else:
1370                 return self.db.getuid()
1372         # get the property (raises KeyErorr if invalid)
1373         prop = self.properties[propname]
1375         if not d.has_key(propname):
1376             if default is self._marker:
1377                 if isinstance(prop, Multilink):
1378                     return []
1379                 else:
1380                     return None
1381             else:
1382                 return default
1384         # don't pass our list to other code
1385         if isinstance(prop, Multilink):
1386             return d[propname][:]
1388         return d[propname]
1390     def set(self, nodeid, **propvalues):
1391         '''Modify a property on an existing node of this class.
1392         
1393         'nodeid' must be the id of an existing node of this class or an
1394         IndexError is raised.
1396         Each key in 'propvalues' must be the name of a property of this
1397         class or a KeyError is raised.
1399         All values in 'propvalues' must be acceptable types for their
1400         corresponding properties or a TypeError is raised.
1402         If the value of the key property is set, it must not collide with
1403         other key strings or a ValueError is raised.
1405         If the value of a Link or Multilink property contains an invalid
1406         node id, a ValueError is raised.
1407         '''
1408         if not propvalues:
1409             return propvalues
1411         if propvalues.has_key('creation') or propvalues.has_key('activity'):
1412             raise KeyError, '"creation" and "activity" are reserved'
1414         if propvalues.has_key('id'):
1415             raise KeyError, '"id" is reserved'
1417         if self.db.journaltag is None:
1418             raise DatabaseError, 'Database open read-only'
1420         self.fireAuditors('set', nodeid, propvalues)
1421         # Take a copy of the node dict so that the subsequent set
1422         # operation doesn't modify the oldvalues structure.
1423         # XXX used to try the cache here first
1424         oldvalues = copy.deepcopy(self.db.getnode(self.classname, nodeid))
1426         node = self.db.getnode(self.classname, nodeid)
1427         if self.is_retired(nodeid):
1428             raise IndexError, 'Requested item is retired'
1429         num_re = re.compile('^\d+$')
1431         # if the journal value is to be different, store it in here
1432         journalvalues = {}
1434         # remember the add/remove stuff for multilinks, making it easier
1435         # for the Database layer to do its stuff
1436         multilink_changes = {}
1438         for propname, value in propvalues.items():
1439             # check to make sure we're not duplicating an existing key
1440             if propname == self.key and node[propname] != value:
1441                 try:
1442                     self.lookup(value)
1443                 except KeyError:
1444                     pass
1445                 else:
1446                     raise ValueError, 'node with key "%s" exists'%value
1448             # this will raise the KeyError if the property isn't valid
1449             # ... we don't use getprops() here because we only care about
1450             # the writeable properties.
1451             try:
1452                 prop = self.properties[propname]
1453             except KeyError:
1454                 raise KeyError, '"%s" has no property named "%s"'%(
1455                     self.classname, propname)
1457             # if the value's the same as the existing value, no sense in
1458             # doing anything
1459             current = node.get(propname, None)
1460             if value == current:
1461                 del propvalues[propname]
1462                 continue
1463             journalvalues[propname] = current
1465             # do stuff based on the prop type
1466             if isinstance(prop, Link):
1467                 link_class = prop.classname
1468                 # if it isn't a number, it's a key
1469                 if value is not None and not isinstance(value, type('')):
1470                     raise ValueError, 'property "%s" link value be a string'%(
1471                         propname)
1472                 if isinstance(value, type('')) and not num_re.match(value):
1473                     try:
1474                         value = self.db.classes[link_class].lookup(value)
1475                     except (TypeError, KeyError):
1476                         raise IndexError, 'new property "%s": %s not a %s'%(
1477                             propname, value, prop.classname)
1479                 if (value is not None and
1480                         not self.db.getclass(link_class).hasnode(value)):
1481                     raise IndexError, '%s has no node %s'%(link_class, value)
1483                 if self.do_journal and prop.do_journal:
1484                     # register the unlink with the old linked node
1485                     if node[propname] is not None:
1486                         self.db.addjournal(link_class, node[propname], 'unlink',
1487                             (self.classname, nodeid, propname))
1489                     # register the link with the newly linked node
1490                     if value is not None:
1491                         self.db.addjournal(link_class, value, 'link',
1492                             (self.classname, nodeid, propname))
1494             elif isinstance(prop, Multilink):
1495                 if type(value) != type([]):
1496                     raise TypeError, 'new property "%s" not a list of'\
1497                         ' ids'%propname
1498                 link_class = self.properties[propname].classname
1499                 l = []
1500                 for entry in value:
1501                     # if it isn't a number, it's a key
1502                     if type(entry) != type(''):
1503                         raise ValueError, 'new property "%s" link value ' \
1504                             'must be a string'%propname
1505                     if not num_re.match(entry):
1506                         try:
1507                             entry = self.db.classes[link_class].lookup(entry)
1508                         except (TypeError, KeyError):
1509                             raise IndexError, 'new property "%s": %s not a %s'%(
1510                                 propname, entry,
1511                                 self.properties[propname].classname)
1512                     l.append(entry)
1513                 value = l
1514                 propvalues[propname] = value
1516                 # figure the journal entry for this property
1517                 add = []
1518                 remove = []
1520                 # handle removals
1521                 if node.has_key(propname):
1522                     l = node[propname]
1523                 else:
1524                     l = []
1525                 for id in l[:]:
1526                     if id in value:
1527                         continue
1528                     # register the unlink with the old linked node
1529                     if self.do_journal and self.properties[propname].do_journal:
1530                         self.db.addjournal(link_class, id, 'unlink',
1531                             (self.classname, nodeid, propname))
1532                     l.remove(id)
1533                     remove.append(id)
1535                 # handle additions
1536                 for id in value:
1537                     if not self.db.getclass(link_class).hasnode(id):
1538                         raise IndexError, '%s has no node %s'%(link_class, id)
1539                     if id in l:
1540                         continue
1541                     # register the link with the newly linked node
1542                     if self.do_journal and self.properties[propname].do_journal:
1543                         self.db.addjournal(link_class, id, 'link',
1544                             (self.classname, nodeid, propname))
1545                     l.append(id)
1546                     add.append(id)
1548                 # figure the journal entry
1549                 l = []
1550                 if add:
1551                     l.append(('+', add))
1552                 if remove:
1553                     l.append(('-', remove))
1554                 multilink_changes[propname] = (add, remove)
1555                 if l:
1556                     journalvalues[propname] = tuple(l)
1558             elif isinstance(prop, String):
1559                 if value is not None and type(value) != type('') and type(value) != type(u''):
1560                     raise TypeError, 'new property "%s" not a string'%propname
1562             elif isinstance(prop, Password):
1563                 if not isinstance(value, password.Password):
1564                     raise TypeError, 'new property "%s" not a Password'%propname
1565                 propvalues[propname] = value
1567             elif value is not None and isinstance(prop, Date):
1568                 if not isinstance(value, date.Date):
1569                     raise TypeError, 'new property "%s" not a Date'% propname
1570                 propvalues[propname] = value
1572             elif value is not None and isinstance(prop, Interval):
1573                 if not isinstance(value, date.Interval):
1574                     raise TypeError, 'new property "%s" not an '\
1575                         'Interval'%propname
1576                 propvalues[propname] = value
1578             elif value is not None and isinstance(prop, Number):
1579                 try:
1580                     float(value)
1581                 except ValueError:
1582                     raise TypeError, 'new property "%s" not numeric'%propname
1584             elif value is not None and isinstance(prop, Boolean):
1585                 try:
1586                     int(value)
1587                 except ValueError:
1588                     raise TypeError, 'new property "%s" not boolean'%propname
1590         # nothing to do?
1591         if not propvalues:
1592             return propvalues
1594         # do the set, and journal it
1595         self.db.setnode(self.classname, nodeid, propvalues, multilink_changes)
1597         if self.do_journal:
1598             self.db.addjournal(self.classname, nodeid, 'set', journalvalues)
1600         self.fireReactors('set', nodeid, oldvalues)
1602         return propvalues        
1604     def retire(self, nodeid):
1605         '''Retire a node.
1606         
1607         The properties on the node remain available from the get() method,
1608         and the node's id is never reused.
1609         
1610         Retired nodes are not returned by the find(), list(), or lookup()
1611         methods, and other nodes may reuse the values of their key properties.
1612         '''
1613         if self.db.journaltag is None:
1614             raise DatabaseError, 'Database open read-only'
1616         self.fireAuditors('retire', nodeid, None)
1618         # use the arg for __retired__ to cope with any odd database type
1619         # conversion (hello, sqlite)
1620         sql = 'update _%s set __retired__=%s where id=%s'%(self.classname,
1621             self.db.arg, self.db.arg)
1622         if __debug__:
1623             print >>hyperdb.DEBUG, 'retire', (self, sql, nodeid)
1624         self.db.cursor.execute(sql, (1, nodeid))
1625         if self.do_journal:
1626             self.db.addjournal(self.classname, nodeid, 'retired', None)
1628         self.fireReactors('retire', nodeid, None)
1630     def restore(self, nodeid):
1631         '''Restore a retired node.
1633         Make node available for all operations like it was before retirement.
1634         '''
1635         if self.db.journaltag is None:
1636             raise DatabaseError, 'Database open read-only'
1638         node = self.db.getnode(self.classname, nodeid)
1639         # check if key property was overrided
1640         key = self.getkey()
1641         try:
1642             id = self.lookup(node[key])
1643         except KeyError:
1644             pass
1645         else:
1646             raise KeyError, "Key property (%s) of retired node clashes with \
1647                 existing one (%s)" % (key, node[key])
1649         self.fireAuditors('restore', nodeid, None)
1650         # use the arg for __retired__ to cope with any odd database type
1651         # conversion (hello, sqlite)
1652         sql = 'update _%s set __retired__=%s where id=%s'%(self.classname,
1653             self.db.arg, self.db.arg)
1654         if __debug__:
1655             print >>hyperdb.DEBUG, 'restore', (self, sql, nodeid)
1656         self.db.cursor.execute(sql, (0, nodeid))
1657         if self.do_journal:
1658             self.db.addjournal(self.classname, nodeid, 'restored', None)
1660         self.fireReactors('restore', nodeid, None)
1661         
1662     def is_retired(self, nodeid):
1663         '''Return true if the node is rerired
1664         '''
1665         sql = 'select __retired__ from _%s where id=%s'%(self.classname,
1666             self.db.arg)
1667         if __debug__:
1668             print >>hyperdb.DEBUG, 'is_retired', (self, sql, nodeid)
1669         self.db.cursor.execute(sql, (nodeid,))
1670         return int(self.db.sql_fetchone()[0])
1672     def destroy(self, nodeid):
1673         '''Destroy a node.
1674         
1675         WARNING: this method should never be used except in extremely rare
1676                  situations where there could never be links to the node being
1677                  deleted
1679         WARNING: use retire() instead
1681         WARNING: the properties of this node will not be available ever again
1683         WARNING: really, use retire() instead
1685         Well, I think that's enough warnings. This method exists mostly to
1686         support the session storage of the cgi interface.
1688         The node is completely removed from the hyperdb, including all journal
1689         entries. It will no longer be available, and will generally break code
1690         if there are any references to the node.
1691         '''
1692         if self.db.journaltag is None:
1693             raise DatabaseError, 'Database open read-only'
1694         self.db.destroynode(self.classname, nodeid)
1696     def history(self, nodeid):
1697         '''Retrieve the journal of edits on a particular node.
1699         'nodeid' must be the id of an existing node of this class or an
1700         IndexError is raised.
1702         The returned list contains tuples of the form
1704             (nodeid, date, tag, action, params)
1706         'date' is a Timestamp object specifying the time of the change and
1707         'tag' is the journaltag specified when the database was opened.
1708         '''
1709         if not self.do_journal:
1710             raise ValueError, 'Journalling is disabled for this class'
1711         return self.db.getjournal(self.classname, nodeid)
1713     # Locating nodes:
1714     def hasnode(self, nodeid):
1715         '''Determine if the given nodeid actually exists
1716         '''
1717         return self.db.hasnode(self.classname, nodeid)
1719     def setkey(self, propname):
1720         '''Select a String property of this class to be the key property.
1722         'propname' must be the name of a String property of this class or
1723         None, or a TypeError is raised.  The values of the key property on
1724         all existing nodes must be unique or a ValueError is raised.
1725         '''
1726         # XXX create an index on the key prop column. We should also 
1727         # record that we've created this index in the schema somewhere.
1728         prop = self.getprops()[propname]
1729         if not isinstance(prop, String):
1730             raise TypeError, 'key properties must be String'
1731         self.key = propname
1733     def getkey(self):
1734         '''Return the name of the key property for this class or None.'''
1735         return self.key
1737     def labelprop(self, default_to_id=0):
1738         '''Return the property name for a label for the given node.
1740         This method attempts to generate a consistent label for the node.
1741         It tries the following in order:
1743         1. key property
1744         2. "name" property
1745         3. "title" property
1746         4. first property from the sorted property name list
1747         '''
1748         k = self.getkey()
1749         if  k:
1750             return k
1751         props = self.getprops()
1752         if props.has_key('name'):
1753             return 'name'
1754         elif props.has_key('title'):
1755             return 'title'
1756         if default_to_id:
1757             return 'id'
1758         props = props.keys()
1759         props.sort()
1760         return props[0]
1762     def lookup(self, keyvalue):
1763         '''Locate a particular node by its key property and return its id.
1765         If this class has no key property, a TypeError is raised.  If the
1766         'keyvalue' matches one of the values for the key property among
1767         the nodes in this class, the matching node's id is returned;
1768         otherwise a KeyError is raised.
1769         '''
1770         if not self.key:
1771             raise TypeError, 'No key property set for class %s'%self.classname
1773         # use the arg to handle any odd database type conversion (hello,
1774         # sqlite)
1775         sql = "select id from _%s where _%s=%s and __retired__ <> %s"%(
1776             self.classname, self.key, self.db.arg, self.db.arg)
1777         self.db.sql(sql, (keyvalue, 1))
1779         # see if there was a result that's not retired
1780         row = self.db.sql_fetchone()
1781         if not row:
1782             raise KeyError, 'No key (%s) value "%s" for "%s"'%(self.key,
1783                 keyvalue, self.classname)
1785         # return the id
1786         return row[0]
1788     def find(self, **propspec):
1789         '''Get the ids of nodes in this class which link to the given nodes.
1791         'propspec' consists of keyword args propname=nodeid or
1792                    propname={nodeid:1, }
1793         'propname' must be the name of a property in this class, or a
1794                    KeyError is raised.  That property must be a Link or
1795                    Multilink property, or a TypeError is raised.
1797         Any node in this class whose 'propname' property links to any of the
1798         nodeids will be returned. Used by the full text indexing, which knows
1799         that "foo" occurs in msg1, msg3 and file7, so we have hits on these
1800         issues:
1802             db.issue.find(messages={'1':1,'3':1}, files={'7':1})
1803         '''
1804         if __debug__:
1805             print >>hyperdb.DEBUG, 'find', (self, propspec)
1807         # shortcut
1808         if not propspec:
1809             return []
1811         # validate the args
1812         props = self.getprops()
1813         propspec = propspec.items()
1814         for propname, nodeids in propspec:
1815             # check the prop is OK
1816             prop = props[propname]
1817             if not isinstance(prop, Link) and not isinstance(prop, Multilink):
1818                 raise TypeError, "'%s' not a Link/Multilink property"%propname
1820         # first, links
1821         a = self.db.arg
1822         allvalues = (1,)
1823         o = []
1824         where = []
1825         for prop, values in propspec:
1826             if not isinstance(props[prop], hyperdb.Link):
1827                 continue
1828             if type(values) is type({}) and len(values) == 1:
1829                 values = values.keys()[0]
1830             if type(values) is type(''):
1831                 allvalues += (values,)
1832                 where.append('_%s = %s'%(prop, a))
1833             elif values is None:
1834                 where.append('_%s is NULL'%prop)
1835             else:
1836                 allvalues += tuple(values.keys())
1837                 where.append('_%s in (%s)'%(prop, ','.join([a]*len(values))))
1838         tables = ['_%s'%self.classname]
1839         if where:
1840             o.append('(' + ' and '.join(where) + ')')
1842         # now multilinks
1843         for prop, values in propspec:
1844             if not isinstance(props[prop], hyperdb.Multilink):
1845                 continue
1846             if not values:
1847                 continue
1848             if type(values) is type(''):
1849                 allvalues += (values,)
1850                 s = a
1851             else:
1852                 allvalues += tuple(values.keys())
1853                 s = ','.join([a]*len(values))
1854             tn = '%s_%s'%(self.classname, prop)
1855             tables.append(tn)
1856             o.append('(id=%s.nodeid and %s.linkid in (%s))'%(tn, tn, s))
1858         if not o:
1859             return []
1860         elif len(o) > 1:
1861             o = '(' + ' or '.join(['(%s)'%i for i in o]) + ')'
1862         else:
1863             o = o[0]
1864         t = ', '.join(tables)
1865         sql = 'select distinct(id) from %s where __retired__ <> %s and %s'%(t, a, o)
1866         self.db.sql(sql, allvalues)
1867         l = [x[0] for x in self.db.sql_fetchall()]
1868         if __debug__:
1869             print >>hyperdb.DEBUG, 'find ... ', l
1870         return l
1872     def stringFind(self, **requirements):
1873         '''Locate a particular node by matching a set of its String
1874         properties in a caseless search.
1876         If the property is not a String property, a TypeError is raised.
1877         
1878         The return is a list of the id of all nodes that match.
1879         '''
1880         where = []
1881         args = []
1882         for propname in requirements.keys():
1883             prop = self.properties[propname]
1884             if not isinstance(prop, String):
1885                 raise TypeError, "'%s' not a String property"%propname
1886             where.append(propname)
1887             args.append(requirements[propname].lower())
1889         # generate the where clause
1890         s = ' and '.join(['lower(_%s)=%s'%(col, self.db.arg) for col in where])
1891         sql = 'select id from _%s where %s and __retired__=%s'%(self.classname,
1892             s, self.db.arg)
1893         args.append(0)
1894         self.db.sql(sql, tuple(args))
1895         l = [x[0] for x in self.db.sql_fetchall()]
1896         if __debug__:
1897             print >>hyperdb.DEBUG, 'find ... ', l
1898         return l
1900     def list(self):
1901         ''' Return a list of the ids of the active nodes in this class.
1902         '''
1903         return self.getnodeids(retired=0)
1905     def getnodeids(self, retired=None):
1906         ''' Retrieve all the ids of the nodes for a particular Class.
1908             Set retired=None to get all nodes. Otherwise it'll get all the 
1909             retired or non-retired nodes, depending on the flag.
1910         '''
1911         # flip the sense of the 'retired' flag if we don't want all of them
1912         if retired is not None:
1913             if retired:
1914                 args = (0, )
1915             else:
1916                 args = (1, )
1917             sql = 'select id from _%s where __retired__ <> %s'%(self.classname,
1918                 self.db.arg)
1919         else:
1920             args = ()
1921             sql = 'select id from _%s'%self.classname
1922         if __debug__:
1923             print >>hyperdb.DEBUG, 'getnodeids', (self, sql, retired)
1924         self.db.cursor.execute(sql, args)
1925         ids = [x[0] for x in self.db.cursor.fetchall()]
1926         return ids
1928     def filter(self, search_matches, filterspec, sort=(None,None),
1929             group=(None,None)):
1930         '''Return a list of the ids of the active nodes in this class that
1931         match the 'filter' spec, sorted by the group spec and then the
1932         sort spec
1934         "filterspec" is {propname: value(s)}
1936         "sort" and "group" are (dir, prop) where dir is '+', '-' or None
1937         and prop is a prop name or None
1939         "search_matches" is {nodeid: marker}
1941         The filter must match all properties specificed - but if the
1942         property value to match is a list, any one of the values in the
1943         list may match for that property to match.
1944         '''
1945         # just don't bother if the full-text search matched diddly
1946         if search_matches == {}:
1947             return []
1949         cn = self.classname
1951         timezone = self.db.getUserTimezone()
1952         
1953         # figure the WHERE clause from the filterspec
1954         props = self.getprops()
1955         frum = ['_'+cn]
1956         where = []
1957         args = []
1958         a = self.db.arg
1959         for k, v in filterspec.items():
1960             propclass = props[k]
1961             # now do other where clause stuff
1962             if isinstance(propclass, Multilink):
1963                 tn = '%s_%s'%(cn, k)
1964                 if v in ('-1', ['-1']):
1965                     # only match rows that have count(linkid)=0 in the
1966                     # corresponding multilink table)
1967                     where.append('id not in (select nodeid from %s)'%tn)
1968                 elif isinstance(v, type([])):
1969                     frum.append(tn)
1970                     s = ','.join([a for x in v])
1971                     where.append('id=%s.nodeid and %s.linkid in (%s)'%(tn,tn,s))
1972                     args = args + v
1973                 else:
1974                     frum.append(tn)
1975                     where.append('id=%s.nodeid and %s.linkid=%s'%(tn, tn, a))
1976                     args.append(v)
1977             elif k == 'id':
1978                 if isinstance(v, type([])):
1979                     s = ','.join([a for x in v])
1980                     where.append('%s in (%s)'%(k, s))
1981                     args = args + v
1982                 else:
1983                     where.append('%s=%s'%(k, a))
1984                     args.append(v)
1985             elif isinstance(propclass, String):
1986                 if not isinstance(v, type([])):
1987                     v = [v]
1989                 # Quote the bits in the string that need it and then embed
1990                 # in a "substring" search. Note - need to quote the '%' so
1991                 # they make it through the python layer happily
1992                 v = ['%%'+self.db.sql_stringquote(s)+'%%' for s in v]
1994                 # now add to the where clause
1995                 where.append(' or '.join(["_%s LIKE '%s'"%(k, s) for s in v]))
1996                 # note: args are embedded in the query string now
1997             elif isinstance(propclass, Link):
1998                 if isinstance(v, type([])):
1999                     if '-1' in v:
2000                         v = v[:]
2001                         v.remove('-1')
2002                         xtra = ' or _%s is NULL'%k
2003                     else:
2004                         xtra = ''
2005                     if v:
2006                         s = ','.join([a for x in v])
2007                         where.append('(_%s in (%s)%s)'%(k, s, xtra))
2008                         args = args + v
2009                     else:
2010                         where.append('_%s is NULL'%k)
2011                 else:
2012                     if v == '-1':
2013                         v = None
2014                         where.append('_%s is NULL'%k)
2015                     else:
2016                         where.append('_%s=%s'%(k, a))
2017                         args.append(v)
2018             elif isinstance(propclass, Date):
2019                 if isinstance(v, type([])):
2020                     s = ','.join([a for x in v])
2021                     where.append('_%s in (%s)'%(k, s))
2022                     args = args + [date.Date(x).serialise() for x in v]
2023                 else:
2024                     try:
2025                         # Try to filter on range of dates
2026                         date_rng = Range(v, date.Date, offset=timezone)
2027                         if (date_rng.from_value):
2028                             where.append('_%s >= %s'%(k, a))                            
2029                             args.append(date_rng.from_value.serialise())
2030                         if (date_rng.to_value):
2031                             where.append('_%s <= %s'%(k, a))
2032                             args.append(date_rng.to_value.serialise())
2033                     except ValueError:
2034                         # If range creation fails - ignore that search parameter
2035                         pass                        
2036             elif isinstance(propclass, Interval):
2037                 if isinstance(v, type([])):
2038                     s = ','.join([a for x in v])
2039                     where.append('_%s in (%s)'%(k, s))
2040                     args = args + [date.Interval(x).serialise() for x in v]
2041                 else:
2042                     try:
2043                         # Try to filter on range of intervals
2044                         date_rng = Range(v, date.Interval)
2045                         if (date_rng.from_value):
2046                             where.append('_%s >= %s'%(k, a))
2047                             args.append(date_rng.from_value.serialise())
2048                         if (date_rng.to_value):
2049                             where.append('_%s <= %s'%(k, a))
2050                             args.append(date_rng.to_value.serialise())
2051                     except ValueError:
2052                         # If range creation fails - ignore that search parameter
2053                         pass                        
2054                     #where.append('_%s=%s'%(k, a))
2055                     #args.append(date.Interval(v).serialise())
2056             else:
2057                 if isinstance(v, type([])):
2058                     s = ','.join([a for x in v])
2059                     where.append('_%s in (%s)'%(k, s))
2060                     args = args + v
2061                 else:
2062                     where.append('_%s=%s'%(k, a))
2063                     args.append(v)
2065         # don't match retired nodes
2066         where.append('__retired__ <> 1')
2068         # add results of full text search
2069         if search_matches is not None:
2070             v = search_matches.keys()
2071             s = ','.join([a for x in v])
2072             where.append('id in (%s)'%s)
2073             args = args + v
2075         # "grouping" is just the first-order sorting in the SQL fetch
2076         # can modify it...)
2077         orderby = []
2078         ordercols = []
2079         if group[0] is not None and group[1] is not None:
2080             if group[0] != '-':
2081                 orderby.append('_'+group[1])
2082                 ordercols.append('_'+group[1])
2083             else:
2084                 orderby.append('_'+group[1]+' desc')
2085                 ordercols.append('_'+group[1])
2087         # now add in the sorting
2088         group = ''
2089         if sort[0] is not None and sort[1] is not None:
2090             direction, colname = sort
2091             if direction != '-':
2092                 if colname == 'id':
2093                     orderby.append(colname)
2094                 else:
2095                     orderby.append('_'+colname)
2096                     ordercols.append('_'+colname)
2097             else:
2098                 if colname == 'id':
2099                     orderby.append(colname+' desc')
2100                     ordercols.append(colname)
2101                 else:
2102                     orderby.append('_'+colname+' desc')
2103                     ordercols.append('_'+colname)
2105         # construct the SQL
2106         frum = ','.join(frum)
2107         if where:
2108             where = ' where ' + (' and '.join(where))
2109         else:
2110             where = ''
2111         cols = ['id']
2112         if orderby:
2113             cols = cols + ordercols
2114             order = ' order by %s'%(','.join(orderby))
2115         else:
2116             order = ''
2117         cols = ','.join(cols)
2118         sql = 'select %s from %s %s%s%s'%(cols, frum, where, group, order)
2119         args = tuple(args)
2120         if __debug__:
2121             print >>hyperdb.DEBUG, 'filter', (self, sql, args)
2122         if args:
2123             self.db.cursor.execute(sql, args)
2124         else:
2125             # psycopg doesn't like empty args
2126             self.db.cursor.execute(sql)
2127         l = self.db.sql_fetchall()
2129         # return the IDs (the first column)
2130         return [row[0] for row in l]
2132     def count(self):
2133         '''Get the number of nodes in this class.
2135         If the returned integer is 'numnodes', the ids of all the nodes
2136         in this class run from 1 to numnodes, and numnodes+1 will be the
2137         id of the next node to be created in this class.
2138         '''
2139         return self.db.countnodes(self.classname)
2141     # Manipulating properties:
2142     def getprops(self, protected=1):
2143         '''Return a dictionary mapping property names to property objects.
2144            If the "protected" flag is true, we include protected properties -
2145            those which may not be modified.
2146         '''
2147         d = self.properties.copy()
2148         if protected:
2149             d['id'] = String()
2150             d['creation'] = hyperdb.Date()
2151             d['activity'] = hyperdb.Date()
2152             d['creator'] = hyperdb.Link('user')
2153         return d
2155     def addprop(self, **properties):
2156         '''Add properties to this class.
2158         The keyword arguments in 'properties' must map names to property
2159         objects, or a TypeError is raised.  None of the keys in 'properties'
2160         may collide with the names of existing properties, or a ValueError
2161         is raised before any properties have been added.
2162         '''
2163         for key in properties.keys():
2164             if self.properties.has_key(key):
2165                 raise ValueError, key
2166         self.properties.update(properties)
2168     def index(self, nodeid):
2169         '''Add (or refresh) the node to search indexes
2170         '''
2171         # find all the String properties that have indexme
2172         for prop, propclass in self.getprops().items():
2173             if isinstance(propclass, String) and propclass.indexme:
2174                 try:
2175                     value = str(self.get(nodeid, prop))
2176                 except IndexError:
2177                     # node no longer exists - entry should be removed
2178                     self.db.indexer.purge_entry((self.classname, nodeid, prop))
2179                 else:
2180                     # and index them under (classname, nodeid, property)
2181                     self.db.indexer.add_text((self.classname, nodeid, prop),
2182                         value)
2185     #
2186     # Detector interface
2187     #
2188     def audit(self, event, detector):
2189         '''Register a detector
2190         '''
2191         l = self.auditors[event]
2192         if detector not in l:
2193             self.auditors[event].append(detector)
2195     def fireAuditors(self, action, nodeid, newvalues):
2196         '''Fire all registered auditors.
2197         '''
2198         for audit in self.auditors[action]:
2199             audit(self.db, self, nodeid, newvalues)
2201     def react(self, event, detector):
2202         '''Register a detector
2203         '''
2204         l = self.reactors[event]
2205         if detector not in l:
2206             self.reactors[event].append(detector)
2208     def fireReactors(self, action, nodeid, oldvalues):
2209         '''Fire all registered reactors.
2210         '''
2211         for react in self.reactors[action]:
2212             react(self.db, self, nodeid, oldvalues)
2214 class FileClass(Class, hyperdb.FileClass):
2215     '''This class defines a large chunk of data. To support this, it has a
2216        mandatory String property "content" which is typically saved off
2217        externally to the hyperdb.
2219        The default MIME type of this data is defined by the
2220        "default_mime_type" class attribute, which may be overridden by each
2221        node if the class defines a "type" String property.
2222     '''
2223     default_mime_type = 'text/plain'
2225     def create(self, **propvalues):
2226         ''' snaffle the file propvalue and store in a file
2227         '''
2228         # we need to fire the auditors now, or the content property won't
2229         # be in propvalues for the auditors to play with
2230         self.fireAuditors('create', None, propvalues)
2232         # now remove the content property so it's not stored in the db
2233         content = propvalues['content']
2234         del propvalues['content']
2236         # do the database create
2237         newid = Class.create_inner(self, **propvalues)
2239         # fire reactors
2240         self.fireReactors('create', newid, None)
2242         # store off the content as a file
2243         self.db.storefile(self.classname, newid, None, content)
2244         return newid
2246     def import_list(self, propnames, proplist):
2247         ''' Trap the "content" property...
2248         '''
2249         # dupe this list so we don't affect others
2250         propnames = propnames[:]
2252         # extract the "content" property from the proplist
2253         i = propnames.index('content')
2254         content = eval(proplist[i])
2255         del propnames[i]
2256         del proplist[i]
2258         # do the normal import
2259         newid = Class.import_list(self, propnames, proplist)
2261         # save off the "content" file
2262         self.db.storefile(self.classname, newid, None, content)
2263         return newid
2265     _marker = []
2266     def get(self, nodeid, propname, default=_marker, cache=1):
2267         ''' Trap the content propname and get it from the file
2269         'cache' exists for backwards compatibility, and is not used.
2270         '''
2271         poss_msg = 'Possibly a access right configuration problem.'
2272         if propname == 'content':
2273             try:
2274                 return self.db.getfile(self.classname, nodeid, None)
2275             except IOError, (strerror):
2276                 # BUG: by catching this we donot see an error in the log.
2277                 return 'ERROR reading file: %s%s\n%s\n%s'%(
2278                         self.classname, nodeid, poss_msg, strerror)
2279         if default is not self._marker:
2280             return Class.get(self, nodeid, propname, default)
2281         else:
2282             return Class.get(self, nodeid, propname)
2284     def getprops(self, protected=1):
2285         ''' In addition to the actual properties on the node, these methods
2286             provide the "content" property. If the "protected" flag is true,
2287             we include protected properties - those which may not be
2288             modified.
2289         '''
2290         d = Class.getprops(self, protected=protected).copy()
2291         d['content'] = hyperdb.String()
2292         return d
2294     def index(self, nodeid):
2295         ''' Index the node in the search index.
2297             We want to index the content in addition to the normal String
2298             property indexing.
2299         '''
2300         # perform normal indexing
2301         Class.index(self, nodeid)
2303         # get the content to index
2304         content = self.get(nodeid, 'content')
2306         # figure the mime type
2307         if self.properties.has_key('type'):
2308             mime_type = self.get(nodeid, 'type')
2309         else:
2310             mime_type = self.default_mime_type
2312         # and index!
2313         self.db.indexer.add_text((self.classname, nodeid, 'content'), content,
2314             mime_type)
2316 # XXX deviation from spec - was called ItemClass
2317 class IssueClass(Class, roundupdb.IssueClass):
2318     # Overridden methods:
2319     def __init__(self, db, classname, **properties):
2320         '''The newly-created class automatically includes the "messages",
2321         "files", "nosy", and "superseder" properties.  If the 'properties'
2322         dictionary attempts to specify any of these properties or a
2323         "creation" or "activity" property, a ValueError is raised.
2324         '''
2325         if not properties.has_key('title'):
2326             properties['title'] = hyperdb.String(indexme='yes')
2327         if not properties.has_key('messages'):
2328             properties['messages'] = hyperdb.Multilink("msg")
2329         if not properties.has_key('files'):
2330             properties['files'] = hyperdb.Multilink("file")
2331         if not properties.has_key('nosy'):
2332             # note: journalling is turned off as it really just wastes
2333             # space. this behaviour may be overridden in an instance
2334             properties['nosy'] = hyperdb.Multilink("user", do_journal="no")
2335         if not properties.has_key('superseder'):
2336             properties['superseder'] = hyperdb.Multilink(classname)
2337         Class.__init__(self, db, classname, **properties)