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