2d030365750679496c7a8925c1e5468814cbfc81
1 # $Id: rdbms_common.py,v 1.80 2004-03-17 22:01:37 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 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.sessions = Sessions(self.config)
64 self.otks = OneTimeKeys(self.config)
65 self.security = security.Security(self)
67 # additional transaction support for external files and the like
68 self.transactions = []
70 # keep a cache of the N most recently retrieved rows of any kind
71 # (classname, nodeid) = row
72 self.cache = {}
73 self.cache_lru = []
75 # database lock
76 self.lockfile = None
78 # open a connection to the database, creating the "conn" attribute
79 self.sql_open_connection()
81 def clearCache(self):
82 self.cache = {}
83 self.cache_lru = []
85 def sql_open_connection(self):
86 ''' Open a connection to the database, creating it if necessary.
88 Must call self.load_dbschema()
89 '''
90 raise NotImplemented
92 def sql(self, sql, args=None):
93 ''' Execute the sql with the optional args.
94 '''
95 if __debug__:
96 print >>hyperdb.DEBUG, (self, sql, args)
97 if args:
98 self.cursor.execute(sql, args)
99 else:
100 self.cursor.execute(sql)
102 def sql_fetchone(self):
103 ''' Fetch a single row. If there's nothing to fetch, return None.
104 '''
105 return self.cursor.fetchone()
107 def sql_fetchall(self):
108 ''' Fetch all rows. If there's nothing to fetch, return [].
109 '''
110 return self.cursor.fetchall()
112 def sql_stringquote(self, value):
113 ''' Quote the string so it's safe to put in the 'sql quotes'
114 '''
115 return re.sub("'", "''", str(value))
117 def init_dbschema(self):
118 self.database_schema = {
119 'version': self.current_db_version,
120 'tables': {}
121 }
123 def load_dbschema(self):
124 ''' Load the schema definition that the database currently implements
125 '''
126 self.cursor.execute('select schema from schema')
127 schema = self.cursor.fetchone()
128 if schema:
129 self.database_schema = eval(schema[0])
130 else:
131 self.database_schema = {}
133 def save_dbschema(self, schema):
134 ''' Save the schema definition that the database currently implements
135 '''
136 s = repr(self.database_schema)
137 self.sql('insert into schema values (%s)', (s,))
139 def post_init(self):
140 ''' Called once the schema initialisation has finished.
142 We should now confirm that the schema defined by our "classes"
143 attribute actually matches the schema in the database.
144 '''
145 save = self.upgrade_db()
147 # now detect changes in the schema
148 tables = self.database_schema['tables']
149 for classname, spec in self.classes.items():
150 if tables.has_key(classname):
151 dbspec = tables[classname]
152 if self.update_class(spec, dbspec):
153 tables[classname] = spec.schema()
154 save = 1
155 else:
156 self.create_class(spec)
157 tables[classname] = spec.schema()
158 save = 1
160 for classname, spec in tables.items():
161 if not self.classes.has_key(classname):
162 self.drop_class(classname, tables[classname])
163 del tables[classname]
164 save = 1
166 # update the database version of the schema
167 if save:
168 self.sql('delete from schema')
169 self.save_dbschema(self.database_schema)
171 # reindex the db if necessary
172 if self.indexer.should_reindex():
173 self.reindex()
175 # commit
176 self.conn.commit()
178 # update this number when we need to make changes to the SQL structure
179 # of the backen database
180 current_db_version = 2
181 def upgrade_db(self):
182 ''' Update the SQL database to reflect changes in the backend code.
184 Return boolean whether we need to save the schema.
185 '''
186 version = self.database_schema.get('version', 1)
187 if version == self.current_db_version:
188 # nothing to do
189 return 0
191 if version == 1:
192 # version 1 doesn't have the OTK, session and indexing in the
193 # database
194 self.create_version_2_tables()
195 # version 1 also didn't have the actor column
196 self.add_actor_column()
198 self.database_schema['version'] = self.current_db_version
199 return 1
202 def refresh_database(self):
203 self.post_init()
205 def reindex(self):
206 for klass in self.classes.values():
207 for nodeid in klass.list():
208 klass.index(nodeid)
209 self.indexer.save_index()
211 def determine_columns(self, properties):
212 ''' Figure the column names and multilink properties from the spec
214 "properties" is a list of (name, prop) where prop may be an
215 instance of a hyperdb "type" _or_ a string repr of that type.
216 '''
217 cols = ['_actor', '_activity', '_creator', '_creation']
218 mls = []
219 # add the multilinks separately
220 for col, prop in properties:
221 if isinstance(prop, Multilink):
222 mls.append(col)
223 elif isinstance(prop, type('')) and prop.find('Multilink') != -1:
224 mls.append(col)
225 else:
226 cols.append('_'+col)
227 cols.sort()
228 return cols, mls
230 def update_class(self, spec, old_spec, force=0):
231 ''' Determine the differences between the current spec and the
232 database version of the spec, and update where necessary.
234 If 'force' is true, update the database anyway.
235 '''
236 new_has = spec.properties.has_key
237 new_spec = spec.schema()
238 new_spec[1].sort()
239 old_spec[1].sort()
240 if not force and new_spec == old_spec:
241 # no changes
242 return 0
244 if __debug__:
245 print >>hyperdb.DEBUG, 'update_class FIRING'
247 # detect key prop change for potential index change
248 keyprop_changes = 0
249 if new_spec[0] != old_spec[0]:
250 keyprop_changes = {'remove': old_spec[0], 'add': new_spec[0]}
252 # detect multilinks that have been removed, and drop their table
253 old_has = {}
254 for name, prop in old_spec[1]:
255 old_has[name] = 1
256 if new_has(name):
257 continue
259 if isinstance(prop, Multilink):
260 # first drop indexes.
261 self.drop_multilink_table_indexes(spec.classname, ml)
263 # now the multilink table itself
264 sql = 'drop table %s_%s'%(spec.classname, prop)
265 else:
266 # if this is the key prop, drop the index first
267 if old_spec[0] == prop:
268 self.drop_class_table_key_index(spec.classname, prop)
269 del keyprop_changes['remove']
271 # drop the column
272 sql = 'alter table _%s drop column _%s'%(spec.classname, prop)
274 if __debug__:
275 print >>hyperdb.DEBUG, 'update_class', (self, sql)
276 self.cursor.execute(sql)
277 old_has = old_has.has_key
279 # if we didn't remove the key prop just then, but the key prop has
280 # changed, we still need to remove the old index
281 if keyprop_changes.has_key('remove'):
282 self.drop_class_table_key_index(spec.classname,
283 keyprop_changes['remove'])
285 # add new columns
286 for propname, x in new_spec[1]:
287 if old_has(propname):
288 continue
289 sql = 'alter table _%s add column _%s varchar(255)'%(
290 spec.classname, propname)
291 if __debug__:
292 print >>hyperdb.DEBUG, 'update_class', (self, sql)
293 self.cursor.execute(sql)
295 # if the new column is a key prop, we need an index!
296 if new_spec[0] == propname:
297 self.create_class_table_key_index(spec.classname, propname)
298 del keyprop_changes['add']
300 # if we didn't add the key prop just then, but the key prop has
301 # changed, we still need to add the new index
302 if keyprop_changes.has_key('add'):
303 self.create_class_table_key_index(spec.classname,
304 keyprop_changes['add'])
306 return 1
308 def create_class_table(self, spec):
309 ''' create the class table for the given spec
310 '''
311 cols, mls = self.determine_columns(spec.properties.items())
313 # add on our special columns
314 cols.append('id')
315 cols.append('__retired__')
317 # create the base table
318 scols = ','.join(['%s varchar'%x for x in cols])
319 sql = 'create table _%s (%s)'%(spec.classname, scols)
320 if __debug__:
321 print >>hyperdb.DEBUG, 'create_class', (self, sql)
322 self.cursor.execute(sql)
324 self.create_class_table_indexes(spec)
326 return cols, mls
328 def create_class_table_indexes(self, spec):
329 ''' create the class table for the given spec
330 '''
331 # create id index
332 index_sql1 = 'create index _%s_id_idx on _%s(id)'%(
333 spec.classname, spec.classname)
334 if __debug__:
335 print >>hyperdb.DEBUG, 'create_index', (self, index_sql1)
336 self.cursor.execute(index_sql1)
338 # create __retired__ index
339 index_sql2 = 'create index _%s_retired_idx on _%s(__retired__)'%(
340 spec.classname, spec.classname)
341 if __debug__:
342 print >>hyperdb.DEBUG, 'create_index', (self, index_sql2)
343 self.cursor.execute(index_sql2)
345 # create index for key property
346 if spec.key:
347 if __debug__:
348 print >>hyperdb.DEBUG, 'update_class setting keyprop %r'% \
349 spec.key
350 index_sql3 = 'create index _%s_%s_idx on _%s(_%s)'%(
351 spec.classname, spec.key,
352 spec.classname, spec.key)
353 if __debug__:
354 print >>hyperdb.DEBUG, 'create_index', (self, index_sql3)
355 self.cursor.execute(index_sql3)
357 def drop_class_table_indexes(self, cn, key):
358 # drop the old table indexes first
359 l = ['_%s_id_idx'%cn, '_%s_retired_idx'%cn]
360 if key:
361 l.append('_%s_%s_idx'%(cn, key))
363 table_name = '_%s'%cn
364 for index_name in l:
365 if not self.sql_index_exists(table_name, index_name):
366 continue
367 index_sql = 'drop index '+index_name
368 if __debug__:
369 print >>hyperdb.DEBUG, 'drop_index', (self, index_sql)
370 self.cursor.execute(index_sql)
372 def create_class_table_key_index(self, cn, key):
373 ''' create the class table for the given spec
374 '''
375 if __debug__:
376 print >>hyperdb.DEBUG, 'update_class setting keyprop %r'% \
377 key
378 index_sql3 = 'create index _%s_%s_idx on _%s(_%s)'%(cn, key,
379 cn, key)
380 if __debug__:
381 print >>hyperdb.DEBUG, 'create_index', (self, index_sql3)
382 self.cursor.execute(index_sql3)
384 def drop_class_table_key_index(self, cn, key):
385 table_name = '_%s'%cn
386 index_name = '_%s_%s_idx'%(cn, key)
387 if not self.sql_index_exists(table_name, index_name):
388 return
389 sql = 'drop index '+index_name
390 if __debug__:
391 print >>hyperdb.DEBUG, 'drop_index', (self, sql)
392 self.cursor.execute(sql)
394 def create_journal_table(self, spec):
395 ''' create the journal table for a class given the spec and
396 already-determined cols
397 '''
398 # journal table
399 cols = ','.join(['%s varchar'%x
400 for x in 'nodeid date tag action params'.split()])
401 sql = 'create table %s__journal (%s)'%(spec.classname, cols)
402 if __debug__:
403 print >>hyperdb.DEBUG, 'create_class', (self, sql)
404 self.cursor.execute(sql)
405 self.create_journal_table_indexes(spec)
407 def create_journal_table_indexes(self, spec):
408 # index on nodeid
409 sql = 'create index %s_journ_idx on %s__journal(nodeid)'%(
410 spec.classname, spec.classname)
411 if __debug__:
412 print >>hyperdb.DEBUG, 'create_index', (self, sql)
413 self.cursor.execute(sql)
415 def drop_journal_table_indexes(self, classname):
416 index_name = '%s_journ_idx'%classname
417 if not self.sql_index_exists('%s__journal'%classname, index_name):
418 return
419 index_sql = 'drop index '+index_name
420 if __debug__:
421 print >>hyperdb.DEBUG, 'drop_index', (self, index_sql)
422 self.cursor.execute(index_sql)
424 def create_multilink_table(self, spec, ml):
425 ''' Create a multilink table for the "ml" property of the class
426 given by the spec
427 '''
428 # create the table
429 sql = 'create table %s_%s (linkid varchar, nodeid varchar)'%(
430 spec.classname, ml)
431 if __debug__:
432 print >>hyperdb.DEBUG, 'create_class', (self, sql)
433 self.cursor.execute(sql)
434 self.create_multilink_table_indexes(spec, ml)
436 def create_multilink_table_indexes(self, spec, ml):
437 # create index on linkid
438 index_sql = 'create index %s_%s_l_idx on %s_%s(linkid)'%(
439 spec.classname, ml, spec.classname, ml)
440 if __debug__:
441 print >>hyperdb.DEBUG, 'create_index', (self, index_sql)
442 self.cursor.execute(index_sql)
444 # create index on nodeid
445 index_sql = 'create index %s_%s_n_idx on %s_%s(nodeid)'%(
446 spec.classname, ml, spec.classname, ml)
447 if __debug__:
448 print >>hyperdb.DEBUG, 'create_index', (self, index_sql)
449 self.cursor.execute(index_sql)
451 def drop_multilink_table_indexes(self, classname, ml):
452 l = [
453 '%s_%s_l_idx'%(classname, ml),
454 '%s_%s_n_idx'%(classname, ml)
455 ]
456 table_name = '%s_%s'%(classname, ml)
457 for index_name in l:
458 if not self.sql_index_exists(table_name, index_name):
459 continue
460 index_sql = 'drop index %s'%index_name
461 if __debug__:
462 print >>hyperdb.DEBUG, 'drop_index', (self, index_sql)
463 self.cursor.execute(index_sql)
465 def create_class(self, spec):
466 ''' Create a database table according to the given spec.
467 '''
468 cols, mls = self.create_class_table(spec)
469 self.create_journal_table(spec)
471 # now create the multilink tables
472 for ml in mls:
473 self.create_multilink_table(spec, ml)
475 # ID counter
476 sql = 'insert into ids (name, num) values (%s,%s)'%(self.arg, self.arg)
477 vals = (spec.classname, 1)
478 if __debug__:
479 print >>hyperdb.DEBUG, 'create_class', (self, sql, vals)
480 self.cursor.execute(sql, vals)
482 def drop_class(self, cn, spec):
483 ''' Drop the given table from the database.
485 Drop the journal and multilink tables too.
486 '''
487 properties = spec[1]
488 # figure the multilinks
489 mls = []
490 for propanme, prop in properties:
491 if isinstance(prop, Multilink):
492 mls.append(propname)
494 # drop class table and indexes
495 self.drop_class_table_indexes(cn, spec[0])
496 sql = 'drop table _%s'%cn
497 if __debug__:
498 print >>hyperdb.DEBUG, 'drop_class', (self, sql)
499 self.cursor.execute(sql)
501 # drop journal table and indexes
502 self.drop_journal_table_indexes(cn)
503 sql = 'drop table %s__journal'%cn
504 if __debug__:
505 print >>hyperdb.DEBUG, 'drop_class', (self, sql)
506 self.cursor.execute(sql)
508 for ml in mls:
509 # drop multilink table and indexes
510 self.drop_multilink_table_indexes(cn, ml)
511 sql = 'drop table %s_%s'%(spec.classname, ml)
512 if __debug__:
513 print >>hyperdb.DEBUG, 'drop_class', (self, sql)
514 self.cursor.execute(sql)
516 #
517 # Classes
518 #
519 def __getattr__(self, classname):
520 ''' A convenient way of calling self.getclass(classname).
521 '''
522 if self.classes.has_key(classname):
523 if __debug__:
524 print >>hyperdb.DEBUG, '__getattr__', (self, classname)
525 return self.classes[classname]
526 raise AttributeError, classname
528 def addclass(self, cl):
529 ''' Add a Class to the hyperdatabase.
530 '''
531 if __debug__:
532 print >>hyperdb.DEBUG, 'addclass', (self, cl)
533 cn = cl.classname
534 if self.classes.has_key(cn):
535 raise ValueError, cn
536 self.classes[cn] = cl
538 # add default Edit and View permissions
539 self.security.addPermission(name="Edit", klass=cn,
540 description="User is allowed to edit "+cn)
541 self.security.addPermission(name="View", klass=cn,
542 description="User is allowed to access "+cn)
544 def getclasses(self):
545 ''' Return a list of the names of all existing classes.
546 '''
547 if __debug__:
548 print >>hyperdb.DEBUG, 'getclasses', (self,)
549 l = self.classes.keys()
550 l.sort()
551 return l
553 def getclass(self, classname):
554 '''Get the Class object representing a particular class.
556 If 'classname' is not a valid class name, a KeyError is raised.
557 '''
558 if __debug__:
559 print >>hyperdb.DEBUG, 'getclass', (self, classname)
560 try:
561 return self.classes[classname]
562 except KeyError:
563 raise KeyError, 'There is no class called "%s"'%classname
565 def clear(self):
566 '''Delete all database contents.
568 Note: I don't commit here, which is different behaviour to the
569 "nuke from orbit" behaviour in the dbs.
570 '''
571 if __debug__:
572 print >>hyperdb.DEBUG, 'clear', (self,)
573 for cn in self.classes.keys():
574 sql = 'delete from _%s'%cn
575 if __debug__:
576 print >>hyperdb.DEBUG, 'clear', (self, sql)
577 self.cursor.execute(sql)
579 #
580 # Node IDs
581 #
582 def newid(self, classname):
583 ''' Generate a new id for the given class
584 '''
585 # get the next ID
586 sql = 'select num from ids where name=%s'%self.arg
587 if __debug__:
588 print >>hyperdb.DEBUG, 'newid', (self, sql, classname)
589 self.cursor.execute(sql, (classname, ))
590 newid = self.cursor.fetchone()[0]
592 # update the counter
593 sql = 'update ids set num=%s where name=%s'%(self.arg, self.arg)
594 vals = (int(newid)+1, classname)
595 if __debug__:
596 print >>hyperdb.DEBUG, 'newid', (self, sql, vals)
597 self.cursor.execute(sql, vals)
599 # return as string
600 return str(newid)
602 def setid(self, classname, setid):
603 ''' Set the id counter: used during import of database
604 '''
605 sql = 'update ids set num=%s where name=%s'%(self.arg, self.arg)
606 vals = (setid, classname)
607 if __debug__:
608 print >>hyperdb.DEBUG, 'setid', (self, sql, vals)
609 self.cursor.execute(sql, vals)
611 #
612 # Nodes
613 #
614 def addnode(self, classname, nodeid, node):
615 ''' Add the specified node to its class's db.
616 '''
617 if __debug__:
618 print >>hyperdb.DEBUG, 'addnode', (self, classname, nodeid, node)
620 # determine the column definitions and multilink tables
621 cl = self.classes[classname]
622 cols, mls = self.determine_columns(cl.properties.items())
624 # we'll be supplied these props if we're doing an import
625 if not node.has_key('creator'):
626 # add in the "calculated" properties (dupe so we don't affect
627 # calling code's node assumptions)
628 node = node.copy()
629 node['creation'] = node['activity'] = date.Date()
630 node['actor'] = node['creator'] = self.getuid()
632 # default the non-multilink columns
633 for col, prop in cl.properties.items():
634 if not node.has_key(col):
635 if isinstance(prop, Multilink):
636 node[col] = []
637 else:
638 node[col] = None
640 # clear this node out of the cache if it's in there
641 key = (classname, nodeid)
642 if self.cache.has_key(key):
643 del self.cache[key]
644 self.cache_lru.remove(key)
646 # make the node data safe for the DB
647 node = self.serialise(classname, node)
649 # make sure the ordering is correct for column name -> column value
650 vals = tuple([node[col[1:]] for col in cols]) + (nodeid, 0)
651 s = ','.join([self.arg for x in cols]) + ',%s,%s'%(self.arg, self.arg)
652 cols = ','.join(cols) + ',id,__retired__'
654 # perform the inserts
655 sql = 'insert into _%s (%s) values (%s)'%(classname, cols, s)
656 if __debug__:
657 print >>hyperdb.DEBUG, 'addnode', (self, sql, vals)
658 self.cursor.execute(sql, vals)
660 # insert the multilink rows
661 for col in mls:
662 t = '%s_%s'%(classname, col)
663 for entry in node[col]:
664 sql = 'insert into %s (linkid, nodeid) values (%s,%s)'%(t,
665 self.arg, self.arg)
666 self.sql(sql, (entry, nodeid))
668 # make sure we do the commit-time extra stuff for this node
669 self.transactions.append((self.doSaveNode, (classname, nodeid, node)))
671 def setnode(self, classname, nodeid, values, multilink_changes):
672 ''' Change the specified node.
673 '''
674 if __debug__:
675 print >>hyperdb.DEBUG, 'setnode', (self, classname, nodeid, values)
677 # clear this node out of the cache if it's in there
678 key = (classname, nodeid)
679 if self.cache.has_key(key):
680 del self.cache[key]
681 self.cache_lru.remove(key)
683 # add the special props
684 values = values.copy()
685 values['activity'] = date.Date()
686 values['actor'] = self.getuid()
688 # make db-friendly
689 values = self.serialise(classname, values)
691 cl = self.classes[classname]
692 cols = []
693 mls = []
694 # add the multilinks separately
695 props = cl.getprops()
696 for col in values.keys():
697 prop = props[col]
698 if isinstance(prop, Multilink):
699 mls.append(col)
700 else:
701 cols.append('_'+col)
702 cols.sort()
704 # if there's any updates to regular columns, do them
705 if cols:
706 # make sure the ordering is correct for column name -> column value
707 sqlvals = tuple([values[col[1:]] for col in cols]) + (nodeid,)
708 s = ','.join(['%s=%s'%(x, self.arg) for x in cols])
709 cols = ','.join(cols)
711 # perform the update
712 sql = 'update _%s set %s where id=%s'%(classname, s, self.arg)
713 if __debug__:
714 print >>hyperdb.DEBUG, 'setnode', (self, sql, sqlvals)
715 self.cursor.execute(sql, sqlvals)
717 # now the fun bit, updating the multilinks ;)
718 for col, (add, remove) in multilink_changes.items():
719 tn = '%s_%s'%(classname, col)
720 if add:
721 sql = 'insert into %s (nodeid, linkid) values (%s,%s)'%(tn,
722 self.arg, self.arg)
723 for addid in add:
724 self.sql(sql, (nodeid, addid))
725 if remove:
726 sql = 'delete from %s where nodeid=%s and linkid=%s'%(tn,
727 self.arg, self.arg)
728 for removeid in remove:
729 self.sql(sql, (nodeid, removeid))
731 # make sure we do the commit-time extra stuff for this node
732 self.transactions.append((self.doSaveNode, (classname, nodeid, values)))
734 def getnode(self, classname, nodeid):
735 ''' Get a node from the database.
736 '''
737 if __debug__:
738 print >>hyperdb.DEBUG, 'getnode', (self, classname, nodeid)
740 # see if we have this node cached
741 key = (classname, nodeid)
742 if self.cache.has_key(key):
743 # push us back to the top of the LRU
744 self.cache_lru.remove(key)
745 self.cache_lru.insert(0, key)
746 # return the cached information
747 return self.cache[key]
749 # figure the columns we're fetching
750 cl = self.classes[classname]
751 cols, mls = self.determine_columns(cl.properties.items())
752 scols = ','.join(cols)
754 # perform the basic property fetch
755 sql = 'select %s from _%s where id=%s'%(scols, classname, self.arg)
756 self.sql(sql, (nodeid,))
758 values = self.sql_fetchone()
759 if values is None:
760 raise IndexError, 'no such %s node %s'%(classname, nodeid)
762 # make up the node
763 node = {}
764 for col in range(len(cols)):
765 node[cols[col][1:]] = values[col]
767 # now the multilinks
768 for col in mls:
769 # get the link ids
770 sql = 'select linkid from %s_%s where nodeid=%s'%(classname, col,
771 self.arg)
772 self.cursor.execute(sql, (nodeid,))
773 # extract the first column from the result
774 node[col] = [x[0] for x in self.cursor.fetchall()]
776 # un-dbificate the node data
777 node = self.unserialise(classname, node)
779 # save off in the cache
780 key = (classname, nodeid)
781 self.cache[key] = node
782 # update the LRU
783 self.cache_lru.insert(0, key)
784 if len(self.cache_lru) > ROW_CACHE_SIZE:
785 del self.cache[self.cache_lru.pop()]
787 return node
789 def destroynode(self, classname, nodeid):
790 '''Remove a node from the database. Called exclusively by the
791 destroy() method on Class.
792 '''
793 if __debug__:
794 print >>hyperdb.DEBUG, 'destroynode', (self, classname, nodeid)
796 # make sure the node exists
797 if not self.hasnode(classname, nodeid):
798 raise IndexError, '%s has no node %s'%(classname, nodeid)
800 # see if we have this node cached
801 if self.cache.has_key((classname, nodeid)):
802 del self.cache[(classname, nodeid)]
804 # see if there's any obvious commit actions that we should get rid of
805 for entry in self.transactions[:]:
806 if entry[1][:2] == (classname, nodeid):
807 self.transactions.remove(entry)
809 # now do the SQL
810 sql = 'delete from _%s where id=%s'%(classname, self.arg)
811 self.sql(sql, (nodeid,))
813 # remove from multilnks
814 cl = self.getclass(classname)
815 x, mls = self.determine_columns(cl.properties.items())
816 for col in mls:
817 # get the link ids
818 sql = 'delete from %s_%s where nodeid=%s'%(classname, col, self.arg)
819 self.sql(sql, (nodeid,))
821 # remove journal entries
822 sql = 'delete from %s__journal where nodeid=%s'%(classname, self.arg)
823 self.sql(sql, (nodeid,))
825 def serialise(self, classname, node):
826 '''Copy the node contents, converting non-marshallable data into
827 marshallable data.
828 '''
829 if __debug__:
830 print >>hyperdb.DEBUG, 'serialise', classname, node
831 properties = self.getclass(classname).getprops()
832 d = {}
833 for k, v in node.items():
834 # if the property doesn't exist, or is the "retired" flag then
835 # it won't be in the properties dict
836 if not properties.has_key(k):
837 d[k] = v
838 continue
840 # get the property spec
841 prop = properties[k]
843 if isinstance(prop, Password) and v is not None:
844 d[k] = str(v)
845 elif isinstance(prop, Date) and v is not None:
846 d[k] = v.serialise()
847 elif isinstance(prop, Interval) and v is not None:
848 d[k] = v.serialise()
849 else:
850 d[k] = v
851 return d
853 def unserialise(self, classname, node):
854 '''Decode the marshalled node data
855 '''
856 if __debug__:
857 print >>hyperdb.DEBUG, 'unserialise', classname, node
858 properties = self.getclass(classname).getprops()
859 d = {}
860 for k, v in node.items():
861 # if the property doesn't exist, or is the "retired" flag then
862 # it won't be in the properties dict
863 if not properties.has_key(k):
864 d[k] = v
865 continue
867 # get the property spec
868 prop = properties[k]
870 if isinstance(prop, Date) and v is not None:
871 d[k] = date.Date(v)
872 elif isinstance(prop, Interval) and v is not None:
873 d[k] = date.Interval(v)
874 elif isinstance(prop, Password) and v is not None:
875 p = password.Password()
876 p.unpack(v)
877 d[k] = p
878 elif isinstance(prop, Boolean) and v is not None:
879 d[k] = int(v)
880 elif isinstance(prop, Number) and v is not None:
881 # try int first, then assume it's a float
882 try:
883 d[k] = int(v)
884 except ValueError:
885 d[k] = float(v)
886 else:
887 d[k] = v
888 return d
890 def hasnode(self, classname, nodeid):
891 ''' Determine if the database has a given node.
892 '''
893 sql = 'select count(*) from _%s where id=%s'%(classname, self.arg)
894 if __debug__:
895 print >>hyperdb.DEBUG, 'hasnode', (self, sql, nodeid)
896 self.cursor.execute(sql, (nodeid,))
897 return int(self.cursor.fetchone()[0])
899 def countnodes(self, classname):
900 ''' Count the number of nodes that exist for a particular Class.
901 '''
902 sql = 'select count(*) from _%s'%classname
903 if __debug__:
904 print >>hyperdb.DEBUG, 'countnodes', (self, sql)
905 self.cursor.execute(sql)
906 return self.cursor.fetchone()[0]
908 def addjournal(self, classname, nodeid, action, params, creator=None,
909 creation=None):
910 ''' Journal the Action
911 'action' may be:
913 'create' or 'set' -- 'params' is a dictionary of property values
914 'link' or 'unlink' -- 'params' is (classname, nodeid, propname)
915 'retire' -- 'params' is None
916 '''
917 # serialise the parameters now if necessary
918 if isinstance(params, type({})):
919 if action in ('set', 'create'):
920 params = self.serialise(classname, params)
922 # handle supply of the special journalling parameters (usually
923 # supplied on importing an existing database)
924 if creator:
925 journaltag = creator
926 else:
927 journaltag = self.getuid()
928 if creation:
929 journaldate = creation.serialise()
930 else:
931 journaldate = date.Date().serialise()
933 # create the journal entry
934 cols = ','.join('nodeid date tag action params'.split())
936 if __debug__:
937 print >>hyperdb.DEBUG, 'addjournal', (nodeid, journaldate,
938 journaltag, action, params)
940 self.save_journal(classname, cols, nodeid, journaldate,
941 journaltag, action, params)
943 def getjournal(self, classname, nodeid):
944 ''' get the journal for id
945 '''
946 # make sure the node exists
947 if not self.hasnode(classname, nodeid):
948 raise IndexError, '%s has no node %s'%(classname, nodeid)
950 cols = ','.join('nodeid date tag action params'.split())
951 return self.load_journal(classname, cols, nodeid)
953 def save_journal(self, classname, cols, nodeid, journaldate,
954 journaltag, action, params):
955 ''' Save the journal entry to the database
956 '''
957 # make the params db-friendly
958 params = repr(params)
959 entry = (nodeid, journaldate, journaltag, action, params)
961 # do the insert
962 a = self.arg
963 sql = 'insert into %s__journal (%s) values (%s,%s,%s,%s,%s)'%(classname,
964 cols, a, a, a, a, a)
965 if __debug__:
966 print >>hyperdb.DEBUG, 'addjournal', (self, sql, entry)
967 self.cursor.execute(sql, entry)
969 def load_journal(self, classname, cols, nodeid):
970 ''' Load the journal from the database
971 '''
972 # now get the journal entries
973 sql = 'select %s from %s__journal where nodeid=%s'%(cols, classname,
974 self.arg)
975 if __debug__:
976 print >>hyperdb.DEBUG, 'load_journal', (self, sql, nodeid)
977 self.cursor.execute(sql, (nodeid,))
978 res = []
979 for nodeid, date_stamp, user, action, params in self.cursor.fetchall():
980 params = eval(params)
981 res.append((nodeid, date.Date(date_stamp), user, action, params))
982 return res
984 def pack(self, pack_before):
985 ''' Delete all journal entries except "create" before 'pack_before'.
986 '''
987 # get a 'yyyymmddhhmmss' version of the date
988 date_stamp = pack_before.serialise()
990 # do the delete
991 for classname in self.classes.keys():
992 sql = "delete from %s__journal where date<%s and "\
993 "action<>'create'"%(classname, self.arg)
994 if __debug__:
995 print >>hyperdb.DEBUG, 'pack', (self, sql, date_stamp)
996 self.cursor.execute(sql, (date_stamp,))
998 def sql_commit(self):
999 ''' Actually commit to the database.
1000 '''
1001 if __debug__:
1002 print >>hyperdb.DEBUG, '+++ commit database connection +++'
1003 self.conn.commit()
1005 def commit(self):
1006 ''' Commit the current transactions.
1008 Save all data changed since the database was opened or since the
1009 last commit() or rollback().
1010 '''
1011 if __debug__:
1012 print >>hyperdb.DEBUG, 'commit', (self,)
1014 # commit the database
1015 self.sql_commit()
1017 # now, do all the other transaction stuff
1018 reindex = {}
1019 for method, args in self.transactions:
1020 reindex[method(*args)] = 1
1022 # reindex the nodes that request it
1023 for classname, nodeid in filter(None, reindex.keys()):
1024 print >>hyperdb.DEBUG, 'commit.reindex', (classname, nodeid)
1025 self.getclass(classname).index(nodeid)
1027 # save the indexer state
1028 self.indexer.save_index()
1030 # clear out the transactions
1031 self.transactions = []
1033 def sql_rollback(self):
1034 self.conn.rollback()
1036 def rollback(self):
1037 ''' Reverse all actions from the current transaction.
1039 Undo all the changes made since the database was opened or the last
1040 commit() or rollback() was performed.
1041 '''
1042 if __debug__:
1043 print >>hyperdb.DEBUG, 'rollback', (self,)
1045 self.sql_rollback()
1047 # roll back "other" transaction stuff
1048 for method, args in self.transactions:
1049 # delete temporary files
1050 if method == self.doStoreFile:
1051 self.rollbackStoreFile(*args)
1052 self.transactions = []
1054 # clear the cache
1055 self.clearCache()
1057 def doSaveNode(self, classname, nodeid, node):
1058 ''' dummy that just generates a reindex event
1059 '''
1060 # return the classname, nodeid so we reindex this content
1061 return (classname, nodeid)
1063 def sql_close(self):
1064 if __debug__:
1065 print >>hyperdb.DEBUG, '+++ close database connection +++'
1066 self.conn.close()
1068 def close(self):
1069 ''' Close off the connection.
1070 '''
1071 self.sql_close()
1072 if self.lockfile is not None:
1073 locking.release_lock(self.lockfile)
1074 if self.lockfile is not None:
1075 self.lockfile.close()
1076 self.lockfile = None
1078 #
1079 # The base Class class
1080 #
1081 class Class(hyperdb.Class):
1082 ''' The handle to a particular class of nodes in a hyperdatabase.
1084 All methods except __repr__ and getnode must be implemented by a
1085 concrete backend Class.
1086 '''
1088 def __init__(self, db, classname, **properties):
1089 '''Create a new class with a given name and property specification.
1091 'classname' must not collide with the name of an existing class,
1092 or a ValueError is raised. The keyword arguments in 'properties'
1093 must map names to property objects, or a TypeError is raised.
1094 '''
1095 for name in 'creation activity creator actor'.split():
1096 if properties.has_key(name):
1097 raise ValueError, '"creation", "activity", "creator" and '\
1098 '"actor" are reserved'
1100 self.classname = classname
1101 self.properties = properties
1102 self.db = weakref.proxy(db) # use a weak ref to avoid circularity
1103 self.key = ''
1105 # should we journal changes (default yes)
1106 self.do_journal = 1
1108 # do the db-related init stuff
1109 db.addclass(self)
1111 self.auditors = {'create': [], 'set': [], 'retire': [], 'restore': []}
1112 self.reactors = {'create': [], 'set': [], 'retire': [], 'restore': []}
1114 def schema(self):
1115 ''' A dumpable version of the schema that we can store in the
1116 database
1117 '''
1118 return (self.key, [(x, repr(y)) for x,y in self.properties.items()])
1120 def enableJournalling(self):
1121 '''Turn journalling on for this class
1122 '''
1123 self.do_journal = 1
1125 def disableJournalling(self):
1126 '''Turn journalling off for this class
1127 '''
1128 self.do_journal = 0
1130 # Editing nodes:
1131 def create(self, **propvalues):
1132 ''' Create a new node of this class and return its id.
1134 The keyword arguments in 'propvalues' map property names to values.
1136 The values of arguments must be acceptable for the types of their
1137 corresponding properties or a TypeError is raised.
1139 If this class has a key property, it must be present and its value
1140 must not collide with other key strings or a ValueError is raised.
1142 Any other properties on this class that are missing from the
1143 'propvalues' dictionary are set to None.
1145 If an id in a link or multilink property does not refer to a valid
1146 node, an IndexError is raised.
1147 '''
1148 self.fireAuditors('create', None, propvalues)
1149 newid = self.create_inner(**propvalues)
1150 self.fireReactors('create', newid, None)
1151 return newid
1153 def create_inner(self, **propvalues):
1154 ''' Called by create, in-between the audit and react calls.
1155 '''
1156 if propvalues.has_key('id'):
1157 raise KeyError, '"id" is reserved'
1159 if self.db.journaltag is None:
1160 raise DatabaseError, 'Database open read-only'
1162 if propvalues.has_key('creator') or propvalues.has_key('actor') or \
1163 propvalues.has_key('creation') or propvalues.has_key('activity'):
1164 raise KeyError, '"creator", "actor", "creation" and '\
1165 '"activity" are reserved'
1167 # new node's id
1168 newid = self.db.newid(self.classname)
1170 # validate propvalues
1171 num_re = re.compile('^\d+$')
1172 for key, value in propvalues.items():
1173 if key == self.key:
1174 try:
1175 self.lookup(value)
1176 except KeyError:
1177 pass
1178 else:
1179 raise ValueError, 'node with key "%s" exists'%value
1181 # try to handle this property
1182 try:
1183 prop = self.properties[key]
1184 except KeyError:
1185 raise KeyError, '"%s" has no property "%s"'%(self.classname,
1186 key)
1188 if value is not None and isinstance(prop, Link):
1189 if type(value) != type(''):
1190 raise ValueError, 'link value must be String'
1191 link_class = self.properties[key].classname
1192 # if it isn't a number, it's a key
1193 if not num_re.match(value):
1194 try:
1195 value = self.db.classes[link_class].lookup(value)
1196 except (TypeError, KeyError):
1197 raise IndexError, 'new property "%s": %s not a %s'%(
1198 key, value, link_class)
1199 elif not self.db.getclass(link_class).hasnode(value):
1200 raise IndexError, '%s has no node %s'%(link_class, value)
1202 # save off the value
1203 propvalues[key] = value
1205 # register the link with the newly linked node
1206 if self.do_journal and self.properties[key].do_journal:
1207 self.db.addjournal(link_class, value, 'link',
1208 (self.classname, newid, key))
1210 elif isinstance(prop, Multilink):
1211 if type(value) != type([]):
1212 raise TypeError, 'new property "%s" not a list of ids'%key
1214 # clean up and validate the list of links
1215 link_class = self.properties[key].classname
1216 l = []
1217 for entry in value:
1218 if type(entry) != type(''):
1219 raise ValueError, '"%s" multilink value (%r) '\
1220 'must contain Strings'%(key, value)
1221 # if it isn't a number, it's a key
1222 if not num_re.match(entry):
1223 try:
1224 entry = self.db.classes[link_class].lookup(entry)
1225 except (TypeError, KeyError):
1226 raise IndexError, 'new property "%s": %s not a %s'%(
1227 key, entry, self.properties[key].classname)
1228 l.append(entry)
1229 value = l
1230 propvalues[key] = value
1232 # handle additions
1233 for nodeid in value:
1234 if not self.db.getclass(link_class).hasnode(nodeid):
1235 raise IndexError, '%s has no node %s'%(link_class,
1236 nodeid)
1237 # register the link with the newly linked node
1238 if self.do_journal and self.properties[key].do_journal:
1239 self.db.addjournal(link_class, nodeid, 'link',
1240 (self.classname, newid, key))
1242 elif isinstance(prop, String):
1243 if type(value) != type('') and type(value) != type(u''):
1244 raise TypeError, 'new property "%s" not a string'%key
1246 elif isinstance(prop, Password):
1247 if not isinstance(value, password.Password):
1248 raise TypeError, 'new property "%s" not a Password'%key
1250 elif isinstance(prop, Date):
1251 if value is not None and not isinstance(value, date.Date):
1252 raise TypeError, 'new property "%s" not a Date'%key
1254 elif isinstance(prop, Interval):
1255 if value is not None and not isinstance(value, date.Interval):
1256 raise TypeError, 'new property "%s" not an Interval'%key
1258 elif value is not None and isinstance(prop, Number):
1259 try:
1260 float(value)
1261 except ValueError:
1262 raise TypeError, 'new property "%s" not numeric'%key
1264 elif value is not None and isinstance(prop, Boolean):
1265 try:
1266 int(value)
1267 except ValueError:
1268 raise TypeError, 'new property "%s" not boolean'%key
1270 # make sure there's data where there needs to be
1271 for key, prop in self.properties.items():
1272 if propvalues.has_key(key):
1273 continue
1274 if key == self.key:
1275 raise ValueError, 'key property "%s" is required'%key
1276 if isinstance(prop, Multilink):
1277 propvalues[key] = []
1278 else:
1279 propvalues[key] = None
1281 # done
1282 self.db.addnode(self.classname, newid, propvalues)
1283 if self.do_journal:
1284 self.db.addjournal(self.classname, newid, 'create', {})
1286 return newid
1288 def export_list(self, propnames, nodeid):
1289 ''' Export a node - generate a list of CSV-able data in the order
1290 specified by propnames for the given node.
1291 '''
1292 properties = self.getprops()
1293 l = []
1294 for prop in propnames:
1295 proptype = properties[prop]
1296 value = self.get(nodeid, prop)
1297 # "marshal" data where needed
1298 if value is None:
1299 pass
1300 elif isinstance(proptype, hyperdb.Date):
1301 value = value.get_tuple()
1302 elif isinstance(proptype, hyperdb.Interval):
1303 value = value.get_tuple()
1304 elif isinstance(proptype, hyperdb.Password):
1305 value = str(value)
1306 l.append(repr(value))
1307 l.append(repr(self.is_retired(nodeid)))
1308 return l
1310 def import_list(self, propnames, proplist):
1311 ''' Import a node - all information including "id" is present and
1312 should not be sanity checked. Triggers are not triggered. The
1313 journal should be initialised using the "creator" and "created"
1314 information.
1316 Return the nodeid of the node imported.
1317 '''
1318 if self.db.journaltag is None:
1319 raise DatabaseError, 'Database open read-only'
1320 properties = self.getprops()
1322 # make the new node's property map
1323 d = {}
1324 retire = 0
1325 newid = None
1326 for i in range(len(propnames)):
1327 # Use eval to reverse the repr() used to output the CSV
1328 value = eval(proplist[i])
1330 # Figure the property for this column
1331 propname = propnames[i]
1333 # "unmarshal" where necessary
1334 if propname == 'id':
1335 newid = value
1336 continue
1337 elif propname == 'is retired':
1338 # is the item retired?
1339 if int(value):
1340 retire = 1
1341 continue
1342 elif value is None:
1343 d[propname] = None
1344 continue
1346 prop = properties[propname]
1347 if value is None:
1348 # don't set Nones
1349 continue
1350 elif isinstance(prop, hyperdb.Date):
1351 value = date.Date(value)
1352 elif isinstance(prop, hyperdb.Interval):
1353 value = date.Interval(value)
1354 elif isinstance(prop, hyperdb.Password):
1355 pwd = password.Password()
1356 pwd.unpack(value)
1357 value = pwd
1358 d[propname] = value
1360 # get a new id if necessary
1361 if newid is None:
1362 newid = self.db.newid(self.classname)
1364 # add the node and journal
1365 self.db.addnode(self.classname, newid, d)
1367 # retire?
1368 if retire:
1369 # use the arg for __retired__ to cope with any odd database type
1370 # conversion (hello, sqlite)
1371 sql = 'update _%s set __retired__=%s where id=%s'%(self.classname,
1372 self.db.arg, self.db.arg)
1373 if __debug__:
1374 print >>hyperdb.DEBUG, 'retire', (self, sql, newid)
1375 self.db.cursor.execute(sql, (1, newid))
1377 # extract the extraneous journalling gumpf and nuke it
1378 if d.has_key('creator'):
1379 creator = d['creator']
1380 del d['creator']
1381 else:
1382 creator = None
1383 if d.has_key('creation'):
1384 creation = d['creation']
1385 del d['creation']
1386 else:
1387 creation = None
1388 if d.has_key('activity'):
1389 del d['activity']
1390 if d.has_key('actor'):
1391 del d['actor']
1392 self.db.addjournal(self.classname, newid, 'create', {}, creator,
1393 creation)
1394 return newid
1396 _marker = []
1397 def get(self, nodeid, propname, default=_marker, cache=1):
1398 '''Get the value of a property on an existing node of this class.
1400 'nodeid' must be the id of an existing node of this class or an
1401 IndexError is raised. 'propname' must be the name of a property
1402 of this class or a KeyError is raised.
1404 'cache' exists for backwards compatibility, and is not used.
1405 '''
1406 if propname == 'id':
1407 return nodeid
1409 # get the node's dict
1410 d = self.db.getnode(self.classname, nodeid)
1412 if propname == 'creation':
1413 if d.has_key('creation'):
1414 return d['creation']
1415 else:
1416 return date.Date()
1417 if propname == 'activity':
1418 if d.has_key('activity'):
1419 return d['activity']
1420 else:
1421 return date.Date()
1422 if propname == 'creator':
1423 if d.has_key('creator'):
1424 return d['creator']
1425 else:
1426 return self.db.getuid()
1427 if propname == 'actor':
1428 if d.has_key('actor'):
1429 return d['actor']
1430 else:
1431 return self.db.getuid()
1433 # get the property (raises KeyErorr if invalid)
1434 prop = self.properties[propname]
1436 if not d.has_key(propname):
1437 if default is self._marker:
1438 if isinstance(prop, Multilink):
1439 return []
1440 else:
1441 return None
1442 else:
1443 return default
1445 # don't pass our list to other code
1446 if isinstance(prop, Multilink):
1447 return d[propname][:]
1449 return d[propname]
1451 def set(self, nodeid, **propvalues):
1452 '''Modify a property on an existing node of this class.
1454 'nodeid' must be the id of an existing node of this class or an
1455 IndexError is raised.
1457 Each key in 'propvalues' must be the name of a property of this
1458 class or a KeyError is raised.
1460 All values in 'propvalues' must be acceptable types for their
1461 corresponding properties or a TypeError is raised.
1463 If the value of the key property is set, it must not collide with
1464 other key strings or a ValueError is raised.
1466 If the value of a Link or Multilink property contains an invalid
1467 node id, a ValueError is raised.
1468 '''
1469 if not propvalues:
1470 return propvalues
1472 if propvalues.has_key('creation') or propvalues.has_key('creator') or \
1473 propvalues.has_key('actor') or propvalues.has_key('activity'):
1474 raise KeyError, '"creation", "creator", "actor" and '\
1475 '"activity" are reserved'
1477 if propvalues.has_key('id'):
1478 raise KeyError, '"id" is reserved'
1480 if self.db.journaltag is None:
1481 raise DatabaseError, 'Database open read-only'
1483 self.fireAuditors('set', nodeid, propvalues)
1484 # Take a copy of the node dict so that the subsequent set
1485 # operation doesn't modify the oldvalues structure.
1486 # XXX used to try the cache here first
1487 oldvalues = copy.deepcopy(self.db.getnode(self.classname, nodeid))
1489 node = self.db.getnode(self.classname, nodeid)
1490 if self.is_retired(nodeid):
1491 raise IndexError, 'Requested item is retired'
1492 num_re = re.compile('^\d+$')
1494 # if the journal value is to be different, store it in here
1495 journalvalues = {}
1497 # remember the add/remove stuff for multilinks, making it easier
1498 # for the Database layer to do its stuff
1499 multilink_changes = {}
1501 for propname, value in propvalues.items():
1502 # check to make sure we're not duplicating an existing key
1503 if propname == self.key and node[propname] != value:
1504 try:
1505 self.lookup(value)
1506 except KeyError:
1507 pass
1508 else:
1509 raise ValueError, 'node with key "%s" exists'%value
1511 # this will raise the KeyError if the property isn't valid
1512 # ... we don't use getprops() here because we only care about
1513 # the writeable properties.
1514 try:
1515 prop = self.properties[propname]
1516 except KeyError:
1517 raise KeyError, '"%s" has no property named "%s"'%(
1518 self.classname, propname)
1520 # if the value's the same as the existing value, no sense in
1521 # doing anything
1522 current = node.get(propname, None)
1523 if value == current:
1524 del propvalues[propname]
1525 continue
1526 journalvalues[propname] = current
1528 # do stuff based on the prop type
1529 if isinstance(prop, Link):
1530 link_class = prop.classname
1531 # if it isn't a number, it's a key
1532 if value is not None and not isinstance(value, type('')):
1533 raise ValueError, 'property "%s" link value be a string'%(
1534 propname)
1535 if isinstance(value, type('')) and not num_re.match(value):
1536 try:
1537 value = self.db.classes[link_class].lookup(value)
1538 except (TypeError, KeyError):
1539 raise IndexError, 'new property "%s": %s not a %s'%(
1540 propname, value, prop.classname)
1542 if (value is not None and
1543 not self.db.getclass(link_class).hasnode(value)):
1544 raise IndexError, '%s has no node %s'%(link_class, value)
1546 if self.do_journal and prop.do_journal:
1547 # register the unlink with the old linked node
1548 if node[propname] is not None:
1549 self.db.addjournal(link_class, node[propname], 'unlink',
1550 (self.classname, nodeid, propname))
1552 # register the link with the newly linked node
1553 if value is not None:
1554 self.db.addjournal(link_class, value, 'link',
1555 (self.classname, nodeid, propname))
1557 elif isinstance(prop, Multilink):
1558 if type(value) != type([]):
1559 raise TypeError, 'new property "%s" not a list of'\
1560 ' ids'%propname
1561 link_class = self.properties[propname].classname
1562 l = []
1563 for entry in value:
1564 # if it isn't a number, it's a key
1565 if type(entry) != type(''):
1566 raise ValueError, 'new property "%s" link value ' \
1567 'must be a string'%propname
1568 if not num_re.match(entry):
1569 try:
1570 entry = self.db.classes[link_class].lookup(entry)
1571 except (TypeError, KeyError):
1572 raise IndexError, 'new property "%s": %s not a %s'%(
1573 propname, entry,
1574 self.properties[propname].classname)
1575 l.append(entry)
1576 value = l
1577 propvalues[propname] = value
1579 # figure the journal entry for this property
1580 add = []
1581 remove = []
1583 # handle removals
1584 if node.has_key(propname):
1585 l = node[propname]
1586 else:
1587 l = []
1588 for id in l[:]:
1589 if id in value:
1590 continue
1591 # register the unlink with the old linked node
1592 if self.do_journal and self.properties[propname].do_journal:
1593 self.db.addjournal(link_class, id, 'unlink',
1594 (self.classname, nodeid, propname))
1595 l.remove(id)
1596 remove.append(id)
1598 # handle additions
1599 for id in value:
1600 if not self.db.getclass(link_class).hasnode(id):
1601 raise IndexError, '%s has no node %s'%(link_class, id)
1602 if id in l:
1603 continue
1604 # register the link with the newly linked node
1605 if self.do_journal and self.properties[propname].do_journal:
1606 self.db.addjournal(link_class, id, 'link',
1607 (self.classname, nodeid, propname))
1608 l.append(id)
1609 add.append(id)
1611 # figure the journal entry
1612 l = []
1613 if add:
1614 l.append(('+', add))
1615 if remove:
1616 l.append(('-', remove))
1617 multilink_changes[propname] = (add, remove)
1618 if l:
1619 journalvalues[propname] = tuple(l)
1621 elif isinstance(prop, String):
1622 if value is not None and type(value) != type('') and type(value) != type(u''):
1623 raise TypeError, 'new property "%s" not a string'%propname
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 self.fireReactors('set', nodeid, oldvalues)
1665 return propvalues
1667 def retire(self, nodeid):
1668 '''Retire a node.
1670 The properties on the node remain available from the get() method,
1671 and the node's id is never reused.
1673 Retired nodes are not returned by the find(), list(), or lookup()
1674 methods, and other nodes may reuse the values of their key properties.
1675 '''
1676 if self.db.journaltag is None:
1677 raise DatabaseError, 'Database open read-only'
1679 self.fireAuditors('retire', nodeid, None)
1681 # use the arg for __retired__ to cope with any odd database type
1682 # conversion (hello, sqlite)
1683 sql = 'update _%s set __retired__=%s where id=%s'%(self.classname,
1684 self.db.arg, self.db.arg)
1685 if __debug__:
1686 print >>hyperdb.DEBUG, 'retire', (self, sql, nodeid)
1687 self.db.cursor.execute(sql, (1, nodeid))
1688 if self.do_journal:
1689 self.db.addjournal(self.classname, nodeid, 'retired', None)
1691 self.fireReactors('retire', nodeid, None)
1693 def restore(self, nodeid):
1694 '''Restore a retired node.
1696 Make node available for all operations like it was before retirement.
1697 '''
1698 if self.db.journaltag is None:
1699 raise DatabaseError, 'Database open read-only'
1701 node = self.db.getnode(self.classname, nodeid)
1702 # check if key property was overrided
1703 key = self.getkey()
1704 try:
1705 id = self.lookup(node[key])
1706 except KeyError:
1707 pass
1708 else:
1709 raise KeyError, "Key property (%s) of retired node clashes with \
1710 existing one (%s)" % (key, node[key])
1712 self.fireAuditors('restore', nodeid, None)
1713 # use the arg for __retired__ to cope with any odd database type
1714 # conversion (hello, sqlite)
1715 sql = 'update _%s set __retired__=%s where id=%s'%(self.classname,
1716 self.db.arg, self.db.arg)
1717 if __debug__:
1718 print >>hyperdb.DEBUG, 'restore', (self, sql, nodeid)
1719 self.db.cursor.execute(sql, (0, nodeid))
1720 if self.do_journal:
1721 self.db.addjournal(self.classname, nodeid, 'restored', None)
1723 self.fireReactors('restore', nodeid, None)
1725 def is_retired(self, nodeid):
1726 '''Return true if the node is rerired
1727 '''
1728 sql = 'select __retired__ from _%s where id=%s'%(self.classname,
1729 self.db.arg)
1730 if __debug__:
1731 print >>hyperdb.DEBUG, 'is_retired', (self, sql, nodeid)
1732 self.db.cursor.execute(sql, (nodeid,))
1733 return int(self.db.sql_fetchone()[0])
1735 def destroy(self, nodeid):
1736 '''Destroy a node.
1738 WARNING: this method should never be used except in extremely rare
1739 situations where there could never be links to the node being
1740 deleted
1742 WARNING: use retire() instead
1744 WARNING: the properties of this node will not be available ever again
1746 WARNING: really, use retire() instead
1748 Well, I think that's enough warnings. This method exists mostly to
1749 support the session storage of the cgi interface.
1751 The node is completely removed from the hyperdb, including all journal
1752 entries. It will no longer be available, and will generally break code
1753 if there are any references to the node.
1754 '''
1755 if self.db.journaltag is None:
1756 raise DatabaseError, 'Database open read-only'
1757 self.db.destroynode(self.classname, nodeid)
1759 def history(self, nodeid):
1760 '''Retrieve the journal of edits on a particular node.
1762 'nodeid' must be the id of an existing node of this class or an
1763 IndexError is raised.
1765 The returned list contains tuples of the form
1767 (nodeid, date, tag, action, params)
1769 'date' is a Timestamp object specifying the time of the change and
1770 'tag' is the journaltag specified when the database was opened.
1771 '''
1772 if not self.do_journal:
1773 raise ValueError, 'Journalling is disabled for this class'
1774 return self.db.getjournal(self.classname, nodeid)
1776 # Locating nodes:
1777 def hasnode(self, nodeid):
1778 '''Determine if the given nodeid actually exists
1779 '''
1780 return self.db.hasnode(self.classname, nodeid)
1782 def setkey(self, propname):
1783 '''Select a String property of this class to be the key property.
1785 'propname' must be the name of a String property of this class or
1786 None, or a TypeError is raised. The values of the key property on
1787 all existing nodes must be unique or a ValueError is raised.
1788 '''
1789 # XXX create an index on the key prop column. We should also
1790 # record that we've created this index in the schema somewhere.
1791 prop = self.getprops()[propname]
1792 if not isinstance(prop, String):
1793 raise TypeError, 'key properties must be String'
1794 self.key = propname
1796 def getkey(self):
1797 '''Return the name of the key property for this class or None.'''
1798 return self.key
1800 def labelprop(self, default_to_id=0):
1801 '''Return the property name for a label for the given node.
1803 This method attempts to generate a consistent label for the node.
1804 It tries the following in order:
1806 1. key property
1807 2. "name" property
1808 3. "title" property
1809 4. first property from the sorted property name list
1810 '''
1811 k = self.getkey()
1812 if k:
1813 return k
1814 props = self.getprops()
1815 if props.has_key('name'):
1816 return 'name'
1817 elif props.has_key('title'):
1818 return 'title'
1819 if default_to_id:
1820 return 'id'
1821 props = props.keys()
1822 props.sort()
1823 return props[0]
1825 def lookup(self, keyvalue):
1826 '''Locate a particular node by its key property and return its id.
1828 If this class has no key property, a TypeError is raised. If the
1829 'keyvalue' matches one of the values for the key property among
1830 the nodes in this class, the matching node's id is returned;
1831 otherwise a KeyError is raised.
1832 '''
1833 if not self.key:
1834 raise TypeError, 'No key property set for class %s'%self.classname
1836 # use the arg to handle any odd database type conversion (hello,
1837 # sqlite)
1838 sql = "select id from _%s where _%s=%s and __retired__ <> %s"%(
1839 self.classname, self.key, self.db.arg, self.db.arg)
1840 self.db.sql(sql, (keyvalue, 1))
1842 # see if there was a result that's not retired
1843 row = self.db.sql_fetchone()
1844 if not row:
1845 raise KeyError, 'No key (%s) value "%s" for "%s"'%(self.key,
1846 keyvalue, self.classname)
1848 # return the id
1849 return row[0]
1851 def find(self, **propspec):
1852 '''Get the ids of nodes in this class which link to the given nodes.
1854 'propspec' consists of keyword args propname=nodeid or
1855 propname={nodeid:1, }
1856 'propname' must be the name of a property in this class, or a
1857 KeyError is raised. That property must be a Link or
1858 Multilink property, or a TypeError is raised.
1860 Any node in this class whose 'propname' property links to any of the
1861 nodeids will be returned. Used by the full text indexing, which knows
1862 that "foo" occurs in msg1, msg3 and file7, so we have hits on these
1863 issues:
1865 db.issue.find(messages={'1':1,'3':1}, files={'7':1})
1866 '''
1867 if __debug__:
1868 print >>hyperdb.DEBUG, 'find', (self, propspec)
1870 # shortcut
1871 if not propspec:
1872 return []
1874 # validate the args
1875 props = self.getprops()
1876 propspec = propspec.items()
1877 for propname, nodeids in propspec:
1878 # check the prop is OK
1879 prop = props[propname]
1880 if not isinstance(prop, Link) and not isinstance(prop, Multilink):
1881 raise TypeError, "'%s' not a Link/Multilink property"%propname
1883 # first, links
1884 a = self.db.arg
1885 allvalues = (1,)
1886 o = []
1887 where = []
1888 for prop, values in propspec:
1889 if not isinstance(props[prop], hyperdb.Link):
1890 continue
1891 if type(values) is type({}) and len(values) == 1:
1892 values = values.keys()[0]
1893 if type(values) is type(''):
1894 allvalues += (values,)
1895 where.append('_%s = %s'%(prop, a))
1896 elif values is None:
1897 where.append('_%s is NULL'%prop)
1898 else:
1899 allvalues += tuple(values.keys())
1900 where.append('_%s in (%s)'%(prop, ','.join([a]*len(values))))
1901 tables = ['_%s'%self.classname]
1902 if where:
1903 o.append('(' + ' and '.join(where) + ')')
1905 # now multilinks
1906 for prop, values in propspec:
1907 if not isinstance(props[prop], hyperdb.Multilink):
1908 continue
1909 if not values:
1910 continue
1911 if type(values) is type(''):
1912 allvalues += (values,)
1913 s = a
1914 else:
1915 allvalues += tuple(values.keys())
1916 s = ','.join([a]*len(values))
1917 tn = '%s_%s'%(self.classname, prop)
1918 tables.append(tn)
1919 o.append('(id=%s.nodeid and %s.linkid in (%s))'%(tn, tn, s))
1921 if not o:
1922 return []
1923 elif len(o) > 1:
1924 o = '(' + ' or '.join(['(%s)'%i for i in o]) + ')'
1925 else:
1926 o = o[0]
1927 t = ', '.join(tables)
1928 sql = 'select distinct(id) from %s where __retired__ <> %s and %s'%(t, a, o)
1929 self.db.sql(sql, allvalues)
1930 l = [x[0] for x in self.db.sql_fetchall()]
1931 if __debug__:
1932 print >>hyperdb.DEBUG, 'find ... ', l
1933 return l
1935 def stringFind(self, **requirements):
1936 '''Locate a particular node by matching a set of its String
1937 properties in a caseless search.
1939 If the property is not a String property, a TypeError is raised.
1941 The return is a list of the id of all nodes that match.
1942 '''
1943 where = []
1944 args = []
1945 for propname in requirements.keys():
1946 prop = self.properties[propname]
1947 if not isinstance(prop, String):
1948 raise TypeError, "'%s' not a String property"%propname
1949 where.append(propname)
1950 args.append(requirements[propname].lower())
1952 # generate the where clause
1953 s = ' and '.join(['lower(_%s)=%s'%(col, self.db.arg) for col in where])
1954 sql = 'select id from _%s where %s and __retired__=%s'%(self.classname,
1955 s, self.db.arg)
1956 args.append(0)
1957 self.db.sql(sql, tuple(args))
1958 l = [x[0] for x in self.db.sql_fetchall()]
1959 if __debug__:
1960 print >>hyperdb.DEBUG, 'find ... ', l
1961 return l
1963 def list(self):
1964 ''' Return a list of the ids of the active nodes in this class.
1965 '''
1966 return self.getnodeids(retired=0)
1968 def getnodeids(self, retired=None):
1969 ''' Retrieve all the ids of the nodes for a particular Class.
1971 Set retired=None to get all nodes. Otherwise it'll get all the
1972 retired or non-retired nodes, depending on the flag.
1973 '''
1974 # flip the sense of the 'retired' flag if we don't want all of them
1975 if retired is not None:
1976 if retired:
1977 args = (0, )
1978 else:
1979 args = (1, )
1980 sql = 'select id from _%s where __retired__ <> %s'%(self.classname,
1981 self.db.arg)
1982 else:
1983 args = ()
1984 sql = 'select id from _%s'%self.classname
1985 if __debug__:
1986 print >>hyperdb.DEBUG, 'getnodeids', (self, sql, retired)
1987 self.db.cursor.execute(sql, args)
1988 ids = [x[0] for x in self.db.cursor.fetchall()]
1989 return ids
1991 def filter(self, search_matches, filterspec, sort=(None,None),
1992 group=(None,None)):
1993 '''Return a list of the ids of the active nodes in this class that
1994 match the 'filter' spec, sorted by the group spec and then the
1995 sort spec
1997 "filterspec" is {propname: value(s)}
1999 "sort" and "group" are (dir, prop) where dir is '+', '-' or None
2000 and prop is a prop name or None
2002 "search_matches" is {nodeid: marker}
2004 The filter must match all properties specificed - but if the
2005 property value to match is a list, any one of the values in the
2006 list may match for that property to match.
2007 '''
2008 # just don't bother if the full-text search matched diddly
2009 if search_matches == {}:
2010 return []
2012 cn = self.classname
2014 timezone = self.db.getUserTimezone()
2016 # figure the WHERE clause from the filterspec
2017 props = self.getprops()
2018 frum = ['_'+cn]
2019 where = []
2020 args = []
2021 a = self.db.arg
2022 for k, v in filterspec.items():
2023 propclass = props[k]
2024 # now do other where clause stuff
2025 if isinstance(propclass, Multilink):
2026 tn = '%s_%s'%(cn, k)
2027 if v in ('-1', ['-1']):
2028 # only match rows that have count(linkid)=0 in the
2029 # corresponding multilink table)
2030 where.append('id not in (select nodeid from %s)'%tn)
2031 elif isinstance(v, type([])):
2032 frum.append(tn)
2033 s = ','.join([a for x in v])
2034 where.append('id=%s.nodeid and %s.linkid in (%s)'%(tn,tn,s))
2035 args = args + v
2036 else:
2037 frum.append(tn)
2038 where.append('id=%s.nodeid and %s.linkid=%s'%(tn, tn, a))
2039 args.append(v)
2040 elif k == 'id':
2041 if isinstance(v, type([])):
2042 s = ','.join([a for x in v])
2043 where.append('%s in (%s)'%(k, s))
2044 args = args + v
2045 else:
2046 where.append('%s=%s'%(k, a))
2047 args.append(v)
2048 elif isinstance(propclass, String):
2049 if not isinstance(v, type([])):
2050 v = [v]
2052 # Quote the bits in the string that need it and then embed
2053 # in a "substring" search. Note - need to quote the '%' so
2054 # they make it through the python layer happily
2055 v = ['%%'+self.db.sql_stringquote(s)+'%%' for s in v]
2057 # now add to the where clause
2058 where.append(' or '.join(["_%s LIKE '%s'"%(k, s) for s in v]))
2059 # note: args are embedded in the query string now
2060 elif isinstance(propclass, Link):
2061 if isinstance(v, type([])):
2062 if '-1' in v:
2063 v = v[:]
2064 v.remove('-1')
2065 xtra = ' or _%s is NULL'%k
2066 else:
2067 xtra = ''
2068 if v:
2069 s = ','.join([a for x in v])
2070 where.append('(_%s in (%s)%s)'%(k, s, xtra))
2071 args = args + v
2072 else:
2073 where.append('_%s is NULL'%k)
2074 else:
2075 if v == '-1':
2076 v = None
2077 where.append('_%s is NULL'%k)
2078 else:
2079 where.append('_%s=%s'%(k, a))
2080 args.append(v)
2081 elif isinstance(propclass, Date):
2082 if isinstance(v, type([])):
2083 s = ','.join([a for x in v])
2084 where.append('_%s in (%s)'%(k, s))
2085 args = args + [date.Date(x).serialise() for x in v]
2086 else:
2087 try:
2088 # Try to filter on range of dates
2089 date_rng = Range(v, date.Date, offset=timezone)
2090 if (date_rng.from_value):
2091 where.append('_%s >= %s'%(k, a))
2092 args.append(date_rng.from_value.serialise())
2093 if (date_rng.to_value):
2094 where.append('_%s <= %s'%(k, a))
2095 args.append(date_rng.to_value.serialise())
2096 except ValueError:
2097 # If range creation fails - ignore that search parameter
2098 pass
2099 elif isinstance(propclass, Interval):
2100 if isinstance(v, type([])):
2101 s = ','.join([a for x in v])
2102 where.append('_%s in (%s)'%(k, s))
2103 args = args + [date.Interval(x).serialise() for x in v]
2104 else:
2105 try:
2106 # Try to filter on range of intervals
2107 date_rng = Range(v, date.Interval)
2108 if (date_rng.from_value):
2109 where.append('_%s >= %s'%(k, a))
2110 args.append(date_rng.from_value.serialise())
2111 if (date_rng.to_value):
2112 where.append('_%s <= %s'%(k, a))
2113 args.append(date_rng.to_value.serialise())
2114 except ValueError:
2115 # If range creation fails - ignore that search parameter
2116 pass
2117 #where.append('_%s=%s'%(k, a))
2118 #args.append(date.Interval(v).serialise())
2119 else:
2120 if isinstance(v, type([])):
2121 s = ','.join([a for x in v])
2122 where.append('_%s in (%s)'%(k, s))
2123 args = args + v
2124 else:
2125 where.append('_%s=%s'%(k, a))
2126 args.append(v)
2128 # don't match retired nodes
2129 where.append('__retired__ <> 1')
2131 # add results of full text search
2132 if search_matches is not None:
2133 v = search_matches.keys()
2134 s = ','.join([a for x in v])
2135 where.append('id in (%s)'%s)
2136 args = args + v
2138 # "grouping" is just the first-order sorting in the SQL fetch
2139 # can modify it...)
2140 orderby = []
2141 ordercols = []
2142 if group[0] is not None and group[1] is not None:
2143 if group[0] != '-':
2144 orderby.append('_'+group[1])
2145 ordercols.append('_'+group[1])
2146 else:
2147 orderby.append('_'+group[1]+' desc')
2148 ordercols.append('_'+group[1])
2150 # now add in the sorting
2151 group = ''
2152 if sort[0] is not None and sort[1] is not None:
2153 direction, colname = sort
2154 if direction != '-':
2155 if colname == 'id':
2156 orderby.append(colname)
2157 else:
2158 orderby.append('_'+colname)
2159 ordercols.append('_'+colname)
2160 else:
2161 if colname == 'id':
2162 orderby.append(colname+' desc')
2163 ordercols.append(colname)
2164 else:
2165 orderby.append('_'+colname+' desc')
2166 ordercols.append('_'+colname)
2168 # construct the SQL
2169 frum = ','.join(frum)
2170 if where:
2171 where = ' where ' + (' and '.join(where))
2172 else:
2173 where = ''
2174 cols = ['id']
2175 if orderby:
2176 cols = cols + ordercols
2177 order = ' order by %s'%(','.join(orderby))
2178 else:
2179 order = ''
2180 cols = ','.join(cols)
2181 sql = 'select %s from %s %s%s%s'%(cols, frum, where, group, order)
2182 args = tuple(args)
2183 if __debug__:
2184 print >>hyperdb.DEBUG, 'filter', (self, sql, args)
2185 if args:
2186 self.db.cursor.execute(sql, args)
2187 else:
2188 # psycopg doesn't like empty args
2189 self.db.cursor.execute(sql)
2190 l = self.db.sql_fetchall()
2192 # return the IDs (the first column)
2193 return [row[0] for row in l]
2195 def count(self):
2196 '''Get the number of nodes in this class.
2198 If the returned integer is 'numnodes', the ids of all the nodes
2199 in this class run from 1 to numnodes, and numnodes+1 will be the
2200 id of the next node to be created in this class.
2201 '''
2202 return self.db.countnodes(self.classname)
2204 # Manipulating properties:
2205 def getprops(self, protected=1):
2206 '''Return a dictionary mapping property names to property objects.
2207 If the "protected" flag is true, we include protected properties -
2208 those which may not be modified.
2209 '''
2210 d = self.properties.copy()
2211 if protected:
2212 d['id'] = String()
2213 d['creation'] = hyperdb.Date()
2214 d['activity'] = hyperdb.Date()
2215 d['creator'] = hyperdb.Link('user')
2216 d['actor'] = hyperdb.Link('user')
2217 return d
2219 def addprop(self, **properties):
2220 '''Add properties to this class.
2222 The keyword arguments in 'properties' must map names to property
2223 objects, or a TypeError is raised. None of the keys in 'properties'
2224 may collide with the names of existing properties, or a ValueError
2225 is raised before any properties have been added.
2226 '''
2227 for key in properties.keys():
2228 if self.properties.has_key(key):
2229 raise ValueError, key
2230 self.properties.update(properties)
2232 def index(self, nodeid):
2233 '''Add (or refresh) the node to search indexes
2234 '''
2235 # find all the String properties that have indexme
2236 for prop, propclass in self.getprops().items():
2237 if isinstance(propclass, String) and propclass.indexme:
2238 try:
2239 value = str(self.get(nodeid, prop))
2240 except IndexError:
2241 # node no longer exists - entry should be removed
2242 self.db.indexer.purge_entry((self.classname, nodeid, prop))
2243 else:
2244 # and index them under (classname, nodeid, property)
2245 self.db.indexer.add_text((self.classname, nodeid, prop),
2246 value)
2249 #
2250 # Detector interface
2251 #
2252 def audit(self, event, detector):
2253 '''Register a detector
2254 '''
2255 l = self.auditors[event]
2256 if detector not in l:
2257 self.auditors[event].append(detector)
2259 def fireAuditors(self, action, nodeid, newvalues):
2260 '''Fire all registered auditors.
2261 '''
2262 for audit in self.auditors[action]:
2263 audit(self.db, self, nodeid, newvalues)
2265 def react(self, event, detector):
2266 '''Register a detector
2267 '''
2268 l = self.reactors[event]
2269 if detector not in l:
2270 self.reactors[event].append(detector)
2272 def fireReactors(self, action, nodeid, oldvalues):
2273 '''Fire all registered reactors.
2274 '''
2275 for react in self.reactors[action]:
2276 react(self.db, self, nodeid, oldvalues)
2278 class FileClass(Class, hyperdb.FileClass):
2279 '''This class defines a large chunk of data. To support this, it has a
2280 mandatory String property "content" which is typically saved off
2281 externally to the hyperdb.
2283 The default MIME type of this data is defined by the
2284 "default_mime_type" class attribute, which may be overridden by each
2285 node if the class defines a "type" String property.
2286 '''
2287 default_mime_type = 'text/plain'
2289 def create(self, **propvalues):
2290 ''' snaffle the file propvalue and store in a file
2291 '''
2292 # we need to fire the auditors now, or the content property won't
2293 # be in propvalues for the auditors to play with
2294 self.fireAuditors('create', None, propvalues)
2296 # now remove the content property so it's not stored in the db
2297 content = propvalues['content']
2298 del propvalues['content']
2300 # do the database create
2301 newid = Class.create_inner(self, **propvalues)
2303 # fire reactors
2304 self.fireReactors('create', newid, None)
2306 # store off the content as a file
2307 self.db.storefile(self.classname, newid, None, content)
2308 return newid
2310 def import_list(self, propnames, proplist):
2311 ''' Trap the "content" property...
2312 '''
2313 # dupe this list so we don't affect others
2314 propnames = propnames[:]
2316 # extract the "content" property from the proplist
2317 i = propnames.index('content')
2318 content = eval(proplist[i])
2319 del propnames[i]
2320 del proplist[i]
2322 # do the normal import
2323 newid = Class.import_list(self, propnames, proplist)
2325 # save off the "content" file
2326 self.db.storefile(self.classname, newid, None, content)
2327 return newid
2329 _marker = []
2330 def get(self, nodeid, propname, default=_marker, cache=1):
2331 ''' Trap the content propname and get it from the file
2333 'cache' exists for backwards compatibility, and is not used.
2334 '''
2335 poss_msg = 'Possibly a access right configuration problem.'
2336 if propname == 'content':
2337 try:
2338 return self.db.getfile(self.classname, nodeid, None)
2339 except IOError, (strerror):
2340 # BUG: by catching this we donot see an error in the log.
2341 return 'ERROR reading file: %s%s\n%s\n%s'%(
2342 self.classname, nodeid, poss_msg, strerror)
2343 if default is not self._marker:
2344 return Class.get(self, nodeid, propname, default)
2345 else:
2346 return Class.get(self, nodeid, propname)
2348 def getprops(self, protected=1):
2349 ''' In addition to the actual properties on the node, these methods
2350 provide the "content" property. If the "protected" flag is true,
2351 we include protected properties - those which may not be
2352 modified.
2353 '''
2354 d = Class.getprops(self, protected=protected).copy()
2355 d['content'] = hyperdb.String()
2356 return d
2358 def index(self, nodeid):
2359 ''' Index the node in the search index.
2361 We want to index the content in addition to the normal String
2362 property indexing.
2363 '''
2364 # perform normal indexing
2365 Class.index(self, nodeid)
2367 # get the content to index
2368 content = self.get(nodeid, 'content')
2370 # figure the mime type
2371 if self.properties.has_key('type'):
2372 mime_type = self.get(nodeid, 'type')
2373 else:
2374 mime_type = self.default_mime_type
2376 # and index!
2377 self.db.indexer.add_text((self.classname, nodeid, 'content'), content,
2378 mime_type)
2380 # XXX deviation from spec - was called ItemClass
2381 class IssueClass(Class, roundupdb.IssueClass):
2382 # Overridden methods:
2383 def __init__(self, db, classname, **properties):
2384 '''The newly-created class automatically includes the "messages",
2385 "files", "nosy", and "superseder" properties. If the 'properties'
2386 dictionary attempts to specify any of these properties or a
2387 "creation", "creator", "activity" or "actor" property, a ValueError
2388 is raised.
2389 '''
2390 if not properties.has_key('title'):
2391 properties['title'] = hyperdb.String(indexme='yes')
2392 if not properties.has_key('messages'):
2393 properties['messages'] = hyperdb.Multilink("msg")
2394 if not properties.has_key('files'):
2395 properties['files'] = hyperdb.Multilink("file")
2396 if not properties.has_key('nosy'):
2397 # note: journalling is turned off as it really just wastes
2398 # space. this behaviour may be overridden in an instance
2399 properties['nosy'] = hyperdb.Multilink("user", do_journal="no")
2400 if not properties.has_key('superseder'):
2401 properties['superseder'] = hyperdb.Multilink(classname)
2402 Class.__init__(self, db, classname, **properties)