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()
1071 #
1072 # The base Class class
1073 #
1074 class Class(hyperdb.Class):
1075 ''' The handle to a particular class of nodes in a hyperdatabase.
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.
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.
1135 Any other properties on this class that are missing from the
1136 'propvalues' dictionary are set to None.
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
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.
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.
1667 The properties on the node remain available from the get() method,
1668 and the node's id is never reused.
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)
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.
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.
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()
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)