1 # $Id: rdbms_common.py,v 1.83 2004-03-21 23:39:08 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 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)
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.sql_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 = int(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.indexer.close()
1070 self.sql_close()
1072 #
1073 # The base Class class
1074 #
1075 class Class(hyperdb.Class):
1076 ''' The handle to a particular class of nodes in a hyperdatabase.
1078 All methods except __repr__ and getnode must be implemented by a
1079 concrete backend Class.
1080 '''
1082 def __init__(self, db, classname, **properties):
1083 '''Create a new class with a given name and property specification.
1085 'classname' must not collide with the name of an existing class,
1086 or a ValueError is raised. The keyword arguments in 'properties'
1087 must map names to property objects, or a TypeError is raised.
1088 '''
1089 for name in 'creation activity creator actor'.split():
1090 if properties.has_key(name):
1091 raise ValueError, '"creation", "activity", "creator" and '\
1092 '"actor" are reserved'
1094 self.classname = classname
1095 self.properties = properties
1096 self.db = weakref.proxy(db) # use a weak ref to avoid circularity
1097 self.key = ''
1099 # should we journal changes (default yes)
1100 self.do_journal = 1
1102 # do the db-related init stuff
1103 db.addclass(self)
1105 self.auditors = {'create': [], 'set': [], 'retire': [], 'restore': []}
1106 self.reactors = {'create': [], 'set': [], 'retire': [], 'restore': []}
1108 def schema(self):
1109 ''' A dumpable version of the schema that we can store in the
1110 database
1111 '''
1112 return (self.key, [(x, repr(y)) for x,y in self.properties.items()])
1114 def enableJournalling(self):
1115 '''Turn journalling on for this class
1116 '''
1117 self.do_journal = 1
1119 def disableJournalling(self):
1120 '''Turn journalling off for this class
1121 '''
1122 self.do_journal = 0
1124 # Editing nodes:
1125 def create(self, **propvalues):
1126 ''' Create a new node of this class and return its id.
1128 The keyword arguments in 'propvalues' map property names to values.
1130 The values of arguments must be acceptable for the types of their
1131 corresponding properties or a TypeError is raised.
1133 If this class has a key property, it must be present and its value
1134 must not collide with other key strings or a ValueError is raised.
1136 Any other properties on this class that are missing from the
1137 'propvalues' dictionary are set to None.
1139 If an id in a link or multilink property does not refer to a valid
1140 node, an IndexError is raised.
1141 '''
1142 self.fireAuditors('create', None, propvalues)
1143 newid = self.create_inner(**propvalues)
1144 self.fireReactors('create', newid, None)
1145 return newid
1147 def create_inner(self, **propvalues):
1148 ''' Called by create, in-between the audit and react calls.
1149 '''
1150 if propvalues.has_key('id'):
1151 raise KeyError, '"id" is reserved'
1153 if self.db.journaltag is None:
1154 raise DatabaseError, 'Database open read-only'
1156 if propvalues.has_key('creator') or propvalues.has_key('actor') or \
1157 propvalues.has_key('creation') or propvalues.has_key('activity'):
1158 raise KeyError, '"creator", "actor", "creation" and '\
1159 '"activity" are reserved'
1161 # new node's id
1162 newid = self.db.newid(self.classname)
1164 # validate propvalues
1165 num_re = re.compile('^\d+$')
1166 for key, value in propvalues.items():
1167 if key == self.key:
1168 try:
1169 self.lookup(value)
1170 except KeyError:
1171 pass
1172 else:
1173 raise ValueError, 'node with key "%s" exists'%value
1175 # try to handle this property
1176 try:
1177 prop = self.properties[key]
1178 except KeyError:
1179 raise KeyError, '"%s" has no property "%s"'%(self.classname,
1180 key)
1182 if value is not None and isinstance(prop, Link):
1183 if type(value) != type(''):
1184 raise ValueError, 'link value must be String'
1185 link_class = self.properties[key].classname
1186 # if it isn't a number, it's a key
1187 if not num_re.match(value):
1188 try:
1189 value = self.db.classes[link_class].lookup(value)
1190 except (TypeError, KeyError):
1191 raise IndexError, 'new property "%s": %s not a %s'%(
1192 key, value, link_class)
1193 elif not self.db.getclass(link_class).hasnode(value):
1194 raise IndexError, '%s has no node %s'%(link_class, value)
1196 # save off the value
1197 propvalues[key] = value
1199 # register the link with the newly linked node
1200 if self.do_journal and self.properties[key].do_journal:
1201 self.db.addjournal(link_class, value, 'link',
1202 (self.classname, newid, key))
1204 elif isinstance(prop, Multilink):
1205 if type(value) != type([]):
1206 raise TypeError, 'new property "%s" not a list of ids'%key
1208 # clean up and validate the list of links
1209 link_class = self.properties[key].classname
1210 l = []
1211 for entry in value:
1212 if type(entry) != type(''):
1213 raise ValueError, '"%s" multilink value (%r) '\
1214 'must contain Strings'%(key, value)
1215 # if it isn't a number, it's a key
1216 if not num_re.match(entry):
1217 try:
1218 entry = self.db.classes[link_class].lookup(entry)
1219 except (TypeError, KeyError):
1220 raise IndexError, 'new property "%s": %s not a %s'%(
1221 key, entry, self.properties[key].classname)
1222 l.append(entry)
1223 value = l
1224 propvalues[key] = value
1226 # handle additions
1227 for nodeid in value:
1228 if not self.db.getclass(link_class).hasnode(nodeid):
1229 raise IndexError, '%s has no node %s'%(link_class,
1230 nodeid)
1231 # register the link with the newly linked node
1232 if self.do_journal and self.properties[key].do_journal:
1233 self.db.addjournal(link_class, nodeid, 'link',
1234 (self.classname, newid, key))
1236 elif isinstance(prop, String):
1237 if type(value) != type('') and type(value) != type(u''):
1238 raise TypeError, 'new property "%s" not a string'%key
1239 self.db.indexer.add_text((self.classname, newid, key), value)
1241 elif isinstance(prop, Password):
1242 if not isinstance(value, password.Password):
1243 raise TypeError, 'new property "%s" not a Password'%key
1245 elif isinstance(prop, Date):
1246 if value is not None and not isinstance(value, date.Date):
1247 raise TypeError, 'new property "%s" not a Date'%key
1249 elif isinstance(prop, Interval):
1250 if value is not None and not isinstance(value, date.Interval):
1251 raise TypeError, 'new property "%s" not an Interval'%key
1253 elif value is not None and isinstance(prop, Number):
1254 try:
1255 float(value)
1256 except ValueError:
1257 raise TypeError, 'new property "%s" not numeric'%key
1259 elif value is not None and isinstance(prop, Boolean):
1260 try:
1261 int(value)
1262 except ValueError:
1263 raise TypeError, 'new property "%s" not boolean'%key
1265 # make sure there's data where there needs to be
1266 for key, prop in self.properties.items():
1267 if propvalues.has_key(key):
1268 continue
1269 if key == self.key:
1270 raise ValueError, 'key property "%s" is required'%key
1271 if isinstance(prop, Multilink):
1272 propvalues[key] = []
1273 else:
1274 propvalues[key] = None
1276 # done
1277 self.db.addnode(self.classname, newid, propvalues)
1278 if self.do_journal:
1279 self.db.addjournal(self.classname, newid, 'create', {})
1281 return newid
1283 def export_list(self, propnames, nodeid):
1284 ''' Export a node - generate a list of CSV-able data in the order
1285 specified by propnames for the given node.
1286 '''
1287 properties = self.getprops()
1288 l = []
1289 for prop in propnames:
1290 proptype = properties[prop]
1291 value = self.get(nodeid, prop)
1292 # "marshal" data where needed
1293 if value is None:
1294 pass
1295 elif isinstance(proptype, hyperdb.Date):
1296 value = value.get_tuple()
1297 elif isinstance(proptype, hyperdb.Interval):
1298 value = value.get_tuple()
1299 elif isinstance(proptype, hyperdb.Password):
1300 value = str(value)
1301 l.append(repr(value))
1302 l.append(repr(self.is_retired(nodeid)))
1303 return l
1305 def import_list(self, propnames, proplist):
1306 ''' Import a node - all information including "id" is present and
1307 should not be sanity checked. Triggers are not triggered. The
1308 journal should be initialised using the "creator" and "created"
1309 information.
1311 Return the nodeid of the node imported.
1312 '''
1313 if self.db.journaltag is None:
1314 raise DatabaseError, 'Database open read-only'
1315 properties = self.getprops()
1317 # make the new node's property map
1318 d = {}
1319 retire = 0
1320 newid = None
1321 for i in range(len(propnames)):
1322 # Use eval to reverse the repr() used to output the CSV
1323 value = eval(proplist[i])
1325 # Figure the property for this column
1326 propname = propnames[i]
1328 # "unmarshal" where necessary
1329 if propname == 'id':
1330 newid = value
1331 continue
1332 elif propname == 'is retired':
1333 # is the item retired?
1334 if int(value):
1335 retire = 1
1336 continue
1337 elif value is None:
1338 d[propname] = None
1339 continue
1341 prop = properties[propname]
1342 if value is None:
1343 # don't set Nones
1344 continue
1345 elif isinstance(prop, hyperdb.Date):
1346 value = date.Date(value)
1347 elif isinstance(prop, hyperdb.Interval):
1348 value = date.Interval(value)
1349 elif isinstance(prop, hyperdb.Password):
1350 pwd = password.Password()
1351 pwd.unpack(value)
1352 value = pwd
1353 d[propname] = value
1355 # get a new id if necessary
1356 if newid is None:
1357 newid = self.db.newid(self.classname)
1359 # add the node and journal
1360 self.db.addnode(self.classname, newid, d)
1362 # retire?
1363 if retire:
1364 # use the arg for __retired__ to cope with any odd database type
1365 # conversion (hello, sqlite)
1366 sql = 'update _%s set __retired__=%s where id=%s'%(self.classname,
1367 self.db.arg, self.db.arg)
1368 if __debug__:
1369 print >>hyperdb.DEBUG, 'retire', (self, sql, newid)
1370 self.db.cursor.execute(sql, (1, newid))
1372 # extract the extraneous journalling gumpf and nuke it
1373 if d.has_key('creator'):
1374 creator = d['creator']
1375 del d['creator']
1376 else:
1377 creator = None
1378 if d.has_key('creation'):
1379 creation = d['creation']
1380 del d['creation']
1381 else:
1382 creation = None
1383 if d.has_key('activity'):
1384 del d['activity']
1385 if d.has_key('actor'):
1386 del d['actor']
1387 self.db.addjournal(self.classname, newid, 'create', {}, creator,
1388 creation)
1389 return newid
1391 _marker = []
1392 def get(self, nodeid, propname, default=_marker, cache=1):
1393 '''Get the value of a property on an existing node of this class.
1395 'nodeid' must be the id of an existing node of this class or an
1396 IndexError is raised. 'propname' must be the name of a property
1397 of this class or a KeyError is raised.
1399 'cache' exists for backwards compatibility, and is not used.
1400 '''
1401 if propname == 'id':
1402 return nodeid
1404 # get the node's dict
1405 d = self.db.getnode(self.classname, nodeid)
1407 if propname == 'creation':
1408 if d.has_key('creation'):
1409 return d['creation']
1410 else:
1411 return date.Date()
1412 if propname == 'activity':
1413 if d.has_key('activity'):
1414 return d['activity']
1415 else:
1416 return date.Date()
1417 if propname == 'creator':
1418 if d.has_key('creator'):
1419 return d['creator']
1420 else:
1421 return self.db.getuid()
1422 if propname == 'actor':
1423 if d.has_key('actor'):
1424 return d['actor']
1425 else:
1426 return self.db.getuid()
1428 # get the property (raises KeyErorr if invalid)
1429 prop = self.properties[propname]
1431 if not d.has_key(propname):
1432 if default is self._marker:
1433 if isinstance(prop, Multilink):
1434 return []
1435 else:
1436 return None
1437 else:
1438 return default
1440 # don't pass our list to other code
1441 if isinstance(prop, Multilink):
1442 return d[propname][:]
1444 return d[propname]
1446 def set(self, nodeid, **propvalues):
1447 '''Modify a property on an existing node of this class.
1449 'nodeid' must be the id of an existing node of this class or an
1450 IndexError is raised.
1452 Each key in 'propvalues' must be the name of a property of this
1453 class or a KeyError is raised.
1455 All values in 'propvalues' must be acceptable types for their
1456 corresponding properties or a TypeError is raised.
1458 If the value of the key property is set, it must not collide with
1459 other key strings or a ValueError is raised.
1461 If the value of a Link or Multilink property contains an invalid
1462 node id, a ValueError is raised.
1463 '''
1464 self.fireAuditors('set', nodeid, propvalues)
1465 oldvalues = copy.deepcopy(self.db.getnode(self.classname, nodeid))
1466 propvalues = self.set_inner(nodeid, **propvalues)
1467 self.fireReactors('set', nodeid, oldvalues)
1468 return propvalues
1470 def set_inner(self, nodeid, **propvalues):
1471 ''' Called by set, in-between the audit and react calls.
1472 '''
1473 if not propvalues:
1474 return propvalues
1476 if propvalues.has_key('creation') or propvalues.has_key('creator') or \
1477 propvalues.has_key('actor') or propvalues.has_key('activity'):
1478 raise KeyError, '"creation", "creator", "actor" and '\
1479 '"activity" are reserved'
1481 if propvalues.has_key('id'):
1482 raise KeyError, '"id" is reserved'
1484 if self.db.journaltag is None:
1485 raise DatabaseError, 'Database open read-only'
1487 node = self.db.getnode(self.classname, nodeid)
1488 if self.is_retired(nodeid):
1489 raise IndexError, 'Requested item is retired'
1490 num_re = re.compile('^\d+$')
1492 # if the journal value is to be different, store it in here
1493 journalvalues = {}
1495 # remember the add/remove stuff for multilinks, making it easier
1496 # for the Database layer to do its stuff
1497 multilink_changes = {}
1499 for propname, value in propvalues.items():
1500 # check to make sure we're not duplicating an existing key
1501 if propname == self.key and node[propname] != value:
1502 try:
1503 self.lookup(value)
1504 except KeyError:
1505 pass
1506 else:
1507 raise ValueError, 'node with key "%s" exists'%value
1509 # this will raise the KeyError if the property isn't valid
1510 # ... we don't use getprops() here because we only care about
1511 # the writeable properties.
1512 try:
1513 prop = self.properties[propname]
1514 except KeyError:
1515 raise KeyError, '"%s" has no property named "%s"'%(
1516 self.classname, propname)
1518 # if the value's the same as the existing value, no sense in
1519 # doing anything
1520 current = node.get(propname, None)
1521 if value == current:
1522 del propvalues[propname]
1523 continue
1524 journalvalues[propname] = current
1526 # do stuff based on the prop type
1527 if isinstance(prop, Link):
1528 link_class = prop.classname
1529 # if it isn't a number, it's a key
1530 if value is not None and not isinstance(value, type('')):
1531 raise ValueError, 'property "%s" link value be a string'%(
1532 propname)
1533 if isinstance(value, type('')) and not num_re.match(value):
1534 try:
1535 value = self.db.classes[link_class].lookup(value)
1536 except (TypeError, KeyError):
1537 raise IndexError, 'new property "%s": %s not a %s'%(
1538 propname, value, prop.classname)
1540 if (value is not None and
1541 not self.db.getclass(link_class).hasnode(value)):
1542 raise IndexError, '%s has no node %s'%(link_class, value)
1544 if self.do_journal and prop.do_journal:
1545 # register the unlink with the old linked node
1546 if node[propname] is not None:
1547 self.db.addjournal(link_class, node[propname], 'unlink',
1548 (self.classname, nodeid, propname))
1550 # register the link with the newly linked node
1551 if value is not None:
1552 self.db.addjournal(link_class, value, 'link',
1553 (self.classname, nodeid, propname))
1555 elif isinstance(prop, Multilink):
1556 if type(value) != type([]):
1557 raise TypeError, 'new property "%s" not a list of'\
1558 ' ids'%propname
1559 link_class = self.properties[propname].classname
1560 l = []
1561 for entry in value:
1562 # if it isn't a number, it's a key
1563 if type(entry) != type(''):
1564 raise ValueError, 'new property "%s" link value ' \
1565 'must be a string'%propname
1566 if not num_re.match(entry):
1567 try:
1568 entry = self.db.classes[link_class].lookup(entry)
1569 except (TypeError, KeyError):
1570 raise IndexError, 'new property "%s": %s not a %s'%(
1571 propname, entry,
1572 self.properties[propname].classname)
1573 l.append(entry)
1574 value = l
1575 propvalues[propname] = value
1577 # figure the journal entry for this property
1578 add = []
1579 remove = []
1581 # handle removals
1582 if node.has_key(propname):
1583 l = node[propname]
1584 else:
1585 l = []
1586 for id in l[:]:
1587 if id in value:
1588 continue
1589 # register the unlink with the old linked node
1590 if self.do_journal and self.properties[propname].do_journal:
1591 self.db.addjournal(link_class, id, 'unlink',
1592 (self.classname, nodeid, propname))
1593 l.remove(id)
1594 remove.append(id)
1596 # handle additions
1597 for id in value:
1598 if not self.db.getclass(link_class).hasnode(id):
1599 raise IndexError, '%s has no node %s'%(link_class, id)
1600 if id in l:
1601 continue
1602 # register the link with the newly linked node
1603 if self.do_journal and self.properties[propname].do_journal:
1604 self.db.addjournal(link_class, id, 'link',
1605 (self.classname, nodeid, propname))
1606 l.append(id)
1607 add.append(id)
1609 # figure the journal entry
1610 l = []
1611 if add:
1612 l.append(('+', add))
1613 if remove:
1614 l.append(('-', remove))
1615 multilink_changes[propname] = (add, remove)
1616 if l:
1617 journalvalues[propname] = tuple(l)
1619 elif isinstance(prop, String):
1620 if value is not None and type(value) != type('') and type(value) != type(u''):
1621 raise TypeError, 'new property "%s" not a string'%propname
1622 self.db.indexer.add_text((self.classname, nodeid, propname),
1623 value)
1625 elif isinstance(prop, Password):
1626 if not isinstance(value, password.Password):
1627 raise TypeError, 'new property "%s" not a Password'%propname
1628 propvalues[propname] = value
1630 elif value is not None and isinstance(prop, Date):
1631 if not isinstance(value, date.Date):
1632 raise TypeError, 'new property "%s" not a Date'% propname
1633 propvalues[propname] = value
1635 elif value is not None and isinstance(prop, Interval):
1636 if not isinstance(value, date.Interval):
1637 raise TypeError, 'new property "%s" not an '\
1638 'Interval'%propname
1639 propvalues[propname] = value
1641 elif value is not None and isinstance(prop, Number):
1642 try:
1643 float(value)
1644 except ValueError:
1645 raise TypeError, 'new property "%s" not numeric'%propname
1647 elif value is not None and isinstance(prop, Boolean):
1648 try:
1649 int(value)
1650 except ValueError:
1651 raise TypeError, 'new property "%s" not boolean'%propname
1653 # nothing to do?
1654 if not propvalues:
1655 return propvalues
1657 # do the set, and journal it
1658 self.db.setnode(self.classname, nodeid, propvalues, multilink_changes)
1660 if self.do_journal:
1661 self.db.addjournal(self.classname, nodeid, 'set', journalvalues)
1663 return propvalues
1665 def retire(self, nodeid):
1666 '''Retire a node.
1668 The properties on the node remain available from the get() method,
1669 and the node's id is never reused.
1671 Retired nodes are not returned by the find(), list(), or lookup()
1672 methods, and other nodes may reuse the values of their key properties.
1673 '''
1674 if self.db.journaltag is None:
1675 raise DatabaseError, 'Database open read-only'
1677 self.fireAuditors('retire', nodeid, None)
1679 # use the arg for __retired__ to cope with any odd database type
1680 # conversion (hello, sqlite)
1681 sql = 'update _%s set __retired__=%s where id=%s'%(self.classname,
1682 self.db.arg, self.db.arg)
1683 if __debug__:
1684 print >>hyperdb.DEBUG, 'retire', (self, sql, nodeid)
1685 self.db.cursor.execute(sql, (1, nodeid))
1686 if self.do_journal:
1687 self.db.addjournal(self.classname, nodeid, 'retired', None)
1689 self.fireReactors('retire', nodeid, None)
1691 def restore(self, nodeid):
1692 '''Restore a retired node.
1694 Make node available for all operations like it was before retirement.
1695 '''
1696 if self.db.journaltag is None:
1697 raise DatabaseError, 'Database open read-only'
1699 node = self.db.getnode(self.classname, nodeid)
1700 # check if key property was overrided
1701 key = self.getkey()
1702 try:
1703 id = self.lookup(node[key])
1704 except KeyError:
1705 pass
1706 else:
1707 raise KeyError, "Key property (%s) of retired node clashes with \
1708 existing one (%s)" % (key, node[key])
1710 self.fireAuditors('restore', nodeid, None)
1711 # use the arg for __retired__ to cope with any odd database type
1712 # conversion (hello, sqlite)
1713 sql = 'update _%s set __retired__=%s where id=%s'%(self.classname,
1714 self.db.arg, self.db.arg)
1715 if __debug__:
1716 print >>hyperdb.DEBUG, 'restore', (self, sql, nodeid)
1717 self.db.cursor.execute(sql, (0, nodeid))
1718 if self.do_journal:
1719 self.db.addjournal(self.classname, nodeid, 'restored', None)
1721 self.fireReactors('restore', nodeid, None)
1723 def is_retired(self, nodeid):
1724 '''Return true if the node is rerired
1725 '''
1726 sql = 'select __retired__ from _%s where id=%s'%(self.classname,
1727 self.db.arg)
1728 if __debug__:
1729 print >>hyperdb.DEBUG, 'is_retired', (self, sql, nodeid)
1730 self.db.cursor.execute(sql, (nodeid,))
1731 return int(self.db.sql_fetchone()[0])
1733 def destroy(self, nodeid):
1734 '''Destroy a node.
1736 WARNING: this method should never be used except in extremely rare
1737 situations where there could never be links to the node being
1738 deleted
1740 WARNING: use retire() instead
1742 WARNING: the properties of this node will not be available ever again
1744 WARNING: really, use retire() instead
1746 Well, I think that's enough warnings. This method exists mostly to
1747 support the session storage of the cgi interface.
1749 The node is completely removed from the hyperdb, including all journal
1750 entries. It will no longer be available, and will generally break code
1751 if there are any references to the node.
1752 '''
1753 if self.db.journaltag is None:
1754 raise DatabaseError, 'Database open read-only'
1755 self.db.destroynode(self.classname, nodeid)
1757 def history(self, nodeid):
1758 '''Retrieve the journal of edits on a particular node.
1760 'nodeid' must be the id of an existing node of this class or an
1761 IndexError is raised.
1763 The returned list contains tuples of the form
1765 (nodeid, date, tag, action, params)
1767 'date' is a Timestamp object specifying the time of the change and
1768 'tag' is the journaltag specified when the database was opened.
1769 '''
1770 if not self.do_journal:
1771 raise ValueError, 'Journalling is disabled for this class'
1772 return self.db.getjournal(self.classname, nodeid)
1774 # Locating nodes:
1775 def hasnode(self, nodeid):
1776 '''Determine if the given nodeid actually exists
1777 '''
1778 return self.db.hasnode(self.classname, nodeid)
1780 def setkey(self, propname):
1781 '''Select a String property of this class to be the key property.
1783 'propname' must be the name of a String property of this class or
1784 None, or a TypeError is raised. The values of the key property on
1785 all existing nodes must be unique or a ValueError is raised.
1786 '''
1787 # XXX create an index on the key prop column. We should also
1788 # record that we've created this index in the schema somewhere.
1789 prop = self.getprops()[propname]
1790 if not isinstance(prop, String):
1791 raise TypeError, 'key properties must be String'
1792 self.key = propname
1794 def getkey(self):
1795 '''Return the name of the key property for this class or None.'''
1796 return self.key
1798 def labelprop(self, default_to_id=0):
1799 '''Return the property name for a label for the given node.
1801 This method attempts to generate a consistent label for the node.
1802 It tries the following in order:
1804 1. key property
1805 2. "name" property
1806 3. "title" property
1807 4. first property from the sorted property name list
1808 '''
1809 k = self.getkey()
1810 if k:
1811 return k
1812 props = self.getprops()
1813 if props.has_key('name'):
1814 return 'name'
1815 elif props.has_key('title'):
1816 return 'title'
1817 if default_to_id:
1818 return 'id'
1819 props = props.keys()
1820 props.sort()
1821 return props[0]
1823 def lookup(self, keyvalue):
1824 '''Locate a particular node by its key property and return its id.
1826 If this class has no key property, a TypeError is raised. If the
1827 'keyvalue' matches one of the values for the key property among
1828 the nodes in this class, the matching node's id is returned;
1829 otherwise a KeyError is raised.
1830 '''
1831 if not self.key:
1832 raise TypeError, 'No key property set for class %s'%self.classname
1834 # use the arg to handle any odd database type conversion (hello,
1835 # sqlite)
1836 sql = "select id from _%s where _%s=%s and __retired__ <> %s"%(
1837 self.classname, self.key, self.db.arg, self.db.arg)
1838 self.db.sql(sql, (keyvalue, 1))
1840 # see if there was a result that's not retired
1841 row = self.db.sql_fetchone()
1842 if not row:
1843 raise KeyError, 'No key (%s) value "%s" for "%s"'%(self.key,
1844 keyvalue, self.classname)
1846 # return the id
1847 return row[0]
1849 def find(self, **propspec):
1850 '''Get the ids of nodes in this class which link to the given nodes.
1852 'propspec' consists of keyword args propname=nodeid or
1853 propname={nodeid:1, }
1854 'propname' must be the name of a property in this class, or a
1855 KeyError is raised. That property must be a Link or
1856 Multilink property, or a TypeError is raised.
1858 Any node in this class whose 'propname' property links to any of the
1859 nodeids will be returned. Used by the full text indexing, which knows
1860 that "foo" occurs in msg1, msg3 and file7, so we have hits on these
1861 issues:
1863 db.issue.find(messages={'1':1,'3':1}, files={'7':1})
1864 '''
1865 if __debug__:
1866 print >>hyperdb.DEBUG, 'find', (self, propspec)
1868 # shortcut
1869 if not propspec:
1870 return []
1872 # validate the args
1873 props = self.getprops()
1874 propspec = propspec.items()
1875 for propname, nodeids in propspec:
1876 # check the prop is OK
1877 prop = props[propname]
1878 if not isinstance(prop, Link) and not isinstance(prop, Multilink):
1879 raise TypeError, "'%s' not a Link/Multilink property"%propname
1881 # first, links
1882 a = self.db.arg
1883 allvalues = (1,)
1884 o = []
1885 where = []
1886 for prop, values in propspec:
1887 if not isinstance(props[prop], hyperdb.Link):
1888 continue
1889 if type(values) is type({}) and len(values) == 1:
1890 values = values.keys()[0]
1891 if type(values) is type(''):
1892 allvalues += (values,)
1893 where.append('_%s = %s'%(prop, a))
1894 elif values is None:
1895 where.append('_%s is NULL'%prop)
1896 else:
1897 allvalues += tuple(values.keys())
1898 where.append('_%s in (%s)'%(prop, ','.join([a]*len(values))))
1899 tables = ['_%s'%self.classname]
1900 if where:
1901 o.append('(' + ' and '.join(where) + ')')
1903 # now multilinks
1904 for prop, values in propspec:
1905 if not isinstance(props[prop], hyperdb.Multilink):
1906 continue
1907 if not values:
1908 continue
1909 if type(values) is type(''):
1910 allvalues += (values,)
1911 s = a
1912 else:
1913 allvalues += tuple(values.keys())
1914 s = ','.join([a]*len(values))
1915 tn = '%s_%s'%(self.classname, prop)
1916 tables.append(tn)
1917 o.append('(id=%s.nodeid and %s.linkid in (%s))'%(tn, tn, s))
1919 if not o:
1920 return []
1921 elif len(o) > 1:
1922 o = '(' + ' or '.join(['(%s)'%i for i in o]) + ')'
1923 else:
1924 o = o[0]
1925 t = ', '.join(tables)
1926 sql = 'select distinct(id) from %s where __retired__ <> %s and %s'%(t, a, o)
1927 self.db.sql(sql, allvalues)
1928 l = [x[0] for x in self.db.sql_fetchall()]
1929 if __debug__:
1930 print >>hyperdb.DEBUG, 'find ... ', l
1931 return l
1933 def stringFind(self, **requirements):
1934 '''Locate a particular node by matching a set of its String
1935 properties in a caseless search.
1937 If the property is not a String property, a TypeError is raised.
1939 The return is a list of the id of all nodes that match.
1940 '''
1941 where = []
1942 args = []
1943 for propname in requirements.keys():
1944 prop = self.properties[propname]
1945 if not isinstance(prop, String):
1946 raise TypeError, "'%s' not a String property"%propname
1947 where.append(propname)
1948 args.append(requirements[propname].lower())
1950 # generate the where clause
1951 s = ' and '.join(['lower(_%s)=%s'%(col, self.db.arg) for col in where])
1952 sql = 'select id from _%s where %s and __retired__=%s'%(self.classname,
1953 s, self.db.arg)
1954 args.append(0)
1955 self.db.sql(sql, tuple(args))
1956 l = [x[0] for x in self.db.sql_fetchall()]
1957 if __debug__:
1958 print >>hyperdb.DEBUG, 'find ... ', l
1959 return l
1961 def list(self):
1962 ''' Return a list of the ids of the active nodes in this class.
1963 '''
1964 return self.getnodeids(retired=0)
1966 def getnodeids(self, retired=None):
1967 ''' Retrieve all the ids of the nodes for a particular Class.
1969 Set retired=None to get all nodes. Otherwise it'll get all the
1970 retired or non-retired nodes, depending on the flag.
1971 '''
1972 # flip the sense of the 'retired' flag if we don't want all of them
1973 if retired is not None:
1974 if retired:
1975 args = (0, )
1976 else:
1977 args = (1, )
1978 sql = 'select id from _%s where __retired__ <> %s'%(self.classname,
1979 self.db.arg)
1980 else:
1981 args = ()
1982 sql = 'select id from _%s'%self.classname
1983 if __debug__:
1984 print >>hyperdb.DEBUG, 'getnodeids', (self, sql, retired)
1985 self.db.cursor.execute(sql, args)
1986 ids = [x[0] for x in self.db.cursor.fetchall()]
1987 return ids
1989 def filter(self, search_matches, filterspec, sort=(None,None),
1990 group=(None,None)):
1991 '''Return a list of the ids of the active nodes in this class that
1992 match the 'filter' spec, sorted by the group spec and then the
1993 sort spec
1995 "filterspec" is {propname: value(s)}
1997 "sort" and "group" are (dir, prop) where dir is '+', '-' or None
1998 and prop is a prop name or None
2000 "search_matches" is {nodeid: marker}
2002 The filter must match all properties specificed - but if the
2003 property value to match is a list, any one of the values in the
2004 list may match for that property to match.
2005 '''
2006 # just don't bother if the full-text search matched diddly
2007 if search_matches == {}:
2008 return []
2010 cn = self.classname
2012 timezone = self.db.getUserTimezone()
2014 # figure the WHERE clause from the filterspec
2015 props = self.getprops()
2016 frum = ['_'+cn]
2017 where = []
2018 args = []
2019 a = self.db.arg
2020 for k, v in filterspec.items():
2021 propclass = props[k]
2022 # now do other where clause stuff
2023 if isinstance(propclass, Multilink):
2024 tn = '%s_%s'%(cn, k)
2025 if v in ('-1', ['-1']):
2026 # only match rows that have count(linkid)=0 in the
2027 # corresponding multilink table)
2028 where.append('id not in (select nodeid from %s)'%tn)
2029 elif isinstance(v, type([])):
2030 frum.append(tn)
2031 s = ','.join([a for x in v])
2032 where.append('id=%s.nodeid and %s.linkid in (%s)'%(tn,tn,s))
2033 args = args + v
2034 else:
2035 frum.append(tn)
2036 where.append('id=%s.nodeid and %s.linkid=%s'%(tn, tn, a))
2037 args.append(v)
2038 elif k == 'id':
2039 if isinstance(v, type([])):
2040 s = ','.join([a for x in v])
2041 where.append('%s in (%s)'%(k, s))
2042 args = args + v
2043 else:
2044 where.append('%s=%s'%(k, a))
2045 args.append(v)
2046 elif isinstance(propclass, String):
2047 if not isinstance(v, type([])):
2048 v = [v]
2050 # Quote the bits in the string that need it and then embed
2051 # in a "substring" search. Note - need to quote the '%' so
2052 # they make it through the python layer happily
2053 v = ['%%'+self.db.sql_stringquote(s)+'%%' for s in v]
2055 # now add to the where clause
2056 where.append(' or '.join(["_%s LIKE '%s'"%(k, s) for s in v]))
2057 # note: args are embedded in the query string now
2058 elif isinstance(propclass, Link):
2059 if isinstance(v, type([])):
2060 if '-1' in v:
2061 v = v[:]
2062 v.remove('-1')
2063 xtra = ' or _%s is NULL'%k
2064 else:
2065 xtra = ''
2066 if v:
2067 s = ','.join([a for x in v])
2068 where.append('(_%s in (%s)%s)'%(k, s, xtra))
2069 args = args + v
2070 else:
2071 where.append('_%s is NULL'%k)
2072 else:
2073 if v == '-1':
2074 v = None
2075 where.append('_%s is NULL'%k)
2076 else:
2077 where.append('_%s=%s'%(k, a))
2078 args.append(v)
2079 elif isinstance(propclass, Date):
2080 if isinstance(v, type([])):
2081 s = ','.join([a for x in v])
2082 where.append('_%s in (%s)'%(k, s))
2083 args = args + [date.Date(x).serialise() for x in v]
2084 else:
2085 try:
2086 # Try to filter on range of dates
2087 date_rng = Range(v, date.Date, offset=timezone)
2088 if (date_rng.from_value):
2089 where.append('_%s >= %s'%(k, a))
2090 args.append(date_rng.from_value.serialise())
2091 if (date_rng.to_value):
2092 where.append('_%s <= %s'%(k, a))
2093 args.append(date_rng.to_value.serialise())
2094 except ValueError:
2095 # If range creation fails - ignore that search parameter
2096 pass
2097 elif isinstance(propclass, Interval):
2098 if isinstance(v, type([])):
2099 s = ','.join([a for x in v])
2100 where.append('_%s in (%s)'%(k, s))
2101 args = args + [date.Interval(x).serialise() for x in v]
2102 else:
2103 try:
2104 # Try to filter on range of intervals
2105 date_rng = Range(v, date.Interval)
2106 if (date_rng.from_value):
2107 where.append('_%s >= %s'%(k, a))
2108 args.append(date_rng.from_value.serialise())
2109 if (date_rng.to_value):
2110 where.append('_%s <= %s'%(k, a))
2111 args.append(date_rng.to_value.serialise())
2112 except ValueError:
2113 # If range creation fails - ignore that search parameter
2114 pass
2115 #where.append('_%s=%s'%(k, a))
2116 #args.append(date.Interval(v).serialise())
2117 else:
2118 if isinstance(v, type([])):
2119 s = ','.join([a for x in v])
2120 where.append('_%s in (%s)'%(k, s))
2121 args = args + v
2122 else:
2123 where.append('_%s=%s'%(k, a))
2124 args.append(v)
2126 # don't match retired nodes
2127 where.append('__retired__ <> 1')
2129 # add results of full text search
2130 if search_matches is not None:
2131 v = search_matches.keys()
2132 s = ','.join([a for x in v])
2133 where.append('id in (%s)'%s)
2134 args = args + v
2136 # "grouping" is just the first-order sorting in the SQL fetch
2137 # can modify it...)
2138 orderby = []
2139 ordercols = []
2140 if group[0] is not None and group[1] is not None:
2141 if group[0] != '-':
2142 orderby.append('_'+group[1])
2143 ordercols.append('_'+group[1])
2144 else:
2145 orderby.append('_'+group[1]+' desc')
2146 ordercols.append('_'+group[1])
2148 # now add in the sorting
2149 group = ''
2150 if sort[0] is not None and sort[1] is not None:
2151 direction, colname = sort
2152 if direction != '-':
2153 if colname == 'id':
2154 orderby.append(colname)
2155 else:
2156 orderby.append('_'+colname)
2157 ordercols.append('_'+colname)
2158 else:
2159 if colname == 'id':
2160 orderby.append(colname+' desc')
2161 ordercols.append(colname)
2162 else:
2163 orderby.append('_'+colname+' desc')
2164 ordercols.append('_'+colname)
2166 # construct the SQL
2167 frum = ','.join(frum)
2168 if where:
2169 where = ' where ' + (' and '.join(where))
2170 else:
2171 where = ''
2172 cols = ['id']
2173 if orderby:
2174 cols = cols + ordercols
2175 order = ' order by %s'%(','.join(orderby))
2176 else:
2177 order = ''
2178 cols = ','.join(cols)
2179 sql = 'select %s from %s %s%s%s'%(cols, frum, where, group, order)
2180 args = tuple(args)
2181 if __debug__:
2182 print >>hyperdb.DEBUG, 'filter', (self, sql, args)
2183 if args:
2184 self.db.cursor.execute(sql, args)
2185 else:
2186 # psycopg doesn't like empty args
2187 self.db.cursor.execute(sql)
2188 l = self.db.sql_fetchall()
2190 # return the IDs (the first column)
2191 return [row[0] for row in l]
2193 def count(self):
2194 '''Get the number of nodes in this class.
2196 If the returned integer is 'numnodes', the ids of all the nodes
2197 in this class run from 1 to numnodes, and numnodes+1 will be the
2198 id of the next node to be created in this class.
2199 '''
2200 return self.db.countnodes(self.classname)
2202 # Manipulating properties:
2203 def getprops(self, protected=1):
2204 '''Return a dictionary mapping property names to property objects.
2205 If the "protected" flag is true, we include protected properties -
2206 those which may not be modified.
2207 '''
2208 d = self.properties.copy()
2209 if protected:
2210 d['id'] = String()
2211 d['creation'] = hyperdb.Date()
2212 d['activity'] = hyperdb.Date()
2213 d['creator'] = hyperdb.Link('user')
2214 d['actor'] = hyperdb.Link('user')
2215 return d
2217 def addprop(self, **properties):
2218 '''Add properties to this class.
2220 The keyword arguments in 'properties' must map names to property
2221 objects, or a TypeError is raised. None of the keys in 'properties'
2222 may collide with the names of existing properties, or a ValueError
2223 is raised before any properties have been added.
2224 '''
2225 for key in properties.keys():
2226 if self.properties.has_key(key):
2227 raise ValueError, key
2228 self.properties.update(properties)
2230 def index(self, nodeid):
2231 '''Add (or refresh) the node to search indexes
2232 '''
2233 # find all the String properties that have indexme
2234 for prop, propclass in self.getprops().items():
2235 if isinstance(propclass, String) and propclass.indexme:
2236 self.db.indexer.add_text((self.classname, nodeid, prop),
2237 str(self.get(nodeid, prop)))
2240 #
2241 # Detector interface
2242 #
2243 def audit(self, event, detector):
2244 '''Register a detector
2245 '''
2246 l = self.auditors[event]
2247 if detector not in l:
2248 self.auditors[event].append(detector)
2250 def fireAuditors(self, action, nodeid, newvalues):
2251 '''Fire all registered auditors.
2252 '''
2253 for audit in self.auditors[action]:
2254 audit(self.db, self, nodeid, newvalues)
2256 def react(self, event, detector):
2257 '''Register a detector
2258 '''
2259 l = self.reactors[event]
2260 if detector not in l:
2261 self.reactors[event].append(detector)
2263 def fireReactors(self, action, nodeid, oldvalues):
2264 '''Fire all registered reactors.
2265 '''
2266 for react in self.reactors[action]:
2267 react(self.db, self, nodeid, oldvalues)
2269 class FileClass(Class, hyperdb.FileClass):
2270 '''This class defines a large chunk of data. To support this, it has a
2271 mandatory String property "content" which is typically saved off
2272 externally to the hyperdb.
2274 The default MIME type of this data is defined by the
2275 "default_mime_type" class attribute, which may be overridden by each
2276 node if the class defines a "type" String property.
2277 '''
2278 default_mime_type = 'text/plain'
2280 def create(self, **propvalues):
2281 ''' snaffle the file propvalue and store in a file
2282 '''
2283 # we need to fire the auditors now, or the content property won't
2284 # be in propvalues for the auditors to play with
2285 self.fireAuditors('create', None, propvalues)
2287 # now remove the content property so it's not stored in the db
2288 content = propvalues['content']
2289 del propvalues['content']
2291 # do the database create
2292 newid = self.create_inner(**propvalues)
2294 # figure the mime type
2295 mime_type = propvalues.get('type', self.default_mime_type)
2297 # and index!
2298 self.db.indexer.add_text((self.classname, newid, 'content'), content,
2299 mime_type)
2301 # fire reactors
2302 self.fireReactors('create', newid, None)
2304 # store off the content as a file
2305 self.db.storefile(self.classname, newid, None, content)
2306 return newid
2308 def import_list(self, propnames, proplist):
2309 ''' Trap the "content" property...
2310 '''
2311 # dupe this list so we don't affect others
2312 propnames = propnames[:]
2314 # extract the "content" property from the proplist
2315 i = propnames.index('content')
2316 content = eval(proplist[i])
2317 del propnames[i]
2318 del proplist[i]
2320 # do the normal import
2321 newid = Class.import_list(self, propnames, proplist)
2323 # save off the "content" file
2324 self.db.storefile(self.classname, newid, None, content)
2325 return newid
2327 _marker = []
2328 def get(self, nodeid, propname, default=_marker, cache=1):
2329 ''' Trap the content propname and get it from the file
2331 'cache' exists for backwards compatibility, and is not used.
2332 '''
2333 poss_msg = 'Possibly a access right configuration problem.'
2334 if propname == 'content':
2335 try:
2336 return self.db.getfile(self.classname, nodeid, None)
2337 except IOError, (strerror):
2338 # BUG: by catching this we donot see an error in the log.
2339 return 'ERROR reading file: %s%s\n%s\n%s'%(
2340 self.classname, nodeid, poss_msg, strerror)
2341 if default is not self._marker:
2342 return Class.get(self, nodeid, propname, default)
2343 else:
2344 return Class.get(self, nodeid, propname)
2346 def getprops(self, protected=1):
2347 ''' In addition to the actual properties on the node, these methods
2348 provide the "content" property. If the "protected" flag is true,
2349 we include protected properties - those which may not be
2350 modified.
2351 '''
2352 d = Class.getprops(self, protected=protected).copy()
2353 d['content'] = hyperdb.String()
2354 return d
2356 def set(self, itemid, **propvalues):
2357 ''' Snarf the "content" propvalue and update it in a file
2358 '''
2359 self.fireAuditors('set', itemid, propvalues)
2360 oldvalues = copy.deepcopy(self.db.getnode(self.classname, itemid))
2362 # now remove the content property so it's not stored in the db
2363 content = None
2364 if propvalues.has_key('content'):
2365 content = propvalues['content']
2366 del propvalues['content']
2368 # do the database create
2369 propvalues = self.set_inner(itemid, **propvalues)
2371 # do content?
2372 if content:
2373 # store and index
2374 self.db.storefile(self.classname, itemid, None, content)
2375 mime_type = propvalues.get('type', self.get(itemid, 'type'))
2376 if not mime_type:
2377 mime_type = self.default_mime_type
2378 self.db.indexer.add_text((self.classname, itemid, 'content'),
2379 content, mime_type)
2381 # fire reactors
2382 self.fireReactors('set', itemid, oldvalues)
2383 return propvalues
2385 # XXX deviation from spec - was called ItemClass
2386 class IssueClass(Class, roundupdb.IssueClass):
2387 # Overridden methods:
2388 def __init__(self, db, classname, **properties):
2389 '''The newly-created class automatically includes the "messages",
2390 "files", "nosy", and "superseder" properties. If the 'properties'
2391 dictionary attempts to specify any of these properties or a
2392 "creation", "creator", "activity" or "actor" property, a ValueError
2393 is raised.
2394 '''
2395 if not properties.has_key('title'):
2396 properties['title'] = hyperdb.String(indexme='yes')
2397 if not properties.has_key('messages'):
2398 properties['messages'] = hyperdb.Multilink("msg")
2399 if not properties.has_key('files'):
2400 properties['files'] = hyperdb.Multilink("file")
2401 if not properties.has_key('nosy'):
2402 # note: journalling is turned off as it really just wastes
2403 # space. this behaviour may be overridden in an instance
2404 properties['nosy'] = hyperdb.Multilink("user", do_journal="no")
2405 if not properties.has_key('superseder'):
2406 properties['superseder'] = hyperdb.Multilink(classname)
2407 Class.__init__(self, db, classname, **properties)