2894cc63e35a8374b17ed070bd0fd0f6955353a9
1 # $Id: rdbms_common.py,v 1.81 2004-03-18 01:58:45 richard Exp $
2 ''' Relational database (SQL) backend common code.
4 Basics:
6 - map roundup classes to relational tables
7 - automatically detect schema changes and modify the table schemas
8 appropriately (we store the "database version" of the schema in the
9 database itself as the only row of the "schema" table)
10 - multilinks (which represent a many-to-many relationship) are handled through
11 intermediate tables
12 - journals are stored adjunct to the per-class tables
13 - table names and columns have "_" prepended so the names can't clash with
14 restricted names (like "order")
15 - retirement is determined by the __retired__ column being true
17 Database-specific changes may generally be pushed out to the overridable
18 sql_* methods, since everything else should be fairly generic. There's
19 probably a bit of work to be done if a database is used that actually
20 honors column typing, since the initial databases don't (sqlite stores
21 everything as a string.)
23 The schema of the hyperdb being mapped to the database is stored in the
24 database itself as a repr()'ed dictionary of information about each Class
25 that maps to a table. If that information differs from the hyperdb schema,
26 then we update it. We also store in the schema dict a version which
27 allows us to upgrade the database schema when necessary. See upgrade_db().
28 '''
29 __docformat__ = 'restructuredtext'
31 # standard python modules
32 import sys, os, time, re, errno, weakref, copy
34 # roundup modules
35 from roundup import hyperdb, date, password, roundupdb, security
36 from roundup.hyperdb import String, Password, Date, Interval, Link, \
37 Multilink, DatabaseError, Boolean, Number, Node
38 from roundup.backends import locking
40 # support
41 from blobfiles import FileStorage
42 from roundup.indexer import Indexer
43 from sessions_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 = 0
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 isinstance(prop, Multilink):
264 # first drop indexes.
265 self.drop_multilink_table_indexes(spec.classname, ml)
267 # now the multilink table itself
268 sql = 'drop table %s_%s'%(spec.classname, prop)
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, prop)
273 del keyprop_changes['remove']
275 # drop the column
276 sql = 'alter table _%s drop column _%s'%(spec.classname, prop)
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'%(cols, classname,
978 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 reindex = {}
1023 for method, args in self.transactions:
1024 reindex[method(*args)] = 1
1026 # reindex the nodes that request it
1027 for classname, nodeid in filter(None, reindex.keys()):
1028 print >>hyperdb.DEBUG, 'commit.reindex', (classname, nodeid)
1029 self.getclass(classname).index(nodeid)
1031 # save the indexer state
1032 self.indexer.save_index()
1034 # clear out the transactions
1035 self.transactions = []
1037 def sql_rollback(self):
1038 self.conn.rollback()
1040 def rollback(self):
1041 ''' Reverse all actions from the current transaction.
1043 Undo all the changes made since the database was opened or the last
1044 commit() or rollback() was performed.
1045 '''
1046 if __debug__:
1047 print >>hyperdb.DEBUG, 'rollback', (self,)
1049 self.sql_rollback()
1051 # roll back "other" transaction stuff
1052 for method, args in self.transactions:
1053 # delete temporary files
1054 if method == self.doStoreFile:
1055 self.rollbackStoreFile(*args)
1056 self.transactions = []
1058 # clear the cache
1059 self.clearCache()
1061 def doSaveNode(self, classname, nodeid, node):
1062 ''' dummy that just generates a reindex event
1063 '''
1064 # return the classname, nodeid so we reindex this content
1065 return (classname, nodeid)
1067 def sql_close(self):
1068 if __debug__:
1069 print >>hyperdb.DEBUG, '+++ close database connection +++'
1070 self.conn.close()
1072 def close(self):
1073 ''' Close off the connection.
1074 '''
1075 self.sql_close()
1077 #
1078 # The base Class class
1079 #
1080 class Class(hyperdb.Class):
1081 ''' The handle to a particular class of nodes in a hyperdatabase.
1083 All methods except __repr__ and getnode must be implemented by a
1084 concrete backend Class.
1085 '''
1087 def __init__(self, db, classname, **properties):
1088 '''Create a new class with a given name and property specification.
1090 'classname' must not collide with the name of an existing class,
1091 or a ValueError is raised. The keyword arguments in 'properties'
1092 must map names to property objects, or a TypeError is raised.
1093 '''
1094 for name in 'creation activity creator actor'.split():
1095 if properties.has_key(name):
1096 raise ValueError, '"creation", "activity", "creator" and '\
1097 '"actor" are reserved'
1099 self.classname = classname
1100 self.properties = properties
1101 self.db = weakref.proxy(db) # use a weak ref to avoid circularity
1102 self.key = ''
1104 # should we journal changes (default yes)
1105 self.do_journal = 1
1107 # do the db-related init stuff
1108 db.addclass(self)
1110 self.auditors = {'create': [], 'set': [], 'retire': [], 'restore': []}
1111 self.reactors = {'create': [], 'set': [], 'retire': [], 'restore': []}
1113 def schema(self):
1114 ''' A dumpable version of the schema that we can store in the
1115 database
1116 '''
1117 return (self.key, [(x, repr(y)) for x,y in self.properties.items()])
1119 def enableJournalling(self):
1120 '''Turn journalling on for this class
1121 '''
1122 self.do_journal = 1
1124 def disableJournalling(self):
1125 '''Turn journalling off for this class
1126 '''
1127 self.do_journal = 0
1129 # Editing nodes:
1130 def create(self, **propvalues):
1131 ''' Create a new node of this class and return its id.
1133 The keyword arguments in 'propvalues' map property names to values.
1135 The values of arguments must be acceptable for the types of their
1136 corresponding properties or a TypeError is raised.
1138 If this class has a key property, it must be present and its value
1139 must not collide with other key strings or a ValueError is raised.
1141 Any other properties on this class that are missing from the
1142 'propvalues' dictionary are set to None.
1144 If an id in a link or multilink property does not refer to a valid
1145 node, an IndexError is raised.
1146 '''
1147 self.fireAuditors('create', None, propvalues)
1148 newid = self.create_inner(**propvalues)
1149 self.fireReactors('create', newid, None)
1150 return newid
1152 def create_inner(self, **propvalues):
1153 ''' Called by create, in-between the audit and react calls.
1154 '''
1155 if propvalues.has_key('id'):
1156 raise KeyError, '"id" is reserved'
1158 if self.db.journaltag is None:
1159 raise DatabaseError, 'Database open read-only'
1161 if propvalues.has_key('creator') or propvalues.has_key('actor') or \
1162 propvalues.has_key('creation') or propvalues.has_key('activity'):
1163 raise KeyError, '"creator", "actor", "creation" and '\
1164 '"activity" are reserved'
1166 # new node's id
1167 newid = self.db.newid(self.classname)
1169 # validate propvalues
1170 num_re = re.compile('^\d+$')
1171 for key, value in propvalues.items():
1172 if key == self.key:
1173 try:
1174 self.lookup(value)
1175 except KeyError:
1176 pass
1177 else:
1178 raise ValueError, 'node with key "%s" exists'%value
1180 # try to handle this property
1181 try:
1182 prop = self.properties[key]
1183 except KeyError:
1184 raise KeyError, '"%s" has no property "%s"'%(self.classname,
1185 key)
1187 if value is not None and isinstance(prop, Link):
1188 if type(value) != type(''):
1189 raise ValueError, 'link value must be String'
1190 link_class = self.properties[key].classname
1191 # if it isn't a number, it's a key
1192 if not num_re.match(value):
1193 try:
1194 value = self.db.classes[link_class].lookup(value)
1195 except (TypeError, KeyError):
1196 raise IndexError, 'new property "%s": %s not a %s'%(
1197 key, value, link_class)
1198 elif not self.db.getclass(link_class).hasnode(value):
1199 raise IndexError, '%s has no node %s'%(link_class, value)
1201 # save off the value
1202 propvalues[key] = value
1204 # register the link with the newly linked node
1205 if self.do_journal and self.properties[key].do_journal:
1206 self.db.addjournal(link_class, value, 'link',
1207 (self.classname, newid, key))
1209 elif isinstance(prop, Multilink):
1210 if type(value) != type([]):
1211 raise TypeError, 'new property "%s" not a list of ids'%key
1213 # clean up and validate the list of links
1214 link_class = self.properties[key].classname
1215 l = []
1216 for entry in value:
1217 if type(entry) != type(''):
1218 raise ValueError, '"%s" multilink value (%r) '\
1219 'must contain Strings'%(key, value)
1220 # if it isn't a number, it's a key
1221 if not num_re.match(entry):
1222 try:
1223 entry = self.db.classes[link_class].lookup(entry)
1224 except (TypeError, KeyError):
1225 raise IndexError, 'new property "%s": %s not a %s'%(
1226 key, entry, self.properties[key].classname)
1227 l.append(entry)
1228 value = l
1229 propvalues[key] = value
1231 # handle additions
1232 for nodeid in value:
1233 if not self.db.getclass(link_class).hasnode(nodeid):
1234 raise IndexError, '%s has no node %s'%(link_class,
1235 nodeid)
1236 # register the link with the newly linked node
1237 if self.do_journal and self.properties[key].do_journal:
1238 self.db.addjournal(link_class, nodeid, 'link',
1239 (self.classname, newid, key))
1241 elif isinstance(prop, String):
1242 if type(value) != type('') and type(value) != type(u''):
1243 raise TypeError, 'new property "%s" not a string'%key
1245 elif isinstance(prop, Password):
1246 if not isinstance(value, password.Password):
1247 raise TypeError, 'new property "%s" not a Password'%key
1249 elif isinstance(prop, Date):
1250 if value is not None and not isinstance(value, date.Date):
1251 raise TypeError, 'new property "%s" not a Date'%key
1253 elif isinstance(prop, Interval):
1254 if value is not None and not isinstance(value, date.Interval):
1255 raise TypeError, 'new property "%s" not an Interval'%key
1257 elif value is not None and isinstance(prop, Number):
1258 try:
1259 float(value)
1260 except ValueError:
1261 raise TypeError, 'new property "%s" not numeric'%key
1263 elif value is not None and isinstance(prop, Boolean):
1264 try:
1265 int(value)
1266 except ValueError:
1267 raise TypeError, 'new property "%s" not boolean'%key
1269 # make sure there's data where there needs to be
1270 for key, prop in self.properties.items():
1271 if propvalues.has_key(key):
1272 continue
1273 if key == self.key:
1274 raise ValueError, 'key property "%s" is required'%key
1275 if isinstance(prop, Multilink):
1276 propvalues[key] = []
1277 else:
1278 propvalues[key] = None
1280 # done
1281 self.db.addnode(self.classname, newid, propvalues)
1282 if self.do_journal:
1283 self.db.addjournal(self.classname, newid, 'create', {})
1285 return newid
1287 def export_list(self, propnames, nodeid):
1288 ''' Export a node - generate a list of CSV-able data in the order
1289 specified by propnames for the given node.
1290 '''
1291 properties = self.getprops()
1292 l = []
1293 for prop in propnames:
1294 proptype = properties[prop]
1295 value = self.get(nodeid, prop)
1296 # "marshal" data where needed
1297 if value is None:
1298 pass
1299 elif isinstance(proptype, hyperdb.Date):
1300 value = value.get_tuple()
1301 elif isinstance(proptype, hyperdb.Interval):
1302 value = value.get_tuple()
1303 elif isinstance(proptype, hyperdb.Password):
1304 value = str(value)
1305 l.append(repr(value))
1306 l.append(repr(self.is_retired(nodeid)))
1307 return l
1309 def import_list(self, propnames, proplist):
1310 ''' Import a node - all information including "id" is present and
1311 should not be sanity checked. Triggers are not triggered. The
1312 journal should be initialised using the "creator" and "created"
1313 information.
1315 Return the nodeid of the node imported.
1316 '''
1317 if self.db.journaltag is None:
1318 raise DatabaseError, 'Database open read-only'
1319 properties = self.getprops()
1321 # make the new node's property map
1322 d = {}
1323 retire = 0
1324 newid = None
1325 for i in range(len(propnames)):
1326 # Use eval to reverse the repr() used to output the CSV
1327 value = eval(proplist[i])
1329 # Figure the property for this column
1330 propname = propnames[i]
1332 # "unmarshal" where necessary
1333 if propname == 'id':
1334 newid = value
1335 continue
1336 elif propname == 'is retired':
1337 # is the item retired?
1338 if int(value):
1339 retire = 1
1340 continue
1341 elif value is None:
1342 d[propname] = None
1343 continue
1345 prop = properties[propname]
1346 if value is None:
1347 # don't set Nones
1348 continue
1349 elif isinstance(prop, hyperdb.Date):
1350 value = date.Date(value)
1351 elif isinstance(prop, hyperdb.Interval):
1352 value = date.Interval(value)
1353 elif isinstance(prop, hyperdb.Password):
1354 pwd = password.Password()
1355 pwd.unpack(value)
1356 value = pwd
1357 d[propname] = value
1359 # get a new id if necessary
1360 if newid is None:
1361 newid = self.db.newid(self.classname)
1363 # add the node and journal
1364 self.db.addnode(self.classname, newid, d)
1366 # retire?
1367 if retire:
1368 # use the arg for __retired__ to cope with any odd database type
1369 # conversion (hello, sqlite)
1370 sql = 'update _%s set __retired__=%s where id=%s'%(self.classname,
1371 self.db.arg, self.db.arg)
1372 if __debug__:
1373 print >>hyperdb.DEBUG, 'retire', (self, sql, newid)
1374 self.db.cursor.execute(sql, (1, newid))
1376 # extract the extraneous journalling gumpf and nuke it
1377 if d.has_key('creator'):
1378 creator = d['creator']
1379 del d['creator']
1380 else:
1381 creator = None
1382 if d.has_key('creation'):
1383 creation = d['creation']
1384 del d['creation']
1385 else:
1386 creation = None
1387 if d.has_key('activity'):
1388 del d['activity']
1389 if d.has_key('actor'):
1390 del d['actor']
1391 self.db.addjournal(self.classname, newid, 'create', {}, creator,
1392 creation)
1393 return newid
1395 _marker = []
1396 def get(self, nodeid, propname, default=_marker, cache=1):
1397 '''Get the value of a property on an existing node of this class.
1399 'nodeid' must be the id of an existing node of this class or an
1400 IndexError is raised. 'propname' must be the name of a property
1401 of this class or a KeyError is raised.
1403 'cache' exists for backwards compatibility, and is not used.
1404 '''
1405 if propname == 'id':
1406 return nodeid
1408 # get the node's dict
1409 d = self.db.getnode(self.classname, nodeid)
1411 if propname == 'creation':
1412 if d.has_key('creation'):
1413 return d['creation']
1414 else:
1415 return date.Date()
1416 if propname == 'activity':
1417 if d.has_key('activity'):
1418 return d['activity']
1419 else:
1420 return date.Date()
1421 if propname == 'creator':
1422 if d.has_key('creator'):
1423 return d['creator']
1424 else:
1425 return self.db.getuid()
1426 if propname == 'actor':
1427 if d.has_key('actor'):
1428 return d['actor']
1429 else:
1430 return self.db.getuid()
1432 # get the property (raises KeyErorr if invalid)
1433 prop = self.properties[propname]
1435 if not d.has_key(propname):
1436 if default is self._marker:
1437 if isinstance(prop, Multilink):
1438 return []
1439 else:
1440 return None
1441 else:
1442 return default
1444 # don't pass our list to other code
1445 if isinstance(prop, Multilink):
1446 return d[propname][:]
1448 return d[propname]
1450 def set(self, nodeid, **propvalues):
1451 '''Modify a property on an existing node of this class.
1453 'nodeid' must be the id of an existing node of this class or an
1454 IndexError is raised.
1456 Each key in 'propvalues' must be the name of a property of this
1457 class or a KeyError is raised.
1459 All values in 'propvalues' must be acceptable types for their
1460 corresponding properties or a TypeError is raised.
1462 If the value of the key property is set, it must not collide with
1463 other key strings or a ValueError is raised.
1465 If the value of a Link or Multilink property contains an invalid
1466 node id, a ValueError is raised.
1467 '''
1468 if not propvalues:
1469 return propvalues
1471 if propvalues.has_key('creation') or propvalues.has_key('creator') or \
1472 propvalues.has_key('actor') or propvalues.has_key('activity'):
1473 raise KeyError, '"creation", "creator", "actor" and '\
1474 '"activity" are reserved'
1476 if propvalues.has_key('id'):
1477 raise KeyError, '"id" is reserved'
1479 if self.db.journaltag is None:
1480 raise DatabaseError, 'Database open read-only'
1482 self.fireAuditors('set', nodeid, propvalues)
1483 # Take a copy of the node dict so that the subsequent set
1484 # operation doesn't modify the oldvalues structure.
1485 # XXX used to try the cache here first
1486 oldvalues = copy.deepcopy(self.db.getnode(self.classname, nodeid))
1488 node = self.db.getnode(self.classname, nodeid)
1489 if self.is_retired(nodeid):
1490 raise IndexError, 'Requested item is retired'
1491 num_re = re.compile('^\d+$')
1493 # if the journal value is to be different, store it in here
1494 journalvalues = {}
1496 # remember the add/remove stuff for multilinks, making it easier
1497 # for the Database layer to do its stuff
1498 multilink_changes = {}
1500 for propname, value in propvalues.items():
1501 # check to make sure we're not duplicating an existing key
1502 if propname == self.key and node[propname] != value:
1503 try:
1504 self.lookup(value)
1505 except KeyError:
1506 pass
1507 else:
1508 raise ValueError, 'node with key "%s" exists'%value
1510 # this will raise the KeyError if the property isn't valid
1511 # ... we don't use getprops() here because we only care about
1512 # the writeable properties.
1513 try:
1514 prop = self.properties[propname]
1515 except KeyError:
1516 raise KeyError, '"%s" has no property named "%s"'%(
1517 self.classname, propname)
1519 # if the value's the same as the existing value, no sense in
1520 # doing anything
1521 current = node.get(propname, None)
1522 if value == current:
1523 del propvalues[propname]
1524 continue
1525 journalvalues[propname] = current
1527 # do stuff based on the prop type
1528 if isinstance(prop, Link):
1529 link_class = prop.classname
1530 # if it isn't a number, it's a key
1531 if value is not None and not isinstance(value, type('')):
1532 raise ValueError, 'property "%s" link value be a string'%(
1533 propname)
1534 if isinstance(value, type('')) and not num_re.match(value):
1535 try:
1536 value = self.db.classes[link_class].lookup(value)
1537 except (TypeError, KeyError):
1538 raise IndexError, 'new property "%s": %s not a %s'%(
1539 propname, value, prop.classname)
1541 if (value is not None and
1542 not self.db.getclass(link_class).hasnode(value)):
1543 raise IndexError, '%s has no node %s'%(link_class, value)
1545 if self.do_journal and prop.do_journal:
1546 # register the unlink with the old linked node
1547 if node[propname] is not None:
1548 self.db.addjournal(link_class, node[propname], 'unlink',
1549 (self.classname, nodeid, propname))
1551 # register the link with the newly linked node
1552 if value is not None:
1553 self.db.addjournal(link_class, value, 'link',
1554 (self.classname, nodeid, propname))
1556 elif isinstance(prop, Multilink):
1557 if type(value) != type([]):
1558 raise TypeError, 'new property "%s" not a list of'\
1559 ' ids'%propname
1560 link_class = self.properties[propname].classname
1561 l = []
1562 for entry in value:
1563 # if it isn't a number, it's a key
1564 if type(entry) != type(''):
1565 raise ValueError, 'new property "%s" link value ' \
1566 'must be a string'%propname
1567 if not num_re.match(entry):
1568 try:
1569 entry = self.db.classes[link_class].lookup(entry)
1570 except (TypeError, KeyError):
1571 raise IndexError, 'new property "%s": %s not a %s'%(
1572 propname, entry,
1573 self.properties[propname].classname)
1574 l.append(entry)
1575 value = l
1576 propvalues[propname] = value
1578 # figure the journal entry for this property
1579 add = []
1580 remove = []
1582 # handle removals
1583 if node.has_key(propname):
1584 l = node[propname]
1585 else:
1586 l = []
1587 for id in l[:]:
1588 if id in value:
1589 continue
1590 # register the unlink with the old linked node
1591 if self.do_journal and self.properties[propname].do_journal:
1592 self.db.addjournal(link_class, id, 'unlink',
1593 (self.classname, nodeid, propname))
1594 l.remove(id)
1595 remove.append(id)
1597 # handle additions
1598 for id in value:
1599 if not self.db.getclass(link_class).hasnode(id):
1600 raise IndexError, '%s has no node %s'%(link_class, id)
1601 if id in l:
1602 continue
1603 # register the link with the newly linked node
1604 if self.do_journal and self.properties[propname].do_journal:
1605 self.db.addjournal(link_class, id, 'link',
1606 (self.classname, nodeid, propname))
1607 l.append(id)
1608 add.append(id)
1610 # figure the journal entry
1611 l = []
1612 if add:
1613 l.append(('+', add))
1614 if remove:
1615 l.append(('-', remove))
1616 multilink_changes[propname] = (add, remove)
1617 if l:
1618 journalvalues[propname] = tuple(l)
1620 elif isinstance(prop, String):
1621 if value is not None and type(value) != type('') and type(value) != type(u''):
1622 raise TypeError, 'new property "%s" not a string'%propname
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 self.fireReactors('set', nodeid, oldvalues)
1664 return propvalues
1666 def retire(self, nodeid):
1667 '''Retire a node.
1669 The properties on the node remain available from the get() method,
1670 and the node's id is never reused.
1672 Retired nodes are not returned by the find(), list(), or lookup()
1673 methods, and other nodes may reuse the values of their key properties.
1674 '''
1675 if self.db.journaltag is None:
1676 raise DatabaseError, 'Database open read-only'
1678 self.fireAuditors('retire', nodeid, None)
1680 # use the arg for __retired__ to cope with any odd database type
1681 # conversion (hello, sqlite)
1682 sql = 'update _%s set __retired__=%s where id=%s'%(self.classname,
1683 self.db.arg, self.db.arg)
1684 if __debug__:
1685 print >>hyperdb.DEBUG, 'retire', (self, sql, nodeid)
1686 self.db.cursor.execute(sql, (1, nodeid))
1687 if self.do_journal:
1688 self.db.addjournal(self.classname, nodeid, 'retired', None)
1690 self.fireReactors('retire', nodeid, None)
1692 def restore(self, nodeid):
1693 '''Restore a retired node.
1695 Make node available for all operations like it was before retirement.
1696 '''
1697 if self.db.journaltag is None:
1698 raise DatabaseError, 'Database open read-only'
1700 node = self.db.getnode(self.classname, nodeid)
1701 # check if key property was overrided
1702 key = self.getkey()
1703 try:
1704 id = self.lookup(node[key])
1705 except KeyError:
1706 pass
1707 else:
1708 raise KeyError, "Key property (%s) of retired node clashes with \
1709 existing one (%s)" % (key, node[key])
1711 self.fireAuditors('restore', nodeid, None)
1712 # use the arg for __retired__ to cope with any odd database type
1713 # conversion (hello, sqlite)
1714 sql = 'update _%s set __retired__=%s where id=%s'%(self.classname,
1715 self.db.arg, self.db.arg)
1716 if __debug__:
1717 print >>hyperdb.DEBUG, 'restore', (self, sql, nodeid)
1718 self.db.cursor.execute(sql, (0, nodeid))
1719 if self.do_journal:
1720 self.db.addjournal(self.classname, nodeid, 'restored', None)
1722 self.fireReactors('restore', nodeid, None)
1724 def is_retired(self, nodeid):
1725 '''Return true if the node is rerired
1726 '''
1727 sql = 'select __retired__ from _%s where id=%s'%(self.classname,
1728 self.db.arg)
1729 if __debug__:
1730 print >>hyperdb.DEBUG, 'is_retired', (self, sql, nodeid)
1731 self.db.cursor.execute(sql, (nodeid,))
1732 return int(self.db.sql_fetchone()[0])
1734 def destroy(self, nodeid):
1735 '''Destroy a node.
1737 WARNING: this method should never be used except in extremely rare
1738 situations where there could never be links to the node being
1739 deleted
1741 WARNING: use retire() instead
1743 WARNING: the properties of this node will not be available ever again
1745 WARNING: really, use retire() instead
1747 Well, I think that's enough warnings. This method exists mostly to
1748 support the session storage of the cgi interface.
1750 The node is completely removed from the hyperdb, including all journal
1751 entries. It will no longer be available, and will generally break code
1752 if there are any references to the node.
1753 '''
1754 if self.db.journaltag is None:
1755 raise DatabaseError, 'Database open read-only'
1756 self.db.destroynode(self.classname, nodeid)
1758 def history(self, nodeid):
1759 '''Retrieve the journal of edits on a particular node.
1761 'nodeid' must be the id of an existing node of this class or an
1762 IndexError is raised.
1764 The returned list contains tuples of the form
1766 (nodeid, date, tag, action, params)
1768 'date' is a Timestamp object specifying the time of the change and
1769 'tag' is the journaltag specified when the database was opened.
1770 '''
1771 if not self.do_journal:
1772 raise ValueError, 'Journalling is disabled for this class'
1773 return self.db.getjournal(self.classname, nodeid)
1775 # Locating nodes:
1776 def hasnode(self, nodeid):
1777 '''Determine if the given nodeid actually exists
1778 '''
1779 return self.db.hasnode(self.classname, nodeid)
1781 def setkey(self, propname):
1782 '''Select a String property of this class to be the key property.
1784 'propname' must be the name of a String property of this class or
1785 None, or a TypeError is raised. The values of the key property on
1786 all existing nodes must be unique or a ValueError is raised.
1787 '''
1788 # XXX create an index on the key prop column. We should also
1789 # record that we've created this index in the schema somewhere.
1790 prop = self.getprops()[propname]
1791 if not isinstance(prop, String):
1792 raise TypeError, 'key properties must be String'
1793 self.key = propname
1795 def getkey(self):
1796 '''Return the name of the key property for this class or None.'''
1797 return self.key
1799 def labelprop(self, default_to_id=0):
1800 '''Return the property name for a label for the given node.
1802 This method attempts to generate a consistent label for the node.
1803 It tries the following in order:
1805 1. key property
1806 2. "name" property
1807 3. "title" property
1808 4. first property from the sorted property name list
1809 '''
1810 k = self.getkey()
1811 if k:
1812 return k
1813 props = self.getprops()
1814 if props.has_key('name'):
1815 return 'name'
1816 elif props.has_key('title'):
1817 return 'title'
1818 if default_to_id:
1819 return 'id'
1820 props = props.keys()
1821 props.sort()
1822 return props[0]
1824 def lookup(self, keyvalue):
1825 '''Locate a particular node by its key property and return its id.
1827 If this class has no key property, a TypeError is raised. If the
1828 'keyvalue' matches one of the values for the key property among
1829 the nodes in this class, the matching node's id is returned;
1830 otherwise a KeyError is raised.
1831 '''
1832 if not self.key:
1833 raise TypeError, 'No key property set for class %s'%self.classname
1835 # use the arg to handle any odd database type conversion (hello,
1836 # sqlite)
1837 sql = "select id from _%s where _%s=%s and __retired__ <> %s"%(
1838 self.classname, self.key, self.db.arg, self.db.arg)
1839 self.db.sql(sql, (keyvalue, 1))
1841 # see if there was a result that's not retired
1842 row = self.db.sql_fetchone()
1843 if not row:
1844 raise KeyError, 'No key (%s) value "%s" for "%s"'%(self.key,
1845 keyvalue, self.classname)
1847 # return the id
1848 return row[0]
1850 def find(self, **propspec):
1851 '''Get the ids of nodes in this class which link to the given nodes.
1853 'propspec' consists of keyword args propname=nodeid or
1854 propname={nodeid:1, }
1855 'propname' must be the name of a property in this class, or a
1856 KeyError is raised. That property must be a Link or
1857 Multilink property, or a TypeError is raised.
1859 Any node in this class whose 'propname' property links to any of the
1860 nodeids will be returned. Used by the full text indexing, which knows
1861 that "foo" occurs in msg1, msg3 and file7, so we have hits on these
1862 issues:
1864 db.issue.find(messages={'1':1,'3':1}, files={'7':1})
1865 '''
1866 if __debug__:
1867 print >>hyperdb.DEBUG, 'find', (self, propspec)
1869 # shortcut
1870 if not propspec:
1871 return []
1873 # validate the args
1874 props = self.getprops()
1875 propspec = propspec.items()
1876 for propname, nodeids in propspec:
1877 # check the prop is OK
1878 prop = props[propname]
1879 if not isinstance(prop, Link) and not isinstance(prop, Multilink):
1880 raise TypeError, "'%s' not a Link/Multilink property"%propname
1882 # first, links
1883 a = self.db.arg
1884 allvalues = (1,)
1885 o = []
1886 where = []
1887 for prop, values in propspec:
1888 if not isinstance(props[prop], hyperdb.Link):
1889 continue
1890 if type(values) is type({}) and len(values) == 1:
1891 values = values.keys()[0]
1892 if type(values) is type(''):
1893 allvalues += (values,)
1894 where.append('_%s = %s'%(prop, a))
1895 elif values is None:
1896 where.append('_%s is NULL'%prop)
1897 else:
1898 allvalues += tuple(values.keys())
1899 where.append('_%s in (%s)'%(prop, ','.join([a]*len(values))))
1900 tables = ['_%s'%self.classname]
1901 if where:
1902 o.append('(' + ' and '.join(where) + ')')
1904 # now multilinks
1905 for prop, values in propspec:
1906 if not isinstance(props[prop], hyperdb.Multilink):
1907 continue
1908 if not values:
1909 continue
1910 if type(values) is type(''):
1911 allvalues += (values,)
1912 s = a
1913 else:
1914 allvalues += tuple(values.keys())
1915 s = ','.join([a]*len(values))
1916 tn = '%s_%s'%(self.classname, prop)
1917 tables.append(tn)
1918 o.append('(id=%s.nodeid and %s.linkid in (%s))'%(tn, tn, s))
1920 if not o:
1921 return []
1922 elif len(o) > 1:
1923 o = '(' + ' or '.join(['(%s)'%i for i in o]) + ')'
1924 else:
1925 o = o[0]
1926 t = ', '.join(tables)
1927 sql = 'select distinct(id) from %s where __retired__ <> %s and %s'%(t, a, o)
1928 self.db.sql(sql, allvalues)
1929 l = [x[0] for x in self.db.sql_fetchall()]
1930 if __debug__:
1931 print >>hyperdb.DEBUG, 'find ... ', l
1932 return l
1934 def stringFind(self, **requirements):
1935 '''Locate a particular node by matching a set of its String
1936 properties in a caseless search.
1938 If the property is not a String property, a TypeError is raised.
1940 The return is a list of the id of all nodes that match.
1941 '''
1942 where = []
1943 args = []
1944 for propname in requirements.keys():
1945 prop = self.properties[propname]
1946 if not isinstance(prop, String):
1947 raise TypeError, "'%s' not a String property"%propname
1948 where.append(propname)
1949 args.append(requirements[propname].lower())
1951 # generate the where clause
1952 s = ' and '.join(['lower(_%s)=%s'%(col, self.db.arg) for col in where])
1953 sql = 'select id from _%s where %s and __retired__=%s'%(self.classname,
1954 s, self.db.arg)
1955 args.append(0)
1956 self.db.sql(sql, tuple(args))
1957 l = [x[0] for x in self.db.sql_fetchall()]
1958 if __debug__:
1959 print >>hyperdb.DEBUG, 'find ... ', l
1960 return l
1962 def list(self):
1963 ''' Return a list of the ids of the active nodes in this class.
1964 '''
1965 return self.getnodeids(retired=0)
1967 def getnodeids(self, retired=None):
1968 ''' Retrieve all the ids of the nodes for a particular Class.
1970 Set retired=None to get all nodes. Otherwise it'll get all the
1971 retired or non-retired nodes, depending on the flag.
1972 '''
1973 # flip the sense of the 'retired' flag if we don't want all of them
1974 if retired is not None:
1975 if retired:
1976 args = (0, )
1977 else:
1978 args = (1, )
1979 sql = 'select id from _%s where __retired__ <> %s'%(self.classname,
1980 self.db.arg)
1981 else:
1982 args = ()
1983 sql = 'select id from _%s'%self.classname
1984 if __debug__:
1985 print >>hyperdb.DEBUG, 'getnodeids', (self, sql, retired)
1986 self.db.cursor.execute(sql, args)
1987 ids = [x[0] for x in self.db.cursor.fetchall()]
1988 return ids
1990 def filter(self, search_matches, filterspec, sort=(None,None),
1991 group=(None,None)):
1992 '''Return a list of the ids of the active nodes in this class that
1993 match the 'filter' spec, sorted by the group spec and then the
1994 sort spec
1996 "filterspec" is {propname: value(s)}
1998 "sort" and "group" are (dir, prop) where dir is '+', '-' or None
1999 and prop is a prop name or None
2001 "search_matches" is {nodeid: marker}
2003 The filter must match all properties specificed - but if the
2004 property value to match is a list, any one of the values in the
2005 list may match for that property to match.
2006 '''
2007 # just don't bother if the full-text search matched diddly
2008 if search_matches == {}:
2009 return []
2011 cn = self.classname
2013 timezone = self.db.getUserTimezone()
2015 # figure the WHERE clause from the filterspec
2016 props = self.getprops()
2017 frum = ['_'+cn]
2018 where = []
2019 args = []
2020 a = self.db.arg
2021 for k, v in filterspec.items():
2022 propclass = props[k]
2023 # now do other where clause stuff
2024 if isinstance(propclass, Multilink):
2025 tn = '%s_%s'%(cn, k)
2026 if v in ('-1', ['-1']):
2027 # only match rows that have count(linkid)=0 in the
2028 # corresponding multilink table)
2029 where.append('id not in (select nodeid from %s)'%tn)
2030 elif isinstance(v, type([])):
2031 frum.append(tn)
2032 s = ','.join([a for x in v])
2033 where.append('id=%s.nodeid and %s.linkid in (%s)'%(tn,tn,s))
2034 args = args + v
2035 else:
2036 frum.append(tn)
2037 where.append('id=%s.nodeid and %s.linkid=%s'%(tn, tn, a))
2038 args.append(v)
2039 elif k == 'id':
2040 if isinstance(v, type([])):
2041 s = ','.join([a for x in v])
2042 where.append('%s in (%s)'%(k, s))
2043 args = args + v
2044 else:
2045 where.append('%s=%s'%(k, a))
2046 args.append(v)
2047 elif isinstance(propclass, String):
2048 if not isinstance(v, type([])):
2049 v = [v]
2051 # Quote the bits in the string that need it and then embed
2052 # in a "substring" search. Note - need to quote the '%' so
2053 # they make it through the python layer happily
2054 v = ['%%'+self.db.sql_stringquote(s)+'%%' for s in v]
2056 # now add to the where clause
2057 where.append(' or '.join(["_%s LIKE '%s'"%(k, s) for s in v]))
2058 # note: args are embedded in the query string now
2059 elif isinstance(propclass, Link):
2060 if isinstance(v, type([])):
2061 if '-1' in v:
2062 v = v[:]
2063 v.remove('-1')
2064 xtra = ' or _%s is NULL'%k
2065 else:
2066 xtra = ''
2067 if v:
2068 s = ','.join([a for x in v])
2069 where.append('(_%s in (%s)%s)'%(k, s, xtra))
2070 args = args + v
2071 else:
2072 where.append('_%s is NULL'%k)
2073 else:
2074 if v == '-1':
2075 v = None
2076 where.append('_%s is NULL'%k)
2077 else:
2078 where.append('_%s=%s'%(k, a))
2079 args.append(v)
2080 elif isinstance(propclass, Date):
2081 if isinstance(v, type([])):
2082 s = ','.join([a for x in v])
2083 where.append('_%s in (%s)'%(k, s))
2084 args = args + [date.Date(x).serialise() for x in v]
2085 else:
2086 try:
2087 # Try to filter on range of dates
2088 date_rng = Range(v, date.Date, offset=timezone)
2089 if (date_rng.from_value):
2090 where.append('_%s >= %s'%(k, a))
2091 args.append(date_rng.from_value.serialise())
2092 if (date_rng.to_value):
2093 where.append('_%s <= %s'%(k, a))
2094 args.append(date_rng.to_value.serialise())
2095 except ValueError:
2096 # If range creation fails - ignore that search parameter
2097 pass
2098 elif isinstance(propclass, Interval):
2099 if isinstance(v, type([])):
2100 s = ','.join([a for x in v])
2101 where.append('_%s in (%s)'%(k, s))
2102 args = args + [date.Interval(x).serialise() for x in v]
2103 else:
2104 try:
2105 # Try to filter on range of intervals
2106 date_rng = Range(v, date.Interval)
2107 if (date_rng.from_value):
2108 where.append('_%s >= %s'%(k, a))
2109 args.append(date_rng.from_value.serialise())
2110 if (date_rng.to_value):
2111 where.append('_%s <= %s'%(k, a))
2112 args.append(date_rng.to_value.serialise())
2113 except ValueError:
2114 # If range creation fails - ignore that search parameter
2115 pass
2116 #where.append('_%s=%s'%(k, a))
2117 #args.append(date.Interval(v).serialise())
2118 else:
2119 if isinstance(v, type([])):
2120 s = ','.join([a for x in v])
2121 where.append('_%s in (%s)'%(k, s))
2122 args = args + v
2123 else:
2124 where.append('_%s=%s'%(k, a))
2125 args.append(v)
2127 # don't match retired nodes
2128 where.append('__retired__ <> 1')
2130 # add results of full text search
2131 if search_matches is not None:
2132 v = search_matches.keys()
2133 s = ','.join([a for x in v])
2134 where.append('id in (%s)'%s)
2135 args = args + v
2137 # "grouping" is just the first-order sorting in the SQL fetch
2138 # can modify it...)
2139 orderby = []
2140 ordercols = []
2141 if group[0] is not None and group[1] is not None:
2142 if group[0] != '-':
2143 orderby.append('_'+group[1])
2144 ordercols.append('_'+group[1])
2145 else:
2146 orderby.append('_'+group[1]+' desc')
2147 ordercols.append('_'+group[1])
2149 # now add in the sorting
2150 group = ''
2151 if sort[0] is not None and sort[1] is not None:
2152 direction, colname = sort
2153 if direction != '-':
2154 if colname == 'id':
2155 orderby.append(colname)
2156 else:
2157 orderby.append('_'+colname)
2158 ordercols.append('_'+colname)
2159 else:
2160 if colname == 'id':
2161 orderby.append(colname+' desc')
2162 ordercols.append(colname)
2163 else:
2164 orderby.append('_'+colname+' desc')
2165 ordercols.append('_'+colname)
2167 # construct the SQL
2168 frum = ','.join(frum)
2169 if where:
2170 where = ' where ' + (' and '.join(where))
2171 else:
2172 where = ''
2173 cols = ['id']
2174 if orderby:
2175 cols = cols + ordercols
2176 order = ' order by %s'%(','.join(orderby))
2177 else:
2178 order = ''
2179 cols = ','.join(cols)
2180 sql = 'select %s from %s %s%s%s'%(cols, frum, where, group, order)
2181 args = tuple(args)
2182 if __debug__:
2183 print >>hyperdb.DEBUG, 'filter', (self, sql, args)
2184 if args:
2185 self.db.cursor.execute(sql, args)
2186 else:
2187 # psycopg doesn't like empty args
2188 self.db.cursor.execute(sql)
2189 l = self.db.sql_fetchall()
2191 # return the IDs (the first column)
2192 return [row[0] for row in l]
2194 def count(self):
2195 '''Get the number of nodes in this class.
2197 If the returned integer is 'numnodes', the ids of all the nodes
2198 in this class run from 1 to numnodes, and numnodes+1 will be the
2199 id of the next node to be created in this class.
2200 '''
2201 return self.db.countnodes(self.classname)
2203 # Manipulating properties:
2204 def getprops(self, protected=1):
2205 '''Return a dictionary mapping property names to property objects.
2206 If the "protected" flag is true, we include protected properties -
2207 those which may not be modified.
2208 '''
2209 d = self.properties.copy()
2210 if protected:
2211 d['id'] = String()
2212 d['creation'] = hyperdb.Date()
2213 d['activity'] = hyperdb.Date()
2214 d['creator'] = hyperdb.Link('user')
2215 d['actor'] = hyperdb.Link('user')
2216 return d
2218 def addprop(self, **properties):
2219 '''Add properties to this class.
2221 The keyword arguments in 'properties' must map names to property
2222 objects, or a TypeError is raised. None of the keys in 'properties'
2223 may collide with the names of existing properties, or a ValueError
2224 is raised before any properties have been added.
2225 '''
2226 for key in properties.keys():
2227 if self.properties.has_key(key):
2228 raise ValueError, key
2229 self.properties.update(properties)
2231 def index(self, nodeid):
2232 '''Add (or refresh) the node to search indexes
2233 '''
2234 # find all the String properties that have indexme
2235 for prop, propclass in self.getprops().items():
2236 if isinstance(propclass, String) and propclass.indexme:
2237 try:
2238 value = str(self.get(nodeid, prop))
2239 except IndexError:
2240 # node no longer exists - entry should be removed
2241 self.db.indexer.purge_entry((self.classname, nodeid, prop))
2242 else:
2243 # and index them under (classname, nodeid, property)
2244 self.db.indexer.add_text((self.classname, nodeid, prop),
2245 value)
2248 #
2249 # Detector interface
2250 #
2251 def audit(self, event, detector):
2252 '''Register a detector
2253 '''
2254 l = self.auditors[event]
2255 if detector not in l:
2256 self.auditors[event].append(detector)
2258 def fireAuditors(self, action, nodeid, newvalues):
2259 '''Fire all registered auditors.
2260 '''
2261 for audit in self.auditors[action]:
2262 audit(self.db, self, nodeid, newvalues)
2264 def react(self, event, detector):
2265 '''Register a detector
2266 '''
2267 l = self.reactors[event]
2268 if detector not in l:
2269 self.reactors[event].append(detector)
2271 def fireReactors(self, action, nodeid, oldvalues):
2272 '''Fire all registered reactors.
2273 '''
2274 for react in self.reactors[action]:
2275 react(self.db, self, nodeid, oldvalues)
2277 class FileClass(Class, hyperdb.FileClass):
2278 '''This class defines a large chunk of data. To support this, it has a
2279 mandatory String property "content" which is typically saved off
2280 externally to the hyperdb.
2282 The default MIME type of this data is defined by the
2283 "default_mime_type" class attribute, which may be overridden by each
2284 node if the class defines a "type" String property.
2285 '''
2286 default_mime_type = 'text/plain'
2288 def create(self, **propvalues):
2289 ''' snaffle the file propvalue and store in a file
2290 '''
2291 # we need to fire the auditors now, or the content property won't
2292 # be in propvalues for the auditors to play with
2293 self.fireAuditors('create', None, propvalues)
2295 # now remove the content property so it's not stored in the db
2296 content = propvalues['content']
2297 del propvalues['content']
2299 # do the database create
2300 newid = Class.create_inner(self, **propvalues)
2302 # fire reactors
2303 self.fireReactors('create', newid, None)
2305 # store off the content as a file
2306 self.db.storefile(self.classname, newid, None, content)
2307 return newid
2309 def import_list(self, propnames, proplist):
2310 ''' Trap the "content" property...
2311 '''
2312 # dupe this list so we don't affect others
2313 propnames = propnames[:]
2315 # extract the "content" property from the proplist
2316 i = propnames.index('content')
2317 content = eval(proplist[i])
2318 del propnames[i]
2319 del proplist[i]
2321 # do the normal import
2322 newid = Class.import_list(self, propnames, proplist)
2324 # save off the "content" file
2325 self.db.storefile(self.classname, newid, None, content)
2326 return newid
2328 _marker = []
2329 def get(self, nodeid, propname, default=_marker, cache=1):
2330 ''' Trap the content propname and get it from the file
2332 'cache' exists for backwards compatibility, and is not used.
2333 '''
2334 poss_msg = 'Possibly a access right configuration problem.'
2335 if propname == 'content':
2336 try:
2337 return self.db.getfile(self.classname, nodeid, None)
2338 except IOError, (strerror):
2339 # BUG: by catching this we donot see an error in the log.
2340 return 'ERROR reading file: %s%s\n%s\n%s'%(
2341 self.classname, nodeid, poss_msg, strerror)
2342 if default is not self._marker:
2343 return Class.get(self, nodeid, propname, default)
2344 else:
2345 return Class.get(self, nodeid, propname)
2347 def getprops(self, protected=1):
2348 ''' In addition to the actual properties on the node, these methods
2349 provide the "content" property. If the "protected" flag is true,
2350 we include protected properties - those which may not be
2351 modified.
2352 '''
2353 d = Class.getprops(self, protected=protected).copy()
2354 d['content'] = hyperdb.String()
2355 return d
2357 def index(self, nodeid):
2358 ''' Index the node in the search index.
2360 We want to index the content in addition to the normal String
2361 property indexing.
2362 '''
2363 # perform normal indexing
2364 Class.index(self, nodeid)
2366 # get the content to index
2367 content = self.get(nodeid, 'content')
2369 # figure the mime type
2370 if self.properties.has_key('type'):
2371 mime_type = self.get(nodeid, 'type')
2372 else:
2373 mime_type = self.default_mime_type
2375 # and index!
2376 self.db.indexer.add_text((self.classname, nodeid, 'content'), content,
2377 mime_type)
2379 # XXX deviation from spec - was called ItemClass
2380 class IssueClass(Class, roundupdb.IssueClass):
2381 # Overridden methods:
2382 def __init__(self, db, classname, **properties):
2383 '''The newly-created class automatically includes the "messages",
2384 "files", "nosy", and "superseder" properties. If the 'properties'
2385 dictionary attempts to specify any of these properties or a
2386 "creation", "creator", "activity" or "actor" property, a ValueError
2387 is raised.
2388 '''
2389 if not properties.has_key('title'):
2390 properties['title'] = hyperdb.String(indexme='yes')
2391 if not properties.has_key('messages'):
2392 properties['messages'] = hyperdb.Multilink("msg")
2393 if not properties.has_key('files'):
2394 properties['files'] = hyperdb.Multilink("file")
2395 if not properties.has_key('nosy'):
2396 # note: journalling is turned off as it really just wastes
2397 # space. this behaviour may be overridden in an instance
2398 properties['nosy'] = hyperdb.Multilink("user", do_journal="no")
2399 if not properties.has_key('superseder'):
2400 properties['superseder'] = hyperdb.Multilink(classname)
2401 Class.__init__(self, db, classname, **properties)