Code

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