Code

Implemented proper datatypes in mysql and postgresql backends (well,
[roundup.git] / roundup / backends / rdbms_common.py
1 # $Id: rdbms_common.py,v 1.84 2004-03-22 07:45:39 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 def _num_cvt(num):
50     num = str(num)
51     try:
52         return int(num)
53     except:
54         return float(num)
56 class Database(FileStorage, hyperdb.Database, roundupdb.Database):
57     ''' Wrapper around an SQL database that presents a hyperdb interface.
59         - some functionality is specific to the actual SQL database, hence
60           the sql_* methods that are NotImplemented
61         - we keep a cache of the latest ROW_CACHE_SIZE row fetches.
62     '''
63     def __init__(self, config, journaltag=None):
64         ''' Open the database and load the schema from it.
65         '''
66         self.config, self.journaltag = config, journaltag
67         self.dir = config.DATABASE
68         self.classes = {}
69         self.indexer = Indexer(self)
70         self.security = security.Security(self)
72         # additional transaction support for external files and the like
73         self.transactions = []
75         # keep a cache of the N most recently retrieved rows of any kind
76         # (classname, nodeid) = row
77         self.cache = {}
78         self.cache_lru = []
80         # database lock
81         self.lockfile = None
83         # open a connection to the database, creating the "conn" attribute
84         self.open_connection()
86     def clearCache(self):
87         self.cache = {}
88         self.cache_lru = []
90     def getSessionManager(self):
91         return Sessions(self)
93     def getOTKManager(self):
94         return OneTimeKeys(self)
96     def open_connection(self):
97         ''' Open a connection to the database, creating it if necessary.
99             Must call self.load_dbschema()
100         '''
101         raise NotImplemented
103     def sql(self, sql, args=None):
104         ''' Execute the sql with the optional args.
105         '''
106         if __debug__:
107             print >>hyperdb.DEBUG, (self, sql, args)
108         if args:
109             self.cursor.execute(sql, args)
110         else:
111             self.cursor.execute(sql)
113     def sql_fetchone(self):
114         ''' Fetch a single row. If there's nothing to fetch, return None.
115         '''
116         return self.cursor.fetchone()
118     def sql_fetchall(self):
119         ''' Fetch all rows. If there's nothing to fetch, return [].
120         '''
121         return self.cursor.fetchall()
123     def sql_stringquote(self, value):
124         ''' Quote the string so it's safe to put in the 'sql quotes'
125         '''
126         return re.sub("'", "''", str(value))
128     def init_dbschema(self):
129         self.database_schema = {
130             'version': self.current_db_version,
131             'tables': {}
132         }
134     def load_dbschema(self):
135         ''' Load the schema definition that the database currently implements
136         '''
137         self.cursor.execute('select schema from schema')
138         schema = self.cursor.fetchone()
139         if schema:
140             self.database_schema = eval(schema[0])
141         else:
142             self.database_schema = {}
144     def save_dbschema(self, schema):
145         ''' Save the schema definition that the database currently implements
146         '''
147         s = repr(self.database_schema)
148         self.sql('insert into schema values (%s)', (s,))
150     def post_init(self):
151         ''' Called once the schema initialisation has finished.
153             We should now confirm that the schema defined by our "classes"
154             attribute actually matches the schema in the database.
155         '''
156         save = self.upgrade_db()
158         # now detect changes in the schema
159         tables = self.database_schema['tables']
160         for classname, spec in self.classes.items():
161             if tables.has_key(classname):
162                 dbspec = tables[classname]
163                 if self.update_class(spec, dbspec):
164                     tables[classname] = spec.schema()
165                     save = 1
166             else:
167                 self.create_class(spec)
168                 tables[classname] = spec.schema()
169                 save = 1
171         for classname, spec in tables.items():
172             if not self.classes.has_key(classname):
173                 self.drop_class(classname, tables[classname])
174                 del tables[classname]
175                 save = 1
177         # update the database version of the schema
178         if save:
179             self.sql('delete from schema')
180             self.save_dbschema(self.database_schema)
182         # reindex the db if necessary
183         if self.indexer.should_reindex():
184             self.reindex()
186         # commit
187         self.sql_commit()
189     # update this number when we need to make changes to the SQL structure
190     # of the backen database
191     current_db_version = 2
192     def upgrade_db(self):
193         ''' Update the SQL database to reflect changes in the backend code.
195             Return boolean whether we need to save the schema.
196         '''
197         version = self.database_schema.get('version', 1)
198         if version == self.current_db_version:
199             # nothing to do
200             return 0
202         if version == 1:
203             # version 1 doesn't have the OTK, session and indexing in the
204             # database
205             self.create_version_2_tables()
206             # version 1 also didn't have the actor column
207             self.add_actor_column()
209         self.database_schema['version'] = self.current_db_version
210         return 1
213     def refresh_database(self):
214         self.post_init()
216     def reindex(self):
217         for klass in self.classes.values():
218             for nodeid in klass.list():
219                 klass.index(nodeid)
220         self.indexer.save_index()
223     hyperdb_to_sql_datatypes = {
224         hyperdb.String : 'VARCHAR(255)',
225         hyperdb.Date   : 'TIMESTAMP',
226         hyperdb.Link   : 'INTEGER',
227         hyperdb.Interval  : 'VARCHAR(255)',
228         hyperdb.Password  : 'VARCHAR(255)',
229         hyperdb.Boolean   : 'INTEGER',
230         hyperdb.Number    : 'REAL',
231     }
232     def determine_columns(self, properties):
233         ''' Figure the column names and multilink properties from the spec
235             "properties" is a list of (name, prop) where prop may be an
236             instance of a hyperdb "type" _or_ a string repr of that type.
237         '''
238         cols = [
239             ('_actor', 'INTEGER'),
240             ('_activity', 'DATE'),
241             ('_creator', 'INTEGER'),
242             ('_creation', 'DATE')
243         ]
244         mls = []
245         # add the multilinks separately
246         for col, prop in properties:
247             if isinstance(prop, Multilink):
248                 mls.append(col)
249                 continue
251             if isinstance(prop, type('')):
252                 raise ValueError, "string property spec!"
253                 #and prop.find('Multilink') != -1:
254                 #mls.append(col)
256             datatype = self.hyperdb_to_sql_datatypes[prop.__class__]
257             cols.append(('_'+col, datatype))
259         cols.sort()
260         return cols, mls
262     def update_class(self, spec, old_spec, force=0):
263         ''' Determine the differences between the current spec and the
264             database version of the spec, and update where necessary.
266             If 'force' is true, update the database anyway.
267         '''
268         new_has = spec.properties.has_key
269         new_spec = spec.schema()
270         new_spec[1].sort()
271         old_spec[1].sort()
272         if not force and new_spec == old_spec:
273             # no changes
274             return 0
276         if __debug__:
277             print >>hyperdb.DEBUG, 'update_class FIRING'
279         # detect key prop change for potential index change
280         keyprop_changes = {}
281         if new_spec[0] != old_spec[0]:
282             keyprop_changes = {'remove': old_spec[0], 'add': new_spec[0]}
284         # detect multilinks that have been removed, and drop their table
285         old_has = {}
286         for name, prop in old_spec[1]:
287             old_has[name] = 1
288             if new_has(name):
289                 continue
291             if prop.find('Multilink to') != -1:
292                 # first drop indexes.
293                 self.drop_multilink_table_indexes(spec.classname, name)
295                 # now the multilink table itself
296                 sql = 'drop table %s_%s'%(spec.classname, name)
297             else:
298                 # if this is the key prop, drop the index first
299                 if old_spec[0] == prop:
300                     self.drop_class_table_key_index(spec.classname, name)
301                     del keyprop_changes['remove']
303                 # drop the column
304                 sql = 'alter table _%s drop column _%s'%(spec.classname, name)
306             if __debug__:
307                 print >>hyperdb.DEBUG, 'update_class', (self, sql)
308             self.cursor.execute(sql)
309         old_has = old_has.has_key
311         # if we didn't remove the key prop just then, but the key prop has
312         # changed, we still need to remove the old index
313         if keyprop_changes.has_key('remove'):
314             self.drop_class_table_key_index(spec.classname,
315                 keyprop_changes['remove'])
317         # add new columns
318         for propname, x in new_spec[1]:
319             if old_has(propname):
320                 continue
321             sql = 'alter table _%s add column _%s varchar(255)'%(
322                 spec.classname, propname)
323             if __debug__:
324                 print >>hyperdb.DEBUG, 'update_class', (self, sql)
325             self.cursor.execute(sql)
327             # if the new column is a key prop, we need an index!
328             if new_spec[0] == propname:
329                 self.create_class_table_key_index(spec.classname, propname)
330                 del keyprop_changes['add']
332         # if we didn't add the key prop just then, but the key prop has
333         # changed, we still need to add the new index
334         if keyprop_changes.has_key('add'):
335             self.create_class_table_key_index(spec.classname,
336                 keyprop_changes['add'])
338         return 1
340     def create_class_table(self, spec):
341         ''' create the class table for the given spec
342         '''
343         cols, mls = self.determine_columns(spec.properties.items())
345         # add on our special columns
346         cols.append(('id', 'INTEGER PRIMARY KEY'))
347         cols.append(('__retired__', 'INTEGER DEFAULT 0'))
349         # create the base table
350         scols = ','.join(['%s %s'%x for x in cols])
351         sql = 'create table _%s (%s)'%(spec.classname, scols)
352         if __debug__:
353             print >>hyperdb.DEBUG, 'create_class', (self, sql)
354         self.cursor.execute(sql)
356         self.create_class_table_indexes(spec)
358         return cols, mls
360     def create_class_table_indexes(self, spec):
361         ''' create the class table for the given spec
362         '''
363         # create __retired__ index
364         index_sql2 = 'create index _%s_retired_idx on _%s(__retired__)'%(
365                         spec.classname, spec.classname)
366         if __debug__:
367             print >>hyperdb.DEBUG, 'create_index', (self, index_sql2)
368         self.cursor.execute(index_sql2)
370         # create index for key property
371         if spec.key:
372             if __debug__:
373                 print >>hyperdb.DEBUG, 'update_class setting keyprop %r'% \
374                     spec.key
375             index_sql3 = 'create index _%s_%s_idx on _%s(_%s)'%(
376                         spec.classname, spec.key,
377                         spec.classname, spec.key)
378             if __debug__:
379                 print >>hyperdb.DEBUG, 'create_index', (self, index_sql3)
380             self.cursor.execute(index_sql3)
382     def drop_class_table_indexes(self, cn, key):
383         # drop the old table indexes first
384         l = ['_%s_id_idx'%cn, '_%s_retired_idx'%cn]
385         if key:
386             l.append('_%s_%s_idx'%(cn, key))
388         table_name = '_%s'%cn
389         for index_name in l:
390             if not self.sql_index_exists(table_name, index_name):
391                 continue
392             index_sql = 'drop index '+index_name
393             if __debug__:
394                 print >>hyperdb.DEBUG, 'drop_index', (self, index_sql)
395             self.cursor.execute(index_sql)
397     def create_class_table_key_index(self, cn, key):
398         ''' create the class table for the given spec
399         '''
400         sql = 'create index _%s_%s_idx on _%s(_%s)'%(cn, key, cn, key)
401         if __debug__:
402             print >>hyperdb.DEBUG, 'create_class_tab_key_index', (self, sql)
403         self.cursor.execute(sql)
405     def drop_class_table_key_index(self, cn, key):
406         table_name = '_%s'%cn
407         index_name = '_%s_%s_idx'%(cn, key)
408         if not self.sql_index_exists(table_name, index_name):
409             return
410         sql = 'drop index '+index_name
411         if __debug__:
412             print >>hyperdb.DEBUG, 'drop_class_tab_key_index', (self, sql)
413         self.cursor.execute(sql)
415     def create_journal_table(self, spec):
416         ''' create the journal table for a class given the spec and 
417             already-determined cols
418         '''
419         # journal table
420         cols = ','.join(['%s varchar'%x
421             for x in 'nodeid date tag action params'.split()])
422         sql = '''create table %s__journal (
423             nodeid integer, date timestamp, tag varchar(255),
424             action varchar(255), params varchar(25))'''%spec.classname
425         if __debug__:
426             print >>hyperdb.DEBUG, 'create_journal_table', (self, sql)
427         self.cursor.execute(sql)
428         self.create_journal_table_indexes(spec)
430     def create_journal_table_indexes(self, spec):
431         # index on nodeid
432         sql = 'create index %s_journ_idx on %s__journal(nodeid)'%(
433                         spec.classname, spec.classname)
434         if __debug__:
435             print >>hyperdb.DEBUG, 'create_index', (self, sql)
436         self.cursor.execute(sql)
438     def drop_journal_table_indexes(self, classname):
439         index_name = '%s_journ_idx'%classname
440         if not self.sql_index_exists('%s__journal'%classname, index_name):
441             return
442         index_sql = 'drop index '+index_name
443         if __debug__:
444             print >>hyperdb.DEBUG, 'drop_index', (self, index_sql)
445         self.cursor.execute(index_sql)
447     def create_multilink_table(self, spec, ml):
448         ''' Create a multilink table for the "ml" property of the class
449             given by the spec
450         '''
451         # create the table
452         sql = 'create table %s_%s (linkid varchar, nodeid varchar)'%(
453             spec.classname, ml)
454         if __debug__:
455             print >>hyperdb.DEBUG, 'create_class', (self, sql)
456         self.cursor.execute(sql)
457         self.create_multilink_table_indexes(spec, ml)
459     def create_multilink_table_indexes(self, spec, ml):
460         # create index on linkid
461         index_sql = 'create index %s_%s_l_idx on %s_%s(linkid)'%(
462                         spec.classname, ml, spec.classname, ml)
463         if __debug__:
464             print >>hyperdb.DEBUG, 'create_index', (self, index_sql)
465         self.cursor.execute(index_sql)
467         # create index on nodeid
468         index_sql = 'create index %s_%s_n_idx on %s_%s(nodeid)'%(
469                         spec.classname, ml, spec.classname, ml)
470         if __debug__:
471             print >>hyperdb.DEBUG, 'create_index', (self, index_sql)
472         self.cursor.execute(index_sql)
474     def drop_multilink_table_indexes(self, classname, ml):
475         l = [
476             '%s_%s_l_idx'%(classname, ml),
477             '%s_%s_n_idx'%(classname, ml)
478         ]
479         table_name = '%s_%s'%(classname, ml)
480         for index_name in l:
481             if not self.sql_index_exists(table_name, index_name):
482                 continue
483             index_sql = 'drop index %s'%index_name
484             if __debug__:
485                 print >>hyperdb.DEBUG, 'drop_index', (self, index_sql)
486             self.cursor.execute(index_sql)
488     def create_class(self, spec):
489         ''' Create a database table according to the given spec.
490         '''
491         cols, mls = self.create_class_table(spec)
492         self.create_journal_table(spec)
494         # now create the multilink tables
495         for ml in mls:
496             self.create_multilink_table(spec, ml)
498     def drop_class(self, cn, spec):
499         ''' Drop the given table from the database.
501             Drop the journal and multilink tables too.
502         '''
503         properties = spec[1]
504         # figure the multilinks
505         mls = []
506         for propanme, prop in properties:
507             if isinstance(prop, Multilink):
508                 mls.append(propname)
510         # drop class table and indexes
511         self.drop_class_table_indexes(cn, spec[0])
513         self.drop_class_table(cn)
515         # drop journal table and indexes
516         self.drop_journal_table_indexes(cn)
517         sql = 'drop table %s__journal'%cn
518         if __debug__:
519             print >>hyperdb.DEBUG, 'drop_class', (self, sql)
520         self.cursor.execute(sql)
522         for ml in mls:
523             # drop multilink table and indexes
524             self.drop_multilink_table_indexes(cn, ml)
525             sql = 'drop table %s_%s'%(spec.classname, ml)
526             if __debug__:
527                 print >>hyperdb.DEBUG, 'drop_class', (self, sql)
528             self.cursor.execute(sql)
530     def drop_class_table(self, cn):
531         sql = 'drop table _%s'%cn
532         if __debug__:
533             print >>hyperdb.DEBUG, 'drop_class', (self, sql)
534         self.cursor.execute(sql)
536     #
537     # Classes
538     #
539     def __getattr__(self, classname):
540         ''' A convenient way of calling self.getclass(classname).
541         '''
542         if self.classes.has_key(classname):
543             if __debug__:
544                 print >>hyperdb.DEBUG, '__getattr__', (self, classname)
545             return self.classes[classname]
546         raise AttributeError, classname
548     def addclass(self, cl):
549         ''' Add a Class to the hyperdatabase.
550         '''
551         if __debug__:
552             print >>hyperdb.DEBUG, 'addclass', (self, cl)
553         cn = cl.classname
554         if self.classes.has_key(cn):
555             raise ValueError, cn
556         self.classes[cn] = cl
558         # add default Edit and View permissions
559         self.security.addPermission(name="Edit", klass=cn,
560             description="User is allowed to edit "+cn)
561         self.security.addPermission(name="View", klass=cn,
562             description="User is allowed to access "+cn)
564     def getclasses(self):
565         ''' Return a list of the names of all existing classes.
566         '''
567         if __debug__:
568             print >>hyperdb.DEBUG, 'getclasses', (self,)
569         l = self.classes.keys()
570         l.sort()
571         return l
573     def getclass(self, classname):
574         '''Get the Class object representing a particular class.
576         If 'classname' is not a valid class name, a KeyError is raised.
577         '''
578         if __debug__:
579             print >>hyperdb.DEBUG, 'getclass', (self, classname)
580         try:
581             return self.classes[classname]
582         except KeyError:
583             raise KeyError, 'There is no class called "%s"'%classname
585     def clear(self):
586         '''Delete all database contents.
588         Note: I don't commit here, which is different behaviour to the
589               "nuke from orbit" behaviour in the dbs.
590         '''
591         if __debug__:
592             print >>hyperdb.DEBUG, 'clear', (self,)
593         for cn in self.classes.keys():
594             sql = 'delete from _%s'%cn
595             if __debug__:
596                 print >>hyperdb.DEBUG, 'clear', (self, sql)
597             self.cursor.execute(sql)
599     #
600     # Nodes
601     #
603     hyperdb_to_sql_value = {
604         hyperdb.String : str,
605         hyperdb.Date   : lambda x: x.formal(sep=' ', sec='%f'),
606         hyperdb.Link   : int,
607         hyperdb.Interval  : lambda x: x.serialise(),
608         hyperdb.Password  : str,
609         hyperdb.Boolean   : int,
610         hyperdb.Number    : lambda x: x,
611     }
612     def addnode(self, classname, nodeid, node):
613         ''' Add the specified node to its class's db.
614         '''
615         if __debug__:
616             print >>hyperdb.DEBUG, 'addnode', (self, classname, nodeid, node)
618         # determine the column definitions and multilink tables
619         cl = self.classes[classname]
620         cols, mls = self.determine_columns(cl.properties.items())
622         # we'll be supplied these props if we're doing an import
623         values = node.copy()
624         if not values.has_key('creator'):
625             # add in the "calculated" properties (dupe so we don't affect
626             # calling code's node assumptions)
627             values['creation'] = values['activity'] = date.Date()
628             values['actor'] = values['creator'] = self.getuid()
630         cl = self.classes[classname]
631         props = cl.getprops(protected=1)
632         del props['id']
634         # default the non-multilink columns
635         for col, prop in props.items():
636             if not values.has_key(col):
637                 if isinstance(prop, Multilink):
638                     values[col] = []
639                 else:
640                     values[col] = None
642         # clear this node out of the cache if it's in there
643         key = (classname, nodeid)
644         if self.cache.has_key(key):
645             del self.cache[key]
646             self.cache_lru.remove(key)
648         # figure the values to insert
649         vals = []
650         for col,dt in cols:
651             prop = props[col[1:]]
652             value = values[col[1:]]
653             if value:
654                 value = self.hyperdb_to_sql_value[prop.__class__](value)
655             vals.append(value)
656         vals.append(nodeid)
657         vals = tuple(vals)
659         # make sure the ordering is correct for column name -> column value
660         s = ','.join([self.arg for x in cols]) + ',%s'%self.arg
661         cols = ','.join([col for col,dt in cols]) + ',id'
663         # perform the inserts
664         sql = 'insert into _%s (%s) values (%s)'%(classname, cols, s)
665         if __debug__:
666             print >>hyperdb.DEBUG, 'addnode', (self, sql, vals)
667         self.cursor.execute(sql, vals)
669         # insert the multilink rows
670         for col in mls:
671             t = '%s_%s'%(classname, col)
672             for entry in node[col]:
673                 sql = 'insert into %s (linkid, nodeid) values (%s,%s)'%(t,
674                     self.arg, self.arg)
675                 self.sql(sql, (entry, nodeid))
677         # make sure we do the commit-time extra stuff for this node
678         self.transactions.append((self.doSaveNode, (classname, nodeid, node)))
680     def setnode(self, classname, nodeid, values, multilink_changes):
681         ''' Change the specified node.
682         '''
683         if __debug__:
684             print >>hyperdb.DEBUG, 'setnode', (self, classname, nodeid, values)
686         # clear this node out of the cache if it's in there
687         key = (classname, nodeid)
688         if self.cache.has_key(key):
689             del self.cache[key]
690             self.cache_lru.remove(key)
692         # add the special props
693         values = values.copy()
694         values['activity'] = date.Date()
695         values['actor'] = self.getuid()
697         cl = self.classes[classname]
698         props = cl.getprops()
700         cols = []
701         mls = []
702         # add the multilinks separately
703         for col in values.keys():
704             prop = props[col]
705             if isinstance(prop, Multilink):
706                 mls.append(col)
707             else:
708                 cols.append(col)
709         cols.sort()
711         # figure the values to insert
712         vals = []
713         for col in cols:
714             prop = props[col]
715             value = values[col]
716             if value is not None:
717                 value = self.hyperdb_to_sql_value[prop.__class__](value)
718             vals.append(value)
719         vals.append(int(nodeid))
720         vals = tuple(vals)
722         # if there's any updates to regular columns, do them
723         if cols:
724             # make sure the ordering is correct for column name -> column value
725             s = ','.join(['_%s=%s'%(x, self.arg) for x in cols])
726             cols = ','.join(cols)
728             # perform the update
729             sql = 'update _%s set %s where id=%s'%(classname, s, self.arg)
730             if __debug__:
731                 print >>hyperdb.DEBUG, 'setnode', (self, sql, vals)
732             self.cursor.execute(sql, vals)
734         # now the fun bit, updating the multilinks ;)
735         for col, (add, remove) in multilink_changes.items():
736             tn = '%s_%s'%(classname, col)
737             if add:
738                 sql = 'insert into %s (nodeid, linkid) values (%s,%s)'%(tn,
739                     self.arg, self.arg)
740                 for addid in add:
741                     # XXX numeric ids
742                     self.sql(sql, (int(nodeid), int(addid)))
743             if remove:
744                 sql = 'delete from %s where nodeid=%s and linkid=%s'%(tn,
745                     self.arg, self.arg)
746                 for removeid in remove:
747                     # XXX numeric ids
748                     self.sql(sql, (int(nodeid), int(removeid)))
750         # make sure we do the commit-time extra stuff for this node
751         self.transactions.append((self.doSaveNode, (classname, nodeid, values)))
753     sql_to_hyperdb_value = {
754         hyperdb.String : str,
755         hyperdb.Date   : date.Date,
756 #        hyperdb.Link   : int,      # XXX numeric ids
757         hyperdb.Link   : str,
758         hyperdb.Interval  : date.Interval,
759         hyperdb.Password  : lambda x: password.Password(encrypted=x),
760         hyperdb.Boolean   : int,
761         hyperdb.Number    : _num_cvt,
762     }
763     def getnode(self, classname, nodeid):
764         ''' Get a node from the database.
765         '''
766         if __debug__:
767             print >>hyperdb.DEBUG, 'getnode', (self, classname, nodeid)
769         # see if we have this node cached
770         key = (classname, nodeid)
771         if self.cache.has_key(key):
772             # push us back to the top of the LRU
773             self.cache_lru.remove(key)
774             self.cache_lru.insert(0, key)
775             # return the cached information
776             return self.cache[key]
778         # figure the columns we're fetching
779         cl = self.classes[classname]
780         cols, mls = self.determine_columns(cl.properties.items())
781         scols = ','.join([col for col,dt in cols])
783         # perform the basic property fetch
784         sql = 'select %s from _%s where id=%s'%(scols, classname, self.arg)
785         self.sql(sql, (nodeid,))
787         values = self.sql_fetchone()
788         if values is None:
789             raise IndexError, 'no such %s node %s'%(classname, nodeid)
791         # make up the node
792         node = {}
793         props = cl.getprops(protected=1)
794         for col in range(len(cols)):
795             name = cols[col][0][1:]
796             value = values[col]
797             if value is not None:
798                 value = self.sql_to_hyperdb_value[props[name].__class__](value)
799             node[name] = value
802         # now the multilinks
803         for col in mls:
804             # get the link ids
805             sql = 'select linkid from %s_%s where nodeid=%s'%(classname, col,
806                 self.arg)
807             self.cursor.execute(sql, (nodeid,))
808             # extract the first column from the result
809             # XXX numeric ids
810             node[col] = [str(x[0]) for x in self.cursor.fetchall()]
812         # save off in the cache
813         key = (classname, nodeid)
814         self.cache[key] = node
815         # update the LRU
816         self.cache_lru.insert(0, key)
817         if len(self.cache_lru) > ROW_CACHE_SIZE:
818             del self.cache[self.cache_lru.pop()]
820         return node
822     def destroynode(self, classname, nodeid):
823         '''Remove a node from the database. Called exclusively by the
824            destroy() method on Class.
825         '''
826         if __debug__:
827             print >>hyperdb.DEBUG, 'destroynode', (self, classname, nodeid)
829         # make sure the node exists
830         if not self.hasnode(classname, nodeid):
831             raise IndexError, '%s has no node %s'%(classname, nodeid)
833         # see if we have this node cached
834         if self.cache.has_key((classname, nodeid)):
835             del self.cache[(classname, nodeid)]
837         # see if there's any obvious commit actions that we should get rid of
838         for entry in self.transactions[:]:
839             if entry[1][:2] == (classname, nodeid):
840                 self.transactions.remove(entry)
842         # now do the SQL
843         sql = 'delete from _%s where id=%s'%(classname, self.arg)
844         self.sql(sql, (nodeid,))
846         # remove from multilnks
847         cl = self.getclass(classname)
848         x, mls = self.determine_columns(cl.properties.items())
849         for col in mls:
850             # get the link ids
851             sql = 'delete from %s_%s where nodeid=%s'%(classname, col, self.arg)
852             self.sql(sql, (nodeid,))
854         # remove journal entries
855         sql = 'delete from %s__journal where nodeid=%s'%(classname, self.arg)
856         self.sql(sql, (nodeid,))
858     def hasnode(self, classname, nodeid):
859         ''' Determine if the database has a given node.
860         '''
861         sql = 'select count(*) from _%s where id=%s'%(classname, self.arg)
862         if __debug__:
863             print >>hyperdb.DEBUG, 'hasnode', (self, sql, nodeid)
864         self.cursor.execute(sql, (nodeid,))
865         return int(self.cursor.fetchone()[0])
867     def countnodes(self, classname):
868         ''' Count the number of nodes that exist for a particular Class.
869         '''
870         sql = 'select count(*) from _%s'%classname
871         if __debug__:
872             print >>hyperdb.DEBUG, 'countnodes', (self, sql)
873         self.cursor.execute(sql)
874         return self.cursor.fetchone()[0]
876     def addjournal(self, classname, nodeid, action, params, creator=None,
877             creation=None):
878         ''' Journal the Action
879         'action' may be:
881             'create' or 'set' -- 'params' is a dictionary of property values
882             'link' or 'unlink' -- 'params' is (classname, nodeid, propname)
883             'retire' -- 'params' is None
884         '''
885         # serialise the parameters now if necessary
886         if isinstance(params, type({})):
887             if action in ('set', 'create'):
888                 params = self.serialise(classname, params)
890         # handle supply of the special journalling parameters (usually
891         # supplied on importing an existing database)
892         if creator:
893             journaltag = creator
894         else:
895             journaltag = self.getuid()
896         if creation:
897             journaldate = creation
898         else:
899             journaldate = date.Date()
901         # create the journal entry
902         cols = ','.join('nodeid date tag action params'.split())
904         if __debug__:
905             print >>hyperdb.DEBUG, 'addjournal', (nodeid, journaldate,
906                 journaltag, action, params)
908         self.save_journal(classname, cols, nodeid, journaldate,
909             journaltag, action, params)
911     def getjournal(self, classname, nodeid):
912         ''' get the journal for id
913         '''
914         # make sure the node exists
915         if not self.hasnode(classname, nodeid):
916             raise IndexError, '%s has no node %s'%(classname, nodeid)
918         cols = ','.join('nodeid date tag action params'.split())
919         return self.load_journal(classname, cols, nodeid)
921     def save_journal(self, classname, cols, nodeid, journaldate,
922             journaltag, action, params):
923         ''' Save the journal entry to the database
924         '''
925         # make the params db-friendly
926         params = repr(params)
927         dc = self.hyperdb_to_sql_value[hyperdb.Date]
928         entry = (nodeid, dc(journaldate), journaltag, action, params)
930         # do the insert
931         a = self.arg
932         sql = 'insert into %s__journal (%s) values (%s,%s,%s,%s,%s)'%(
933             classname, cols, a, a, a, a, a)
934         if __debug__:
935             print >>hyperdb.DEBUG, 'addjournal', (self, sql, entry)
936         self.cursor.execute(sql, entry)
938     def load_journal(self, classname, cols, nodeid):
939         ''' Load the journal from the database
940         '''
941         # now get the journal entries
942         sql = 'select %s from %s__journal where nodeid=%s order by date'%(
943             cols, classname, self.arg)
944         if __debug__:
945             print >>hyperdb.DEBUG, 'load_journal', (self, sql, nodeid)
946         self.cursor.execute(sql, (nodeid,))
947         res = []
948         dc = self.sql_to_hyperdb_value[hyperdb.Date]
949         for nodeid, date_stamp, user, action, params in self.cursor.fetchall():
950             params = eval(params)
951             # XXX numeric ids
952             res.append((str(nodeid), dc(date_stamp), user, action, params))
953         return res
955     def pack(self, pack_before):
956         ''' Delete all journal entries except "create" before 'pack_before'.
957         '''
958         # get a 'yyyymmddhhmmss' version of the date
959         date_stamp = pack_before.serialise()
961         # do the delete
962         for classname in self.classes.keys():
963             sql = "delete from %s__journal where date<%s and "\
964                 "action<>'create'"%(classname, self.arg)
965             if __debug__:
966                 print >>hyperdb.DEBUG, 'pack', (self, sql, date_stamp)
967             self.cursor.execute(sql, (date_stamp,))
969     def sql_commit(self):
970         ''' Actually commit to the database.
971         '''
972         if __debug__:
973             print >>hyperdb.DEBUG, '+++ commit database connection +++'
974         self.conn.commit()
976     def commit(self):
977         ''' Commit the current transactions.
979         Save all data changed since the database was opened or since the
980         last commit() or rollback().
981         '''
982         if __debug__:
983             print >>hyperdb.DEBUG, 'commit', (self,)
985         # commit the database
986         self.sql_commit()
988         # now, do all the other transaction stuff
989         for method, args in self.transactions:
990             method(*args)
992         # save the indexer state
993         self.indexer.save_index()
995         # clear out the transactions
996         self.transactions = []
998     def sql_rollback(self):
999         self.conn.rollback()
1001     def rollback(self):
1002         ''' Reverse all actions from the current transaction.
1004         Undo all the changes made since the database was opened or the last
1005         commit() or rollback() was performed.
1006         '''
1007         if __debug__:
1008             print >>hyperdb.DEBUG, 'rollback', (self,)
1010         self.sql_rollback()
1012         # roll back "other" transaction stuff
1013         for method, args in self.transactions:
1014             # delete temporary files
1015             if method == self.doStoreFile:
1016                 self.rollbackStoreFile(*args)
1017         self.transactions = []
1019         # clear the cache
1020         self.clearCache()
1022     def doSaveNode(self, classname, nodeid, node):
1023         ''' dummy that just generates a reindex event
1024         '''
1025         # return the classname, nodeid so we reindex this content
1026         return (classname, nodeid)
1028     def sql_close(self):
1029         if __debug__:
1030             print >>hyperdb.DEBUG, '+++ close database connection +++'
1031         self.conn.close()
1033     def close(self):
1034         ''' Close off the connection.
1035         '''
1036         self.indexer.close()
1037         self.sql_close()
1040 # The base Class class
1042 class Class(hyperdb.Class):
1043     ''' The handle to a particular class of nodes in a hyperdatabase.
1044         
1045         All methods except __repr__ and getnode must be implemented by a
1046         concrete backend Class.
1047     '''
1049     def __init__(self, db, classname, **properties):
1050         '''Create a new class with a given name and property specification.
1052         'classname' must not collide with the name of an existing class,
1053         or a ValueError is raised.  The keyword arguments in 'properties'
1054         must map names to property objects, or a TypeError is raised.
1055         '''
1056         for name in 'creation activity creator actor'.split():
1057             if properties.has_key(name):
1058                 raise ValueError, '"creation", "activity", "creator" and '\
1059                     '"actor" are reserved'
1061         self.classname = classname
1062         self.properties = properties
1063         self.db = weakref.proxy(db)       # use a weak ref to avoid circularity
1064         self.key = ''
1066         # should we journal changes (default yes)
1067         self.do_journal = 1
1069         # do the db-related init stuff
1070         db.addclass(self)
1072         self.auditors = {'create': [], 'set': [], 'retire': [], 'restore': []}
1073         self.reactors = {'create': [], 'set': [], 'retire': [], 'restore': []}
1075     def schema(self):
1076         ''' A dumpable version of the schema that we can store in the
1077             database
1078         '''
1079         return (self.key, [(x, repr(y)) for x,y in self.properties.items()])
1081     def enableJournalling(self):
1082         '''Turn journalling on for this class
1083         '''
1084         self.do_journal = 1
1086     def disableJournalling(self):
1087         '''Turn journalling off for this class
1088         '''
1089         self.do_journal = 0
1091     # Editing nodes:
1092     def create(self, **propvalues):
1093         ''' Create a new node of this class and return its id.
1095         The keyword arguments in 'propvalues' map property names to values.
1097         The values of arguments must be acceptable for the types of their
1098         corresponding properties or a TypeError is raised.
1099         
1100         If this class has a key property, it must be present and its value
1101         must not collide with other key strings or a ValueError is raised.
1102         
1103         Any other properties on this class that are missing from the
1104         'propvalues' dictionary are set to None.
1105         
1106         If an id in a link or multilink property does not refer to a valid
1107         node, an IndexError is raised.
1108         '''
1109         self.fireAuditors('create', None, propvalues)
1110         newid = self.create_inner(**propvalues)
1111         self.fireReactors('create', newid, None)
1112         return newid
1113     
1114     def create_inner(self, **propvalues):
1115         ''' Called by create, in-between the audit and react calls.
1116         '''
1117         if propvalues.has_key('id'):
1118             raise KeyError, '"id" is reserved'
1120         if self.db.journaltag is None:
1121             raise DatabaseError, 'Database open read-only'
1123         if propvalues.has_key('creator') or propvalues.has_key('actor') or \
1124              propvalues.has_key('creation') or propvalues.has_key('activity'):
1125             raise KeyError, '"creator", "actor", "creation" and '\
1126                 '"activity" are reserved'
1128         # new node's id
1129         newid = self.db.newid(self.classname)
1131         # validate propvalues
1132         num_re = re.compile('^\d+$')
1133         for key, value in propvalues.items():
1134             if key == self.key:
1135                 try:
1136                     self.lookup(value)
1137                 except KeyError:
1138                     pass
1139                 else:
1140                     raise ValueError, 'node with key "%s" exists'%value
1142             # try to handle this property
1143             try:
1144                 prop = self.properties[key]
1145             except KeyError:
1146                 raise KeyError, '"%s" has no property "%s"'%(self.classname,
1147                     key)
1149             if value is not None and isinstance(prop, Link):
1150                 if type(value) != type(''):
1151                     raise ValueError, 'link value must be String'
1152                 link_class = self.properties[key].classname
1153                 # if it isn't a number, it's a key
1154                 if not num_re.match(value):
1155                     try:
1156                         value = self.db.classes[link_class].lookup(value)
1157                     except (TypeError, KeyError):
1158                         raise IndexError, 'new property "%s": %s not a %s'%(
1159                             key, value, link_class)
1160                 elif not self.db.getclass(link_class).hasnode(value):
1161                     raise IndexError, '%s has no node %s'%(link_class, value)
1163                 # save off the value
1164                 propvalues[key] = value
1166                 # register the link with the newly linked node
1167                 if self.do_journal and self.properties[key].do_journal:
1168                     self.db.addjournal(link_class, value, 'link',
1169                         (self.classname, newid, key))
1171             elif isinstance(prop, Multilink):
1172                 if type(value) != type([]):
1173                     raise TypeError, 'new property "%s" not a list of ids'%key
1175                 # clean up and validate the list of links
1176                 link_class = self.properties[key].classname
1177                 l = []
1178                 for entry in value:
1179                     if type(entry) != type(''):
1180                         raise ValueError, '"%s" multilink value (%r) '\
1181                             'must contain Strings'%(key, value)
1182                     # if it isn't a number, it's a key
1183                     if not num_re.match(entry):
1184                         try:
1185                             entry = self.db.classes[link_class].lookup(entry)
1186                         except (TypeError, KeyError):
1187                             raise IndexError, 'new property "%s": %s not a %s'%(
1188                                 key, entry, self.properties[key].classname)
1189                     l.append(entry)
1190                 value = l
1191                 propvalues[key] = value
1193                 # handle additions
1194                 for nodeid in value:
1195                     if not self.db.getclass(link_class).hasnode(nodeid):
1196                         raise IndexError, '%s has no node %s'%(link_class,
1197                             nodeid)
1198                     # register the link with the newly linked node
1199                     if self.do_journal and self.properties[key].do_journal:
1200                         self.db.addjournal(link_class, nodeid, 'link',
1201                             (self.classname, newid, key))
1203             elif isinstance(prop, String):
1204                 if type(value) != type('') and type(value) != type(u''):
1205                     raise TypeError, 'new property "%s" not a string'%key
1206                 self.db.indexer.add_text((self.classname, newid, key), value)
1208             elif isinstance(prop, Password):
1209                 if not isinstance(value, password.Password):
1210                     raise TypeError, 'new property "%s" not a Password'%key
1212             elif isinstance(prop, Date):
1213                 if value is not None and not isinstance(value, date.Date):
1214                     raise TypeError, 'new property "%s" not a Date'%key
1216             elif isinstance(prop, Interval):
1217                 if value is not None and not isinstance(value, date.Interval):
1218                     raise TypeError, 'new property "%s" not an Interval'%key
1220             elif value is not None and isinstance(prop, Number):
1221                 try:
1222                     float(value)
1223                 except ValueError:
1224                     raise TypeError, 'new property "%s" not numeric'%key
1226             elif value is not None and isinstance(prop, Boolean):
1227                 try:
1228                     int(value)
1229                 except ValueError:
1230                     raise TypeError, 'new property "%s" not boolean'%key
1232         # make sure there's data where there needs to be
1233         for key, prop in self.properties.items():
1234             if propvalues.has_key(key):
1235                 continue
1236             if key == self.key:
1237                 raise ValueError, 'key property "%s" is required'%key
1238             if isinstance(prop, Multilink):
1239                 propvalues[key] = []
1240             else:
1241                 propvalues[key] = None
1243         # done
1244         self.db.addnode(self.classname, newid, propvalues)
1245         if self.do_journal:
1246             self.db.addjournal(self.classname, newid, 'create', {})
1248         # XXX numeric ids
1249         return str(newid)
1251     def export_list(self, propnames, nodeid):
1252         ''' Export a node - generate a list of CSV-able data in the order
1253             specified by propnames for the given node.
1254         '''
1255         properties = self.getprops()
1256         l = []
1257         for prop in propnames:
1258             proptype = properties[prop]
1259             value = self.get(nodeid, prop)
1260             # "marshal" data where needed
1261             if value is None:
1262                 pass
1263             elif isinstance(proptype, hyperdb.Date):
1264                 value = value.get_tuple()
1265             elif isinstance(proptype, hyperdb.Interval):
1266                 value = value.get_tuple()
1267             elif isinstance(proptype, hyperdb.Password):
1268                 value = str(value)
1269             l.append(repr(value))
1270         l.append(repr(self.is_retired(nodeid)))
1271         return l
1273     def import_list(self, propnames, proplist):
1274         ''' Import a node - all information including "id" is present and
1275             should not be sanity checked. Triggers are not triggered. The
1276             journal should be initialised using the "creator" and "created"
1277             information.
1279             Return the nodeid of the node imported.
1280         '''
1281         if self.db.journaltag is None:
1282             raise DatabaseError, 'Database open read-only'
1283         properties = self.getprops()
1285         # make the new node's property map
1286         d = {}
1287         retire = 0
1288         newid = None
1289         for i in range(len(propnames)):
1290             # Use eval to reverse the repr() used to output the CSV
1291             value = eval(proplist[i])
1293             # Figure the property for this column
1294             propname = propnames[i]
1296             # "unmarshal" where necessary
1297             if propname == 'id':
1298                 newid = value
1299                 continue
1300             elif propname == 'is retired':
1301                 # is the item retired?
1302                 if int(value):
1303                     retire = 1
1304                 continue
1305             elif value is None:
1306                 d[propname] = None
1307                 continue
1309             prop = properties[propname]
1310             if value is None:
1311                 # don't set Nones
1312                 continue
1313             elif isinstance(prop, hyperdb.Date):
1314                 value = date.Date(value)
1315             elif isinstance(prop, hyperdb.Interval):
1316                 value = date.Interval(value)
1317             elif isinstance(prop, hyperdb.Password):
1318                 pwd = password.Password()
1319                 pwd.unpack(value)
1320                 value = pwd
1321             d[propname] = value
1323         # get a new id if necessary
1324         if newid is None:
1325             newid = self.db.newid(self.classname)
1327         # add the node and journal
1328         self.db.addnode(self.classname, newid, d)
1330         # retire?
1331         if retire:
1332             # use the arg for __retired__ to cope with any odd database type
1333             # conversion (hello, sqlite)
1334             sql = 'update _%s set __retired__=%s where id=%s'%(self.classname,
1335                 self.db.arg, self.db.arg)
1336             if __debug__:
1337                 print >>hyperdb.DEBUG, 'retire', (self, sql, newid)
1338             self.db.cursor.execute(sql, (1, newid))
1340         # extract the extraneous journalling gumpf and nuke it
1341         if d.has_key('creator'):
1342             creator = d['creator']
1343             del d['creator']
1344         else:
1345             creator = None
1346         if d.has_key('creation'):
1347             creation = d['creation']
1348             del d['creation']
1349         else:
1350             creation = None
1351         if d.has_key('activity'):
1352             del d['activity']
1353         if d.has_key('actor'):
1354             del d['actor']
1355         self.db.addjournal(self.classname, newid, 'create', {}, creator,
1356             creation)
1357         return newid
1359     _marker = []
1360     def get(self, nodeid, propname, default=_marker, cache=1):
1361         '''Get the value of a property on an existing node of this class.
1363         'nodeid' must be the id of an existing node of this class or an
1364         IndexError is raised.  'propname' must be the name of a property
1365         of this class or a KeyError is raised.
1367         'cache' exists for backwards compatibility, and is not used.
1368         '''
1369         if propname == 'id':
1370             return nodeid
1372         # get the node's dict
1373         d = self.db.getnode(self.classname, nodeid)
1375         if propname == 'creation':
1376             if d.has_key('creation'):
1377                 return d['creation']
1378             else:
1379                 return date.Date()
1380         if propname == 'activity':
1381             if d.has_key('activity'):
1382                 return d['activity']
1383             else:
1384                 return date.Date()
1385         if propname == 'creator':
1386             if d.has_key('creator'):
1387                 return d['creator']
1388             else:
1389                 return self.db.getuid()
1390         if propname == 'actor':
1391             if d.has_key('actor'):
1392                 return d['actor']
1393             else:
1394                 return self.db.getuid()
1396         # get the property (raises KeyErorr if invalid)
1397         prop = self.properties[propname]
1399         if not d.has_key(propname):
1400             if default is self._marker:
1401                 if isinstance(prop, Multilink):
1402                     return []
1403                 else:
1404                     return None
1405             else:
1406                 return default
1408         # don't pass our list to other code
1409         if isinstance(prop, Multilink):
1410             return d[propname][:]
1412         return d[propname]
1414     def set(self, nodeid, **propvalues):
1415         '''Modify a property on an existing node of this class.
1416         
1417         'nodeid' must be the id of an existing node of this class or an
1418         IndexError is raised.
1420         Each key in 'propvalues' must be the name of a property of this
1421         class or a KeyError is raised.
1423         All values in 'propvalues' must be acceptable types for their
1424         corresponding properties or a TypeError is raised.
1426         If the value of the key property is set, it must not collide with
1427         other key strings or a ValueError is raised.
1429         If the value of a Link or Multilink property contains an invalid
1430         node id, a ValueError is raised.
1431         '''
1432         self.fireAuditors('set', nodeid, propvalues)
1433         oldvalues = copy.deepcopy(self.db.getnode(self.classname, nodeid))
1434         propvalues = self.set_inner(nodeid, **propvalues)
1435         self.fireReactors('set', nodeid, oldvalues)
1436         return propvalues        
1438     def set_inner(self, nodeid, **propvalues):
1439         ''' Called by set, in-between the audit and react calls.
1440         ''' 
1441         if not propvalues:
1442             return propvalues
1444         if propvalues.has_key('creation') or propvalues.has_key('creator') or \
1445                 propvalues.has_key('actor') or propvalues.has_key('activity'):
1446             raise KeyError, '"creation", "creator", "actor" and '\
1447                 '"activity" are reserved'
1449         if propvalues.has_key('id'):
1450             raise KeyError, '"id" is reserved'
1452         if self.db.journaltag is None:
1453             raise DatabaseError, 'Database open read-only'
1455         node = self.db.getnode(self.classname, nodeid)
1456         if self.is_retired(nodeid):
1457             raise IndexError, 'Requested item is retired'
1458         num_re = re.compile('^\d+$')
1460         # if the journal value is to be different, store it in here
1461         journalvalues = {}
1463         # remember the add/remove stuff for multilinks, making it easier
1464         # for the Database layer to do its stuff
1465         multilink_changes = {}
1467         for propname, value in propvalues.items():
1468             # check to make sure we're not duplicating an existing key
1469             if propname == self.key and node[propname] != value:
1470                 try:
1471                     self.lookup(value)
1472                 except KeyError:
1473                     pass
1474                 else:
1475                     raise ValueError, 'node with key "%s" exists'%value
1477             # this will raise the KeyError if the property isn't valid
1478             # ... we don't use getprops() here because we only care about
1479             # the writeable properties.
1480             try:
1481                 prop = self.properties[propname]
1482             except KeyError:
1483                 raise KeyError, '"%s" has no property named "%s"'%(
1484                     self.classname, propname)
1486             # if the value's the same as the existing value, no sense in
1487             # doing anything
1488             current = node.get(propname, None)
1489             if value == current:
1490                 del propvalues[propname]
1491                 continue
1492             journalvalues[propname] = current
1494             # do stuff based on the prop type
1495             if isinstance(prop, Link):
1496                 link_class = prop.classname
1497                 # if it isn't a number, it's a key
1498                 if value is not None and not isinstance(value, type('')):
1499                     raise ValueError, 'property "%s" link value be a string'%(
1500                         propname)
1501                 if isinstance(value, type('')) and not num_re.match(value):
1502                     try:
1503                         value = self.db.classes[link_class].lookup(value)
1504                     except (TypeError, KeyError):
1505                         raise IndexError, 'new property "%s": %s not a %s'%(
1506                             propname, value, prop.classname)
1508                 if (value is not None and
1509                         not self.db.getclass(link_class).hasnode(value)):
1510                     raise IndexError, '%s has no node %s'%(link_class, value)
1512                 if self.do_journal and prop.do_journal:
1513                     # register the unlink with the old linked node
1514                     if node[propname] is not None:
1515                         self.db.addjournal(link_class, node[propname], 'unlink',
1516                             (self.classname, nodeid, propname))
1518                     # register the link with the newly linked node
1519                     if value is not None:
1520                         self.db.addjournal(link_class, value, 'link',
1521                             (self.classname, nodeid, propname))
1523             elif isinstance(prop, Multilink):
1524                 if type(value) != type([]):
1525                     raise TypeError, 'new property "%s" not a list of'\
1526                         ' ids'%propname
1527                 link_class = self.properties[propname].classname
1528                 l = []
1529                 for entry in value:
1530                     # if it isn't a number, it's a key
1531                     if type(entry) != type(''):
1532                         raise ValueError, 'new property "%s" link value ' \
1533                             'must be a string'%propname
1534                     if not num_re.match(entry):
1535                         try:
1536                             entry = self.db.classes[link_class].lookup(entry)
1537                         except (TypeError, KeyError):
1538                             raise IndexError, 'new property "%s": %s not a %s'%(
1539                                 propname, entry,
1540                                 self.properties[propname].classname)
1541                     l.append(entry)
1542                 value = l
1543                 propvalues[propname] = value
1545                 # figure the journal entry for this property
1546                 add = []
1547                 remove = []
1549                 # handle removals
1550                 if node.has_key(propname):
1551                     l = node[propname]
1552                 else:
1553                     l = []
1554                 for id in l[:]:
1555                     if id in value:
1556                         continue
1557                     # register the unlink with the old linked node
1558                     if self.do_journal and self.properties[propname].do_journal:
1559                         self.db.addjournal(link_class, id, 'unlink',
1560                             (self.classname, nodeid, propname))
1561                     l.remove(id)
1562                     remove.append(id)
1564                 # handle additions
1565                 for id in value:
1566                     if not self.db.getclass(link_class).hasnode(id):
1567                         raise IndexError, '%s has no node %s'%(link_class, id)
1568                     if id in l:
1569                         continue
1570                     # register the link with the newly linked node
1571                     if self.do_journal and self.properties[propname].do_journal:
1572                         self.db.addjournal(link_class, id, 'link',
1573                             (self.classname, nodeid, propname))
1574                     l.append(id)
1575                     add.append(id)
1577                 # figure the journal entry
1578                 l = []
1579                 if add:
1580                     l.append(('+', add))
1581                 if remove:
1582                     l.append(('-', remove))
1583                 multilink_changes[propname] = (add, remove)
1584                 if l:
1585                     journalvalues[propname] = tuple(l)
1587             elif isinstance(prop, String):
1588                 if value is not None and type(value) != type('') and type(value) != type(u''):
1589                     raise TypeError, 'new property "%s" not a string'%propname
1590                 self.db.indexer.add_text((self.classname, nodeid, propname),
1591                     value)
1593             elif isinstance(prop, Password):
1594                 if not isinstance(value, password.Password):
1595                     raise TypeError, 'new property "%s" not a Password'%propname
1596                 propvalues[propname] = value
1598             elif value is not None and isinstance(prop, Date):
1599                 if not isinstance(value, date.Date):
1600                     raise TypeError, 'new property "%s" not a Date'% propname
1601                 propvalues[propname] = value
1603             elif value is not None and isinstance(prop, Interval):
1604                 if not isinstance(value, date.Interval):
1605                     raise TypeError, 'new property "%s" not an '\
1606                         'Interval'%propname
1607                 propvalues[propname] = value
1609             elif value is not None and isinstance(prop, Number):
1610                 try:
1611                     float(value)
1612                 except ValueError:
1613                     raise TypeError, 'new property "%s" not numeric'%propname
1615             elif value is not None and isinstance(prop, Boolean):
1616                 try:
1617                     int(value)
1618                 except ValueError:
1619                     raise TypeError, 'new property "%s" not boolean'%propname
1621         # nothing to do?
1622         if not propvalues:
1623             return propvalues
1625         # do the set, and journal it
1626         self.db.setnode(self.classname, nodeid, propvalues, multilink_changes)
1628         if self.do_journal:
1629             self.db.addjournal(self.classname, nodeid, 'set', journalvalues)
1631         return propvalues        
1633     def retire(self, nodeid):
1634         '''Retire a node.
1635         
1636         The properties on the node remain available from the get() method,
1637         and the node's id is never reused.
1638         
1639         Retired nodes are not returned by the find(), list(), or lookup()
1640         methods, and other nodes may reuse the values of their key properties.
1641         '''
1642         if self.db.journaltag is None:
1643             raise DatabaseError, 'Database open read-only'
1645         self.fireAuditors('retire', nodeid, None)
1647         # use the arg for __retired__ to cope with any odd database type
1648         # conversion (hello, sqlite)
1649         sql = 'update _%s set __retired__=%s where id=%s'%(self.classname,
1650             self.db.arg, self.db.arg)
1651         if __debug__:
1652             print >>hyperdb.DEBUG, 'retire', (self, sql, nodeid)
1653         self.db.cursor.execute(sql, (1, nodeid))
1654         if self.do_journal:
1655             self.db.addjournal(self.classname, nodeid, 'retired', None)
1657         self.fireReactors('retire', nodeid, None)
1659     def restore(self, nodeid):
1660         '''Restore a retired node.
1662         Make node available for all operations like it was before retirement.
1663         '''
1664         if self.db.journaltag is None:
1665             raise DatabaseError, 'Database open read-only'
1667         node = self.db.getnode(self.classname, nodeid)
1668         # check if key property was overrided
1669         key = self.getkey()
1670         try:
1671             id = self.lookup(node[key])
1672         except KeyError:
1673             pass
1674         else:
1675             raise KeyError, "Key property (%s) of retired node clashes with \
1676                 existing one (%s)" % (key, node[key])
1678         self.fireAuditors('restore', 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, 'restore', (self, sql, nodeid)
1685         self.db.cursor.execute(sql, (0, nodeid))
1686         if self.do_journal:
1687             self.db.addjournal(self.classname, nodeid, 'restored', None)
1689         self.fireReactors('restore', nodeid, None)
1690         
1691     def is_retired(self, nodeid):
1692         '''Return true if the node is rerired
1693         '''
1694         sql = 'select __retired__ from _%s where id=%s'%(self.classname,
1695             self.db.arg)
1696         if __debug__:
1697             print >>hyperdb.DEBUG, 'is_retired', (self, sql, nodeid)
1698         self.db.cursor.execute(sql, (nodeid,))
1699         return int(self.db.sql_fetchone()[0])
1701     def destroy(self, nodeid):
1702         '''Destroy a node.
1703         
1704         WARNING: this method should never be used except in extremely rare
1705                  situations where there could never be links to the node being
1706                  deleted
1708         WARNING: use retire() instead
1710         WARNING: the properties of this node will not be available ever again
1712         WARNING: really, use retire() instead
1714         Well, I think that's enough warnings. This method exists mostly to
1715         support the session storage of the cgi interface.
1717         The node is completely removed from the hyperdb, including all journal
1718         entries. It will no longer be available, and will generally break code
1719         if there are any references to the node.
1720         '''
1721         if self.db.journaltag is None:
1722             raise DatabaseError, 'Database open read-only'
1723         self.db.destroynode(self.classname, nodeid)
1725     def history(self, nodeid):
1726         '''Retrieve the journal of edits on a particular node.
1728         'nodeid' must be the id of an existing node of this class or an
1729         IndexError is raised.
1731         The returned list contains tuples of the form
1733             (nodeid, date, tag, action, params)
1735         'date' is a Timestamp object specifying the time of the change and
1736         'tag' is the journaltag specified when the database was opened.
1737         '''
1738         if not self.do_journal:
1739             raise ValueError, 'Journalling is disabled for this class'
1740         return self.db.getjournal(self.classname, nodeid)
1742     # Locating nodes:
1743     def hasnode(self, nodeid):
1744         '''Determine if the given nodeid actually exists
1745         '''
1746         return self.db.hasnode(self.classname, nodeid)
1748     def setkey(self, propname):
1749         '''Select a String property of this class to be the key property.
1751         'propname' must be the name of a String property of this class or
1752         None, or a TypeError is raised.  The values of the key property on
1753         all existing nodes must be unique or a ValueError is raised.
1754         '''
1755         # XXX create an index on the key prop column. We should also 
1756         # record that we've created this index in the schema somewhere.
1757         prop = self.getprops()[propname]
1758         if not isinstance(prop, String):
1759             raise TypeError, 'key properties must be String'
1760         self.key = propname
1762     def getkey(self):
1763         '''Return the name of the key property for this class or None.'''
1764         return self.key
1766     def labelprop(self, default_to_id=0):
1767         '''Return the property name for a label for the given node.
1769         This method attempts to generate a consistent label for the node.
1770         It tries the following in order:
1772         1. key property
1773         2. "name" property
1774         3. "title" property
1775         4. first property from the sorted property name list
1776         '''
1777         k = self.getkey()
1778         if  k:
1779             return k
1780         props = self.getprops()
1781         if props.has_key('name'):
1782             return 'name'
1783         elif props.has_key('title'):
1784             return 'title'
1785         if default_to_id:
1786             return 'id'
1787         props = props.keys()
1788         props.sort()
1789         return props[0]
1791     def lookup(self, keyvalue):
1792         '''Locate a particular node by its key property and return its id.
1794         If this class has no key property, a TypeError is raised.  If the
1795         'keyvalue' matches one of the values for the key property among
1796         the nodes in this class, the matching node's id is returned;
1797         otherwise a KeyError is raised.
1798         '''
1799         if not self.key:
1800             raise TypeError, 'No key property set for class %s'%self.classname
1802         # use the arg to handle any odd database type conversion (hello,
1803         # sqlite)
1804         sql = "select id from _%s where _%s=%s and __retired__ <> %s"%(
1805             self.classname, self.key, self.db.arg, self.db.arg)
1806         self.db.sql(sql, (keyvalue, 1))
1808         # see if there was a result that's not retired
1809         row = self.db.sql_fetchone()
1810         if not row:
1811             raise KeyError, 'No key (%s) value "%s" for "%s"'%(self.key,
1812                 keyvalue, self.classname)
1814         # return the id
1815         # XXX numeric ids
1816         return str(row[0])
1818     def find(self, **propspec):
1819         '''Get the ids of nodes in this class which link to the given nodes.
1821         'propspec' consists of keyword args propname=nodeid or
1822                    propname={nodeid:1, }
1823         'propname' must be the name of a property in this class, or a
1824                    KeyError is raised.  That property must be a Link or
1825                    Multilink property, or a TypeError is raised.
1827         Any node in this class whose 'propname' property links to any of the
1828         nodeids will be returned. Used by the full text indexing, which knows
1829         that "foo" occurs in msg1, msg3 and file7, so we have hits on these
1830         issues:
1832             db.issue.find(messages={'1':1,'3':1}, files={'7':1})
1833         '''
1834         if __debug__:
1835             print >>hyperdb.DEBUG, 'find', (self, propspec)
1837         # shortcut
1838         if not propspec:
1839             return []
1841         # validate the args
1842         props = self.getprops()
1843         propspec = propspec.items()
1844         for propname, nodeids in propspec:
1845             # check the prop is OK
1846             prop = props[propname]
1847             if not isinstance(prop, Link) and not isinstance(prop, Multilink):
1848                 raise TypeError, "'%s' not a Link/Multilink property"%propname
1850         # first, links
1851         a = self.db.arg
1852         allvalues = (1,)
1853         o = []
1854         where = []
1855         for prop, values in propspec:
1856             if not isinstance(props[prop], hyperdb.Link):
1857                 continue
1858             if type(values) is type({}) and len(values) == 1:
1859                 values = values.keys()[0]
1860             if type(values) is type(''):
1861                 allvalues += (values,)
1862                 where.append('_%s = %s'%(prop, a))
1863             elif values is None:
1864                 where.append('_%s is NULL'%prop)
1865             else:
1866                 allvalues += tuple(values.keys())
1867                 where.append('_%s in (%s)'%(prop, ','.join([a]*len(values))))
1868         tables = ['_%s'%self.classname]
1869         if where:
1870             o.append('(' + ' and '.join(where) + ')')
1872         # now multilinks
1873         for prop, values in propspec:
1874             if not isinstance(props[prop], hyperdb.Multilink):
1875                 continue
1876             if not values:
1877                 continue
1878             if type(values) is type(''):
1879                 allvalues += (values,)
1880                 s = a
1881             else:
1882                 allvalues += tuple(values.keys())
1883                 s = ','.join([a]*len(values))
1884             tn = '%s_%s'%(self.classname, prop)
1885             tables.append(tn)
1886             o.append('(id=%s.nodeid and %s.linkid in (%s))'%(tn, tn, s))
1888         if not o:
1889             return []
1890         elif len(o) > 1:
1891             o = '(' + ' or '.join(['(%s)'%i for i in o]) + ')'
1892         else:
1893             o = o[0]
1894         t = ', '.join(tables)
1895         sql = 'select distinct(id) from %s where __retired__ <> %s and %s'%(
1896             t, a, o)
1897         self.db.sql(sql, allvalues)
1898         # XXX numeric ids
1899         l = [str(x[0]) for x in self.db.sql_fetchall()]
1900         if __debug__:
1901             print >>hyperdb.DEBUG, 'find ... ', l
1902         return l
1904     def stringFind(self, **requirements):
1905         '''Locate a particular node by matching a set of its String
1906         properties in a caseless search.
1908         If the property is not a String property, a TypeError is raised.
1909         
1910         The return is a list of the id of all nodes that match.
1911         '''
1912         where = []
1913         args = []
1914         for propname in requirements.keys():
1915             prop = self.properties[propname]
1916             if not isinstance(prop, String):
1917                 raise TypeError, "'%s' not a String property"%propname
1918             where.append(propname)
1919             args.append(requirements[propname].lower())
1921         # generate the where clause
1922         s = ' and '.join(['lower(_%s)=%s'%(col, self.db.arg) for col in where])
1923         sql = 'select id from _%s where %s and __retired__=%s'%(self.classname,
1924             s, self.db.arg)
1925         args.append(0)
1926         self.db.sql(sql, tuple(args))
1927         # XXX numeric ids
1928         l = [str(x[0]) for x in self.db.sql_fetchall()]
1929         if __debug__:
1930             print >>hyperdb.DEBUG, 'find ... ', l
1931         return l
1933     def list(self):
1934         ''' Return a list of the ids of the active nodes in this class.
1935         '''
1936         return self.getnodeids(retired=0)
1938     def getnodeids(self, retired=None):
1939         ''' Retrieve all the ids of the nodes for a particular Class.
1941             Set retired=None to get all nodes. Otherwise it'll get all the 
1942             retired or non-retired nodes, depending on the flag.
1943         '''
1944         # flip the sense of the 'retired' flag if we don't want all of them
1945         if retired is not None:
1946             if retired:
1947                 args = (0, )
1948             else:
1949                 args = (1, )
1950             sql = 'select id from _%s where __retired__ <> %s'%(self.classname,
1951                 self.db.arg)
1952         else:
1953             args = ()
1954             sql = 'select id from _%s'%self.classname
1955         if __debug__:
1956             print >>hyperdb.DEBUG, 'getnodeids', (self, sql, retired)
1957         self.db.cursor.execute(sql, args)
1958         # XXX numeric ids
1959         ids = [str(x[0]) for x in self.db.cursor.fetchall()]
1960         return ids
1962     def filter(self, search_matches, filterspec, sort=(None,None),
1963             group=(None,None)):
1964         '''Return a list of the ids of the active nodes in this class that
1965         match the 'filter' spec, sorted by the group spec and then the
1966         sort spec
1968         "filterspec" is {propname: value(s)}
1970         "sort" and "group" are (dir, prop) where dir is '+', '-' or None
1971         and prop is a prop name or None
1973         "search_matches" is {nodeid: marker}
1975         The filter must match all properties specificed - but if the
1976         property value to match is a list, any one of the values in the
1977         list may match for that property to match.
1978         '''
1979         # just don't bother if the full-text search matched diddly
1980         if search_matches == {}:
1981             return []
1983         cn = self.classname
1985         timezone = self.db.getUserTimezone()
1986         
1987         # figure the WHERE clause from the filterspec
1988         props = self.getprops()
1989         frum = ['_'+cn]
1990         where = []
1991         args = []
1992         a = self.db.arg
1993         for k, v in filterspec.items():
1994             propclass = props[k]
1995             # now do other where clause stuff
1996             if isinstance(propclass, Multilink):
1997                 tn = '%s_%s'%(cn, k)
1998                 if v in ('-1', ['-1']):
1999                     # only match rows that have count(linkid)=0 in the
2000                     # corresponding multilink table)
2001                     where.append('id not in (select nodeid from %s)'%tn)
2002                 elif isinstance(v, type([])):
2003                     frum.append(tn)
2004                     s = ','.join([a for x in v])
2005                     where.append('id=%s.nodeid and %s.linkid in (%s)'%(tn,tn,s))
2006                     args = args + v
2007                 else:
2008                     frum.append(tn)
2009                     where.append('id=%s.nodeid and %s.linkid=%s'%(tn, tn, a))
2010                     args.append(v)
2011             elif k == 'id':
2012                 if isinstance(v, type([])):
2013                     s = ','.join([a for x in v])
2014                     where.append('%s in (%s)'%(k, s))
2015                     args = args + v
2016                 else:
2017                     where.append('%s=%s'%(k, a))
2018                     args.append(v)
2019             elif isinstance(propclass, String):
2020                 if not isinstance(v, type([])):
2021                     v = [v]
2023                 # Quote the bits in the string that need it and then embed
2024                 # in a "substring" search. Note - need to quote the '%' so
2025                 # they make it through the python layer happily
2026                 v = ['%%'+self.db.sql_stringquote(s)+'%%' for s in v]
2028                 # now add to the where clause
2029                 where.append(' or '.join(["_%s LIKE '%s'"%(k, s) for s in v]))
2030                 # note: args are embedded in the query string now
2031             elif isinstance(propclass, Link):
2032                 if isinstance(v, type([])):
2033                     if '-1' in v:
2034                         v = v[:]
2035                         v.remove('-1')
2036                         xtra = ' or _%s is NULL'%k
2037                     else:
2038                         xtra = ''
2039                     if v:
2040                         s = ','.join([a for x in v])
2041                         where.append('(_%s in (%s)%s)'%(k, s, xtra))
2042                         args = args + v
2043                     else:
2044                         where.append('_%s is NULL'%k)
2045                 else:
2046                     if v == '-1':
2047                         v = None
2048                         where.append('_%s is NULL'%k)
2049                     else:
2050                         where.append('_%s=%s'%(k, a))
2051                         args.append(v)
2052             elif isinstance(propclass, Date):
2053                 dc = self.db.hyperdb_to_sql_value[hyperdb.Date]
2054                 if isinstance(v, type([])):
2055                     s = ','.join([a for x in v])
2056                     where.append('_%s in (%s)'%(k, s))
2057                     args = args + [dc(date.Date(v)) for x in v]
2058                 else:
2059                     try:
2060                         # Try to filter on range of dates
2061                         date_rng = Range(v, date.Date, offset=timezone)
2062                         if date_rng.from_value:
2063                             where.append('_%s >= %s'%(k, a))                            
2064                             args.append(dc(date_rng.from_value))
2065                         if date_rng.to_value:
2066                             where.append('_%s <= %s'%(k, a))
2067                             args.append(dc(date_rng.to_value))
2068                     except ValueError:
2069                         # If range creation fails - ignore that search parameter
2070                         pass                        
2071             elif isinstance(propclass, Interval):
2072                 if isinstance(v, type([])):
2073                     s = ','.join([a for x in v])
2074                     where.append('_%s in (%s)'%(k, s))
2075                     args = args + [date.Interval(x).serialise() for x in v]
2076                 else:
2077                     try:
2078                         # Try to filter on range of intervals
2079                         date_rng = Range(v, date.Interval)
2080                         if date_rng.from_value:
2081                             where.append('_%s >= %s'%(k, a))
2082                             args.append(date_rng.from_value.serialise())
2083                         if date_rng.to_value:
2084                             where.append('_%s <= %s'%(k, a))
2085                             args.append(date_rng.to_value.serialise())
2086                     except ValueError:
2087                         # If range creation fails - ignore that search parameter
2088                         pass                        
2089                     #where.append('_%s=%s'%(k, a))
2090                     #args.append(date.Interval(v).serialise())
2091             else:
2092                 if isinstance(v, type([])):
2093                     s = ','.join([a for x in v])
2094                     where.append('_%s in (%s)'%(k, s))
2095                     args = args + v
2096                 else:
2097                     where.append('_%s=%s'%(k, a))
2098                     args.append(v)
2100         # don't match retired nodes
2101         where.append('__retired__ <> 1')
2103         # add results of full text search
2104         if search_matches is not None:
2105             v = search_matches.keys()
2106             s = ','.join([a for x in v])
2107             where.append('id in (%s)'%s)
2108             args = args + v
2110         # "grouping" is just the first-order sorting in the SQL fetch
2111         # can modify it...)
2112         orderby = []
2113         ordercols = []
2114         if group[0] is not None and group[1] is not None:
2115             if group[0] != '-':
2116                 orderby.append('_'+group[1])
2117                 ordercols.append('_'+group[1])
2118             else:
2119                 orderby.append('_'+group[1]+' desc')
2120                 ordercols.append('_'+group[1])
2122         # now add in the sorting
2123         group = ''
2124         if sort[0] is not None and sort[1] is not None:
2125             direction, colname = sort
2126             if direction != '-':
2127                 if colname == 'id':
2128                     orderby.append(colname)
2129                 else:
2130                     orderby.append('_'+colname)
2131                     ordercols.append('_'+colname)
2132             else:
2133                 if colname == 'id':
2134                     orderby.append(colname+' desc')
2135                     ordercols.append(colname)
2136                 else:
2137                     orderby.append('_'+colname+' desc')
2138                     ordercols.append('_'+colname)
2140         # construct the SQL
2141         frum = ','.join(frum)
2142         if where:
2143             where = ' where ' + (' and '.join(where))
2144         else:
2145             where = ''
2146         cols = ['id']
2147         if orderby:
2148             cols = cols + ordercols
2149             order = ' order by %s'%(','.join(orderby))
2150         else:
2151             order = ''
2152         cols = ','.join(cols)
2153         sql = 'select %s from %s %s%s%s'%(cols, frum, where, group, order)
2154         args = tuple(args)
2155         if __debug__:
2156             print >>hyperdb.DEBUG, 'filter', (self, sql, args)
2157         if args:
2158             self.db.cursor.execute(sql, args)
2159         else:
2160             # psycopg doesn't like empty args
2161             self.db.cursor.execute(sql)
2162         l = self.db.sql_fetchall()
2164         # return the IDs (the first column)
2165         # XXX numeric ids
2166         return [str(row[0]) for row in l]
2168     def count(self):
2169         '''Get the number of nodes in this class.
2171         If the returned integer is 'numnodes', the ids of all the nodes
2172         in this class run from 1 to numnodes, and numnodes+1 will be the
2173         id of the next node to be created in this class.
2174         '''
2175         return self.db.countnodes(self.classname)
2177     # Manipulating properties:
2178     def getprops(self, protected=1):
2179         '''Return a dictionary mapping property names to property objects.
2180            If the "protected" flag is true, we include protected properties -
2181            those which may not be modified.
2182         '''
2183         d = self.properties.copy()
2184         if protected:
2185             d['id'] = String()
2186             d['creation'] = hyperdb.Date()
2187             d['activity'] = hyperdb.Date()
2188             d['creator'] = hyperdb.Link('user')
2189             d['actor'] = hyperdb.Link('user')
2190         return d
2192     def addprop(self, **properties):
2193         '''Add properties to this class.
2195         The keyword arguments in 'properties' must map names to property
2196         objects, or a TypeError is raised.  None of the keys in 'properties'
2197         may collide with the names of existing properties, or a ValueError
2198         is raised before any properties have been added.
2199         '''
2200         for key in properties.keys():
2201             if self.properties.has_key(key):
2202                 raise ValueError, key
2203         self.properties.update(properties)
2205     def index(self, nodeid):
2206         '''Add (or refresh) the node to search indexes
2207         '''
2208         # find all the String properties that have indexme
2209         for prop, propclass in self.getprops().items():
2210             if isinstance(propclass, String) and propclass.indexme:
2211                 self.db.indexer.add_text((self.classname, nodeid, prop),
2212                     str(self.get(nodeid, prop)))
2215     #
2216     # Detector interface
2217     #
2218     def audit(self, event, detector):
2219         '''Register a detector
2220         '''
2221         l = self.auditors[event]
2222         if detector not in l:
2223             self.auditors[event].append(detector)
2225     def fireAuditors(self, action, nodeid, newvalues):
2226         '''Fire all registered auditors.
2227         '''
2228         for audit in self.auditors[action]:
2229             audit(self.db, self, nodeid, newvalues)
2231     def react(self, event, detector):
2232         '''Register a detector
2233         '''
2234         l = self.reactors[event]
2235         if detector not in l:
2236             self.reactors[event].append(detector)
2238     def fireReactors(self, action, nodeid, oldvalues):
2239         '''Fire all registered reactors.
2240         '''
2241         for react in self.reactors[action]:
2242             react(self.db, self, nodeid, oldvalues)
2244 class FileClass(Class, hyperdb.FileClass):
2245     '''This class defines a large chunk of data. To support this, it has a
2246        mandatory String property "content" which is typically saved off
2247        externally to the hyperdb.
2249        The default MIME type of this data is defined by the
2250        "default_mime_type" class attribute, which may be overridden by each
2251        node if the class defines a "type" String property.
2252     '''
2253     default_mime_type = 'text/plain'
2255     def create(self, **propvalues):
2256         ''' snaffle the file propvalue and store in a file
2257         '''
2258         # we need to fire the auditors now, or the content property won't
2259         # be in propvalues for the auditors to play with
2260         self.fireAuditors('create', None, propvalues)
2262         # now remove the content property so it's not stored in the db
2263         content = propvalues['content']
2264         del propvalues['content']
2266         # do the database create
2267         newid = self.create_inner(**propvalues)
2269         # figure the mime type
2270         mime_type = propvalues.get('type', self.default_mime_type)
2272         # and index!
2273         self.db.indexer.add_text((self.classname, newid, 'content'), content,
2274             mime_type)
2276         # fire reactors
2277         self.fireReactors('create', newid, None)
2279         # store off the content as a file
2280         self.db.storefile(self.classname, newid, None, content)
2281         return newid
2283     def import_list(self, propnames, proplist):
2284         ''' Trap the "content" property...
2285         '''
2286         # dupe this list so we don't affect others
2287         propnames = propnames[:]
2289         # extract the "content" property from the proplist
2290         i = propnames.index('content')
2291         content = eval(proplist[i])
2292         del propnames[i]
2293         del proplist[i]
2295         # do the normal import
2296         newid = Class.import_list(self, propnames, proplist)
2298         # save off the "content" file
2299         self.db.storefile(self.classname, newid, None, content)
2300         return newid
2302     _marker = []
2303     def get(self, nodeid, propname, default=_marker, cache=1):
2304         ''' Trap the content propname and get it from the file
2306         'cache' exists for backwards compatibility, and is not used.
2307         '''
2308         poss_msg = 'Possibly a access right configuration problem.'
2309         if propname == 'content':
2310             try:
2311                 return self.db.getfile(self.classname, nodeid, None)
2312             except IOError, (strerror):
2313                 # BUG: by catching this we donot see an error in the log.
2314                 return 'ERROR reading file: %s%s\n%s\n%s'%(
2315                         self.classname, nodeid, poss_msg, strerror)
2316         if default is not self._marker:
2317             return Class.get(self, nodeid, propname, default)
2318         else:
2319             return Class.get(self, nodeid, propname)
2321     def getprops(self, protected=1):
2322         ''' In addition to the actual properties on the node, these methods
2323             provide the "content" property. If the "protected" flag is true,
2324             we include protected properties - those which may not be
2325             modified.
2326         '''
2327         d = Class.getprops(self, protected=protected).copy()
2328         d['content'] = hyperdb.String()
2329         return d
2331     def set(self, itemid, **propvalues):
2332         ''' Snarf the "content" propvalue and update it in a file
2333         '''
2334         self.fireAuditors('set', itemid, propvalues)
2335         oldvalues = copy.deepcopy(self.db.getnode(self.classname, itemid))
2337         # now remove the content property so it's not stored in the db
2338         content = None
2339         if propvalues.has_key('content'):
2340             content = propvalues['content']
2341             del propvalues['content']
2343         # do the database create
2344         propvalues = self.set_inner(itemid, **propvalues)
2346         # do content?
2347         if content:
2348             # store and index
2349             self.db.storefile(self.classname, itemid, None, content)
2350             mime_type = propvalues.get('type', self.get(itemid, 'type'))
2351             if not mime_type:
2352                 mime_type = self.default_mime_type
2353             self.db.indexer.add_text((self.classname, itemid, 'content'),
2354                 content, mime_type)
2356         # fire reactors
2357         self.fireReactors('set', itemid, oldvalues)
2358         return propvalues
2360 # XXX deviation from spec - was called ItemClass
2361 class IssueClass(Class, roundupdb.IssueClass):
2362     # Overridden methods:
2363     def __init__(self, db, classname, **properties):
2364         '''The newly-created class automatically includes the "messages",
2365         "files", "nosy", and "superseder" properties.  If the 'properties'
2366         dictionary attempts to specify any of these properties or a
2367         "creation", "creator", "activity" or "actor" property, a ValueError
2368         is raised.
2369         '''
2370         if not properties.has_key('title'):
2371             properties['title'] = hyperdb.String(indexme='yes')
2372         if not properties.has_key('messages'):
2373             properties['messages'] = hyperdb.Multilink("msg")
2374         if not properties.has_key('files'):
2375             properties['files'] = hyperdb.Multilink("file")
2376         if not properties.has_key('nosy'):
2377             # note: journalling is turned off as it really just wastes
2378             # space. this behaviour may be overridden in an instance
2379             properties['nosy'] = hyperdb.Multilink("user", do_journal="no")
2380         if not properties.has_key('superseder'):
2381             properties['superseder'] = hyperdb.Multilink(classname)
2382         Class.__init__(self, db, classname, **properties)