1 # $Id: rdbms_common.py,v 1.59 2003-08-26 00:06:56 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.open_connection()
74 def clearCache(self):
75 self.cache = {}
76 self.cache_lru = []
78 def 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 raise NotImplemented
98 def sql_stringquote(self, value):
99 ''' Quote the string so it's safe to put in the 'sql quotes'
100 '''
101 return re.sub("'", "''", str(value))
103 def save_dbschema(self, schema):
104 ''' Save the schema definition that the database currently implements
105 '''
106 raise NotImplemented
108 def load_dbschema(self):
109 ''' Load the schema definition that the database currently implements
110 '''
111 raise NotImplemented
113 def post_init(self):
114 ''' Called once the schema initialisation has finished.
116 We should now confirm that the schema defined by our "classes"
117 attribute actually matches the schema in the database.
118 '''
119 # now detect changes in the schema
120 save = 0
121 for classname, spec in self.classes.items():
122 if self.database_schema.has_key(classname):
123 dbspec = self.database_schema[classname]
124 if self.update_class(spec, dbspec):
125 self.database_schema[classname] = spec.schema()
126 save = 1
127 else:
128 self.create_class(spec)
129 self.database_schema[classname] = spec.schema()
130 save = 1
132 for classname in self.database_schema.keys():
133 if not self.classes.has_key(classname):
134 self.drop_class(classname)
136 # update the database version of the schema
137 if save:
138 self.sql('delete from schema')
139 self.save_dbschema(self.database_schema)
141 # reindex the db if necessary
142 if self.indexer.should_reindex():
143 self.reindex()
145 # commit
146 self.conn.commit()
148 # figure the "curuserid"
149 if self.journaltag is None:
150 self.curuserid = None
151 elif self.journaltag == 'admin':
152 # admin user may not exist, but always has ID 1
153 self.curuserid = '1'
154 else:
155 self.curuserid = self.user.lookup(self.journaltag)
157 def reindex(self):
158 for klass in self.classes.values():
159 for nodeid in klass.list():
160 klass.index(nodeid)
161 self.indexer.save_index()
163 def determine_columns(self, properties):
164 ''' Figure the column names and multilink properties from the spec
166 "properties" is a list of (name, prop) where prop may be an
167 instance of a hyperdb "type" _or_ a string repr of that type.
168 '''
169 cols = ['_activity', '_creator', '_creation']
170 mls = []
171 # add the multilinks separately
172 for col, prop in properties:
173 if isinstance(prop, Multilink):
174 mls.append(col)
175 elif isinstance(prop, type('')) and prop.find('Multilink') != -1:
176 mls.append(col)
177 else:
178 cols.append('_'+col)
179 cols.sort()
180 return cols, mls
182 def update_class(self, spec, old_spec):
183 ''' Determine the differences between the current spec and the
184 database version of the spec, and update where necessary
185 '''
186 new_spec = spec
187 new_has = new_spec.properties.has_key
189 new_spec = new_spec.schema()
190 new_spec[1].sort()
191 old_spec[1].sort()
192 if new_spec == old_spec:
193 # no changes
194 return 0
196 if __debug__:
197 print >>hyperdb.DEBUG, 'update_class FIRING'
199 # key property changed?
200 if old_spec[0] != new_spec[0]:
201 if __debug__:
202 print >>hyperdb.DEBUG, 'update_class setting keyprop', `spec[0]`
203 # XXX turn on indexing for the key property
205 # detect multilinks that have been removed, and drop their table
206 old_has = {}
207 for name,prop in old_spec[1]:
208 old_has[name] = 1
209 if not new_has(name) and isinstance(prop, Multilink):
210 # it's a multilink, and it's been removed - drop the old
211 # table
212 sql = 'drop table %s_%s'%(spec.classname, prop)
213 if __debug__:
214 print >>hyperdb.DEBUG, 'update_class', (self, sql)
215 self.cursor.execute(sql)
216 continue
217 old_has = old_has.has_key
219 # now figure how we populate the new table
220 fetch = ['_activity', '_creation', '_creator']
221 properties = spec.getprops()
222 for propname,x in new_spec[1]:
223 prop = properties[propname]
224 if isinstance(prop, Multilink):
225 if not old_has(propname):
226 # we need to create the new table
227 self.create_multilink_table(spec, propname)
228 elif old_has(propname):
229 # we copy this col over from the old table
230 fetch.append('_'+propname)
232 # select the data out of the old table
233 fetch.append('id')
234 fetch.append('__retired__')
235 fetchcols = ','.join(fetch)
236 cn = spec.classname
237 sql = 'select %s from _%s'%(fetchcols, cn)
238 if __debug__:
239 print >>hyperdb.DEBUG, 'update_class', (self, sql)
240 self.cursor.execute(sql)
241 olddata = self.cursor.fetchall()
243 # drop the old table
244 self.cursor.execute('drop table _%s'%cn)
246 # create the new table
247 self.create_class_table(spec)
249 if olddata:
250 # do the insert
251 args = ','.join([self.arg for x in fetch])
252 sql = 'insert into _%s (%s) values (%s)'%(cn, fetchcols, args)
253 if __debug__:
254 print >>hyperdb.DEBUG, 'update_class', (self, sql, olddata[0])
255 for entry in olddata:
256 self.cursor.execute(sql, tuple(entry))
258 return 1
260 def create_class_table(self, spec):
261 ''' create the class table for the given spec
262 '''
263 cols, mls = self.determine_columns(spec.properties.items())
265 # add on our special columns
266 cols.append('id')
267 cols.append('__retired__')
269 # create the base table
270 scols = ','.join(['%s varchar'%x for x in cols])
271 sql = 'create table _%s (%s)'%(spec.classname, scols)
272 if __debug__:
273 print >>hyperdb.DEBUG, 'create_class', (self, sql)
274 self.cursor.execute(sql)
276 return cols, mls
278 def create_journal_table(self, spec):
279 ''' create the journal table for a class given the spec and
280 already-determined cols
281 '''
282 # journal table
283 cols = ','.join(['%s varchar'%x
284 for x in 'nodeid date tag action params'.split()])
285 sql = 'create table %s__journal (%s)'%(spec.classname, cols)
286 if __debug__:
287 print >>hyperdb.DEBUG, 'create_class', (self, sql)
288 self.cursor.execute(sql)
290 def create_multilink_table(self, spec, ml):
291 ''' Create a multilink table for the "ml" property of the class
292 given by the spec
293 '''
294 sql = 'create table %s_%s (linkid varchar, nodeid varchar)'%(
295 spec.classname, ml)
296 if __debug__:
297 print >>hyperdb.DEBUG, 'create_class', (self, sql)
298 self.cursor.execute(sql)
300 def create_class(self, spec):
301 ''' Create a database table according to the given spec.
302 '''
303 cols, mls = self.create_class_table(spec)
304 self.create_journal_table(spec)
306 # now create the multilink tables
307 for ml in mls:
308 self.create_multilink_table(spec, ml)
310 # ID counter
311 sql = 'insert into ids (name, num) values (%s,%s)'%(self.arg, self.arg)
312 vals = (spec.classname, 1)
313 if __debug__:
314 print >>hyperdb.DEBUG, 'create_class', (self, sql, vals)
315 self.cursor.execute(sql, vals)
317 def drop_class(self, spec):
318 ''' Drop the given table from the database.
320 Drop the journal and multilink tables too.
321 '''
322 # figure the multilinks
323 mls = []
324 for col, prop in spec.properties.items():
325 if isinstance(prop, Multilink):
326 mls.append(col)
328 sql = 'drop table _%s'%spec.classname
329 if __debug__:
330 print >>hyperdb.DEBUG, 'drop_class', (self, sql)
331 self.cursor.execute(sql)
333 sql = 'drop table %s__journal'%spec.classname
334 if __debug__:
335 print >>hyperdb.DEBUG, 'drop_class', (self, sql)
336 self.cursor.execute(sql)
338 for ml in mls:
339 sql = 'drop table %s_%s'%(spec.classname, ml)
340 if __debug__:
341 print >>hyperdb.DEBUG, 'drop_class', (self, sql)
342 self.cursor.execute(sql)
344 #
345 # Classes
346 #
347 def __getattr__(self, classname):
348 ''' A convenient way of calling self.getclass(classname).
349 '''
350 if self.classes.has_key(classname):
351 if __debug__:
352 print >>hyperdb.DEBUG, '__getattr__', (self, classname)
353 return self.classes[classname]
354 raise AttributeError, classname
356 def addclass(self, cl):
357 ''' Add a Class to the hyperdatabase.
358 '''
359 if __debug__:
360 print >>hyperdb.DEBUG, 'addclass', (self, cl)
361 cn = cl.classname
362 if self.classes.has_key(cn):
363 raise ValueError, cn
364 self.classes[cn] = cl
366 def getclasses(self):
367 ''' Return a list of the names of all existing classes.
368 '''
369 if __debug__:
370 print >>hyperdb.DEBUG, 'getclasses', (self,)
371 l = self.classes.keys()
372 l.sort()
373 return l
375 def getclass(self, classname):
376 '''Get the Class object representing a particular class.
378 If 'classname' is not a valid class name, a KeyError is raised.
379 '''
380 if __debug__:
381 print >>hyperdb.DEBUG, 'getclass', (self, classname)
382 try:
383 return self.classes[classname]
384 except KeyError:
385 raise KeyError, 'There is no class called "%s"'%classname
387 def clear(self):
388 ''' Delete all database contents.
390 Note: I don't commit here, which is different behaviour to the
391 "nuke from orbit" behaviour in the *dbms.
392 '''
393 if __debug__:
394 print >>hyperdb.DEBUG, 'clear', (self,)
395 for cn in self.classes.keys():
396 sql = 'delete from _%s'%cn
397 if __debug__:
398 print >>hyperdb.DEBUG, 'clear', (self, sql)
399 self.cursor.execute(sql)
401 #
402 # Node IDs
403 #
404 def newid(self, classname):
405 ''' Generate a new id for the given class
406 '''
407 # get the next ID
408 sql = 'select num from ids where name=%s'%self.arg
409 if __debug__:
410 print >>hyperdb.DEBUG, 'newid', (self, sql, classname)
411 self.cursor.execute(sql, (classname, ))
412 newid = self.cursor.fetchone()[0]
414 # update the counter
415 sql = 'update ids set num=%s where name=%s'%(self.arg, self.arg)
416 vals = (int(newid)+1, classname)
417 if __debug__:
418 print >>hyperdb.DEBUG, 'newid', (self, sql, vals)
419 self.cursor.execute(sql, vals)
421 # return as string
422 return str(newid)
424 def setid(self, classname, setid):
425 ''' Set the id counter: used during import of database
426 '''
427 sql = 'update ids set num=%s where name=%s'%(self.arg, self.arg)
428 vals = (setid, classname)
429 if __debug__:
430 print >>hyperdb.DEBUG, 'setid', (self, sql, vals)
431 self.cursor.execute(sql, vals)
433 #
434 # Nodes
435 #
436 def addnode(self, classname, nodeid, node):
437 ''' Add the specified node to its class's db.
438 '''
439 if __debug__:
440 print >>hyperdb.DEBUG, 'addnode', (self, classname, nodeid, node)
442 # determine the column definitions and multilink tables
443 cl = self.classes[classname]
444 cols, mls = self.determine_columns(cl.properties.items())
446 # we'll be supplied these props if we're doing an import
447 if not node.has_key('creator'):
448 # add in the "calculated" properties (dupe so we don't affect
449 # calling code's node assumptions)
450 node = node.copy()
451 node['creation'] = node['activity'] = date.Date()
452 node['creator'] = self.curuserid
454 # default the non-multilink columns
455 for col, prop in cl.properties.items():
456 if not node.has_key(col):
457 if isinstance(prop, Multilink):
458 node[col] = []
459 else:
460 node[col] = None
462 # clear this node out of the cache if it's in there
463 key = (classname, nodeid)
464 if self.cache.has_key(key):
465 del self.cache[key]
466 self.cache_lru.remove(key)
468 # make the node data safe for the DB
469 node = self.serialise(classname, node)
471 # make sure the ordering is correct for column name -> column value
472 vals = tuple([node[col[1:]] for col in cols]) + (nodeid, 0)
473 s = ','.join([self.arg for x in cols]) + ',%s,%s'%(self.arg, self.arg)
474 cols = ','.join(cols) + ',id,__retired__'
476 # perform the inserts
477 sql = 'insert into _%s (%s) values (%s)'%(classname, cols, s)
478 if __debug__:
479 print >>hyperdb.DEBUG, 'addnode', (self, sql, vals)
480 self.cursor.execute(sql, vals)
482 # insert the multilink rows
483 for col in mls:
484 t = '%s_%s'%(classname, col)
485 for entry in node[col]:
486 sql = 'insert into %s (linkid, nodeid) values (%s,%s)'%(t,
487 self.arg, self.arg)
488 self.sql(sql, (entry, nodeid))
490 # make sure we do the commit-time extra stuff for this node
491 self.transactions.append((self.doSaveNode, (classname, nodeid, node)))
493 def setnode(self, classname, nodeid, values, multilink_changes):
494 ''' Change the specified node.
495 '''
496 if __debug__:
497 print >>hyperdb.DEBUG, 'setnode', (self, classname, nodeid, values)
499 # clear this node out of the cache if it's in there
500 key = (classname, nodeid)
501 if self.cache.has_key(key):
502 del self.cache[key]
503 self.cache_lru.remove(key)
505 # add the special props
506 values = values.copy()
507 values['activity'] = date.Date()
509 # make db-friendly
510 values = self.serialise(classname, values)
512 cl = self.classes[classname]
513 cols = []
514 mls = []
515 # add the multilinks separately
516 props = cl.getprops()
517 for col in values.keys():
518 prop = props[col]
519 if isinstance(prop, Multilink):
520 mls.append(col)
521 else:
522 cols.append('_'+col)
523 cols.sort()
525 # if there's any updates to regular columns, do them
526 if cols:
527 # make sure the ordering is correct for column name -> column value
528 sqlvals = tuple([values[col[1:]] for col in cols]) + (nodeid,)
529 s = ','.join(['%s=%s'%(x, self.arg) for x in cols])
530 cols = ','.join(cols)
532 # perform the update
533 sql = 'update _%s set %s where id=%s'%(classname, s, self.arg)
534 if __debug__:
535 print >>hyperdb.DEBUG, 'setnode', (self, sql, sqlvals)
536 self.cursor.execute(sql, sqlvals)
538 # now the fun bit, updating the multilinks ;)
539 for col, (add, remove) in multilink_changes.items():
540 tn = '%s_%s'%(classname, col)
541 if add:
542 sql = 'insert into %s (nodeid, linkid) values (%s,%s)'%(tn,
543 self.arg, self.arg)
544 for addid in add:
545 self.sql(sql, (nodeid, addid))
546 if remove:
547 sql = 'delete from %s where nodeid=%s and linkid=%s'%(tn,
548 self.arg, self.arg)
549 for removeid in remove:
550 self.sql(sql, (nodeid, removeid))
552 # make sure we do the commit-time extra stuff for this node
553 self.transactions.append((self.doSaveNode, (classname, nodeid, values)))
555 def getnode(self, classname, nodeid):
556 ''' Get a node from the database.
557 '''
558 if __debug__:
559 print >>hyperdb.DEBUG, 'getnode', (self, classname, nodeid)
561 # see if we have this node cached
562 key = (classname, nodeid)
563 if self.cache.has_key(key):
564 # push us back to the top of the LRU
565 self.cache_lru.remove(key)
566 self.cache_lru.insert(0, key)
567 # return the cached information
568 return self.cache[key]
570 # figure the columns we're fetching
571 cl = self.classes[classname]
572 cols, mls = self.determine_columns(cl.properties.items())
573 scols = ','.join(cols)
575 # perform the basic property fetch
576 sql = 'select %s from _%s where id=%s'%(scols, classname, self.arg)
577 self.sql(sql, (nodeid,))
579 values = self.sql_fetchone()
580 if values is None:
581 raise IndexError, 'no such %s node %s'%(classname, nodeid)
583 # make up the node
584 node = {}
585 for col in range(len(cols)):
586 node[cols[col][1:]] = values[col]
588 # now the multilinks
589 for col in mls:
590 # get the link ids
591 sql = 'select linkid from %s_%s where nodeid=%s'%(classname, col,
592 self.arg)
593 self.cursor.execute(sql, (nodeid,))
594 # extract the first column from the result
595 node[col] = [x[0] for x in self.cursor.fetchall()]
597 # un-dbificate the node data
598 node = self.unserialise(classname, node)
600 # save off in the cache
601 key = (classname, nodeid)
602 self.cache[key] = node
603 # update the LRU
604 self.cache_lru.insert(0, key)
605 if len(self.cache_lru) > ROW_CACHE_SIZE:
606 del self.cache[self.cache_lru.pop()]
608 return node
610 def destroynode(self, classname, nodeid):
611 '''Remove a node from the database. Called exclusively by the
612 destroy() method on Class.
613 '''
614 if __debug__:
615 print >>hyperdb.DEBUG, 'destroynode', (self, classname, nodeid)
617 # make sure the node exists
618 if not self.hasnode(classname, nodeid):
619 raise IndexError, '%s has no node %s'%(classname, nodeid)
621 # see if we have this node cached
622 if self.cache.has_key((classname, nodeid)):
623 del self.cache[(classname, nodeid)]
625 # see if there's any obvious commit actions that we should get rid of
626 for entry in self.transactions[:]:
627 if entry[1][:2] == (classname, nodeid):
628 self.transactions.remove(entry)
630 # now do the SQL
631 sql = 'delete from _%s where id=%s'%(classname, self.arg)
632 self.sql(sql, (nodeid,))
634 # remove from multilnks
635 cl = self.getclass(classname)
636 x, mls = self.determine_columns(cl.properties.items())
637 for col in mls:
638 # get the link ids
639 sql = 'delete from %s_%s where nodeid=%s'%(classname, col, self.arg)
640 self.cursor.execute(sql, (nodeid,))
642 # remove journal entries
643 sql = 'delete from %s__journal where nodeid=%s'%(classname, self.arg)
644 self.sql(sql, (nodeid,))
646 def serialise(self, classname, node):
647 '''Copy the node contents, converting non-marshallable data into
648 marshallable data.
649 '''
650 if __debug__:
651 print >>hyperdb.DEBUG, 'serialise', classname, node
652 properties = self.getclass(classname).getprops()
653 d = {}
654 for k, v in node.items():
655 # if the property doesn't exist, or is the "retired" flag then
656 # it won't be in the properties dict
657 if not properties.has_key(k):
658 d[k] = v
659 continue
661 # get the property spec
662 prop = properties[k]
664 if isinstance(prop, Password) and v is not None:
665 d[k] = str(v)
666 elif isinstance(prop, Date) and v is not None:
667 d[k] = v.serialise()
668 elif isinstance(prop, Interval) and v is not None:
669 d[k] = v.serialise()
670 else:
671 d[k] = v
672 return d
674 def unserialise(self, classname, node):
675 '''Decode the marshalled node data
676 '''
677 if __debug__:
678 print >>hyperdb.DEBUG, 'unserialise', classname, node
679 properties = self.getclass(classname).getprops()
680 d = {}
681 for k, v in node.items():
682 # if the property doesn't exist, or is the "retired" flag then
683 # it won't be in the properties dict
684 if not properties.has_key(k):
685 d[k] = v
686 continue
688 # get the property spec
689 prop = properties[k]
691 if isinstance(prop, Date) and v is not None:
692 d[k] = date.Date(v)
693 elif isinstance(prop, Interval) and v is not None:
694 d[k] = date.Interval(v)
695 elif isinstance(prop, Password) and v is not None:
696 p = password.Password()
697 p.unpack(v)
698 d[k] = p
699 elif (isinstance(prop, Boolean) or isinstance(prop, Number)) and v is not None:
700 d[k]=float(v)
701 else:
702 d[k] = v
703 return d
705 def hasnode(self, classname, nodeid):
706 ''' Determine if the database has a given node.
707 '''
708 sql = 'select count(*) from _%s where id=%s'%(classname, self.arg)
709 if __debug__:
710 print >>hyperdb.DEBUG, 'hasnode', (self, sql, nodeid)
711 self.cursor.execute(sql, (nodeid,))
712 return int(self.cursor.fetchone()[0])
714 def countnodes(self, classname):
715 ''' Count the number of nodes that exist for a particular Class.
716 '''
717 sql = 'select count(*) from _%s'%classname
718 if __debug__:
719 print >>hyperdb.DEBUG, 'countnodes', (self, sql)
720 self.cursor.execute(sql)
721 return self.cursor.fetchone()[0]
723 def addjournal(self, classname, nodeid, action, params, creator=None,
724 creation=None):
725 ''' Journal the Action
726 'action' may be:
728 'create' or 'set' -- 'params' is a dictionary of property values
729 'link' or 'unlink' -- 'params' is (classname, nodeid, propname)
730 'retire' -- 'params' is None
731 '''
732 # serialise the parameters now if necessary
733 if isinstance(params, type({})):
734 if action in ('set', 'create'):
735 params = self.serialise(classname, params)
737 # handle supply of the special journalling parameters (usually
738 # supplied on importing an existing database)
739 if creator:
740 journaltag = creator
741 else:
742 journaltag = self.curuserid
743 if creation:
744 journaldate = creation.serialise()
745 else:
746 journaldate = date.Date().serialise()
748 # create the journal entry
749 cols = ','.join('nodeid date tag action params'.split())
751 if __debug__:
752 print >>hyperdb.DEBUG, 'addjournal', (nodeid, journaldate,
753 journaltag, action, params)
755 self.save_journal(classname, cols, nodeid, journaldate,
756 journaltag, action, params)
758 def save_journal(self, classname, cols, nodeid, journaldate,
759 journaltag, action, params):
760 ''' Save the journal entry to the database
761 '''
762 raise NotImplemented
764 def getjournal(self, classname, nodeid):
765 ''' get the journal for id
766 '''
767 # make sure the node exists
768 if not self.hasnode(classname, nodeid):
769 raise IndexError, '%s has no node %s'%(classname, nodeid)
771 cols = ','.join('nodeid date tag action params'.split())
772 return self.load_journal(classname, cols, nodeid)
774 def load_journal(self, classname, cols, nodeid):
775 ''' Load the journal from the database
776 '''
777 raise NotImplemented
779 def pack(self, pack_before):
780 ''' Delete all journal entries except "create" before 'pack_before'.
781 '''
782 # get a 'yyyymmddhhmmss' version of the date
783 date_stamp = pack_before.serialise()
785 # do the delete
786 for classname in self.classes.keys():
787 sql = "delete from %s__journal where date<%s and "\
788 "action<>'create'"%(classname, self.arg)
789 if __debug__:
790 print >>hyperdb.DEBUG, 'pack', (self, sql, date_stamp)
791 self.cursor.execute(sql, (date_stamp,))
793 def sql_commit(self):
794 ''' Actually commit to the database.
795 '''
796 self.conn.commit()
798 def commit(self):
799 ''' Commit the current transactions.
801 Save all data changed since the database was opened or since the
802 last commit() or rollback().
803 '''
804 if __debug__:
805 print >>hyperdb.DEBUG, 'commit', (self,)
807 # commit the database
808 self.sql_commit()
810 # now, do all the other transaction stuff
811 reindex = {}
812 for method, args in self.transactions:
813 reindex[method(*args)] = 1
815 # reindex the nodes that request it
816 for classname, nodeid in filter(None, reindex.keys()):
817 print >>hyperdb.DEBUG, 'commit.reindex', (classname, nodeid)
818 self.getclass(classname).index(nodeid)
820 # save the indexer state
821 self.indexer.save_index()
823 # clear out the transactions
824 self.transactions = []
826 def rollback(self):
827 ''' Reverse all actions from the current transaction.
829 Undo all the changes made since the database was opened or the last
830 commit() or rollback() was performed.
831 '''
832 if __debug__:
833 print >>hyperdb.DEBUG, 'rollback', (self,)
835 # roll back
836 self.conn.rollback()
838 # roll back "other" transaction stuff
839 for method, args in self.transactions:
840 # delete temporary files
841 if method == self.doStoreFile:
842 self.rollbackStoreFile(*args)
843 self.transactions = []
845 # clear the cache
846 self.clearCache()
848 def doSaveNode(self, classname, nodeid, node):
849 ''' dummy that just generates a reindex event
850 '''
851 # return the classname, nodeid so we reindex this content
852 return (classname, nodeid)
854 def close(self):
855 ''' Close off the connection.
856 '''
857 self.conn.close()
858 if self.lockfile is not None:
859 locking.release_lock(self.lockfile)
860 if self.lockfile is not None:
861 self.lockfile.close()
862 self.lockfile = None
864 #
865 # The base Class class
866 #
867 class Class(hyperdb.Class):
868 ''' The handle to a particular class of nodes in a hyperdatabase.
870 All methods except __repr__ and getnode must be implemented by a
871 concrete backend Class.
872 '''
874 def __init__(self, db, classname, **properties):
875 '''Create a new class with a given name and property specification.
877 'classname' must not collide with the name of an existing class,
878 or a ValueError is raised. The keyword arguments in 'properties'
879 must map names to property objects, or a TypeError is raised.
880 '''
881 if (properties.has_key('creation') or properties.has_key('activity')
882 or properties.has_key('creator')):
883 raise ValueError, '"creation", "activity" and "creator" are '\
884 'reserved'
886 self.classname = classname
887 self.properties = properties
888 self.db = weakref.proxy(db) # use a weak ref to avoid circularity
889 self.key = ''
891 # should we journal changes (default yes)
892 self.do_journal = 1
894 # do the db-related init stuff
895 db.addclass(self)
897 self.auditors = {'create': [], 'set': [], 'retire': [], 'restore': []}
898 self.reactors = {'create': [], 'set': [], 'retire': [], 'restore': []}
900 def schema(self):
901 ''' A dumpable version of the schema that we can store in the
902 database
903 '''
904 return (self.key, [(x, repr(y)) for x,y in self.properties.items()])
906 def enableJournalling(self):
907 '''Turn journalling on for this class
908 '''
909 self.do_journal = 1
911 def disableJournalling(self):
912 '''Turn journalling off for this class
913 '''
914 self.do_journal = 0
916 # Editing nodes:
917 def create(self, **propvalues):
918 ''' Create a new node of this class and return its id.
920 The keyword arguments in 'propvalues' map property names to values.
922 The values of arguments must be acceptable for the types of their
923 corresponding properties or a TypeError is raised.
925 If this class has a key property, it must be present and its value
926 must not collide with other key strings or a ValueError is raised.
928 Any other properties on this class that are missing from the
929 'propvalues' dictionary are set to None.
931 If an id in a link or multilink property does not refer to a valid
932 node, an IndexError is raised.
933 '''
934 self.fireAuditors('create', None, propvalues)
935 newid = self.create_inner(**propvalues)
936 self.fireReactors('create', newid, None)
937 return newid
939 def create_inner(self, **propvalues):
940 ''' Called by create, in-between the audit and react calls.
941 '''
942 if propvalues.has_key('id'):
943 raise KeyError, '"id" is reserved'
945 if self.db.journaltag is None:
946 raise DatabaseError, 'Database open read-only'
948 if propvalues.has_key('creation') or propvalues.has_key('activity'):
949 raise KeyError, '"creation" and "activity" are reserved'
951 # new node's id
952 newid = self.db.newid(self.classname)
954 # validate propvalues
955 num_re = re.compile('^\d+$')
956 for key, value in propvalues.items():
957 if key == self.key:
958 try:
959 self.lookup(value)
960 except KeyError:
961 pass
962 else:
963 raise ValueError, 'node with key "%s" exists'%value
965 # try to handle this property
966 try:
967 prop = self.properties[key]
968 except KeyError:
969 raise KeyError, '"%s" has no property "%s"'%(self.classname,
970 key)
972 if value is not None and isinstance(prop, Link):
973 if type(value) != type(''):
974 raise ValueError, 'link value must be String'
975 link_class = self.properties[key].classname
976 # if it isn't a number, it's a key
977 if not num_re.match(value):
978 try:
979 value = self.db.classes[link_class].lookup(value)
980 except (TypeError, KeyError):
981 raise IndexError, 'new property "%s": %s not a %s'%(
982 key, value, link_class)
983 elif not self.db.getclass(link_class).hasnode(value):
984 raise IndexError, '%s has no node %s'%(link_class, value)
986 # save off the value
987 propvalues[key] = value
989 # register the link with the newly linked node
990 if self.do_journal and self.properties[key].do_journal:
991 self.db.addjournal(link_class, value, 'link',
992 (self.classname, newid, key))
994 elif isinstance(prop, Multilink):
995 if type(value) != type([]):
996 raise TypeError, 'new property "%s" not a list of ids'%key
998 # clean up and validate the list of links
999 link_class = self.properties[key].classname
1000 l = []
1001 for entry in value:
1002 if type(entry) != type(''):
1003 raise ValueError, '"%s" multilink value (%r) '\
1004 'must contain Strings'%(key, value)
1005 # if it isn't a number, it's a key
1006 if not num_re.match(entry):
1007 try:
1008 entry = self.db.classes[link_class].lookup(entry)
1009 except (TypeError, KeyError):
1010 raise IndexError, 'new property "%s": %s not a %s'%(
1011 key, entry, self.properties[key].classname)
1012 l.append(entry)
1013 value = l
1014 propvalues[key] = value
1016 # handle additions
1017 for nodeid in value:
1018 if not self.db.getclass(link_class).hasnode(nodeid):
1019 raise IndexError, '%s has no node %s'%(link_class,
1020 nodeid)
1021 # register the link with the newly linked node
1022 if self.do_journal and self.properties[key].do_journal:
1023 self.db.addjournal(link_class, nodeid, 'link',
1024 (self.classname, newid, key))
1026 elif isinstance(prop, String):
1027 if type(value) != type('') and type(value) != type(u''):
1028 raise TypeError, 'new property "%s" not a string'%key
1030 elif isinstance(prop, Password):
1031 if not isinstance(value, password.Password):
1032 raise TypeError, 'new property "%s" not a Password'%key
1034 elif isinstance(prop, Date):
1035 if value is not None and not isinstance(value, date.Date):
1036 raise TypeError, 'new property "%s" not a Date'%key
1038 elif isinstance(prop, Interval):
1039 if value is not None and not isinstance(value, date.Interval):
1040 raise TypeError, 'new property "%s" not an Interval'%key
1042 elif value is not None and isinstance(prop, Number):
1043 try:
1044 float(value)
1045 except ValueError:
1046 raise TypeError, 'new property "%s" not numeric'%key
1048 elif value is not None and isinstance(prop, Boolean):
1049 try:
1050 int(value)
1051 except ValueError:
1052 raise TypeError, 'new property "%s" not boolean'%key
1054 # make sure there's data where there needs to be
1055 for key, prop in self.properties.items():
1056 if propvalues.has_key(key):
1057 continue
1058 if key == self.key:
1059 raise ValueError, 'key property "%s" is required'%key
1060 if isinstance(prop, Multilink):
1061 propvalues[key] = []
1062 else:
1063 propvalues[key] = None
1065 # done
1066 self.db.addnode(self.classname, newid, propvalues)
1067 if self.do_journal:
1068 self.db.addjournal(self.classname, newid, 'create', {})
1070 return newid
1072 def export_list(self, propnames, nodeid):
1073 ''' Export a node - generate a list of CSV-able data in the order
1074 specified by propnames for the given node.
1075 '''
1076 properties = self.getprops()
1077 l = []
1078 for prop in propnames:
1079 proptype = properties[prop]
1080 value = self.get(nodeid, prop)
1081 # "marshal" data where needed
1082 if value is None:
1083 pass
1084 elif isinstance(proptype, hyperdb.Date):
1085 value = value.get_tuple()
1086 elif isinstance(proptype, hyperdb.Interval):
1087 value = value.get_tuple()
1088 elif isinstance(proptype, hyperdb.Password):
1089 value = str(value)
1090 l.append(repr(value))
1091 l.append(self.is_retired(nodeid))
1092 return l
1094 def import_list(self, propnames, proplist):
1095 ''' Import a node - all information including "id" is present and
1096 should not be sanity checked. Triggers are not triggered. The
1097 journal should be initialised using the "creator" and "created"
1098 information.
1100 Return the nodeid of the node imported.
1101 '''
1102 if self.db.journaltag is None:
1103 raise DatabaseError, 'Database open read-only'
1104 properties = self.getprops()
1106 # make the new node's property map
1107 d = {}
1108 retire = 0
1109 newid = None
1110 for i in range(len(propnames)):
1111 # Use eval to reverse the repr() used to output the CSV
1112 value = eval(proplist[i])
1114 # Figure the property for this column
1115 propname = propnames[i]
1117 # "unmarshal" where necessary
1118 if propname == 'id':
1119 newid = value
1120 continue
1121 elif propname == 'is retired':
1122 # is the item retired?
1123 if int(value):
1124 retire = 1
1125 continue
1126 elif value is None:
1127 d[propname] = None
1128 continue
1130 prop = properties[propname]
1131 if value is None:
1132 # don't set Nones
1133 continue
1134 elif isinstance(prop, hyperdb.Date):
1135 value = date.Date(value)
1136 elif isinstance(prop, hyperdb.Interval):
1137 value = date.Interval(value)
1138 elif isinstance(prop, hyperdb.Password):
1139 pwd = password.Password()
1140 pwd.unpack(value)
1141 value = pwd
1142 d[propname] = value
1144 # get a new id if necessary
1145 if newid is None:
1146 newid = self.db.newid(self.classname)
1148 # retire?
1149 if retire:
1150 # use the arg for __retired__ to cope with any odd database type
1151 # conversion (hello, sqlite)
1152 sql = 'update _%s set __retired__=%s where id=%s'%(self.classname,
1153 self.db.arg, self.db.arg)
1154 if __debug__:
1155 print >>hyperdb.DEBUG, 'retire', (self, sql, newid)
1156 self.db.cursor.execute(sql, (1, newid))
1158 # add the node and journal
1159 self.db.addnode(self.classname, newid, d)
1161 # extract the extraneous journalling gumpf and nuke it
1162 if d.has_key('creator'):
1163 creator = d['creator']
1164 del d['creator']
1165 else:
1166 creator = None
1167 if d.has_key('creation'):
1168 creation = d['creation']
1169 del d['creation']
1170 else:
1171 creation = None
1172 if d.has_key('activity'):
1173 del d['activity']
1174 self.db.addjournal(self.classname, newid, 'create', {}, creator,
1175 creation)
1176 return newid
1178 _marker = []
1179 def get(self, nodeid, propname, default=_marker, cache=1):
1180 '''Get the value of a property on an existing node of this class.
1182 'nodeid' must be the id of an existing node of this class or an
1183 IndexError is raised. 'propname' must be the name of a property
1184 of this class or a KeyError is raised.
1186 'cache' indicates whether the transaction cache should be queried
1187 for the node. If the node has been modified and you need to
1188 determine what its values prior to modification are, you need to
1189 set cache=0.
1190 '''
1191 if propname == 'id':
1192 return nodeid
1194 # get the node's dict
1195 d = self.db.getnode(self.classname, nodeid)
1197 if propname == 'creation':
1198 if d.has_key('creation'):
1199 return d['creation']
1200 else:
1201 return date.Date()
1202 if propname == 'activity':
1203 if d.has_key('activity'):
1204 return d['activity']
1205 else:
1206 return date.Date()
1207 if propname == 'creator':
1208 if d.has_key('creator'):
1209 return d['creator']
1210 else:
1211 return self.db.curuserid
1213 # get the property (raises KeyErorr if invalid)
1214 prop = self.properties[propname]
1216 if not d.has_key(propname):
1217 if default is self._marker:
1218 if isinstance(prop, Multilink):
1219 return []
1220 else:
1221 return None
1222 else:
1223 return default
1225 # don't pass our list to other code
1226 if isinstance(prop, Multilink):
1227 return d[propname][:]
1229 return d[propname]
1231 def getnode(self, nodeid, cache=1):
1232 ''' Return a convenience wrapper for the node.
1234 'nodeid' must be the id of an existing node of this class or an
1235 IndexError is raised.
1237 'cache' indicates whether the transaction cache should be queried
1238 for the node. If the node has been modified and you need to
1239 determine what its values prior to modification are, you need to
1240 set cache=0.
1241 '''
1242 return Node(self, nodeid, cache=cache)
1244 def set(self, nodeid, **propvalues):
1245 '''Modify a property on an existing node of this class.
1247 'nodeid' must be the id of an existing node of this class or an
1248 IndexError is raised.
1250 Each key in 'propvalues' must be the name of a property of this
1251 class or a KeyError is raised.
1253 All values in 'propvalues' must be acceptable types for their
1254 corresponding properties or a TypeError is raised.
1256 If the value of the key property is set, it must not collide with
1257 other key strings or a ValueError is raised.
1259 If the value of a Link or Multilink property contains an invalid
1260 node id, a ValueError is raised.
1261 '''
1262 if not propvalues:
1263 return propvalues
1265 if propvalues.has_key('creation') or propvalues.has_key('activity'):
1266 raise KeyError, '"creation" and "activity" are reserved'
1268 if propvalues.has_key('id'):
1269 raise KeyError, '"id" is reserved'
1271 if self.db.journaltag is None:
1272 raise DatabaseError, 'Database open read-only'
1274 self.fireAuditors('set', nodeid, propvalues)
1275 # Take a copy of the node dict so that the subsequent set
1276 # operation doesn't modify the oldvalues structure.
1277 # XXX used to try the cache here first
1278 oldvalues = copy.deepcopy(self.db.getnode(self.classname, nodeid))
1280 node = self.db.getnode(self.classname, nodeid)
1281 if self.is_retired(nodeid):
1282 raise IndexError, 'Requested item is retired'
1283 num_re = re.compile('^\d+$')
1285 # if the journal value is to be different, store it in here
1286 journalvalues = {}
1288 # remember the add/remove stuff for multilinks, making it easier
1289 # for the Database layer to do its stuff
1290 multilink_changes = {}
1292 for propname, value in propvalues.items():
1293 # check to make sure we're not duplicating an existing key
1294 if propname == self.key and node[propname] != value:
1295 try:
1296 self.lookup(value)
1297 except KeyError:
1298 pass
1299 else:
1300 raise ValueError, 'node with key "%s" exists'%value
1302 # this will raise the KeyError if the property isn't valid
1303 # ... we don't use getprops() here because we only care about
1304 # the writeable properties.
1305 try:
1306 prop = self.properties[propname]
1307 except KeyError:
1308 raise KeyError, '"%s" has no property named "%s"'%(
1309 self.classname, propname)
1311 # if the value's the same as the existing value, no sense in
1312 # doing anything
1313 current = node.get(propname, None)
1314 if value == current:
1315 del propvalues[propname]
1316 continue
1317 journalvalues[propname] = current
1319 # do stuff based on the prop type
1320 if isinstance(prop, Link):
1321 link_class = prop.classname
1322 # if it isn't a number, it's a key
1323 if value is not None and not isinstance(value, type('')):
1324 raise ValueError, 'property "%s" link value be a string'%(
1325 propname)
1326 if isinstance(value, type('')) and not num_re.match(value):
1327 try:
1328 value = self.db.classes[link_class].lookup(value)
1329 except (TypeError, KeyError):
1330 raise IndexError, 'new property "%s": %s not a %s'%(
1331 propname, value, prop.classname)
1333 if (value is not None and
1334 not self.db.getclass(link_class).hasnode(value)):
1335 raise IndexError, '%s has no node %s'%(link_class, value)
1337 if self.do_journal and prop.do_journal:
1338 # register the unlink with the old linked node
1339 if node[propname] is not None:
1340 self.db.addjournal(link_class, node[propname], 'unlink',
1341 (self.classname, nodeid, propname))
1343 # register the link with the newly linked node
1344 if value is not None:
1345 self.db.addjournal(link_class, value, 'link',
1346 (self.classname, nodeid, propname))
1348 elif isinstance(prop, Multilink):
1349 if type(value) != type([]):
1350 raise TypeError, 'new property "%s" not a list of'\
1351 ' ids'%propname
1352 link_class = self.properties[propname].classname
1353 l = []
1354 for entry in value:
1355 # if it isn't a number, it's a key
1356 if type(entry) != type(''):
1357 raise ValueError, 'new property "%s" link value ' \
1358 'must be a string'%propname
1359 if not num_re.match(entry):
1360 try:
1361 entry = self.db.classes[link_class].lookup(entry)
1362 except (TypeError, KeyError):
1363 raise IndexError, 'new property "%s": %s not a %s'%(
1364 propname, entry,
1365 self.properties[propname].classname)
1366 l.append(entry)
1367 value = l
1368 propvalues[propname] = value
1370 # figure the journal entry for this property
1371 add = []
1372 remove = []
1374 # handle removals
1375 if node.has_key(propname):
1376 l = node[propname]
1377 else:
1378 l = []
1379 for id in l[:]:
1380 if id in value:
1381 continue
1382 # register the unlink with the old linked node
1383 if self.do_journal and self.properties[propname].do_journal:
1384 self.db.addjournal(link_class, id, 'unlink',
1385 (self.classname, nodeid, propname))
1386 l.remove(id)
1387 remove.append(id)
1389 # handle additions
1390 for id in value:
1391 if not self.db.getclass(link_class).hasnode(id):
1392 raise IndexError, '%s has no node %s'%(link_class, id)
1393 if id in l:
1394 continue
1395 # register the link with the newly linked node
1396 if self.do_journal and self.properties[propname].do_journal:
1397 self.db.addjournal(link_class, id, 'link',
1398 (self.classname, nodeid, propname))
1399 l.append(id)
1400 add.append(id)
1402 # figure the journal entry
1403 l = []
1404 if add:
1405 l.append(('+', add))
1406 if remove:
1407 l.append(('-', remove))
1408 multilink_changes[propname] = (add, remove)
1409 if l:
1410 journalvalues[propname] = tuple(l)
1412 elif isinstance(prop, String):
1413 if value is not None and type(value) != type('') and type(value) != type(u''):
1414 raise TypeError, 'new property "%s" not a string'%propname
1416 elif isinstance(prop, Password):
1417 if not isinstance(value, password.Password):
1418 raise TypeError, 'new property "%s" not a Password'%propname
1419 propvalues[propname] = value
1421 elif value is not None and isinstance(prop, Date):
1422 if not isinstance(value, date.Date):
1423 raise TypeError, 'new property "%s" not a Date'% propname
1424 propvalues[propname] = value
1426 elif value is not None and isinstance(prop, Interval):
1427 if not isinstance(value, date.Interval):
1428 raise TypeError, 'new property "%s" not an '\
1429 'Interval'%propname
1430 propvalues[propname] = value
1432 elif value is not None and isinstance(prop, Number):
1433 try:
1434 float(value)
1435 except ValueError:
1436 raise TypeError, 'new property "%s" not numeric'%propname
1438 elif value is not None and isinstance(prop, Boolean):
1439 try:
1440 int(value)
1441 except ValueError:
1442 raise TypeError, 'new property "%s" not boolean'%propname
1444 # nothing to do?
1445 if not propvalues:
1446 return propvalues
1448 # do the set, and journal it
1449 self.db.setnode(self.classname, nodeid, propvalues, multilink_changes)
1451 if self.do_journal:
1452 self.db.addjournal(self.classname, nodeid, 'set', journalvalues)
1454 self.fireReactors('set', nodeid, oldvalues)
1456 return propvalues
1458 def retire(self, nodeid):
1459 '''Retire a node.
1461 The properties on the node remain available from the get() method,
1462 and the node's id is never reused.
1464 Retired nodes are not returned by the find(), list(), or lookup()
1465 methods, and other nodes may reuse the values of their key properties.
1466 '''
1467 if self.db.journaltag is None:
1468 raise DatabaseError, 'Database open read-only'
1470 self.fireAuditors('retire', nodeid, None)
1472 # use the arg for __retired__ to cope with any odd database type
1473 # conversion (hello, sqlite)
1474 sql = 'update _%s set __retired__=%s where id=%s'%(self.classname,
1475 self.db.arg, self.db.arg)
1476 if __debug__:
1477 print >>hyperdb.DEBUG, 'retire', (self, sql, nodeid)
1478 self.db.cursor.execute(sql, (1, nodeid))
1479 if self.do_journal:
1480 self.db.addjournal(self.classname, nodeid, 'retired', None)
1482 self.fireReactors('retire', nodeid, None)
1484 def restore(self, nodeid):
1485 '''Restore a retired node.
1487 Make node available for all operations like it was before retirement.
1488 '''
1489 if self.db.journaltag is None:
1490 raise DatabaseError, 'Database open read-only'
1492 node = self.db.getnode(self.classname, nodeid)
1493 # check if key property was overrided
1494 key = self.getkey()
1495 try:
1496 id = self.lookup(node[key])
1497 except KeyError:
1498 pass
1499 else:
1500 raise KeyError, "Key property (%s) of retired node clashes with \
1501 existing one (%s)" % (key, node[key])
1503 self.fireAuditors('restore', nodeid, None)
1504 # use the arg for __retired__ to cope with any odd database type
1505 # conversion (hello, sqlite)
1506 sql = 'update _%s set __retired__=%s where id=%s'%(self.classname,
1507 self.db.arg, self.db.arg)
1508 if __debug__:
1509 print >>hyperdb.DEBUG, 'restore', (self, sql, nodeid)
1510 self.db.cursor.execute(sql, (0, nodeid))
1511 if self.do_journal:
1512 self.db.addjournal(self.classname, nodeid, 'restored', None)
1514 self.fireReactors('restore', nodeid, None)
1516 def is_retired(self, nodeid):
1517 '''Return true if the node is rerired
1518 '''
1519 sql = 'select __retired__ from _%s where id=%s'%(self.classname,
1520 self.db.arg)
1521 if __debug__:
1522 print >>hyperdb.DEBUG, 'is_retired', (self, sql, nodeid)
1523 self.db.cursor.execute(sql, (nodeid,))
1524 return int(self.db.sql_fetchone()[0])
1526 def destroy(self, nodeid):
1527 '''Destroy a node.
1529 WARNING: this method should never be used except in extremely rare
1530 situations where there could never be links to the node being
1531 deleted
1532 WARNING: use retire() instead
1533 WARNING: the properties of this node will not be available ever again
1534 WARNING: really, use retire() instead
1536 Well, I think that's enough warnings. This method exists mostly to
1537 support the session storage of the cgi interface.
1539 The node is completely removed from the hyperdb, including all journal
1540 entries. It will no longer be available, and will generally break code
1541 if there are any references to the node.
1542 '''
1543 if self.db.journaltag is None:
1544 raise DatabaseError, 'Database open read-only'
1545 self.db.destroynode(self.classname, nodeid)
1547 def history(self, nodeid):
1548 '''Retrieve the journal of edits on a particular node.
1550 'nodeid' must be the id of an existing node of this class or an
1551 IndexError is raised.
1553 The returned list contains tuples of the form
1555 (nodeid, date, tag, action, params)
1557 'date' is a Timestamp object specifying the time of the change and
1558 'tag' is the journaltag specified when the database was opened.
1559 '''
1560 if not self.do_journal:
1561 raise ValueError, 'Journalling is disabled for this class'
1562 return self.db.getjournal(self.classname, nodeid)
1564 # Locating nodes:
1565 def hasnode(self, nodeid):
1566 '''Determine if the given nodeid actually exists
1567 '''
1568 return self.db.hasnode(self.classname, nodeid)
1570 def setkey(self, propname):
1571 '''Select a String property of this class to be the key property.
1573 'propname' must be the name of a String property of this class or
1574 None, or a TypeError is raised. The values of the key property on
1575 all existing nodes must be unique or a ValueError is raised.
1576 '''
1577 # XXX create an index on the key prop column
1578 prop = self.getprops()[propname]
1579 if not isinstance(prop, String):
1580 raise TypeError, 'key properties must be String'
1581 self.key = propname
1583 def getkey(self):
1584 '''Return the name of the key property for this class or None.'''
1585 return self.key
1587 def labelprop(self, default_to_id=0):
1588 ''' Return the property name for a label for the given node.
1590 This method attempts to generate a consistent label for the node.
1591 It tries the following in order:
1592 1. key property
1593 2. "name" property
1594 3. "title" property
1595 4. first property from the sorted property name list
1596 '''
1597 k = self.getkey()
1598 if k:
1599 return k
1600 props = self.getprops()
1601 if props.has_key('name'):
1602 return 'name'
1603 elif props.has_key('title'):
1604 return 'title'
1605 if default_to_id:
1606 return 'id'
1607 props = props.keys()
1608 props.sort()
1609 return props[0]
1611 def lookup(self, keyvalue):
1612 '''Locate a particular node by its key property and return its id.
1614 If this class has no key property, a TypeError is raised. If the
1615 'keyvalue' matches one of the values for the key property among
1616 the nodes in this class, the matching node's id is returned;
1617 otherwise a KeyError is raised.
1618 '''
1619 if not self.key:
1620 raise TypeError, 'No key property set for class %s'%self.classname
1622 # use the arg to handle any odd database type conversion (hello,
1623 # sqlite)
1624 sql = "select id from _%s where _%s=%s and __retired__ <> %s"%(
1625 self.classname, self.key, self.db.arg, self.db.arg)
1626 self.db.sql(sql, (keyvalue, 1))
1628 # see if there was a result that's not retired
1629 row = self.db.sql_fetchone()
1630 if not row:
1631 raise KeyError, 'No key (%s) value "%s" for "%s"'%(self.key,
1632 keyvalue, self.classname)
1634 # return the id
1635 return row[0]
1637 def find(self, **propspec):
1638 '''Get the ids of nodes in this class which link to the given nodes.
1640 'propspec' consists of keyword args propname=nodeid or
1641 propname={nodeid:1, }
1642 'propname' must be the name of a property in this class, or a
1643 KeyError is raised. That property must be a Link or Multilink
1644 property, or a TypeError is raised.
1646 Any node in this class whose 'propname' property links to any of the
1647 nodeids will be returned. Used by the full text indexing, which knows
1648 that "foo" occurs in msg1, msg3 and file7, so we have hits on these
1649 issues:
1651 db.issue.find(messages={'1':1,'3':1}, files={'7':1})
1652 '''
1653 if __debug__:
1654 print >>hyperdb.DEBUG, 'find', (self, propspec)
1656 # shortcut
1657 if not propspec:
1658 return []
1660 # validate the args
1661 props = self.getprops()
1662 propspec = propspec.items()
1663 for propname, nodeids in propspec:
1664 # check the prop is OK
1665 prop = props[propname]
1666 if not isinstance(prop, Link) and not isinstance(prop, Multilink):
1667 raise TypeError, "'%s' not a Link/Multilink property"%propname
1669 # first, links
1670 where = []
1671 allvalues = ()
1672 a = self.db.arg
1673 for prop, values in propspec:
1674 if not isinstance(props[prop], hyperdb.Link):
1675 continue
1676 if type(values) is type(''):
1677 allvalues += (values,)
1678 where.append('_%s = %s'%(prop, a))
1679 elif values is None:
1680 where.append('_%s is NULL'%prop)
1681 else:
1682 allvalues += tuple(values.keys())
1683 where.append('_%s in (%s)'%(prop, ','.join([a]*len(values))))
1684 tables = []
1685 if where:
1686 tables.append('select id as nodeid from _%s where %s'%(
1687 self.classname, ' and '.join(where)))
1689 # now multilinks
1690 for prop, values in propspec:
1691 if not isinstance(props[prop], hyperdb.Multilink):
1692 continue
1693 if type(values) is type(''):
1694 allvalues += (values,)
1695 s = a
1696 else:
1697 allvalues += tuple(values.keys())
1698 s = ','.join([a]*len(values))
1699 tables.append('select nodeid from %s_%s where linkid in (%s)'%(
1700 self.classname, prop, s))
1701 sql = '\nunion\n'.join(tables)
1702 self.db.sql(sql, allvalues)
1703 l = [x[0] for x in self.db.sql_fetchall()]
1704 if __debug__:
1705 print >>hyperdb.DEBUG, 'find ... ', l
1706 return l
1708 def stringFind(self, **requirements):
1709 '''Locate a particular node by matching a set of its String
1710 properties in a caseless search.
1712 If the property is not a String property, a TypeError is raised.
1714 The return is a list of the id of all nodes that match.
1715 '''
1716 where = []
1717 args = []
1718 for propname in requirements.keys():
1719 prop = self.properties[propname]
1720 if isinstance(not prop, String):
1721 raise TypeError, "'%s' not a String property"%propname
1722 where.append(propname)
1723 args.append(requirements[propname].lower())
1725 # generate the where clause
1726 s = ' and '.join(['lower(_%s)=%s'%(col, self.db.arg) for col in where])
1727 sql = 'select id from _%s where %s'%(self.classname, s)
1728 self.db.sql(sql, tuple(args))
1729 l = [x[0] for x in self.db.sql_fetchall()]
1730 if __debug__:
1731 print >>hyperdb.DEBUG, 'find ... ', l
1732 return l
1734 def list(self):
1735 ''' Return a list of the ids of the active nodes in this class.
1736 '''
1737 return self.getnodeids(retired=0)
1739 def getnodeids(self, retired=None):
1740 ''' Retrieve all the ids of the nodes for a particular Class.
1742 Set retired=None to get all nodes. Otherwise it'll get all the
1743 retired or non-retired nodes, depending on the flag.
1744 '''
1745 # flip the sense of the 'retired' flag if we don't want all of them
1746 if retired is not None:
1747 if retired:
1748 args = (0, )
1749 else:
1750 args = (1, )
1751 sql = 'select id from _%s where __retired__ <> %s'%(self.classname,
1752 self.db.arg)
1753 else:
1754 args = ()
1755 sql = 'select id from _%s'%self.classname
1756 if __debug__:
1757 print >>hyperdb.DEBUG, 'getnodeids', (self, sql, retired)
1758 self.db.cursor.execute(sql, args)
1759 ids = [x[0] for x in self.db.cursor.fetchall()]
1760 return ids
1762 def filter(self, search_matches, filterspec, sort=(None,None),
1763 group=(None,None)):
1764 ''' Return a list of the ids of the active nodes in this class that
1765 match the 'filter' spec, sorted by the group spec and then the
1766 sort spec
1768 "filterspec" is {propname: value(s)}
1769 "sort" and "group" are (dir, prop) where dir is '+', '-' or None
1770 and prop is a prop name or None
1771 "search_matches" is {nodeid: marker}
1773 The filter must match all properties specificed - but if the
1774 property value to match is a list, any one of the values in the
1775 list may match for that property to match.
1776 '''
1777 # just don't bother if the full-text search matched diddly
1778 if search_matches == {}:
1779 return []
1781 cn = self.classname
1783 timezone = self.db.getUserTimezone()
1785 # figure the WHERE clause from the filterspec
1786 props = self.getprops()
1787 frum = ['_'+cn]
1788 where = []
1789 args = []
1790 a = self.db.arg
1791 for k, v in filterspec.items():
1792 propclass = props[k]
1793 # now do other where clause stuff
1794 if isinstance(propclass, Multilink):
1795 tn = '%s_%s'%(cn, k)
1796 if v in ('-1', ['-1']):
1797 # only match rows that have count(linkid)=0 in the
1798 # corresponding multilink table)
1799 where.append('id not in (select nodeid from %s)'%tn)
1800 elif isinstance(v, type([])):
1801 frum.append(tn)
1802 s = ','.join([a for x in v])
1803 where.append('id=%s.nodeid and %s.linkid in (%s)'%(tn,tn,s))
1804 args = args + v
1805 else:
1806 frum.append(tn)
1807 where.append('id=%s.nodeid and %s.linkid=%s'%(tn, tn, a))
1808 args.append(v)
1809 elif k == 'id':
1810 if isinstance(v, type([])):
1811 s = ','.join([a for x in v])
1812 where.append('%s in (%s)'%(k, s))
1813 args = args + v
1814 else:
1815 where.append('%s=%s'%(k, a))
1816 args.append(v)
1817 elif isinstance(propclass, String):
1818 if not isinstance(v, type([])):
1819 v = [v]
1821 # Quote the bits in the string that need it and then embed
1822 # in a "substring" search. Note - need to quote the '%' so
1823 # they make it through the python layer happily
1824 v = ['%%'+self.db.sql_stringquote(s)+'%%' for s in v]
1826 # now add to the where clause
1827 where.append(' or '.join(["_%s LIKE '%s'"%(k, s) for s in v]))
1828 # note: args are embedded in the query string now
1829 elif isinstance(propclass, Link):
1830 if isinstance(v, type([])):
1831 if '-1' in v:
1832 v = v[:]
1833 v.remove('-1')
1834 xtra = ' or _%s is NULL'%k
1835 else:
1836 xtra = ''
1837 if v:
1838 s = ','.join([a for x in v])
1839 where.append('(_%s in (%s)%s)'%(k, s, xtra))
1840 args = args + v
1841 else:
1842 where.append('_%s is NULL'%k)
1843 else:
1844 if v == '-1':
1845 v = None
1846 where.append('_%s is NULL'%k)
1847 else:
1848 where.append('_%s=%s'%(k, a))
1849 args.append(v)
1850 elif isinstance(propclass, Date):
1851 if isinstance(v, type([])):
1852 s = ','.join([a for x in v])
1853 where.append('_%s in (%s)'%(k, s))
1854 args = args + [date.Date(x).serialise() for x in v]
1855 else:
1856 try:
1857 # Try to filter on range of dates
1858 date_rng = Range(v, date.Date, offset=timezone)
1859 if (date_rng.from_value):
1860 where.append('_%s >= %s'%(k, a))
1861 args.append(date_rng.from_value.serialise())
1862 if (date_rng.to_value):
1863 where.append('_%s <= %s'%(k, a))
1864 args.append(date_rng.to_value.serialise())
1865 except ValueError:
1866 # If range creation fails - ignore that search parameter
1867 pass
1868 elif isinstance(propclass, Interval):
1869 if isinstance(v, type([])):
1870 s = ','.join([a for x in v])
1871 where.append('_%s in (%s)'%(k, s))
1872 args = args + [date.Interval(x).serialise() for x in v]
1873 else:
1874 try:
1875 # Try to filter on range of intervals
1876 date_rng = Range(v, date.Interval)
1877 if (date_rng.from_value):
1878 where.append('_%s >= %s'%(k, a))
1879 args.append(date_rng.from_value.serialise())
1880 if (date_rng.to_value):
1881 where.append('_%s <= %s'%(k, a))
1882 args.append(date_rng.to_value.serialise())
1883 except ValueError:
1884 # If range creation fails - ignore that search parameter
1885 pass
1886 #where.append('_%s=%s'%(k, a))
1887 #args.append(date.Interval(v).serialise())
1888 else:
1889 if isinstance(v, type([])):
1890 s = ','.join([a for x in v])
1891 where.append('_%s in (%s)'%(k, s))
1892 args = args + v
1893 else:
1894 where.append('_%s=%s'%(k, a))
1895 args.append(v)
1897 # don't match retired nodes
1898 where.append('__retired__ <> 1')
1900 # add results of full text search
1901 if search_matches is not None:
1902 v = search_matches.keys()
1903 s = ','.join([a for x in v])
1904 where.append('id in (%s)'%s)
1905 args = args + v
1907 # "grouping" is just the first-order sorting in the SQL fetch
1908 # can modify it...)
1909 orderby = []
1910 ordercols = []
1911 if group[0] is not None and group[1] is not None:
1912 if group[0] != '-':
1913 orderby.append('_'+group[1])
1914 ordercols.append('_'+group[1])
1915 else:
1916 orderby.append('_'+group[1]+' desc')
1917 ordercols.append('_'+group[1])
1919 # now add in the sorting
1920 group = ''
1921 if sort[0] is not None and sort[1] is not None:
1922 direction, colname = sort
1923 if direction != '-':
1924 if colname == 'id':
1925 orderby.append(colname)
1926 else:
1927 orderby.append('_'+colname)
1928 ordercols.append('_'+colname)
1929 else:
1930 if colname == 'id':
1931 orderby.append(colname+' desc')
1932 ordercols.append(colname)
1933 else:
1934 orderby.append('_'+colname+' desc')
1935 ordercols.append('_'+colname)
1937 # construct the SQL
1938 frum = ','.join(frum)
1939 if where:
1940 where = ' where ' + (' and '.join(where))
1941 else:
1942 where = ''
1943 cols = ['id']
1944 if orderby:
1945 cols = cols + ordercols
1946 order = ' order by %s'%(','.join(orderby))
1947 else:
1948 order = ''
1949 cols = ','.join(cols)
1950 sql = 'select %s from %s %s%s%s'%(cols, frum, where, group, order)
1951 args = tuple(args)
1952 if __debug__:
1953 print >>hyperdb.DEBUG, 'filter', (self, sql, args)
1954 self.db.cursor.execute(sql, args)
1955 l = self.db.cursor.fetchall()
1957 # return the IDs (the first column)
1958 # XXX The filter(None, l) bit is sqlite-specific... if there's _NO_
1959 # XXX matches to a fetch, it returns NULL instead of nothing!?!
1960 return filter(None, [row[0] for row in l])
1962 def count(self):
1963 '''Get the number of nodes in this class.
1965 If the returned integer is 'numnodes', the ids of all the nodes
1966 in this class run from 1 to numnodes, and numnodes+1 will be the
1967 id of the next node to be created in this class.
1968 '''
1969 return self.db.countnodes(self.classname)
1971 # Manipulating properties:
1972 def getprops(self, protected=1):
1973 '''Return a dictionary mapping property names to property objects.
1974 If the "protected" flag is true, we include protected properties -
1975 those which may not be modified.
1976 '''
1977 d = self.properties.copy()
1978 if protected:
1979 d['id'] = String()
1980 d['creation'] = hyperdb.Date()
1981 d['activity'] = hyperdb.Date()
1982 d['creator'] = hyperdb.Link('user')
1983 return d
1985 def addprop(self, **properties):
1986 '''Add properties to this class.
1988 The keyword arguments in 'properties' must map names to property
1989 objects, or a TypeError is raised. None of the keys in 'properties'
1990 may collide with the names of existing properties, or a ValueError
1991 is raised before any properties have been added.
1992 '''
1993 for key in properties.keys():
1994 if self.properties.has_key(key):
1995 raise ValueError, key
1996 self.properties.update(properties)
1998 def index(self, nodeid):
1999 '''Add (or refresh) the node to search indexes
2000 '''
2001 # find all the String properties that have indexme
2002 for prop, propclass in self.getprops().items():
2003 if isinstance(propclass, String) and propclass.indexme:
2004 try:
2005 value = str(self.get(nodeid, prop))
2006 except IndexError:
2007 # node no longer exists - entry should be removed
2008 self.db.indexer.purge_entry((self.classname, nodeid, prop))
2009 else:
2010 # and index them under (classname, nodeid, property)
2011 self.db.indexer.add_text((self.classname, nodeid, prop),
2012 value)
2015 #
2016 # Detector interface
2017 #
2018 def audit(self, event, detector):
2019 '''Register a detector
2020 '''
2021 l = self.auditors[event]
2022 if detector not in l:
2023 self.auditors[event].append(detector)
2025 def fireAuditors(self, action, nodeid, newvalues):
2026 '''Fire all registered auditors.
2027 '''
2028 for audit in self.auditors[action]:
2029 audit(self.db, self, nodeid, newvalues)
2031 def react(self, event, detector):
2032 '''Register a detector
2033 '''
2034 l = self.reactors[event]
2035 if detector not in l:
2036 self.reactors[event].append(detector)
2038 def fireReactors(self, action, nodeid, oldvalues):
2039 '''Fire all registered reactors.
2040 '''
2041 for react in self.reactors[action]:
2042 react(self.db, self, nodeid, oldvalues)
2044 class FileClass(Class, hyperdb.FileClass):
2045 '''This class defines a large chunk of data. To support this, it has a
2046 mandatory String property "content" which is typically saved off
2047 externally to the hyperdb.
2049 The default MIME type of this data is defined by the
2050 "default_mime_type" class attribute, which may be overridden by each
2051 node if the class defines a "type" String property.
2052 '''
2053 default_mime_type = 'text/plain'
2055 def create(self, **propvalues):
2056 ''' snaffle the file propvalue and store in a file
2057 '''
2058 # we need to fire the auditors now, or the content property won't
2059 # be in propvalues for the auditors to play with
2060 self.fireAuditors('create', None, propvalues)
2062 # now remove the content property so it's not stored in the db
2063 content = propvalues['content']
2064 del propvalues['content']
2066 # do the database create
2067 newid = Class.create_inner(self, **propvalues)
2069 # fire reactors
2070 self.fireReactors('create', newid, None)
2072 # store off the content as a file
2073 self.db.storefile(self.classname, newid, None, content)
2074 return newid
2076 def import_list(self, propnames, proplist):
2077 ''' Trap the "content" property...
2078 '''
2079 # dupe this list so we don't affect others
2080 propnames = propnames[:]
2082 # extract the "content" property from the proplist
2083 i = propnames.index('content')
2084 content = eval(proplist[i])
2085 del propnames[i]
2086 del proplist[i]
2088 # do the normal import
2089 newid = Class.import_list(self, propnames, proplist)
2091 # save off the "content" file
2092 self.db.storefile(self.classname, newid, None, content)
2093 return newid
2095 _marker = []
2096 def get(self, nodeid, propname, default=_marker, cache=1):
2097 ''' trap the content propname and get it from the file
2098 '''
2099 poss_msg = 'Possibly a access right configuration problem.'
2100 if propname == 'content':
2101 try:
2102 return self.db.getfile(self.classname, nodeid, None)
2103 except IOError, (strerror):
2104 # BUG: by catching this we donot see an error in the log.
2105 return 'ERROR reading file: %s%s\n%s\n%s'%(
2106 self.classname, nodeid, poss_msg, strerror)
2107 if default is not self._marker:
2108 return Class.get(self, nodeid, propname, default, cache=cache)
2109 else:
2110 return Class.get(self, nodeid, propname, cache=cache)
2112 def getprops(self, protected=1):
2113 ''' In addition to the actual properties on the node, these methods
2114 provide the "content" property. If the "protected" flag is true,
2115 we include protected properties - those which may not be
2116 modified.
2117 '''
2118 d = Class.getprops(self, protected=protected).copy()
2119 d['content'] = hyperdb.String()
2120 return d
2122 def index(self, nodeid):
2123 ''' Index the node in the search index.
2125 We want to index the content in addition to the normal String
2126 property indexing.
2127 '''
2128 # perform normal indexing
2129 Class.index(self, nodeid)
2131 # get the content to index
2132 content = self.get(nodeid, 'content')
2134 # figure the mime type
2135 if self.properties.has_key('type'):
2136 mime_type = self.get(nodeid, 'type')
2137 else:
2138 mime_type = self.default_mime_type
2140 # and index!
2141 self.db.indexer.add_text((self.classname, nodeid, 'content'), content,
2142 mime_type)
2144 # XXX deviation from spec - was called ItemClass
2145 class IssueClass(Class, roundupdb.IssueClass):
2146 # Overridden methods:
2147 def __init__(self, db, classname, **properties):
2148 '''The newly-created class automatically includes the "messages",
2149 "files", "nosy", and "superseder" properties. If the 'properties'
2150 dictionary attempts to specify any of these properties or a
2151 "creation" or "activity" property, a ValueError is raised.
2152 '''
2153 if not properties.has_key('title'):
2154 properties['title'] = hyperdb.String(indexme='yes')
2155 if not properties.has_key('messages'):
2156 properties['messages'] = hyperdb.Multilink("msg")
2157 if not properties.has_key('files'):
2158 properties['files'] = hyperdb.Multilink("file")
2159 if not properties.has_key('nosy'):
2160 # note: journalling is turned off as it really just wastes
2161 # space. this behaviour may be overridden in an instance
2162 properties['nosy'] = hyperdb.Multilink("user", do_journal="no")
2163 if not properties.has_key('superseder'):
2164 properties['superseder'] = hyperdb.Multilink(classname)
2165 Class.__init__(self, db, classname, **properties)