1 # $Id: rdbms_common.py,v 1.71 2003-11-16 18:41:40 jlgijsbers 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.)
22 '''
24 # standard python modules
25 import sys, os, time, re, errno, weakref, copy
27 # roundup modules
28 from roundup import hyperdb, date, password, roundupdb, security
29 from roundup.hyperdb import String, Password, Date, Interval, Link, \
30 Multilink, DatabaseError, Boolean, Number, Node
31 from roundup.backends import locking
33 # support
34 from blobfiles import FileStorage
35 from roundup.indexer import Indexer
36 from sessions import Sessions, OneTimeKeys
37 from roundup.date import Range
39 # number of rows to keep in memory
40 ROW_CACHE_SIZE = 100
42 class Database(FileStorage, hyperdb.Database, roundupdb.Database):
43 ''' Wrapper around an SQL database that presents a hyperdb interface.
45 - some functionality is specific to the actual SQL database, hence
46 the sql_* methods that are NotImplemented
47 - we keep a cache of the latest ROW_CACHE_SIZE row fetches.
48 '''
49 def __init__(self, config, journaltag=None):
50 ''' Open the database and load the schema from it.
51 '''
52 self.config, self.journaltag = config, journaltag
53 self.dir = config.DATABASE
54 self.classes = {}
55 self.indexer = Indexer(self.dir)
56 self.sessions = Sessions(self.config)
57 self.otks = OneTimeKeys(self.config)
58 self.security = security.Security(self)
60 # additional transaction support for external files and the like
61 self.transactions = []
63 # keep a cache of the N most recently retrieved rows of any kind
64 # (classname, nodeid) = row
65 self.cache = {}
66 self.cache_lru = []
68 # database lock
69 self.lockfile = None
71 # open a connection to the database, creating the "conn" attribute
72 self.sql_open_connection()
74 def clearCache(self):
75 self.cache = {}
76 self.cache_lru = []
78 def sql_open_connection(self):
79 ''' Open a connection to the database, creating it if necessary
80 '''
81 raise NotImplemented
83 def sql(self, sql, args=None):
84 ''' Execute the sql with the optional args.
85 '''
86 if __debug__:
87 print >>hyperdb.DEBUG, (self, sql, args)
88 if args:
89 self.cursor.execute(sql, args)
90 else:
91 self.cursor.execute(sql)
93 def sql_fetchone(self):
94 ''' Fetch a single row. If there's nothing to fetch, return None.
95 '''
96 return self.cursor.fetchone()
98 def sql_fetchall(self):
99 ''' Fetch all rows. If there's nothing to fetch, return [].
100 '''
101 return self.cursor.fetchall()
103 def sql_stringquote(self, value):
104 ''' Quote the string so it's safe to put in the 'sql quotes'
105 '''
106 return re.sub("'", "''", str(value))
108 def save_dbschema(self, schema):
109 ''' Save the schema definition that the database currently implements
110 '''
111 s = repr(self.database_schema)
112 self.sql('insert into schema values (%s)', (s,))
114 def load_dbschema(self):
115 ''' Load the schema definition that the database currently implements
116 '''
117 self.cursor.execute('select schema from schema')
118 return eval(self.cursor.fetchone()[0])
120 def post_init(self):
121 ''' Called once the schema initialisation has finished.
123 We should now confirm that the schema defined by our "classes"
124 attribute actually matches the schema in the database.
125 '''
126 # now detect changes in the schema
127 save = 0
128 for classname, spec in self.classes.items():
129 if self.database_schema.has_key(classname):
130 dbspec = self.database_schema[classname]
131 if self.update_class(spec, dbspec):
132 self.database_schema[classname] = spec.schema()
133 save = 1
134 else:
135 self.create_class(spec)
136 self.database_schema[classname] = spec.schema()
137 save = 1
139 for classname, spec in self.database_schema.items():
140 if not self.classes.has_key(classname):
141 self.drop_class(classname, spec)
142 del self.database_schema[classname]
143 save = 1
145 # update the database version of the schema
146 if save:
147 self.sql('delete from schema')
148 self.save_dbschema(self.database_schema)
150 # reindex the db if necessary
151 if self.indexer.should_reindex():
152 self.reindex()
154 # commit
155 self.conn.commit()
157 def refresh_database(self):
158 # now detect changes in the schema
159 for classname, spec in self.classes.items():
160 dbspec = self.database_schema[classname]
161 self.update_class(spec, dbspec, force=1)
162 self.database_schema[classname] = spec.schema()
163 # update the database version of the schema
164 self.sql('delete from schema')
165 self.save_dbschema(self.database_schema)
166 # reindex the db
167 self.reindex()
168 # commit
169 self.conn.commit()
172 def reindex(self):
173 for klass in self.classes.values():
174 for nodeid in klass.list():
175 klass.index(nodeid)
176 self.indexer.save_index()
178 def determine_columns(self, properties):
179 ''' Figure the column names and multilink properties from the spec
181 "properties" is a list of (name, prop) where prop may be an
182 instance of a hyperdb "type" _or_ a string repr of that type.
183 '''
184 cols = ['_activity', '_creator', '_creation']
185 mls = []
186 # add the multilinks separately
187 for col, prop in properties:
188 if isinstance(prop, Multilink):
189 mls.append(col)
190 elif isinstance(prop, type('')) and prop.find('Multilink') != -1:
191 mls.append(col)
192 else:
193 cols.append('_'+col)
194 cols.sort()
195 return cols, mls
197 def update_class(self, spec, old_spec, force=0):
198 ''' Determine the differences between the current spec and the
199 database version of the spec, and update where necessary.
201 If 'force' is true, update the database anyway.
202 '''
203 new_has = spec.properties.has_key
204 new_spec = spec.schema()
205 new_spec[1].sort()
206 old_spec[1].sort()
207 if not force and new_spec == old_spec:
208 # no changes
209 return 0
211 if __debug__:
212 print >>hyperdb.DEBUG, 'update_class FIRING'
214 # detect multilinks that have been removed, and drop their table
215 old_has = {}
216 for name,prop in old_spec[1]:
217 old_has[name] = 1
218 if new_has(name) or not isinstance(prop, Multilink):
219 continue
220 # it's a multilink, and it's been removed - drop the old
221 # table. First drop indexes.
222 self.drop_multilink_table_indexes(spec.classname, ml)
223 sql = 'drop table %s_%s'%(spec.classname, prop)
224 if __debug__:
225 print >>hyperdb.DEBUG, 'update_class', (self, sql)
226 self.cursor.execute(sql)
227 old_has = old_has.has_key
229 # now figure how we populate the new table
230 fetch = ['_activity', '_creation', '_creator']
231 properties = spec.getprops()
232 for propname,x in new_spec[1]:
233 prop = properties[propname]
234 if isinstance(prop, Multilink):
235 if force or not old_has(propname):
236 # we need to create the new table
237 self.create_multilink_table(spec, propname)
238 elif old_has(propname):
239 # we copy this col over from the old table
240 fetch.append('_'+propname)
242 # select the data out of the old table
243 fetch.append('id')
244 fetch.append('__retired__')
245 fetchcols = ','.join(fetch)
246 cn = spec.classname
247 sql = 'select %s from _%s'%(fetchcols, cn)
248 if __debug__:
249 print >>hyperdb.DEBUG, 'update_class', (self, sql)
250 self.cursor.execute(sql)
251 olddata = self.cursor.fetchall()
253 # TODO: update all the other index dropping code
254 self.drop_class_table_indexes(cn, old_spec[0])
256 # drop the old table
257 self.cursor.execute('drop table _%s'%cn)
259 # create the new table
260 self.create_class_table(spec)
262 if olddata:
263 # do the insert
264 args = ','.join([self.arg for x in fetch])
265 sql = 'insert into _%s (%s) values (%s)'%(cn, fetchcols, args)
266 if __debug__:
267 print >>hyperdb.DEBUG, 'update_class', (self, sql, olddata[0])
268 for entry in olddata:
269 self.cursor.execute(sql, tuple(entry))
271 return 1
273 def create_class_table(self, spec):
274 ''' create the class table for the given spec
275 '''
276 cols, mls = self.determine_columns(spec.properties.items())
278 # add on our special columns
279 cols.append('id')
280 cols.append('__retired__')
282 # create the base table
283 scols = ','.join(['%s varchar'%x for x in cols])
284 sql = 'create table _%s (%s)'%(spec.classname, scols)
285 if __debug__:
286 print >>hyperdb.DEBUG, 'create_class', (self, sql)
287 self.cursor.execute(sql)
289 self.create_class_table_indexes(spec)
291 return cols, mls
293 def create_class_table_indexes(self, spec):
294 ''' create the class table for the given spec
295 '''
296 # create id index
297 index_sql1 = 'create index _%s_id_idx on _%s(id)'%(
298 spec.classname, spec.classname)
299 if __debug__:
300 print >>hyperdb.DEBUG, 'create_index', (self, index_sql1)
301 self.cursor.execute(index_sql1)
303 # create __retired__ index
304 index_sql2 = 'create index _%s_retired_idx on _%s(__retired__)'%(
305 spec.classname, spec.classname)
306 if __debug__:
307 print >>hyperdb.DEBUG, 'create_index', (self, index_sql2)
308 self.cursor.execute(index_sql2)
310 # create index for key property
311 if spec.key:
312 if __debug__:
313 print >>hyperdb.DEBUG, 'update_class setting keyprop %r'% \
314 spec.key
315 index_sql3 = 'create index _%s_%s_idx on _%s(_%s)'%(
316 spec.classname, spec.key,
317 spec.classname, spec.key)
318 if __debug__:
319 print >>hyperdb.DEBUG, 'create_index', (self, index_sql3)
320 self.cursor.execute(index_sql3)
322 def drop_class_table_indexes(self, cn, key):
323 # drop the old table indexes first
324 l = ['_%s_id_idx'%cn, '_%s_retired_idx'%cn]
325 if key:
326 # key prop too?
327 l.append('_%s_%s_idx'%(cn, key))
329 # TODO: update all the other index dropping code
330 table_name = '_%s'%cn
331 for index_name in l:
332 if not self.sql_index_exists(table_name, index_name):
333 continue
334 index_sql = 'drop index '+index_name
335 if __debug__:
336 print >>hyperdb.DEBUG, 'drop_index', (self, index_sql)
337 self.cursor.execute(index_sql)
339 def create_journal_table(self, spec):
340 ''' create the journal table for a class given the spec and
341 already-determined cols
342 '''
343 # journal table
344 cols = ','.join(['%s varchar'%x
345 for x in 'nodeid date tag action params'.split()])
346 sql = 'create table %s__journal (%s)'%(spec.classname, cols)
347 if __debug__:
348 print >>hyperdb.DEBUG, 'create_class', (self, sql)
349 self.cursor.execute(sql)
350 self.create_journal_table_indexes(spec)
352 def create_journal_table_indexes(self, spec):
353 # index on nodeid
354 index_sql = 'create index %s_journ_idx on %s__journal(nodeid)'%(
355 spec.classname, spec.classname)
356 if __debug__:
357 print >>hyperdb.DEBUG, 'create_index', (self, index_sql)
358 self.cursor.execute(index_sql)
360 def drop_journal_table_indexes(self, classname):
361 index_name = '%s_journ_idx'%classname
362 if not self.sql_index_exists('%s__journal'%classname, index_name):
363 return
364 index_sql = 'drop index '+index_name
365 if __debug__:
366 print >>hyperdb.DEBUG, 'drop_index', (self, index_sql)
367 self.cursor.execute(index_sql)
369 def create_multilink_table(self, spec, ml):
370 ''' Create a multilink table for the "ml" property of the class
371 given by the spec
372 '''
373 # create the table
374 sql = 'create table %s_%s (linkid varchar, nodeid varchar)'%(
375 spec.classname, ml)
376 if __debug__:
377 print >>hyperdb.DEBUG, 'create_class', (self, sql)
378 self.cursor.execute(sql)
379 self.create_multilink_table_indexes(spec, ml)
381 def create_multilink_table_indexes(self, spec, ml):
382 # create index on linkid
383 index_sql = 'create index %s_%s_l_idx on %s_%s(linkid)'%(
384 spec.classname, ml, spec.classname, ml)
385 if __debug__:
386 print >>hyperdb.DEBUG, 'create_index', (self, index_sql)
387 self.cursor.execute(index_sql)
389 # create index on nodeid
390 index_sql = 'create index %s_%s_n_idx on %s_%s(nodeid)'%(
391 spec.classname, ml, spec.classname, ml)
392 if __debug__:
393 print >>hyperdb.DEBUG, 'create_index', (self, index_sql)
394 self.cursor.execute(index_sql)
396 def drop_multilink_table_indexes(self, classname, ml):
397 l = [
398 '%s_%s_l_idx'%(classname, ml),
399 '%s_%s_n_idx'%(classname, ml)
400 ]
401 table_name = '%s_%s'%(classname, ml)
402 for index_name in l:
403 if not self.sql_index_exists(table_name, index_name):
404 continue
405 index_sql = 'drop index %s'%index_name
406 if __debug__:
407 print >>hyperdb.DEBUG, 'drop_index', (self, index_sql)
408 self.cursor.execute(index_sql)
410 def create_class(self, spec):
411 ''' Create a database table according to the given spec.
412 '''
413 cols, mls = self.create_class_table(spec)
414 self.create_journal_table(spec)
416 # now create the multilink tables
417 for ml in mls:
418 self.create_multilink_table(spec, ml)
420 # ID counter
421 sql = 'insert into ids (name, num) values (%s,%s)'%(self.arg, self.arg)
422 vals = (spec.classname, 1)
423 if __debug__:
424 print >>hyperdb.DEBUG, 'create_class', (self, sql, vals)
425 self.cursor.execute(sql, vals)
427 def drop_class(self, cn, spec):
428 ''' Drop the given table from the database.
430 Drop the journal and multilink tables too.
431 '''
432 properties = spec[1]
433 # figure the multilinks
434 mls = []
435 for propanme, prop in properties:
436 if isinstance(prop, Multilink):
437 mls.append(propname)
439 # drop class table and indexes
440 self.drop_class_table_indexes(cn, spec[0])
441 sql = 'drop table _%s'%cn
442 if __debug__:
443 print >>hyperdb.DEBUG, 'drop_class', (self, sql)
444 self.cursor.execute(sql)
446 # drop journal table and indexes
447 self.drop_journal_table_indexes(cn)
448 sql = 'drop table %s__journal'%cn
449 if __debug__:
450 print >>hyperdb.DEBUG, 'drop_class', (self, sql)
451 self.cursor.execute(sql)
453 for ml in mls:
454 # drop multilink table and indexes
455 self.drop_multilink_table_indexes(cn, ml)
456 sql = 'drop table %s_%s'%(spec.classname, ml)
457 if __debug__:
458 print >>hyperdb.DEBUG, 'drop_class', (self, sql)
459 self.cursor.execute(sql)
461 #
462 # Classes
463 #
464 def __getattr__(self, classname):
465 ''' A convenient way of calling self.getclass(classname).
466 '''
467 if self.classes.has_key(classname):
468 if __debug__:
469 print >>hyperdb.DEBUG, '__getattr__', (self, classname)
470 return self.classes[classname]
471 raise AttributeError, classname
473 def addclass(self, cl):
474 ''' Add a Class to the hyperdatabase.
475 '''
476 if __debug__:
477 print >>hyperdb.DEBUG, 'addclass', (self, cl)
478 cn = cl.classname
479 if self.classes.has_key(cn):
480 raise ValueError, cn
481 self.classes[cn] = cl
483 def getclasses(self):
484 ''' Return a list of the names of all existing classes.
485 '''
486 if __debug__:
487 print >>hyperdb.DEBUG, 'getclasses', (self,)
488 l = self.classes.keys()
489 l.sort()
490 return l
492 def getclass(self, classname):
493 '''Get the Class object representing a particular class.
495 If 'classname' is not a valid class name, a KeyError is raised.
496 '''
497 if __debug__:
498 print >>hyperdb.DEBUG, 'getclass', (self, classname)
499 try:
500 return self.classes[classname]
501 except KeyError:
502 raise KeyError, 'There is no class called "%s"'%classname
504 def clear(self):
505 ''' Delete all database contents.
507 Note: I don't commit here, which is different behaviour to the
508 "nuke from orbit" behaviour in the *dbms.
509 '''
510 if __debug__:
511 print >>hyperdb.DEBUG, 'clear', (self,)
512 for cn in self.classes.keys():
513 sql = 'delete from _%s'%cn
514 if __debug__:
515 print >>hyperdb.DEBUG, 'clear', (self, sql)
516 self.cursor.execute(sql)
518 #
519 # Node IDs
520 #
521 def newid(self, classname):
522 ''' Generate a new id for the given class
523 '''
524 # get the next ID
525 sql = 'select num from ids where name=%s'%self.arg
526 if __debug__:
527 print >>hyperdb.DEBUG, 'newid', (self, sql, classname)
528 self.cursor.execute(sql, (classname, ))
529 newid = self.cursor.fetchone()[0]
531 # update the counter
532 sql = 'update ids set num=%s where name=%s'%(self.arg, self.arg)
533 vals = (int(newid)+1, classname)
534 if __debug__:
535 print >>hyperdb.DEBUG, 'newid', (self, sql, vals)
536 self.cursor.execute(sql, vals)
538 # return as string
539 return str(newid)
541 def setid(self, classname, setid):
542 ''' Set the id counter: used during import of database
543 '''
544 sql = 'update ids set num=%s where name=%s'%(self.arg, self.arg)
545 vals = (setid, classname)
546 if __debug__:
547 print >>hyperdb.DEBUG, 'setid', (self, sql, vals)
548 self.cursor.execute(sql, vals)
550 #
551 # Nodes
552 #
553 def addnode(self, classname, nodeid, node):
554 ''' Add the specified node to its class's db.
555 '''
556 if __debug__:
557 print >>hyperdb.DEBUG, 'addnode', (self, classname, nodeid, node)
559 # determine the column definitions and multilink tables
560 cl = self.classes[classname]
561 cols, mls = self.determine_columns(cl.properties.items())
563 # we'll be supplied these props if we're doing an import
564 if not node.has_key('creator'):
565 # add in the "calculated" properties (dupe so we don't affect
566 # calling code's node assumptions)
567 node = node.copy()
568 node['creation'] = node['activity'] = date.Date()
569 node['creator'] = self.getuid()
571 # default the non-multilink columns
572 for col, prop in cl.properties.items():
573 if not node.has_key(col):
574 if isinstance(prop, Multilink):
575 node[col] = []
576 else:
577 node[col] = None
579 # clear this node out of the cache if it's in there
580 key = (classname, nodeid)
581 if self.cache.has_key(key):
582 del self.cache[key]
583 self.cache_lru.remove(key)
585 # make the node data safe for the DB
586 node = self.serialise(classname, node)
588 # make sure the ordering is correct for column name -> column value
589 vals = tuple([node[col[1:]] for col in cols]) + (nodeid, 0)
590 s = ','.join([self.arg for x in cols]) + ',%s,%s'%(self.arg, self.arg)
591 cols = ','.join(cols) + ',id,__retired__'
593 # perform the inserts
594 sql = 'insert into _%s (%s) values (%s)'%(classname, cols, s)
595 if __debug__:
596 print >>hyperdb.DEBUG, 'addnode', (self, sql, vals)
597 self.cursor.execute(sql, vals)
599 # insert the multilink rows
600 for col in mls:
601 t = '%s_%s'%(classname, col)
602 for entry in node[col]:
603 sql = 'insert into %s (linkid, nodeid) values (%s,%s)'%(t,
604 self.arg, self.arg)
605 self.sql(sql, (entry, nodeid))
607 # make sure we do the commit-time extra stuff for this node
608 self.transactions.append((self.doSaveNode, (classname, nodeid, node)))
610 def setnode(self, classname, nodeid, values, multilink_changes):
611 ''' Change the specified node.
612 '''
613 if __debug__:
614 print >>hyperdb.DEBUG, 'setnode', (self, classname, nodeid, values)
616 # clear this node out of the cache if it's in there
617 key = (classname, nodeid)
618 if self.cache.has_key(key):
619 del self.cache[key]
620 self.cache_lru.remove(key)
622 # add the special props
623 values = values.copy()
624 values['activity'] = date.Date()
626 # make db-friendly
627 values = self.serialise(classname, values)
629 cl = self.classes[classname]
630 cols = []
631 mls = []
632 # add the multilinks separately
633 props = cl.getprops()
634 for col in values.keys():
635 prop = props[col]
636 if isinstance(prop, Multilink):
637 mls.append(col)
638 else:
639 cols.append('_'+col)
640 cols.sort()
642 # if there's any updates to regular columns, do them
643 if cols:
644 # make sure the ordering is correct for column name -> column value
645 sqlvals = tuple([values[col[1:]] for col in cols]) + (nodeid,)
646 s = ','.join(['%s=%s'%(x, self.arg) for x in cols])
647 cols = ','.join(cols)
649 # perform the update
650 sql = 'update _%s set %s where id=%s'%(classname, s, self.arg)
651 if __debug__:
652 print >>hyperdb.DEBUG, 'setnode', (self, sql, sqlvals)
653 self.cursor.execute(sql, sqlvals)
655 # now the fun bit, updating the multilinks ;)
656 for col, (add, remove) in multilink_changes.items():
657 tn = '%s_%s'%(classname, col)
658 if add:
659 sql = 'insert into %s (nodeid, linkid) values (%s,%s)'%(tn,
660 self.arg, self.arg)
661 for addid in add:
662 self.sql(sql, (nodeid, addid))
663 if remove:
664 sql = 'delete from %s where nodeid=%s and linkid=%s'%(tn,
665 self.arg, self.arg)
666 for removeid in remove:
667 self.sql(sql, (nodeid, removeid))
669 # make sure we do the commit-time extra stuff for this node
670 self.transactions.append((self.doSaveNode, (classname, nodeid, values)))
672 def getnode(self, classname, nodeid):
673 ''' Get a node from the database.
674 '''
675 if __debug__:
676 print >>hyperdb.DEBUG, 'getnode', (self, classname, nodeid)
678 # see if we have this node cached
679 key = (classname, nodeid)
680 if self.cache.has_key(key):
681 # push us back to the top of the LRU
682 self.cache_lru.remove(key)
683 self.cache_lru.insert(0, key)
684 # return the cached information
685 return self.cache[key]
687 # figure the columns we're fetching
688 cl = self.classes[classname]
689 cols, mls = self.determine_columns(cl.properties.items())
690 scols = ','.join(cols)
692 # perform the basic property fetch
693 sql = 'select %s from _%s where id=%s'%(scols, classname, self.arg)
694 self.sql(sql, (nodeid,))
696 values = self.sql_fetchone()
697 if values is None:
698 raise IndexError, 'no such %s node %s'%(classname, nodeid)
700 # make up the node
701 node = {}
702 for col in range(len(cols)):
703 node[cols[col][1:]] = values[col]
705 # now the multilinks
706 for col in mls:
707 # get the link ids
708 sql = 'select linkid from %s_%s where nodeid=%s'%(classname, col,
709 self.arg)
710 self.cursor.execute(sql, (nodeid,))
711 # extract the first column from the result
712 node[col] = [x[0] for x in self.cursor.fetchall()]
714 # un-dbificate the node data
715 node = self.unserialise(classname, node)
717 # save off in the cache
718 key = (classname, nodeid)
719 self.cache[key] = node
720 # update the LRU
721 self.cache_lru.insert(0, key)
722 if len(self.cache_lru) > ROW_CACHE_SIZE:
723 del self.cache[self.cache_lru.pop()]
725 return node
727 def destroynode(self, classname, nodeid):
728 '''Remove a node from the database. Called exclusively by the
729 destroy() method on Class.
730 '''
731 if __debug__:
732 print >>hyperdb.DEBUG, 'destroynode', (self, classname, nodeid)
734 # make sure the node exists
735 if not self.hasnode(classname, nodeid):
736 raise IndexError, '%s has no node %s'%(classname, nodeid)
738 # see if we have this node cached
739 if self.cache.has_key((classname, nodeid)):
740 del self.cache[(classname, nodeid)]
742 # see if there's any obvious commit actions that we should get rid of
743 for entry in self.transactions[:]:
744 if entry[1][:2] == (classname, nodeid):
745 self.transactions.remove(entry)
747 # now do the SQL
748 sql = 'delete from _%s where id=%s'%(classname, self.arg)
749 self.sql(sql, (nodeid,))
751 # remove from multilnks
752 cl = self.getclass(classname)
753 x, mls = self.determine_columns(cl.properties.items())
754 for col in mls:
755 # get the link ids
756 sql = 'delete from %s_%s where nodeid=%s'%(classname, col, self.arg)
757 self.sql(sql, (nodeid,))
759 # remove journal entries
760 sql = 'delete from %s__journal where nodeid=%s'%(classname, self.arg)
761 self.sql(sql, (nodeid,))
763 def serialise(self, classname, node):
764 '''Copy the node contents, converting non-marshallable data into
765 marshallable data.
766 '''
767 if __debug__:
768 print >>hyperdb.DEBUG, 'serialise', classname, node
769 properties = self.getclass(classname).getprops()
770 d = {}
771 for k, v in node.items():
772 # if the property doesn't exist, or is the "retired" flag then
773 # it won't be in the properties dict
774 if not properties.has_key(k):
775 d[k] = v
776 continue
778 # get the property spec
779 prop = properties[k]
781 if isinstance(prop, Password) and v is not None:
782 d[k] = str(v)
783 elif isinstance(prop, Date) and v is not None:
784 d[k] = v.serialise()
785 elif isinstance(prop, Interval) and v is not None:
786 d[k] = v.serialise()
787 else:
788 d[k] = v
789 return d
791 def unserialise(self, classname, node):
792 '''Decode the marshalled node data
793 '''
794 if __debug__:
795 print >>hyperdb.DEBUG, 'unserialise', classname, node
796 properties = self.getclass(classname).getprops()
797 d = {}
798 for k, v in node.items():
799 # if the property doesn't exist, or is the "retired" flag then
800 # it won't be in the properties dict
801 if not properties.has_key(k):
802 d[k] = v
803 continue
805 # get the property spec
806 prop = properties[k]
808 if isinstance(prop, Date) and v is not None:
809 d[k] = date.Date(v)
810 elif isinstance(prop, Interval) and v is not None:
811 d[k] = date.Interval(v)
812 elif isinstance(prop, Password) and v is not None:
813 p = password.Password()
814 p.unpack(v)
815 d[k] = p
816 elif isinstance(prop, Boolean) and v is not None:
817 d[k] = int(v)
818 elif isinstance(prop, Number) and v is not None:
819 # try int first, then assume it's a float
820 try:
821 d[k] = int(v)
822 except ValueError:
823 d[k] = float(v)
824 else:
825 d[k] = v
826 return d
828 def hasnode(self, classname, nodeid):
829 ''' Determine if the database has a given node.
830 '''
831 sql = 'select count(*) from _%s where id=%s'%(classname, self.arg)
832 if __debug__:
833 print >>hyperdb.DEBUG, 'hasnode', (self, sql, nodeid)
834 self.cursor.execute(sql, (nodeid,))
835 return int(self.cursor.fetchone()[0])
837 def countnodes(self, classname):
838 ''' Count the number of nodes that exist for a particular Class.
839 '''
840 sql = 'select count(*) from _%s'%classname
841 if __debug__:
842 print >>hyperdb.DEBUG, 'countnodes', (self, sql)
843 self.cursor.execute(sql)
844 return self.cursor.fetchone()[0]
846 def addjournal(self, classname, nodeid, action, params, creator=None,
847 creation=None):
848 ''' Journal the Action
849 'action' may be:
851 'create' or 'set' -- 'params' is a dictionary of property values
852 'link' or 'unlink' -- 'params' is (classname, nodeid, propname)
853 'retire' -- 'params' is None
854 '''
855 # serialise the parameters now if necessary
856 if isinstance(params, type({})):
857 if action in ('set', 'create'):
858 params = self.serialise(classname, params)
860 # handle supply of the special journalling parameters (usually
861 # supplied on importing an existing database)
862 if creator:
863 journaltag = creator
864 else:
865 journaltag = self.getuid()
866 if creation:
867 journaldate = creation.serialise()
868 else:
869 journaldate = date.Date().serialise()
871 # create the journal entry
872 cols = ','.join('nodeid date tag action params'.split())
874 if __debug__:
875 print >>hyperdb.DEBUG, 'addjournal', (nodeid, journaldate,
876 journaltag, action, params)
878 self.save_journal(classname, cols, nodeid, journaldate,
879 journaltag, action, params)
881 def getjournal(self, classname, nodeid):
882 ''' get the journal for id
883 '''
884 # make sure the node exists
885 if not self.hasnode(classname, nodeid):
886 raise IndexError, '%s has no node %s'%(classname, nodeid)
888 cols = ','.join('nodeid date tag action params'.split())
889 return self.load_journal(classname, cols, nodeid)
891 def save_journal(self, classname, cols, nodeid, journaldate,
892 journaltag, action, params):
893 ''' Save the journal entry to the database
894 '''
895 # make the params db-friendly
896 params = repr(params)
897 entry = (nodeid, journaldate, journaltag, action, params)
899 # do the insert
900 a = self.arg
901 sql = 'insert into %s__journal (%s) values (%s,%s,%s,%s,%s)'%(classname,
902 cols, a, a, a, a, a)
903 if __debug__:
904 print >>hyperdb.DEBUG, 'addjournal', (self, sql, entry)
905 self.cursor.execute(sql, entry)
907 def load_journal(self, classname, cols, nodeid):
908 ''' Load the journal from the database
909 '''
910 # now get the journal entries
911 sql = 'select %s from %s__journal where nodeid=%s'%(cols, classname,
912 self.arg)
913 if __debug__:
914 print >>hyperdb.DEBUG, 'load_journal', (self, sql, nodeid)
915 self.cursor.execute(sql, (nodeid,))
916 res = []
917 for nodeid, date_stamp, user, action, params in self.cursor.fetchall():
918 params = eval(params)
919 res.append((nodeid, date.Date(date_stamp), user, action, params))
920 return res
922 def pack(self, pack_before):
923 ''' Delete all journal entries except "create" before 'pack_before'.
924 '''
925 # get a 'yyyymmddhhmmss' version of the date
926 date_stamp = pack_before.serialise()
928 # do the delete
929 for classname in self.classes.keys():
930 sql = "delete from %s__journal where date<%s and "\
931 "action<>'create'"%(classname, self.arg)
932 if __debug__:
933 print >>hyperdb.DEBUG, 'pack', (self, sql, date_stamp)
934 self.cursor.execute(sql, (date_stamp,))
936 def sql_commit(self):
937 ''' Actually commit to the database.
938 '''
939 self.conn.commit()
941 def commit(self):
942 ''' Commit the current transactions.
944 Save all data changed since the database was opened or since the
945 last commit() or rollback().
946 '''
947 if __debug__:
948 print >>hyperdb.DEBUG, 'commit', (self,)
950 # commit the database
951 self.sql_commit()
953 # now, do all the other transaction stuff
954 reindex = {}
955 for method, args in self.transactions:
956 reindex[method(*args)] = 1
958 # reindex the nodes that request it
959 for classname, nodeid in filter(None, reindex.keys()):
960 print >>hyperdb.DEBUG, 'commit.reindex', (classname, nodeid)
961 self.getclass(classname).index(nodeid)
963 # save the indexer state
964 self.indexer.save_index()
966 # clear out the transactions
967 self.transactions = []
969 def sql_rollback(self):
970 self.conn.rollback()
972 def rollback(self):
973 ''' Reverse all actions from the current transaction.
975 Undo all the changes made since the database was opened or the last
976 commit() or rollback() was performed.
977 '''
978 if __debug__:
979 print >>hyperdb.DEBUG, 'rollback', (self,)
981 self.sql_rollback()
983 # roll back "other" transaction stuff
984 for method, args in self.transactions:
985 # delete temporary files
986 if method == self.doStoreFile:
987 self.rollbackStoreFile(*args)
988 self.transactions = []
990 # clear the cache
991 self.clearCache()
993 def doSaveNode(self, classname, nodeid, node):
994 ''' dummy that just generates a reindex event
995 '''
996 # return the classname, nodeid so we reindex this content
997 return (classname, nodeid)
999 def sql_close(self):
1000 self.conn.close()
1002 def close(self):
1003 ''' Close off the connection.
1004 '''
1005 self.sql_close()
1006 if self.lockfile is not None:
1007 locking.release_lock(self.lockfile)
1008 if self.lockfile is not None:
1009 self.lockfile.close()
1010 self.lockfile = None
1012 #
1013 # The base Class class
1014 #
1015 class Class(hyperdb.Class):
1016 ''' The handle to a particular class of nodes in a hyperdatabase.
1018 All methods except __repr__ and getnode must be implemented by a
1019 concrete backend Class.
1020 '''
1022 def __init__(self, db, classname, **properties):
1023 '''Create a new class with a given name and property specification.
1025 'classname' must not collide with the name of an existing class,
1026 or a ValueError is raised. The keyword arguments in 'properties'
1027 must map names to property objects, or a TypeError is raised.
1028 '''
1029 if (properties.has_key('creation') or properties.has_key('activity')
1030 or properties.has_key('creator')):
1031 raise ValueError, '"creation", "activity" and "creator" are '\
1032 'reserved'
1034 self.classname = classname
1035 self.properties = properties
1036 self.db = weakref.proxy(db) # use a weak ref to avoid circularity
1037 self.key = ''
1039 # should we journal changes (default yes)
1040 self.do_journal = 1
1042 # do the db-related init stuff
1043 db.addclass(self)
1045 self.auditors = {'create': [], 'set': [], 'retire': [], 'restore': []}
1046 self.reactors = {'create': [], 'set': [], 'retire': [], 'restore': []}
1048 def schema(self):
1049 ''' A dumpable version of the schema that we can store in the
1050 database
1051 '''
1052 return (self.key, [(x, repr(y)) for x,y in self.properties.items()])
1054 def enableJournalling(self):
1055 '''Turn journalling on for this class
1056 '''
1057 self.do_journal = 1
1059 def disableJournalling(self):
1060 '''Turn journalling off for this class
1061 '''
1062 self.do_journal = 0
1064 # Editing nodes:
1065 def create(self, **propvalues):
1066 ''' Create a new node of this class and return its id.
1068 The keyword arguments in 'propvalues' map property names to values.
1070 The values of arguments must be acceptable for the types of their
1071 corresponding properties or a TypeError is raised.
1073 If this class has a key property, it must be present and its value
1074 must not collide with other key strings or a ValueError is raised.
1076 Any other properties on this class that are missing from the
1077 'propvalues' dictionary are set to None.
1079 If an id in a link or multilink property does not refer to a valid
1080 node, an IndexError is raised.
1081 '''
1082 self.fireAuditors('create', None, propvalues)
1083 newid = self.create_inner(**propvalues)
1084 self.fireReactors('create', newid, None)
1085 return newid
1087 def create_inner(self, **propvalues):
1088 ''' Called by create, in-between the audit and react calls.
1089 '''
1090 if propvalues.has_key('id'):
1091 raise KeyError, '"id" is reserved'
1093 if self.db.journaltag is None:
1094 raise DatabaseError, 'Database open read-only'
1096 if propvalues.has_key('creation') or propvalues.has_key('activity'):
1097 raise KeyError, '"creation" and "activity" are reserved'
1099 # new node's id
1100 newid = self.db.newid(self.classname)
1102 # validate propvalues
1103 num_re = re.compile('^\d+$')
1104 for key, value in propvalues.items():
1105 if key == self.key:
1106 try:
1107 self.lookup(value)
1108 except KeyError:
1109 pass
1110 else:
1111 raise ValueError, 'node with key "%s" exists'%value
1113 # try to handle this property
1114 try:
1115 prop = self.properties[key]
1116 except KeyError:
1117 raise KeyError, '"%s" has no property "%s"'%(self.classname,
1118 key)
1120 if value is not None and isinstance(prop, Link):
1121 if type(value) != type(''):
1122 raise ValueError, 'link value must be String'
1123 link_class = self.properties[key].classname
1124 # if it isn't a number, it's a key
1125 if not num_re.match(value):
1126 try:
1127 value = self.db.classes[link_class].lookup(value)
1128 except (TypeError, KeyError):
1129 raise IndexError, 'new property "%s": %s not a %s'%(
1130 key, value, link_class)
1131 elif not self.db.getclass(link_class).hasnode(value):
1132 raise IndexError, '%s has no node %s'%(link_class, value)
1134 # save off the value
1135 propvalues[key] = value
1137 # register the link with the newly linked node
1138 if self.do_journal and self.properties[key].do_journal:
1139 self.db.addjournal(link_class, value, 'link',
1140 (self.classname, newid, key))
1142 elif isinstance(prop, Multilink):
1143 if type(value) != type([]):
1144 raise TypeError, 'new property "%s" not a list of ids'%key
1146 # clean up and validate the list of links
1147 link_class = self.properties[key].classname
1148 l = []
1149 for entry in value:
1150 if type(entry) != type(''):
1151 raise ValueError, '"%s" multilink value (%r) '\
1152 'must contain Strings'%(key, value)
1153 # if it isn't a number, it's a key
1154 if not num_re.match(entry):
1155 try:
1156 entry = self.db.classes[link_class].lookup(entry)
1157 except (TypeError, KeyError):
1158 raise IndexError, 'new property "%s": %s not a %s'%(
1159 key, entry, self.properties[key].classname)
1160 l.append(entry)
1161 value = l
1162 propvalues[key] = value
1164 # handle additions
1165 for nodeid in value:
1166 if not self.db.getclass(link_class).hasnode(nodeid):
1167 raise IndexError, '%s has no node %s'%(link_class,
1168 nodeid)
1169 # register the link with the newly linked node
1170 if self.do_journal and self.properties[key].do_journal:
1171 self.db.addjournal(link_class, nodeid, 'link',
1172 (self.classname, newid, key))
1174 elif isinstance(prop, String):
1175 if type(value) != type('') and type(value) != type(u''):
1176 raise TypeError, 'new property "%s" not a string'%key
1178 elif isinstance(prop, Password):
1179 if not isinstance(value, password.Password):
1180 raise TypeError, 'new property "%s" not a Password'%key
1182 elif isinstance(prop, Date):
1183 if value is not None and not isinstance(value, date.Date):
1184 raise TypeError, 'new property "%s" not a Date'%key
1186 elif isinstance(prop, Interval):
1187 if value is not None and not isinstance(value, date.Interval):
1188 raise TypeError, 'new property "%s" not an Interval'%key
1190 elif value is not None and isinstance(prop, Number):
1191 try:
1192 float(value)
1193 except ValueError:
1194 raise TypeError, 'new property "%s" not numeric'%key
1196 elif value is not None and isinstance(prop, Boolean):
1197 try:
1198 int(value)
1199 except ValueError:
1200 raise TypeError, 'new property "%s" not boolean'%key
1202 # make sure there's data where there needs to be
1203 for key, prop in self.properties.items():
1204 if propvalues.has_key(key):
1205 continue
1206 if key == self.key:
1207 raise ValueError, 'key property "%s" is required'%key
1208 if isinstance(prop, Multilink):
1209 propvalues[key] = []
1210 else:
1211 propvalues[key] = None
1213 # done
1214 self.db.addnode(self.classname, newid, propvalues)
1215 if self.do_journal:
1216 self.db.addjournal(self.classname, newid, 'create', {})
1218 return newid
1220 def export_list(self, propnames, nodeid):
1221 ''' Export a node - generate a list of CSV-able data in the order
1222 specified by propnames for the given node.
1223 '''
1224 properties = self.getprops()
1225 l = []
1226 for prop in propnames:
1227 proptype = properties[prop]
1228 value = self.get(nodeid, prop)
1229 # "marshal" data where needed
1230 if value is None:
1231 pass
1232 elif isinstance(proptype, hyperdb.Date):
1233 value = value.get_tuple()
1234 elif isinstance(proptype, hyperdb.Interval):
1235 value = value.get_tuple()
1236 elif isinstance(proptype, hyperdb.Password):
1237 value = str(value)
1238 l.append(repr(value))
1239 l.append(repr(self.is_retired(nodeid)))
1240 return l
1242 def import_list(self, propnames, proplist):
1243 ''' Import a node - all information including "id" is present and
1244 should not be sanity checked. Triggers are not triggered. The
1245 journal should be initialised using the "creator" and "created"
1246 information.
1248 Return the nodeid of the node imported.
1249 '''
1250 if self.db.journaltag is None:
1251 raise DatabaseError, 'Database open read-only'
1252 properties = self.getprops()
1254 # make the new node's property map
1255 d = {}
1256 retire = 0
1257 newid = None
1258 for i in range(len(propnames)):
1259 # Use eval to reverse the repr() used to output the CSV
1260 value = eval(proplist[i])
1262 # Figure the property for this column
1263 propname = propnames[i]
1265 # "unmarshal" where necessary
1266 if propname == 'id':
1267 newid = value
1268 continue
1269 elif propname == 'is retired':
1270 # is the item retired?
1271 if int(value):
1272 retire = 1
1273 continue
1274 elif value is None:
1275 d[propname] = None
1276 continue
1278 prop = properties[propname]
1279 if value is None:
1280 # don't set Nones
1281 continue
1282 elif isinstance(prop, hyperdb.Date):
1283 value = date.Date(value)
1284 elif isinstance(prop, hyperdb.Interval):
1285 value = date.Interval(value)
1286 elif isinstance(prop, hyperdb.Password):
1287 pwd = password.Password()
1288 pwd.unpack(value)
1289 value = pwd
1290 d[propname] = value
1292 # get a new id if necessary
1293 if newid is None:
1294 newid = self.db.newid(self.classname)
1296 # add the node and journal
1297 self.db.addnode(self.classname, newid, d)
1299 # retire?
1300 if retire:
1301 # use the arg for __retired__ to cope with any odd database type
1302 # conversion (hello, sqlite)
1303 sql = 'update _%s set __retired__=%s where id=%s'%(self.classname,
1304 self.db.arg, self.db.arg)
1305 if __debug__:
1306 print >>hyperdb.DEBUG, 'retire', (self, sql, newid)
1307 self.db.cursor.execute(sql, (1, newid))
1309 # extract the extraneous journalling gumpf and nuke it
1310 if d.has_key('creator'):
1311 creator = d['creator']
1312 del d['creator']
1313 else:
1314 creator = None
1315 if d.has_key('creation'):
1316 creation = d['creation']
1317 del d['creation']
1318 else:
1319 creation = None
1320 if d.has_key('activity'):
1321 del d['activity']
1322 self.db.addjournal(self.classname, newid, 'create', {}, creator,
1323 creation)
1324 return newid
1326 _marker = []
1327 def get(self, nodeid, propname, default=_marker, cache=1):
1328 '''Get the value of a property on an existing node of this class.
1330 'nodeid' must be the id of an existing node of this class or an
1331 IndexError is raised. 'propname' must be the name of a property
1332 of this class or a KeyError is raised.
1334 'cache' exists for backwards compatibility, and is not used.
1335 '''
1336 if propname == 'id':
1337 return nodeid
1339 # get the node's dict
1340 d = self.db.getnode(self.classname, nodeid)
1342 if propname == 'creation':
1343 if d.has_key('creation'):
1344 return d['creation']
1345 else:
1346 return date.Date()
1347 if propname == 'activity':
1348 if d.has_key('activity'):
1349 return d['activity']
1350 else:
1351 return date.Date()
1352 if propname == 'creator':
1353 if d.has_key('creator'):
1354 return d['creator']
1355 else:
1356 return self.db.getuid()
1358 # get the property (raises KeyErorr if invalid)
1359 prop = self.properties[propname]
1361 if not d.has_key(propname):
1362 if default is self._marker:
1363 if isinstance(prop, Multilink):
1364 return []
1365 else:
1366 return None
1367 else:
1368 return default
1370 # don't pass our list to other code
1371 if isinstance(prop, Multilink):
1372 return d[propname][:]
1374 return d[propname]
1376 def set(self, nodeid, **propvalues):
1377 '''Modify a property on an existing node of this class.
1379 'nodeid' must be the id of an existing node of this class or an
1380 IndexError is raised.
1382 Each key in 'propvalues' must be the name of a property of this
1383 class or a KeyError is raised.
1385 All values in 'propvalues' must be acceptable types for their
1386 corresponding properties or a TypeError is raised.
1388 If the value of the key property is set, it must not collide with
1389 other key strings or a ValueError is raised.
1391 If the value of a Link or Multilink property contains an invalid
1392 node id, a ValueError is raised.
1393 '''
1394 if not propvalues:
1395 return propvalues
1397 if propvalues.has_key('creation') or propvalues.has_key('activity'):
1398 raise KeyError, '"creation" and "activity" are reserved'
1400 if propvalues.has_key('id'):
1401 raise KeyError, '"id" is reserved'
1403 if self.db.journaltag is None:
1404 raise DatabaseError, 'Database open read-only'
1406 self.fireAuditors('set', nodeid, propvalues)
1407 # Take a copy of the node dict so that the subsequent set
1408 # operation doesn't modify the oldvalues structure.
1409 # XXX used to try the cache here first
1410 oldvalues = copy.deepcopy(self.db.getnode(self.classname, nodeid))
1412 node = self.db.getnode(self.classname, nodeid)
1413 if self.is_retired(nodeid):
1414 raise IndexError, 'Requested item is retired'
1415 num_re = re.compile('^\d+$')
1417 # if the journal value is to be different, store it in here
1418 journalvalues = {}
1420 # remember the add/remove stuff for multilinks, making it easier
1421 # for the Database layer to do its stuff
1422 multilink_changes = {}
1424 for propname, value in propvalues.items():
1425 # check to make sure we're not duplicating an existing key
1426 if propname == self.key and node[propname] != value:
1427 try:
1428 self.lookup(value)
1429 except KeyError:
1430 pass
1431 else:
1432 raise ValueError, 'node with key "%s" exists'%value
1434 # this will raise the KeyError if the property isn't valid
1435 # ... we don't use getprops() here because we only care about
1436 # the writeable properties.
1437 try:
1438 prop = self.properties[propname]
1439 except KeyError:
1440 raise KeyError, '"%s" has no property named "%s"'%(
1441 self.classname, propname)
1443 # if the value's the same as the existing value, no sense in
1444 # doing anything
1445 current = node.get(propname, None)
1446 if value == current:
1447 del propvalues[propname]
1448 continue
1449 journalvalues[propname] = current
1451 # do stuff based on the prop type
1452 if isinstance(prop, Link):
1453 link_class = prop.classname
1454 # if it isn't a number, it's a key
1455 if value is not None and not isinstance(value, type('')):
1456 raise ValueError, 'property "%s" link value be a string'%(
1457 propname)
1458 if isinstance(value, type('')) and not num_re.match(value):
1459 try:
1460 value = self.db.classes[link_class].lookup(value)
1461 except (TypeError, KeyError):
1462 raise IndexError, 'new property "%s": %s not a %s'%(
1463 propname, value, prop.classname)
1465 if (value is not None and
1466 not self.db.getclass(link_class).hasnode(value)):
1467 raise IndexError, '%s has no node %s'%(link_class, value)
1469 if self.do_journal and prop.do_journal:
1470 # register the unlink with the old linked node
1471 if node[propname] is not None:
1472 self.db.addjournal(link_class, node[propname], 'unlink',
1473 (self.classname, nodeid, propname))
1475 # register the link with the newly linked node
1476 if value is not None:
1477 self.db.addjournal(link_class, value, 'link',
1478 (self.classname, nodeid, propname))
1480 elif isinstance(prop, Multilink):
1481 if type(value) != type([]):
1482 raise TypeError, 'new property "%s" not a list of'\
1483 ' ids'%propname
1484 link_class = self.properties[propname].classname
1485 l = []
1486 for entry in value:
1487 # if it isn't a number, it's a key
1488 if type(entry) != type(''):
1489 raise ValueError, 'new property "%s" link value ' \
1490 'must be a string'%propname
1491 if not num_re.match(entry):
1492 try:
1493 entry = self.db.classes[link_class].lookup(entry)
1494 except (TypeError, KeyError):
1495 raise IndexError, 'new property "%s": %s not a %s'%(
1496 propname, entry,
1497 self.properties[propname].classname)
1498 l.append(entry)
1499 value = l
1500 propvalues[propname] = value
1502 # figure the journal entry for this property
1503 add = []
1504 remove = []
1506 # handle removals
1507 if node.has_key(propname):
1508 l = node[propname]
1509 else:
1510 l = []
1511 for id in l[:]:
1512 if id in value:
1513 continue
1514 # register the unlink with the old linked node
1515 if self.do_journal and self.properties[propname].do_journal:
1516 self.db.addjournal(link_class, id, 'unlink',
1517 (self.classname, nodeid, propname))
1518 l.remove(id)
1519 remove.append(id)
1521 # handle additions
1522 for id in value:
1523 if not self.db.getclass(link_class).hasnode(id):
1524 raise IndexError, '%s has no node %s'%(link_class, id)
1525 if id in l:
1526 continue
1527 # register the link with the newly linked node
1528 if self.do_journal and self.properties[propname].do_journal:
1529 self.db.addjournal(link_class, id, 'link',
1530 (self.classname, nodeid, propname))
1531 l.append(id)
1532 add.append(id)
1534 # figure the journal entry
1535 l = []
1536 if add:
1537 l.append(('+', add))
1538 if remove:
1539 l.append(('-', remove))
1540 multilink_changes[propname] = (add, remove)
1541 if l:
1542 journalvalues[propname] = tuple(l)
1544 elif isinstance(prop, String):
1545 if value is not None and type(value) != type('') and type(value) != type(u''):
1546 raise TypeError, 'new property "%s" not a string'%propname
1548 elif isinstance(prop, Password):
1549 if not isinstance(value, password.Password):
1550 raise TypeError, 'new property "%s" not a Password'%propname
1551 propvalues[propname] = value
1553 elif value is not None and isinstance(prop, Date):
1554 if not isinstance(value, date.Date):
1555 raise TypeError, 'new property "%s" not a Date'% propname
1556 propvalues[propname] = value
1558 elif value is not None and isinstance(prop, Interval):
1559 if not isinstance(value, date.Interval):
1560 raise TypeError, 'new property "%s" not an '\
1561 'Interval'%propname
1562 propvalues[propname] = value
1564 elif value is not None and isinstance(prop, Number):
1565 try:
1566 float(value)
1567 except ValueError:
1568 raise TypeError, 'new property "%s" not numeric'%propname
1570 elif value is not None and isinstance(prop, Boolean):
1571 try:
1572 int(value)
1573 except ValueError:
1574 raise TypeError, 'new property "%s" not boolean'%propname
1576 # nothing to do?
1577 if not propvalues:
1578 return propvalues
1580 # do the set, and journal it
1581 self.db.setnode(self.classname, nodeid, propvalues, multilink_changes)
1583 if self.do_journal:
1584 self.db.addjournal(self.classname, nodeid, 'set', journalvalues)
1586 self.fireReactors('set', nodeid, oldvalues)
1588 return propvalues
1590 def retire(self, nodeid):
1591 '''Retire a node.
1593 The properties on the node remain available from the get() method,
1594 and the node's id is never reused.
1596 Retired nodes are not returned by the find(), list(), or lookup()
1597 methods, and other nodes may reuse the values of their key properties.
1598 '''
1599 if self.db.journaltag is None:
1600 raise DatabaseError, 'Database open read-only'
1602 self.fireAuditors('retire', nodeid, None)
1604 # use the arg for __retired__ to cope with any odd database type
1605 # conversion (hello, sqlite)
1606 sql = 'update _%s set __retired__=%s where id=%s'%(self.classname,
1607 self.db.arg, self.db.arg)
1608 if __debug__:
1609 print >>hyperdb.DEBUG, 'retire', (self, sql, nodeid)
1610 self.db.cursor.execute(sql, (1, nodeid))
1611 if self.do_journal:
1612 self.db.addjournal(self.classname, nodeid, 'retired', None)
1614 self.fireReactors('retire', nodeid, None)
1616 def restore(self, nodeid):
1617 '''Restore a retired node.
1619 Make node available for all operations like it was before retirement.
1620 '''
1621 if self.db.journaltag is None:
1622 raise DatabaseError, 'Database open read-only'
1624 node = self.db.getnode(self.classname, nodeid)
1625 # check if key property was overrided
1626 key = self.getkey()
1627 try:
1628 id = self.lookup(node[key])
1629 except KeyError:
1630 pass
1631 else:
1632 raise KeyError, "Key property (%s) of retired node clashes with \
1633 existing one (%s)" % (key, node[key])
1635 self.fireAuditors('restore', nodeid, None)
1636 # use the arg for __retired__ to cope with any odd database type
1637 # conversion (hello, sqlite)
1638 sql = 'update _%s set __retired__=%s where id=%s'%(self.classname,
1639 self.db.arg, self.db.arg)
1640 if __debug__:
1641 print >>hyperdb.DEBUG, 'restore', (self, sql, nodeid)
1642 self.db.cursor.execute(sql, (0, nodeid))
1643 if self.do_journal:
1644 self.db.addjournal(self.classname, nodeid, 'restored', None)
1646 self.fireReactors('restore', nodeid, None)
1648 def is_retired(self, nodeid):
1649 '''Return true if the node is rerired
1650 '''
1651 sql = 'select __retired__ from _%s where id=%s'%(self.classname,
1652 self.db.arg)
1653 if __debug__:
1654 print >>hyperdb.DEBUG, 'is_retired', (self, sql, nodeid)
1655 self.db.cursor.execute(sql, (nodeid,))
1656 return int(self.db.sql_fetchone()[0])
1658 def destroy(self, nodeid):
1659 '''Destroy a node.
1661 WARNING: this method should never be used except in extremely rare
1662 situations where there could never be links to the node being
1663 deleted
1664 WARNING: use retire() instead
1665 WARNING: the properties of this node will not be available ever again
1666 WARNING: really, use retire() instead
1668 Well, I think that's enough warnings. This method exists mostly to
1669 support the session storage of the cgi interface.
1671 The node is completely removed from the hyperdb, including all journal
1672 entries. It will no longer be available, and will generally break code
1673 if there are any references to the node.
1674 '''
1675 if self.db.journaltag is None:
1676 raise DatabaseError, 'Database open read-only'
1677 self.db.destroynode(self.classname, nodeid)
1679 def history(self, nodeid):
1680 '''Retrieve the journal of edits on a particular node.
1682 'nodeid' must be the id of an existing node of this class or an
1683 IndexError is raised.
1685 The returned list contains tuples of the form
1687 (nodeid, date, tag, action, params)
1689 'date' is a Timestamp object specifying the time of the change and
1690 'tag' is the journaltag specified when the database was opened.
1691 '''
1692 if not self.do_journal:
1693 raise ValueError, 'Journalling is disabled for this class'
1694 return self.db.getjournal(self.classname, nodeid)
1696 # Locating nodes:
1697 def hasnode(self, nodeid):
1698 '''Determine if the given nodeid actually exists
1699 '''
1700 return self.db.hasnode(self.classname, nodeid)
1702 def setkey(self, propname):
1703 '''Select a String property of this class to be the key property.
1705 'propname' must be the name of a String property of this class or
1706 None, or a TypeError is raised. The values of the key property on
1707 all existing nodes must be unique or a ValueError is raised.
1708 '''
1709 # XXX create an index on the key prop column. We should also
1710 # record that we've created this index in the schema somewhere.
1711 prop = self.getprops()[propname]
1712 if not isinstance(prop, String):
1713 raise TypeError, 'key properties must be String'
1714 self.key = propname
1716 def getkey(self):
1717 '''Return the name of the key property for this class or None.'''
1718 return self.key
1720 def labelprop(self, default_to_id=0):
1721 ''' Return the property name for a label for the given node.
1723 This method attempts to generate a consistent label for the node.
1724 It tries the following in order:
1725 1. key property
1726 2. "name" property
1727 3. "title" property
1728 4. first property from the sorted property name list
1729 '''
1730 k = self.getkey()
1731 if k:
1732 return k
1733 props = self.getprops()
1734 if props.has_key('name'):
1735 return 'name'
1736 elif props.has_key('title'):
1737 return 'title'
1738 if default_to_id:
1739 return 'id'
1740 props = props.keys()
1741 props.sort()
1742 return props[0]
1744 def lookup(self, keyvalue):
1745 '''Locate a particular node by its key property and return its id.
1747 If this class has no key property, a TypeError is raised. If the
1748 'keyvalue' matches one of the values for the key property among
1749 the nodes in this class, the matching node's id is returned;
1750 otherwise a KeyError is raised.
1751 '''
1752 if not self.key:
1753 raise TypeError, 'No key property set for class %s'%self.classname
1755 # use the arg to handle any odd database type conversion (hello,
1756 # sqlite)
1757 sql = "select id from _%s where _%s=%s and __retired__ <> %s"%(
1758 self.classname, self.key, self.db.arg, self.db.arg)
1759 self.db.sql(sql, (keyvalue, 1))
1761 # see if there was a result that's not retired
1762 row = self.db.sql_fetchone()
1763 if not row:
1764 raise KeyError, 'No key (%s) value "%s" for "%s"'%(self.key,
1765 keyvalue, self.classname)
1767 # return the id
1768 return row[0]
1770 def find(self, **propspec):
1771 '''Get the ids of nodes in this class which link to the given nodes.
1773 'propspec' consists of keyword args propname=nodeid or
1774 propname={nodeid:1, }
1775 'propname' must be the name of a property in this class, or a
1776 KeyError is raised. That property must be a Link or
1777 Multilink property, or a TypeError is raised.
1779 Any node in this class whose 'propname' property links to any of the
1780 nodeids will be returned. Used by the full text indexing, which knows
1781 that "foo" occurs in msg1, msg3 and file7, so we have hits on these
1782 issues:
1784 db.issue.find(messages={'1':1,'3':1}, files={'7':1})
1785 '''
1786 if __debug__:
1787 print >>hyperdb.DEBUG, 'find', (self, propspec)
1789 # shortcut
1790 if not propspec:
1791 return []
1793 # validate the args
1794 props = self.getprops()
1795 propspec = propspec.items()
1796 for propname, nodeids in propspec:
1797 # check the prop is OK
1798 prop = props[propname]
1799 if not isinstance(prop, Link) and not isinstance(prop, Multilink):
1800 raise TypeError, "'%s' not a Link/Multilink property"%propname
1802 # first, links
1803 where = []
1804 allvalues = ()
1805 a = self.db.arg
1806 for prop, values in propspec:
1807 if not isinstance(props[prop], hyperdb.Link):
1808 continue
1809 if type(values) is type({}) and len(values) == 1:
1810 values = values.keys()[0]
1811 if type(values) is type(''):
1812 allvalues += (values,)
1813 where.append('_%s = %s'%(prop, a))
1814 elif values is None:
1815 where.append('_%s is NULL'%prop)
1816 else:
1817 allvalues += tuple(values.keys())
1818 where.append('_%s in (%s)'%(prop, ','.join([a]*len(values))))
1819 tables = []
1820 if where:
1821 tables.append('select id as nodeid from _%s where %s'%(
1822 self.classname, ' and '.join(where)))
1824 # now multilinks
1825 for prop, values in propspec:
1826 if not isinstance(props[prop], hyperdb.Multilink):
1827 continue
1828 if type(values) is type(''):
1829 allvalues += (values,)
1830 s = a
1831 else:
1832 allvalues += tuple(values.keys())
1833 s = ','.join([a]*len(values))
1834 tables.append('select nodeid from %s_%s where linkid in (%s)'%(
1835 self.classname, prop, s))
1836 sql = '\nunion\n'.join(tables)
1837 self.db.sql(sql, allvalues)
1838 l = [x[0] for x in self.db.sql_fetchall()]
1839 if __debug__:
1840 print >>hyperdb.DEBUG, 'find ... ', l
1841 return l
1843 def stringFind(self, **requirements):
1844 '''Locate a particular node by matching a set of its String
1845 properties in a caseless search.
1847 If the property is not a String property, a TypeError is raised.
1849 The return is a list of the id of all nodes that match.
1850 '''
1851 where = []
1852 args = []
1853 for propname in requirements.keys():
1854 prop = self.properties[propname]
1855 if isinstance(not prop, String):
1856 raise TypeError, "'%s' not a String property"%propname
1857 where.append(propname)
1858 args.append(requirements[propname].lower())
1860 # generate the where clause
1861 s = ' and '.join(['lower(_%s)=%s'%(col, self.db.arg) for col in where])
1862 sql = 'select id from _%s where %s'%(self.classname, s)
1863 self.db.sql(sql, tuple(args))
1864 l = [x[0] for x in self.db.sql_fetchall()]
1865 if __debug__:
1866 print >>hyperdb.DEBUG, 'find ... ', l
1867 return l
1869 def list(self):
1870 ''' Return a list of the ids of the active nodes in this class.
1871 '''
1872 return self.getnodeids(retired=0)
1874 def getnodeids(self, retired=None):
1875 ''' Retrieve all the ids of the nodes for a particular Class.
1877 Set retired=None to get all nodes. Otherwise it'll get all the
1878 retired or non-retired nodes, depending on the flag.
1879 '''
1880 # flip the sense of the 'retired' flag if we don't want all of them
1881 if retired is not None:
1882 if retired:
1883 args = (0, )
1884 else:
1885 args = (1, )
1886 sql = 'select id from _%s where __retired__ <> %s'%(self.classname,
1887 self.db.arg)
1888 else:
1889 args = ()
1890 sql = 'select id from _%s'%self.classname
1891 if __debug__:
1892 print >>hyperdb.DEBUG, 'getnodeids', (self, sql, retired)
1893 self.db.cursor.execute(sql, args)
1894 ids = [x[0] for x in self.db.cursor.fetchall()]
1895 return ids
1897 def filter(self, search_matches, filterspec, sort=(None,None),
1898 group=(None,None)):
1899 ''' Return a list of the ids of the active nodes in this class that
1900 match the 'filter' spec, sorted by the group spec and then the
1901 sort spec
1903 "filterspec" is {propname: value(s)}
1904 "sort" and "group" are (dir, prop) where dir is '+', '-' or None
1905 and prop is a prop name or None
1906 "search_matches" is {nodeid: marker}
1908 The filter must match all properties specificed - but if the
1909 property value to match is a list, any one of the values in the
1910 list may match for that property to match.
1911 '''
1912 # just don't bother if the full-text search matched diddly
1913 if search_matches == {}:
1914 return []
1916 cn = self.classname
1918 timezone = self.db.getUserTimezone()
1920 # figure the WHERE clause from the filterspec
1921 props = self.getprops()
1922 frum = ['_'+cn]
1923 where = []
1924 args = []
1925 a = self.db.arg
1926 for k, v in filterspec.items():
1927 propclass = props[k]
1928 # now do other where clause stuff
1929 if isinstance(propclass, Multilink):
1930 tn = '%s_%s'%(cn, k)
1931 if v in ('-1', ['-1']):
1932 # only match rows that have count(linkid)=0 in the
1933 # corresponding multilink table)
1934 where.append('id not in (select nodeid from %s)'%tn)
1935 elif isinstance(v, type([])):
1936 frum.append(tn)
1937 s = ','.join([a for x in v])
1938 where.append('id=%s.nodeid and %s.linkid in (%s)'%(tn,tn,s))
1939 args = args + v
1940 else:
1941 frum.append(tn)
1942 where.append('id=%s.nodeid and %s.linkid=%s'%(tn, tn, a))
1943 args.append(v)
1944 elif k == 'id':
1945 if isinstance(v, type([])):
1946 s = ','.join([a for x in v])
1947 where.append('%s in (%s)'%(k, s))
1948 args = args + v
1949 else:
1950 where.append('%s=%s'%(k, a))
1951 args.append(v)
1952 elif isinstance(propclass, String):
1953 if not isinstance(v, type([])):
1954 v = [v]
1956 # Quote the bits in the string that need it and then embed
1957 # in a "substring" search. Note - need to quote the '%' so
1958 # they make it through the python layer happily
1959 v = ['%%'+self.db.sql_stringquote(s)+'%%' for s in v]
1961 # now add to the where clause
1962 where.append(' or '.join(["_%s LIKE '%s'"%(k, s) for s in v]))
1963 # note: args are embedded in the query string now
1964 elif isinstance(propclass, Link):
1965 if isinstance(v, type([])):
1966 if '-1' in v:
1967 v = v[:]
1968 v.remove('-1')
1969 xtra = ' or _%s is NULL'%k
1970 else:
1971 xtra = ''
1972 if v:
1973 s = ','.join([a for x in v])
1974 where.append('(_%s in (%s)%s)'%(k, s, xtra))
1975 args = args + v
1976 else:
1977 where.append('_%s is NULL'%k)
1978 else:
1979 if v == '-1':
1980 v = None
1981 where.append('_%s is NULL'%k)
1982 else:
1983 where.append('_%s=%s'%(k, a))
1984 args.append(v)
1985 elif isinstance(propclass, Date):
1986 if isinstance(v, type([])):
1987 s = ','.join([a for x in v])
1988 where.append('_%s in (%s)'%(k, s))
1989 args = args + [date.Date(x).serialise() for x in v]
1990 else:
1991 try:
1992 # Try to filter on range of dates
1993 date_rng = Range(v, date.Date, offset=timezone)
1994 if (date_rng.from_value):
1995 where.append('_%s >= %s'%(k, a))
1996 args.append(date_rng.from_value.serialise())
1997 if (date_rng.to_value):
1998 where.append('_%s <= %s'%(k, a))
1999 args.append(date_rng.to_value.serialise())
2000 except ValueError:
2001 # If range creation fails - ignore that search parameter
2002 pass
2003 elif isinstance(propclass, Interval):
2004 if isinstance(v, type([])):
2005 s = ','.join([a for x in v])
2006 where.append('_%s in (%s)'%(k, s))
2007 args = args + [date.Interval(x).serialise() for x in v]
2008 else:
2009 try:
2010 # Try to filter on range of intervals
2011 date_rng = Range(v, date.Interval)
2012 if (date_rng.from_value):
2013 where.append('_%s >= %s'%(k, a))
2014 args.append(date_rng.from_value.serialise())
2015 if (date_rng.to_value):
2016 where.append('_%s <= %s'%(k, a))
2017 args.append(date_rng.to_value.serialise())
2018 except ValueError:
2019 # If range creation fails - ignore that search parameter
2020 pass
2021 #where.append('_%s=%s'%(k, a))
2022 #args.append(date.Interval(v).serialise())
2023 else:
2024 if isinstance(v, type([])):
2025 s = ','.join([a for x in v])
2026 where.append('_%s in (%s)'%(k, s))
2027 args = args + v
2028 else:
2029 where.append('_%s=%s'%(k, a))
2030 args.append(v)
2032 # don't match retired nodes
2033 where.append('__retired__ <> 1')
2035 # add results of full text search
2036 if search_matches is not None:
2037 v = search_matches.keys()
2038 s = ','.join([a for x in v])
2039 where.append('id in (%s)'%s)
2040 args = args + v
2042 # "grouping" is just the first-order sorting in the SQL fetch
2043 # can modify it...)
2044 orderby = []
2045 ordercols = []
2046 if group[0] is not None and group[1] is not None:
2047 if group[0] != '-':
2048 orderby.append('_'+group[1])
2049 ordercols.append('_'+group[1])
2050 else:
2051 orderby.append('_'+group[1]+' desc')
2052 ordercols.append('_'+group[1])
2054 # now add in the sorting
2055 group = ''
2056 if sort[0] is not None and sort[1] is not None:
2057 direction, colname = sort
2058 if direction != '-':
2059 if colname == 'id':
2060 orderby.append(colname)
2061 else:
2062 orderby.append('_'+colname)
2063 ordercols.append('_'+colname)
2064 else:
2065 if colname == 'id':
2066 orderby.append(colname+' desc')
2067 ordercols.append(colname)
2068 else:
2069 orderby.append('_'+colname+' desc')
2070 ordercols.append('_'+colname)
2072 # construct the SQL
2073 frum = ','.join(frum)
2074 if where:
2075 where = ' where ' + (' and '.join(where))
2076 else:
2077 where = ''
2078 cols = ['id']
2079 if orderby:
2080 cols = cols + ordercols
2081 order = ' order by %s'%(','.join(orderby))
2082 else:
2083 order = ''
2084 cols = ','.join(cols)
2085 sql = 'select %s from %s %s%s%s'%(cols, frum, where, group, order)
2086 args = tuple(args)
2087 if __debug__:
2088 print >>hyperdb.DEBUG, 'filter', (self, sql, args)
2089 if args:
2090 self.db.cursor.execute(sql, args)
2091 else:
2092 # psycopg doesn't like empty args
2093 self.db.cursor.execute(sql)
2094 l = self.db.sql_fetchall()
2096 # return the IDs (the first column)
2097 return [row[0] for row in l]
2099 def count(self):
2100 '''Get the number of nodes in this class.
2102 If the returned integer is 'numnodes', the ids of all the nodes
2103 in this class run from 1 to numnodes, and numnodes+1 will be the
2104 id of the next node to be created in this class.
2105 '''
2106 return self.db.countnodes(self.classname)
2108 # Manipulating properties:
2109 def getprops(self, protected=1):
2110 '''Return a dictionary mapping property names to property objects.
2111 If the "protected" flag is true, we include protected properties -
2112 those which may not be modified.
2113 '''
2114 d = self.properties.copy()
2115 if protected:
2116 d['id'] = String()
2117 d['creation'] = hyperdb.Date()
2118 d['activity'] = hyperdb.Date()
2119 d['creator'] = hyperdb.Link('user')
2120 return d
2122 def addprop(self, **properties):
2123 '''Add properties to this class.
2125 The keyword arguments in 'properties' must map names to property
2126 objects, or a TypeError is raised. None of the keys in 'properties'
2127 may collide with the names of existing properties, or a ValueError
2128 is raised before any properties have been added.
2129 '''
2130 for key in properties.keys():
2131 if self.properties.has_key(key):
2132 raise ValueError, key
2133 self.properties.update(properties)
2135 def index(self, nodeid):
2136 '''Add (or refresh) the node to search indexes
2137 '''
2138 # find all the String properties that have indexme
2139 for prop, propclass in self.getprops().items():
2140 if isinstance(propclass, String) and propclass.indexme:
2141 try:
2142 value = str(self.get(nodeid, prop))
2143 except IndexError:
2144 # node no longer exists - entry should be removed
2145 self.db.indexer.purge_entry((self.classname, nodeid, prop))
2146 else:
2147 # and index them under (classname, nodeid, property)
2148 self.db.indexer.add_text((self.classname, nodeid, prop),
2149 value)
2152 #
2153 # Detector interface
2154 #
2155 def audit(self, event, detector):
2156 '''Register a detector
2157 '''
2158 l = self.auditors[event]
2159 if detector not in l:
2160 self.auditors[event].append(detector)
2162 def fireAuditors(self, action, nodeid, newvalues):
2163 '''Fire all registered auditors.
2164 '''
2165 for audit in self.auditors[action]:
2166 audit(self.db, self, nodeid, newvalues)
2168 def react(self, event, detector):
2169 '''Register a detector
2170 '''
2171 l = self.reactors[event]
2172 if detector not in l:
2173 self.reactors[event].append(detector)
2175 def fireReactors(self, action, nodeid, oldvalues):
2176 '''Fire all registered reactors.
2177 '''
2178 for react in self.reactors[action]:
2179 react(self.db, self, nodeid, oldvalues)
2181 class FileClass(Class, hyperdb.FileClass):
2182 '''This class defines a large chunk of data. To support this, it has a
2183 mandatory String property "content" which is typically saved off
2184 externally to the hyperdb.
2186 The default MIME type of this data is defined by the
2187 "default_mime_type" class attribute, which may be overridden by each
2188 node if the class defines a "type" String property.
2189 '''
2190 default_mime_type = 'text/plain'
2192 def create(self, **propvalues):
2193 ''' snaffle the file propvalue and store in a file
2194 '''
2195 # we need to fire the auditors now, or the content property won't
2196 # be in propvalues for the auditors to play with
2197 self.fireAuditors('create', None, propvalues)
2199 # now remove the content property so it's not stored in the db
2200 content = propvalues['content']
2201 del propvalues['content']
2203 # do the database create
2204 newid = Class.create_inner(self, **propvalues)
2206 # fire reactors
2207 self.fireReactors('create', newid, None)
2209 # store off the content as a file
2210 self.db.storefile(self.classname, newid, None, content)
2211 return newid
2213 def import_list(self, propnames, proplist):
2214 ''' Trap the "content" property...
2215 '''
2216 # dupe this list so we don't affect others
2217 propnames = propnames[:]
2219 # extract the "content" property from the proplist
2220 i = propnames.index('content')
2221 content = eval(proplist[i])
2222 del propnames[i]
2223 del proplist[i]
2225 # do the normal import
2226 newid = Class.import_list(self, propnames, proplist)
2228 # save off the "content" file
2229 self.db.storefile(self.classname, newid, None, content)
2230 return newid
2232 _marker = []
2233 def get(self, nodeid, propname, default=_marker, cache=1):
2234 ''' Trap the content propname and get it from the file
2236 'cache' exists for backwards compatibility, and is not used.
2237 '''
2238 poss_msg = 'Possibly a access right configuration problem.'
2239 if propname == 'content':
2240 try:
2241 return self.db.getfile(self.classname, nodeid, None)
2242 except IOError, (strerror):
2243 # BUG: by catching this we donot see an error in the log.
2244 return 'ERROR reading file: %s%s\n%s\n%s'%(
2245 self.classname, nodeid, poss_msg, strerror)
2246 if default is not self._marker:
2247 return Class.get(self, nodeid, propname, default)
2248 else:
2249 return Class.get(self, nodeid, propname)
2251 def getprops(self, protected=1):
2252 ''' In addition to the actual properties on the node, these methods
2253 provide the "content" property. If the "protected" flag is true,
2254 we include protected properties - those which may not be
2255 modified.
2256 '''
2257 d = Class.getprops(self, protected=protected).copy()
2258 d['content'] = hyperdb.String()
2259 return d
2261 def index(self, nodeid):
2262 ''' Index the node in the search index.
2264 We want to index the content in addition to the normal String
2265 property indexing.
2266 '''
2267 # perform normal indexing
2268 Class.index(self, nodeid)
2270 # get the content to index
2271 content = self.get(nodeid, 'content')
2273 # figure the mime type
2274 if self.properties.has_key('type'):
2275 mime_type = self.get(nodeid, 'type')
2276 else:
2277 mime_type = self.default_mime_type
2279 # and index!
2280 self.db.indexer.add_text((self.classname, nodeid, 'content'), content,
2281 mime_type)
2283 # XXX deviation from spec - was called ItemClass
2284 class IssueClass(Class, roundupdb.IssueClass):
2285 # Overridden methods:
2286 def __init__(self, db, classname, **properties):
2287 '''The newly-created class automatically includes the "messages",
2288 "files", "nosy", and "superseder" properties. If the 'properties'
2289 dictionary attempts to specify any of these properties or a
2290 "creation" or "activity" property, a ValueError is raised.
2291 '''
2292 if not properties.has_key('title'):
2293 properties['title'] = hyperdb.String(indexme='yes')
2294 if not properties.has_key('messages'):
2295 properties['messages'] = hyperdb.Multilink("msg")
2296 if not properties.has_key('files'):
2297 properties['files'] = hyperdb.Multilink("file")
2298 if not properties.has_key('nosy'):
2299 # note: journalling is turned off as it really just wastes
2300 # space. this behaviour may be overridden in an instance
2301 properties['nosy'] = hyperdb.Multilink("user", do_journal="no")
2302 if not properties.has_key('superseder'):
2303 properties['superseder'] = hyperdb.Multilink(classname)
2304 Class.__init__(self, db, classname, **properties)