Code

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