56d0e1c0041a8a77c0bc5530b841a48e0a6ce541
1 # $Id: rdbms_common.py,v 1.84 2004-03-22 07:45:39 richard Exp $
2 ''' Relational database (SQL) backend common code.
4 Basics:
6 - map roundup classes to relational tables
7 - automatically detect schema changes and modify the table schemas
8 appropriately (we store the "database version" of the schema in the
9 database itself as the only row of the "schema" table)
10 - multilinks (which represent a many-to-many relationship) are handled through
11 intermediate tables
12 - journals are stored adjunct to the per-class tables
13 - table names and columns have "_" prepended so the names can't clash with
14 restricted names (like "order")
15 - retirement is determined by the __retired__ column being true
17 Database-specific changes may generally be pushed out to the overridable
18 sql_* methods, since everything else should be fairly generic. There's
19 probably a bit of work to be done if a database is used that actually
20 honors column typing, since the initial databases don't (sqlite stores
21 everything as a string.)
23 The schema of the hyperdb being mapped to the database is stored in the
24 database itself as a repr()'ed dictionary of information about each Class
25 that maps to a table. If that information differs from the hyperdb schema,
26 then we update it. We also store in the schema dict a version which
27 allows us to upgrade the database schema when necessary. See upgrade_db().
28 '''
29 __docformat__ = 'restructuredtext'
31 # standard python modules
32 import sys, os, time, re, errno, weakref, copy
34 # roundup modules
35 from roundup import hyperdb, date, password, roundupdb, security
36 from roundup.hyperdb import String, Password, Date, Interval, Link, \
37 Multilink, DatabaseError, Boolean, Number, Node
38 from roundup.backends import locking
40 # support
41 from blobfiles import FileStorage
42 from indexer_rdbms import Indexer
43 from sessions_rdbms import Sessions, OneTimeKeys
44 from roundup.date import Range
46 # number of rows to keep in memory
47 ROW_CACHE_SIZE = 100
49 def _num_cvt(num):
50 num = str(num)
51 try:
52 return int(num)
53 except:
54 return float(num)
56 class Database(FileStorage, hyperdb.Database, roundupdb.Database):
57 ''' Wrapper around an SQL database that presents a hyperdb interface.
59 - some functionality is specific to the actual SQL database, hence
60 the sql_* methods that are NotImplemented
61 - we keep a cache of the latest ROW_CACHE_SIZE row fetches.
62 '''
63 def __init__(self, config, journaltag=None):
64 ''' Open the database and load the schema from it.
65 '''
66 self.config, self.journaltag = config, journaltag
67 self.dir = config.DATABASE
68 self.classes = {}
69 self.indexer = Indexer(self)
70 self.security = security.Security(self)
72 # additional transaction support for external files and the like
73 self.transactions = []
75 # keep a cache of the N most recently retrieved rows of any kind
76 # (classname, nodeid) = row
77 self.cache = {}
78 self.cache_lru = []
80 # database lock
81 self.lockfile = None
83 # open a connection to the database, creating the "conn" attribute
84 self.open_connection()
86 def clearCache(self):
87 self.cache = {}
88 self.cache_lru = []
90 def getSessionManager(self):
91 return Sessions(self)
93 def getOTKManager(self):
94 return OneTimeKeys(self)
96 def open_connection(self):
97 ''' Open a connection to the database, creating it if necessary.
99 Must call self.load_dbschema()
100 '''
101 raise NotImplemented
103 def sql(self, sql, args=None):
104 ''' Execute the sql with the optional args.
105 '''
106 if __debug__:
107 print >>hyperdb.DEBUG, (self, sql, args)
108 if args:
109 self.cursor.execute(sql, args)
110 else:
111 self.cursor.execute(sql)
113 def sql_fetchone(self):
114 ''' Fetch a single row. If there's nothing to fetch, return None.
115 '''
116 return self.cursor.fetchone()
118 def sql_fetchall(self):
119 ''' Fetch all rows. If there's nothing to fetch, return [].
120 '''
121 return self.cursor.fetchall()
123 def sql_stringquote(self, value):
124 ''' Quote the string so it's safe to put in the 'sql quotes'
125 '''
126 return re.sub("'", "''", str(value))
128 def init_dbschema(self):
129 self.database_schema = {
130 'version': self.current_db_version,
131 'tables': {}
132 }
134 def load_dbschema(self):
135 ''' Load the schema definition that the database currently implements
136 '''
137 self.cursor.execute('select schema from schema')
138 schema = self.cursor.fetchone()
139 if schema:
140 self.database_schema = eval(schema[0])
141 else:
142 self.database_schema = {}
144 def save_dbschema(self, schema):
145 ''' Save the schema definition that the database currently implements
146 '''
147 s = repr(self.database_schema)
148 self.sql('insert into schema values (%s)', (s,))
150 def post_init(self):
151 ''' Called once the schema initialisation has finished.
153 We should now confirm that the schema defined by our "classes"
154 attribute actually matches the schema in the database.
155 '''
156 save = self.upgrade_db()
158 # now detect changes in the schema
159 tables = self.database_schema['tables']
160 for classname, spec in self.classes.items():
161 if tables.has_key(classname):
162 dbspec = tables[classname]
163 if self.update_class(spec, dbspec):
164 tables[classname] = spec.schema()
165 save = 1
166 else:
167 self.create_class(spec)
168 tables[classname] = spec.schema()
169 save = 1
171 for classname, spec in tables.items():
172 if not self.classes.has_key(classname):
173 self.drop_class(classname, tables[classname])
174 del tables[classname]
175 save = 1
177 # update the database version of the schema
178 if save:
179 self.sql('delete from schema')
180 self.save_dbschema(self.database_schema)
182 # reindex the db if necessary
183 if self.indexer.should_reindex():
184 self.reindex()
186 # commit
187 self.sql_commit()
189 # update this number when we need to make changes to the SQL structure
190 # of the backen database
191 current_db_version = 2
192 def upgrade_db(self):
193 ''' Update the SQL database to reflect changes in the backend code.
195 Return boolean whether we need to save the schema.
196 '''
197 version = self.database_schema.get('version', 1)
198 if version == self.current_db_version:
199 # nothing to do
200 return 0
202 if version == 1:
203 # version 1 doesn't have the OTK, session and indexing in the
204 # database
205 self.create_version_2_tables()
206 # version 1 also didn't have the actor column
207 self.add_actor_column()
209 self.database_schema['version'] = self.current_db_version
210 return 1
213 def refresh_database(self):
214 self.post_init()
216 def reindex(self):
217 for klass in self.classes.values():
218 for nodeid in klass.list():
219 klass.index(nodeid)
220 self.indexer.save_index()
223 hyperdb_to_sql_datatypes = {
224 hyperdb.String : 'VARCHAR(255)',
225 hyperdb.Date : 'TIMESTAMP',
226 hyperdb.Link : 'INTEGER',
227 hyperdb.Interval : 'VARCHAR(255)',
228 hyperdb.Password : 'VARCHAR(255)',
229 hyperdb.Boolean : 'INTEGER',
230 hyperdb.Number : 'REAL',
231 }
232 def determine_columns(self, properties):
233 ''' Figure the column names and multilink properties from the spec
235 "properties" is a list of (name, prop) where prop may be an
236 instance of a hyperdb "type" _or_ a string repr of that type.
237 '''
238 cols = [
239 ('_actor', 'INTEGER'),
240 ('_activity', 'DATE'),
241 ('_creator', 'INTEGER'),
242 ('_creation', 'DATE')
243 ]
244 mls = []
245 # add the multilinks separately
246 for col, prop in properties:
247 if isinstance(prop, Multilink):
248 mls.append(col)
249 continue
251 if isinstance(prop, type('')):
252 raise ValueError, "string property spec!"
253 #and prop.find('Multilink') != -1:
254 #mls.append(col)
256 datatype = self.hyperdb_to_sql_datatypes[prop.__class__]
257 cols.append(('_'+col, datatype))
259 cols.sort()
260 return cols, mls
262 def update_class(self, spec, old_spec, force=0):
263 ''' Determine the differences between the current spec and the
264 database version of the spec, and update where necessary.
266 If 'force' is true, update the database anyway.
267 '''
268 new_has = spec.properties.has_key
269 new_spec = spec.schema()
270 new_spec[1].sort()
271 old_spec[1].sort()
272 if not force and new_spec == old_spec:
273 # no changes
274 return 0
276 if __debug__:
277 print >>hyperdb.DEBUG, 'update_class FIRING'
279 # detect key prop change for potential index change
280 keyprop_changes = {}
281 if new_spec[0] != old_spec[0]:
282 keyprop_changes = {'remove': old_spec[0], 'add': new_spec[0]}
284 # detect multilinks that have been removed, and drop their table
285 old_has = {}
286 for name, prop in old_spec[1]:
287 old_has[name] = 1
288 if new_has(name):
289 continue
291 if prop.find('Multilink to') != -1:
292 # first drop indexes.
293 self.drop_multilink_table_indexes(spec.classname, name)
295 # now the multilink table itself
296 sql = 'drop table %s_%s'%(spec.classname, name)
297 else:
298 # if this is the key prop, drop the index first
299 if old_spec[0] == prop:
300 self.drop_class_table_key_index(spec.classname, name)
301 del keyprop_changes['remove']
303 # drop the column
304 sql = 'alter table _%s drop column _%s'%(spec.classname, name)
306 if __debug__:
307 print >>hyperdb.DEBUG, 'update_class', (self, sql)
308 self.cursor.execute(sql)
309 old_has = old_has.has_key
311 # if we didn't remove the key prop just then, but the key prop has
312 # changed, we still need to remove the old index
313 if keyprop_changes.has_key('remove'):
314 self.drop_class_table_key_index(spec.classname,
315 keyprop_changes['remove'])
317 # add new columns
318 for propname, x in new_spec[1]:
319 if old_has(propname):
320 continue
321 sql = 'alter table _%s add column _%s varchar(255)'%(
322 spec.classname, propname)
323 if __debug__:
324 print >>hyperdb.DEBUG, 'update_class', (self, sql)
325 self.cursor.execute(sql)
327 # if the new column is a key prop, we need an index!
328 if new_spec[0] == propname:
329 self.create_class_table_key_index(spec.classname, propname)
330 del keyprop_changes['add']
332 # if we didn't add the key prop just then, but the key prop has
333 # changed, we still need to add the new index
334 if keyprop_changes.has_key('add'):
335 self.create_class_table_key_index(spec.classname,
336 keyprop_changes['add'])
338 return 1
340 def create_class_table(self, spec):
341 ''' create the class table for the given spec
342 '''
343 cols, mls = self.determine_columns(spec.properties.items())
345 # add on our special columns
346 cols.append(('id', 'INTEGER PRIMARY KEY'))
347 cols.append(('__retired__', 'INTEGER DEFAULT 0'))
349 # create the base table
350 scols = ','.join(['%s %s'%x for x in cols])
351 sql = 'create table _%s (%s)'%(spec.classname, scols)
352 if __debug__:
353 print >>hyperdb.DEBUG, 'create_class', (self, sql)
354 self.cursor.execute(sql)
356 self.create_class_table_indexes(spec)
358 return cols, mls
360 def create_class_table_indexes(self, spec):
361 ''' create the class table for the given spec
362 '''
363 # create __retired__ index
364 index_sql2 = 'create index _%s_retired_idx on _%s(__retired__)'%(
365 spec.classname, spec.classname)
366 if __debug__:
367 print >>hyperdb.DEBUG, 'create_index', (self, index_sql2)
368 self.cursor.execute(index_sql2)
370 # create index for key property
371 if spec.key:
372 if __debug__:
373 print >>hyperdb.DEBUG, 'update_class setting keyprop %r'% \
374 spec.key
375 index_sql3 = 'create index _%s_%s_idx on _%s(_%s)'%(
376 spec.classname, spec.key,
377 spec.classname, spec.key)
378 if __debug__:
379 print >>hyperdb.DEBUG, 'create_index', (self, index_sql3)
380 self.cursor.execute(index_sql3)
382 def drop_class_table_indexes(self, cn, key):
383 # drop the old table indexes first
384 l = ['_%s_id_idx'%cn, '_%s_retired_idx'%cn]
385 if key:
386 l.append('_%s_%s_idx'%(cn, key))
388 table_name = '_%s'%cn
389 for index_name in l:
390 if not self.sql_index_exists(table_name, index_name):
391 continue
392 index_sql = 'drop index '+index_name
393 if __debug__:
394 print >>hyperdb.DEBUG, 'drop_index', (self, index_sql)
395 self.cursor.execute(index_sql)
397 def create_class_table_key_index(self, cn, key):
398 ''' create the class table for the given spec
399 '''
400 sql = 'create index _%s_%s_idx on _%s(_%s)'%(cn, key, cn, key)
401 if __debug__:
402 print >>hyperdb.DEBUG, 'create_class_tab_key_index', (self, sql)
403 self.cursor.execute(sql)
405 def drop_class_table_key_index(self, cn, key):
406 table_name = '_%s'%cn
407 index_name = '_%s_%s_idx'%(cn, key)
408 if not self.sql_index_exists(table_name, index_name):
409 return
410 sql = 'drop index '+index_name
411 if __debug__:
412 print >>hyperdb.DEBUG, 'drop_class_tab_key_index', (self, sql)
413 self.cursor.execute(sql)
415 def create_journal_table(self, spec):
416 ''' create the journal table for a class given the spec and
417 already-determined cols
418 '''
419 # journal table
420 cols = ','.join(['%s varchar'%x
421 for x in 'nodeid date tag action params'.split()])
422 sql = '''create table %s__journal (
423 nodeid integer, date timestamp, tag varchar(255),
424 action varchar(255), params varchar(25))'''%spec.classname
425 if __debug__:
426 print >>hyperdb.DEBUG, 'create_journal_table', (self, sql)
427 self.cursor.execute(sql)
428 self.create_journal_table_indexes(spec)
430 def create_journal_table_indexes(self, spec):
431 # index on nodeid
432 sql = 'create index %s_journ_idx on %s__journal(nodeid)'%(
433 spec.classname, spec.classname)
434 if __debug__:
435 print >>hyperdb.DEBUG, 'create_index', (self, sql)
436 self.cursor.execute(sql)
438 def drop_journal_table_indexes(self, classname):
439 index_name = '%s_journ_idx'%classname
440 if not self.sql_index_exists('%s__journal'%classname, index_name):
441 return
442 index_sql = 'drop index '+index_name
443 if __debug__:
444 print >>hyperdb.DEBUG, 'drop_index', (self, index_sql)
445 self.cursor.execute(index_sql)
447 def create_multilink_table(self, spec, ml):
448 ''' Create a multilink table for the "ml" property of the class
449 given by the spec
450 '''
451 # create the table
452 sql = 'create table %s_%s (linkid varchar, nodeid varchar)'%(
453 spec.classname, ml)
454 if __debug__:
455 print >>hyperdb.DEBUG, 'create_class', (self, sql)
456 self.cursor.execute(sql)
457 self.create_multilink_table_indexes(spec, ml)
459 def create_multilink_table_indexes(self, spec, ml):
460 # create index on linkid
461 index_sql = 'create index %s_%s_l_idx on %s_%s(linkid)'%(
462 spec.classname, ml, spec.classname, ml)
463 if __debug__:
464 print >>hyperdb.DEBUG, 'create_index', (self, index_sql)
465 self.cursor.execute(index_sql)
467 # create index on nodeid
468 index_sql = 'create index %s_%s_n_idx on %s_%s(nodeid)'%(
469 spec.classname, ml, spec.classname, ml)
470 if __debug__:
471 print >>hyperdb.DEBUG, 'create_index', (self, index_sql)
472 self.cursor.execute(index_sql)
474 def drop_multilink_table_indexes(self, classname, ml):
475 l = [
476 '%s_%s_l_idx'%(classname, ml),
477 '%s_%s_n_idx'%(classname, ml)
478 ]
479 table_name = '%s_%s'%(classname, ml)
480 for index_name in l:
481 if not self.sql_index_exists(table_name, index_name):
482 continue
483 index_sql = 'drop index %s'%index_name
484 if __debug__:
485 print >>hyperdb.DEBUG, 'drop_index', (self, index_sql)
486 self.cursor.execute(index_sql)
488 def create_class(self, spec):
489 ''' Create a database table according to the given spec.
490 '''
491 cols, mls = self.create_class_table(spec)
492 self.create_journal_table(spec)
494 # now create the multilink tables
495 for ml in mls:
496 self.create_multilink_table(spec, ml)
498 def drop_class(self, cn, spec):
499 ''' Drop the given table from the database.
501 Drop the journal and multilink tables too.
502 '''
503 properties = spec[1]
504 # figure the multilinks
505 mls = []
506 for propanme, prop in properties:
507 if isinstance(prop, Multilink):
508 mls.append(propname)
510 # drop class table and indexes
511 self.drop_class_table_indexes(cn, spec[0])
513 self.drop_class_table(cn)
515 # drop journal table and indexes
516 self.drop_journal_table_indexes(cn)
517 sql = 'drop table %s__journal'%cn
518 if __debug__:
519 print >>hyperdb.DEBUG, 'drop_class', (self, sql)
520 self.cursor.execute(sql)
522 for ml in mls:
523 # drop multilink table and indexes
524 self.drop_multilink_table_indexes(cn, ml)
525 sql = 'drop table %s_%s'%(spec.classname, ml)
526 if __debug__:
527 print >>hyperdb.DEBUG, 'drop_class', (self, sql)
528 self.cursor.execute(sql)
530 def drop_class_table(self, cn):
531 sql = 'drop table _%s'%cn
532 if __debug__:
533 print >>hyperdb.DEBUG, 'drop_class', (self, sql)
534 self.cursor.execute(sql)
536 #
537 # Classes
538 #
539 def __getattr__(self, classname):
540 ''' A convenient way of calling self.getclass(classname).
541 '''
542 if self.classes.has_key(classname):
543 if __debug__:
544 print >>hyperdb.DEBUG, '__getattr__', (self, classname)
545 return self.classes[classname]
546 raise AttributeError, classname
548 def addclass(self, cl):
549 ''' Add a Class to the hyperdatabase.
550 '''
551 if __debug__:
552 print >>hyperdb.DEBUG, 'addclass', (self, cl)
553 cn = cl.classname
554 if self.classes.has_key(cn):
555 raise ValueError, cn
556 self.classes[cn] = cl
558 # add default Edit and View permissions
559 self.security.addPermission(name="Edit", klass=cn,
560 description="User is allowed to edit "+cn)
561 self.security.addPermission(name="View", klass=cn,
562 description="User is allowed to access "+cn)
564 def getclasses(self):
565 ''' Return a list of the names of all existing classes.
566 '''
567 if __debug__:
568 print >>hyperdb.DEBUG, 'getclasses', (self,)
569 l = self.classes.keys()
570 l.sort()
571 return l
573 def getclass(self, classname):
574 '''Get the Class object representing a particular class.
576 If 'classname' is not a valid class name, a KeyError is raised.
577 '''
578 if __debug__:
579 print >>hyperdb.DEBUG, 'getclass', (self, classname)
580 try:
581 return self.classes[classname]
582 except KeyError:
583 raise KeyError, 'There is no class called "%s"'%classname
585 def clear(self):
586 '''Delete all database contents.
588 Note: I don't commit here, which is different behaviour to the
589 "nuke from orbit" behaviour in the dbs.
590 '''
591 if __debug__:
592 print >>hyperdb.DEBUG, 'clear', (self,)
593 for cn in self.classes.keys():
594 sql = 'delete from _%s'%cn
595 if __debug__:
596 print >>hyperdb.DEBUG, 'clear', (self, sql)
597 self.cursor.execute(sql)
599 #
600 # Nodes
601 #
603 hyperdb_to_sql_value = {
604 hyperdb.String : str,
605 hyperdb.Date : lambda x: x.formal(sep=' ', sec='%f'),
606 hyperdb.Link : int,
607 hyperdb.Interval : lambda x: x.serialise(),
608 hyperdb.Password : str,
609 hyperdb.Boolean : int,
610 hyperdb.Number : lambda x: x,
611 }
612 def addnode(self, classname, nodeid, node):
613 ''' Add the specified node to its class's db.
614 '''
615 if __debug__:
616 print >>hyperdb.DEBUG, 'addnode', (self, classname, nodeid, node)
618 # determine the column definitions and multilink tables
619 cl = self.classes[classname]
620 cols, mls = self.determine_columns(cl.properties.items())
622 # we'll be supplied these props if we're doing an import
623 values = node.copy()
624 if not values.has_key('creator'):
625 # add in the "calculated" properties (dupe so we don't affect
626 # calling code's node assumptions)
627 values['creation'] = values['activity'] = date.Date()
628 values['actor'] = values['creator'] = self.getuid()
630 cl = self.classes[classname]
631 props = cl.getprops(protected=1)
632 del props['id']
634 # default the non-multilink columns
635 for col, prop in props.items():
636 if not values.has_key(col):
637 if isinstance(prop, Multilink):
638 values[col] = []
639 else:
640 values[col] = None
642 # clear this node out of the cache if it's in there
643 key = (classname, nodeid)
644 if self.cache.has_key(key):
645 del self.cache[key]
646 self.cache_lru.remove(key)
648 # figure the values to insert
649 vals = []
650 for col,dt in cols:
651 prop = props[col[1:]]
652 value = values[col[1:]]
653 if value:
654 value = self.hyperdb_to_sql_value[prop.__class__](value)
655 vals.append(value)
656 vals.append(nodeid)
657 vals = tuple(vals)
659 # make sure the ordering is correct for column name -> column value
660 s = ','.join([self.arg for x in cols]) + ',%s'%self.arg
661 cols = ','.join([col for col,dt in cols]) + ',id'
663 # perform the inserts
664 sql = 'insert into _%s (%s) values (%s)'%(classname, cols, s)
665 if __debug__:
666 print >>hyperdb.DEBUG, 'addnode', (self, sql, vals)
667 self.cursor.execute(sql, vals)
669 # insert the multilink rows
670 for col in mls:
671 t = '%s_%s'%(classname, col)
672 for entry in node[col]:
673 sql = 'insert into %s (linkid, nodeid) values (%s,%s)'%(t,
674 self.arg, self.arg)
675 self.sql(sql, (entry, nodeid))
677 # make sure we do the commit-time extra stuff for this node
678 self.transactions.append((self.doSaveNode, (classname, nodeid, node)))
680 def setnode(self, classname, nodeid, values, multilink_changes):
681 ''' Change the specified node.
682 '''
683 if __debug__:
684 print >>hyperdb.DEBUG, 'setnode', (self, classname, nodeid, values)
686 # clear this node out of the cache if it's in there
687 key = (classname, nodeid)
688 if self.cache.has_key(key):
689 del self.cache[key]
690 self.cache_lru.remove(key)
692 # add the special props
693 values = values.copy()
694 values['activity'] = date.Date()
695 values['actor'] = self.getuid()
697 cl = self.classes[classname]
698 props = cl.getprops()
700 cols = []
701 mls = []
702 # add the multilinks separately
703 for col in values.keys():
704 prop = props[col]
705 if isinstance(prop, Multilink):
706 mls.append(col)
707 else:
708 cols.append(col)
709 cols.sort()
711 # figure the values to insert
712 vals = []
713 for col in cols:
714 prop = props[col]
715 value = values[col]
716 if value is not None:
717 value = self.hyperdb_to_sql_value[prop.__class__](value)
718 vals.append(value)
719 vals.append(int(nodeid))
720 vals = tuple(vals)
722 # if there's any updates to regular columns, do them
723 if cols:
724 # make sure the ordering is correct for column name -> column value
725 s = ','.join(['_%s=%s'%(x, self.arg) for x in cols])
726 cols = ','.join(cols)
728 # perform the update
729 sql = 'update _%s set %s where id=%s'%(classname, s, self.arg)
730 if __debug__:
731 print >>hyperdb.DEBUG, 'setnode', (self, sql, vals)
732 self.cursor.execute(sql, vals)
734 # now the fun bit, updating the multilinks ;)
735 for col, (add, remove) in multilink_changes.items():
736 tn = '%s_%s'%(classname, col)
737 if add:
738 sql = 'insert into %s (nodeid, linkid) values (%s,%s)'%(tn,
739 self.arg, self.arg)
740 for addid in add:
741 # XXX numeric ids
742 self.sql(sql, (int(nodeid), int(addid)))
743 if remove:
744 sql = 'delete from %s where nodeid=%s and linkid=%s'%(tn,
745 self.arg, self.arg)
746 for removeid in remove:
747 # XXX numeric ids
748 self.sql(sql, (int(nodeid), int(removeid)))
750 # make sure we do the commit-time extra stuff for this node
751 self.transactions.append((self.doSaveNode, (classname, nodeid, values)))
753 sql_to_hyperdb_value = {
754 hyperdb.String : str,
755 hyperdb.Date : date.Date,
756 # hyperdb.Link : int, # XXX numeric ids
757 hyperdb.Link : str,
758 hyperdb.Interval : date.Interval,
759 hyperdb.Password : lambda x: password.Password(encrypted=x),
760 hyperdb.Boolean : int,
761 hyperdb.Number : _num_cvt,
762 }
763 def getnode(self, classname, nodeid):
764 ''' Get a node from the database.
765 '''
766 if __debug__:
767 print >>hyperdb.DEBUG, 'getnode', (self, classname, nodeid)
769 # see if we have this node cached
770 key = (classname, nodeid)
771 if self.cache.has_key(key):
772 # push us back to the top of the LRU
773 self.cache_lru.remove(key)
774 self.cache_lru.insert(0, key)
775 # return the cached information
776 return self.cache[key]
778 # figure the columns we're fetching
779 cl = self.classes[classname]
780 cols, mls = self.determine_columns(cl.properties.items())
781 scols = ','.join([col for col,dt in cols])
783 # perform the basic property fetch
784 sql = 'select %s from _%s where id=%s'%(scols, classname, self.arg)
785 self.sql(sql, (nodeid,))
787 values = self.sql_fetchone()
788 if values is None:
789 raise IndexError, 'no such %s node %s'%(classname, nodeid)
791 # make up the node
792 node = {}
793 props = cl.getprops(protected=1)
794 for col in range(len(cols)):
795 name = cols[col][0][1:]
796 value = values[col]
797 if value is not None:
798 value = self.sql_to_hyperdb_value[props[name].__class__](value)
799 node[name] = value
802 # now the multilinks
803 for col in mls:
804 # get the link ids
805 sql = 'select linkid from %s_%s where nodeid=%s'%(classname, col,
806 self.arg)
807 self.cursor.execute(sql, (nodeid,))
808 # extract the first column from the result
809 # XXX numeric ids
810 node[col] = [str(x[0]) for x in self.cursor.fetchall()]
812 # save off in the cache
813 key = (classname, nodeid)
814 self.cache[key] = node
815 # update the LRU
816 self.cache_lru.insert(0, key)
817 if len(self.cache_lru) > ROW_CACHE_SIZE:
818 del self.cache[self.cache_lru.pop()]
820 return node
822 def destroynode(self, classname, nodeid):
823 '''Remove a node from the database. Called exclusively by the
824 destroy() method on Class.
825 '''
826 if __debug__:
827 print >>hyperdb.DEBUG, 'destroynode', (self, classname, nodeid)
829 # make sure the node exists
830 if not self.hasnode(classname, nodeid):
831 raise IndexError, '%s has no node %s'%(classname, nodeid)
833 # see if we have this node cached
834 if self.cache.has_key((classname, nodeid)):
835 del self.cache[(classname, nodeid)]
837 # see if there's any obvious commit actions that we should get rid of
838 for entry in self.transactions[:]:
839 if entry[1][:2] == (classname, nodeid):
840 self.transactions.remove(entry)
842 # now do the SQL
843 sql = 'delete from _%s where id=%s'%(classname, self.arg)
844 self.sql(sql, (nodeid,))
846 # remove from multilnks
847 cl = self.getclass(classname)
848 x, mls = self.determine_columns(cl.properties.items())
849 for col in mls:
850 # get the link ids
851 sql = 'delete from %s_%s where nodeid=%s'%(classname, col, self.arg)
852 self.sql(sql, (nodeid,))
854 # remove journal entries
855 sql = 'delete from %s__journal where nodeid=%s'%(classname, self.arg)
856 self.sql(sql, (nodeid,))
858 def hasnode(self, classname, nodeid):
859 ''' Determine if the database has a given node.
860 '''
861 sql = 'select count(*) from _%s where id=%s'%(classname, self.arg)
862 if __debug__:
863 print >>hyperdb.DEBUG, 'hasnode', (self, sql, nodeid)
864 self.cursor.execute(sql, (nodeid,))
865 return int(self.cursor.fetchone()[0])
867 def countnodes(self, classname):
868 ''' Count the number of nodes that exist for a particular Class.
869 '''
870 sql = 'select count(*) from _%s'%classname
871 if __debug__:
872 print >>hyperdb.DEBUG, 'countnodes', (self, sql)
873 self.cursor.execute(sql)
874 return self.cursor.fetchone()[0]
876 def addjournal(self, classname, nodeid, action, params, creator=None,
877 creation=None):
878 ''' Journal the Action
879 'action' may be:
881 'create' or 'set' -- 'params' is a dictionary of property values
882 'link' or 'unlink' -- 'params' is (classname, nodeid, propname)
883 'retire' -- 'params' is None
884 '''
885 # serialise the parameters now if necessary
886 if isinstance(params, type({})):
887 if action in ('set', 'create'):
888 params = self.serialise(classname, params)
890 # handle supply of the special journalling parameters (usually
891 # supplied on importing an existing database)
892 if creator:
893 journaltag = creator
894 else:
895 journaltag = self.getuid()
896 if creation:
897 journaldate = creation
898 else:
899 journaldate = date.Date()
901 # create the journal entry
902 cols = ','.join('nodeid date tag action params'.split())
904 if __debug__:
905 print >>hyperdb.DEBUG, 'addjournal', (nodeid, journaldate,
906 journaltag, action, params)
908 self.save_journal(classname, cols, nodeid, journaldate,
909 journaltag, action, params)
911 def getjournal(self, classname, nodeid):
912 ''' get the journal for id
913 '''
914 # make sure the node exists
915 if not self.hasnode(classname, nodeid):
916 raise IndexError, '%s has no node %s'%(classname, nodeid)
918 cols = ','.join('nodeid date tag action params'.split())
919 return self.load_journal(classname, cols, nodeid)
921 def save_journal(self, classname, cols, nodeid, journaldate,
922 journaltag, action, params):
923 ''' Save the journal entry to the database
924 '''
925 # make the params db-friendly
926 params = repr(params)
927 dc = self.hyperdb_to_sql_value[hyperdb.Date]
928 entry = (nodeid, dc(journaldate), journaltag, action, params)
930 # do the insert
931 a = self.arg
932 sql = 'insert into %s__journal (%s) values (%s,%s,%s,%s,%s)'%(
933 classname, cols, a, a, a, a, a)
934 if __debug__:
935 print >>hyperdb.DEBUG, 'addjournal', (self, sql, entry)
936 self.cursor.execute(sql, entry)
938 def load_journal(self, classname, cols, nodeid):
939 ''' Load the journal from the database
940 '''
941 # now get the journal entries
942 sql = 'select %s from %s__journal where nodeid=%s order by date'%(
943 cols, classname, self.arg)
944 if __debug__:
945 print >>hyperdb.DEBUG, 'load_journal', (self, sql, nodeid)
946 self.cursor.execute(sql, (nodeid,))
947 res = []
948 dc = self.sql_to_hyperdb_value[hyperdb.Date]
949 for nodeid, date_stamp, user, action, params in self.cursor.fetchall():
950 params = eval(params)
951 # XXX numeric ids
952 res.append((str(nodeid), dc(date_stamp), user, action, params))
953 return res
955 def pack(self, pack_before):
956 ''' Delete all journal entries except "create" before 'pack_before'.
957 '''
958 # get a 'yyyymmddhhmmss' version of the date
959 date_stamp = pack_before.serialise()
961 # do the delete
962 for classname in self.classes.keys():
963 sql = "delete from %s__journal where date<%s and "\
964 "action<>'create'"%(classname, self.arg)
965 if __debug__:
966 print >>hyperdb.DEBUG, 'pack', (self, sql, date_stamp)
967 self.cursor.execute(sql, (date_stamp,))
969 def sql_commit(self):
970 ''' Actually commit to the database.
971 '''
972 if __debug__:
973 print >>hyperdb.DEBUG, '+++ commit database connection +++'
974 self.conn.commit()
976 def commit(self):
977 ''' Commit the current transactions.
979 Save all data changed since the database was opened or since the
980 last commit() or rollback().
981 '''
982 if __debug__:
983 print >>hyperdb.DEBUG, 'commit', (self,)
985 # commit the database
986 self.sql_commit()
988 # now, do all the other transaction stuff
989 for method, args in self.transactions:
990 method(*args)
992 # save the indexer state
993 self.indexer.save_index()
995 # clear out the transactions
996 self.transactions = []
998 def sql_rollback(self):
999 self.conn.rollback()
1001 def rollback(self):
1002 ''' Reverse all actions from the current transaction.
1004 Undo all the changes made since the database was opened or the last
1005 commit() or rollback() was performed.
1006 '''
1007 if __debug__:
1008 print >>hyperdb.DEBUG, 'rollback', (self,)
1010 self.sql_rollback()
1012 # roll back "other" transaction stuff
1013 for method, args in self.transactions:
1014 # delete temporary files
1015 if method == self.doStoreFile:
1016 self.rollbackStoreFile(*args)
1017 self.transactions = []
1019 # clear the cache
1020 self.clearCache()
1022 def doSaveNode(self, classname, nodeid, node):
1023 ''' dummy that just generates a reindex event
1024 '''
1025 # return the classname, nodeid so we reindex this content
1026 return (classname, nodeid)
1028 def sql_close(self):
1029 if __debug__:
1030 print >>hyperdb.DEBUG, '+++ close database connection +++'
1031 self.conn.close()
1033 def close(self):
1034 ''' Close off the connection.
1035 '''
1036 self.indexer.close()
1037 self.sql_close()
1039 #
1040 # The base Class class
1041 #
1042 class Class(hyperdb.Class):
1043 ''' The handle to a particular class of nodes in a hyperdatabase.
1045 All methods except __repr__ and getnode must be implemented by a
1046 concrete backend Class.
1047 '''
1049 def __init__(self, db, classname, **properties):
1050 '''Create a new class with a given name and property specification.
1052 'classname' must not collide with the name of an existing class,
1053 or a ValueError is raised. The keyword arguments in 'properties'
1054 must map names to property objects, or a TypeError is raised.
1055 '''
1056 for name in 'creation activity creator actor'.split():
1057 if properties.has_key(name):
1058 raise ValueError, '"creation", "activity", "creator" and '\
1059 '"actor" are reserved'
1061 self.classname = classname
1062 self.properties = properties
1063 self.db = weakref.proxy(db) # use a weak ref to avoid circularity
1064 self.key = ''
1066 # should we journal changes (default yes)
1067 self.do_journal = 1
1069 # do the db-related init stuff
1070 db.addclass(self)
1072 self.auditors = {'create': [], 'set': [], 'retire': [], 'restore': []}
1073 self.reactors = {'create': [], 'set': [], 'retire': [], 'restore': []}
1075 def schema(self):
1076 ''' A dumpable version of the schema that we can store in the
1077 database
1078 '''
1079 return (self.key, [(x, repr(y)) for x,y in self.properties.items()])
1081 def enableJournalling(self):
1082 '''Turn journalling on for this class
1083 '''
1084 self.do_journal = 1
1086 def disableJournalling(self):
1087 '''Turn journalling off for this class
1088 '''
1089 self.do_journal = 0
1091 # Editing nodes:
1092 def create(self, **propvalues):
1093 ''' Create a new node of this class and return its id.
1095 The keyword arguments in 'propvalues' map property names to values.
1097 The values of arguments must be acceptable for the types of their
1098 corresponding properties or a TypeError is raised.
1100 If this class has a key property, it must be present and its value
1101 must not collide with other key strings or a ValueError is raised.
1103 Any other properties on this class that are missing from the
1104 'propvalues' dictionary are set to None.
1106 If an id in a link or multilink property does not refer to a valid
1107 node, an IndexError is raised.
1108 '''
1109 self.fireAuditors('create', None, propvalues)
1110 newid = self.create_inner(**propvalues)
1111 self.fireReactors('create', newid, None)
1112 return newid
1114 def create_inner(self, **propvalues):
1115 ''' Called by create, in-between the audit and react calls.
1116 '''
1117 if propvalues.has_key('id'):
1118 raise KeyError, '"id" is reserved'
1120 if self.db.journaltag is None:
1121 raise DatabaseError, 'Database open read-only'
1123 if propvalues.has_key('creator') or propvalues.has_key('actor') or \
1124 propvalues.has_key('creation') or propvalues.has_key('activity'):
1125 raise KeyError, '"creator", "actor", "creation" and '\
1126 '"activity" are reserved'
1128 # new node's id
1129 newid = self.db.newid(self.classname)
1131 # validate propvalues
1132 num_re = re.compile('^\d+$')
1133 for key, value in propvalues.items():
1134 if key == self.key:
1135 try:
1136 self.lookup(value)
1137 except KeyError:
1138 pass
1139 else:
1140 raise ValueError, 'node with key "%s" exists'%value
1142 # try to handle this property
1143 try:
1144 prop = self.properties[key]
1145 except KeyError:
1146 raise KeyError, '"%s" has no property "%s"'%(self.classname,
1147 key)
1149 if value is not None and isinstance(prop, Link):
1150 if type(value) != type(''):
1151 raise ValueError, 'link value must be String'
1152 link_class = self.properties[key].classname
1153 # if it isn't a number, it's a key
1154 if not num_re.match(value):
1155 try:
1156 value = self.db.classes[link_class].lookup(value)
1157 except (TypeError, KeyError):
1158 raise IndexError, 'new property "%s": %s not a %s'%(
1159 key, value, link_class)
1160 elif not self.db.getclass(link_class).hasnode(value):
1161 raise IndexError, '%s has no node %s'%(link_class, value)
1163 # save off the value
1164 propvalues[key] = value
1166 # register the link with the newly linked node
1167 if self.do_journal and self.properties[key].do_journal:
1168 self.db.addjournal(link_class, value, 'link',
1169 (self.classname, newid, key))
1171 elif isinstance(prop, Multilink):
1172 if type(value) != type([]):
1173 raise TypeError, 'new property "%s" not a list of ids'%key
1175 # clean up and validate the list of links
1176 link_class = self.properties[key].classname
1177 l = []
1178 for entry in value:
1179 if type(entry) != type(''):
1180 raise ValueError, '"%s" multilink value (%r) '\
1181 'must contain Strings'%(key, value)
1182 # if it isn't a number, it's a key
1183 if not num_re.match(entry):
1184 try:
1185 entry = self.db.classes[link_class].lookup(entry)
1186 except (TypeError, KeyError):
1187 raise IndexError, 'new property "%s": %s not a %s'%(
1188 key, entry, self.properties[key].classname)
1189 l.append(entry)
1190 value = l
1191 propvalues[key] = value
1193 # handle additions
1194 for nodeid in value:
1195 if not self.db.getclass(link_class).hasnode(nodeid):
1196 raise IndexError, '%s has no node %s'%(link_class,
1197 nodeid)
1198 # register the link with the newly linked node
1199 if self.do_journal and self.properties[key].do_journal:
1200 self.db.addjournal(link_class, nodeid, 'link',
1201 (self.classname, newid, key))
1203 elif isinstance(prop, String):
1204 if type(value) != type('') and type(value) != type(u''):
1205 raise TypeError, 'new property "%s" not a string'%key
1206 self.db.indexer.add_text((self.classname, newid, key), value)
1208 elif isinstance(prop, Password):
1209 if not isinstance(value, password.Password):
1210 raise TypeError, 'new property "%s" not a Password'%key
1212 elif isinstance(prop, Date):
1213 if value is not None and not isinstance(value, date.Date):
1214 raise TypeError, 'new property "%s" not a Date'%key
1216 elif isinstance(prop, Interval):
1217 if value is not None and not isinstance(value, date.Interval):
1218 raise TypeError, 'new property "%s" not an Interval'%key
1220 elif value is not None and isinstance(prop, Number):
1221 try:
1222 float(value)
1223 except ValueError:
1224 raise TypeError, 'new property "%s" not numeric'%key
1226 elif value is not None and isinstance(prop, Boolean):
1227 try:
1228 int(value)
1229 except ValueError:
1230 raise TypeError, 'new property "%s" not boolean'%key
1232 # make sure there's data where there needs to be
1233 for key, prop in self.properties.items():
1234 if propvalues.has_key(key):
1235 continue
1236 if key == self.key:
1237 raise ValueError, 'key property "%s" is required'%key
1238 if isinstance(prop, Multilink):
1239 propvalues[key] = []
1240 else:
1241 propvalues[key] = None
1243 # done
1244 self.db.addnode(self.classname, newid, propvalues)
1245 if self.do_journal:
1246 self.db.addjournal(self.classname, newid, 'create', {})
1248 # XXX numeric ids
1249 return str(newid)
1251 def export_list(self, propnames, nodeid):
1252 ''' Export a node - generate a list of CSV-able data in the order
1253 specified by propnames for the given node.
1254 '''
1255 properties = self.getprops()
1256 l = []
1257 for prop in propnames:
1258 proptype = properties[prop]
1259 value = self.get(nodeid, prop)
1260 # "marshal" data where needed
1261 if value is None:
1262 pass
1263 elif isinstance(proptype, hyperdb.Date):
1264 value = value.get_tuple()
1265 elif isinstance(proptype, hyperdb.Interval):
1266 value = value.get_tuple()
1267 elif isinstance(proptype, hyperdb.Password):
1268 value = str(value)
1269 l.append(repr(value))
1270 l.append(repr(self.is_retired(nodeid)))
1271 return l
1273 def import_list(self, propnames, proplist):
1274 ''' Import a node - all information including "id" is present and
1275 should not be sanity checked. Triggers are not triggered. The
1276 journal should be initialised using the "creator" and "created"
1277 information.
1279 Return the nodeid of the node imported.
1280 '''
1281 if self.db.journaltag is None:
1282 raise DatabaseError, 'Database open read-only'
1283 properties = self.getprops()
1285 # make the new node's property map
1286 d = {}
1287 retire = 0
1288 newid = None
1289 for i in range(len(propnames)):
1290 # Use eval to reverse the repr() used to output the CSV
1291 value = eval(proplist[i])
1293 # Figure the property for this column
1294 propname = propnames[i]
1296 # "unmarshal" where necessary
1297 if propname == 'id':
1298 newid = value
1299 continue
1300 elif propname == 'is retired':
1301 # is the item retired?
1302 if int(value):
1303 retire = 1
1304 continue
1305 elif value is None:
1306 d[propname] = None
1307 continue
1309 prop = properties[propname]
1310 if value is None:
1311 # don't set Nones
1312 continue
1313 elif isinstance(prop, hyperdb.Date):
1314 value = date.Date(value)
1315 elif isinstance(prop, hyperdb.Interval):
1316 value = date.Interval(value)
1317 elif isinstance(prop, hyperdb.Password):
1318 pwd = password.Password()
1319 pwd.unpack(value)
1320 value = pwd
1321 d[propname] = value
1323 # get a new id if necessary
1324 if newid is None:
1325 newid = self.db.newid(self.classname)
1327 # add the node and journal
1328 self.db.addnode(self.classname, newid, d)
1330 # retire?
1331 if retire:
1332 # use the arg for __retired__ to cope with any odd database type
1333 # conversion (hello, sqlite)
1334 sql = 'update _%s set __retired__=%s where id=%s'%(self.classname,
1335 self.db.arg, self.db.arg)
1336 if __debug__:
1337 print >>hyperdb.DEBUG, 'retire', (self, sql, newid)
1338 self.db.cursor.execute(sql, (1, newid))
1340 # extract the extraneous journalling gumpf and nuke it
1341 if d.has_key('creator'):
1342 creator = d['creator']
1343 del d['creator']
1344 else:
1345 creator = None
1346 if d.has_key('creation'):
1347 creation = d['creation']
1348 del d['creation']
1349 else:
1350 creation = None
1351 if d.has_key('activity'):
1352 del d['activity']
1353 if d.has_key('actor'):
1354 del d['actor']
1355 self.db.addjournal(self.classname, newid, 'create', {}, creator,
1356 creation)
1357 return newid
1359 _marker = []
1360 def get(self, nodeid, propname, default=_marker, cache=1):
1361 '''Get the value of a property on an existing node of this class.
1363 'nodeid' must be the id of an existing node of this class or an
1364 IndexError is raised. 'propname' must be the name of a property
1365 of this class or a KeyError is raised.
1367 'cache' exists for backwards compatibility, and is not used.
1368 '''
1369 if propname == 'id':
1370 return nodeid
1372 # get the node's dict
1373 d = self.db.getnode(self.classname, nodeid)
1375 if propname == 'creation':
1376 if d.has_key('creation'):
1377 return d['creation']
1378 else:
1379 return date.Date()
1380 if propname == 'activity':
1381 if d.has_key('activity'):
1382 return d['activity']
1383 else:
1384 return date.Date()
1385 if propname == 'creator':
1386 if d.has_key('creator'):
1387 return d['creator']
1388 else:
1389 return self.db.getuid()
1390 if propname == 'actor':
1391 if d.has_key('actor'):
1392 return d['actor']
1393 else:
1394 return self.db.getuid()
1396 # get the property (raises KeyErorr if invalid)
1397 prop = self.properties[propname]
1399 if not d.has_key(propname):
1400 if default is self._marker:
1401 if isinstance(prop, Multilink):
1402 return []
1403 else:
1404 return None
1405 else:
1406 return default
1408 # don't pass our list to other code
1409 if isinstance(prop, Multilink):
1410 return d[propname][:]
1412 return d[propname]
1414 def set(self, nodeid, **propvalues):
1415 '''Modify a property on an existing node of this class.
1417 'nodeid' must be the id of an existing node of this class or an
1418 IndexError is raised.
1420 Each key in 'propvalues' must be the name of a property of this
1421 class or a KeyError is raised.
1423 All values in 'propvalues' must be acceptable types for their
1424 corresponding properties or a TypeError is raised.
1426 If the value of the key property is set, it must not collide with
1427 other key strings or a ValueError is raised.
1429 If the value of a Link or Multilink property contains an invalid
1430 node id, a ValueError is raised.
1431 '''
1432 self.fireAuditors('set', nodeid, propvalues)
1433 oldvalues = copy.deepcopy(self.db.getnode(self.classname, nodeid))
1434 propvalues = self.set_inner(nodeid, **propvalues)
1435 self.fireReactors('set', nodeid, oldvalues)
1436 return propvalues
1438 def set_inner(self, nodeid, **propvalues):
1439 ''' Called by set, in-between the audit and react calls.
1440 '''
1441 if not propvalues:
1442 return propvalues
1444 if propvalues.has_key('creation') or propvalues.has_key('creator') or \
1445 propvalues.has_key('actor') or propvalues.has_key('activity'):
1446 raise KeyError, '"creation", "creator", "actor" and '\
1447 '"activity" are reserved'
1449 if propvalues.has_key('id'):
1450 raise KeyError, '"id" is reserved'
1452 if self.db.journaltag is None:
1453 raise DatabaseError, 'Database open read-only'
1455 node = self.db.getnode(self.classname, nodeid)
1456 if self.is_retired(nodeid):
1457 raise IndexError, 'Requested item is retired'
1458 num_re = re.compile('^\d+$')
1460 # if the journal value is to be different, store it in here
1461 journalvalues = {}
1463 # remember the add/remove stuff for multilinks, making it easier
1464 # for the Database layer to do its stuff
1465 multilink_changes = {}
1467 for propname, value in propvalues.items():
1468 # check to make sure we're not duplicating an existing key
1469 if propname == self.key and node[propname] != value:
1470 try:
1471 self.lookup(value)
1472 except KeyError:
1473 pass
1474 else:
1475 raise ValueError, 'node with key "%s" exists'%value
1477 # this will raise the KeyError if the property isn't valid
1478 # ... we don't use getprops() here because we only care about
1479 # the writeable properties.
1480 try:
1481 prop = self.properties[propname]
1482 except KeyError:
1483 raise KeyError, '"%s" has no property named "%s"'%(
1484 self.classname, propname)
1486 # if the value's the same as the existing value, no sense in
1487 # doing anything
1488 current = node.get(propname, None)
1489 if value == current:
1490 del propvalues[propname]
1491 continue
1492 journalvalues[propname] = current
1494 # do stuff based on the prop type
1495 if isinstance(prop, Link):
1496 link_class = prop.classname
1497 # if it isn't a number, it's a key
1498 if value is not None and not isinstance(value, type('')):
1499 raise ValueError, 'property "%s" link value be a string'%(
1500 propname)
1501 if isinstance(value, type('')) and not num_re.match(value):
1502 try:
1503 value = self.db.classes[link_class].lookup(value)
1504 except (TypeError, KeyError):
1505 raise IndexError, 'new property "%s": %s not a %s'%(
1506 propname, value, prop.classname)
1508 if (value is not None and
1509 not self.db.getclass(link_class).hasnode(value)):
1510 raise IndexError, '%s has no node %s'%(link_class, value)
1512 if self.do_journal and prop.do_journal:
1513 # register the unlink with the old linked node
1514 if node[propname] is not None:
1515 self.db.addjournal(link_class, node[propname], 'unlink',
1516 (self.classname, nodeid, propname))
1518 # register the link with the newly linked node
1519 if value is not None:
1520 self.db.addjournal(link_class, value, 'link',
1521 (self.classname, nodeid, propname))
1523 elif isinstance(prop, Multilink):
1524 if type(value) != type([]):
1525 raise TypeError, 'new property "%s" not a list of'\
1526 ' ids'%propname
1527 link_class = self.properties[propname].classname
1528 l = []
1529 for entry in value:
1530 # if it isn't a number, it's a key
1531 if type(entry) != type(''):
1532 raise ValueError, 'new property "%s" link value ' \
1533 'must be a string'%propname
1534 if not num_re.match(entry):
1535 try:
1536 entry = self.db.classes[link_class].lookup(entry)
1537 except (TypeError, KeyError):
1538 raise IndexError, 'new property "%s": %s not a %s'%(
1539 propname, entry,
1540 self.properties[propname].classname)
1541 l.append(entry)
1542 value = l
1543 propvalues[propname] = value
1545 # figure the journal entry for this property
1546 add = []
1547 remove = []
1549 # handle removals
1550 if node.has_key(propname):
1551 l = node[propname]
1552 else:
1553 l = []
1554 for id in l[:]:
1555 if id in value:
1556 continue
1557 # register the unlink with the old linked node
1558 if self.do_journal and self.properties[propname].do_journal:
1559 self.db.addjournal(link_class, id, 'unlink',
1560 (self.classname, nodeid, propname))
1561 l.remove(id)
1562 remove.append(id)
1564 # handle additions
1565 for id in value:
1566 if not self.db.getclass(link_class).hasnode(id):
1567 raise IndexError, '%s has no node %s'%(link_class, id)
1568 if id in l:
1569 continue
1570 # register the link with the newly linked node
1571 if self.do_journal and self.properties[propname].do_journal:
1572 self.db.addjournal(link_class, id, 'link',
1573 (self.classname, nodeid, propname))
1574 l.append(id)
1575 add.append(id)
1577 # figure the journal entry
1578 l = []
1579 if add:
1580 l.append(('+', add))
1581 if remove:
1582 l.append(('-', remove))
1583 multilink_changes[propname] = (add, remove)
1584 if l:
1585 journalvalues[propname] = tuple(l)
1587 elif isinstance(prop, String):
1588 if value is not None and type(value) != type('') and type(value) != type(u''):
1589 raise TypeError, 'new property "%s" not a string'%propname
1590 self.db.indexer.add_text((self.classname, nodeid, propname),
1591 value)
1593 elif isinstance(prop, Password):
1594 if not isinstance(value, password.Password):
1595 raise TypeError, 'new property "%s" not a Password'%propname
1596 propvalues[propname] = value
1598 elif value is not None and isinstance(prop, Date):
1599 if not isinstance(value, date.Date):
1600 raise TypeError, 'new property "%s" not a Date'% propname
1601 propvalues[propname] = value
1603 elif value is not None and isinstance(prop, Interval):
1604 if not isinstance(value, date.Interval):
1605 raise TypeError, 'new property "%s" not an '\
1606 'Interval'%propname
1607 propvalues[propname] = value
1609 elif value is not None and isinstance(prop, Number):
1610 try:
1611 float(value)
1612 except ValueError:
1613 raise TypeError, 'new property "%s" not numeric'%propname
1615 elif value is not None and isinstance(prop, Boolean):
1616 try:
1617 int(value)
1618 except ValueError:
1619 raise TypeError, 'new property "%s" not boolean'%propname
1621 # nothing to do?
1622 if not propvalues:
1623 return propvalues
1625 # do the set, and journal it
1626 self.db.setnode(self.classname, nodeid, propvalues, multilink_changes)
1628 if self.do_journal:
1629 self.db.addjournal(self.classname, nodeid, 'set', journalvalues)
1631 return propvalues
1633 def retire(self, nodeid):
1634 '''Retire a node.
1636 The properties on the node remain available from the get() method,
1637 and the node's id is never reused.
1639 Retired nodes are not returned by the find(), list(), or lookup()
1640 methods, and other nodes may reuse the values of their key properties.
1641 '''
1642 if self.db.journaltag is None:
1643 raise DatabaseError, 'Database open read-only'
1645 self.fireAuditors('retire', nodeid, None)
1647 # use the arg for __retired__ to cope with any odd database type
1648 # conversion (hello, sqlite)
1649 sql = 'update _%s set __retired__=%s where id=%s'%(self.classname,
1650 self.db.arg, self.db.arg)
1651 if __debug__:
1652 print >>hyperdb.DEBUG, 'retire', (self, sql, nodeid)
1653 self.db.cursor.execute(sql, (1, nodeid))
1654 if self.do_journal:
1655 self.db.addjournal(self.classname, nodeid, 'retired', None)
1657 self.fireReactors('retire', nodeid, None)
1659 def restore(self, nodeid):
1660 '''Restore a retired node.
1662 Make node available for all operations like it was before retirement.
1663 '''
1664 if self.db.journaltag is None:
1665 raise DatabaseError, 'Database open read-only'
1667 node = self.db.getnode(self.classname, nodeid)
1668 # check if key property was overrided
1669 key = self.getkey()
1670 try:
1671 id = self.lookup(node[key])
1672 except KeyError:
1673 pass
1674 else:
1675 raise KeyError, "Key property (%s) of retired node clashes with \
1676 existing one (%s)" % (key, node[key])
1678 self.fireAuditors('restore', nodeid, None)
1679 # use the arg for __retired__ to cope with any odd database type
1680 # conversion (hello, sqlite)
1681 sql = 'update _%s set __retired__=%s where id=%s'%(self.classname,
1682 self.db.arg, self.db.arg)
1683 if __debug__:
1684 print >>hyperdb.DEBUG, 'restore', (self, sql, nodeid)
1685 self.db.cursor.execute(sql, (0, nodeid))
1686 if self.do_journal:
1687 self.db.addjournal(self.classname, nodeid, 'restored', None)
1689 self.fireReactors('restore', nodeid, None)
1691 def is_retired(self, nodeid):
1692 '''Return true if the node is rerired
1693 '''
1694 sql = 'select __retired__ from _%s where id=%s'%(self.classname,
1695 self.db.arg)
1696 if __debug__:
1697 print >>hyperdb.DEBUG, 'is_retired', (self, sql, nodeid)
1698 self.db.cursor.execute(sql, (nodeid,))
1699 return int(self.db.sql_fetchone()[0])
1701 def destroy(self, nodeid):
1702 '''Destroy a node.
1704 WARNING: this method should never be used except in extremely rare
1705 situations where there could never be links to the node being
1706 deleted
1708 WARNING: use retire() instead
1710 WARNING: the properties of this node will not be available ever again
1712 WARNING: really, use retire() instead
1714 Well, I think that's enough warnings. This method exists mostly to
1715 support the session storage of the cgi interface.
1717 The node is completely removed from the hyperdb, including all journal
1718 entries. It will no longer be available, and will generally break code
1719 if there are any references to the node.
1720 '''
1721 if self.db.journaltag is None:
1722 raise DatabaseError, 'Database open read-only'
1723 self.db.destroynode(self.classname, nodeid)
1725 def history(self, nodeid):
1726 '''Retrieve the journal of edits on a particular node.
1728 'nodeid' must be the id of an existing node of this class or an
1729 IndexError is raised.
1731 The returned list contains tuples of the form
1733 (nodeid, date, tag, action, params)
1735 'date' is a Timestamp object specifying the time of the change and
1736 'tag' is the journaltag specified when the database was opened.
1737 '''
1738 if not self.do_journal:
1739 raise ValueError, 'Journalling is disabled for this class'
1740 return self.db.getjournal(self.classname, nodeid)
1742 # Locating nodes:
1743 def hasnode(self, nodeid):
1744 '''Determine if the given nodeid actually exists
1745 '''
1746 return self.db.hasnode(self.classname, nodeid)
1748 def setkey(self, propname):
1749 '''Select a String property of this class to be the key property.
1751 'propname' must be the name of a String property of this class or
1752 None, or a TypeError is raised. The values of the key property on
1753 all existing nodes must be unique or a ValueError is raised.
1754 '''
1755 # XXX create an index on the key prop column. We should also
1756 # record that we've created this index in the schema somewhere.
1757 prop = self.getprops()[propname]
1758 if not isinstance(prop, String):
1759 raise TypeError, 'key properties must be String'
1760 self.key = propname
1762 def getkey(self):
1763 '''Return the name of the key property for this class or None.'''
1764 return self.key
1766 def labelprop(self, default_to_id=0):
1767 '''Return the property name for a label for the given node.
1769 This method attempts to generate a consistent label for the node.
1770 It tries the following in order:
1772 1. key property
1773 2. "name" property
1774 3. "title" property
1775 4. first property from the sorted property name list
1776 '''
1777 k = self.getkey()
1778 if k:
1779 return k
1780 props = self.getprops()
1781 if props.has_key('name'):
1782 return 'name'
1783 elif props.has_key('title'):
1784 return 'title'
1785 if default_to_id:
1786 return 'id'
1787 props = props.keys()
1788 props.sort()
1789 return props[0]
1791 def lookup(self, keyvalue):
1792 '''Locate a particular node by its key property and return its id.
1794 If this class has no key property, a TypeError is raised. If the
1795 'keyvalue' matches one of the values for the key property among
1796 the nodes in this class, the matching node's id is returned;
1797 otherwise a KeyError is raised.
1798 '''
1799 if not self.key:
1800 raise TypeError, 'No key property set for class %s'%self.classname
1802 # use the arg to handle any odd database type conversion (hello,
1803 # sqlite)
1804 sql = "select id from _%s where _%s=%s and __retired__ <> %s"%(
1805 self.classname, self.key, self.db.arg, self.db.arg)
1806 self.db.sql(sql, (keyvalue, 1))
1808 # see if there was a result that's not retired
1809 row = self.db.sql_fetchone()
1810 if not row:
1811 raise KeyError, 'No key (%s) value "%s" for "%s"'%(self.key,
1812 keyvalue, self.classname)
1814 # return the id
1815 # XXX numeric ids
1816 return str(row[0])
1818 def find(self, **propspec):
1819 '''Get the ids of nodes in this class which link to the given nodes.
1821 'propspec' consists of keyword args propname=nodeid or
1822 propname={nodeid:1, }
1823 'propname' must be the name of a property in this class, or a
1824 KeyError is raised. That property must be a Link or
1825 Multilink property, or a TypeError is raised.
1827 Any node in this class whose 'propname' property links to any of the
1828 nodeids will be returned. Used by the full text indexing, which knows
1829 that "foo" occurs in msg1, msg3 and file7, so we have hits on these
1830 issues:
1832 db.issue.find(messages={'1':1,'3':1}, files={'7':1})
1833 '''
1834 if __debug__:
1835 print >>hyperdb.DEBUG, 'find', (self, propspec)
1837 # shortcut
1838 if not propspec:
1839 return []
1841 # validate the args
1842 props = self.getprops()
1843 propspec = propspec.items()
1844 for propname, nodeids in propspec:
1845 # check the prop is OK
1846 prop = props[propname]
1847 if not isinstance(prop, Link) and not isinstance(prop, Multilink):
1848 raise TypeError, "'%s' not a Link/Multilink property"%propname
1850 # first, links
1851 a = self.db.arg
1852 allvalues = (1,)
1853 o = []
1854 where = []
1855 for prop, values in propspec:
1856 if not isinstance(props[prop], hyperdb.Link):
1857 continue
1858 if type(values) is type({}) and len(values) == 1:
1859 values = values.keys()[0]
1860 if type(values) is type(''):
1861 allvalues += (values,)
1862 where.append('_%s = %s'%(prop, a))
1863 elif values is None:
1864 where.append('_%s is NULL'%prop)
1865 else:
1866 allvalues += tuple(values.keys())
1867 where.append('_%s in (%s)'%(prop, ','.join([a]*len(values))))
1868 tables = ['_%s'%self.classname]
1869 if where:
1870 o.append('(' + ' and '.join(where) + ')')
1872 # now multilinks
1873 for prop, values in propspec:
1874 if not isinstance(props[prop], hyperdb.Multilink):
1875 continue
1876 if not values:
1877 continue
1878 if type(values) is type(''):
1879 allvalues += (values,)
1880 s = a
1881 else:
1882 allvalues += tuple(values.keys())
1883 s = ','.join([a]*len(values))
1884 tn = '%s_%s'%(self.classname, prop)
1885 tables.append(tn)
1886 o.append('(id=%s.nodeid and %s.linkid in (%s))'%(tn, tn, s))
1888 if not o:
1889 return []
1890 elif len(o) > 1:
1891 o = '(' + ' or '.join(['(%s)'%i for i in o]) + ')'
1892 else:
1893 o = o[0]
1894 t = ', '.join(tables)
1895 sql = 'select distinct(id) from %s where __retired__ <> %s and %s'%(
1896 t, a, o)
1897 self.db.sql(sql, allvalues)
1898 # XXX numeric ids
1899 l = [str(x[0]) for x in self.db.sql_fetchall()]
1900 if __debug__:
1901 print >>hyperdb.DEBUG, 'find ... ', l
1902 return l
1904 def stringFind(self, **requirements):
1905 '''Locate a particular node by matching a set of its String
1906 properties in a caseless search.
1908 If the property is not a String property, a TypeError is raised.
1910 The return is a list of the id of all nodes that match.
1911 '''
1912 where = []
1913 args = []
1914 for propname in requirements.keys():
1915 prop = self.properties[propname]
1916 if not isinstance(prop, String):
1917 raise TypeError, "'%s' not a String property"%propname
1918 where.append(propname)
1919 args.append(requirements[propname].lower())
1921 # generate the where clause
1922 s = ' and '.join(['lower(_%s)=%s'%(col, self.db.arg) for col in where])
1923 sql = 'select id from _%s where %s and __retired__=%s'%(self.classname,
1924 s, self.db.arg)
1925 args.append(0)
1926 self.db.sql(sql, tuple(args))
1927 # XXX numeric ids
1928 l = [str(x[0]) for x in self.db.sql_fetchall()]
1929 if __debug__:
1930 print >>hyperdb.DEBUG, 'find ... ', l
1931 return l
1933 def list(self):
1934 ''' Return a list of the ids of the active nodes in this class.
1935 '''
1936 return self.getnodeids(retired=0)
1938 def getnodeids(self, retired=None):
1939 ''' Retrieve all the ids of the nodes for a particular Class.
1941 Set retired=None to get all nodes. Otherwise it'll get all the
1942 retired or non-retired nodes, depending on the flag.
1943 '''
1944 # flip the sense of the 'retired' flag if we don't want all of them
1945 if retired is not None:
1946 if retired:
1947 args = (0, )
1948 else:
1949 args = (1, )
1950 sql = 'select id from _%s where __retired__ <> %s'%(self.classname,
1951 self.db.arg)
1952 else:
1953 args = ()
1954 sql = 'select id from _%s'%self.classname
1955 if __debug__:
1956 print >>hyperdb.DEBUG, 'getnodeids', (self, sql, retired)
1957 self.db.cursor.execute(sql, args)
1958 # XXX numeric ids
1959 ids = [str(x[0]) for x in self.db.cursor.fetchall()]
1960 return ids
1962 def filter(self, search_matches, filterspec, sort=(None,None),
1963 group=(None,None)):
1964 '''Return a list of the ids of the active nodes in this class that
1965 match the 'filter' spec, sorted by the group spec and then the
1966 sort spec
1968 "filterspec" is {propname: value(s)}
1970 "sort" and "group" are (dir, prop) where dir is '+', '-' or None
1971 and prop is a prop name or None
1973 "search_matches" is {nodeid: marker}
1975 The filter must match all properties specificed - but if the
1976 property value to match is a list, any one of the values in the
1977 list may match for that property to match.
1978 '''
1979 # just don't bother if the full-text search matched diddly
1980 if search_matches == {}:
1981 return []
1983 cn = self.classname
1985 timezone = self.db.getUserTimezone()
1987 # figure the WHERE clause from the filterspec
1988 props = self.getprops()
1989 frum = ['_'+cn]
1990 where = []
1991 args = []
1992 a = self.db.arg
1993 for k, v in filterspec.items():
1994 propclass = props[k]
1995 # now do other where clause stuff
1996 if isinstance(propclass, Multilink):
1997 tn = '%s_%s'%(cn, k)
1998 if v in ('-1', ['-1']):
1999 # only match rows that have count(linkid)=0 in the
2000 # corresponding multilink table)
2001 where.append('id not in (select nodeid from %s)'%tn)
2002 elif isinstance(v, type([])):
2003 frum.append(tn)
2004 s = ','.join([a for x in v])
2005 where.append('id=%s.nodeid and %s.linkid in (%s)'%(tn,tn,s))
2006 args = args + v
2007 else:
2008 frum.append(tn)
2009 where.append('id=%s.nodeid and %s.linkid=%s'%(tn, tn, a))
2010 args.append(v)
2011 elif k == 'id':
2012 if isinstance(v, type([])):
2013 s = ','.join([a for x in v])
2014 where.append('%s in (%s)'%(k, s))
2015 args = args + v
2016 else:
2017 where.append('%s=%s'%(k, a))
2018 args.append(v)
2019 elif isinstance(propclass, String):
2020 if not isinstance(v, type([])):
2021 v = [v]
2023 # Quote the bits in the string that need it and then embed
2024 # in a "substring" search. Note - need to quote the '%' so
2025 # they make it through the python layer happily
2026 v = ['%%'+self.db.sql_stringquote(s)+'%%' for s in v]
2028 # now add to the where clause
2029 where.append(' or '.join(["_%s LIKE '%s'"%(k, s) for s in v]))
2030 # note: args are embedded in the query string now
2031 elif isinstance(propclass, Link):
2032 if isinstance(v, type([])):
2033 if '-1' in v:
2034 v = v[:]
2035 v.remove('-1')
2036 xtra = ' or _%s is NULL'%k
2037 else:
2038 xtra = ''
2039 if v:
2040 s = ','.join([a for x in v])
2041 where.append('(_%s in (%s)%s)'%(k, s, xtra))
2042 args = args + v
2043 else:
2044 where.append('_%s is NULL'%k)
2045 else:
2046 if v == '-1':
2047 v = None
2048 where.append('_%s is NULL'%k)
2049 else:
2050 where.append('_%s=%s'%(k, a))
2051 args.append(v)
2052 elif isinstance(propclass, Date):
2053 dc = self.db.hyperdb_to_sql_value[hyperdb.Date]
2054 if isinstance(v, type([])):
2055 s = ','.join([a for x in v])
2056 where.append('_%s in (%s)'%(k, s))
2057 args = args + [dc(date.Date(v)) for x in v]
2058 else:
2059 try:
2060 # Try to filter on range of dates
2061 date_rng = Range(v, date.Date, offset=timezone)
2062 if date_rng.from_value:
2063 where.append('_%s >= %s'%(k, a))
2064 args.append(dc(date_rng.from_value))
2065 if date_rng.to_value:
2066 where.append('_%s <= %s'%(k, a))
2067 args.append(dc(date_rng.to_value))
2068 except ValueError:
2069 # If range creation fails - ignore that search parameter
2070 pass
2071 elif isinstance(propclass, Interval):
2072 if isinstance(v, type([])):
2073 s = ','.join([a for x in v])
2074 where.append('_%s in (%s)'%(k, s))
2075 args = args + [date.Interval(x).serialise() for x in v]
2076 else:
2077 try:
2078 # Try to filter on range of intervals
2079 date_rng = Range(v, date.Interval)
2080 if date_rng.from_value:
2081 where.append('_%s >= %s'%(k, a))
2082 args.append(date_rng.from_value.serialise())
2083 if date_rng.to_value:
2084 where.append('_%s <= %s'%(k, a))
2085 args.append(date_rng.to_value.serialise())
2086 except ValueError:
2087 # If range creation fails - ignore that search parameter
2088 pass
2089 #where.append('_%s=%s'%(k, a))
2090 #args.append(date.Interval(v).serialise())
2091 else:
2092 if isinstance(v, type([])):
2093 s = ','.join([a for x in v])
2094 where.append('_%s in (%s)'%(k, s))
2095 args = args + v
2096 else:
2097 where.append('_%s=%s'%(k, a))
2098 args.append(v)
2100 # don't match retired nodes
2101 where.append('__retired__ <> 1')
2103 # add results of full text search
2104 if search_matches is not None:
2105 v = search_matches.keys()
2106 s = ','.join([a for x in v])
2107 where.append('id in (%s)'%s)
2108 args = args + v
2110 # "grouping" is just the first-order sorting in the SQL fetch
2111 # can modify it...)
2112 orderby = []
2113 ordercols = []
2114 if group[0] is not None and group[1] is not None:
2115 if group[0] != '-':
2116 orderby.append('_'+group[1])
2117 ordercols.append('_'+group[1])
2118 else:
2119 orderby.append('_'+group[1]+' desc')
2120 ordercols.append('_'+group[1])
2122 # now add in the sorting
2123 group = ''
2124 if sort[0] is not None and sort[1] is not None:
2125 direction, colname = sort
2126 if direction != '-':
2127 if colname == 'id':
2128 orderby.append(colname)
2129 else:
2130 orderby.append('_'+colname)
2131 ordercols.append('_'+colname)
2132 else:
2133 if colname == 'id':
2134 orderby.append(colname+' desc')
2135 ordercols.append(colname)
2136 else:
2137 orderby.append('_'+colname+' desc')
2138 ordercols.append('_'+colname)
2140 # construct the SQL
2141 frum = ','.join(frum)
2142 if where:
2143 where = ' where ' + (' and '.join(where))
2144 else:
2145 where = ''
2146 cols = ['id']
2147 if orderby:
2148 cols = cols + ordercols
2149 order = ' order by %s'%(','.join(orderby))
2150 else:
2151 order = ''
2152 cols = ','.join(cols)
2153 sql = 'select %s from %s %s%s%s'%(cols, frum, where, group, order)
2154 args = tuple(args)
2155 if __debug__:
2156 print >>hyperdb.DEBUG, 'filter', (self, sql, args)
2157 if args:
2158 self.db.cursor.execute(sql, args)
2159 else:
2160 # psycopg doesn't like empty args
2161 self.db.cursor.execute(sql)
2162 l = self.db.sql_fetchall()
2164 # return the IDs (the first column)
2165 # XXX numeric ids
2166 return [str(row[0]) for row in l]
2168 def count(self):
2169 '''Get the number of nodes in this class.
2171 If the returned integer is 'numnodes', the ids of all the nodes
2172 in this class run from 1 to numnodes, and numnodes+1 will be the
2173 id of the next node to be created in this class.
2174 '''
2175 return self.db.countnodes(self.classname)
2177 # Manipulating properties:
2178 def getprops(self, protected=1):
2179 '''Return a dictionary mapping property names to property objects.
2180 If the "protected" flag is true, we include protected properties -
2181 those which may not be modified.
2182 '''
2183 d = self.properties.copy()
2184 if protected:
2185 d['id'] = String()
2186 d['creation'] = hyperdb.Date()
2187 d['activity'] = hyperdb.Date()
2188 d['creator'] = hyperdb.Link('user')
2189 d['actor'] = hyperdb.Link('user')
2190 return d
2192 def addprop(self, **properties):
2193 '''Add properties to this class.
2195 The keyword arguments in 'properties' must map names to property
2196 objects, or a TypeError is raised. None of the keys in 'properties'
2197 may collide with the names of existing properties, or a ValueError
2198 is raised before any properties have been added.
2199 '''
2200 for key in properties.keys():
2201 if self.properties.has_key(key):
2202 raise ValueError, key
2203 self.properties.update(properties)
2205 def index(self, nodeid):
2206 '''Add (or refresh) the node to search indexes
2207 '''
2208 # find all the String properties that have indexme
2209 for prop, propclass in self.getprops().items():
2210 if isinstance(propclass, String) and propclass.indexme:
2211 self.db.indexer.add_text((self.classname, nodeid, prop),
2212 str(self.get(nodeid, prop)))
2215 #
2216 # Detector interface
2217 #
2218 def audit(self, event, detector):
2219 '''Register a detector
2220 '''
2221 l = self.auditors[event]
2222 if detector not in l:
2223 self.auditors[event].append(detector)
2225 def fireAuditors(self, action, nodeid, newvalues):
2226 '''Fire all registered auditors.
2227 '''
2228 for audit in self.auditors[action]:
2229 audit(self.db, self, nodeid, newvalues)
2231 def react(self, event, detector):
2232 '''Register a detector
2233 '''
2234 l = self.reactors[event]
2235 if detector not in l:
2236 self.reactors[event].append(detector)
2238 def fireReactors(self, action, nodeid, oldvalues):
2239 '''Fire all registered reactors.
2240 '''
2241 for react in self.reactors[action]:
2242 react(self.db, self, nodeid, oldvalues)
2244 class FileClass(Class, hyperdb.FileClass):
2245 '''This class defines a large chunk of data. To support this, it has a
2246 mandatory String property "content" which is typically saved off
2247 externally to the hyperdb.
2249 The default MIME type of this data is defined by the
2250 "default_mime_type" class attribute, which may be overridden by each
2251 node if the class defines a "type" String property.
2252 '''
2253 default_mime_type = 'text/plain'
2255 def create(self, **propvalues):
2256 ''' snaffle the file propvalue and store in a file
2257 '''
2258 # we need to fire the auditors now, or the content property won't
2259 # be in propvalues for the auditors to play with
2260 self.fireAuditors('create', None, propvalues)
2262 # now remove the content property so it's not stored in the db
2263 content = propvalues['content']
2264 del propvalues['content']
2266 # do the database create
2267 newid = self.create_inner(**propvalues)
2269 # figure the mime type
2270 mime_type = propvalues.get('type', self.default_mime_type)
2272 # and index!
2273 self.db.indexer.add_text((self.classname, newid, 'content'), content,
2274 mime_type)
2276 # fire reactors
2277 self.fireReactors('create', newid, None)
2279 # store off the content as a file
2280 self.db.storefile(self.classname, newid, None, content)
2281 return newid
2283 def import_list(self, propnames, proplist):
2284 ''' Trap the "content" property...
2285 '''
2286 # dupe this list so we don't affect others
2287 propnames = propnames[:]
2289 # extract the "content" property from the proplist
2290 i = propnames.index('content')
2291 content = eval(proplist[i])
2292 del propnames[i]
2293 del proplist[i]
2295 # do the normal import
2296 newid = Class.import_list(self, propnames, proplist)
2298 # save off the "content" file
2299 self.db.storefile(self.classname, newid, None, content)
2300 return newid
2302 _marker = []
2303 def get(self, nodeid, propname, default=_marker, cache=1):
2304 ''' Trap the content propname and get it from the file
2306 'cache' exists for backwards compatibility, and is not used.
2307 '''
2308 poss_msg = 'Possibly a access right configuration problem.'
2309 if propname == 'content':
2310 try:
2311 return self.db.getfile(self.classname, nodeid, None)
2312 except IOError, (strerror):
2313 # BUG: by catching this we donot see an error in the log.
2314 return 'ERROR reading file: %s%s\n%s\n%s'%(
2315 self.classname, nodeid, poss_msg, strerror)
2316 if default is not self._marker:
2317 return Class.get(self, nodeid, propname, default)
2318 else:
2319 return Class.get(self, nodeid, propname)
2321 def getprops(self, protected=1):
2322 ''' In addition to the actual properties on the node, these methods
2323 provide the "content" property. If the "protected" flag is true,
2324 we include protected properties - those which may not be
2325 modified.
2326 '''
2327 d = Class.getprops(self, protected=protected).copy()
2328 d['content'] = hyperdb.String()
2329 return d
2331 def set(self, itemid, **propvalues):
2332 ''' Snarf the "content" propvalue and update it in a file
2333 '''
2334 self.fireAuditors('set', itemid, propvalues)
2335 oldvalues = copy.deepcopy(self.db.getnode(self.classname, itemid))
2337 # now remove the content property so it's not stored in the db
2338 content = None
2339 if propvalues.has_key('content'):
2340 content = propvalues['content']
2341 del propvalues['content']
2343 # do the database create
2344 propvalues = self.set_inner(itemid, **propvalues)
2346 # do content?
2347 if content:
2348 # store and index
2349 self.db.storefile(self.classname, itemid, None, content)
2350 mime_type = propvalues.get('type', self.get(itemid, 'type'))
2351 if not mime_type:
2352 mime_type = self.default_mime_type
2353 self.db.indexer.add_text((self.classname, itemid, 'content'),
2354 content, mime_type)
2356 # fire reactors
2357 self.fireReactors('set', itemid, oldvalues)
2358 return propvalues
2360 # XXX deviation from spec - was called ItemClass
2361 class IssueClass(Class, roundupdb.IssueClass):
2362 # Overridden methods:
2363 def __init__(self, db, classname, **properties):
2364 '''The newly-created class automatically includes the "messages",
2365 "files", "nosy", and "superseder" properties. If the 'properties'
2366 dictionary attempts to specify any of these properties or a
2367 "creation", "creator", "activity" or "actor" property, a ValueError
2368 is raised.
2369 '''
2370 if not properties.has_key('title'):
2371 properties['title'] = hyperdb.String(indexme='yes')
2372 if not properties.has_key('messages'):
2373 properties['messages'] = hyperdb.Multilink("msg")
2374 if not properties.has_key('files'):
2375 properties['files'] = hyperdb.Multilink("file")
2376 if not properties.has_key('nosy'):
2377 # note: journalling is turned off as it really just wastes
2378 # space. this behaviour may be overridden in an instance
2379 properties['nosy'] = hyperdb.Multilink("user", do_journal="no")
2380 if not properties.has_key('superseder'):
2381 properties['superseder'] = hyperdb.Multilink(classname)
2382 Class.__init__(self, db, classname, **properties)