Code

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