Code

MySQL and Postgresql use BOOL/BOOLEAN for Boolean types
[roundup.git] / roundup / backends / rdbms_common.py
1 # $Id: rdbms_common.py,v 1.87 2004-03-31 07:25:14 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             # change the schema structure
204             self.database_schema = {'tables': self.database_schema}
206             # version 1 didn't have the actor column (note that in
207             # MySQL this will also transition the tables to typed columns)
208             self.add_actor_column()
210             # version 1 doesn't have the OTK, session and indexing in the
211             # database
212             self.create_version_2_tables()
214         self.database_schema['version'] = self.current_db_version
215         return 1
218     def refresh_database(self):
219         self.post_init()
221     def reindex(self):
222         for klass in self.classes.values():
223             for nodeid in klass.list():
224                 klass.index(nodeid)
225         self.indexer.save_index()
228     hyperdb_to_sql_datatypes = {
229         hyperdb.String : 'VARCHAR(255)',
230         hyperdb.Date   : 'TIMESTAMP',
231         hyperdb.Link   : 'INTEGER',
232         hyperdb.Interval  : 'VARCHAR(255)',
233         hyperdb.Password  : 'VARCHAR(255)',
234         hyperdb.Boolean   : 'BOOLEAN',
235         hyperdb.Number    : 'REAL',
236     }
237     def determine_columns(self, properties):
238         ''' Figure the column names and multilink properties from the spec
240             "properties" is a list of (name, prop) where prop may be an
241             instance of a hyperdb "type" _or_ a string repr of that type.
242         '''
243         cols = [
244             ('_actor', self.hyperdb_to_sql_datatypes[hyperdb.Link]),
245             ('_activity', self.hyperdb_to_sql_datatypes[hyperdb.Date]),
246             ('_creator', self.hyperdb_to_sql_datatypes[hyperdb.Link]),
247             ('_creation', self.hyperdb_to_sql_datatypes[hyperdb.Date]),
248         ]
249         mls = []
250         # add the multilinks separately
251         for col, prop in properties:
252             if isinstance(prop, Multilink):
253                 mls.append(col)
254                 continue
256             if isinstance(prop, type('')):
257                 raise ValueError, "string property spec!"
258                 #and prop.find('Multilink') != -1:
259                 #mls.append(col)
261             datatype = self.hyperdb_to_sql_datatypes[prop.__class__]
262             cols.append(('_'+col, datatype))
264         cols.sort()
265         return cols, mls
267     def update_class(self, spec, old_spec, force=0):
268         ''' Determine the differences between the current spec and the
269             database version of the spec, and update where necessary.
271             If 'force' is true, update the database anyway.
272         '''
273         new_has = spec.properties.has_key
274         new_spec = spec.schema()
275         new_spec[1].sort()
276         old_spec[1].sort()
277         if not force and new_spec == old_spec:
278             # no changes
279             return 0
281         if __debug__:
282             print >>hyperdb.DEBUG, 'update_class FIRING'
284         # detect key prop change for potential index change
285         keyprop_changes = {}
286         if new_spec[0] != old_spec[0]:
287             keyprop_changes = {'remove': old_spec[0], 'add': new_spec[0]}
289         # detect multilinks that have been removed, and drop their table
290         old_has = {}
291         for name, prop in old_spec[1]:
292             old_has[name] = 1
293             if new_has(name):
294                 continue
296             if prop.find('Multilink to') != -1:
297                 # first drop indexes.
298                 self.drop_multilink_table_indexes(spec.classname, name)
300                 # now the multilink table itself
301                 sql = 'drop table %s_%s'%(spec.classname, name)
302             else:
303                 # if this is the key prop, drop the index first
304                 if old_spec[0] == prop:
305                     self.drop_class_table_key_index(spec.classname, name)
306                     del keyprop_changes['remove']
308                 # drop the column
309                 sql = 'alter table _%s drop column _%s'%(spec.classname, name)
311             if __debug__:
312                 print >>hyperdb.DEBUG, 'update_class', (self, sql)
313             self.cursor.execute(sql)
314         old_has = old_has.has_key
316         # if we didn't remove the key prop just then, but the key prop has
317         # changed, we still need to remove the old index
318         if keyprop_changes.has_key('remove'):
319             self.drop_class_table_key_index(spec.classname,
320                 keyprop_changes['remove'])
322         # add new columns
323         for propname, x in new_spec[1]:
324             if old_has(propname):
325                 continue
326             sql = 'alter table _%s add column _%s varchar(255)'%(
327                 spec.classname, propname)
328             if __debug__:
329                 print >>hyperdb.DEBUG, 'update_class', (self, sql)
330             self.cursor.execute(sql)
332             # if the new column is a key prop, we need an index!
333             if new_spec[0] == propname:
334                 self.create_class_table_key_index(spec.classname, propname)
335                 del keyprop_changes['add']
337         # if we didn't add the key prop just then, but the key prop has
338         # changed, we still need to add the new index
339         if keyprop_changes.has_key('add'):
340             self.create_class_table_key_index(spec.classname,
341                 keyprop_changes['add'])
343         return 1
345     def create_class_table(self, spec):
346         '''Create the class table for the given Class "spec". Creates the
347         indexes too.'''
348         cols, mls = self.determine_columns(spec.properties.items())
350         # add on our special columns
351         cols.append(('id', 'INTEGER PRIMARY KEY'))
352         cols.append(('__retired__', 'INTEGER DEFAULT 0'))
354         # create the base table
355         scols = ','.join(['%s %s'%x for x in cols])
356         sql = 'create table _%s (%s)'%(spec.classname, scols)
357         if __debug__:
358             print >>hyperdb.DEBUG, 'create_class', (self, sql)
359         self.cursor.execute(sql)
361         self.create_class_table_indexes(spec)
363         return cols, mls
365     def create_class_table_indexes(self, spec):
366         ''' create the class table for the given spec
367         '''
368         # create __retired__ index
369         index_sql2 = 'create index _%s_retired_idx on _%s(__retired__)'%(
370                         spec.classname, spec.classname)
371         if __debug__:
372             print >>hyperdb.DEBUG, 'create_index', (self, index_sql2)
373         self.cursor.execute(index_sql2)
375         # create index for key property
376         if spec.key:
377             if __debug__:
378                 print >>hyperdb.DEBUG, 'update_class setting keyprop %r'% \
379                     spec.key
380             index_sql3 = 'create index _%s_%s_idx on _%s(_%s)'%(
381                         spec.classname, spec.key,
382                         spec.classname, spec.key)
383             if __debug__:
384                 print >>hyperdb.DEBUG, 'create_index', (self, index_sql3)
385             self.cursor.execute(index_sql3)
387     def drop_class_table_indexes(self, cn, key):
388         # drop the old table indexes first
389         l = ['_%s_id_idx'%cn, '_%s_retired_idx'%cn]
390         if key:
391             l.append('_%s_%s_idx'%(cn, key))
393         table_name = '_%s'%cn
394         for index_name in l:
395             if not self.sql_index_exists(table_name, index_name):
396                 continue
397             index_sql = 'drop index '+index_name
398             if __debug__:
399                 print >>hyperdb.DEBUG, 'drop_index', (self, index_sql)
400             self.cursor.execute(index_sql)
402     def create_class_table_key_index(self, cn, key):
403         ''' create the class table for the given spec
404         '''
405         sql = 'create index _%s_%s_idx on _%s(_%s)'%(cn, key, cn, key)
406         if __debug__:
407             print >>hyperdb.DEBUG, 'create_class_tab_key_index', (self, sql)
408         self.cursor.execute(sql)
410     def drop_class_table_key_index(self, cn, key):
411         table_name = '_%s'%cn
412         index_name = '_%s_%s_idx'%(cn, key)
413         if not self.sql_index_exists(table_name, index_name):
414             return
415         sql = 'drop index '+index_name
416         if __debug__:
417             print >>hyperdb.DEBUG, 'drop_class_tab_key_index', (self, sql)
418         self.cursor.execute(sql)
420     def create_journal_table(self, spec):
421         ''' create the journal table for a class given the spec and 
422             already-determined cols
423         '''
424         # journal table
425         cols = ','.join(['%s varchar'%x
426             for x in 'nodeid date tag action params'.split()])
427         sql = '''create table %s__journal (
428             nodeid integer, date timestamp, tag varchar(255),
429             action varchar(255), params varchar(25))'''%spec.classname
430         if __debug__:
431             print >>hyperdb.DEBUG, 'create_journal_table', (self, sql)
432         self.cursor.execute(sql)
433         self.create_journal_table_indexes(spec)
435     def create_journal_table_indexes(self, spec):
436         # index on nodeid
437         sql = 'create index %s_journ_idx on %s__journal(nodeid)'%(
438                         spec.classname, spec.classname)
439         if __debug__:
440             print >>hyperdb.DEBUG, 'create_index', (self, sql)
441         self.cursor.execute(sql)
443     def drop_journal_table_indexes(self, classname):
444         index_name = '%s_journ_idx'%classname
445         if not self.sql_index_exists('%s__journal'%classname, index_name):
446             return
447         index_sql = 'drop index '+index_name
448         if __debug__:
449             print >>hyperdb.DEBUG, 'drop_index', (self, index_sql)
450         self.cursor.execute(index_sql)
452     def create_multilink_table(self, spec, ml):
453         ''' Create a multilink table for the "ml" property of the class
454             given by the spec
455         '''
456         # create the table
457         sql = 'create table %s_%s (linkid varchar, nodeid varchar)'%(
458             spec.classname, ml)
459         if __debug__:
460             print >>hyperdb.DEBUG, 'create_class', (self, sql)
461         self.cursor.execute(sql)
462         self.create_multilink_table_indexes(spec, ml)
464     def create_multilink_table_indexes(self, spec, ml):
465         # create index on linkid
466         index_sql = 'create index %s_%s_l_idx on %s_%s(linkid)'%(
467             spec.classname, ml, spec.classname, ml)
468         if __debug__:
469             print >>hyperdb.DEBUG, 'create_index', (self, index_sql)
470         self.cursor.execute(index_sql)
472         # create index on nodeid
473         index_sql = 'create index %s_%s_n_idx on %s_%s(nodeid)'%(
474             spec.classname, ml, spec.classname, ml)
475         if __debug__:
476             print >>hyperdb.DEBUG, 'create_index', (self, index_sql)
477         self.cursor.execute(index_sql)
479     def drop_multilink_table_indexes(self, classname, ml):
480         l = [
481             '%s_%s_l_idx'%(classname, ml),
482             '%s_%s_n_idx'%(classname, ml)
483         ]
484         table_name = '%s_%s'%(classname, ml)
485         for index_name in l:
486             if not self.sql_index_exists(table_name, index_name):
487                 continue
488             index_sql = 'drop index %s'%index_name
489             if __debug__:
490                 print >>hyperdb.DEBUG, 'drop_index', (self, index_sql)
491             self.cursor.execute(index_sql)
493     def create_class(self, spec):
494         ''' Create a database table according to the given spec.
495         '''
496         cols, mls = self.create_class_table(spec)
497         self.create_journal_table(spec)
499         # now create the multilink tables
500         for ml in mls:
501             self.create_multilink_table(spec, ml)
503     def drop_class(self, cn, spec):
504         ''' Drop the given table from the database.
506             Drop the journal and multilink tables too.
507         '''
508         properties = spec[1]
509         # figure the multilinks
510         mls = []
511         for propanme, prop in properties:
512             if isinstance(prop, Multilink):
513                 mls.append(propname)
515         # drop class table and indexes
516         self.drop_class_table_indexes(cn, spec[0])
518         self.drop_class_table(cn)
520         # drop journal table and indexes
521         self.drop_journal_table_indexes(cn)
522         sql = 'drop table %s__journal'%cn
523         if __debug__:
524             print >>hyperdb.DEBUG, 'drop_class', (self, sql)
525         self.cursor.execute(sql)
527         for ml in mls:
528             # drop multilink table and indexes
529             self.drop_multilink_table_indexes(cn, ml)
530             sql = 'drop table %s_%s'%(spec.classname, ml)
531             if __debug__:
532                 print >>hyperdb.DEBUG, 'drop_class', (self, sql)
533             self.cursor.execute(sql)
535     def drop_class_table(self, cn):
536         sql = 'drop table _%s'%cn
537         if __debug__:
538             print >>hyperdb.DEBUG, 'drop_class', (self, sql)
539         self.cursor.execute(sql)
541     #
542     # Classes
543     #
544     def __getattr__(self, classname):
545         ''' A convenient way of calling self.getclass(classname).
546         '''
547         if self.classes.has_key(classname):
548             if __debug__:
549                 print >>hyperdb.DEBUG, '__getattr__', (self, classname)
550             return self.classes[classname]
551         raise AttributeError, classname
553     def addclass(self, cl):
554         ''' Add a Class to the hyperdatabase.
555         '''
556         if __debug__:
557             print >>hyperdb.DEBUG, 'addclass', (self, cl)
558         cn = cl.classname
559         if self.classes.has_key(cn):
560             raise ValueError, cn
561         self.classes[cn] = cl
563         # add default Edit and View permissions
564         self.security.addPermission(name="Edit", klass=cn,
565             description="User is allowed to edit "+cn)
566         self.security.addPermission(name="View", klass=cn,
567             description="User is allowed to access "+cn)
569     def getclasses(self):
570         ''' Return a list of the names of all existing classes.
571         '''
572         if __debug__:
573             print >>hyperdb.DEBUG, 'getclasses', (self,)
574         l = self.classes.keys()
575         l.sort()
576         return l
578     def getclass(self, classname):
579         '''Get the Class object representing a particular class.
581         If 'classname' is not a valid class name, a KeyError is raised.
582         '''
583         if __debug__:
584             print >>hyperdb.DEBUG, 'getclass', (self, classname)
585         try:
586             return self.classes[classname]
587         except KeyError:
588             raise KeyError, 'There is no class called "%s"'%classname
590     def clear(self):
591         '''Delete all database contents.
593         Note: I don't commit here, which is different behaviour to the
594               "nuke from orbit" behaviour in the dbs.
595         '''
596         if __debug__:
597             print >>hyperdb.DEBUG, 'clear', (self,)
598         for cn in self.classes.keys():
599             sql = 'delete from _%s'%cn
600             if __debug__:
601                 print >>hyperdb.DEBUG, 'clear', (self, sql)
602             self.cursor.execute(sql)
604     #
605     # Nodes
606     #
608     hyperdb_to_sql_value = {
609         hyperdb.String : str,
610         hyperdb.Date   : lambda x: x.formal(sep=' ', sec='%.3f'),
611         hyperdb.Link   : int,
612         hyperdb.Interval  : lambda x: x.serialise(),
613         hyperdb.Password  : str,
614         hyperdb.Boolean   : lambda x: x and 'TRUE' or 'FALSE',
615         hyperdb.Number    : lambda x: x,
616     }
617     def addnode(self, classname, nodeid, node):
618         ''' Add the specified node to its class's db.
619         '''
620         if __debug__:
621             print >>hyperdb.DEBUG, 'addnode', (self, classname, nodeid, node)
623         # determine the column definitions and multilink tables
624         cl = self.classes[classname]
625         cols, mls = self.determine_columns(cl.properties.items())
627         # we'll be supplied these props if we're doing an import
628         values = node.copy()
629         if not values.has_key('creator'):
630             # add in the "calculated" properties (dupe so we don't affect
631             # calling code's node assumptions)
632             values['creation'] = values['activity'] = date.Date()
633             values['actor'] = values['creator'] = self.getuid()
635         cl = self.classes[classname]
636         props = cl.getprops(protected=1)
637         del props['id']
639         # default the non-multilink columns
640         for col, prop in props.items():
641             if not values.has_key(col):
642                 if isinstance(prop, Multilink):
643                     values[col] = []
644                 else:
645                     values[col] = None
647         # clear this node out of the cache if it's in there
648         key = (classname, nodeid)
649         if self.cache.has_key(key):
650             del self.cache[key]
651             self.cache_lru.remove(key)
653         # figure the values to insert
654         vals = []
655         for col,dt in cols:
656             prop = props[col[1:]]
657             value = values[col[1:]]
658             if value:
659                 value = self.hyperdb_to_sql_value[prop.__class__](value)
660             vals.append(value)
661         vals.append(nodeid)
662         vals = tuple(vals)
664         # make sure the ordering is correct for column name -> column value
665         s = ','.join([self.arg for x in cols]) + ',%s'%self.arg
666         cols = ','.join([col for col,dt in cols]) + ',id'
668         # perform the inserts
669         sql = 'insert into _%s (%s) values (%s)'%(classname, cols, s)
670         if __debug__:
671             print >>hyperdb.DEBUG, 'addnode', (self, sql, vals)
672         self.cursor.execute(sql, vals)
674         # insert the multilink rows
675         for col in mls:
676             t = '%s_%s'%(classname, col)
677             for entry in node[col]:
678                 sql = 'insert into %s (linkid, nodeid) values (%s,%s)'%(t,
679                     self.arg, self.arg)
680                 self.sql(sql, (entry, nodeid))
682         # make sure we do the commit-time extra stuff for this node
683         self.transactions.append((self.doSaveNode, (classname, nodeid, node)))
685     def setnode(self, classname, nodeid, values, multilink_changes):
686         ''' Change the specified node.
687         '''
688         if __debug__:
689             print >>hyperdb.DEBUG, 'setnode', (self, classname, nodeid, values)
691         # clear this node out of the cache if it's in there
692         key = (classname, nodeid)
693         if self.cache.has_key(key):
694             del self.cache[key]
695             self.cache_lru.remove(key)
697         # add the special props
698         values = values.copy()
699         values['activity'] = date.Date()
700         values['actor'] = self.getuid()
702         cl = self.classes[classname]
703         props = cl.getprops()
705         cols = []
706         mls = []
707         # add the multilinks separately
708         for col in values.keys():
709             prop = props[col]
710             if isinstance(prop, Multilink):
711                 mls.append(col)
712             else:
713                 cols.append(col)
714         cols.sort()
716         # figure the values to insert
717         vals = []
718         for col in cols:
719             prop = props[col]
720             value = values[col]
721             if value is not None:
722                 value = self.hyperdb_to_sql_value[prop.__class__](value)
723             vals.append(value)
724         vals.append(int(nodeid))
725         vals = tuple(vals)
727         # if there's any updates to regular columns, do them
728         if cols:
729             # make sure the ordering is correct for column name -> column value
730             s = ','.join(['_%s=%s'%(x, self.arg) for x in cols])
731             cols = ','.join(cols)
733             # perform the update
734             sql = 'update _%s set %s where id=%s'%(classname, s, self.arg)
735             if __debug__:
736                 print >>hyperdb.DEBUG, 'setnode', (self, sql, vals)
737             self.cursor.execute(sql, vals)
739         # now the fun bit, updating the multilinks ;)
740         for col, (add, remove) in multilink_changes.items():
741             tn = '%s_%s'%(classname, col)
742             if add:
743                 sql = 'insert into %s (nodeid, linkid) values (%s,%s)'%(tn,
744                     self.arg, self.arg)
745                 for addid in add:
746                     # XXX numeric ids
747                     self.sql(sql, (int(nodeid), int(addid)))
748             if remove:
749                 sql = 'delete from %s where nodeid=%s and linkid=%s'%(tn,
750                     self.arg, self.arg)
751                 for removeid in remove:
752                     # XXX numeric ids
753                     self.sql(sql, (int(nodeid), int(removeid)))
755         # make sure we do the commit-time extra stuff for this node
756         self.transactions.append((self.doSaveNode, (classname, nodeid, values)))
758     sql_to_hyperdb_value = {
759         hyperdb.String : str,
760         hyperdb.Date   : lambda x:date.Date(str(x).replace(' ', '.')),
761 #        hyperdb.Link   : int,      # XXX numeric ids
762         hyperdb.Link   : str,
763         hyperdb.Interval  : date.Interval,
764         hyperdb.Password  : lambda x: password.Password(encrypted=x),
765         hyperdb.Boolean   : int,
766         hyperdb.Number    : _num_cvt,
767     }
768     def getnode(self, classname, nodeid):
769         ''' Get a node from the database.
770         '''
771         if __debug__:
772             print >>hyperdb.DEBUG, 'getnode', (self, classname, nodeid)
774         # see if we have this node cached
775         key = (classname, nodeid)
776         if self.cache.has_key(key):
777             # push us back to the top of the LRU
778             self.cache_lru.remove(key)
779             self.cache_lru.insert(0, key)
780             # return the cached information
781             return self.cache[key]
783         # figure the columns we're fetching
784         cl = self.classes[classname]
785         cols, mls = self.determine_columns(cl.properties.items())
786         scols = ','.join([col for col,dt in cols])
788         # perform the basic property fetch
789         sql = 'select %s from _%s where id=%s'%(scols, classname, self.arg)
790         self.sql(sql, (nodeid,))
792         values = self.sql_fetchone()
793         if values is None:
794             raise IndexError, 'no such %s node %s'%(classname, nodeid)
796         # make up the node
797         node = {}
798         props = cl.getprops(protected=1)
799         for col in range(len(cols)):
800             name = cols[col][0][1:]
801             value = values[col]
802             if value is not None:
803                 value = self.sql_to_hyperdb_value[props[name].__class__](value)
804             node[name] = value
807         # now the multilinks
808         for col in mls:
809             # get the link ids
810             sql = 'select linkid from %s_%s where nodeid=%s'%(classname, col,
811                 self.arg)
812             self.cursor.execute(sql, (nodeid,))
813             # extract the first column from the result
814             # XXX numeric ids
815             node[col] = [str(x[0]) for x in self.cursor.fetchall()]
817         # save off in the cache
818         key = (classname, nodeid)
819         self.cache[key] = node
820         # update the LRU
821         self.cache_lru.insert(0, key)
822         if len(self.cache_lru) > ROW_CACHE_SIZE:
823             del self.cache[self.cache_lru.pop()]
825         return node
827     def destroynode(self, classname, nodeid):
828         '''Remove a node from the database. Called exclusively by the
829            destroy() method on Class.
830         '''
831         if __debug__:
832             print >>hyperdb.DEBUG, 'destroynode', (self, classname, nodeid)
834         # make sure the node exists
835         if not self.hasnode(classname, nodeid):
836             raise IndexError, '%s has no node %s'%(classname, nodeid)
838         # see if we have this node cached
839         if self.cache.has_key((classname, nodeid)):
840             del self.cache[(classname, nodeid)]
842         # see if there's any obvious commit actions that we should get rid of
843         for entry in self.transactions[:]:
844             if entry[1][:2] == (classname, nodeid):
845                 self.transactions.remove(entry)
847         # now do the SQL
848         sql = 'delete from _%s where id=%s'%(classname, self.arg)
849         self.sql(sql, (nodeid,))
851         # remove from multilnks
852         cl = self.getclass(classname)
853         x, mls = self.determine_columns(cl.properties.items())
854         for col in mls:
855             # get the link ids
856             sql = 'delete from %s_%s where nodeid=%s'%(classname, col, self.arg)
857             self.sql(sql, (nodeid,))
859         # remove journal entries
860         sql = 'delete from %s__journal where nodeid=%s'%(classname, self.arg)
861         self.sql(sql, (nodeid,))
863     def hasnode(self, classname, nodeid):
864         ''' Determine if the database has a given node.
865         '''
866         sql = 'select count(*) from _%s where id=%s'%(classname, self.arg)
867         if __debug__:
868             print >>hyperdb.DEBUG, 'hasnode', (self, sql, nodeid)
869         self.cursor.execute(sql, (nodeid,))
870         return int(self.cursor.fetchone()[0])
872     def countnodes(self, classname):
873         ''' Count the number of nodes that exist for a particular Class.
874         '''
875         sql = 'select count(*) from _%s'%classname
876         if __debug__:
877             print >>hyperdb.DEBUG, 'countnodes', (self, sql)
878         self.cursor.execute(sql)
879         return self.cursor.fetchone()[0]
881     def addjournal(self, classname, nodeid, action, params, creator=None,
882             creation=None):
883         ''' Journal the Action
884         'action' may be:
886             'create' or 'set' -- 'params' is a dictionary of property values
887             'link' or 'unlink' -- 'params' is (classname, nodeid, propname)
888             'retire' -- 'params' is None
889         '''
890         # serialise the parameters now if necessary
891         if isinstance(params, type({})):
892             if action in ('set', 'create'):
893                 params = self.serialise(classname, params)
895         # handle supply of the special journalling parameters (usually
896         # supplied on importing an existing database)
897         if creator:
898             journaltag = creator
899         else:
900             journaltag = self.getuid()
901         if creation:
902             journaldate = creation
903         else:
904             journaldate = date.Date()
906         # create the journal entry
907         cols = ','.join('nodeid date tag action params'.split())
909         if __debug__:
910             print >>hyperdb.DEBUG, 'addjournal', (nodeid, journaldate,
911                 journaltag, action, params)
913         self.save_journal(classname, cols, nodeid, journaldate,
914             journaltag, action, params)
916     def getjournal(self, classname, nodeid):
917         ''' get the journal for id
918         '''
919         # make sure the node exists
920         if not self.hasnode(classname, nodeid):
921             raise IndexError, '%s has no node %s'%(classname, nodeid)
923         cols = ','.join('nodeid date tag action params'.split())
924         return self.load_journal(classname, cols, nodeid)
926     def save_journal(self, classname, cols, nodeid, journaldate,
927             journaltag, action, params):
928         ''' Save the journal entry to the database
929         '''
930         # make the params db-friendly
931         params = repr(params)
932         dc = self.hyperdb_to_sql_value[hyperdb.Date]
933         entry = (nodeid, dc(journaldate), journaltag, action, params)
935         # do the insert
936         a = self.arg
937         sql = 'insert into %s__journal (%s) values (%s,%s,%s,%s,%s)'%(
938             classname, cols, a, a, a, a, a)
939         if __debug__:
940             print >>hyperdb.DEBUG, 'save_journal', (self, sql, entry)
941         self.cursor.execute(sql, entry)
943     def load_journal(self, classname, cols, nodeid):
944         ''' Load the journal from the database
945         '''
946         # now get the journal entries
947         sql = 'select %s from %s__journal where nodeid=%s order by date'%(
948             cols, classname, self.arg)
949         if __debug__:
950             print >>hyperdb.DEBUG, 'load_journal', (self, sql, nodeid)
951         self.cursor.execute(sql, (nodeid,))
952         res = []
953         dc = self.sql_to_hyperdb_value[hyperdb.Date]
954         for nodeid, date_stamp, user, action, params in self.cursor.fetchall():
955             params = eval(params)
956             # XXX numeric ids
957             res.append((str(nodeid), dc(date_stamp), user, action, params))
958         return res
960     def pack(self, pack_before):
961         ''' Delete all journal entries except "create" before 'pack_before'.
962         '''
963         # get a 'yyyymmddhhmmss' version of the date
964         date_stamp = pack_before.serialise()
966         # do the delete
967         for classname in self.classes.keys():
968             sql = "delete from %s__journal where date<%s and "\
969                 "action<>'create'"%(classname, self.arg)
970             if __debug__:
971                 print >>hyperdb.DEBUG, 'pack', (self, sql, date_stamp)
972             self.cursor.execute(sql, (date_stamp,))
974     def sql_commit(self):
975         ''' Actually commit to the database.
976         '''
977         if __debug__:
978             print >>hyperdb.DEBUG, '+++ commit database connection +++'
979         self.conn.commit()
981     def commit(self):
982         ''' Commit the current transactions.
984         Save all data changed since the database was opened or since the
985         last commit() or rollback().
986         '''
987         if __debug__:
988             print >>hyperdb.DEBUG, 'commit', (self,)
990         # commit the database
991         self.sql_commit()
993         # now, do all the other transaction stuff
994         for method, args in self.transactions:
995             method(*args)
997         # save the indexer state
998         self.indexer.save_index()
1000         # clear out the transactions
1001         self.transactions = []
1003     def sql_rollback(self):
1004         self.conn.rollback()
1006     def rollback(self):
1007         ''' Reverse all actions from the current transaction.
1009         Undo all the changes made since the database was opened or the last
1010         commit() or rollback() was performed.
1011         '''
1012         if __debug__:
1013             print >>hyperdb.DEBUG, 'rollback', (self,)
1015         self.sql_rollback()
1017         # roll back "other" transaction stuff
1018         for method, args in self.transactions:
1019             # delete temporary files
1020             if method == self.doStoreFile:
1021                 self.rollbackStoreFile(*args)
1022         self.transactions = []
1024         # clear the cache
1025         self.clearCache()
1027     def doSaveNode(self, classname, nodeid, node):
1028         ''' dummy that just generates a reindex event
1029         '''
1030         # return the classname, nodeid so we reindex this content
1031         return (classname, nodeid)
1033     def sql_close(self):
1034         if __debug__:
1035             print >>hyperdb.DEBUG, '+++ close database connection +++'
1036         self.conn.close()
1038     def close(self):
1039         ''' Close off the connection.
1040         '''
1041         self.indexer.close()
1042         self.sql_close()
1045 # The base Class class
1047 class Class(hyperdb.Class):
1048     ''' The handle to a particular class of nodes in a hyperdatabase.
1049         
1050         All methods except __repr__ and getnode must be implemented by a
1051         concrete backend Class.
1052     '''
1054     def __init__(self, db, classname, **properties):
1055         '''Create a new class with a given name and property specification.
1057         'classname' must not collide with the name of an existing class,
1058         or a ValueError is raised.  The keyword arguments in 'properties'
1059         must map names to property objects, or a TypeError is raised.
1060         '''
1061         for name in 'creation activity creator actor'.split():
1062             if properties.has_key(name):
1063                 raise ValueError, '"creation", "activity", "creator" and '\
1064                     '"actor" are reserved'
1066         self.classname = classname
1067         self.properties = properties
1068         self.db = weakref.proxy(db)       # use a weak ref to avoid circularity
1069         self.key = ''
1071         # should we journal changes (default yes)
1072         self.do_journal = 1
1074         # do the db-related init stuff
1075         db.addclass(self)
1077         self.auditors = {'create': [], 'set': [], 'retire': [], 'restore': []}
1078         self.reactors = {'create': [], 'set': [], 'retire': [], 'restore': []}
1080     def schema(self):
1081         ''' A dumpable version of the schema that we can store in the
1082             database
1083         '''
1084         return (self.key, [(x, repr(y)) for x,y in self.properties.items()])
1086     def enableJournalling(self):
1087         '''Turn journalling on for this class
1088         '''
1089         self.do_journal = 1
1091     def disableJournalling(self):
1092         '''Turn journalling off for this class
1093         '''
1094         self.do_journal = 0
1096     # Editing nodes:
1097     def create(self, **propvalues):
1098         ''' Create a new node of this class and return its id.
1100         The keyword arguments in 'propvalues' map property names to values.
1102         The values of arguments must be acceptable for the types of their
1103         corresponding properties or a TypeError is raised.
1104         
1105         If this class has a key property, it must be present and its value
1106         must not collide with other key strings or a ValueError is raised.
1107         
1108         Any other properties on this class that are missing from the
1109         'propvalues' dictionary are set to None.
1110         
1111         If an id in a link or multilink property does not refer to a valid
1112         node, an IndexError is raised.
1113         '''
1114         self.fireAuditors('create', None, propvalues)
1115         newid = self.create_inner(**propvalues)
1116         self.fireReactors('create', newid, None)
1117         return newid
1118     
1119     def create_inner(self, **propvalues):
1120         ''' Called by create, in-between the audit and react calls.
1121         '''
1122         if propvalues.has_key('id'):
1123             raise KeyError, '"id" is reserved'
1125         if self.db.journaltag is None:
1126             raise DatabaseError, 'Database open read-only'
1128         if propvalues.has_key('creator') or propvalues.has_key('actor') or \
1129              propvalues.has_key('creation') or propvalues.has_key('activity'):
1130             raise KeyError, '"creator", "actor", "creation" and '\
1131                 '"activity" are reserved'
1133         # new node's id
1134         newid = self.db.newid(self.classname)
1136         # validate propvalues
1137         num_re = re.compile('^\d+$')
1138         for key, value in propvalues.items():
1139             if key == self.key:
1140                 try:
1141                     self.lookup(value)
1142                 except KeyError:
1143                     pass
1144                 else:
1145                     raise ValueError, 'node with key "%s" exists'%value
1147             # try to handle this property
1148             try:
1149                 prop = self.properties[key]
1150             except KeyError:
1151                 raise KeyError, '"%s" has no property "%s"'%(self.classname,
1152                     key)
1154             if value is not None and isinstance(prop, Link):
1155                 if type(value) != type(''):
1156                     raise ValueError, 'link value must be String'
1157                 link_class = self.properties[key].classname
1158                 # if it isn't a number, it's a key
1159                 if not num_re.match(value):
1160                     try:
1161                         value = self.db.classes[link_class].lookup(value)
1162                     except (TypeError, KeyError):
1163                         raise IndexError, 'new property "%s": %s not a %s'%(
1164                             key, value, link_class)
1165                 elif not self.db.getclass(link_class).hasnode(value):
1166                     raise IndexError, '%s has no node %s'%(link_class, value)
1168                 # save off the value
1169                 propvalues[key] = value
1171                 # register the link with the newly linked node
1172                 if self.do_journal and self.properties[key].do_journal:
1173                     self.db.addjournal(link_class, value, 'link',
1174                         (self.classname, newid, key))
1176             elif isinstance(prop, Multilink):
1177                 if type(value) != type([]):
1178                     raise TypeError, 'new property "%s" not a list of ids'%key
1180                 # clean up and validate the list of links
1181                 link_class = self.properties[key].classname
1182                 l = []
1183                 for entry in value:
1184                     if type(entry) != type(''):
1185                         raise ValueError, '"%s" multilink value (%r) '\
1186                             'must contain Strings'%(key, value)
1187                     # if it isn't a number, it's a key
1188                     if not num_re.match(entry):
1189                         try:
1190                             entry = self.db.classes[link_class].lookup(entry)
1191                         except (TypeError, KeyError):
1192                             raise IndexError, 'new property "%s": %s not a %s'%(
1193                                 key, entry, self.properties[key].classname)
1194                     l.append(entry)
1195                 value = l
1196                 propvalues[key] = value
1198                 # handle additions
1199                 for nodeid in value:
1200                     if not self.db.getclass(link_class).hasnode(nodeid):
1201                         raise IndexError, '%s has no node %s'%(link_class,
1202                             nodeid)
1203                     # register the link with the newly linked node
1204                     if self.do_journal and self.properties[key].do_journal:
1205                         self.db.addjournal(link_class, nodeid, 'link',
1206                             (self.classname, newid, key))
1208             elif isinstance(prop, String):
1209                 if type(value) != type('') and type(value) != type(u''):
1210                     raise TypeError, 'new property "%s" not a string'%key
1211                 self.db.indexer.add_text((self.classname, newid, key), value)
1213             elif isinstance(prop, Password):
1214                 if not isinstance(value, password.Password):
1215                     raise TypeError, 'new property "%s" not a Password'%key
1217             elif isinstance(prop, Date):
1218                 if value is not None and not isinstance(value, date.Date):
1219                     raise TypeError, 'new property "%s" not a Date'%key
1221             elif isinstance(prop, Interval):
1222                 if value is not None and not isinstance(value, date.Interval):
1223                     raise TypeError, 'new property "%s" not an Interval'%key
1225             elif value is not None and isinstance(prop, Number):
1226                 try:
1227                     float(value)
1228                 except ValueError:
1229                     raise TypeError, 'new property "%s" not numeric'%key
1231             elif value is not None and isinstance(prop, Boolean):
1232                 try:
1233                     int(value)
1234                 except ValueError:
1235                     raise TypeError, 'new property "%s" not boolean'%key
1237         # make sure there's data where there needs to be
1238         for key, prop in self.properties.items():
1239             if propvalues.has_key(key):
1240                 continue
1241             if key == self.key:
1242                 raise ValueError, 'key property "%s" is required'%key
1243             if isinstance(prop, Multilink):
1244                 propvalues[key] = []
1245             else:
1246                 propvalues[key] = None
1248         # done
1249         self.db.addnode(self.classname, newid, propvalues)
1250         if self.do_journal:
1251             self.db.addjournal(self.classname, newid, 'create', {})
1253         # XXX numeric ids
1254         return str(newid)
1256     def export_list(self, propnames, nodeid):
1257         ''' Export a node - generate a list of CSV-able data in the order
1258             specified by propnames for the given node.
1259         '''
1260         properties = self.getprops()
1261         l = []
1262         for prop in propnames:
1263             proptype = properties[prop]
1264             value = self.get(nodeid, prop)
1265             # "marshal" data where needed
1266             if value is None:
1267                 pass
1268             elif isinstance(proptype, hyperdb.Date):
1269                 value = value.get_tuple()
1270             elif isinstance(proptype, hyperdb.Interval):
1271                 value = value.get_tuple()
1272             elif isinstance(proptype, hyperdb.Password):
1273                 value = str(value)
1274             l.append(repr(value))
1275         l.append(repr(self.is_retired(nodeid)))
1276         return l
1278     def import_list(self, propnames, proplist):
1279         ''' Import a node - all information including "id" is present and
1280             should not be sanity checked. Triggers are not triggered. The
1281             journal should be initialised using the "creator" and "created"
1282             information.
1284             Return the nodeid of the node imported.
1285         '''
1286         if self.db.journaltag is None:
1287             raise DatabaseError, 'Database open read-only'
1288         properties = self.getprops()
1290         # make the new node's property map
1291         d = {}
1292         retire = 0
1293         newid = None
1294         for i in range(len(propnames)):
1295             # Use eval to reverse the repr() used to output the CSV
1296             value = eval(proplist[i])
1298             # Figure the property for this column
1299             propname = propnames[i]
1301             # "unmarshal" where necessary
1302             if propname == 'id':
1303                 newid = value
1304                 continue
1305             elif propname == 'is retired':
1306                 # is the item retired?
1307                 if int(value):
1308                     retire = 1
1309                 continue
1310             elif value is None:
1311                 d[propname] = None
1312                 continue
1314             prop = properties[propname]
1315             if value is None:
1316                 # don't set Nones
1317                 continue
1318             elif isinstance(prop, hyperdb.Date):
1319                 value = date.Date(value)
1320             elif isinstance(prop, hyperdb.Interval):
1321                 value = date.Interval(value)
1322             elif isinstance(prop, hyperdb.Password):
1323                 pwd = password.Password()
1324                 pwd.unpack(value)
1325                 value = pwd
1326             d[propname] = value
1328         # get a new id if necessary
1329         if newid is None:
1330             newid = self.db.newid(self.classname)
1332         # add the node and journal
1333         self.db.addnode(self.classname, newid, d)
1335         # retire?
1336         if retire:
1337             # use the arg for __retired__ to cope with any odd database type
1338             # conversion (hello, sqlite)
1339             sql = 'update _%s set __retired__=%s where id=%s'%(self.classname,
1340                 self.db.arg, self.db.arg)
1341             if __debug__:
1342                 print >>hyperdb.DEBUG, 'retire', (self, sql, newid)
1343             self.db.cursor.execute(sql, (1, newid))
1345         # extract the extraneous journalling gumpf and nuke it
1346         if d.has_key('creator'):
1347             creator = d['creator']
1348             del d['creator']
1349         else:
1350             creator = None
1351         if d.has_key('creation'):
1352             creation = d['creation']
1353             del d['creation']
1354         else:
1355             creation = None
1356         if d.has_key('activity'):
1357             del d['activity']
1358         if d.has_key('actor'):
1359             del d['actor']
1360         self.db.addjournal(self.classname, newid, 'create', {}, creator,
1361             creation)
1362         return newid
1364     _marker = []
1365     def get(self, nodeid, propname, default=_marker, cache=1):
1366         '''Get the value of a property on an existing node of this class.
1368         'nodeid' must be the id of an existing node of this class or an
1369         IndexError is raised.  'propname' must be the name of a property
1370         of this class or a KeyError is raised.
1372         'cache' exists for backwards compatibility, and is not used.
1373         '''
1374         if propname == 'id':
1375             return nodeid
1377         # get the node's dict
1378         d = self.db.getnode(self.classname, nodeid)
1380         if propname == 'creation':
1381             if d.has_key('creation'):
1382                 return d['creation']
1383             else:
1384                 return date.Date()
1385         if propname == 'activity':
1386             if d.has_key('activity'):
1387                 return d['activity']
1388             else:
1389                 return date.Date()
1390         if propname == 'creator':
1391             if d.has_key('creator'):
1392                 return d['creator']
1393             else:
1394                 return self.db.getuid()
1395         if propname == 'actor':
1396             if d.has_key('actor'):
1397                 return d['actor']
1398             else:
1399                 return self.db.getuid()
1401         # get the property (raises KeyErorr if invalid)
1402         prop = self.properties[propname]
1404         if not d.has_key(propname):
1405             if default is self._marker:
1406                 if isinstance(prop, Multilink):
1407                     return []
1408                 else:
1409                     return None
1410             else:
1411                 return default
1413         # don't pass our list to other code
1414         if isinstance(prop, Multilink):
1415             return d[propname][:]
1417         return d[propname]
1419     def set(self, nodeid, **propvalues):
1420         '''Modify a property on an existing node of this class.
1421         
1422         'nodeid' must be the id of an existing node of this class or an
1423         IndexError is raised.
1425         Each key in 'propvalues' must be the name of a property of this
1426         class or a KeyError is raised.
1428         All values in 'propvalues' must be acceptable types for their
1429         corresponding properties or a TypeError is raised.
1431         If the value of the key property is set, it must not collide with
1432         other key strings or a ValueError is raised.
1434         If the value of a Link or Multilink property contains an invalid
1435         node id, a ValueError is raised.
1436         '''
1437         self.fireAuditors('set', nodeid, propvalues)
1438         oldvalues = copy.deepcopy(self.db.getnode(self.classname, nodeid))
1439         propvalues = self.set_inner(nodeid, **propvalues)
1440         self.fireReactors('set', nodeid, oldvalues)
1441         return propvalues        
1443     def set_inner(self, nodeid, **propvalues):
1444         ''' Called by set, in-between the audit and react calls.
1445         ''' 
1446         if not propvalues:
1447             return propvalues
1449         if propvalues.has_key('creation') or propvalues.has_key('creator') or \
1450                 propvalues.has_key('actor') or propvalues.has_key('activity'):
1451             raise KeyError, '"creation", "creator", "actor" and '\
1452                 '"activity" are reserved'
1454         if propvalues.has_key('id'):
1455             raise KeyError, '"id" is reserved'
1457         if self.db.journaltag is None:
1458             raise DatabaseError, 'Database open read-only'
1460         node = self.db.getnode(self.classname, nodeid)
1461         if self.is_retired(nodeid):
1462             raise IndexError, 'Requested item is retired'
1463         num_re = re.compile('^\d+$')
1465         # if the journal value is to be different, store it in here
1466         journalvalues = {}
1468         # remember the add/remove stuff for multilinks, making it easier
1469         # for the Database layer to do its stuff
1470         multilink_changes = {}
1472         for propname, value in propvalues.items():
1473             # check to make sure we're not duplicating an existing key
1474             if propname == self.key and node[propname] != value:
1475                 try:
1476                     self.lookup(value)
1477                 except KeyError:
1478                     pass
1479                 else:
1480                     raise ValueError, 'node with key "%s" exists'%value
1482             # this will raise the KeyError if the property isn't valid
1483             # ... we don't use getprops() here because we only care about
1484             # the writeable properties.
1485             try:
1486                 prop = self.properties[propname]
1487             except KeyError:
1488                 raise KeyError, '"%s" has no property named "%s"'%(
1489                     self.classname, propname)
1491             # if the value's the same as the existing value, no sense in
1492             # doing anything
1493             current = node.get(propname, None)
1494             if value == current:
1495                 del propvalues[propname]
1496                 continue
1497             journalvalues[propname] = current
1499             # do stuff based on the prop type
1500             if isinstance(prop, Link):
1501                 link_class = prop.classname
1502                 # if it isn't a number, it's a key
1503                 if value is not None and not isinstance(value, type('')):
1504                     raise ValueError, 'property "%s" link value be a string'%(
1505                         propname)
1506                 if isinstance(value, type('')) and not num_re.match(value):
1507                     try:
1508                         value = self.db.classes[link_class].lookup(value)
1509                     except (TypeError, KeyError):
1510                         raise IndexError, 'new property "%s": %s not a %s'%(
1511                             propname, value, prop.classname)
1513                 if (value is not None and
1514                         not self.db.getclass(link_class).hasnode(value)):
1515                     raise IndexError, '%s has no node %s'%(link_class, value)
1517                 if self.do_journal and prop.do_journal:
1518                     # register the unlink with the old linked node
1519                     if node[propname] is not None:
1520                         self.db.addjournal(link_class, node[propname], 'unlink',
1521                             (self.classname, nodeid, propname))
1523                     # register the link with the newly linked node
1524                     if value is not None:
1525                         self.db.addjournal(link_class, value, 'link',
1526                             (self.classname, nodeid, propname))
1528             elif isinstance(prop, Multilink):
1529                 if type(value) != type([]):
1530                     raise TypeError, 'new property "%s" not a list of'\
1531                         ' ids'%propname
1532                 link_class = self.properties[propname].classname
1533                 l = []
1534                 for entry in value:
1535                     # if it isn't a number, it's a key
1536                     if type(entry) != type(''):
1537                         raise ValueError, 'new property "%s" link value ' \
1538                             'must be a string'%propname
1539                     if not num_re.match(entry):
1540                         try:
1541                             entry = self.db.classes[link_class].lookup(entry)
1542                         except (TypeError, KeyError):
1543                             raise IndexError, 'new property "%s": %s not a %s'%(
1544                                 propname, entry,
1545                                 self.properties[propname].classname)
1546                     l.append(entry)
1547                 value = l
1548                 propvalues[propname] = value
1550                 # figure the journal entry for this property
1551                 add = []
1552                 remove = []
1554                 # handle removals
1555                 if node.has_key(propname):
1556                     l = node[propname]
1557                 else:
1558                     l = []
1559                 for id in l[:]:
1560                     if id in value:
1561                         continue
1562                     # register the unlink with the old linked node
1563                     if self.do_journal and self.properties[propname].do_journal:
1564                         self.db.addjournal(link_class, id, 'unlink',
1565                             (self.classname, nodeid, propname))
1566                     l.remove(id)
1567                     remove.append(id)
1569                 # handle additions
1570                 for id in value:
1571                     if not self.db.getclass(link_class).hasnode(id):
1572                         raise IndexError, '%s has no node %s'%(link_class, id)
1573                     if id in l:
1574                         continue
1575                     # register the link with the newly linked node
1576                     if self.do_journal and self.properties[propname].do_journal:
1577                         self.db.addjournal(link_class, id, 'link',
1578                             (self.classname, nodeid, propname))
1579                     l.append(id)
1580                     add.append(id)
1582                 # figure the journal entry
1583                 l = []
1584                 if add:
1585                     l.append(('+', add))
1586                 if remove:
1587                     l.append(('-', remove))
1588                 multilink_changes[propname] = (add, remove)
1589                 if l:
1590                     journalvalues[propname] = tuple(l)
1592             elif isinstance(prop, String):
1593                 if value is not None and type(value) != type('') and type(value) != type(u''):
1594                     raise TypeError, 'new property "%s" not a string'%propname
1595                 self.db.indexer.add_text((self.classname, nodeid, propname),
1596                     value)
1598             elif isinstance(prop, Password):
1599                 if not isinstance(value, password.Password):
1600                     raise TypeError, 'new property "%s" not a Password'%propname
1601                 propvalues[propname] = value
1603             elif value is not None and isinstance(prop, Date):
1604                 if not isinstance(value, date.Date):
1605                     raise TypeError, 'new property "%s" not a Date'% propname
1606                 propvalues[propname] = value
1608             elif value is not None and isinstance(prop, Interval):
1609                 if not isinstance(value, date.Interval):
1610                     raise TypeError, 'new property "%s" not an '\
1611                         'Interval'%propname
1612                 propvalues[propname] = value
1614             elif value is not None and isinstance(prop, Number):
1615                 try:
1616                     float(value)
1617                 except ValueError:
1618                     raise TypeError, 'new property "%s" not numeric'%propname
1620             elif value is not None and isinstance(prop, Boolean):
1621                 try:
1622                     int(value)
1623                 except ValueError:
1624                     raise TypeError, 'new property "%s" not boolean'%propname
1626         # nothing to do?
1627         if not propvalues:
1628             return propvalues
1630         # do the set, and journal it
1631         self.db.setnode(self.classname, nodeid, propvalues, multilink_changes)
1633         if self.do_journal:
1634             self.db.addjournal(self.classname, nodeid, 'set', journalvalues)
1636         return propvalues        
1638     def retire(self, nodeid):
1639         '''Retire a node.
1640         
1641         The properties on the node remain available from the get() method,
1642         and the node's id is never reused.
1643         
1644         Retired nodes are not returned by the find(), list(), or lookup()
1645         methods, and other nodes may reuse the values of their key properties.
1646         '''
1647         if self.db.journaltag is None:
1648             raise DatabaseError, 'Database open read-only'
1650         self.fireAuditors('retire', nodeid, None)
1652         # use the arg for __retired__ to cope with any odd database type
1653         # conversion (hello, sqlite)
1654         sql = 'update _%s set __retired__=%s where id=%s'%(self.classname,
1655             self.db.arg, self.db.arg)
1656         if __debug__:
1657             print >>hyperdb.DEBUG, 'retire', (self, sql, nodeid)
1658         self.db.cursor.execute(sql, (1, nodeid))
1659         if self.do_journal:
1660             self.db.addjournal(self.classname, nodeid, 'retired', None)
1662         self.fireReactors('retire', nodeid, None)
1664     def restore(self, nodeid):
1665         '''Restore a retired node.
1667         Make node available for all operations like it was before retirement.
1668         '''
1669         if self.db.journaltag is None:
1670             raise DatabaseError, 'Database open read-only'
1672         node = self.db.getnode(self.classname, nodeid)
1673         # check if key property was overrided
1674         key = self.getkey()
1675         try:
1676             id = self.lookup(node[key])
1677         except KeyError:
1678             pass
1679         else:
1680             raise KeyError, "Key property (%s) of retired node clashes with \
1681                 existing one (%s)" % (key, node[key])
1683         self.fireAuditors('restore', nodeid, None)
1684         # use the arg for __retired__ to cope with any odd database type
1685         # conversion (hello, sqlite)
1686         sql = 'update _%s set __retired__=%s where id=%s'%(self.classname,
1687             self.db.arg, self.db.arg)
1688         if __debug__:
1689             print >>hyperdb.DEBUG, 'restore', (self, sql, nodeid)
1690         self.db.cursor.execute(sql, (0, nodeid))
1691         if self.do_journal:
1692             self.db.addjournal(self.classname, nodeid, 'restored', None)
1694         self.fireReactors('restore', nodeid, None)
1695         
1696     def is_retired(self, nodeid):
1697         '''Return true if the node is rerired
1698         '''
1699         sql = 'select __retired__ from _%s where id=%s'%(self.classname,
1700             self.db.arg)
1701         if __debug__:
1702             print >>hyperdb.DEBUG, 'is_retired', (self, sql, nodeid)
1703         self.db.cursor.execute(sql, (nodeid,))
1704         return int(self.db.sql_fetchone()[0])
1706     def destroy(self, nodeid):
1707         '''Destroy a node.
1708         
1709         WARNING: this method should never be used except in extremely rare
1710                  situations where there could never be links to the node being
1711                  deleted
1713         WARNING: use retire() instead
1715         WARNING: the properties of this node will not be available ever again
1717         WARNING: really, use retire() instead
1719         Well, I think that's enough warnings. This method exists mostly to
1720         support the session storage of the cgi interface.
1722         The node is completely removed from the hyperdb, including all journal
1723         entries. It will no longer be available, and will generally break code
1724         if there are any references to the node.
1725         '''
1726         if self.db.journaltag is None:
1727             raise DatabaseError, 'Database open read-only'
1728         self.db.destroynode(self.classname, nodeid)
1730     def history(self, nodeid):
1731         '''Retrieve the journal of edits on a particular node.
1733         'nodeid' must be the id of an existing node of this class or an
1734         IndexError is raised.
1736         The returned list contains tuples of the form
1738             (nodeid, date, tag, action, params)
1740         'date' is a Timestamp object specifying the time of the change and
1741         'tag' is the journaltag specified when the database was opened.
1742         '''
1743         if not self.do_journal:
1744             raise ValueError, 'Journalling is disabled for this class'
1745         return self.db.getjournal(self.classname, nodeid)
1747     # Locating nodes:
1748     def hasnode(self, nodeid):
1749         '''Determine if the given nodeid actually exists
1750         '''
1751         return self.db.hasnode(self.classname, nodeid)
1753     def setkey(self, propname):
1754         '''Select a String property of this class to be the key property.
1756         'propname' must be the name of a String property of this class or
1757         None, or a TypeError is raised.  The values of the key property on
1758         all existing nodes must be unique or a ValueError is raised.
1759         '''
1760         # XXX create an index on the key prop column. We should also 
1761         # record that we've created this index in the schema somewhere.
1762         prop = self.getprops()[propname]
1763         if not isinstance(prop, String):
1764             raise TypeError, 'key properties must be String'
1765         self.key = propname
1767     def getkey(self):
1768         '''Return the name of the key property for this class or None.'''
1769         return self.key
1771     def labelprop(self, default_to_id=0):
1772         '''Return the property name for a label for the given node.
1774         This method attempts to generate a consistent label for the node.
1775         It tries the following in order:
1777         1. key property
1778         2. "name" property
1779         3. "title" property
1780         4. first property from the sorted property name list
1781         '''
1782         k = self.getkey()
1783         if  k:
1784             return k
1785         props = self.getprops()
1786         if props.has_key('name'):
1787             return 'name'
1788         elif props.has_key('title'):
1789             return 'title'
1790         if default_to_id:
1791             return 'id'
1792         props = props.keys()
1793         props.sort()
1794         return props[0]
1796     def lookup(self, keyvalue):
1797         '''Locate a particular node by its key property and return its id.
1799         If this class has no key property, a TypeError is raised.  If the
1800         'keyvalue' matches one of the values for the key property among
1801         the nodes in this class, the matching node's id is returned;
1802         otherwise a KeyError is raised.
1803         '''
1804         if not self.key:
1805             raise TypeError, 'No key property set for class %s'%self.classname
1807         # use the arg to handle any odd database type conversion (hello,
1808         # sqlite)
1809         sql = "select id from _%s where _%s=%s and __retired__ <> %s"%(
1810             self.classname, self.key, self.db.arg, self.db.arg)
1811         self.db.sql(sql, (keyvalue, 1))
1813         # see if there was a result that's not retired
1814         row = self.db.sql_fetchone()
1815         if not row:
1816             raise KeyError, 'No key (%s) value "%s" for "%s"'%(self.key,
1817                 keyvalue, self.classname)
1819         # return the id
1820         # XXX numeric ids
1821         return str(row[0])
1823     def find(self, **propspec):
1824         '''Get the ids of nodes in this class which link to the given nodes.
1826         'propspec' consists of keyword args propname=nodeid or
1827                    propname={nodeid:1, }
1828         'propname' must be the name of a property in this class, or a
1829                    KeyError is raised.  That property must be a Link or
1830                    Multilink property, or a TypeError is raised.
1832         Any node in this class whose 'propname' property links to any of the
1833         nodeids will be returned. Used by the full text indexing, which knows
1834         that "foo" occurs in msg1, msg3 and file7, so we have hits on these
1835         issues:
1837             db.issue.find(messages={'1':1,'3':1}, files={'7':1})
1838         '''
1839         if __debug__:
1840             print >>hyperdb.DEBUG, 'find', (self, propspec)
1842         # shortcut
1843         if not propspec:
1844             return []
1846         # validate the args
1847         props = self.getprops()
1848         propspec = propspec.items()
1849         for propname, nodeids in propspec:
1850             # check the prop is OK
1851             prop = props[propname]
1852             if not isinstance(prop, Link) and not isinstance(prop, Multilink):
1853                 raise TypeError, "'%s' not a Link/Multilink property"%propname
1855         # first, links
1856         a = self.db.arg
1857         allvalues = (1,)
1858         o = []
1859         where = []
1860         for prop, values in propspec:
1861             if not isinstance(props[prop], hyperdb.Link):
1862                 continue
1863             if type(values) is type({}) and len(values) == 1:
1864                 values = values.keys()[0]
1865             if type(values) is type(''):
1866                 allvalues += (values,)
1867                 where.append('_%s = %s'%(prop, a))
1868             elif values is None:
1869                 where.append('_%s is NULL'%prop)
1870             else:
1871                 allvalues += tuple(values.keys())
1872                 where.append('_%s in (%s)'%(prop, ','.join([a]*len(values))))
1873         tables = ['_%s'%self.classname]
1874         if where:
1875             o.append('(' + ' and '.join(where) + ')')
1877         # now multilinks
1878         for prop, values in propspec:
1879             if not isinstance(props[prop], hyperdb.Multilink):
1880                 continue
1881             if not values:
1882                 continue
1883             if type(values) is type(''):
1884                 allvalues += (values,)
1885                 s = a
1886             else:
1887                 allvalues += tuple(values.keys())
1888                 s = ','.join([a]*len(values))
1889             tn = '%s_%s'%(self.classname, prop)
1890             tables.append(tn)
1891             o.append('(id=%s.nodeid and %s.linkid in (%s))'%(tn, tn, s))
1893         if not o:
1894             return []
1895         elif len(o) > 1:
1896             o = '(' + ' or '.join(['(%s)'%i for i in o]) + ')'
1897         else:
1898             o = o[0]
1899         t = ', '.join(tables)
1900         sql = 'select distinct(id) from %s where __retired__ <> %s and %s'%(
1901             t, a, o)
1902         self.db.sql(sql, allvalues)
1903         # XXX numeric ids
1904         l = [str(x[0]) for x in self.db.sql_fetchall()]
1905         if __debug__:
1906             print >>hyperdb.DEBUG, 'find ... ', l
1907         return l
1909     def stringFind(self, **requirements):
1910         '''Locate a particular node by matching a set of its String
1911         properties in a caseless search.
1913         If the property is not a String property, a TypeError is raised.
1914         
1915         The return is a list of the id of all nodes that match.
1916         '''
1917         where = []
1918         args = []
1919         for propname in requirements.keys():
1920             prop = self.properties[propname]
1921             if not isinstance(prop, String):
1922                 raise TypeError, "'%s' not a String property"%propname
1923             where.append(propname)
1924             args.append(requirements[propname].lower())
1926         # generate the where clause
1927         s = ' and '.join(['lower(_%s)=%s'%(col, self.db.arg) for col in where])
1928         sql = 'select id from _%s where %s and __retired__=%s'%(self.classname,
1929             s, self.db.arg)
1930         args.append(0)
1931         self.db.sql(sql, tuple(args))
1932         # XXX numeric ids
1933         l = [str(x[0]) for x in self.db.sql_fetchall()]
1934         if __debug__:
1935             print >>hyperdb.DEBUG, 'find ... ', l
1936         return l
1938     def list(self):
1939         ''' Return a list of the ids of the active nodes in this class.
1940         '''
1941         return self.getnodeids(retired=0)
1943     def getnodeids(self, retired=None):
1944         ''' Retrieve all the ids of the nodes for a particular Class.
1946             Set retired=None to get all nodes. Otherwise it'll get all the 
1947             retired or non-retired nodes, depending on the flag.
1948         '''
1949         # flip the sense of the 'retired' flag if we don't want all of them
1950         if retired is not None:
1951             if retired:
1952                 args = (0, )
1953             else:
1954                 args = (1, )
1955             sql = 'select id from _%s where __retired__ <> %s'%(self.classname,
1956                 self.db.arg)
1957         else:
1958             args = ()
1959             sql = 'select id from _%s'%self.classname
1960         if __debug__:
1961             print >>hyperdb.DEBUG, 'getnodeids', (self, sql, retired)
1962         self.db.cursor.execute(sql, args)
1963         # XXX numeric ids
1964         ids = [str(x[0]) for x in self.db.cursor.fetchall()]
1965         return ids
1967     def filter(self, search_matches, filterspec, sort=(None,None),
1968             group=(None,None)):
1969         '''Return a list of the ids of the active nodes in this class that
1970         match the 'filter' spec, sorted by the group spec and then the
1971         sort spec
1973         "filterspec" is {propname: value(s)}
1975         "sort" and "group" are (dir, prop) where dir is '+', '-' or None
1976         and prop is a prop name or None
1978         "search_matches" is {nodeid: marker}
1980         The filter must match all properties specificed - but if the
1981         property value to match is a list, any one of the values in the
1982         list may match for that property to match.
1983         '''
1984         # just don't bother if the full-text search matched diddly
1985         if search_matches == {}:
1986             return []
1988         cn = self.classname
1990         timezone = self.db.getUserTimezone()
1991         
1992         # figure the WHERE clause from the filterspec
1993         props = self.getprops()
1994         frum = ['_'+cn]
1995         where = []
1996         args = []
1997         a = self.db.arg
1998         for k, v in filterspec.items():
1999             propclass = props[k]
2000             # now do other where clause stuff
2001             if isinstance(propclass, Multilink):
2002                 tn = '%s_%s'%(cn, k)
2003                 if v in ('-1', ['-1']):
2004                     # only match rows that have count(linkid)=0 in the
2005                     # corresponding multilink table)
2006                     where.append('id not in (select nodeid from %s)'%tn)
2007                 elif isinstance(v, type([])):
2008                     frum.append(tn)
2009                     s = ','.join([a for x in v])
2010                     where.append('id=%s.nodeid and %s.linkid in (%s)'%(tn,tn,s))
2011                     args = args + v
2012                 else:
2013                     frum.append(tn)
2014                     where.append('id=%s.nodeid and %s.linkid=%s'%(tn, tn, a))
2015                     args.append(v)
2016             elif k == 'id':
2017                 if isinstance(v, type([])):
2018                     s = ','.join([a for x in v])
2019                     where.append('%s in (%s)'%(k, s))
2020                     args = args + v
2021                 else:
2022                     where.append('%s=%s'%(k, a))
2023                     args.append(v)
2024             elif isinstance(propclass, String):
2025                 if not isinstance(v, type([])):
2026                     v = [v]
2028                 # Quote the bits in the string that need it and then embed
2029                 # in a "substring" search. Note - need to quote the '%' so
2030                 # they make it through the python layer happily
2031                 v = ['%%'+self.db.sql_stringquote(s)+'%%' for s in v]
2033                 # now add to the where clause
2034                 where.append(' or '.join(["_%s LIKE '%s'"%(k, s) for s in v]))
2035                 # note: args are embedded in the query string now
2036             elif isinstance(propclass, Link):
2037                 if isinstance(v, type([])):
2038                     if '-1' in v:
2039                         v = v[:]
2040                         v.remove('-1')
2041                         xtra = ' or _%s is NULL'%k
2042                     else:
2043                         xtra = ''
2044                     if v:
2045                         s = ','.join([a for x in v])
2046                         where.append('(_%s in (%s)%s)'%(k, s, xtra))
2047                         args = args + v
2048                     else:
2049                         where.append('_%s is NULL'%k)
2050                 else:
2051                     if v == '-1':
2052                         v = None
2053                         where.append('_%s is NULL'%k)
2054                     else:
2055                         where.append('_%s=%s'%(k, a))
2056                         args.append(v)
2057             elif isinstance(propclass, Date):
2058                 dc = self.db.hyperdb_to_sql_value[hyperdb.Date]
2059                 if isinstance(v, type([])):
2060                     s = ','.join([a for x in v])
2061                     where.append('_%s in (%s)'%(k, s))
2062                     args = args + [dc(date.Date(v)) for x in v]
2063                 else:
2064                     try:
2065                         # Try to filter on range of dates
2066                         date_rng = Range(v, date.Date, offset=timezone)
2067                         if date_rng.from_value:
2068                             where.append('_%s >= %s'%(k, a))                            
2069                             args.append(dc(date_rng.from_value))
2070                         if date_rng.to_value:
2071                             where.append('_%s <= %s'%(k, a))
2072                             args.append(dc(date_rng.to_value))
2073                     except ValueError:
2074                         # If range creation fails - ignore that search parameter
2075                         pass                        
2076             elif isinstance(propclass, Interval):
2077                 if isinstance(v, type([])):
2078                     s = ','.join([a for x in v])
2079                     where.append('_%s in (%s)'%(k, s))
2080                     args = args + [date.Interval(x).serialise() for x in v]
2081                 else:
2082                     try:
2083                         # Try to filter on range of intervals
2084                         date_rng = Range(v, date.Interval)
2085                         if date_rng.from_value:
2086                             where.append('_%s >= %s'%(k, a))
2087                             args.append(date_rng.from_value.serialise())
2088                         if date_rng.to_value:
2089                             where.append('_%s <= %s'%(k, a))
2090                             args.append(date_rng.to_value.serialise())
2091                     except ValueError:
2092                         # If range creation fails - ignore that search parameter
2093                         pass                        
2094                     #where.append('_%s=%s'%(k, a))
2095                     #args.append(date.Interval(v).serialise())
2096             else:
2097                 if isinstance(v, type([])):
2098                     s = ','.join([a for x in v])
2099                     where.append('_%s in (%s)'%(k, s))
2100                     args = args + v
2101                 else:
2102                     where.append('_%s=%s'%(k, a))
2103                     args.append(v)
2105         # don't match retired nodes
2106         where.append('__retired__ <> 1')
2108         # add results of full text search
2109         if search_matches is not None:
2110             v = search_matches.keys()
2111             s = ','.join([a for x in v])
2112             where.append('id in (%s)'%s)
2113             args = args + v
2115         # "grouping" is just the first-order sorting in the SQL fetch
2116         # can modify it...)
2117         orderby = []
2118         ordercols = []
2119         if group[0] is not None and group[1] is not None:
2120             if group[0] != '-':
2121                 orderby.append('_'+group[1])
2122                 ordercols.append('_'+group[1])
2123             else:
2124                 orderby.append('_'+group[1]+' desc')
2125                 ordercols.append('_'+group[1])
2127         # now add in the sorting
2128         group = ''
2129         if sort[0] is not None and sort[1] is not None:
2130             direction, colname = sort
2131             if direction != '-':
2132                 if colname == 'id':
2133                     orderby.append(colname)
2134                 else:
2135                     orderby.append('_'+colname)
2136                     ordercols.append('_'+colname)
2137             else:
2138                 if colname == 'id':
2139                     orderby.append(colname+' desc')
2140                     ordercols.append(colname)
2141                 else:
2142                     orderby.append('_'+colname+' desc')
2143                     ordercols.append('_'+colname)
2145         # construct the SQL
2146         frum = ','.join(frum)
2147         if where:
2148             where = ' where ' + (' and '.join(where))
2149         else:
2150             where = ''
2151         cols = ['id']
2152         if orderby:
2153             cols = cols + ordercols
2154             order = ' order by %s'%(','.join(orderby))
2155         else:
2156             order = ''
2157         cols = ','.join(cols)
2158         sql = 'select %s from %s %s%s%s'%(cols, frum, where, group, order)
2159         args = tuple(args)
2160         if __debug__:
2161             print >>hyperdb.DEBUG, 'filter', (self, sql, args)
2162         if args:
2163             self.db.cursor.execute(sql, args)
2164         else:
2165             # psycopg doesn't like empty args
2166             self.db.cursor.execute(sql)
2167         l = self.db.sql_fetchall()
2169         # return the IDs (the first column)
2170         # XXX numeric ids
2171         return [str(row[0]) for row in l]
2173     def count(self):
2174         '''Get the number of nodes in this class.
2176         If the returned integer is 'numnodes', the ids of all the nodes
2177         in this class run from 1 to numnodes, and numnodes+1 will be the
2178         id of the next node to be created in this class.
2179         '''
2180         return self.db.countnodes(self.classname)
2182     # Manipulating properties:
2183     def getprops(self, protected=1):
2184         '''Return a dictionary mapping property names to property objects.
2185            If the "protected" flag is true, we include protected properties -
2186            those which may not be modified.
2187         '''
2188         d = self.properties.copy()
2189         if protected:
2190             d['id'] = String()
2191             d['creation'] = hyperdb.Date()
2192             d['activity'] = hyperdb.Date()
2193             d['creator'] = hyperdb.Link('user')
2194             d['actor'] = hyperdb.Link('user')
2195         return d
2197     def addprop(self, **properties):
2198         '''Add properties to this class.
2200         The keyword arguments in 'properties' must map names to property
2201         objects, or a TypeError is raised.  None of the keys in 'properties'
2202         may collide with the names of existing properties, or a ValueError
2203         is raised before any properties have been added.
2204         '''
2205         for key in properties.keys():
2206             if self.properties.has_key(key):
2207                 raise ValueError, key
2208         self.properties.update(properties)
2210     def index(self, nodeid):
2211         '''Add (or refresh) the node to search indexes
2212         '''
2213         # find all the String properties that have indexme
2214         for prop, propclass in self.getprops().items():
2215             if isinstance(propclass, String) and propclass.indexme:
2216                 self.db.indexer.add_text((self.classname, nodeid, prop),
2217                     str(self.get(nodeid, prop)))
2220     #
2221     # Detector interface
2222     #
2223     def audit(self, event, detector):
2224         '''Register a detector
2225         '''
2226         l = self.auditors[event]
2227         if detector not in l:
2228             self.auditors[event].append(detector)
2230     def fireAuditors(self, action, nodeid, newvalues):
2231         '''Fire all registered auditors.
2232         '''
2233         for audit in self.auditors[action]:
2234             audit(self.db, self, nodeid, newvalues)
2236     def react(self, event, detector):
2237         '''Register a detector
2238         '''
2239         l = self.reactors[event]
2240         if detector not in l:
2241             self.reactors[event].append(detector)
2243     def fireReactors(self, action, nodeid, oldvalues):
2244         '''Fire all registered reactors.
2245         '''
2246         for react in self.reactors[action]:
2247             react(self.db, self, nodeid, oldvalues)
2249 class FileClass(Class, hyperdb.FileClass):
2250     '''This class defines a large chunk of data. To support this, it has a
2251        mandatory String property "content" which is typically saved off
2252        externally to the hyperdb.
2254        The default MIME type of this data is defined by the
2255        "default_mime_type" class attribute, which may be overridden by each
2256        node if the class defines a "type" String property.
2257     '''
2258     default_mime_type = 'text/plain'
2260     def create(self, **propvalues):
2261         ''' snaffle the file propvalue and store in a file
2262         '''
2263         # we need to fire the auditors now, or the content property won't
2264         # be in propvalues for the auditors to play with
2265         self.fireAuditors('create', None, propvalues)
2267         # now remove the content property so it's not stored in the db
2268         content = propvalues['content']
2269         del propvalues['content']
2271         # do the database create
2272         newid = self.create_inner(**propvalues)
2274         # figure the mime type
2275         mime_type = propvalues.get('type', self.default_mime_type)
2277         # and index!
2278         self.db.indexer.add_text((self.classname, newid, 'content'), content,
2279             mime_type)
2281         # fire reactors
2282         self.fireReactors('create', newid, None)
2284         # store off the content as a file
2285         self.db.storefile(self.classname, newid, None, content)
2286         return newid
2288     def import_list(self, propnames, proplist):
2289         ''' Trap the "content" property...
2290         '''
2291         # dupe this list so we don't affect others
2292         propnames = propnames[:]
2294         # extract the "content" property from the proplist
2295         i = propnames.index('content')
2296         content = eval(proplist[i])
2297         del propnames[i]
2298         del proplist[i]
2300         # do the normal import
2301         newid = Class.import_list(self, propnames, proplist)
2303         # save off the "content" file
2304         self.db.storefile(self.classname, newid, None, content)
2305         return newid
2307     _marker = []
2308     def get(self, nodeid, propname, default=_marker, cache=1):
2309         ''' Trap the content propname and get it from the file
2311         'cache' exists for backwards compatibility, and is not used.
2312         '''
2313         poss_msg = 'Possibly a access right configuration problem.'
2314         if propname == 'content':
2315             try:
2316                 return self.db.getfile(self.classname, nodeid, None)
2317             except IOError, (strerror):
2318                 # BUG: by catching this we donot see an error in the log.
2319                 return 'ERROR reading file: %s%s\n%s\n%s'%(
2320                         self.classname, nodeid, poss_msg, strerror)
2321         if default is not self._marker:
2322             return Class.get(self, nodeid, propname, default)
2323         else:
2324             return Class.get(self, nodeid, propname)
2326     def getprops(self, protected=1):
2327         ''' In addition to the actual properties on the node, these methods
2328             provide the "content" property. If the "protected" flag is true,
2329             we include protected properties - those which may not be
2330             modified.
2331         '''
2332         d = Class.getprops(self, protected=protected).copy()
2333         d['content'] = hyperdb.String()
2334         return d
2336     def set(self, itemid, **propvalues):
2337         ''' Snarf the "content" propvalue and update it in a file
2338         '''
2339         self.fireAuditors('set', itemid, propvalues)
2340         oldvalues = copy.deepcopy(self.db.getnode(self.classname, itemid))
2342         # now remove the content property so it's not stored in the db
2343         content = None
2344         if propvalues.has_key('content'):
2345             content = propvalues['content']
2346             del propvalues['content']
2348         # do the database create
2349         propvalues = self.set_inner(itemid, **propvalues)
2351         # do content?
2352         if content:
2353             # store and index
2354             self.db.storefile(self.classname, itemid, None, content)
2355             mime_type = propvalues.get('type', self.get(itemid, 'type'))
2356             if not mime_type:
2357                 mime_type = self.default_mime_type
2358             self.db.indexer.add_text((self.classname, itemid, 'content'),
2359                 content, mime_type)
2361         # fire reactors
2362         self.fireReactors('set', itemid, oldvalues)
2363         return propvalues
2365 # XXX deviation from spec - was called ItemClass
2366 class IssueClass(Class, roundupdb.IssueClass):
2367     # Overridden methods:
2368     def __init__(self, db, classname, **properties):
2369         '''The newly-created class automatically includes the "messages",
2370         "files", "nosy", and "superseder" properties.  If the 'properties'
2371         dictionary attempts to specify any of these properties or a
2372         "creation", "creator", "activity" or "actor" property, a ValueError
2373         is raised.
2374         '''
2375         if not properties.has_key('title'):
2376             properties['title'] = hyperdb.String(indexme='yes')
2377         if not properties.has_key('messages'):
2378             properties['messages'] = hyperdb.Multilink("msg")
2379         if not properties.has_key('files'):
2380             properties['files'] = hyperdb.Multilink("file")
2381         if not properties.has_key('nosy'):
2382             # note: journalling is turned off as it really just wastes
2383             # space. this behaviour may be overridden in an instance
2384             properties['nosy'] = hyperdb.Multilink("user", do_journal="no")
2385         if not properties.has_key('superseder'):
2386             properties['superseder'] = hyperdb.Multilink(classname)
2387         Class.__init__(self, db, classname, **properties)