1 # $Id: rdbms_common.py,v 1.73 2004-01-20 03:58:38 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.)
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 self.post_init()
160 def reindex(self):
161 for klass in self.classes.values():
162 for nodeid in klass.list():
163 klass.index(nodeid)
164 self.indexer.save_index()
166 def determine_columns(self, properties):
167 ''' Figure the column names and multilink properties from the spec
169 "properties" is a list of (name, prop) where prop may be an
170 instance of a hyperdb "type" _or_ a string repr of that type.
171 '''
172 cols = ['_activity', '_creator', '_creation']
173 mls = []
174 # add the multilinks separately
175 for col, prop in properties:
176 if isinstance(prop, Multilink):
177 mls.append(col)
178 elif isinstance(prop, type('')) and prop.find('Multilink') != -1:
179 mls.append(col)
180 else:
181 cols.append('_'+col)
182 cols.sort()
183 return cols, mls
185 def update_class(self, spec, old_spec, force=0):
186 ''' Determine the differences between the current spec and the
187 database version of the spec, and update where necessary.
189 If 'force' is true, update the database anyway.
190 '''
191 new_has = spec.properties.has_key
192 new_spec = spec.schema()
193 new_spec[1].sort()
194 old_spec[1].sort()
195 if not force and new_spec == old_spec:
196 # no changes
197 return 0
199 if __debug__:
200 print >>hyperdb.DEBUG, 'update_class FIRING'
202 # detect multilinks that have been removed, and drop their table
203 old_has = {}
204 for name,prop in old_spec[1]:
205 old_has[name] = 1
206 if new_has(name) or not isinstance(prop, Multilink):
207 continue
208 # it's a multilink, and it's been removed - drop the old
209 # table. First drop indexes.
210 self.drop_multilink_table_indexes(spec.classname, ml)
211 sql = 'drop table %s_%s'%(spec.classname, prop)
212 if __debug__:
213 print >>hyperdb.DEBUG, 'update_class', (self, sql)
214 self.cursor.execute(sql)
215 old_has = old_has.has_key
217 # now figure how we populate the new table
218 fetch = ['_activity', '_creation', '_creator']
219 properties = spec.getprops()
220 for propname,x in new_spec[1]:
221 prop = properties[propname]
222 if isinstance(prop, Multilink):
223 if force or not old_has(propname):
224 # we need to create the new table
225 self.create_multilink_table(spec, propname)
226 elif old_has(propname):
227 # we copy this col over from the old table
228 fetch.append('_'+propname)
230 # select the data out of the old table
231 fetch.append('id')
232 fetch.append('__retired__')
233 fetchcols = ','.join(fetch)
234 cn = spec.classname
235 sql = 'select %s from _%s'%(fetchcols, cn)
236 if __debug__:
237 print >>hyperdb.DEBUG, 'update_class', (self, sql)
238 self.cursor.execute(sql)
239 olddata = self.cursor.fetchall()
241 # TODO: update all the other index dropping code
242 self.drop_class_table_indexes(cn, old_spec[0])
244 # drop the old table
245 self.cursor.execute('drop table _%s'%cn)
247 # create the new table
248 self.create_class_table(spec)
250 if olddata:
251 # do the insert
252 args = ','.join([self.arg for x in fetch])
253 sql = 'insert into _%s (%s) values (%s)'%(cn, fetchcols, args)
254 if __debug__:
255 print >>hyperdb.DEBUG, 'update_class', (self, sql, olddata[0])
256 for entry in olddata:
257 self.cursor.execute(sql, tuple(entry))
259 return 1
261 def create_class_table(self, spec):
262 ''' create the class table for the given spec
263 '''
264 cols, mls = self.determine_columns(spec.properties.items())
266 # add on our special columns
267 cols.append('id')
268 cols.append('__retired__')
270 # create the base table
271 scols = ','.join(['%s varchar'%x for x in cols])
272 sql = 'create table _%s (%s)'%(spec.classname, scols)
273 if __debug__:
274 print >>hyperdb.DEBUG, 'create_class', (self, sql)
275 self.cursor.execute(sql)
277 self.create_class_table_indexes(spec)
279 return cols, mls
281 def create_class_table_indexes(self, spec):
282 ''' create the class table for the given spec
283 '''
284 # create id index
285 index_sql1 = 'create index _%s_id_idx on _%s(id)'%(
286 spec.classname, spec.classname)
287 if __debug__:
288 print >>hyperdb.DEBUG, 'create_index', (self, index_sql1)
289 self.cursor.execute(index_sql1)
291 # create __retired__ index
292 index_sql2 = 'create index _%s_retired_idx on _%s(__retired__)'%(
293 spec.classname, spec.classname)
294 if __debug__:
295 print >>hyperdb.DEBUG, 'create_index', (self, index_sql2)
296 self.cursor.execute(index_sql2)
298 # create index for key property
299 if spec.key:
300 if __debug__:
301 print >>hyperdb.DEBUG, 'update_class setting keyprop %r'% \
302 spec.key
303 index_sql3 = 'create index _%s_%s_idx on _%s(_%s)'%(
304 spec.classname, spec.key,
305 spec.classname, spec.key)
306 if __debug__:
307 print >>hyperdb.DEBUG, 'create_index', (self, index_sql3)
308 self.cursor.execute(index_sql3)
310 def drop_class_table_indexes(self, cn, key):
311 # drop the old table indexes first
312 l = ['_%s_id_idx'%cn, '_%s_retired_idx'%cn]
313 if key:
314 # key prop too?
315 l.append('_%s_%s_idx'%(cn, key))
317 # TODO: update all the other index dropping code
318 table_name = '_%s'%cn
319 for index_name in l:
320 if not self.sql_index_exists(table_name, index_name):
321 continue
322 index_sql = 'drop index '+index_name
323 if __debug__:
324 print >>hyperdb.DEBUG, 'drop_index', (self, index_sql)
325 self.cursor.execute(index_sql)
327 def create_journal_table(self, spec):
328 ''' create the journal table for a class given the spec and
329 already-determined cols
330 '''
331 # journal table
332 cols = ','.join(['%s varchar'%x
333 for x in 'nodeid date tag action params'.split()])
334 sql = 'create table %s__journal (%s)'%(spec.classname, cols)
335 if __debug__:
336 print >>hyperdb.DEBUG, 'create_class', (self, sql)
337 self.cursor.execute(sql)
338 self.create_journal_table_indexes(spec)
340 def create_journal_table_indexes(self, spec):
341 # index on nodeid
342 index_sql = 'create index %s_journ_idx on %s__journal(nodeid)'%(
343 spec.classname, spec.classname)
344 if __debug__:
345 print >>hyperdb.DEBUG, 'create_index', (self, index_sql)
346 self.cursor.execute(index_sql)
348 def drop_journal_table_indexes(self, classname):
349 index_name = '%s_journ_idx'%classname
350 if not self.sql_index_exists('%s__journal'%classname, index_name):
351 return
352 index_sql = 'drop index '+index_name
353 if __debug__:
354 print >>hyperdb.DEBUG, 'drop_index', (self, index_sql)
355 self.cursor.execute(index_sql)
357 def create_multilink_table(self, spec, ml):
358 ''' Create a multilink table for the "ml" property of the class
359 given by the spec
360 '''
361 # create the table
362 sql = 'create table %s_%s (linkid varchar, nodeid varchar)'%(
363 spec.classname, ml)
364 if __debug__:
365 print >>hyperdb.DEBUG, 'create_class', (self, sql)
366 self.cursor.execute(sql)
367 self.create_multilink_table_indexes(spec, ml)
369 def create_multilink_table_indexes(self, spec, ml):
370 # create index on linkid
371 index_sql = 'create index %s_%s_l_idx on %s_%s(linkid)'%(
372 spec.classname, ml, spec.classname, ml)
373 if __debug__:
374 print >>hyperdb.DEBUG, 'create_index', (self, index_sql)
375 self.cursor.execute(index_sql)
377 # create index on nodeid
378 index_sql = 'create index %s_%s_n_idx on %s_%s(nodeid)'%(
379 spec.classname, ml, spec.classname, ml)
380 if __debug__:
381 print >>hyperdb.DEBUG, 'create_index', (self, index_sql)
382 self.cursor.execute(index_sql)
384 def drop_multilink_table_indexes(self, classname, ml):
385 l = [
386 '%s_%s_l_idx'%(classname, ml),
387 '%s_%s_n_idx'%(classname, ml)
388 ]
389 table_name = '%s_%s'%(classname, ml)
390 for index_name in l:
391 if not self.sql_index_exists(table_name, index_name):
392 continue
393 index_sql = 'drop index %s'%index_name
394 if __debug__:
395 print >>hyperdb.DEBUG, 'drop_index', (self, index_sql)
396 self.cursor.execute(index_sql)
398 def create_class(self, spec):
399 ''' Create a database table according to the given spec.
400 '''
401 cols, mls = self.create_class_table(spec)
402 self.create_journal_table(spec)
404 # now create the multilink tables
405 for ml in mls:
406 self.create_multilink_table(spec, ml)
408 # ID counter
409 sql = 'insert into ids (name, num) values (%s,%s)'%(self.arg, self.arg)
410 vals = (spec.classname, 1)
411 if __debug__:
412 print >>hyperdb.DEBUG, 'create_class', (self, sql, vals)
413 self.cursor.execute(sql, vals)
415 def drop_class(self, cn, spec):
416 ''' Drop the given table from the database.
418 Drop the journal and multilink tables too.
419 '''
420 properties = spec[1]
421 # figure the multilinks
422 mls = []
423 for propanme, prop in properties:
424 if isinstance(prop, Multilink):
425 mls.append(propname)
427 # drop class table and indexes
428 self.drop_class_table_indexes(cn, spec[0])
429 sql = 'drop table _%s'%cn
430 if __debug__:
431 print >>hyperdb.DEBUG, 'drop_class', (self, sql)
432 self.cursor.execute(sql)
434 # drop journal table and indexes
435 self.drop_journal_table_indexes(cn)
436 sql = 'drop table %s__journal'%cn
437 if __debug__:
438 print >>hyperdb.DEBUG, 'drop_class', (self, sql)
439 self.cursor.execute(sql)
441 for ml in mls:
442 # drop multilink table and indexes
443 self.drop_multilink_table_indexes(cn, ml)
444 sql = 'drop table %s_%s'%(spec.classname, ml)
445 if __debug__:
446 print >>hyperdb.DEBUG, 'drop_class', (self, sql)
447 self.cursor.execute(sql)
449 #
450 # Classes
451 #
452 def __getattr__(self, classname):
453 ''' A convenient way of calling self.getclass(classname).
454 '''
455 if self.classes.has_key(classname):
456 if __debug__:
457 print >>hyperdb.DEBUG, '__getattr__', (self, classname)
458 return self.classes[classname]
459 raise AttributeError, classname
461 def addclass(self, cl):
462 ''' Add a Class to the hyperdatabase.
463 '''
464 if __debug__:
465 print >>hyperdb.DEBUG, 'addclass', (self, cl)
466 cn = cl.classname
467 if self.classes.has_key(cn):
468 raise ValueError, cn
469 self.classes[cn] = cl
471 def getclasses(self):
472 ''' Return a list of the names of all existing classes.
473 '''
474 if __debug__:
475 print >>hyperdb.DEBUG, 'getclasses', (self,)
476 l = self.classes.keys()
477 l.sort()
478 return l
480 def getclass(self, classname):
481 '''Get the Class object representing a particular class.
483 If 'classname' is not a valid class name, a KeyError is raised.
484 '''
485 if __debug__:
486 print >>hyperdb.DEBUG, 'getclass', (self, classname)
487 try:
488 return self.classes[classname]
489 except KeyError:
490 raise KeyError, 'There is no class called "%s"'%classname
492 def clear(self):
493 ''' Delete all database contents.
495 Note: I don't commit here, which is different behaviour to the
496 "nuke from orbit" behaviour in the *dbms.
497 '''
498 if __debug__:
499 print >>hyperdb.DEBUG, 'clear', (self,)
500 for cn in self.classes.keys():
501 sql = 'delete from _%s'%cn
502 if __debug__:
503 print >>hyperdb.DEBUG, 'clear', (self, sql)
504 self.cursor.execute(sql)
506 #
507 # Node IDs
508 #
509 def newid(self, classname):
510 ''' Generate a new id for the given class
511 '''
512 # get the next ID
513 sql = 'select num from ids where name=%s'%self.arg
514 if __debug__:
515 print >>hyperdb.DEBUG, 'newid', (self, sql, classname)
516 self.cursor.execute(sql, (classname, ))
517 newid = self.cursor.fetchone()[0]
519 # update the counter
520 sql = 'update ids set num=%s where name=%s'%(self.arg, self.arg)
521 vals = (int(newid)+1, classname)
522 if __debug__:
523 print >>hyperdb.DEBUG, 'newid', (self, sql, vals)
524 self.cursor.execute(sql, vals)
526 # return as string
527 return str(newid)
529 def setid(self, classname, setid):
530 ''' Set the id counter: used during import of database
531 '''
532 sql = 'update ids set num=%s where name=%s'%(self.arg, self.arg)
533 vals = (setid, classname)
534 if __debug__:
535 print >>hyperdb.DEBUG, 'setid', (self, sql, vals)
536 self.cursor.execute(sql, vals)
538 #
539 # Nodes
540 #
541 def addnode(self, classname, nodeid, node):
542 ''' Add the specified node to its class's db.
543 '''
544 if __debug__:
545 print >>hyperdb.DEBUG, 'addnode', (self, classname, nodeid, node)
547 # determine the column definitions and multilink tables
548 cl = self.classes[classname]
549 cols, mls = self.determine_columns(cl.properties.items())
551 # we'll be supplied these props if we're doing an import
552 if not node.has_key('creator'):
553 # add in the "calculated" properties (dupe so we don't affect
554 # calling code's node assumptions)
555 node = node.copy()
556 node['creation'] = node['activity'] = date.Date()
557 node['creator'] = self.getuid()
559 # default the non-multilink columns
560 for col, prop in cl.properties.items():
561 if not node.has_key(col):
562 if isinstance(prop, Multilink):
563 node[col] = []
564 else:
565 node[col] = None
567 # clear this node out of the cache if it's in there
568 key = (classname, nodeid)
569 if self.cache.has_key(key):
570 del self.cache[key]
571 self.cache_lru.remove(key)
573 # make the node data safe for the DB
574 node = self.serialise(classname, node)
576 # make sure the ordering is correct for column name -> column value
577 vals = tuple([node[col[1:]] for col in cols]) + (nodeid, 0)
578 s = ','.join([self.arg for x in cols]) + ',%s,%s'%(self.arg, self.arg)
579 cols = ','.join(cols) + ',id,__retired__'
581 # perform the inserts
582 sql = 'insert into _%s (%s) values (%s)'%(classname, cols, s)
583 if __debug__:
584 print >>hyperdb.DEBUG, 'addnode', (self, sql, vals)
585 self.cursor.execute(sql, vals)
587 # insert the multilink rows
588 for col in mls:
589 t = '%s_%s'%(classname, col)
590 for entry in node[col]:
591 sql = 'insert into %s (linkid, nodeid) values (%s,%s)'%(t,
592 self.arg, self.arg)
593 self.sql(sql, (entry, nodeid))
595 # make sure we do the commit-time extra stuff for this node
596 self.transactions.append((self.doSaveNode, (classname, nodeid, node)))
598 def setnode(self, classname, nodeid, values, multilink_changes):
599 ''' Change the specified node.
600 '''
601 if __debug__:
602 print >>hyperdb.DEBUG, 'setnode', (self, classname, nodeid, values)
604 # clear this node out of the cache if it's in there
605 key = (classname, nodeid)
606 if self.cache.has_key(key):
607 del self.cache[key]
608 self.cache_lru.remove(key)
610 # add the special props
611 values = values.copy()
612 values['activity'] = date.Date()
614 # make db-friendly
615 values = self.serialise(classname, values)
617 cl = self.classes[classname]
618 cols = []
619 mls = []
620 # add the multilinks separately
621 props = cl.getprops()
622 for col in values.keys():
623 prop = props[col]
624 if isinstance(prop, Multilink):
625 mls.append(col)
626 else:
627 cols.append('_'+col)
628 cols.sort()
630 # if there's any updates to regular columns, do them
631 if cols:
632 # make sure the ordering is correct for column name -> column value
633 sqlvals = tuple([values[col[1:]] for col in cols]) + (nodeid,)
634 s = ','.join(['%s=%s'%(x, self.arg) for x in cols])
635 cols = ','.join(cols)
637 # perform the update
638 sql = 'update _%s set %s where id=%s'%(classname, s, self.arg)
639 if __debug__:
640 print >>hyperdb.DEBUG, 'setnode', (self, sql, sqlvals)
641 self.cursor.execute(sql, sqlvals)
643 # now the fun bit, updating the multilinks ;)
644 for col, (add, remove) in multilink_changes.items():
645 tn = '%s_%s'%(classname, col)
646 if add:
647 sql = 'insert into %s (nodeid, linkid) values (%s,%s)'%(tn,
648 self.arg, self.arg)
649 for addid in add:
650 self.sql(sql, (nodeid, addid))
651 if remove:
652 sql = 'delete from %s where nodeid=%s and linkid=%s'%(tn,
653 self.arg, self.arg)
654 for removeid in remove:
655 self.sql(sql, (nodeid, removeid))
657 # make sure we do the commit-time extra stuff for this node
658 self.transactions.append((self.doSaveNode, (classname, nodeid, values)))
660 def getnode(self, classname, nodeid):
661 ''' Get a node from the database.
662 '''
663 if __debug__:
664 print >>hyperdb.DEBUG, 'getnode', (self, classname, nodeid)
666 # see if we have this node cached
667 key = (classname, nodeid)
668 if self.cache.has_key(key):
669 # push us back to the top of the LRU
670 self.cache_lru.remove(key)
671 self.cache_lru.insert(0, key)
672 # return the cached information
673 return self.cache[key]
675 # figure the columns we're fetching
676 cl = self.classes[classname]
677 cols, mls = self.determine_columns(cl.properties.items())
678 scols = ','.join(cols)
680 # perform the basic property fetch
681 sql = 'select %s from _%s where id=%s'%(scols, classname, self.arg)
682 self.sql(sql, (nodeid,))
684 values = self.sql_fetchone()
685 if values is None:
686 raise IndexError, 'no such %s node %s'%(classname, nodeid)
688 # make up the node
689 node = {}
690 for col in range(len(cols)):
691 node[cols[col][1:]] = values[col]
693 # now the multilinks
694 for col in mls:
695 # get the link ids
696 sql = 'select linkid from %s_%s where nodeid=%s'%(classname, col,
697 self.arg)
698 self.cursor.execute(sql, (nodeid,))
699 # extract the first column from the result
700 node[col] = [x[0] for x in self.cursor.fetchall()]
702 # un-dbificate the node data
703 node = self.unserialise(classname, node)
705 # save off in the cache
706 key = (classname, nodeid)
707 self.cache[key] = node
708 # update the LRU
709 self.cache_lru.insert(0, key)
710 if len(self.cache_lru) > ROW_CACHE_SIZE:
711 del self.cache[self.cache_lru.pop()]
713 return node
715 def destroynode(self, classname, nodeid):
716 '''Remove a node from the database. Called exclusively by the
717 destroy() method on Class.
718 '''
719 if __debug__:
720 print >>hyperdb.DEBUG, 'destroynode', (self, classname, nodeid)
722 # make sure the node exists
723 if not self.hasnode(classname, nodeid):
724 raise IndexError, '%s has no node %s'%(classname, nodeid)
726 # see if we have this node cached
727 if self.cache.has_key((classname, nodeid)):
728 del self.cache[(classname, nodeid)]
730 # see if there's any obvious commit actions that we should get rid of
731 for entry in self.transactions[:]:
732 if entry[1][:2] == (classname, nodeid):
733 self.transactions.remove(entry)
735 # now do the SQL
736 sql = 'delete from _%s where id=%s'%(classname, self.arg)
737 self.sql(sql, (nodeid,))
739 # remove from multilnks
740 cl = self.getclass(classname)
741 x, mls = self.determine_columns(cl.properties.items())
742 for col in mls:
743 # get the link ids
744 sql = 'delete from %s_%s where nodeid=%s'%(classname, col, self.arg)
745 self.sql(sql, (nodeid,))
747 # remove journal entries
748 sql = 'delete from %s__journal where nodeid=%s'%(classname, self.arg)
749 self.sql(sql, (nodeid,))
751 def serialise(self, classname, node):
752 '''Copy the node contents, converting non-marshallable data into
753 marshallable data.
754 '''
755 if __debug__:
756 print >>hyperdb.DEBUG, 'serialise', classname, node
757 properties = self.getclass(classname).getprops()
758 d = {}
759 for k, v in node.items():
760 # if the property doesn't exist, or is the "retired" flag then
761 # it won't be in the properties dict
762 if not properties.has_key(k):
763 d[k] = v
764 continue
766 # get the property spec
767 prop = properties[k]
769 if isinstance(prop, Password) and v is not None:
770 d[k] = str(v)
771 elif isinstance(prop, Date) and v is not None:
772 d[k] = v.serialise()
773 elif isinstance(prop, Interval) and v is not None:
774 d[k] = v.serialise()
775 else:
776 d[k] = v
777 return d
779 def unserialise(self, classname, node):
780 '''Decode the marshalled node data
781 '''
782 if __debug__:
783 print >>hyperdb.DEBUG, 'unserialise', classname, node
784 properties = self.getclass(classname).getprops()
785 d = {}
786 for k, v in node.items():
787 # if the property doesn't exist, or is the "retired" flag then
788 # it won't be in the properties dict
789 if not properties.has_key(k):
790 d[k] = v
791 continue
793 # get the property spec
794 prop = properties[k]
796 if isinstance(prop, Date) and v is not None:
797 d[k] = date.Date(v)
798 elif isinstance(prop, Interval) and v is not None:
799 d[k] = date.Interval(v)
800 elif isinstance(prop, Password) and v is not None:
801 p = password.Password()
802 p.unpack(v)
803 d[k] = p
804 elif isinstance(prop, Boolean) and v is not None:
805 d[k] = int(v)
806 elif isinstance(prop, Number) and v is not None:
807 # try int first, then assume it's a float
808 try:
809 d[k] = int(v)
810 except ValueError:
811 d[k] = float(v)
812 else:
813 d[k] = v
814 return d
816 def hasnode(self, classname, nodeid):
817 ''' Determine if the database has a given node.
818 '''
819 sql = 'select count(*) from _%s where id=%s'%(classname, self.arg)
820 if __debug__:
821 print >>hyperdb.DEBUG, 'hasnode', (self, sql, nodeid)
822 self.cursor.execute(sql, (nodeid,))
823 return int(self.cursor.fetchone()[0])
825 def countnodes(self, classname):
826 ''' Count the number of nodes that exist for a particular Class.
827 '''
828 sql = 'select count(*) from _%s'%classname
829 if __debug__:
830 print >>hyperdb.DEBUG, 'countnodes', (self, sql)
831 self.cursor.execute(sql)
832 return self.cursor.fetchone()[0]
834 def addjournal(self, classname, nodeid, action, params, creator=None,
835 creation=None):
836 ''' Journal the Action
837 'action' may be:
839 'create' or 'set' -- 'params' is a dictionary of property values
840 'link' or 'unlink' -- 'params' is (classname, nodeid, propname)
841 'retire' -- 'params' is None
842 '''
843 # serialise the parameters now if necessary
844 if isinstance(params, type({})):
845 if action in ('set', 'create'):
846 params = self.serialise(classname, params)
848 # handle supply of the special journalling parameters (usually
849 # supplied on importing an existing database)
850 if creator:
851 journaltag = creator
852 else:
853 journaltag = self.getuid()
854 if creation:
855 journaldate = creation.serialise()
856 else:
857 journaldate = date.Date().serialise()
859 # create the journal entry
860 cols = ','.join('nodeid date tag action params'.split())
862 if __debug__:
863 print >>hyperdb.DEBUG, 'addjournal', (nodeid, journaldate,
864 journaltag, action, params)
866 self.save_journal(classname, cols, nodeid, journaldate,
867 journaltag, action, params)
869 def getjournal(self, classname, nodeid):
870 ''' get the journal for id
871 '''
872 # make sure the node exists
873 if not self.hasnode(classname, nodeid):
874 raise IndexError, '%s has no node %s'%(classname, nodeid)
876 cols = ','.join('nodeid date tag action params'.split())
877 return self.load_journal(classname, cols, nodeid)
879 def save_journal(self, classname, cols, nodeid, journaldate,
880 journaltag, action, params):
881 ''' Save the journal entry to the database
882 '''
883 # make the params db-friendly
884 params = repr(params)
885 entry = (nodeid, journaldate, journaltag, action, params)
887 # do the insert
888 a = self.arg
889 sql = 'insert into %s__journal (%s) values (%s,%s,%s,%s,%s)'%(classname,
890 cols, a, a, a, a, a)
891 if __debug__:
892 print >>hyperdb.DEBUG, 'addjournal', (self, sql, entry)
893 self.cursor.execute(sql, entry)
895 def load_journal(self, classname, cols, nodeid):
896 ''' Load the journal from the database
897 '''
898 # now get the journal entries
899 sql = 'select %s from %s__journal where nodeid=%s'%(cols, classname,
900 self.arg)
901 if __debug__:
902 print >>hyperdb.DEBUG, 'load_journal', (self, sql, nodeid)
903 self.cursor.execute(sql, (nodeid,))
904 res = []
905 for nodeid, date_stamp, user, action, params in self.cursor.fetchall():
906 params = eval(params)
907 res.append((nodeid, date.Date(date_stamp), user, action, params))
908 return res
910 def pack(self, pack_before):
911 ''' Delete all journal entries except "create" before 'pack_before'.
912 '''
913 # get a 'yyyymmddhhmmss' version of the date
914 date_stamp = pack_before.serialise()
916 # do the delete
917 for classname in self.classes.keys():
918 sql = "delete from %s__journal where date<%s and "\
919 "action<>'create'"%(classname, self.arg)
920 if __debug__:
921 print >>hyperdb.DEBUG, 'pack', (self, sql, date_stamp)
922 self.cursor.execute(sql, (date_stamp,))
924 def sql_commit(self):
925 ''' Actually commit to the database.
926 '''
927 self.conn.commit()
929 def commit(self):
930 ''' Commit the current transactions.
932 Save all data changed since the database was opened or since the
933 last commit() or rollback().
934 '''
935 if __debug__:
936 print >>hyperdb.DEBUG, 'commit', (self,)
938 # commit the database
939 self.sql_commit()
941 # now, do all the other transaction stuff
942 reindex = {}
943 for method, args in self.transactions:
944 reindex[method(*args)] = 1
946 # reindex the nodes that request it
947 for classname, nodeid in filter(None, reindex.keys()):
948 print >>hyperdb.DEBUG, 'commit.reindex', (classname, nodeid)
949 self.getclass(classname).index(nodeid)
951 # save the indexer state
952 self.indexer.save_index()
954 # clear out the transactions
955 self.transactions = []
957 def sql_rollback(self):
958 self.conn.rollback()
960 def rollback(self):
961 ''' Reverse all actions from the current transaction.
963 Undo all the changes made since the database was opened or the last
964 commit() or rollback() was performed.
965 '''
966 if __debug__:
967 print >>hyperdb.DEBUG, 'rollback', (self,)
969 self.sql_rollback()
971 # roll back "other" transaction stuff
972 for method, args in self.transactions:
973 # delete temporary files
974 if method == self.doStoreFile:
975 self.rollbackStoreFile(*args)
976 self.transactions = []
978 # clear the cache
979 self.clearCache()
981 def doSaveNode(self, classname, nodeid, node):
982 ''' dummy that just generates a reindex event
983 '''
984 # return the classname, nodeid so we reindex this content
985 return (classname, nodeid)
987 def sql_close(self):
988 self.conn.close()
990 def close(self):
991 ''' Close off the connection.
992 '''
993 self.sql_close()
994 if self.lockfile is not None:
995 locking.release_lock(self.lockfile)
996 if self.lockfile is not None:
997 self.lockfile.close()
998 self.lockfile = None
1000 #
1001 # The base Class class
1002 #
1003 class Class(hyperdb.Class):
1004 ''' The handle to a particular class of nodes in a hyperdatabase.
1006 All methods except __repr__ and getnode must be implemented by a
1007 concrete backend Class.
1008 '''
1010 def __init__(self, db, classname, **properties):
1011 '''Create a new class with a given name and property specification.
1013 'classname' must not collide with the name of an existing class,
1014 or a ValueError is raised. The keyword arguments in 'properties'
1015 must map names to property objects, or a TypeError is raised.
1016 '''
1017 if (properties.has_key('creation') or properties.has_key('activity')
1018 or properties.has_key('creator')):
1019 raise ValueError, '"creation", "activity" and "creator" are '\
1020 'reserved'
1022 self.classname = classname
1023 self.properties = properties
1024 self.db = weakref.proxy(db) # use a weak ref to avoid circularity
1025 self.key = ''
1027 # should we journal changes (default yes)
1028 self.do_journal = 1
1030 # do the db-related init stuff
1031 db.addclass(self)
1033 self.auditors = {'create': [], 'set': [], 'retire': [], 'restore': []}
1034 self.reactors = {'create': [], 'set': [], 'retire': [], 'restore': []}
1036 def schema(self):
1037 ''' A dumpable version of the schema that we can store in the
1038 database
1039 '''
1040 return (self.key, [(x, repr(y)) for x,y in self.properties.items()])
1042 def enableJournalling(self):
1043 '''Turn journalling on for this class
1044 '''
1045 self.do_journal = 1
1047 def disableJournalling(self):
1048 '''Turn journalling off for this class
1049 '''
1050 self.do_journal = 0
1052 # Editing nodes:
1053 def create(self, **propvalues):
1054 ''' Create a new node of this class and return its id.
1056 The keyword arguments in 'propvalues' map property names to values.
1058 The values of arguments must be acceptable for the types of their
1059 corresponding properties or a TypeError is raised.
1061 If this class has a key property, it must be present and its value
1062 must not collide with other key strings or a ValueError is raised.
1064 Any other properties on this class that are missing from the
1065 'propvalues' dictionary are set to None.
1067 If an id in a link or multilink property does not refer to a valid
1068 node, an IndexError is raised.
1069 '''
1070 self.fireAuditors('create', None, propvalues)
1071 newid = self.create_inner(**propvalues)
1072 self.fireReactors('create', newid, None)
1073 return newid
1075 def create_inner(self, **propvalues):
1076 ''' Called by create, in-between the audit and react calls.
1077 '''
1078 if propvalues.has_key('id'):
1079 raise KeyError, '"id" is reserved'
1081 if self.db.journaltag is None:
1082 raise DatabaseError, 'Database open read-only'
1084 if propvalues.has_key('creation') or propvalues.has_key('activity'):
1085 raise KeyError, '"creation" and "activity" are reserved'
1087 # new node's id
1088 newid = self.db.newid(self.classname)
1090 # validate propvalues
1091 num_re = re.compile('^\d+$')
1092 for key, value in propvalues.items():
1093 if key == self.key:
1094 try:
1095 self.lookup(value)
1096 except KeyError:
1097 pass
1098 else:
1099 raise ValueError, 'node with key "%s" exists'%value
1101 # try to handle this property
1102 try:
1103 prop = self.properties[key]
1104 except KeyError:
1105 raise KeyError, '"%s" has no property "%s"'%(self.classname,
1106 key)
1108 if value is not None and isinstance(prop, Link):
1109 if type(value) != type(''):
1110 raise ValueError, 'link value must be String'
1111 link_class = self.properties[key].classname
1112 # if it isn't a number, it's a key
1113 if not num_re.match(value):
1114 try:
1115 value = self.db.classes[link_class].lookup(value)
1116 except (TypeError, KeyError):
1117 raise IndexError, 'new property "%s": %s not a %s'%(
1118 key, value, link_class)
1119 elif not self.db.getclass(link_class).hasnode(value):
1120 raise IndexError, '%s has no node %s'%(link_class, value)
1122 # save off the value
1123 propvalues[key] = value
1125 # register the link with the newly linked node
1126 if self.do_journal and self.properties[key].do_journal:
1127 self.db.addjournal(link_class, value, 'link',
1128 (self.classname, newid, key))
1130 elif isinstance(prop, Multilink):
1131 if type(value) != type([]):
1132 raise TypeError, 'new property "%s" not a list of ids'%key
1134 # clean up and validate the list of links
1135 link_class = self.properties[key].classname
1136 l = []
1137 for entry in value:
1138 if type(entry) != type(''):
1139 raise ValueError, '"%s" multilink value (%r) '\
1140 'must contain Strings'%(key, value)
1141 # if it isn't a number, it's a key
1142 if not num_re.match(entry):
1143 try:
1144 entry = self.db.classes[link_class].lookup(entry)
1145 except (TypeError, KeyError):
1146 raise IndexError, 'new property "%s": %s not a %s'%(
1147 key, entry, self.properties[key].classname)
1148 l.append(entry)
1149 value = l
1150 propvalues[key] = value
1152 # handle additions
1153 for nodeid in value:
1154 if not self.db.getclass(link_class).hasnode(nodeid):
1155 raise IndexError, '%s has no node %s'%(link_class,
1156 nodeid)
1157 # register the link with the newly linked node
1158 if self.do_journal and self.properties[key].do_journal:
1159 self.db.addjournal(link_class, nodeid, 'link',
1160 (self.classname, newid, key))
1162 elif isinstance(prop, String):
1163 if type(value) != type('') and type(value) != type(u''):
1164 raise TypeError, 'new property "%s" not a string'%key
1166 elif isinstance(prop, Password):
1167 if not isinstance(value, password.Password):
1168 raise TypeError, 'new property "%s" not a Password'%key
1170 elif isinstance(prop, Date):
1171 if value is not None and not isinstance(value, date.Date):
1172 raise TypeError, 'new property "%s" not a Date'%key
1174 elif isinstance(prop, Interval):
1175 if value is not None and not isinstance(value, date.Interval):
1176 raise TypeError, 'new property "%s" not an Interval'%key
1178 elif value is not None and isinstance(prop, Number):
1179 try:
1180 float(value)
1181 except ValueError:
1182 raise TypeError, 'new property "%s" not numeric'%key
1184 elif value is not None and isinstance(prop, Boolean):
1185 try:
1186 int(value)
1187 except ValueError:
1188 raise TypeError, 'new property "%s" not boolean'%key
1190 # make sure there's data where there needs to be
1191 for key, prop in self.properties.items():
1192 if propvalues.has_key(key):
1193 continue
1194 if key == self.key:
1195 raise ValueError, 'key property "%s" is required'%key
1196 if isinstance(prop, Multilink):
1197 propvalues[key] = []
1198 else:
1199 propvalues[key] = None
1201 # done
1202 self.db.addnode(self.classname, newid, propvalues)
1203 if self.do_journal:
1204 self.db.addjournal(self.classname, newid, 'create', {})
1206 return newid
1208 def export_list(self, propnames, nodeid):
1209 ''' Export a node - generate a list of CSV-able data in the order
1210 specified by propnames for the given node.
1211 '''
1212 properties = self.getprops()
1213 l = []
1214 for prop in propnames:
1215 proptype = properties[prop]
1216 value = self.get(nodeid, prop)
1217 # "marshal" data where needed
1218 if value is None:
1219 pass
1220 elif isinstance(proptype, hyperdb.Date):
1221 value = value.get_tuple()
1222 elif isinstance(proptype, hyperdb.Interval):
1223 value = value.get_tuple()
1224 elif isinstance(proptype, hyperdb.Password):
1225 value = str(value)
1226 l.append(repr(value))
1227 l.append(repr(self.is_retired(nodeid)))
1228 return l
1230 def import_list(self, propnames, proplist):
1231 ''' Import a node - all information including "id" is present and
1232 should not be sanity checked. Triggers are not triggered. The
1233 journal should be initialised using the "creator" and "created"
1234 information.
1236 Return the nodeid of the node imported.
1237 '''
1238 if self.db.journaltag is None:
1239 raise DatabaseError, 'Database open read-only'
1240 properties = self.getprops()
1242 # make the new node's property map
1243 d = {}
1244 retire = 0
1245 newid = None
1246 for i in range(len(propnames)):
1247 # Use eval to reverse the repr() used to output the CSV
1248 value = eval(proplist[i])
1250 # Figure the property for this column
1251 propname = propnames[i]
1253 # "unmarshal" where necessary
1254 if propname == 'id':
1255 newid = value
1256 continue
1257 elif propname == 'is retired':
1258 # is the item retired?
1259 if int(value):
1260 retire = 1
1261 continue
1262 elif value is None:
1263 d[propname] = None
1264 continue
1266 prop = properties[propname]
1267 if value is None:
1268 # don't set Nones
1269 continue
1270 elif isinstance(prop, hyperdb.Date):
1271 value = date.Date(value)
1272 elif isinstance(prop, hyperdb.Interval):
1273 value = date.Interval(value)
1274 elif isinstance(prop, hyperdb.Password):
1275 pwd = password.Password()
1276 pwd.unpack(value)
1277 value = pwd
1278 d[propname] = value
1280 # get a new id if necessary
1281 if newid is None:
1282 newid = self.db.newid(self.classname)
1284 # add the node and journal
1285 self.db.addnode(self.classname, newid, d)
1287 # retire?
1288 if retire:
1289 # use the arg for __retired__ to cope with any odd database type
1290 # conversion (hello, sqlite)
1291 sql = 'update _%s set __retired__=%s where id=%s'%(self.classname,
1292 self.db.arg, self.db.arg)
1293 if __debug__:
1294 print >>hyperdb.DEBUG, 'retire', (self, sql, newid)
1295 self.db.cursor.execute(sql, (1, newid))
1297 # extract the extraneous journalling gumpf and nuke it
1298 if d.has_key('creator'):
1299 creator = d['creator']
1300 del d['creator']
1301 else:
1302 creator = None
1303 if d.has_key('creation'):
1304 creation = d['creation']
1305 del d['creation']
1306 else:
1307 creation = None
1308 if d.has_key('activity'):
1309 del d['activity']
1310 self.db.addjournal(self.classname, newid, 'create', {}, creator,
1311 creation)
1312 return newid
1314 _marker = []
1315 def get(self, nodeid, propname, default=_marker, cache=1):
1316 '''Get the value of a property on an existing node of this class.
1318 'nodeid' must be the id of an existing node of this class or an
1319 IndexError is raised. 'propname' must be the name of a property
1320 of this class or a KeyError is raised.
1322 'cache' exists for backwards compatibility, and is not used.
1323 '''
1324 if propname == 'id':
1325 return nodeid
1327 # get the node's dict
1328 d = self.db.getnode(self.classname, nodeid)
1330 if propname == 'creation':
1331 if d.has_key('creation'):
1332 return d['creation']
1333 else:
1334 return date.Date()
1335 if propname == 'activity':
1336 if d.has_key('activity'):
1337 return d['activity']
1338 else:
1339 return date.Date()
1340 if propname == 'creator':
1341 if d.has_key('creator'):
1342 return d['creator']
1343 else:
1344 return self.db.getuid()
1346 # get the property (raises KeyErorr if invalid)
1347 prop = self.properties[propname]
1349 if not d.has_key(propname):
1350 if default is self._marker:
1351 if isinstance(prop, Multilink):
1352 return []
1353 else:
1354 return None
1355 else:
1356 return default
1358 # don't pass our list to other code
1359 if isinstance(prop, Multilink):
1360 return d[propname][:]
1362 return d[propname]
1364 def set(self, nodeid, **propvalues):
1365 '''Modify a property on an existing node of this class.
1367 'nodeid' must be the id of an existing node of this class or an
1368 IndexError is raised.
1370 Each key in 'propvalues' must be the name of a property of this
1371 class or a KeyError is raised.
1373 All values in 'propvalues' must be acceptable types for their
1374 corresponding properties or a TypeError is raised.
1376 If the value of the key property is set, it must not collide with
1377 other key strings or a ValueError is raised.
1379 If the value of a Link or Multilink property contains an invalid
1380 node id, a ValueError is raised.
1381 '''
1382 if not propvalues:
1383 return propvalues
1385 if propvalues.has_key('creation') or propvalues.has_key('activity'):
1386 raise KeyError, '"creation" and "activity" are reserved'
1388 if propvalues.has_key('id'):
1389 raise KeyError, '"id" is reserved'
1391 if self.db.journaltag is None:
1392 raise DatabaseError, 'Database open read-only'
1394 self.fireAuditors('set', nodeid, propvalues)
1395 # Take a copy of the node dict so that the subsequent set
1396 # operation doesn't modify the oldvalues structure.
1397 # XXX used to try the cache here first
1398 oldvalues = copy.deepcopy(self.db.getnode(self.classname, nodeid))
1400 node = self.db.getnode(self.classname, nodeid)
1401 if self.is_retired(nodeid):
1402 raise IndexError, 'Requested item is retired'
1403 num_re = re.compile('^\d+$')
1405 # if the journal value is to be different, store it in here
1406 journalvalues = {}
1408 # remember the add/remove stuff for multilinks, making it easier
1409 # for the Database layer to do its stuff
1410 multilink_changes = {}
1412 for propname, value in propvalues.items():
1413 # check to make sure we're not duplicating an existing key
1414 if propname == self.key and node[propname] != value:
1415 try:
1416 self.lookup(value)
1417 except KeyError:
1418 pass
1419 else:
1420 raise ValueError, 'node with key "%s" exists'%value
1422 # this will raise the KeyError if the property isn't valid
1423 # ... we don't use getprops() here because we only care about
1424 # the writeable properties.
1425 try:
1426 prop = self.properties[propname]
1427 except KeyError:
1428 raise KeyError, '"%s" has no property named "%s"'%(
1429 self.classname, propname)
1431 # if the value's the same as the existing value, no sense in
1432 # doing anything
1433 current = node.get(propname, None)
1434 if value == current:
1435 del propvalues[propname]
1436 continue
1437 journalvalues[propname] = current
1439 # do stuff based on the prop type
1440 if isinstance(prop, Link):
1441 link_class = prop.classname
1442 # if it isn't a number, it's a key
1443 if value is not None and not isinstance(value, type('')):
1444 raise ValueError, 'property "%s" link value be a string'%(
1445 propname)
1446 if isinstance(value, type('')) and not num_re.match(value):
1447 try:
1448 value = self.db.classes[link_class].lookup(value)
1449 except (TypeError, KeyError):
1450 raise IndexError, 'new property "%s": %s not a %s'%(
1451 propname, value, prop.classname)
1453 if (value is not None and
1454 not self.db.getclass(link_class).hasnode(value)):
1455 raise IndexError, '%s has no node %s'%(link_class, value)
1457 if self.do_journal and prop.do_journal:
1458 # register the unlink with the old linked node
1459 if node[propname] is not None:
1460 self.db.addjournal(link_class, node[propname], 'unlink',
1461 (self.classname, nodeid, propname))
1463 # register the link with the newly linked node
1464 if value is not None:
1465 self.db.addjournal(link_class, value, 'link',
1466 (self.classname, nodeid, propname))
1468 elif isinstance(prop, Multilink):
1469 if type(value) != type([]):
1470 raise TypeError, 'new property "%s" not a list of'\
1471 ' ids'%propname
1472 link_class = self.properties[propname].classname
1473 l = []
1474 for entry in value:
1475 # if it isn't a number, it's a key
1476 if type(entry) != type(''):
1477 raise ValueError, 'new property "%s" link value ' \
1478 'must be a string'%propname
1479 if not num_re.match(entry):
1480 try:
1481 entry = self.db.classes[link_class].lookup(entry)
1482 except (TypeError, KeyError):
1483 raise IndexError, 'new property "%s": %s not a %s'%(
1484 propname, entry,
1485 self.properties[propname].classname)
1486 l.append(entry)
1487 value = l
1488 propvalues[propname] = value
1490 # figure the journal entry for this property
1491 add = []
1492 remove = []
1494 # handle removals
1495 if node.has_key(propname):
1496 l = node[propname]
1497 else:
1498 l = []
1499 for id in l[:]:
1500 if id in value:
1501 continue
1502 # register the unlink with the old linked node
1503 if self.do_journal and self.properties[propname].do_journal:
1504 self.db.addjournal(link_class, id, 'unlink',
1505 (self.classname, nodeid, propname))
1506 l.remove(id)
1507 remove.append(id)
1509 # handle additions
1510 for id in value:
1511 if not self.db.getclass(link_class).hasnode(id):
1512 raise IndexError, '%s has no node %s'%(link_class, id)
1513 if id in l:
1514 continue
1515 # register the link with the newly linked node
1516 if self.do_journal and self.properties[propname].do_journal:
1517 self.db.addjournal(link_class, id, 'link',
1518 (self.classname, nodeid, propname))
1519 l.append(id)
1520 add.append(id)
1522 # figure the journal entry
1523 l = []
1524 if add:
1525 l.append(('+', add))
1526 if remove:
1527 l.append(('-', remove))
1528 multilink_changes[propname] = (add, remove)
1529 if l:
1530 journalvalues[propname] = tuple(l)
1532 elif isinstance(prop, String):
1533 if value is not None and type(value) != type('') and type(value) != type(u''):
1534 raise TypeError, 'new property "%s" not a string'%propname
1536 elif isinstance(prop, Password):
1537 if not isinstance(value, password.Password):
1538 raise TypeError, 'new property "%s" not a Password'%propname
1539 propvalues[propname] = value
1541 elif value is not None and isinstance(prop, Date):
1542 if not isinstance(value, date.Date):
1543 raise TypeError, 'new property "%s" not a Date'% propname
1544 propvalues[propname] = value
1546 elif value is not None and isinstance(prop, Interval):
1547 if not isinstance(value, date.Interval):
1548 raise TypeError, 'new property "%s" not an '\
1549 'Interval'%propname
1550 propvalues[propname] = value
1552 elif value is not None and isinstance(prop, Number):
1553 try:
1554 float(value)
1555 except ValueError:
1556 raise TypeError, 'new property "%s" not numeric'%propname
1558 elif value is not None and isinstance(prop, Boolean):
1559 try:
1560 int(value)
1561 except ValueError:
1562 raise TypeError, 'new property "%s" not boolean'%propname
1564 # nothing to do?
1565 if not propvalues:
1566 return propvalues
1568 # do the set, and journal it
1569 self.db.setnode(self.classname, nodeid, propvalues, multilink_changes)
1571 if self.do_journal:
1572 self.db.addjournal(self.classname, nodeid, 'set', journalvalues)
1574 self.fireReactors('set', nodeid, oldvalues)
1576 return propvalues
1578 def retire(self, nodeid):
1579 '''Retire a node.
1581 The properties on the node remain available from the get() method,
1582 and the node's id is never reused.
1584 Retired nodes are not returned by the find(), list(), or lookup()
1585 methods, and other nodes may reuse the values of their key properties.
1586 '''
1587 if self.db.journaltag is None:
1588 raise DatabaseError, 'Database open read-only'
1590 self.fireAuditors('retire', nodeid, None)
1592 # use the arg for __retired__ to cope with any odd database type
1593 # conversion (hello, sqlite)
1594 sql = 'update _%s set __retired__=%s where id=%s'%(self.classname,
1595 self.db.arg, self.db.arg)
1596 if __debug__:
1597 print >>hyperdb.DEBUG, 'retire', (self, sql, nodeid)
1598 self.db.cursor.execute(sql, (1, nodeid))
1599 if self.do_journal:
1600 self.db.addjournal(self.classname, nodeid, 'retired', None)
1602 self.fireReactors('retire', nodeid, None)
1604 def restore(self, nodeid):
1605 '''Restore a retired node.
1607 Make node available for all operations like it was before retirement.
1608 '''
1609 if self.db.journaltag is None:
1610 raise DatabaseError, 'Database open read-only'
1612 node = self.db.getnode(self.classname, nodeid)
1613 # check if key property was overrided
1614 key = self.getkey()
1615 try:
1616 id = self.lookup(node[key])
1617 except KeyError:
1618 pass
1619 else:
1620 raise KeyError, "Key property (%s) of retired node clashes with \
1621 existing one (%s)" % (key, node[key])
1623 self.fireAuditors('restore', nodeid, None)
1624 # use the arg for __retired__ to cope with any odd database type
1625 # conversion (hello, sqlite)
1626 sql = 'update _%s set __retired__=%s where id=%s'%(self.classname,
1627 self.db.arg, self.db.arg)
1628 if __debug__:
1629 print >>hyperdb.DEBUG, 'restore', (self, sql, nodeid)
1630 self.db.cursor.execute(sql, (0, nodeid))
1631 if self.do_journal:
1632 self.db.addjournal(self.classname, nodeid, 'restored', None)
1634 self.fireReactors('restore', nodeid, None)
1636 def is_retired(self, nodeid):
1637 '''Return true if the node is rerired
1638 '''
1639 sql = 'select __retired__ from _%s where id=%s'%(self.classname,
1640 self.db.arg)
1641 if __debug__:
1642 print >>hyperdb.DEBUG, 'is_retired', (self, sql, nodeid)
1643 self.db.cursor.execute(sql, (nodeid,))
1644 return int(self.db.sql_fetchone()[0])
1646 def destroy(self, nodeid):
1647 '''Destroy a node.
1649 WARNING: this method should never be used except in extremely rare
1650 situations where there could never be links to the node being
1651 deleted
1652 WARNING: use retire() instead
1653 WARNING: the properties of this node will not be available ever again
1654 WARNING: really, use retire() instead
1656 Well, I think that's enough warnings. This method exists mostly to
1657 support the session storage of the cgi interface.
1659 The node is completely removed from the hyperdb, including all journal
1660 entries. It will no longer be available, and will generally break code
1661 if there are any references to the node.
1662 '''
1663 if self.db.journaltag is None:
1664 raise DatabaseError, 'Database open read-only'
1665 self.db.destroynode(self.classname, nodeid)
1667 def history(self, nodeid):
1668 '''Retrieve the journal of edits on a particular node.
1670 'nodeid' must be the id of an existing node of this class or an
1671 IndexError is raised.
1673 The returned list contains tuples of the form
1675 (nodeid, date, tag, action, params)
1677 'date' is a Timestamp object specifying the time of the change and
1678 'tag' is the journaltag specified when the database was opened.
1679 '''
1680 if not self.do_journal:
1681 raise ValueError, 'Journalling is disabled for this class'
1682 return self.db.getjournal(self.classname, nodeid)
1684 # Locating nodes:
1685 def hasnode(self, nodeid):
1686 '''Determine if the given nodeid actually exists
1687 '''
1688 return self.db.hasnode(self.classname, nodeid)
1690 def setkey(self, propname):
1691 '''Select a String property of this class to be the key property.
1693 'propname' must be the name of a String property of this class or
1694 None, or a TypeError is raised. The values of the key property on
1695 all existing nodes must be unique or a ValueError is raised.
1696 '''
1697 # XXX create an index on the key prop column. We should also
1698 # record that we've created this index in the schema somewhere.
1699 prop = self.getprops()[propname]
1700 if not isinstance(prop, String):
1701 raise TypeError, 'key properties must be String'
1702 self.key = propname
1704 def getkey(self):
1705 '''Return the name of the key property for this class or None.'''
1706 return self.key
1708 def labelprop(self, default_to_id=0):
1709 ''' Return the property name for a label for the given node.
1711 This method attempts to generate a consistent label for the node.
1712 It tries the following in order:
1713 1. key property
1714 2. "name" property
1715 3. "title" property
1716 4. first property from the sorted property name list
1717 '''
1718 k = self.getkey()
1719 if k:
1720 return k
1721 props = self.getprops()
1722 if props.has_key('name'):
1723 return 'name'
1724 elif props.has_key('title'):
1725 return 'title'
1726 if default_to_id:
1727 return 'id'
1728 props = props.keys()
1729 props.sort()
1730 return props[0]
1732 def lookup(self, keyvalue):
1733 '''Locate a particular node by its key property and return its id.
1735 If this class has no key property, a TypeError is raised. If the
1736 'keyvalue' matches one of the values for the key property among
1737 the nodes in this class, the matching node's id is returned;
1738 otherwise a KeyError is raised.
1739 '''
1740 if not self.key:
1741 raise TypeError, 'No key property set for class %s'%self.classname
1743 # use the arg to handle any odd database type conversion (hello,
1744 # sqlite)
1745 sql = "select id from _%s where _%s=%s and __retired__ <> %s"%(
1746 self.classname, self.key, self.db.arg, self.db.arg)
1747 self.db.sql(sql, (keyvalue, 1))
1749 # see if there was a result that's not retired
1750 row = self.db.sql_fetchone()
1751 if not row:
1752 raise KeyError, 'No key (%s) value "%s" for "%s"'%(self.key,
1753 keyvalue, self.classname)
1755 # return the id
1756 return row[0]
1758 def find(self, **propspec):
1759 '''Get the ids of nodes in this class which link to the given nodes.
1761 'propspec' consists of keyword args propname=nodeid or
1762 propname={nodeid:1, }
1763 'propname' must be the name of a property in this class, or a
1764 KeyError is raised. That property must be a Link or
1765 Multilink property, or a TypeError is raised.
1767 Any node in this class whose 'propname' property links to any of the
1768 nodeids will be returned. Used by the full text indexing, which knows
1769 that "foo" occurs in msg1, msg3 and file7, so we have hits on these
1770 issues:
1772 db.issue.find(messages={'1':1,'3':1}, files={'7':1})
1773 '''
1774 if __debug__:
1775 print >>hyperdb.DEBUG, 'find', (self, propspec)
1777 # shortcut
1778 if not propspec:
1779 return []
1781 # validate the args
1782 props = self.getprops()
1783 propspec = propspec.items()
1784 for propname, nodeids in propspec:
1785 # check the prop is OK
1786 prop = props[propname]
1787 if not isinstance(prop, Link) and not isinstance(prop, Multilink):
1788 raise TypeError, "'%s' not a Link/Multilink property"%propname
1790 # first, links
1791 a = self.db.arg
1792 where = ['__retired__ <> %s'%a]
1793 allvalues = (1,)
1794 for prop, values in propspec:
1795 if not isinstance(props[prop], hyperdb.Link):
1796 continue
1797 if type(values) is type({}) and len(values) == 1:
1798 values = values.keys()[0]
1799 if type(values) is type(''):
1800 allvalues += (values,)
1801 where.append('_%s = %s'%(prop, a))
1802 elif values is None:
1803 where.append('_%s is NULL'%prop)
1804 else:
1805 allvalues += tuple(values.keys())
1806 where.append('_%s in (%s)'%(prop, ','.join([a]*len(values))))
1807 tables = []
1808 if where:
1809 tables.append('select id as nodeid from _%s where %s'%(
1810 self.classname, ' and '.join(where)))
1812 # now multilinks
1813 for prop, values in propspec:
1814 if not isinstance(props[prop], hyperdb.Multilink):
1815 continue
1816 if type(values) is type(''):
1817 allvalues += (values,)
1818 s = a
1819 else:
1820 allvalues += tuple(values.keys())
1821 s = ','.join([a]*len(values))
1822 tables.append('select nodeid from %s_%s where linkid in (%s)'%(
1823 self.classname, prop, s))
1825 sql = '\nintersect\n'.join(tables)
1826 self.db.sql(sql, allvalues)
1827 l = [x[0] for x in self.db.sql_fetchall()]
1828 if __debug__:
1829 print >>hyperdb.DEBUG, 'find ... ', l
1830 return l
1832 def stringFind(self, **requirements):
1833 '''Locate a particular node by matching a set of its String
1834 properties in a caseless search.
1836 If the property is not a String property, a TypeError is raised.
1838 The return is a list of the id of all nodes that match.
1839 '''
1840 where = []
1841 args = []
1842 for propname in requirements.keys():
1843 prop = self.properties[propname]
1844 if not isinstance(prop, String):
1845 raise TypeError, "'%s' not a String property"%propname
1846 where.append(propname)
1847 args.append(requirements[propname].lower())
1849 # generate the where clause
1850 s = ' and '.join(['lower(_%s)=%s'%(col, self.db.arg) for col in where])
1851 sql = 'select id from _%s where %s and __retired__=%s'%(self.classname,
1852 s, self.db.arg)
1853 args.append(0)
1854 self.db.sql(sql, tuple(args))
1855 l = [x[0] for x in self.db.sql_fetchall()]
1856 if __debug__:
1857 print >>hyperdb.DEBUG, 'find ... ', l
1858 return l
1860 def list(self):
1861 ''' Return a list of the ids of the active nodes in this class.
1862 '''
1863 return self.getnodeids(retired=0)
1865 def getnodeids(self, retired=None):
1866 ''' Retrieve all the ids of the nodes for a particular Class.
1868 Set retired=None to get all nodes. Otherwise it'll get all the
1869 retired or non-retired nodes, depending on the flag.
1870 '''
1871 # flip the sense of the 'retired' flag if we don't want all of them
1872 if retired is not None:
1873 if retired:
1874 args = (0, )
1875 else:
1876 args = (1, )
1877 sql = 'select id from _%s where __retired__ <> %s'%(self.classname,
1878 self.db.arg)
1879 else:
1880 args = ()
1881 sql = 'select id from _%s'%self.classname
1882 if __debug__:
1883 print >>hyperdb.DEBUG, 'getnodeids', (self, sql, retired)
1884 self.db.cursor.execute(sql, args)
1885 ids = [x[0] for x in self.db.cursor.fetchall()]
1886 return ids
1888 def filter(self, search_matches, filterspec, sort=(None,None),
1889 group=(None,None)):
1890 ''' Return a list of the ids of the active nodes in this class that
1891 match the 'filter' spec, sorted by the group spec and then the
1892 sort spec
1894 "filterspec" is {propname: value(s)}
1895 "sort" and "group" are (dir, prop) where dir is '+', '-' or None
1896 and prop is a prop name or None
1897 "search_matches" is {nodeid: marker}
1899 The filter must match all properties specificed - but if the
1900 property value to match is a list, any one of the values in the
1901 list may match for that property to match.
1902 '''
1903 # just don't bother if the full-text search matched diddly
1904 if search_matches == {}:
1905 return []
1907 cn = self.classname
1909 timezone = self.db.getUserTimezone()
1911 # figure the WHERE clause from the filterspec
1912 props = self.getprops()
1913 frum = ['_'+cn]
1914 where = []
1915 args = []
1916 a = self.db.arg
1917 for k, v in filterspec.items():
1918 propclass = props[k]
1919 # now do other where clause stuff
1920 if isinstance(propclass, Multilink):
1921 tn = '%s_%s'%(cn, k)
1922 if v in ('-1', ['-1']):
1923 # only match rows that have count(linkid)=0 in the
1924 # corresponding multilink table)
1925 where.append('id not in (select nodeid from %s)'%tn)
1926 elif isinstance(v, type([])):
1927 frum.append(tn)
1928 s = ','.join([a for x in v])
1929 where.append('id=%s.nodeid and %s.linkid in (%s)'%(tn,tn,s))
1930 args = args + v
1931 else:
1932 frum.append(tn)
1933 where.append('id=%s.nodeid and %s.linkid=%s'%(tn, tn, a))
1934 args.append(v)
1935 elif k == 'id':
1936 if isinstance(v, type([])):
1937 s = ','.join([a for x in v])
1938 where.append('%s in (%s)'%(k, s))
1939 args = args + v
1940 else:
1941 where.append('%s=%s'%(k, a))
1942 args.append(v)
1943 elif isinstance(propclass, String):
1944 if not isinstance(v, type([])):
1945 v = [v]
1947 # Quote the bits in the string that need it and then embed
1948 # in a "substring" search. Note - need to quote the '%' so
1949 # they make it through the python layer happily
1950 v = ['%%'+self.db.sql_stringquote(s)+'%%' for s in v]
1952 # now add to the where clause
1953 where.append(' or '.join(["_%s LIKE '%s'"%(k, s) for s in v]))
1954 # note: args are embedded in the query string now
1955 elif isinstance(propclass, Link):
1956 if isinstance(v, type([])):
1957 if '-1' in v:
1958 v = v[:]
1959 v.remove('-1')
1960 xtra = ' or _%s is NULL'%k
1961 else:
1962 xtra = ''
1963 if v:
1964 s = ','.join([a for x in v])
1965 where.append('(_%s in (%s)%s)'%(k, s, xtra))
1966 args = args + v
1967 else:
1968 where.append('_%s is NULL'%k)
1969 else:
1970 if v == '-1':
1971 v = None
1972 where.append('_%s is NULL'%k)
1973 else:
1974 where.append('_%s=%s'%(k, a))
1975 args.append(v)
1976 elif isinstance(propclass, Date):
1977 if isinstance(v, type([])):
1978 s = ','.join([a for x in v])
1979 where.append('_%s in (%s)'%(k, s))
1980 args = args + [date.Date(x).serialise() for x in v]
1981 else:
1982 try:
1983 # Try to filter on range of dates
1984 date_rng = Range(v, date.Date, offset=timezone)
1985 if (date_rng.from_value):
1986 where.append('_%s >= %s'%(k, a))
1987 args.append(date_rng.from_value.serialise())
1988 if (date_rng.to_value):
1989 where.append('_%s <= %s'%(k, a))
1990 args.append(date_rng.to_value.serialise())
1991 except ValueError:
1992 # If range creation fails - ignore that search parameter
1993 pass
1994 elif isinstance(propclass, Interval):
1995 if isinstance(v, type([])):
1996 s = ','.join([a for x in v])
1997 where.append('_%s in (%s)'%(k, s))
1998 args = args + [date.Interval(x).serialise() for x in v]
1999 else:
2000 try:
2001 # Try to filter on range of intervals
2002 date_rng = Range(v, date.Interval)
2003 if (date_rng.from_value):
2004 where.append('_%s >= %s'%(k, a))
2005 args.append(date_rng.from_value.serialise())
2006 if (date_rng.to_value):
2007 where.append('_%s <= %s'%(k, a))
2008 args.append(date_rng.to_value.serialise())
2009 except ValueError:
2010 # If range creation fails - ignore that search parameter
2011 pass
2012 #where.append('_%s=%s'%(k, a))
2013 #args.append(date.Interval(v).serialise())
2014 else:
2015 if isinstance(v, type([])):
2016 s = ','.join([a for x in v])
2017 where.append('_%s in (%s)'%(k, s))
2018 args = args + v
2019 else:
2020 where.append('_%s=%s'%(k, a))
2021 args.append(v)
2023 # don't match retired nodes
2024 where.append('__retired__ <> 1')
2026 # add results of full text search
2027 if search_matches is not None:
2028 v = search_matches.keys()
2029 s = ','.join([a for x in v])
2030 where.append('id in (%s)'%s)
2031 args = args + v
2033 # "grouping" is just the first-order sorting in the SQL fetch
2034 # can modify it...)
2035 orderby = []
2036 ordercols = []
2037 if group[0] is not None and group[1] is not None:
2038 if group[0] != '-':
2039 orderby.append('_'+group[1])
2040 ordercols.append('_'+group[1])
2041 else:
2042 orderby.append('_'+group[1]+' desc')
2043 ordercols.append('_'+group[1])
2045 # now add in the sorting
2046 group = ''
2047 if sort[0] is not None and sort[1] is not None:
2048 direction, colname = sort
2049 if direction != '-':
2050 if colname == 'id':
2051 orderby.append(colname)
2052 else:
2053 orderby.append('_'+colname)
2054 ordercols.append('_'+colname)
2055 else:
2056 if colname == 'id':
2057 orderby.append(colname+' desc')
2058 ordercols.append(colname)
2059 else:
2060 orderby.append('_'+colname+' desc')
2061 ordercols.append('_'+colname)
2063 # construct the SQL
2064 frum = ','.join(frum)
2065 if where:
2066 where = ' where ' + (' and '.join(where))
2067 else:
2068 where = ''
2069 cols = ['id']
2070 if orderby:
2071 cols = cols + ordercols
2072 order = ' order by %s'%(','.join(orderby))
2073 else:
2074 order = ''
2075 cols = ','.join(cols)
2076 sql = 'select %s from %s %s%s%s'%(cols, frum, where, group, order)
2077 args = tuple(args)
2078 if __debug__:
2079 print >>hyperdb.DEBUG, 'filter', (self, sql, args)
2080 if args:
2081 self.db.cursor.execute(sql, args)
2082 else:
2083 # psycopg doesn't like empty args
2084 self.db.cursor.execute(sql)
2085 l = self.db.sql_fetchall()
2087 # return the IDs (the first column)
2088 return [row[0] for row in l]
2090 def count(self):
2091 '''Get the number of nodes in this class.
2093 If the returned integer is 'numnodes', the ids of all the nodes
2094 in this class run from 1 to numnodes, and numnodes+1 will be the
2095 id of the next node to be created in this class.
2096 '''
2097 return self.db.countnodes(self.classname)
2099 # Manipulating properties:
2100 def getprops(self, protected=1):
2101 '''Return a dictionary mapping property names to property objects.
2102 If the "protected" flag is true, we include protected properties -
2103 those which may not be modified.
2104 '''
2105 d = self.properties.copy()
2106 if protected:
2107 d['id'] = String()
2108 d['creation'] = hyperdb.Date()
2109 d['activity'] = hyperdb.Date()
2110 d['creator'] = hyperdb.Link('user')
2111 return d
2113 def addprop(self, **properties):
2114 '''Add properties to this class.
2116 The keyword arguments in 'properties' must map names to property
2117 objects, or a TypeError is raised. None of the keys in 'properties'
2118 may collide with the names of existing properties, or a ValueError
2119 is raised before any properties have been added.
2120 '''
2121 for key in properties.keys():
2122 if self.properties.has_key(key):
2123 raise ValueError, key
2124 self.properties.update(properties)
2126 def index(self, nodeid):
2127 '''Add (or refresh) the node to search indexes
2128 '''
2129 # find all the String properties that have indexme
2130 for prop, propclass in self.getprops().items():
2131 if isinstance(propclass, String) and propclass.indexme:
2132 try:
2133 value = str(self.get(nodeid, prop))
2134 except IndexError:
2135 # node no longer exists - entry should be removed
2136 self.db.indexer.purge_entry((self.classname, nodeid, prop))
2137 else:
2138 # and index them under (classname, nodeid, property)
2139 self.db.indexer.add_text((self.classname, nodeid, prop),
2140 value)
2143 #
2144 # Detector interface
2145 #
2146 def audit(self, event, detector):
2147 '''Register a detector
2148 '''
2149 l = self.auditors[event]
2150 if detector not in l:
2151 self.auditors[event].append(detector)
2153 def fireAuditors(self, action, nodeid, newvalues):
2154 '''Fire all registered auditors.
2155 '''
2156 for audit in self.auditors[action]:
2157 audit(self.db, self, nodeid, newvalues)
2159 def react(self, event, detector):
2160 '''Register a detector
2161 '''
2162 l = self.reactors[event]
2163 if detector not in l:
2164 self.reactors[event].append(detector)
2166 def fireReactors(self, action, nodeid, oldvalues):
2167 '''Fire all registered reactors.
2168 '''
2169 for react in self.reactors[action]:
2170 react(self.db, self, nodeid, oldvalues)
2172 class FileClass(Class, hyperdb.FileClass):
2173 '''This class defines a large chunk of data. To support this, it has a
2174 mandatory String property "content" which is typically saved off
2175 externally to the hyperdb.
2177 The default MIME type of this data is defined by the
2178 "default_mime_type" class attribute, which may be overridden by each
2179 node if the class defines a "type" String property.
2180 '''
2181 default_mime_type = 'text/plain'
2183 def create(self, **propvalues):
2184 ''' snaffle the file propvalue and store in a file
2185 '''
2186 # we need to fire the auditors now, or the content property won't
2187 # be in propvalues for the auditors to play with
2188 self.fireAuditors('create', None, propvalues)
2190 # now remove the content property so it's not stored in the db
2191 content = propvalues['content']
2192 del propvalues['content']
2194 # do the database create
2195 newid = Class.create_inner(self, **propvalues)
2197 # fire reactors
2198 self.fireReactors('create', newid, None)
2200 # store off the content as a file
2201 self.db.storefile(self.classname, newid, None, content)
2202 return newid
2204 def import_list(self, propnames, proplist):
2205 ''' Trap the "content" property...
2206 '''
2207 # dupe this list so we don't affect others
2208 propnames = propnames[:]
2210 # extract the "content" property from the proplist
2211 i = propnames.index('content')
2212 content = eval(proplist[i])
2213 del propnames[i]
2214 del proplist[i]
2216 # do the normal import
2217 newid = Class.import_list(self, propnames, proplist)
2219 # save off the "content" file
2220 self.db.storefile(self.classname, newid, None, content)
2221 return newid
2223 _marker = []
2224 def get(self, nodeid, propname, default=_marker, cache=1):
2225 ''' Trap the content propname and get it from the file
2227 'cache' exists for backwards compatibility, and is not used.
2228 '''
2229 poss_msg = 'Possibly a access right configuration problem.'
2230 if propname == 'content':
2231 try:
2232 return self.db.getfile(self.classname, nodeid, None)
2233 except IOError, (strerror):
2234 # BUG: by catching this we donot see an error in the log.
2235 return 'ERROR reading file: %s%s\n%s\n%s'%(
2236 self.classname, nodeid, poss_msg, strerror)
2237 if default is not self._marker:
2238 return Class.get(self, nodeid, propname, default)
2239 else:
2240 return Class.get(self, nodeid, propname)
2242 def getprops(self, protected=1):
2243 ''' In addition to the actual properties on the node, these methods
2244 provide the "content" property. If the "protected" flag is true,
2245 we include protected properties - those which may not be
2246 modified.
2247 '''
2248 d = Class.getprops(self, protected=protected).copy()
2249 d['content'] = hyperdb.String()
2250 return d
2252 def index(self, nodeid):
2253 ''' Index the node in the search index.
2255 We want to index the content in addition to the normal String
2256 property indexing.
2257 '''
2258 # perform normal indexing
2259 Class.index(self, nodeid)
2261 # get the content to index
2262 content = self.get(nodeid, 'content')
2264 # figure the mime type
2265 if self.properties.has_key('type'):
2266 mime_type = self.get(nodeid, 'type')
2267 else:
2268 mime_type = self.default_mime_type
2270 # and index!
2271 self.db.indexer.add_text((self.classname, nodeid, 'content'), content,
2272 mime_type)
2274 # XXX deviation from spec - was called ItemClass
2275 class IssueClass(Class, roundupdb.IssueClass):
2276 # Overridden methods:
2277 def __init__(self, db, classname, **properties):
2278 '''The newly-created class automatically includes the "messages",
2279 "files", "nosy", and "superseder" properties. If the 'properties'
2280 dictionary attempts to specify any of these properties or a
2281 "creation" or "activity" property, a ValueError is raised.
2282 '''
2283 if not properties.has_key('title'):
2284 properties['title'] = hyperdb.String(indexme='yes')
2285 if not properties.has_key('messages'):
2286 properties['messages'] = hyperdb.Multilink("msg")
2287 if not properties.has_key('files'):
2288 properties['files'] = hyperdb.Multilink("file")
2289 if not properties.has_key('nosy'):
2290 # note: journalling is turned off as it really just wastes
2291 # space. this behaviour may be overridden in an instance
2292 properties['nosy'] = hyperdb.Multilink("user", do_journal="no")
2293 if not properties.has_key('superseder'):
2294 properties['superseder'] = hyperdb.Multilink(classname)
2295 Class.__init__(self, db, classname, **properties)