Code

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