Code

That's the last of the RDBMS migration steps done! Yay!
[roundup.git] / roundup / backends / rdbms_common.py
1 # $Id: rdbms_common.py,v 1.83 2004-03-21 23:39:08 richard Exp $
2 ''' Relational database (SQL) backend common code.
4 Basics:
6 - map roundup classes to relational tables
7 - automatically detect schema changes and modify the table schemas
8   appropriately (we store the "database version" of the schema in the
9   database itself as the only row of the "schema" table)
10 - multilinks (which represent a many-to-many relationship) are handled through
11   intermediate tables
12 - journals are stored adjunct to the per-class tables
13 - table names and columns have "_" prepended so the names can't clash with
14   restricted names (like "order")
15 - retirement is determined by the __retired__ column being true
17 Database-specific changes may generally be pushed out to the overridable
18 sql_* methods, since everything else should be fairly generic. There's
19 probably a bit of work to be done if a database is used that actually
20 honors column typing, since the initial databases don't (sqlite stores
21 everything as a string.)
23 The schema of the hyperdb being mapped to the database is stored in the
24 database itself as a repr()'ed dictionary of information about each Class
25 that maps to a table. If that information differs from the hyperdb schema,
26 then we update it. We also store in the schema dict a version which
27 allows us to upgrade the database schema when necessary. See upgrade_db().
28 '''
29 __docformat__ = 'restructuredtext'
31 # standard python modules
32 import sys, os, time, re, errno, weakref, copy
34 # roundup modules
35 from roundup import hyperdb, date, password, roundupdb, security
36 from roundup.hyperdb import String, Password, Date, Interval, Link, \
37     Multilink, DatabaseError, Boolean, Number, Node
38 from roundup.backends import locking
40 # support
41 from blobfiles import FileStorage
42 from indexer_rdbms import Indexer
43 from sessions_rdbms import Sessions, OneTimeKeys
44 from roundup.date import Range
46 # number of rows to keep in memory
47 ROW_CACHE_SIZE = 100
49 class Database(FileStorage, hyperdb.Database, roundupdb.Database):
50     ''' Wrapper around an SQL database that presents a hyperdb interface.
52         - some functionality is specific to the actual SQL database, hence
53           the sql_* methods that are NotImplemented
54         - we keep a cache of the latest ROW_CACHE_SIZE row fetches.
55     '''
56     def __init__(self, config, journaltag=None):
57         ''' Open the database and load the schema from it.
58         '''
59         self.config, self.journaltag = config, journaltag
60         self.dir = config.DATABASE
61         self.classes = {}
62         self.indexer = Indexer(self)
63         self.security = security.Security(self)
65         # additional transaction support for external files and the like
66         self.transactions = []
68         # keep a cache of the N most recently retrieved rows of any kind
69         # (classname, nodeid) = row
70         self.cache = {}
71         self.cache_lru = []
73         # database lock
74         self.lockfile = None
76         # open a connection to the database, creating the "conn" attribute
77         self.open_connection()
79     def clearCache(self):
80         self.cache = {}
81         self.cache_lru = []
83     def getSessionManager(self):
84         return Sessions(self)
86     def getOTKManager(self):
87         return OneTimeKeys(self)
89     def open_connection(self):
90         ''' Open a connection to the database, creating it if necessary.
92             Must call self.load_dbschema()
93         '''
94         raise NotImplemented
96     def sql(self, sql, args=None):
97         ''' Execute the sql with the optional args.
98         '''
99         if __debug__:
100             print >>hyperdb.DEBUG, (self, sql, args)
101         if args:
102             self.cursor.execute(sql, args)
103         else:
104             self.cursor.execute(sql)
106     def sql_fetchone(self):
107         ''' Fetch a single row. If there's nothing to fetch, return None.
108         '''
109         return self.cursor.fetchone()
111     def sql_fetchall(self):
112         ''' Fetch all rows. If there's nothing to fetch, return [].
113         '''
114         return self.cursor.fetchall()
116     def sql_stringquote(self, value):
117         ''' Quote the string so it's safe to put in the 'sql quotes'
118         '''
119         return re.sub("'", "''", str(value))
121     def init_dbschema(self):
122         self.database_schema = {
123             'version': self.current_db_version,
124             'tables': {}
125         }
127     def load_dbschema(self):
128         ''' Load the schema definition that the database currently implements
129         '''
130         self.cursor.execute('select schema from schema')
131         schema = self.cursor.fetchone()
132         if schema:
133             self.database_schema = eval(schema[0])
134         else:
135             self.database_schema = {}
137     def save_dbschema(self, schema):
138         ''' Save the schema definition that the database currently implements
139         '''
140         s = repr(self.database_schema)
141         self.sql('insert into schema values (%s)', (s,))
143     def post_init(self):
144         ''' Called once the schema initialisation has finished.
146             We should now confirm that the schema defined by our "classes"
147             attribute actually matches the schema in the database.
148         '''
149         save = self.upgrade_db()
151         # now detect changes in the schema
152         tables = self.database_schema['tables']
153         for classname, spec in self.classes.items():
154             if tables.has_key(classname):
155                 dbspec = tables[classname]
156                 if self.update_class(spec, dbspec):
157                     tables[classname] = spec.schema()
158                     save = 1
159             else:
160                 self.create_class(spec)
161                 tables[classname] = spec.schema()
162                 save = 1
164         for classname, spec in tables.items():
165             if not self.classes.has_key(classname):
166                 self.drop_class(classname, tables[classname])
167                 del tables[classname]
168                 save = 1
170         # update the database version of the schema
171         if save:
172             self.sql('delete from schema')
173             self.save_dbschema(self.database_schema)
175         # reindex the db if necessary
176         if self.indexer.should_reindex():
177             self.reindex()
179         # commit
180         self.sql_commit()
182     # update this number when we need to make changes to the SQL structure
183     # of the backen database
184     current_db_version = 2
185     def upgrade_db(self):
186         ''' Update the SQL database to reflect changes in the backend code.
188             Return boolean whether we need to save the schema.
189         '''
190         version = self.database_schema.get('version', 1)
191         if version == self.current_db_version:
192             # nothing to do
193             return 0
195         if version == 1:
196             # version 1 doesn't have the OTK, session and indexing in the
197             # database
198             self.create_version_2_tables()
199             # version 1 also didn't have the actor column
200             self.add_actor_column()
202         self.database_schema['version'] = self.current_db_version
203         return 1
206     def refresh_database(self):
207         self.post_init()
209     def reindex(self):
210         for klass in self.classes.values():
211             for nodeid in klass.list():
212                 klass.index(nodeid)
213         self.indexer.save_index()
215     def determine_columns(self, properties):
216         ''' Figure the column names and multilink properties from the spec
218             "properties" is a list of (name, prop) where prop may be an
219             instance of a hyperdb "type" _or_ a string repr of that type.
220         '''
221         cols = ['_actor', '_activity', '_creator', '_creation']
222         mls = []
223         # add the multilinks separately
224         for col, prop in properties:
225             if isinstance(prop, Multilink):
226                 mls.append(col)
227             elif isinstance(prop, type('')) and prop.find('Multilink') != -1:
228                 mls.append(col)
229             else:
230                 cols.append('_'+col)
231         cols.sort()
232         return cols, mls
234     def update_class(self, spec, old_spec, force=0):
235         ''' Determine the differences between the current spec and the
236             database version of the spec, and update where necessary.
238             If 'force' is true, update the database anyway.
239         '''
240         new_has = spec.properties.has_key
241         new_spec = spec.schema()
242         new_spec[1].sort()
243         old_spec[1].sort()
244         if not force and new_spec == old_spec:
245             # no changes
246             return 0
248         if __debug__:
249             print >>hyperdb.DEBUG, 'update_class FIRING'
251         # detect key prop change for potential index change
252         keyprop_changes = {}
253         if new_spec[0] != old_spec[0]:
254             keyprop_changes = {'remove': old_spec[0], 'add': new_spec[0]}
256         # detect multilinks that have been removed, and drop their table
257         old_has = {}
258         for name, prop in old_spec[1]:
259             old_has[name] = 1
260             if new_has(name):
261                 continue
263             if prop.find('Multilink to') != -1:
264                 # first drop indexes.
265                 self.drop_multilink_table_indexes(spec.classname, name)
267                 # now the multilink table itself
268                 sql = 'drop table %s_%s'%(spec.classname, name)
269             else:
270                 # if this is the key prop, drop the index first
271                 if old_spec[0] == prop:
272                     self.drop_class_table_key_index(spec.classname, name)
273                     del keyprop_changes['remove']
275                 # drop the column
276                 sql = 'alter table _%s drop column _%s'%(spec.classname, name)
278             if __debug__:
279                 print >>hyperdb.DEBUG, 'update_class', (self, sql)
280             self.cursor.execute(sql)
281         old_has = old_has.has_key
283         # if we didn't remove the key prop just then, but the key prop has
284         # changed, we still need to remove the old index
285         if keyprop_changes.has_key('remove'):
286             self.drop_class_table_key_index(spec.classname,
287                 keyprop_changes['remove'])
289         # add new columns
290         for propname, x in new_spec[1]:
291             if old_has(propname):
292                 continue
293             sql = 'alter table _%s add column _%s varchar(255)'%(
294                 spec.classname, propname)
295             if __debug__:
296                 print >>hyperdb.DEBUG, 'update_class', (self, sql)
297             self.cursor.execute(sql)
299             # if the new column is a key prop, we need an index!
300             if new_spec[0] == propname:
301                 self.create_class_table_key_index(spec.classname, propname)
302                 del keyprop_changes['add']
304         # if we didn't add the key prop just then, but the key prop has
305         # changed, we still need to add the new index
306         if keyprop_changes.has_key('add'):
307             self.create_class_table_key_index(spec.classname,
308                 keyprop_changes['add'])
310         return 1
312     def create_class_table(self, spec):
313         ''' create the class table for the given spec
314         '''
315         cols, mls = self.determine_columns(spec.properties.items())
317         # add on our special columns
318         cols.append('id')
319         cols.append('__retired__')
321         # create the base table
322         scols = ','.join(['%s varchar'%x for x in cols])
323         sql = 'create table _%s (%s)'%(spec.classname, scols)
324         if __debug__:
325             print >>hyperdb.DEBUG, 'create_class', (self, sql)
326         self.cursor.execute(sql)
328         self.create_class_table_indexes(spec)
330         return cols, mls
332     def create_class_table_indexes(self, spec):
333         ''' create the class table for the given spec
334         '''
335         # create id index
336         index_sql1 = 'create index _%s_id_idx on _%s(id)'%(
337                         spec.classname, spec.classname)
338         if __debug__:
339             print >>hyperdb.DEBUG, 'create_index', (self, index_sql1)
340         self.cursor.execute(index_sql1)
342         # create __retired__ index
343         index_sql2 = 'create index _%s_retired_idx on _%s(__retired__)'%(
344                         spec.classname, spec.classname)
345         if __debug__:
346             print >>hyperdb.DEBUG, 'create_index', (self, index_sql2)
347         self.cursor.execute(index_sql2)
349         # create index for key property
350         if spec.key:
351             if __debug__:
352                 print >>hyperdb.DEBUG, 'update_class setting keyprop %r'% \
353                     spec.key
354             index_sql3 = 'create index _%s_%s_idx on _%s(_%s)'%(
355                         spec.classname, spec.key,
356                         spec.classname, spec.key)
357             if __debug__:
358                 print >>hyperdb.DEBUG, 'create_index', (self, index_sql3)
359             self.cursor.execute(index_sql3)
361     def drop_class_table_indexes(self, cn, key):
362         # drop the old table indexes first
363         l = ['_%s_id_idx'%cn, '_%s_retired_idx'%cn]
364         if key:
365             l.append('_%s_%s_idx'%(cn, key))
367         table_name = '_%s'%cn
368         for index_name in l:
369             if not self.sql_index_exists(table_name, index_name):
370                 continue
371             index_sql = 'drop index '+index_name
372             if __debug__:
373                 print >>hyperdb.DEBUG, 'drop_index', (self, index_sql)
374             self.cursor.execute(index_sql)
376     def create_class_table_key_index(self, cn, key):
377         ''' create the class table for the given spec
378         '''
379         if __debug__:
380             print >>hyperdb.DEBUG, 'update_class setting keyprop %r'% \
381                 key
382         index_sql3 = 'create index _%s_%s_idx on _%s(_%s)'%(cn, key,
383             cn, key)
384         if __debug__:
385             print >>hyperdb.DEBUG, 'create_index', (self, index_sql3)
386         self.cursor.execute(index_sql3)
388     def drop_class_table_key_index(self, cn, key):
389         table_name = '_%s'%cn
390         index_name = '_%s_%s_idx'%(cn, key)
391         if not self.sql_index_exists(table_name, index_name):
392             return
393         sql = 'drop index '+index_name
394         if __debug__:
395             print >>hyperdb.DEBUG, 'drop_index', (self, sql)
396         self.cursor.execute(sql)
398     def create_journal_table(self, spec):
399         ''' create the journal table for a class given the spec and 
400             already-determined cols
401         '''
402         # journal table
403         cols = ','.join(['%s varchar'%x
404             for x in 'nodeid date tag action params'.split()])
405         sql = 'create table %s__journal (%s)'%(spec.classname, cols)
406         if __debug__:
407             print >>hyperdb.DEBUG, 'create_class', (self, sql)
408         self.cursor.execute(sql)
409         self.create_journal_table_indexes(spec)
411     def create_journal_table_indexes(self, spec):
412         # index on nodeid
413         sql = 'create index %s_journ_idx on %s__journal(nodeid)'%(
414                         spec.classname, spec.classname)
415         if __debug__:
416             print >>hyperdb.DEBUG, 'create_index', (self, sql)
417         self.cursor.execute(sql)
419     def drop_journal_table_indexes(self, classname):
420         index_name = '%s_journ_idx'%classname
421         if not self.sql_index_exists('%s__journal'%classname, index_name):
422             return
423         index_sql = 'drop index '+index_name
424         if __debug__:
425             print >>hyperdb.DEBUG, 'drop_index', (self, index_sql)
426         self.cursor.execute(index_sql)
428     def create_multilink_table(self, spec, ml):
429         ''' Create a multilink table for the "ml" property of the class
430             given by the spec
431         '''
432         # create the table
433         sql = 'create table %s_%s (linkid varchar, nodeid varchar)'%(
434             spec.classname, ml)
435         if __debug__:
436             print >>hyperdb.DEBUG, 'create_class', (self, sql)
437         self.cursor.execute(sql)
438         self.create_multilink_table_indexes(spec, ml)
440     def create_multilink_table_indexes(self, spec, ml):
441         # create index on linkid
442         index_sql = 'create index %s_%s_l_idx on %s_%s(linkid)'%(
443                         spec.classname, ml, spec.classname, ml)
444         if __debug__:
445             print >>hyperdb.DEBUG, 'create_index', (self, index_sql)
446         self.cursor.execute(index_sql)
448         # create index on nodeid
449         index_sql = 'create index %s_%s_n_idx on %s_%s(nodeid)'%(
450                         spec.classname, ml, spec.classname, ml)
451         if __debug__:
452             print >>hyperdb.DEBUG, 'create_index', (self, index_sql)
453         self.cursor.execute(index_sql)
455     def drop_multilink_table_indexes(self, classname, ml):
456         l = [
457             '%s_%s_l_idx'%(classname, ml),
458             '%s_%s_n_idx'%(classname, ml)
459         ]
460         table_name = '%s_%s'%(classname, ml)
461         for index_name in l:
462             if not self.sql_index_exists(table_name, index_name):
463                 continue
464             index_sql = 'drop index %s'%index_name
465             if __debug__:
466                 print >>hyperdb.DEBUG, 'drop_index', (self, index_sql)
467             self.cursor.execute(index_sql)
469     def create_class(self, spec):
470         ''' Create a database table according to the given spec.
471         '''
472         cols, mls = self.create_class_table(spec)
473         self.create_journal_table(spec)
475         # now create the multilink tables
476         for ml in mls:
477             self.create_multilink_table(spec, ml)
479         # ID counter
480         sql = 'insert into ids (name, num) values (%s,%s)'%(self.arg, self.arg)
481         vals = (spec.classname, 1)
482         if __debug__:
483             print >>hyperdb.DEBUG, 'create_class', (self, sql, vals)
484         self.cursor.execute(sql, vals)
486     def drop_class(self, cn, spec):
487         ''' Drop the given table from the database.
489             Drop the journal and multilink tables too.
490         '''
491         properties = spec[1]
492         # figure the multilinks
493         mls = []
494         for propanme, prop in properties:
495             if isinstance(prop, Multilink):
496                 mls.append(propname)
498         # drop class table and indexes
499         self.drop_class_table_indexes(cn, spec[0])
500         sql = 'drop table _%s'%cn
501         if __debug__:
502             print >>hyperdb.DEBUG, 'drop_class', (self, sql)
503         self.cursor.execute(sql)
505         # drop journal table and indexes
506         self.drop_journal_table_indexes(cn)
507         sql = 'drop table %s__journal'%cn
508         if __debug__:
509             print >>hyperdb.DEBUG, 'drop_class', (self, sql)
510         self.cursor.execute(sql)
512         for ml in mls:
513             # drop multilink table and indexes
514             self.drop_multilink_table_indexes(cn, ml)
515             sql = 'drop table %s_%s'%(spec.classname, ml)
516             if __debug__:
517                 print >>hyperdb.DEBUG, 'drop_class', (self, sql)
518             self.cursor.execute(sql)
520     #
521     # Classes
522     #
523     def __getattr__(self, classname):
524         ''' A convenient way of calling self.getclass(classname).
525         '''
526         if self.classes.has_key(classname):
527             if __debug__:
528                 print >>hyperdb.DEBUG, '__getattr__', (self, classname)
529             return self.classes[classname]
530         raise AttributeError, classname
532     def addclass(self, cl):
533         ''' Add a Class to the hyperdatabase.
534         '''
535         if __debug__:
536             print >>hyperdb.DEBUG, 'addclass', (self, cl)
537         cn = cl.classname
538         if self.classes.has_key(cn):
539             raise ValueError, cn
540         self.classes[cn] = cl
542         # add default Edit and View permissions
543         self.security.addPermission(name="Edit", klass=cn,
544             description="User is allowed to edit "+cn)
545         self.security.addPermission(name="View", klass=cn,
546             description="User is allowed to access "+cn)
548     def getclasses(self):
549         ''' Return a list of the names of all existing classes.
550         '''
551         if __debug__:
552             print >>hyperdb.DEBUG, 'getclasses', (self,)
553         l = self.classes.keys()
554         l.sort()
555         return l
557     def getclass(self, classname):
558         '''Get the Class object representing a particular class.
560         If 'classname' is not a valid class name, a KeyError is raised.
561         '''
562         if __debug__:
563             print >>hyperdb.DEBUG, 'getclass', (self, classname)
564         try:
565             return self.classes[classname]
566         except KeyError:
567             raise KeyError, 'There is no class called "%s"'%classname
569     def clear(self):
570         '''Delete all database contents.
572         Note: I don't commit here, which is different behaviour to the
573               "nuke from orbit" behaviour in the dbs.
574         '''
575         if __debug__:
576             print >>hyperdb.DEBUG, 'clear', (self,)
577         for cn in self.classes.keys():
578             sql = 'delete from _%s'%cn
579             if __debug__:
580                 print >>hyperdb.DEBUG, 'clear', (self, sql)
581             self.cursor.execute(sql)
583     #
584     # Node IDs
585     #
586     def newid(self, classname):
587         ''' Generate a new id for the given class
588         '''
589         # get the next ID
590         sql = 'select num from ids where name=%s'%self.arg
591         if __debug__:
592             print >>hyperdb.DEBUG, 'newid', (self, sql, classname)
593         self.cursor.execute(sql, (classname, ))
594         newid = int(self.cursor.fetchone()[0])
596         # update the counter
597         sql = 'update ids set num=%s where name=%s'%(self.arg, self.arg)
598         vals = (int(newid)+1, classname)
599         if __debug__:
600             print >>hyperdb.DEBUG, 'newid', (self, sql, vals)
601         self.cursor.execute(sql, vals)
603         # return as string
604         return str(newid)
606     def setid(self, classname, setid):
607         ''' Set the id counter: used during import of database
608         '''
609         sql = 'update ids set num=%s where name=%s'%(self.arg, self.arg)
610         vals = (setid, classname)
611         if __debug__:
612             print >>hyperdb.DEBUG, 'setid', (self, sql, vals)
613         self.cursor.execute(sql, vals)
615     #
616     # Nodes
617     #
618     def addnode(self, classname, nodeid, node):
619         ''' Add the specified node to its class's db.
620         '''
621         if __debug__:
622             print >>hyperdb.DEBUG, 'addnode', (self, classname, nodeid, node)
624         # determine the column definitions and multilink tables
625         cl = self.classes[classname]
626         cols, mls = self.determine_columns(cl.properties.items())
628         # we'll be supplied these props if we're doing an import
629         if not node.has_key('creator'):
630             # add in the "calculated" properties (dupe so we don't affect
631             # calling code's node assumptions)
632             node = node.copy()
633             node['creation'] = node['activity'] = date.Date()
634             node['actor'] = node['creator'] = self.getuid()
636         # default the non-multilink columns
637         for col, prop in cl.properties.items():
638             if not node.has_key(col):
639                 if isinstance(prop, Multilink):
640                     node[col] = []
641                 else:
642                     node[col] = None
644         # clear this node out of the cache if it's in there
645         key = (classname, nodeid)
646         if self.cache.has_key(key):
647             del self.cache[key]
648             self.cache_lru.remove(key)
650         # make the node data safe for the DB
651         node = self.serialise(classname, node)
653         # make sure the ordering is correct for column name -> column value
654         vals = tuple([node[col[1:]] for col in cols]) + (nodeid, 0)
655         s = ','.join([self.arg for x in cols]) + ',%s,%s'%(self.arg, self.arg)
656         cols = ','.join(cols) + ',id,__retired__'
658         # perform the inserts
659         sql = 'insert into _%s (%s) values (%s)'%(classname, cols, s)
660         if __debug__:
661             print >>hyperdb.DEBUG, 'addnode', (self, sql, vals)
662         self.cursor.execute(sql, vals)
664         # insert the multilink rows
665         for col in mls:
666             t = '%s_%s'%(classname, col)
667             for entry in node[col]:
668                 sql = 'insert into %s (linkid, nodeid) values (%s,%s)'%(t,
669                     self.arg, self.arg)
670                 self.sql(sql, (entry, nodeid))
672         # make sure we do the commit-time extra stuff for this node
673         self.transactions.append((self.doSaveNode, (classname, nodeid, node)))
675     def setnode(self, classname, nodeid, values, multilink_changes):
676         ''' Change the specified node.
677         '''
678         if __debug__:
679             print >>hyperdb.DEBUG, 'setnode', (self, classname, nodeid, values)
681         # clear this node out of the cache if it's in there
682         key = (classname, nodeid)
683         if self.cache.has_key(key):
684             del self.cache[key]
685             self.cache_lru.remove(key)
687         # add the special props
688         values = values.copy()
689         values['activity'] = date.Date()
690         values['actor'] = self.getuid()
692         # make db-friendly
693         values = self.serialise(classname, values)
695         cl = self.classes[classname]
696         cols = []
697         mls = []
698         # add the multilinks separately
699         props = cl.getprops()
700         for col in values.keys():
701             prop = props[col]
702             if isinstance(prop, Multilink):
703                 mls.append(col)
704             else:
705                 cols.append('_'+col)
706         cols.sort()
708         # if there's any updates to regular columns, do them
709         if cols:
710             # make sure the ordering is correct for column name -> column value
711             sqlvals = tuple([values[col[1:]] for col in cols]) + (nodeid,)
712             s = ','.join(['%s=%s'%(x, self.arg) for x in cols])
713             cols = ','.join(cols)
715             # perform the update
716             sql = 'update _%s set %s where id=%s'%(classname, s, self.arg)
717             if __debug__:
718                 print >>hyperdb.DEBUG, 'setnode', (self, sql, sqlvals)
719             self.cursor.execute(sql, sqlvals)
721         # now the fun bit, updating the multilinks ;)
722         for col, (add, remove) in multilink_changes.items():
723             tn = '%s_%s'%(classname, col)
724             if add:
725                 sql = 'insert into %s (nodeid, linkid) values (%s,%s)'%(tn,
726                     self.arg, self.arg)
727                 for addid in add:
728                     self.sql(sql, (nodeid, addid))
729             if remove:
730                 sql = 'delete from %s where nodeid=%s and linkid=%s'%(tn,
731                     self.arg, self.arg)
732                 for removeid in remove:
733                     self.sql(sql, (nodeid, removeid))
735         # make sure we do the commit-time extra stuff for this node
736         self.transactions.append((self.doSaveNode, (classname, nodeid, values)))
738     def getnode(self, classname, nodeid):
739         ''' Get a node from the database.
740         '''
741         if __debug__:
742             print >>hyperdb.DEBUG, 'getnode', (self, classname, nodeid)
744         # see if we have this node cached
745         key = (classname, nodeid)
746         if self.cache.has_key(key):
747             # push us back to the top of the LRU
748             self.cache_lru.remove(key)
749             self.cache_lru.insert(0, key)
750             # return the cached information
751             return self.cache[key]
753         # figure the columns we're fetching
754         cl = self.classes[classname]
755         cols, mls = self.determine_columns(cl.properties.items())
756         scols = ','.join(cols)
758         # perform the basic property fetch
759         sql = 'select %s from _%s where id=%s'%(scols, classname, self.arg)
760         self.sql(sql, (nodeid,))
762         values = self.sql_fetchone()
763         if values is None:
764             raise IndexError, 'no such %s node %s'%(classname, nodeid)
766         # make up the node
767         node = {}
768         for col in range(len(cols)):
769             node[cols[col][1:]] = values[col]
771         # now the multilinks
772         for col in mls:
773             # get the link ids
774             sql = 'select linkid from %s_%s where nodeid=%s'%(classname, col,
775                 self.arg)
776             self.cursor.execute(sql, (nodeid,))
777             # extract the first column from the result
778             node[col] = [x[0] for x in self.cursor.fetchall()]
780         # un-dbificate the node data
781         node = self.unserialise(classname, node)
783         # save off in the cache
784         key = (classname, nodeid)
785         self.cache[key] = node
786         # update the LRU
787         self.cache_lru.insert(0, key)
788         if len(self.cache_lru) > ROW_CACHE_SIZE:
789             del self.cache[self.cache_lru.pop()]
791         return node
793     def destroynode(self, classname, nodeid):
794         '''Remove a node from the database. Called exclusively by the
795            destroy() method on Class.
796         '''
797         if __debug__:
798             print >>hyperdb.DEBUG, 'destroynode', (self, classname, nodeid)
800         # make sure the node exists
801         if not self.hasnode(classname, nodeid):
802             raise IndexError, '%s has no node %s'%(classname, nodeid)
804         # see if we have this node cached
805         if self.cache.has_key((classname, nodeid)):
806             del self.cache[(classname, nodeid)]
808         # see if there's any obvious commit actions that we should get rid of
809         for entry in self.transactions[:]:
810             if entry[1][:2] == (classname, nodeid):
811                 self.transactions.remove(entry)
813         # now do the SQL
814         sql = 'delete from _%s where id=%s'%(classname, self.arg)
815         self.sql(sql, (nodeid,))
817         # remove from multilnks
818         cl = self.getclass(classname)
819         x, mls = self.determine_columns(cl.properties.items())
820         for col in mls:
821             # get the link ids
822             sql = 'delete from %s_%s where nodeid=%s'%(classname, col, self.arg)
823             self.sql(sql, (nodeid,))
825         # remove journal entries
826         sql = 'delete from %s__journal where nodeid=%s'%(classname, self.arg)
827         self.sql(sql, (nodeid,))
829     def serialise(self, classname, node):
830         '''Copy the node contents, converting non-marshallable data into
831            marshallable data.
832         '''
833         if __debug__:
834             print >>hyperdb.DEBUG, 'serialise', classname, node
835         properties = self.getclass(classname).getprops()
836         d = {}
837         for k, v in node.items():
838             # if the property doesn't exist, or is the "retired" flag then
839             # it won't be in the properties dict
840             if not properties.has_key(k):
841                 d[k] = v
842                 continue
844             # get the property spec
845             prop = properties[k]
847             if isinstance(prop, Password) and v is not None:
848                 d[k] = str(v)
849             elif isinstance(prop, Date) and v is not None:
850                 d[k] = v.serialise()
851             elif isinstance(prop, Interval) and v is not None:
852                 d[k] = v.serialise()
853             else:
854                 d[k] = v
855         return d
857     def unserialise(self, classname, node):
858         '''Decode the marshalled node data
859         '''
860         if __debug__:
861             print >>hyperdb.DEBUG, 'unserialise', classname, node
862         properties = self.getclass(classname).getprops()
863         d = {}
864         for k, v in node.items():
865             # if the property doesn't exist, or is the "retired" flag then
866             # it won't be in the properties dict
867             if not properties.has_key(k):
868                 d[k] = v
869                 continue
871             # get the property spec
872             prop = properties[k]
874             if isinstance(prop, Date) and v is not None:
875                 d[k] = date.Date(v)
876             elif isinstance(prop, Interval) and v is not None:
877                 d[k] = date.Interval(v)
878             elif isinstance(prop, Password) and v is not None:
879                 p = password.Password()
880                 p.unpack(v)
881                 d[k] = p
882             elif isinstance(prop, Boolean) and v is not None:
883                 d[k] = int(v)
884             elif isinstance(prop, Number) and v is not None:
885                 # try int first, then assume it's a float
886                 try:
887                     d[k] = int(v)
888                 except ValueError:
889                     d[k] = float(v)
890             else:
891                 d[k] = v
892         return d
894     def hasnode(self, classname, nodeid):
895         ''' Determine if the database has a given node.
896         '''
897         sql = 'select count(*) from _%s where id=%s'%(classname, self.arg)
898         if __debug__:
899             print >>hyperdb.DEBUG, 'hasnode', (self, sql, nodeid)
900         self.cursor.execute(sql, (nodeid,))
901         return int(self.cursor.fetchone()[0])
903     def countnodes(self, classname):
904         ''' Count the number of nodes that exist for a particular Class.
905         '''
906         sql = 'select count(*) from _%s'%classname
907         if __debug__:
908             print >>hyperdb.DEBUG, 'countnodes', (self, sql)
909         self.cursor.execute(sql)
910         return self.cursor.fetchone()[0]
912     def addjournal(self, classname, nodeid, action, params, creator=None,
913             creation=None):
914         ''' Journal the Action
915         'action' may be:
917             'create' or 'set' -- 'params' is a dictionary of property values
918             'link' or 'unlink' -- 'params' is (classname, nodeid, propname)
919             'retire' -- 'params' is None
920         '''
921         # serialise the parameters now if necessary
922         if isinstance(params, type({})):
923             if action in ('set', 'create'):
924                 params = self.serialise(classname, params)
926         # handle supply of the special journalling parameters (usually
927         # supplied on importing an existing database)
928         if creator:
929             journaltag = creator
930         else:
931             journaltag = self.getuid()
932         if creation:
933             journaldate = creation.serialise()
934         else:
935             journaldate = date.Date().serialise()
937         # create the journal entry
938         cols = ','.join('nodeid date tag action params'.split())
940         if __debug__:
941             print >>hyperdb.DEBUG, 'addjournal', (nodeid, journaldate,
942                 journaltag, action, params)
944         self.save_journal(classname, cols, nodeid, journaldate,
945             journaltag, action, params)
947     def getjournal(self, classname, nodeid):
948         ''' get the journal for id
949         '''
950         # make sure the node exists
951         if not self.hasnode(classname, nodeid):
952             raise IndexError, '%s has no node %s'%(classname, nodeid)
954         cols = ','.join('nodeid date tag action params'.split())
955         return self.load_journal(classname, cols, nodeid)
957     def save_journal(self, classname, cols, nodeid, journaldate,
958             journaltag, action, params):
959         ''' Save the journal entry to the database
960         '''
961         # make the params db-friendly
962         params = repr(params)
963         entry = (nodeid, journaldate, journaltag, action, params)
965         # do the insert
966         a = self.arg
967         sql = 'insert into %s__journal (%s) values (%s,%s,%s,%s,%s)'%(classname,
968             cols, a, a, a, a, a)
969         if __debug__:
970             print >>hyperdb.DEBUG, 'addjournal', (self, sql, entry)
971         self.cursor.execute(sql, entry)
973     def load_journal(self, classname, cols, nodeid):
974         ''' Load the journal from the database
975         '''
976         # now get the journal entries
977         sql = 'select %s from %s__journal where nodeid=%s order by date'%(
978             cols, classname, self.arg)
979         if __debug__:
980             print >>hyperdb.DEBUG, 'load_journal', (self, sql, nodeid)
981         self.cursor.execute(sql, (nodeid,))
982         res = []
983         for nodeid, date_stamp, user, action, params in self.cursor.fetchall():
984             params = eval(params)
985             res.append((nodeid, date.Date(date_stamp), user, action, params))
986         return res
988     def pack(self, pack_before):
989         ''' Delete all journal entries except "create" before 'pack_before'.
990         '''
991         # get a 'yyyymmddhhmmss' version of the date
992         date_stamp = pack_before.serialise()
994         # do the delete
995         for classname in self.classes.keys():
996             sql = "delete from %s__journal where date<%s and "\
997                 "action<>'create'"%(classname, self.arg)
998             if __debug__:
999                 print >>hyperdb.DEBUG, 'pack', (self, sql, date_stamp)
1000             self.cursor.execute(sql, (date_stamp,))
1002     def sql_commit(self):
1003         ''' Actually commit to the database.
1004         '''
1005         if __debug__:
1006             print >>hyperdb.DEBUG, '+++ commit database connection +++'
1007         self.conn.commit()
1009     def commit(self):
1010         ''' Commit the current transactions.
1012         Save all data changed since the database was opened or since the
1013         last commit() or rollback().
1014         '''
1015         if __debug__:
1016             print >>hyperdb.DEBUG, 'commit', (self,)
1018         # commit the database
1019         self.sql_commit()
1021         # now, do all the other transaction stuff
1022         for method, args in self.transactions:
1023             method(*args)
1025         # save the indexer state
1026         self.indexer.save_index()
1028         # clear out the transactions
1029         self.transactions = []
1031     def sql_rollback(self):
1032         self.conn.rollback()
1034     def rollback(self):
1035         ''' Reverse all actions from the current transaction.
1037         Undo all the changes made since the database was opened or the last
1038         commit() or rollback() was performed.
1039         '''
1040         if __debug__:
1041             print >>hyperdb.DEBUG, 'rollback', (self,)
1043         self.sql_rollback()
1045         # roll back "other" transaction stuff
1046         for method, args in self.transactions:
1047             # delete temporary files
1048             if method == self.doStoreFile:
1049                 self.rollbackStoreFile(*args)
1050         self.transactions = []
1052         # clear the cache
1053         self.clearCache()
1055     def doSaveNode(self, classname, nodeid, node):
1056         ''' dummy that just generates a reindex event
1057         '''
1058         # return the classname, nodeid so we reindex this content
1059         return (classname, nodeid)
1061     def sql_close(self):
1062         if __debug__:
1063             print >>hyperdb.DEBUG, '+++ close database connection +++'
1064         self.conn.close()
1066     def close(self):
1067         ''' Close off the connection.
1068         '''
1069         self.indexer.close()
1070         self.sql_close()
1073 # The base Class class
1075 class Class(hyperdb.Class):
1076     ''' The handle to a particular class of nodes in a hyperdatabase.
1077         
1078         All methods except __repr__ and getnode must be implemented by a
1079         concrete backend Class.
1080     '''
1082     def __init__(self, db, classname, **properties):
1083         '''Create a new class with a given name and property specification.
1085         'classname' must not collide with the name of an existing class,
1086         or a ValueError is raised.  The keyword arguments in 'properties'
1087         must map names to property objects, or a TypeError is raised.
1088         '''
1089         for name in 'creation activity creator actor'.split():
1090             if properties.has_key(name):
1091                 raise ValueError, '"creation", "activity", "creator" and '\
1092                     '"actor" are reserved'
1094         self.classname = classname
1095         self.properties = properties
1096         self.db = weakref.proxy(db)       # use a weak ref to avoid circularity
1097         self.key = ''
1099         # should we journal changes (default yes)
1100         self.do_journal = 1
1102         # do the db-related init stuff
1103         db.addclass(self)
1105         self.auditors = {'create': [], 'set': [], 'retire': [], 'restore': []}
1106         self.reactors = {'create': [], 'set': [], 'retire': [], 'restore': []}
1108     def schema(self):
1109         ''' A dumpable version of the schema that we can store in the
1110             database
1111         '''
1112         return (self.key, [(x, repr(y)) for x,y in self.properties.items()])
1114     def enableJournalling(self):
1115         '''Turn journalling on for this class
1116         '''
1117         self.do_journal = 1
1119     def disableJournalling(self):
1120         '''Turn journalling off for this class
1121         '''
1122         self.do_journal = 0
1124     # Editing nodes:
1125     def create(self, **propvalues):
1126         ''' Create a new node of this class and return its id.
1128         The keyword arguments in 'propvalues' map property names to values.
1130         The values of arguments must be acceptable for the types of their
1131         corresponding properties or a TypeError is raised.
1132         
1133         If this class has a key property, it must be present and its value
1134         must not collide with other key strings or a ValueError is raised.
1135         
1136         Any other properties on this class that are missing from the
1137         'propvalues' dictionary are set to None.
1138         
1139         If an id in a link or multilink property does not refer to a valid
1140         node, an IndexError is raised.
1141         '''
1142         self.fireAuditors('create', None, propvalues)
1143         newid = self.create_inner(**propvalues)
1144         self.fireReactors('create', newid, None)
1145         return newid
1146     
1147     def create_inner(self, **propvalues):
1148         ''' Called by create, in-between the audit and react calls.
1149         '''
1150         if propvalues.has_key('id'):
1151             raise KeyError, '"id" is reserved'
1153         if self.db.journaltag is None:
1154             raise DatabaseError, 'Database open read-only'
1156         if propvalues.has_key('creator') or propvalues.has_key('actor') or \
1157              propvalues.has_key('creation') or propvalues.has_key('activity'):
1158             raise KeyError, '"creator", "actor", "creation" and '\
1159                 '"activity" are reserved'
1161         # new node's id
1162         newid = self.db.newid(self.classname)
1164         # validate propvalues
1165         num_re = re.compile('^\d+$')
1166         for key, value in propvalues.items():
1167             if key == self.key:
1168                 try:
1169                     self.lookup(value)
1170                 except KeyError:
1171                     pass
1172                 else:
1173                     raise ValueError, 'node with key "%s" exists'%value
1175             # try to handle this property
1176             try:
1177                 prop = self.properties[key]
1178             except KeyError:
1179                 raise KeyError, '"%s" has no property "%s"'%(self.classname,
1180                     key)
1182             if value is not None and isinstance(prop, Link):
1183                 if type(value) != type(''):
1184                     raise ValueError, 'link value must be String'
1185                 link_class = self.properties[key].classname
1186                 # if it isn't a number, it's a key
1187                 if not num_re.match(value):
1188                     try:
1189                         value = self.db.classes[link_class].lookup(value)
1190                     except (TypeError, KeyError):
1191                         raise IndexError, 'new property "%s": %s not a %s'%(
1192                             key, value, link_class)
1193                 elif not self.db.getclass(link_class).hasnode(value):
1194                     raise IndexError, '%s has no node %s'%(link_class, value)
1196                 # save off the value
1197                 propvalues[key] = value
1199                 # register the link with the newly linked node
1200                 if self.do_journal and self.properties[key].do_journal:
1201                     self.db.addjournal(link_class, value, 'link',
1202                         (self.classname, newid, key))
1204             elif isinstance(prop, Multilink):
1205                 if type(value) != type([]):
1206                     raise TypeError, 'new property "%s" not a list of ids'%key
1208                 # clean up and validate the list of links
1209                 link_class = self.properties[key].classname
1210                 l = []
1211                 for entry in value:
1212                     if type(entry) != type(''):
1213                         raise ValueError, '"%s" multilink value (%r) '\
1214                             'must contain Strings'%(key, value)
1215                     # if it isn't a number, it's a key
1216                     if not num_re.match(entry):
1217                         try:
1218                             entry = self.db.classes[link_class].lookup(entry)
1219                         except (TypeError, KeyError):
1220                             raise IndexError, 'new property "%s": %s not a %s'%(
1221                                 key, entry, self.properties[key].classname)
1222                     l.append(entry)
1223                 value = l
1224                 propvalues[key] = value
1226                 # handle additions
1227                 for nodeid in value:
1228                     if not self.db.getclass(link_class).hasnode(nodeid):
1229                         raise IndexError, '%s has no node %s'%(link_class,
1230                             nodeid)
1231                     # register the link with the newly linked node
1232                     if self.do_journal and self.properties[key].do_journal:
1233                         self.db.addjournal(link_class, nodeid, 'link',
1234                             (self.classname, newid, key))
1236             elif isinstance(prop, String):
1237                 if type(value) != type('') and type(value) != type(u''):
1238                     raise TypeError, 'new property "%s" not a string'%key
1239                 self.db.indexer.add_text((self.classname, newid, key), value)
1241             elif isinstance(prop, Password):
1242                 if not isinstance(value, password.Password):
1243                     raise TypeError, 'new property "%s" not a Password'%key
1245             elif isinstance(prop, Date):
1246                 if value is not None and not isinstance(value, date.Date):
1247                     raise TypeError, 'new property "%s" not a Date'%key
1249             elif isinstance(prop, Interval):
1250                 if value is not None and not isinstance(value, date.Interval):
1251                     raise TypeError, 'new property "%s" not an Interval'%key
1253             elif value is not None and isinstance(prop, Number):
1254                 try:
1255                     float(value)
1256                 except ValueError:
1257                     raise TypeError, 'new property "%s" not numeric'%key
1259             elif value is not None and isinstance(prop, Boolean):
1260                 try:
1261                     int(value)
1262                 except ValueError:
1263                     raise TypeError, 'new property "%s" not boolean'%key
1265         # make sure there's data where there needs to be
1266         for key, prop in self.properties.items():
1267             if propvalues.has_key(key):
1268                 continue
1269             if key == self.key:
1270                 raise ValueError, 'key property "%s" is required'%key
1271             if isinstance(prop, Multilink):
1272                 propvalues[key] = []
1273             else:
1274                 propvalues[key] = None
1276         # done
1277         self.db.addnode(self.classname, newid, propvalues)
1278         if self.do_journal:
1279             self.db.addjournal(self.classname, newid, 'create', {})
1281         return newid
1283     def export_list(self, propnames, nodeid):
1284         ''' Export a node - generate a list of CSV-able data in the order
1285             specified by propnames for the given node.
1286         '''
1287         properties = self.getprops()
1288         l = []
1289         for prop in propnames:
1290             proptype = properties[prop]
1291             value = self.get(nodeid, prop)
1292             # "marshal" data where needed
1293             if value is None:
1294                 pass
1295             elif isinstance(proptype, hyperdb.Date):
1296                 value = value.get_tuple()
1297             elif isinstance(proptype, hyperdb.Interval):
1298                 value = value.get_tuple()
1299             elif isinstance(proptype, hyperdb.Password):
1300                 value = str(value)
1301             l.append(repr(value))
1302         l.append(repr(self.is_retired(nodeid)))
1303         return l
1305     def import_list(self, propnames, proplist):
1306         ''' Import a node - all information including "id" is present and
1307             should not be sanity checked. Triggers are not triggered. The
1308             journal should be initialised using the "creator" and "created"
1309             information.
1311             Return the nodeid of the node imported.
1312         '''
1313         if self.db.journaltag is None:
1314             raise DatabaseError, 'Database open read-only'
1315         properties = self.getprops()
1317         # make the new node's property map
1318         d = {}
1319         retire = 0
1320         newid = None
1321         for i in range(len(propnames)):
1322             # Use eval to reverse the repr() used to output the CSV
1323             value = eval(proplist[i])
1325             # Figure the property for this column
1326             propname = propnames[i]
1328             # "unmarshal" where necessary
1329             if propname == 'id':
1330                 newid = value
1331                 continue
1332             elif propname == 'is retired':
1333                 # is the item retired?
1334                 if int(value):
1335                     retire = 1
1336                 continue
1337             elif value is None:
1338                 d[propname] = None
1339                 continue
1341             prop = properties[propname]
1342             if value is None:
1343                 # don't set Nones
1344                 continue
1345             elif isinstance(prop, hyperdb.Date):
1346                 value = date.Date(value)
1347             elif isinstance(prop, hyperdb.Interval):
1348                 value = date.Interval(value)
1349             elif isinstance(prop, hyperdb.Password):
1350                 pwd = password.Password()
1351                 pwd.unpack(value)
1352                 value = pwd
1353             d[propname] = value
1355         # get a new id if necessary
1356         if newid is None:
1357             newid = self.db.newid(self.classname)
1359         # add the node and journal
1360         self.db.addnode(self.classname, newid, d)
1362         # retire?
1363         if retire:
1364             # use the arg for __retired__ to cope with any odd database type
1365             # conversion (hello, sqlite)
1366             sql = 'update _%s set __retired__=%s where id=%s'%(self.classname,
1367                 self.db.arg, self.db.arg)
1368             if __debug__:
1369                 print >>hyperdb.DEBUG, 'retire', (self, sql, newid)
1370             self.db.cursor.execute(sql, (1, newid))
1372         # extract the extraneous journalling gumpf and nuke it
1373         if d.has_key('creator'):
1374             creator = d['creator']
1375             del d['creator']
1376         else:
1377             creator = None
1378         if d.has_key('creation'):
1379             creation = d['creation']
1380             del d['creation']
1381         else:
1382             creation = None
1383         if d.has_key('activity'):
1384             del d['activity']
1385         if d.has_key('actor'):
1386             del d['actor']
1387         self.db.addjournal(self.classname, newid, 'create', {}, creator,
1388             creation)
1389         return newid
1391     _marker = []
1392     def get(self, nodeid, propname, default=_marker, cache=1):
1393         '''Get the value of a property on an existing node of this class.
1395         'nodeid' must be the id of an existing node of this class or an
1396         IndexError is raised.  'propname' must be the name of a property
1397         of this class or a KeyError is raised.
1399         'cache' exists for backwards compatibility, and is not used.
1400         '''
1401         if propname == 'id':
1402             return nodeid
1404         # get the node's dict
1405         d = self.db.getnode(self.classname, nodeid)
1407         if propname == 'creation':
1408             if d.has_key('creation'):
1409                 return d['creation']
1410             else:
1411                 return date.Date()
1412         if propname == 'activity':
1413             if d.has_key('activity'):
1414                 return d['activity']
1415             else:
1416                 return date.Date()
1417         if propname == 'creator':
1418             if d.has_key('creator'):
1419                 return d['creator']
1420             else:
1421                 return self.db.getuid()
1422         if propname == 'actor':
1423             if d.has_key('actor'):
1424                 return d['actor']
1425             else:
1426                 return self.db.getuid()
1428         # get the property (raises KeyErorr if invalid)
1429         prop = self.properties[propname]
1431         if not d.has_key(propname):
1432             if default is self._marker:
1433                 if isinstance(prop, Multilink):
1434                     return []
1435                 else:
1436                     return None
1437             else:
1438                 return default
1440         # don't pass our list to other code
1441         if isinstance(prop, Multilink):
1442             return d[propname][:]
1444         return d[propname]
1446     def set(self, nodeid, **propvalues):
1447         '''Modify a property on an existing node of this class.
1448         
1449         'nodeid' must be the id of an existing node of this class or an
1450         IndexError is raised.
1452         Each key in 'propvalues' must be the name of a property of this
1453         class or a KeyError is raised.
1455         All values in 'propvalues' must be acceptable types for their
1456         corresponding properties or a TypeError is raised.
1458         If the value of the key property is set, it must not collide with
1459         other key strings or a ValueError is raised.
1461         If the value of a Link or Multilink property contains an invalid
1462         node id, a ValueError is raised.
1463         '''
1464         self.fireAuditors('set', nodeid, propvalues)
1465         oldvalues = copy.deepcopy(self.db.getnode(self.classname, nodeid))
1466         propvalues = self.set_inner(nodeid, **propvalues)
1467         self.fireReactors('set', nodeid, oldvalues)
1468         return propvalues        
1470     def set_inner(self, nodeid, **propvalues):
1471         ''' Called by set, in-between the audit and react calls.
1472         ''' 
1473         if not propvalues:
1474             return propvalues
1476         if propvalues.has_key('creation') or propvalues.has_key('creator') or \
1477                 propvalues.has_key('actor') or propvalues.has_key('activity'):
1478             raise KeyError, '"creation", "creator", "actor" and '\
1479                 '"activity" are reserved'
1481         if propvalues.has_key('id'):
1482             raise KeyError, '"id" is reserved'
1484         if self.db.journaltag is None:
1485             raise DatabaseError, 'Database open read-only'
1487         node = self.db.getnode(self.classname, nodeid)
1488         if self.is_retired(nodeid):
1489             raise IndexError, 'Requested item is retired'
1490         num_re = re.compile('^\d+$')
1492         # if the journal value is to be different, store it in here
1493         journalvalues = {}
1495         # remember the add/remove stuff for multilinks, making it easier
1496         # for the Database layer to do its stuff
1497         multilink_changes = {}
1499         for propname, value in propvalues.items():
1500             # check to make sure we're not duplicating an existing key
1501             if propname == self.key and node[propname] != value:
1502                 try:
1503                     self.lookup(value)
1504                 except KeyError:
1505                     pass
1506                 else:
1507                     raise ValueError, 'node with key "%s" exists'%value
1509             # this will raise the KeyError if the property isn't valid
1510             # ... we don't use getprops() here because we only care about
1511             # the writeable properties.
1512             try:
1513                 prop = self.properties[propname]
1514             except KeyError:
1515                 raise KeyError, '"%s" has no property named "%s"'%(
1516                     self.classname, propname)
1518             # if the value's the same as the existing value, no sense in
1519             # doing anything
1520             current = node.get(propname, None)
1521             if value == current:
1522                 del propvalues[propname]
1523                 continue
1524             journalvalues[propname] = current
1526             # do stuff based on the prop type
1527             if isinstance(prop, Link):
1528                 link_class = prop.classname
1529                 # if it isn't a number, it's a key
1530                 if value is not None and not isinstance(value, type('')):
1531                     raise ValueError, 'property "%s" link value be a string'%(
1532                         propname)
1533                 if isinstance(value, type('')) and not num_re.match(value):
1534                     try:
1535                         value = self.db.classes[link_class].lookup(value)
1536                     except (TypeError, KeyError):
1537                         raise IndexError, 'new property "%s": %s not a %s'%(
1538                             propname, value, prop.classname)
1540                 if (value is not None and
1541                         not self.db.getclass(link_class).hasnode(value)):
1542                     raise IndexError, '%s has no node %s'%(link_class, value)
1544                 if self.do_journal and prop.do_journal:
1545                     # register the unlink with the old linked node
1546                     if node[propname] is not None:
1547                         self.db.addjournal(link_class, node[propname], 'unlink',
1548                             (self.classname, nodeid, propname))
1550                     # register the link with the newly linked node
1551                     if value is not None:
1552                         self.db.addjournal(link_class, value, 'link',
1553                             (self.classname, nodeid, propname))
1555             elif isinstance(prop, Multilink):
1556                 if type(value) != type([]):
1557                     raise TypeError, 'new property "%s" not a list of'\
1558                         ' ids'%propname
1559                 link_class = self.properties[propname].classname
1560                 l = []
1561                 for entry in value:
1562                     # if it isn't a number, it's a key
1563                     if type(entry) != type(''):
1564                         raise ValueError, 'new property "%s" link value ' \
1565                             'must be a string'%propname
1566                     if not num_re.match(entry):
1567                         try:
1568                             entry = self.db.classes[link_class].lookup(entry)
1569                         except (TypeError, KeyError):
1570                             raise IndexError, 'new property "%s": %s not a %s'%(
1571                                 propname, entry,
1572                                 self.properties[propname].classname)
1573                     l.append(entry)
1574                 value = l
1575                 propvalues[propname] = value
1577                 # figure the journal entry for this property
1578                 add = []
1579                 remove = []
1581                 # handle removals
1582                 if node.has_key(propname):
1583                     l = node[propname]
1584                 else:
1585                     l = []
1586                 for id in l[:]:
1587                     if id in value:
1588                         continue
1589                     # register the unlink with the old linked node
1590                     if self.do_journal and self.properties[propname].do_journal:
1591                         self.db.addjournal(link_class, id, 'unlink',
1592                             (self.classname, nodeid, propname))
1593                     l.remove(id)
1594                     remove.append(id)
1596                 # handle additions
1597                 for id in value:
1598                     if not self.db.getclass(link_class).hasnode(id):
1599                         raise IndexError, '%s has no node %s'%(link_class, id)
1600                     if id in l:
1601                         continue
1602                     # register the link with the newly linked node
1603                     if self.do_journal and self.properties[propname].do_journal:
1604                         self.db.addjournal(link_class, id, 'link',
1605                             (self.classname, nodeid, propname))
1606                     l.append(id)
1607                     add.append(id)
1609                 # figure the journal entry
1610                 l = []
1611                 if add:
1612                     l.append(('+', add))
1613                 if remove:
1614                     l.append(('-', remove))
1615                 multilink_changes[propname] = (add, remove)
1616                 if l:
1617                     journalvalues[propname] = tuple(l)
1619             elif isinstance(prop, String):
1620                 if value is not None and type(value) != type('') and type(value) != type(u''):
1621                     raise TypeError, 'new property "%s" not a string'%propname
1622                 self.db.indexer.add_text((self.classname, nodeid, propname),
1623                     value)
1625             elif isinstance(prop, Password):
1626                 if not isinstance(value, password.Password):
1627                     raise TypeError, 'new property "%s" not a Password'%propname
1628                 propvalues[propname] = value
1630             elif value is not None and isinstance(prop, Date):
1631                 if not isinstance(value, date.Date):
1632                     raise TypeError, 'new property "%s" not a Date'% propname
1633                 propvalues[propname] = value
1635             elif value is not None and isinstance(prop, Interval):
1636                 if not isinstance(value, date.Interval):
1637                     raise TypeError, 'new property "%s" not an '\
1638                         'Interval'%propname
1639                 propvalues[propname] = value
1641             elif value is not None and isinstance(prop, Number):
1642                 try:
1643                     float(value)
1644                 except ValueError:
1645                     raise TypeError, 'new property "%s" not numeric'%propname
1647             elif value is not None and isinstance(prop, Boolean):
1648                 try:
1649                     int(value)
1650                 except ValueError:
1651                     raise TypeError, 'new property "%s" not boolean'%propname
1653         # nothing to do?
1654         if not propvalues:
1655             return propvalues
1657         # do the set, and journal it
1658         self.db.setnode(self.classname, nodeid, propvalues, multilink_changes)
1660         if self.do_journal:
1661             self.db.addjournal(self.classname, nodeid, 'set', journalvalues)
1663         return propvalues        
1665     def retire(self, nodeid):
1666         '''Retire a node.
1667         
1668         The properties on the node remain available from the get() method,
1669         and the node's id is never reused.
1670         
1671         Retired nodes are not returned by the find(), list(), or lookup()
1672         methods, and other nodes may reuse the values of their key properties.
1673         '''
1674         if self.db.journaltag is None:
1675             raise DatabaseError, 'Database open read-only'
1677         self.fireAuditors('retire', nodeid, None)
1679         # use the arg for __retired__ to cope with any odd database type
1680         # conversion (hello, sqlite)
1681         sql = 'update _%s set __retired__=%s where id=%s'%(self.classname,
1682             self.db.arg, self.db.arg)
1683         if __debug__:
1684             print >>hyperdb.DEBUG, 'retire', (self, sql, nodeid)
1685         self.db.cursor.execute(sql, (1, nodeid))
1686         if self.do_journal:
1687             self.db.addjournal(self.classname, nodeid, 'retired', None)
1689         self.fireReactors('retire', nodeid, None)
1691     def restore(self, nodeid):
1692         '''Restore a retired node.
1694         Make node available for all operations like it was before retirement.
1695         '''
1696         if self.db.journaltag is None:
1697             raise DatabaseError, 'Database open read-only'
1699         node = self.db.getnode(self.classname, nodeid)
1700         # check if key property was overrided
1701         key = self.getkey()
1702         try:
1703             id = self.lookup(node[key])
1704         except KeyError:
1705             pass
1706         else:
1707             raise KeyError, "Key property (%s) of retired node clashes with \
1708                 existing one (%s)" % (key, node[key])
1710         self.fireAuditors('restore', nodeid, None)
1711         # use the arg for __retired__ to cope with any odd database type
1712         # conversion (hello, sqlite)
1713         sql = 'update _%s set __retired__=%s where id=%s'%(self.classname,
1714             self.db.arg, self.db.arg)
1715         if __debug__:
1716             print >>hyperdb.DEBUG, 'restore', (self, sql, nodeid)
1717         self.db.cursor.execute(sql, (0, nodeid))
1718         if self.do_journal:
1719             self.db.addjournal(self.classname, nodeid, 'restored', None)
1721         self.fireReactors('restore', nodeid, None)
1722         
1723     def is_retired(self, nodeid):
1724         '''Return true if the node is rerired
1725         '''
1726         sql = 'select __retired__ from _%s where id=%s'%(self.classname,
1727             self.db.arg)
1728         if __debug__:
1729             print >>hyperdb.DEBUG, 'is_retired', (self, sql, nodeid)
1730         self.db.cursor.execute(sql, (nodeid,))
1731         return int(self.db.sql_fetchone()[0])
1733     def destroy(self, nodeid):
1734         '''Destroy a node.
1735         
1736         WARNING: this method should never be used except in extremely rare
1737                  situations where there could never be links to the node being
1738                  deleted
1740         WARNING: use retire() instead
1742         WARNING: the properties of this node will not be available ever again
1744         WARNING: really, use retire() instead
1746         Well, I think that's enough warnings. This method exists mostly to
1747         support the session storage of the cgi interface.
1749         The node is completely removed from the hyperdb, including all journal
1750         entries. It will no longer be available, and will generally break code
1751         if there are any references to the node.
1752         '''
1753         if self.db.journaltag is None:
1754             raise DatabaseError, 'Database open read-only'
1755         self.db.destroynode(self.classname, nodeid)
1757     def history(self, nodeid):
1758         '''Retrieve the journal of edits on a particular node.
1760         'nodeid' must be the id of an existing node of this class or an
1761         IndexError is raised.
1763         The returned list contains tuples of the form
1765             (nodeid, date, tag, action, params)
1767         'date' is a Timestamp object specifying the time of the change and
1768         'tag' is the journaltag specified when the database was opened.
1769         '''
1770         if not self.do_journal:
1771             raise ValueError, 'Journalling is disabled for this class'
1772         return self.db.getjournal(self.classname, nodeid)
1774     # Locating nodes:
1775     def hasnode(self, nodeid):
1776         '''Determine if the given nodeid actually exists
1777         '''
1778         return self.db.hasnode(self.classname, nodeid)
1780     def setkey(self, propname):
1781         '''Select a String property of this class to be the key property.
1783         'propname' must be the name of a String property of this class or
1784         None, or a TypeError is raised.  The values of the key property on
1785         all existing nodes must be unique or a ValueError is raised.
1786         '''
1787         # XXX create an index on the key prop column. We should also 
1788         # record that we've created this index in the schema somewhere.
1789         prop = self.getprops()[propname]
1790         if not isinstance(prop, String):
1791             raise TypeError, 'key properties must be String'
1792         self.key = propname
1794     def getkey(self):
1795         '''Return the name of the key property for this class or None.'''
1796         return self.key
1798     def labelprop(self, default_to_id=0):
1799         '''Return the property name for a label for the given node.
1801         This method attempts to generate a consistent label for the node.
1802         It tries the following in order:
1804         1. key property
1805         2. "name" property
1806         3. "title" property
1807         4. first property from the sorted property name list
1808         '''
1809         k = self.getkey()
1810         if  k:
1811             return k
1812         props = self.getprops()
1813         if props.has_key('name'):
1814             return 'name'
1815         elif props.has_key('title'):
1816             return 'title'
1817         if default_to_id:
1818             return 'id'
1819         props = props.keys()
1820         props.sort()
1821         return props[0]
1823     def lookup(self, keyvalue):
1824         '''Locate a particular node by its key property and return its id.
1826         If this class has no key property, a TypeError is raised.  If the
1827         'keyvalue' matches one of the values for the key property among
1828         the nodes in this class, the matching node's id is returned;
1829         otherwise a KeyError is raised.
1830         '''
1831         if not self.key:
1832             raise TypeError, 'No key property set for class %s'%self.classname
1834         # use the arg to handle any odd database type conversion (hello,
1835         # sqlite)
1836         sql = "select id from _%s where _%s=%s and __retired__ <> %s"%(
1837             self.classname, self.key, self.db.arg, self.db.arg)
1838         self.db.sql(sql, (keyvalue, 1))
1840         # see if there was a result that's not retired
1841         row = self.db.sql_fetchone()
1842         if not row:
1843             raise KeyError, 'No key (%s) value "%s" for "%s"'%(self.key,
1844                 keyvalue, self.classname)
1846         # return the id
1847         return row[0]
1849     def find(self, **propspec):
1850         '''Get the ids of nodes in this class which link to the given nodes.
1852         'propspec' consists of keyword args propname=nodeid or
1853                    propname={nodeid:1, }
1854         'propname' must be the name of a property in this class, or a
1855                    KeyError is raised.  That property must be a Link or
1856                    Multilink property, or a TypeError is raised.
1858         Any node in this class whose 'propname' property links to any of the
1859         nodeids will be returned. Used by the full text indexing, which knows
1860         that "foo" occurs in msg1, msg3 and file7, so we have hits on these
1861         issues:
1863             db.issue.find(messages={'1':1,'3':1}, files={'7':1})
1864         '''
1865         if __debug__:
1866             print >>hyperdb.DEBUG, 'find', (self, propspec)
1868         # shortcut
1869         if not propspec:
1870             return []
1872         # validate the args
1873         props = self.getprops()
1874         propspec = propspec.items()
1875         for propname, nodeids in propspec:
1876             # check the prop is OK
1877             prop = props[propname]
1878             if not isinstance(prop, Link) and not isinstance(prop, Multilink):
1879                 raise TypeError, "'%s' not a Link/Multilink property"%propname
1881         # first, links
1882         a = self.db.arg
1883         allvalues = (1,)
1884         o = []
1885         where = []
1886         for prop, values in propspec:
1887             if not isinstance(props[prop], hyperdb.Link):
1888                 continue
1889             if type(values) is type({}) and len(values) == 1:
1890                 values = values.keys()[0]
1891             if type(values) is type(''):
1892                 allvalues += (values,)
1893                 where.append('_%s = %s'%(prop, a))
1894             elif values is None:
1895                 where.append('_%s is NULL'%prop)
1896             else:
1897                 allvalues += tuple(values.keys())
1898                 where.append('_%s in (%s)'%(prop, ','.join([a]*len(values))))
1899         tables = ['_%s'%self.classname]
1900         if where:
1901             o.append('(' + ' and '.join(where) + ')')
1903         # now multilinks
1904         for prop, values in propspec:
1905             if not isinstance(props[prop], hyperdb.Multilink):
1906                 continue
1907             if not values:
1908                 continue
1909             if type(values) is type(''):
1910                 allvalues += (values,)
1911                 s = a
1912             else:
1913                 allvalues += tuple(values.keys())
1914                 s = ','.join([a]*len(values))
1915             tn = '%s_%s'%(self.classname, prop)
1916             tables.append(tn)
1917             o.append('(id=%s.nodeid and %s.linkid in (%s))'%(tn, tn, s))
1919         if not o:
1920             return []
1921         elif len(o) > 1:
1922             o = '(' + ' or '.join(['(%s)'%i for i in o]) + ')'
1923         else:
1924             o = o[0]
1925         t = ', '.join(tables)
1926         sql = 'select distinct(id) from %s where __retired__ <> %s and %s'%(t, a, o)
1927         self.db.sql(sql, allvalues)
1928         l = [x[0] for x in self.db.sql_fetchall()]
1929         if __debug__:
1930             print >>hyperdb.DEBUG, 'find ... ', l
1931         return l
1933     def stringFind(self, **requirements):
1934         '''Locate a particular node by matching a set of its String
1935         properties in a caseless search.
1937         If the property is not a String property, a TypeError is raised.
1938         
1939         The return is a list of the id of all nodes that match.
1940         '''
1941         where = []
1942         args = []
1943         for propname in requirements.keys():
1944             prop = self.properties[propname]
1945             if not isinstance(prop, String):
1946                 raise TypeError, "'%s' not a String property"%propname
1947             where.append(propname)
1948             args.append(requirements[propname].lower())
1950         # generate the where clause
1951         s = ' and '.join(['lower(_%s)=%s'%(col, self.db.arg) for col in where])
1952         sql = 'select id from _%s where %s and __retired__=%s'%(self.classname,
1953             s, self.db.arg)
1954         args.append(0)
1955         self.db.sql(sql, tuple(args))
1956         l = [x[0] for x in self.db.sql_fetchall()]
1957         if __debug__:
1958             print >>hyperdb.DEBUG, 'find ... ', l
1959         return l
1961     def list(self):
1962         ''' Return a list of the ids of the active nodes in this class.
1963         '''
1964         return self.getnodeids(retired=0)
1966     def getnodeids(self, retired=None):
1967         ''' Retrieve all the ids of the nodes for a particular Class.
1969             Set retired=None to get all nodes. Otherwise it'll get all the 
1970             retired or non-retired nodes, depending on the flag.
1971         '''
1972         # flip the sense of the 'retired' flag if we don't want all of them
1973         if retired is not None:
1974             if retired:
1975                 args = (0, )
1976             else:
1977                 args = (1, )
1978             sql = 'select id from _%s where __retired__ <> %s'%(self.classname,
1979                 self.db.arg)
1980         else:
1981             args = ()
1982             sql = 'select id from _%s'%self.classname
1983         if __debug__:
1984             print >>hyperdb.DEBUG, 'getnodeids', (self, sql, retired)
1985         self.db.cursor.execute(sql, args)
1986         ids = [x[0] for x in self.db.cursor.fetchall()]
1987         return ids
1989     def filter(self, search_matches, filterspec, sort=(None,None),
1990             group=(None,None)):
1991         '''Return a list of the ids of the active nodes in this class that
1992         match the 'filter' spec, sorted by the group spec and then the
1993         sort spec
1995         "filterspec" is {propname: value(s)}
1997         "sort" and "group" are (dir, prop) where dir is '+', '-' or None
1998         and prop is a prop name or None
2000         "search_matches" is {nodeid: marker}
2002         The filter must match all properties specificed - but if the
2003         property value to match is a list, any one of the values in the
2004         list may match for that property to match.
2005         '''
2006         # just don't bother if the full-text search matched diddly
2007         if search_matches == {}:
2008             return []
2010         cn = self.classname
2012         timezone = self.db.getUserTimezone()
2013         
2014         # figure the WHERE clause from the filterspec
2015         props = self.getprops()
2016         frum = ['_'+cn]
2017         where = []
2018         args = []
2019         a = self.db.arg
2020         for k, v in filterspec.items():
2021             propclass = props[k]
2022             # now do other where clause stuff
2023             if isinstance(propclass, Multilink):
2024                 tn = '%s_%s'%(cn, k)
2025                 if v in ('-1', ['-1']):
2026                     # only match rows that have count(linkid)=0 in the
2027                     # corresponding multilink table)
2028                     where.append('id not in (select nodeid from %s)'%tn)
2029                 elif isinstance(v, type([])):
2030                     frum.append(tn)
2031                     s = ','.join([a for x in v])
2032                     where.append('id=%s.nodeid and %s.linkid in (%s)'%(tn,tn,s))
2033                     args = args + v
2034                 else:
2035                     frum.append(tn)
2036                     where.append('id=%s.nodeid and %s.linkid=%s'%(tn, tn, a))
2037                     args.append(v)
2038             elif k == 'id':
2039                 if isinstance(v, type([])):
2040                     s = ','.join([a for x in v])
2041                     where.append('%s in (%s)'%(k, s))
2042                     args = args + v
2043                 else:
2044                     where.append('%s=%s'%(k, a))
2045                     args.append(v)
2046             elif isinstance(propclass, String):
2047                 if not isinstance(v, type([])):
2048                     v = [v]
2050                 # Quote the bits in the string that need it and then embed
2051                 # in a "substring" search. Note - need to quote the '%' so
2052                 # they make it through the python layer happily
2053                 v = ['%%'+self.db.sql_stringquote(s)+'%%' for s in v]
2055                 # now add to the where clause
2056                 where.append(' or '.join(["_%s LIKE '%s'"%(k, s) for s in v]))
2057                 # note: args are embedded in the query string now
2058             elif isinstance(propclass, Link):
2059                 if isinstance(v, type([])):
2060                     if '-1' in v:
2061                         v = v[:]
2062                         v.remove('-1')
2063                         xtra = ' or _%s is NULL'%k
2064                     else:
2065                         xtra = ''
2066                     if v:
2067                         s = ','.join([a for x in v])
2068                         where.append('(_%s in (%s)%s)'%(k, s, xtra))
2069                         args = args + v
2070                     else:
2071                         where.append('_%s is NULL'%k)
2072                 else:
2073                     if v == '-1':
2074                         v = None
2075                         where.append('_%s is NULL'%k)
2076                     else:
2077                         where.append('_%s=%s'%(k, a))
2078                         args.append(v)
2079             elif isinstance(propclass, Date):
2080                 if isinstance(v, type([])):
2081                     s = ','.join([a for x in v])
2082                     where.append('_%s in (%s)'%(k, s))
2083                     args = args + [date.Date(x).serialise() for x in v]
2084                 else:
2085                     try:
2086                         # Try to filter on range of dates
2087                         date_rng = Range(v, date.Date, offset=timezone)
2088                         if (date_rng.from_value):
2089                             where.append('_%s >= %s'%(k, a))                            
2090                             args.append(date_rng.from_value.serialise())
2091                         if (date_rng.to_value):
2092                             where.append('_%s <= %s'%(k, a))
2093                             args.append(date_rng.to_value.serialise())
2094                     except ValueError:
2095                         # If range creation fails - ignore that search parameter
2096                         pass                        
2097             elif isinstance(propclass, Interval):
2098                 if isinstance(v, type([])):
2099                     s = ','.join([a for x in v])
2100                     where.append('_%s in (%s)'%(k, s))
2101                     args = args + [date.Interval(x).serialise() for x in v]
2102                 else:
2103                     try:
2104                         # Try to filter on range of intervals
2105                         date_rng = Range(v, date.Interval)
2106                         if (date_rng.from_value):
2107                             where.append('_%s >= %s'%(k, a))
2108                             args.append(date_rng.from_value.serialise())
2109                         if (date_rng.to_value):
2110                             where.append('_%s <= %s'%(k, a))
2111                             args.append(date_rng.to_value.serialise())
2112                     except ValueError:
2113                         # If range creation fails - ignore that search parameter
2114                         pass                        
2115                     #where.append('_%s=%s'%(k, a))
2116                     #args.append(date.Interval(v).serialise())
2117             else:
2118                 if isinstance(v, type([])):
2119                     s = ','.join([a for x in v])
2120                     where.append('_%s in (%s)'%(k, s))
2121                     args = args + v
2122                 else:
2123                     where.append('_%s=%s'%(k, a))
2124                     args.append(v)
2126         # don't match retired nodes
2127         where.append('__retired__ <> 1')
2129         # add results of full text search
2130         if search_matches is not None:
2131             v = search_matches.keys()
2132             s = ','.join([a for x in v])
2133             where.append('id in (%s)'%s)
2134             args = args + v
2136         # "grouping" is just the first-order sorting in the SQL fetch
2137         # can modify it...)
2138         orderby = []
2139         ordercols = []
2140         if group[0] is not None and group[1] is not None:
2141             if group[0] != '-':
2142                 orderby.append('_'+group[1])
2143                 ordercols.append('_'+group[1])
2144             else:
2145                 orderby.append('_'+group[1]+' desc')
2146                 ordercols.append('_'+group[1])
2148         # now add in the sorting
2149         group = ''
2150         if sort[0] is not None and sort[1] is not None:
2151             direction, colname = sort
2152             if direction != '-':
2153                 if colname == 'id':
2154                     orderby.append(colname)
2155                 else:
2156                     orderby.append('_'+colname)
2157                     ordercols.append('_'+colname)
2158             else:
2159                 if colname == 'id':
2160                     orderby.append(colname+' desc')
2161                     ordercols.append(colname)
2162                 else:
2163                     orderby.append('_'+colname+' desc')
2164                     ordercols.append('_'+colname)
2166         # construct the SQL
2167         frum = ','.join(frum)
2168         if where:
2169             where = ' where ' + (' and '.join(where))
2170         else:
2171             where = ''
2172         cols = ['id']
2173         if orderby:
2174             cols = cols + ordercols
2175             order = ' order by %s'%(','.join(orderby))
2176         else:
2177             order = ''
2178         cols = ','.join(cols)
2179         sql = 'select %s from %s %s%s%s'%(cols, frum, where, group, order)
2180         args = tuple(args)
2181         if __debug__:
2182             print >>hyperdb.DEBUG, 'filter', (self, sql, args)
2183         if args:
2184             self.db.cursor.execute(sql, args)
2185         else:
2186             # psycopg doesn't like empty args
2187             self.db.cursor.execute(sql)
2188         l = self.db.sql_fetchall()
2190         # return the IDs (the first column)
2191         return [row[0] for row in l]
2193     def count(self):
2194         '''Get the number of nodes in this class.
2196         If the returned integer is 'numnodes', the ids of all the nodes
2197         in this class run from 1 to numnodes, and numnodes+1 will be the
2198         id of the next node to be created in this class.
2199         '''
2200         return self.db.countnodes(self.classname)
2202     # Manipulating properties:
2203     def getprops(self, protected=1):
2204         '''Return a dictionary mapping property names to property objects.
2205            If the "protected" flag is true, we include protected properties -
2206            those which may not be modified.
2207         '''
2208         d = self.properties.copy()
2209         if protected:
2210             d['id'] = String()
2211             d['creation'] = hyperdb.Date()
2212             d['activity'] = hyperdb.Date()
2213             d['creator'] = hyperdb.Link('user')
2214             d['actor'] = hyperdb.Link('user')
2215         return d
2217     def addprop(self, **properties):
2218         '''Add properties to this class.
2220         The keyword arguments in 'properties' must map names to property
2221         objects, or a TypeError is raised.  None of the keys in 'properties'
2222         may collide with the names of existing properties, or a ValueError
2223         is raised before any properties have been added.
2224         '''
2225         for key in properties.keys():
2226             if self.properties.has_key(key):
2227                 raise ValueError, key
2228         self.properties.update(properties)
2230     def index(self, nodeid):
2231         '''Add (or refresh) the node to search indexes
2232         '''
2233         # find all the String properties that have indexme
2234         for prop, propclass in self.getprops().items():
2235             if isinstance(propclass, String) and propclass.indexme:
2236                 self.db.indexer.add_text((self.classname, nodeid, prop),
2237                     str(self.get(nodeid, prop)))
2240     #
2241     # Detector interface
2242     #
2243     def audit(self, event, detector):
2244         '''Register a detector
2245         '''
2246         l = self.auditors[event]
2247         if detector not in l:
2248             self.auditors[event].append(detector)
2250     def fireAuditors(self, action, nodeid, newvalues):
2251         '''Fire all registered auditors.
2252         '''
2253         for audit in self.auditors[action]:
2254             audit(self.db, self, nodeid, newvalues)
2256     def react(self, event, detector):
2257         '''Register a detector
2258         '''
2259         l = self.reactors[event]
2260         if detector not in l:
2261             self.reactors[event].append(detector)
2263     def fireReactors(self, action, nodeid, oldvalues):
2264         '''Fire all registered reactors.
2265         '''
2266         for react in self.reactors[action]:
2267             react(self.db, self, nodeid, oldvalues)
2269 class FileClass(Class, hyperdb.FileClass):
2270     '''This class defines a large chunk of data. To support this, it has a
2271        mandatory String property "content" which is typically saved off
2272        externally to the hyperdb.
2274        The default MIME type of this data is defined by the
2275        "default_mime_type" class attribute, which may be overridden by each
2276        node if the class defines a "type" String property.
2277     '''
2278     default_mime_type = 'text/plain'
2280     def create(self, **propvalues):
2281         ''' snaffle the file propvalue and store in a file
2282         '''
2283         # we need to fire the auditors now, or the content property won't
2284         # be in propvalues for the auditors to play with
2285         self.fireAuditors('create', None, propvalues)
2287         # now remove the content property so it's not stored in the db
2288         content = propvalues['content']
2289         del propvalues['content']
2291         # do the database create
2292         newid = self.create_inner(**propvalues)
2294         # figure the mime type
2295         mime_type = propvalues.get('type', self.default_mime_type)
2297         # and index!
2298         self.db.indexer.add_text((self.classname, newid, 'content'), content,
2299             mime_type)
2301         # fire reactors
2302         self.fireReactors('create', newid, None)
2304         # store off the content as a file
2305         self.db.storefile(self.classname, newid, None, content)
2306         return newid
2308     def import_list(self, propnames, proplist):
2309         ''' Trap the "content" property...
2310         '''
2311         # dupe this list so we don't affect others
2312         propnames = propnames[:]
2314         # extract the "content" property from the proplist
2315         i = propnames.index('content')
2316         content = eval(proplist[i])
2317         del propnames[i]
2318         del proplist[i]
2320         # do the normal import
2321         newid = Class.import_list(self, propnames, proplist)
2323         # save off the "content" file
2324         self.db.storefile(self.classname, newid, None, content)
2325         return newid
2327     _marker = []
2328     def get(self, nodeid, propname, default=_marker, cache=1):
2329         ''' Trap the content propname and get it from the file
2331         'cache' exists for backwards compatibility, and is not used.
2332         '''
2333         poss_msg = 'Possibly a access right configuration problem.'
2334         if propname == 'content':
2335             try:
2336                 return self.db.getfile(self.classname, nodeid, None)
2337             except IOError, (strerror):
2338                 # BUG: by catching this we donot see an error in the log.
2339                 return 'ERROR reading file: %s%s\n%s\n%s'%(
2340                         self.classname, nodeid, poss_msg, strerror)
2341         if default is not self._marker:
2342             return Class.get(self, nodeid, propname, default)
2343         else:
2344             return Class.get(self, nodeid, propname)
2346     def getprops(self, protected=1):
2347         ''' In addition to the actual properties on the node, these methods
2348             provide the "content" property. If the "protected" flag is true,
2349             we include protected properties - those which may not be
2350             modified.
2351         '''
2352         d = Class.getprops(self, protected=protected).copy()
2353         d['content'] = hyperdb.String()
2354         return d
2356     def set(self, itemid, **propvalues):
2357         ''' Snarf the "content" propvalue and update it in a file
2358         '''
2359         self.fireAuditors('set', itemid, propvalues)
2360         oldvalues = copy.deepcopy(self.db.getnode(self.classname, itemid))
2362         # now remove the content property so it's not stored in the db
2363         content = None
2364         if propvalues.has_key('content'):
2365             content = propvalues['content']
2366             del propvalues['content']
2368         # do the database create
2369         propvalues = self.set_inner(itemid, **propvalues)
2371         # do content?
2372         if content:
2373             # store and index
2374             self.db.storefile(self.classname, itemid, None, content)
2375             mime_type = propvalues.get('type', self.get(itemid, 'type'))
2376             if not mime_type:
2377                 mime_type = self.default_mime_type
2378             self.db.indexer.add_text((self.classname, itemid, 'content'),
2379                 content, mime_type)
2381         # fire reactors
2382         self.fireReactors('set', itemid, oldvalues)
2383         return propvalues
2385 # XXX deviation from spec - was called ItemClass
2386 class IssueClass(Class, roundupdb.IssueClass):
2387     # Overridden methods:
2388     def __init__(self, db, classname, **properties):
2389         '''The newly-created class automatically includes the "messages",
2390         "files", "nosy", and "superseder" properties.  If the 'properties'
2391         dictionary attempts to specify any of these properties or a
2392         "creation", "creator", "activity" or "actor" property, a ValueError
2393         is raised.
2394         '''
2395         if not properties.has_key('title'):
2396             properties['title'] = hyperdb.String(indexme='yes')
2397         if not properties.has_key('messages'):
2398             properties['messages'] = hyperdb.Multilink("msg")
2399         if not properties.has_key('files'):
2400             properties['files'] = hyperdb.Multilink("file")
2401         if not properties.has_key('nosy'):
2402             # note: journalling is turned off as it really just wastes
2403             # space. this behaviour may be overridden in an instance
2404             properties['nosy'] = hyperdb.Multilink("user", do_journal="no")
2405         if not properties.has_key('superseder'):
2406             properties['superseder'] = hyperdb.Multilink(classname)
2407         Class.__init__(self, db, classname, **properties)