1 # $Id: rdbms_common.py,v 1.31 2003-02-08 15:31:28 kedder 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, and gadfly stores anything that's marsallable).
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
31 from roundup.backends import locking
33 # support
34 from blobfiles import FileStorage
35 from roundup.indexer import Indexer
36 from sessions import Sessions
38 # number of rows to keep in memory
39 ROW_CACHE_SIZE = 100
41 class Database(FileStorage, hyperdb.Database, roundupdb.Database):
42 ''' Wrapper around an SQL database that presents a hyperdb interface.
44 - some functionality is specific to the actual SQL database, hence
45 the sql_* methods that are NotImplemented
46 - we keep a cache of the latest ROW_CACHE_SIZE row fetches.
47 '''
48 def __init__(self, config, journaltag=None):
49 ''' Open the database and load the schema from it.
50 '''
51 self.config, self.journaltag = config, journaltag
52 self.dir = config.DATABASE
53 self.classes = {}
54 self.indexer = Indexer(self.dir)
55 self.sessions = Sessions(self.config)
56 self.security = security.Security(self)
58 # additional transaction support for external files and the like
59 self.transactions = []
61 # keep a cache of the N most recently retrieved rows of any kind
62 # (classname, nodeid) = row
63 self.cache = {}
64 self.cache_lru = []
66 # database lock
67 self.lockfile = None
69 # open a connection to the database, creating the "conn" attribute
70 self.open_connection()
72 def clearCache(self):
73 self.cache = {}
74 self.cache_lru = []
76 def open_connection(self):
77 ''' Open a connection to the database, creating it if necessary
78 '''
79 raise NotImplemented
81 def sql(self, sql, args=None):
82 ''' Execute the sql with the optional args.
83 '''
84 if __debug__:
85 print >>hyperdb.DEBUG, (self, sql, args)
86 if args:
87 self.cursor.execute(sql, args)
88 else:
89 self.cursor.execute(sql)
91 def sql_fetchone(self):
92 ''' Fetch a single row. If there's nothing to fetch, return None.
93 '''
94 raise NotImplemented
96 def sql_stringquote(self, value):
97 ''' Quote the string so it's safe to put in the 'sql quotes'
98 '''
99 return re.sub("'", "''", str(value))
101 def save_dbschema(self, schema):
102 ''' Save the schema definition that the database currently implements
103 '''
104 raise NotImplemented
106 def load_dbschema(self):
107 ''' Load the schema definition that the database currently implements
108 '''
109 raise NotImplemented
111 def post_init(self):
112 ''' Called once the schema initialisation has finished.
114 We should now confirm that the schema defined by our "classes"
115 attribute actually matches the schema in the database.
116 '''
117 # now detect changes in the schema
118 save = 0
119 for classname, spec in self.classes.items():
120 if self.database_schema.has_key(classname):
121 dbspec = self.database_schema[classname]
122 if self.update_class(spec, dbspec):
123 self.database_schema[classname] = spec.schema()
124 save = 1
125 else:
126 self.create_class(spec)
127 self.database_schema[classname] = spec.schema()
128 save = 1
130 for classname in self.database_schema.keys():
131 if not self.classes.has_key(classname):
132 self.drop_class(classname)
134 # update the database version of the schema
135 if save:
136 self.sql('delete from schema')
137 self.save_dbschema(self.database_schema)
139 # reindex the db if necessary
140 if self.indexer.should_reindex():
141 self.reindex()
143 # commit
144 self.conn.commit()
146 # figure the "curuserid"
147 if self.journaltag is None:
148 self.curuserid = None
149 elif self.journaltag == 'admin':
150 # admin user may not exist, but always has ID 1
151 self.curuserid = '1'
152 else:
153 self.curuserid = self.user.lookup(self.journaltag)
155 def reindex(self):
156 for klass in self.classes.values():
157 for nodeid in klass.list():
158 klass.index(nodeid)
159 self.indexer.save_index()
161 def determine_columns(self, properties):
162 ''' Figure the column names and multilink properties from the spec
164 "properties" is a list of (name, prop) where prop may be an
165 instance of a hyperdb "type" _or_ a string repr of that type.
166 '''
167 cols = ['_activity', '_creator', '_creation']
168 mls = []
169 # add the multilinks separately
170 for col, prop in properties:
171 if isinstance(prop, Multilink):
172 mls.append(col)
173 elif isinstance(prop, type('')) and prop.find('Multilink') != -1:
174 mls.append(col)
175 else:
176 cols.append('_'+col)
177 cols.sort()
178 return cols, mls
180 def update_class(self, spec, dbspec):
181 ''' Determine the differences between the current spec and the
182 database version of the spec, and update where necessary
183 '''
184 spec_schema = spec.schema()
185 if spec_schema == dbspec:
186 # no save needed for this one
187 return 0
188 if __debug__:
189 print >>hyperdb.DEBUG, 'update_class FIRING'
191 # key property changed?
192 if dbspec[0] != spec_schema[0]:
193 if __debug__:
194 print >>hyperdb.DEBUG, 'update_class setting keyprop', `spec[0]`
195 # XXX turn on indexing for the key property
197 # dict 'em up
198 spec_propnames,spec_props = [],{}
199 for propname,prop in spec_schema[1]:
200 spec_propnames.append(propname)
201 spec_props[propname] = prop
202 dbspec_propnames,dbspec_props = [],{}
203 for propname,prop in dbspec[1]:
204 dbspec_propnames.append(propname)
205 dbspec_props[propname] = prop
207 # now compare
208 for propname in spec_propnames:
209 prop = spec_props[propname]
210 if dbspec_props.has_key(propname) and prop==dbspec_props[propname]:
211 continue
212 if __debug__:
213 print >>hyperdb.DEBUG, 'update_class ADD', (propname, prop)
215 if not dbspec_props.has_key(propname):
216 # add the property
217 if isinstance(prop, Multilink):
218 # all we have to do here is create a new table, easy!
219 self.create_multilink_table(spec, propname)
220 continue
222 # no ALTER TABLE, so we:
223 # 1. pull out the data, including an extra None column
224 oldcols, x = self.determine_columns(dbspec[1])
225 oldcols.append('id')
226 oldcols.append('__retired__')
227 cn = spec.classname
228 sql = 'select %s,%s from _%s'%(','.join(oldcols), self.arg, cn)
229 if __debug__:
230 print >>hyperdb.DEBUG, 'update_class', (self, sql, None)
231 self.cursor.execute(sql, (None,))
232 olddata = self.cursor.fetchall()
234 # 2. drop the old table
235 self.cursor.execute('drop table _%s'%cn)
237 # 3. create the new table
238 cols, mls = self.create_class_table(spec)
239 # ensure the new column is last
240 cols.remove('_'+propname)
241 assert oldcols == cols, "Column lists don't match!"
242 cols.append('_'+propname)
244 # 4. populate with the data from step one
245 s = ','.join([self.arg for x in cols])
246 scols = ','.join(cols)
247 sql = 'insert into _%s (%s) values (%s)'%(cn, scols, s)
249 # GAH, nothing had better go wrong from here on in... but
250 # we have to commit the drop...
251 # XXX this isn't necessary in sqlite :(
252 self.conn.commit()
254 # do the insert
255 for row in olddata:
256 self.sql(sql, tuple(row))
258 else:
259 # modify the property
260 if __debug__:
261 print >>hyperdb.DEBUG, 'update_class NOOP'
262 pass # NOOP in gadfly
264 # and the other way - only worry about deletions here
265 for propname in dbspec_propnames:
266 prop = dbspec_props[propname]
267 if spec_props.has_key(propname):
268 continue
269 if __debug__:
270 print >>hyperdb.DEBUG, 'update_class REMOVE', `prop`
272 # delete the property
273 if isinstance(prop, Multilink):
274 sql = 'drop table %s_%s'%(spec.classname, prop)
275 if __debug__:
276 print >>hyperdb.DEBUG, 'update_class', (self, sql)
277 self.cursor.execute(sql)
278 else:
279 # no ALTER TABLE, so we:
280 # 1. pull out the data, excluding the removed column
281 oldcols, x = self.determine_columns(spec.properties.items())
282 oldcols.append('id')
283 oldcols.append('__retired__')
284 # remove the missing column
285 oldcols.remove('_'+propname)
286 cn = spec.classname
287 sql = 'select %s from _%s'%(','.join(oldcols), cn)
288 self.cursor.execute(sql, (None,))
289 olddata = sql.fetchall()
291 # 2. drop the old table
292 self.cursor.execute('drop table _%s'%cn)
294 # 3. create the new table
295 cols, mls = self.create_class_table(self, spec)
296 assert oldcols != cols, "Column lists don't match!"
298 # 4. populate with the data from step one
299 qs = ','.join([self.arg for x in cols])
300 sql = 'insert into _%s values (%s)'%(cn, s)
301 self.cursor.execute(sql, olddata)
302 return 1
304 def create_class_table(self, spec):
305 ''' create the class table for the given spec
306 '''
307 cols, mls = self.determine_columns(spec.properties.items())
309 # add on our special columns
310 cols.append('id')
311 cols.append('__retired__')
313 # create the base table
314 scols = ','.join(['%s varchar'%x for x in cols])
315 sql = 'create table _%s (%s)'%(spec.classname, scols)
316 if __debug__:
317 print >>hyperdb.DEBUG, 'create_class', (self, sql)
318 self.cursor.execute(sql)
320 return cols, mls
322 def create_journal_table(self, spec):
323 ''' create the journal table for a class given the spec and
324 already-determined cols
325 '''
326 # journal table
327 cols = ','.join(['%s varchar'%x
328 for x in 'nodeid date tag action params'.split()])
329 sql = 'create table %s__journal (%s)'%(spec.classname, cols)
330 if __debug__:
331 print >>hyperdb.DEBUG, 'create_class', (self, sql)
332 self.cursor.execute(sql)
334 def create_multilink_table(self, spec, ml):
335 ''' Create a multilink table for the "ml" property of the class
336 given by the spec
337 '''
338 sql = 'create table %s_%s (linkid varchar, nodeid varchar)'%(
339 spec.classname, ml)
340 if __debug__:
341 print >>hyperdb.DEBUG, 'create_class', (self, sql)
342 self.cursor.execute(sql)
344 def create_class(self, spec):
345 ''' Create a database table according to the given spec.
346 '''
347 cols, mls = self.create_class_table(spec)
348 self.create_journal_table(spec)
350 # now create the multilink tables
351 for ml in mls:
352 self.create_multilink_table(spec, ml)
354 # ID counter
355 sql = 'insert into ids (name, num) values (%s,%s)'%(self.arg, self.arg)
356 vals = (spec.classname, 1)
357 if __debug__:
358 print >>hyperdb.DEBUG, 'create_class', (self, sql, vals)
359 self.cursor.execute(sql, vals)
361 def drop_class(self, spec):
362 ''' Drop the given table from the database.
364 Drop the journal and multilink tables too.
365 '''
366 # figure the multilinks
367 mls = []
368 for col, prop in spec.properties.items():
369 if isinstance(prop, Multilink):
370 mls.append(col)
372 sql = 'drop table _%s'%spec.classname
373 if __debug__:
374 print >>hyperdb.DEBUG, 'drop_class', (self, sql)
375 self.cursor.execute(sql)
377 sql = 'drop table %s__journal'%spec.classname
378 if __debug__:
379 print >>hyperdb.DEBUG, 'drop_class', (self, sql)
380 self.cursor.execute(sql)
382 for ml in mls:
383 sql = 'drop table %s_%s'%(spec.classname, ml)
384 if __debug__:
385 print >>hyperdb.DEBUG, 'drop_class', (self, sql)
386 self.cursor.execute(sql)
388 #
389 # Classes
390 #
391 def __getattr__(self, classname):
392 ''' A convenient way of calling self.getclass(classname).
393 '''
394 if self.classes.has_key(classname):
395 if __debug__:
396 print >>hyperdb.DEBUG, '__getattr__', (self, classname)
397 return self.classes[classname]
398 raise AttributeError, classname
400 def addclass(self, cl):
401 ''' Add a Class to the hyperdatabase.
402 '''
403 if __debug__:
404 print >>hyperdb.DEBUG, 'addclass', (self, cl)
405 cn = cl.classname
406 if self.classes.has_key(cn):
407 raise ValueError, cn
408 self.classes[cn] = cl
410 def getclasses(self):
411 ''' Return a list of the names of all existing classes.
412 '''
413 if __debug__:
414 print >>hyperdb.DEBUG, 'getclasses', (self,)
415 l = self.classes.keys()
416 l.sort()
417 return l
419 def getclass(self, classname):
420 '''Get the Class object representing a particular class.
422 If 'classname' is not a valid class name, a KeyError is raised.
423 '''
424 if __debug__:
425 print >>hyperdb.DEBUG, 'getclass', (self, classname)
426 try:
427 return self.classes[classname]
428 except KeyError:
429 raise KeyError, 'There is no class called "%s"'%classname
431 def clear(self):
432 ''' Delete all database contents.
434 Note: I don't commit here, which is different behaviour to the
435 "nuke from orbit" behaviour in the *dbms.
436 '''
437 if __debug__:
438 print >>hyperdb.DEBUG, 'clear', (self,)
439 for cn in self.classes.keys():
440 sql = 'delete from _%s'%cn
441 if __debug__:
442 print >>hyperdb.DEBUG, 'clear', (self, sql)
443 self.cursor.execute(sql)
445 #
446 # Node IDs
447 #
448 def newid(self, classname):
449 ''' Generate a new id for the given class
450 '''
451 # get the next ID
452 sql = 'select num from ids where name=%s'%self.arg
453 if __debug__:
454 print >>hyperdb.DEBUG, 'newid', (self, sql, classname)
455 self.cursor.execute(sql, (classname, ))
456 newid = self.cursor.fetchone()[0]
458 # update the counter
459 sql = 'update ids set num=%s where name=%s'%(self.arg, self.arg)
460 vals = (int(newid)+1, classname)
461 if __debug__:
462 print >>hyperdb.DEBUG, 'newid', (self, sql, vals)
463 self.cursor.execute(sql, vals)
465 # return as string
466 return str(newid)
468 def setid(self, classname, setid):
469 ''' Set the id counter: used during import of database
470 '''
471 sql = 'update ids set num=%s where name=%s'%(self.arg, self.arg)
472 vals = (setid, classname)
473 if __debug__:
474 print >>hyperdb.DEBUG, 'setid', (self, sql, vals)
475 self.cursor.execute(sql, vals)
477 #
478 # Nodes
479 #
481 def addnode(self, classname, nodeid, node):
482 ''' Add the specified node to its class's db.
483 '''
484 if __debug__:
485 print >>hyperdb.DEBUG, 'addnode', (self, classname, nodeid, node)
486 # gadfly requires values for all non-multilink columns
487 cl = self.classes[classname]
488 cols, mls = self.determine_columns(cl.properties.items())
490 # we'll be supplied these props if we're doing an import
491 if not node.has_key('creator'):
492 # add in the "calculated" properties (dupe so we don't affect
493 # calling code's node assumptions)
494 node = node.copy()
495 node['creation'] = node['activity'] = date.Date()
496 node['creator'] = self.curuserid
498 # default the non-multilink columns
499 for col, prop in cl.properties.items():
500 if not isinstance(col, Multilink):
501 if not node.has_key(col):
502 node[col] = None
504 # clear this node out of the cache if it's in there
505 key = (classname, nodeid)
506 if self.cache.has_key(key):
507 del self.cache[key]
508 self.cache_lru.remove(key)
510 # make the node data safe for the DB
511 node = self.serialise(classname, node)
513 # make sure the ordering is correct for column name -> column value
514 vals = tuple([node[col[1:]] for col in cols]) + (nodeid, 0)
515 s = ','.join([self.arg for x in cols]) + ',%s,%s'%(self.arg, self.arg)
516 cols = ','.join(cols) + ',id,__retired__'
518 # perform the inserts
519 sql = 'insert into _%s (%s) values (%s)'%(classname, cols, s)
520 if __debug__:
521 print >>hyperdb.DEBUG, 'addnode', (self, sql, vals)
522 self.cursor.execute(sql, vals)
524 # insert the multilink rows
525 for col in mls:
526 t = '%s_%s'%(classname, col)
527 for entry in node[col]:
528 sql = 'insert into %s (linkid, nodeid) values (%s,%s)'%(t,
529 self.arg, self.arg)
530 self.sql(sql, (entry, nodeid))
532 # make sure we do the commit-time extra stuff for this node
533 self.transactions.append((self.doSaveNode, (classname, nodeid, node)))
535 def setnode(self, classname, nodeid, values, multilink_changes):
536 ''' Change the specified node.
537 '''
538 if __debug__:
539 print >>hyperdb.DEBUG, 'setnode', (self, classname, nodeid, values)
541 # clear this node out of the cache if it's in there
542 key = (classname, nodeid)
543 if self.cache.has_key(key):
544 del self.cache[key]
545 self.cache_lru.remove(key)
547 # add the special props
548 values = values.copy()
549 values['activity'] = date.Date()
551 # make db-friendly
552 values = self.serialise(classname, values)
554 cl = self.classes[classname]
555 cols = []
556 mls = []
557 # add the multilinks separately
558 props = cl.getprops()
559 for col in values.keys():
560 prop = props[col]
561 if isinstance(prop, Multilink):
562 mls.append(col)
563 else:
564 cols.append('_'+col)
565 cols.sort()
567 # if there's any updates to regular columns, do them
568 if cols:
569 # make sure the ordering is correct for column name -> column value
570 sqlvals = tuple([values[col[1:]] for col in cols]) + (nodeid,)
571 s = ','.join(['%s=%s'%(x, self.arg) for x in cols])
572 cols = ','.join(cols)
574 # perform the update
575 sql = 'update _%s set %s where id=%s'%(classname, s, self.arg)
576 if __debug__:
577 print >>hyperdb.DEBUG, 'setnode', (self, sql, sqlvals)
578 self.cursor.execute(sql, sqlvals)
580 # now the fun bit, updating the multilinks ;)
581 for col, (add, remove) in multilink_changes.items():
582 tn = '%s_%s'%(classname, col)
583 if add:
584 sql = 'insert into %s (nodeid, linkid) values (%s,%s)'%(tn,
585 self.arg, self.arg)
586 for addid in add:
587 self.sql(sql, (nodeid, addid))
588 if remove:
589 sql = 'delete from %s where nodeid=%s and linkid=%s'%(tn,
590 self.arg, self.arg)
591 for removeid in remove:
592 self.sql(sql, (nodeid, removeid))
594 # make sure we do the commit-time extra stuff for this node
595 self.transactions.append((self.doSaveNode, (classname, nodeid, values)))
597 def getnode(self, classname, nodeid):
598 ''' Get a node from the database.
599 '''
600 if __debug__:
601 print >>hyperdb.DEBUG, 'getnode', (self, classname, nodeid)
603 # see if we have this node cached
604 key = (classname, nodeid)
605 if self.cache.has_key(key):
606 # push us back to the top of the LRU
607 self.cache_lru.remove(key)
608 self.cache_lru.insert(0, key)
609 # return the cached information
610 return self.cache[key]
612 # figure the columns we're fetching
613 cl = self.classes[classname]
614 cols, mls = self.determine_columns(cl.properties.items())
615 scols = ','.join(cols)
617 # perform the basic property fetch
618 sql = 'select %s from _%s where id=%s'%(scols, classname, self.arg)
619 self.sql(sql, (nodeid,))
621 values = self.sql_fetchone()
622 if values is None:
623 raise IndexError, 'no such %s node %s'%(classname, nodeid)
625 # make up the node
626 node = {}
627 for col in range(len(cols)):
628 node[cols[col][1:]] = values[col]
630 # now the multilinks
631 for col in mls:
632 # get the link ids
633 sql = 'select linkid from %s_%s where nodeid=%s'%(classname, col,
634 self.arg)
635 self.cursor.execute(sql, (nodeid,))
636 # extract the first column from the result
637 node[col] = [x[0] for x in self.cursor.fetchall()]
639 # un-dbificate the node data
640 node = self.unserialise(classname, node)
642 # save off in the cache
643 key = (classname, nodeid)
644 self.cache[key] = node
645 # update the LRU
646 self.cache_lru.insert(0, key)
647 if len(self.cache_lru) > ROW_CACHE_SIZE:
648 del self.cache[self.cache_lru.pop()]
650 return node
652 def destroynode(self, classname, nodeid):
653 '''Remove a node from the database. Called exclusively by the
654 destroy() method on Class.
655 '''
656 if __debug__:
657 print >>hyperdb.DEBUG, 'destroynode', (self, classname, nodeid)
659 # make sure the node exists
660 if not self.hasnode(classname, nodeid):
661 raise IndexError, '%s has no node %s'%(classname, nodeid)
663 # see if we have this node cached
664 if self.cache.has_key((classname, nodeid)):
665 del self.cache[(classname, nodeid)]
667 # see if there's any obvious commit actions that we should get rid of
668 for entry in self.transactions[:]:
669 if entry[1][:2] == (classname, nodeid):
670 self.transactions.remove(entry)
672 # now do the SQL
673 sql = 'delete from _%s where id=%s'%(classname, self.arg)
674 self.sql(sql, (nodeid,))
676 # remove from multilnks
677 cl = self.getclass(classname)
678 x, mls = self.determine_columns(cl.properties.items())
679 for col in mls:
680 # get the link ids
681 sql = 'delete from %s_%s where nodeid=%s'%(classname, col, self.arg)
682 self.cursor.execute(sql, (nodeid,))
684 # remove journal entries
685 sql = 'delete from %s__journal where nodeid=%s'%(classname, self.arg)
686 self.sql(sql, (nodeid,))
688 def serialise(self, classname, node):
689 '''Copy the node contents, converting non-marshallable data into
690 marshallable data.
691 '''
692 if __debug__:
693 print >>hyperdb.DEBUG, 'serialise', classname, node
694 properties = self.getclass(classname).getprops()
695 d = {}
696 for k, v in node.items():
697 # if the property doesn't exist, or is the "retired" flag then
698 # it won't be in the properties dict
699 if not properties.has_key(k):
700 d[k] = v
701 continue
703 # get the property spec
704 prop = properties[k]
706 if isinstance(prop, Password) and v is not None:
707 d[k] = str(v)
708 elif isinstance(prop, Date) and v is not None:
709 d[k] = v.serialise()
710 elif isinstance(prop, Interval) and v is not None:
711 d[k] = v.serialise()
712 else:
713 d[k] = v
714 return d
716 def unserialise(self, classname, node):
717 '''Decode the marshalled node data
718 '''
719 if __debug__:
720 print >>hyperdb.DEBUG, 'unserialise', classname, node
721 properties = self.getclass(classname).getprops()
722 d = {}
723 for k, v in node.items():
724 # if the property doesn't exist, or is the "retired" flag then
725 # it won't be in the properties dict
726 if not properties.has_key(k):
727 d[k] = v
728 continue
730 # get the property spec
731 prop = properties[k]
733 if isinstance(prop, Date) and v is not None:
734 d[k] = date.Date(v)
735 elif isinstance(prop, Interval) and v is not None:
736 d[k] = date.Interval(v)
737 elif isinstance(prop, Password) and v is not None:
738 p = password.Password()
739 p.unpack(v)
740 d[k] = p
741 elif (isinstance(prop, Boolean) or isinstance(prop, Number)) and v is not None:
742 d[k]=float(v)
743 else:
744 d[k] = v
745 return d
747 def hasnode(self, classname, nodeid):
748 ''' Determine if the database has a given node.
749 '''
750 sql = 'select count(*) from _%s where id=%s'%(classname, self.arg)
751 if __debug__:
752 print >>hyperdb.DEBUG, 'hasnode', (self, sql, nodeid)
753 self.cursor.execute(sql, (nodeid,))
754 return int(self.cursor.fetchone()[0])
756 def countnodes(self, classname):
757 ''' Count the number of nodes that exist for a particular Class.
758 '''
759 sql = 'select count(*) from _%s'%classname
760 if __debug__:
761 print >>hyperdb.DEBUG, 'countnodes', (self, sql)
762 self.cursor.execute(sql)
763 return self.cursor.fetchone()[0]
765 def getnodeids(self, classname, retired=0):
766 ''' Retrieve all the ids of the nodes for a particular Class.
768 Set retired=None to get all nodes. Otherwise it'll get all the
769 retired or non-retired nodes, depending on the flag.
770 '''
771 # flip the sense of the flag if we don't want all of them
772 if retired is not None:
773 retired = not retired
774 sql = 'select id from _%s where __retired__ <> %s'%(classname, self.arg)
775 if __debug__:
776 print >>hyperdb.DEBUG, 'getnodeids', (self, sql, retired)
777 self.cursor.execute(sql, (retired,))
778 return [x[0] for x in self.cursor.fetchall()]
780 def addjournal(self, classname, nodeid, action, params, creator=None,
781 creation=None):
782 ''' Journal the Action
783 'action' may be:
785 'create' or 'set' -- 'params' is a dictionary of property values
786 'link' or 'unlink' -- 'params' is (classname, nodeid, propname)
787 'retire' -- 'params' is None
788 '''
789 # serialise the parameters now if necessary
790 if isinstance(params, type({})):
791 if action in ('set', 'create'):
792 params = self.serialise(classname, params)
794 # handle supply of the special journalling parameters (usually
795 # supplied on importing an existing database)
796 if creator:
797 journaltag = creator
798 else:
799 journaltag = self.curuserid
800 if creation:
801 journaldate = creation.serialise()
802 else:
803 journaldate = date.Date().serialise()
805 # create the journal entry
806 cols = ','.join('nodeid date tag action params'.split())
808 if __debug__:
809 print >>hyperdb.DEBUG, 'addjournal', (nodeid, journaldate,
810 journaltag, action, params)
812 self.save_journal(classname, cols, nodeid, journaldate,
813 journaltag, action, params)
815 def save_journal(self, classname, cols, nodeid, journaldate,
816 journaltag, action, params):
817 ''' Save the journal entry to the database
818 '''
819 raise NotImplemented
821 def getjournal(self, classname, nodeid):
822 ''' get the journal for id
823 '''
824 # make sure the node exists
825 if not self.hasnode(classname, nodeid):
826 raise IndexError, '%s has no node %s'%(classname, nodeid)
828 cols = ','.join('nodeid date tag action params'.split())
829 return self.load_journal(classname, cols, nodeid)
831 def load_journal(self, classname, cols, nodeid):
832 ''' Load the journal from the database
833 '''
834 raise NotImplemented
836 def pack(self, pack_before):
837 ''' Delete all journal entries except "create" before 'pack_before'.
838 '''
839 # get a 'yyyymmddhhmmss' version of the date
840 date_stamp = pack_before.serialise()
842 # do the delete
843 for classname in self.classes.keys():
844 sql = "delete from %s__journal where date<%s and "\
845 "action<>'create'"%(classname, self.arg)
846 if __debug__:
847 print >>hyperdb.DEBUG, 'pack', (self, sql, date_stamp)
848 self.cursor.execute(sql, (date_stamp,))
850 def sql_commit(self):
851 ''' Actually commit to the database.
852 '''
853 self.conn.commit()
855 def commit(self):
856 ''' Commit the current transactions.
858 Save all data changed since the database was opened or since the
859 last commit() or rollback().
860 '''
861 if __debug__:
862 print >>hyperdb.DEBUG, 'commit', (self,)
864 # commit the database
865 self.sql_commit()
867 # now, do all the other transaction stuff
868 reindex = {}
869 for method, args in self.transactions:
870 reindex[method(*args)] = 1
872 # reindex the nodes that request it
873 for classname, nodeid in filter(None, reindex.keys()):
874 print >>hyperdb.DEBUG, 'commit.reindex', (classname, nodeid)
875 self.getclass(classname).index(nodeid)
877 # save the indexer state
878 self.indexer.save_index()
880 # clear out the transactions
881 self.transactions = []
883 def rollback(self):
884 ''' Reverse all actions from the current transaction.
886 Undo all the changes made since the database was opened or the last
887 commit() or rollback() was performed.
888 '''
889 if __debug__:
890 print >>hyperdb.DEBUG, 'rollback', (self,)
892 # roll back
893 self.conn.rollback()
895 # roll back "other" transaction stuff
896 for method, args in self.transactions:
897 # delete temporary files
898 if method == self.doStoreFile:
899 self.rollbackStoreFile(*args)
900 self.transactions = []
902 def doSaveNode(self, classname, nodeid, node):
903 ''' dummy that just generates a reindex event
904 '''
905 # return the classname, nodeid so we reindex this content
906 return (classname, nodeid)
908 def close(self):
909 ''' Close off the connection.
910 '''
911 self.conn.close()
912 if self.lockfile is not None:
913 locking.release_lock(self.lockfile)
914 if self.lockfile is not None:
915 self.lockfile.close()
916 self.lockfile = None
918 #
919 # The base Class class
920 #
921 class Class(hyperdb.Class):
922 ''' The handle to a particular class of nodes in a hyperdatabase.
924 All methods except __repr__ and getnode must be implemented by a
925 concrete backend Class.
926 '''
928 def __init__(self, db, classname, **properties):
929 '''Create a new class with a given name and property specification.
931 'classname' must not collide with the name of an existing class,
932 or a ValueError is raised. The keyword arguments in 'properties'
933 must map names to property objects, or a TypeError is raised.
934 '''
935 if (properties.has_key('creation') or properties.has_key('activity')
936 or properties.has_key('creator')):
937 raise ValueError, '"creation", "activity" and "creator" are '\
938 'reserved'
940 self.classname = classname
941 self.properties = properties
942 self.db = weakref.proxy(db) # use a weak ref to avoid circularity
943 self.key = ''
945 # should we journal changes (default yes)
946 self.do_journal = 1
948 # do the db-related init stuff
949 db.addclass(self)
951 self.auditors = {'create': [], 'set': [], 'retire': []}
952 self.reactors = {'create': [], 'set': [], 'retire': []}
954 def schema(self):
955 ''' A dumpable version of the schema that we can store in the
956 database
957 '''
958 return (self.key, [(x, repr(y)) for x,y in self.properties.items()])
960 def enableJournalling(self):
961 '''Turn journalling on for this class
962 '''
963 self.do_journal = 1
965 def disableJournalling(self):
966 '''Turn journalling off for this class
967 '''
968 self.do_journal = 0
970 # Editing nodes:
971 def create(self, **propvalues):
972 ''' Create a new node of this class and return its id.
974 The keyword arguments in 'propvalues' map property names to values.
976 The values of arguments must be acceptable for the types of their
977 corresponding properties or a TypeError is raised.
979 If this class has a key property, it must be present and its value
980 must not collide with other key strings or a ValueError is raised.
982 Any other properties on this class that are missing from the
983 'propvalues' dictionary are set to None.
985 If an id in a link or multilink property does not refer to a valid
986 node, an IndexError is raised.
987 '''
988 if propvalues.has_key('id'):
989 raise KeyError, '"id" is reserved'
991 if self.db.journaltag is None:
992 raise DatabaseError, 'Database open read-only'
994 if propvalues.has_key('creation') or propvalues.has_key('activity'):
995 raise KeyError, '"creation" and "activity" are reserved'
997 self.fireAuditors('create', None, propvalues)
999 # new node's id
1000 newid = self.db.newid(self.classname)
1002 # validate propvalues
1003 num_re = re.compile('^\d+$')
1004 for key, value in propvalues.items():
1005 if key == self.key:
1006 try:
1007 self.lookup(value)
1008 except KeyError:
1009 pass
1010 else:
1011 raise ValueError, 'node with key "%s" exists'%value
1013 # try to handle this property
1014 try:
1015 prop = self.properties[key]
1016 except KeyError:
1017 raise KeyError, '"%s" has no property "%s"'%(self.classname,
1018 key)
1020 if value is not None and isinstance(prop, Link):
1021 if type(value) != type(''):
1022 raise ValueError, 'link value must be String'
1023 link_class = self.properties[key].classname
1024 # if it isn't a number, it's a key
1025 if not num_re.match(value):
1026 try:
1027 value = self.db.classes[link_class].lookup(value)
1028 except (TypeError, KeyError):
1029 raise IndexError, 'new property "%s": %s not a %s'%(
1030 key, value, link_class)
1031 elif not self.db.getclass(link_class).hasnode(value):
1032 raise IndexError, '%s has no node %s'%(link_class, value)
1034 # save off the value
1035 propvalues[key] = value
1037 # register the link with the newly linked node
1038 if self.do_journal and self.properties[key].do_journal:
1039 self.db.addjournal(link_class, value, 'link',
1040 (self.classname, newid, key))
1042 elif isinstance(prop, Multilink):
1043 if type(value) != type([]):
1044 raise TypeError, 'new property "%s" not a list of ids'%key
1046 # clean up and validate the list of links
1047 link_class = self.properties[key].classname
1048 l = []
1049 for entry in value:
1050 if type(entry) != type(''):
1051 raise ValueError, '"%s" multilink value (%r) '\
1052 'must contain Strings'%(key, value)
1053 # if it isn't a number, it's a key
1054 if not num_re.match(entry):
1055 try:
1056 entry = self.db.classes[link_class].lookup(entry)
1057 except (TypeError, KeyError):
1058 raise IndexError, 'new property "%s": %s not a %s'%(
1059 key, entry, self.properties[key].classname)
1060 l.append(entry)
1061 value = l
1062 propvalues[key] = value
1064 # handle additions
1065 for nodeid in value:
1066 if not self.db.getclass(link_class).hasnode(nodeid):
1067 raise IndexError, '%s has no node %s'%(link_class,
1068 nodeid)
1069 # register the link with the newly linked node
1070 if self.do_journal and self.properties[key].do_journal:
1071 self.db.addjournal(link_class, nodeid, 'link',
1072 (self.classname, newid, key))
1074 elif isinstance(prop, String):
1075 if type(value) != type('') and type(value) != type(u''):
1076 raise TypeError, 'new property "%s" not a string'%key
1078 elif isinstance(prop, Password):
1079 if not isinstance(value, password.Password):
1080 raise TypeError, 'new property "%s" not a Password'%key
1082 elif isinstance(prop, Date):
1083 if value is not None and not isinstance(value, date.Date):
1084 raise TypeError, 'new property "%s" not a Date'%key
1086 elif isinstance(prop, Interval):
1087 if value is not None and not isinstance(value, date.Interval):
1088 raise TypeError, 'new property "%s" not an Interval'%key
1090 elif value is not None and isinstance(prop, Number):
1091 try:
1092 float(value)
1093 except ValueError:
1094 raise TypeError, 'new property "%s" not numeric'%key
1096 elif value is not None and isinstance(prop, Boolean):
1097 try:
1098 int(value)
1099 except ValueError:
1100 raise TypeError, 'new property "%s" not boolean'%key
1102 # make sure there's data where there needs to be
1103 for key, prop in self.properties.items():
1104 if propvalues.has_key(key):
1105 continue
1106 if key == self.key:
1107 raise ValueError, 'key property "%s" is required'%key
1108 if isinstance(prop, Multilink):
1109 propvalues[key] = []
1110 else:
1111 propvalues[key] = None
1113 # done
1114 self.db.addnode(self.classname, newid, propvalues)
1115 if self.do_journal:
1116 self.db.addjournal(self.classname, newid, 'create', {})
1118 self.fireReactors('create', newid, None)
1120 return newid
1122 def export_list(self, propnames, nodeid):
1123 ''' Export a node - generate a list of CSV-able data in the order
1124 specified by propnames for the given node.
1125 '''
1126 properties = self.getprops()
1127 l = []
1128 for prop in propnames:
1129 proptype = properties[prop]
1130 value = self.get(nodeid, prop)
1131 # "marshal" data where needed
1132 if value is None:
1133 pass
1134 elif isinstance(proptype, hyperdb.Date):
1135 value = value.get_tuple()
1136 elif isinstance(proptype, hyperdb.Interval):
1137 value = value.get_tuple()
1138 elif isinstance(proptype, hyperdb.Password):
1139 value = str(value)
1140 l.append(repr(value))
1141 return l
1143 def import_list(self, propnames, proplist):
1144 ''' Import a node - all information including "id" is present and
1145 should not be sanity checked. Triggers are not triggered. The
1146 journal should be initialised using the "creator" and "created"
1147 information.
1149 Return the nodeid of the node imported.
1150 '''
1151 if self.db.journaltag is None:
1152 raise DatabaseError, 'Database open read-only'
1153 properties = self.getprops()
1155 # make the new node's property map
1156 d = {}
1157 for i in range(len(propnames)):
1158 # Use eval to reverse the repr() used to output the CSV
1159 value = eval(proplist[i])
1161 # Figure the property for this column
1162 propname = propnames[i]
1163 prop = properties[propname]
1165 # "unmarshal" where necessary
1166 if propname == 'id':
1167 newid = value
1168 continue
1169 elif value is None:
1170 # don't set Nones
1171 continue
1172 elif isinstance(prop, hyperdb.Date):
1173 value = date.Date(value)
1174 elif isinstance(prop, hyperdb.Interval):
1175 value = date.Interval(value)
1176 elif isinstance(prop, hyperdb.Password):
1177 pwd = password.Password()
1178 pwd.unpack(value)
1179 value = pwd
1180 d[propname] = value
1182 # add the node and journal
1183 self.db.addnode(self.classname, newid, d)
1185 # extract the extraneous journalling gumpf and nuke it
1186 if d.has_key('creator'):
1187 creator = d['creator']
1188 del d['creator']
1189 else:
1190 creator = None
1191 if d.has_key('creation'):
1192 creation = d['creation']
1193 del d['creation']
1194 else:
1195 creation = None
1196 if d.has_key('activity'):
1197 del d['activity']
1198 self.db.addjournal(self.classname, newid, 'create', {}, creator,
1199 creation)
1200 return newid
1202 _marker = []
1203 def get(self, nodeid, propname, default=_marker, cache=1):
1204 '''Get the value of a property on an existing node of this class.
1206 'nodeid' must be the id of an existing node of this class or an
1207 IndexError is raised. 'propname' must be the name of a property
1208 of this class or a KeyError is raised.
1210 'cache' indicates whether the transaction cache should be queried
1211 for the node. If the node has been modified and you need to
1212 determine what its values prior to modification are, you need to
1213 set cache=0.
1214 '''
1215 if propname == 'id':
1216 return nodeid
1218 # get the node's dict
1219 d = self.db.getnode(self.classname, nodeid)
1221 if propname == 'creation':
1222 if d.has_key('creation'):
1223 return d['creation']
1224 else:
1225 return date.Date()
1226 if propname == 'activity':
1227 if d.has_key('activity'):
1228 return d['activity']
1229 else:
1230 return date.Date()
1231 if propname == 'creator':
1232 if d.has_key('creator'):
1233 return d['creator']
1234 else:
1235 return self.db.curuserid
1237 # get the property (raises KeyErorr if invalid)
1238 prop = self.properties[propname]
1240 if not d.has_key(propname):
1241 if default is self._marker:
1242 if isinstance(prop, Multilink):
1243 return []
1244 else:
1245 return None
1246 else:
1247 return default
1249 # don't pass our list to other code
1250 if isinstance(prop, Multilink):
1251 return d[propname][:]
1253 return d[propname]
1255 def getnode(self, nodeid, cache=1):
1256 ''' Return a convenience wrapper for the node.
1258 'nodeid' must be the id of an existing node of this class or an
1259 IndexError is raised.
1261 'cache' indicates whether the transaction cache should be queried
1262 for the node. If the node has been modified and you need to
1263 determine what its values prior to modification are, you need to
1264 set cache=0.
1265 '''
1266 return Node(self, nodeid, cache=cache)
1268 def set(self, nodeid, **propvalues):
1269 '''Modify a property on an existing node of this class.
1271 'nodeid' must be the id of an existing node of this class or an
1272 IndexError is raised.
1274 Each key in 'propvalues' must be the name of a property of this
1275 class or a KeyError is raised.
1277 All values in 'propvalues' must be acceptable types for their
1278 corresponding properties or a TypeError is raised.
1280 If the value of the key property is set, it must not collide with
1281 other key strings or a ValueError is raised.
1283 If the value of a Link or Multilink property contains an invalid
1284 node id, a ValueError is raised.
1285 '''
1286 if not propvalues:
1287 return propvalues
1289 if propvalues.has_key('creation') or propvalues.has_key('activity'):
1290 raise KeyError, '"creation" and "activity" are reserved'
1292 if propvalues.has_key('id'):
1293 raise KeyError, '"id" is reserved'
1295 if self.db.journaltag is None:
1296 raise DatabaseError, 'Database open read-only'
1298 self.fireAuditors('set', nodeid, propvalues)
1299 # Take a copy of the node dict so that the subsequent set
1300 # operation doesn't modify the oldvalues structure.
1301 # XXX used to try the cache here first
1302 oldvalues = copy.deepcopy(self.db.getnode(self.classname, nodeid))
1304 node = self.db.getnode(self.classname, nodeid)
1305 if self.is_retired(nodeid):
1306 raise IndexError, 'Requested item is retired'
1307 num_re = re.compile('^\d+$')
1309 # if the journal value is to be different, store it in here
1310 journalvalues = {}
1312 # remember the add/remove stuff for multilinks, making it easier
1313 # for the Database layer to do its stuff
1314 multilink_changes = {}
1316 for propname, value in propvalues.items():
1317 # check to make sure we're not duplicating an existing key
1318 if propname == self.key and node[propname] != value:
1319 try:
1320 self.lookup(value)
1321 except KeyError:
1322 pass
1323 else:
1324 raise ValueError, 'node with key "%s" exists'%value
1326 # this will raise the KeyError if the property isn't valid
1327 # ... we don't use getprops() here because we only care about
1328 # the writeable properties.
1329 try:
1330 prop = self.properties[propname]
1331 except KeyError:
1332 raise KeyError, '"%s" has no property named "%s"'%(
1333 self.classname, propname)
1335 # if the value's the same as the existing value, no sense in
1336 # doing anything
1337 current = node.get(propname, None)
1338 if value == current:
1339 del propvalues[propname]
1340 continue
1341 journalvalues[propname] = current
1343 # do stuff based on the prop type
1344 if isinstance(prop, Link):
1345 link_class = prop.classname
1346 # if it isn't a number, it's a key
1347 if value is not None and not isinstance(value, type('')):
1348 raise ValueError, 'property "%s" link value be a string'%(
1349 propname)
1350 if isinstance(value, type('')) and not num_re.match(value):
1351 try:
1352 value = self.db.classes[link_class].lookup(value)
1353 except (TypeError, KeyError):
1354 raise IndexError, 'new property "%s": %s not a %s'%(
1355 propname, value, prop.classname)
1357 if (value is not None and
1358 not self.db.getclass(link_class).hasnode(value)):
1359 raise IndexError, '%s has no node %s'%(link_class, value)
1361 if self.do_journal and prop.do_journal:
1362 # register the unlink with the old linked node
1363 if node[propname] is not None:
1364 self.db.addjournal(link_class, node[propname], 'unlink',
1365 (self.classname, nodeid, propname))
1367 # register the link with the newly linked node
1368 if value is not None:
1369 self.db.addjournal(link_class, value, 'link',
1370 (self.classname, nodeid, propname))
1372 elif isinstance(prop, Multilink):
1373 if type(value) != type([]):
1374 raise TypeError, 'new property "%s" not a list of'\
1375 ' ids'%propname
1376 link_class = self.properties[propname].classname
1377 l = []
1378 for entry in value:
1379 # if it isn't a number, it's a key
1380 if type(entry) != type(''):
1381 raise ValueError, 'new property "%s" link value ' \
1382 'must be a string'%propname
1383 if not num_re.match(entry):
1384 try:
1385 entry = self.db.classes[link_class].lookup(entry)
1386 except (TypeError, KeyError):
1387 raise IndexError, 'new property "%s": %s not a %s'%(
1388 propname, entry,
1389 self.properties[propname].classname)
1390 l.append(entry)
1391 value = l
1392 propvalues[propname] = value
1394 # figure the journal entry for this property
1395 add = []
1396 remove = []
1398 # handle removals
1399 if node.has_key(propname):
1400 l = node[propname]
1401 else:
1402 l = []
1403 for id in l[:]:
1404 if id in value:
1405 continue
1406 # register the unlink with the old linked node
1407 if self.do_journal and self.properties[propname].do_journal:
1408 self.db.addjournal(link_class, id, 'unlink',
1409 (self.classname, nodeid, propname))
1410 l.remove(id)
1411 remove.append(id)
1413 # handle additions
1414 for id in value:
1415 if not self.db.getclass(link_class).hasnode(id):
1416 raise IndexError, '%s has no node %s'%(link_class, id)
1417 if id in l:
1418 continue
1419 # register the link with the newly linked node
1420 if self.do_journal and self.properties[propname].do_journal:
1421 self.db.addjournal(link_class, id, 'link',
1422 (self.classname, nodeid, propname))
1423 l.append(id)
1424 add.append(id)
1426 # figure the journal entry
1427 l = []
1428 if add:
1429 l.append(('+', add))
1430 if remove:
1431 l.append(('-', remove))
1432 multilink_changes[propname] = (add, remove)
1433 if l:
1434 journalvalues[propname] = tuple(l)
1436 elif isinstance(prop, String):
1437 if value is not None and type(value) != type('') and type(value) != type(u''):
1438 raise TypeError, 'new property "%s" not a string'%propname
1440 elif isinstance(prop, Password):
1441 if not isinstance(value, password.Password):
1442 raise TypeError, 'new property "%s" not a Password'%propname
1443 propvalues[propname] = value
1445 elif value is not None and isinstance(prop, Date):
1446 if not isinstance(value, date.Date):
1447 raise TypeError, 'new property "%s" not a Date'% propname
1448 propvalues[propname] = value
1450 elif value is not None and isinstance(prop, Interval):
1451 if not isinstance(value, date.Interval):
1452 raise TypeError, 'new property "%s" not an '\
1453 'Interval'%propname
1454 propvalues[propname] = value
1456 elif value is not None and isinstance(prop, Number):
1457 try:
1458 float(value)
1459 except ValueError:
1460 raise TypeError, 'new property "%s" not numeric'%propname
1462 elif value is not None and isinstance(prop, Boolean):
1463 try:
1464 int(value)
1465 except ValueError:
1466 raise TypeError, 'new property "%s" not boolean'%propname
1468 # nothing to do?
1469 if not propvalues:
1470 return propvalues
1472 # do the set, and journal it
1473 self.db.setnode(self.classname, nodeid, propvalues, multilink_changes)
1475 if self.do_journal:
1476 self.db.addjournal(self.classname, nodeid, 'set', journalvalues)
1478 self.fireReactors('set', nodeid, oldvalues)
1480 return propvalues
1482 def retire(self, nodeid):
1483 '''Retire a node.
1485 The properties on the node remain available from the get() method,
1486 and the node's id is never reused.
1488 Retired nodes are not returned by the find(), list(), or lookup()
1489 methods, and other nodes may reuse the values of their key properties.
1490 '''
1491 if self.db.journaltag is None:
1492 raise DatabaseError, 'Database open read-only'
1494 self.fireAuditors('retire', nodeid, None)
1496 # use the arg for __retired__ to cope with any odd database type
1497 # conversion (hello, sqlite)
1498 sql = 'update _%s set __retired__=%s where id=%s'%(self.classname,
1499 self.db.arg, self.db.arg)
1500 if __debug__:
1501 print >>hyperdb.DEBUG, 'retire', (self, sql, nodeid)
1502 self.db.cursor.execute(sql, (1, nodeid))
1504 self.fireReactors('retire', nodeid, None)
1506 def is_retired(self, nodeid):
1507 '''Return true if the node is rerired
1508 '''
1509 sql = 'select __retired__ from _%s where id=%s'%(self.classname,
1510 self.db.arg)
1511 if __debug__:
1512 print >>hyperdb.DEBUG, 'is_retired', (self, sql, nodeid)
1513 self.db.cursor.execute(sql, (nodeid,))
1514 return int(self.db.sql_fetchone()[0])
1516 def destroy(self, nodeid):
1517 '''Destroy a node.
1519 WARNING: this method should never be used except in extremely rare
1520 situations where there could never be links to the node being
1521 deleted
1522 WARNING: use retire() instead
1523 WARNING: the properties of this node will not be available ever again
1524 WARNING: really, use retire() instead
1526 Well, I think that's enough warnings. This method exists mostly to
1527 support the session storage of the cgi interface.
1529 The node is completely removed from the hyperdb, including all journal
1530 entries. It will no longer be available, and will generally break code
1531 if there are any references to the node.
1532 '''
1533 if self.db.journaltag is None:
1534 raise DatabaseError, 'Database open read-only'
1535 self.db.destroynode(self.classname, nodeid)
1537 def history(self, nodeid):
1538 '''Retrieve the journal of edits on a particular node.
1540 'nodeid' must be the id of an existing node of this class or an
1541 IndexError is raised.
1543 The returned list contains tuples of the form
1545 (nodeid, date, tag, action, params)
1547 'date' is a Timestamp object specifying the time of the change and
1548 'tag' is the journaltag specified when the database was opened.
1549 '''
1550 if not self.do_journal:
1551 raise ValueError, 'Journalling is disabled for this class'
1552 return self.db.getjournal(self.classname, nodeid)
1554 # Locating nodes:
1555 def hasnode(self, nodeid):
1556 '''Determine if the given nodeid actually exists
1557 '''
1558 return self.db.hasnode(self.classname, nodeid)
1560 def setkey(self, propname):
1561 '''Select a String property of this class to be the key property.
1563 'propname' must be the name of a String property of this class or
1564 None, or a TypeError is raised. The values of the key property on
1565 all existing nodes must be unique or a ValueError is raised.
1566 '''
1567 # XXX create an index on the key prop column
1568 prop = self.getprops()[propname]
1569 if not isinstance(prop, String):
1570 raise TypeError, 'key properties must be String'
1571 self.key = propname
1573 def getkey(self):
1574 '''Return the name of the key property for this class or None.'''
1575 return self.key
1577 def labelprop(self, default_to_id=0):
1578 ''' Return the property name for a label for the given node.
1580 This method attempts to generate a consistent label for the node.
1581 It tries the following in order:
1582 1. key property
1583 2. "name" property
1584 3. "title" property
1585 4. first property from the sorted property name list
1586 '''
1587 k = self.getkey()
1588 if k:
1589 return k
1590 props = self.getprops()
1591 if props.has_key('name'):
1592 return 'name'
1593 elif props.has_key('title'):
1594 return 'title'
1595 if default_to_id:
1596 return 'id'
1597 props = props.keys()
1598 props.sort()
1599 return props[0]
1601 def lookup(self, keyvalue):
1602 '''Locate a particular node by its key property and return its id.
1604 If this class has no key property, a TypeError is raised. If the
1605 'keyvalue' matches one of the values for the key property among
1606 the nodes in this class, the matching node's id is returned;
1607 otherwise a KeyError is raised.
1608 '''
1609 if not self.key:
1610 raise TypeError, 'No key property set for class %s'%self.classname
1612 # use the arg to handle any odd database type conversion (hello,
1613 # sqlite)
1614 sql = "select id from _%s where _%s=%s and __retired__ <> %s"%(
1615 self.classname, self.key, self.db.arg, self.db.arg)
1616 self.db.sql(sql, (keyvalue, 1))
1618 # see if there was a result that's not retired
1619 row = self.db.sql_fetchone()
1620 if not row:
1621 raise KeyError, 'No key (%s) value "%s" for "%s"'%(self.key,
1622 keyvalue, self.classname)
1624 # return the id
1625 return row[0]
1627 def find(self, **propspec):
1628 '''Get the ids of nodes in this class which link to the given nodes.
1630 'propspec' consists of keyword args propname=nodeid or
1631 propname={nodeid:1, }
1632 'propname' must be the name of a property in this class, or a
1633 KeyError is raised. That property must be a Link or Multilink
1634 property, or a TypeError is raised.
1636 Any node in this class whose 'propname' property links to any of the
1637 nodeids will be returned. Used by the full text indexing, which knows
1638 that "foo" occurs in msg1, msg3 and file7, so we have hits on these
1639 issues:
1641 db.issue.find(messages={'1':1,'3':1}, files={'7':1})
1642 '''
1643 if __debug__:
1644 print >>hyperdb.DEBUG, 'find', (self, propspec)
1646 # shortcut
1647 if not propspec:
1648 return []
1650 # validate the args
1651 props = self.getprops()
1652 propspec = propspec.items()
1653 for propname, nodeids in propspec:
1654 # check the prop is OK
1655 prop = props[propname]
1656 if not isinstance(prop, Link) and not isinstance(prop, Multilink):
1657 raise TypeError, "'%s' not a Link/Multilink property"%propname
1659 # first, links
1660 where = []
1661 allvalues = ()
1662 a = self.db.arg
1663 for prop, values in propspec:
1664 if not isinstance(props[prop], hyperdb.Link):
1665 continue
1666 if type(values) is type(''):
1667 allvalues += (values,)
1668 where.append('_%s = %s'%(prop, a))
1669 else:
1670 allvalues += tuple(values.keys())
1671 where.append('_%s in (%s)'%(prop, ','.join([a]*len(values))))
1672 tables = []
1673 if where:
1674 tables.append('select id as nodeid from _%s where %s'%(
1675 self.classname, ' and '.join(where)))
1677 # now multilinks
1678 for prop, values in propspec:
1679 if not isinstance(props[prop], hyperdb.Multilink):
1680 continue
1681 if type(values) is type(''):
1682 allvalues += (values,)
1683 s = a
1684 else:
1685 allvalues += tuple(values.keys())
1686 s = ','.join([a]*len(values))
1687 tables.append('select nodeid from %s_%s where linkid in (%s)'%(
1688 self.classname, prop, s))
1689 sql = '\nunion\n'.join(tables)
1690 self.db.sql(sql, allvalues)
1691 l = [x[0] for x in self.db.sql_fetchall()]
1692 if __debug__:
1693 print >>hyperdb.DEBUG, 'find ... ', l
1694 return l
1696 def stringFind(self, **requirements):
1697 '''Locate a particular node by matching a set of its String
1698 properties in a caseless search.
1700 If the property is not a String property, a TypeError is raised.
1702 The return is a list of the id of all nodes that match.
1703 '''
1704 where = []
1705 args = []
1706 for propname in requirements.keys():
1707 prop = self.properties[propname]
1708 if isinstance(not prop, String):
1709 raise TypeError, "'%s' not a String property"%propname
1710 where.append(propname)
1711 args.append(requirements[propname].lower())
1713 # generate the where clause
1714 s = ' and '.join(['_%s=%s'%(col, self.db.arg) for col in where])
1715 sql = 'select id from _%s where %s'%(self.classname, s)
1716 self.db.sql(sql, tuple(args))
1717 l = [x[0] for x in self.db.sql_fetchall()]
1718 if __debug__:
1719 print >>hyperdb.DEBUG, 'find ... ', l
1720 return l
1722 def list(self):
1723 ''' Return a list of the ids of the active nodes in this class.
1724 '''
1725 return self.db.getnodeids(self.classname, retired=0)
1727 def filter(self, search_matches, filterspec, sort=(None,None),
1728 group=(None,None)):
1729 ''' Return a list of the ids of the active nodes in this class that
1730 match the 'filter' spec, sorted by the group spec and then the
1731 sort spec
1733 "filterspec" is {propname: value(s)}
1734 "sort" and "group" are (dir, prop) where dir is '+', '-' or None
1735 and prop is a prop name or None
1736 "search_matches" is {nodeid: marker}
1738 The filter must match all properties specificed - but if the
1739 property value to match is a list, any one of the values in the
1740 list may match for that property to match.
1741 '''
1742 # just don't bother if the full-text search matched diddly
1743 if search_matches == {}:
1744 return []
1746 cn = self.classname
1748 # figure the WHERE clause from the filterspec
1749 props = self.getprops()
1750 frum = ['_'+cn]
1751 where = []
1752 args = []
1753 a = self.db.arg
1754 for k, v in filterspec.items():
1755 propclass = props[k]
1756 # now do other where clause stuff
1757 if isinstance(propclass, Multilink):
1758 tn = '%s_%s'%(cn, k)
1759 frum.append(tn)
1760 if isinstance(v, type([])):
1761 s = ','.join([a for x in v])
1762 where.append('id=%s.nodeid and %s.linkid in (%s)'%(tn,tn,s))
1763 args = args + v
1764 else:
1765 where.append('id=%s.nodeid and %s.linkid = %s'%(tn, tn, a))
1766 args.append(v)
1767 elif k == 'id':
1768 if isinstance(v, type([])):
1769 s = ','.join([a for x in v])
1770 where.append('%s in (%s)'%(k, s))
1771 args = args + v
1772 else:
1773 where.append('%s=%s'%(k, a))
1774 args.append(v)
1775 elif isinstance(propclass, String):
1776 if not isinstance(v, type([])):
1777 v = [v]
1779 # Quote the bits in the string that need it and then embed
1780 # in a "substring" search. Note - need to quote the '%' so
1781 # they make it through the python layer happily
1782 v = ['%%'+self.db.sql_stringquote(s)+'%%' for s in v]
1784 # now add to the where clause
1785 where.append(' or '.join(["_%s LIKE '%s'"%(k, s) for s in v]))
1786 # note: args are embedded in the query string now
1787 elif isinstance(propclass, Link):
1788 if isinstance(v, type([])):
1789 if '-1' in v:
1790 v.remove('-1')
1791 xtra = ' or _%s is NULL'%k
1792 else:
1793 xtra = ''
1794 if v:
1795 s = ','.join([a for x in v])
1796 where.append('(_%s in (%s)%s)'%(k, s, xtra))
1797 args = args + v
1798 else:
1799 where.append('_%s is NULL'%k)
1800 else:
1801 if v == '-1':
1802 v = None
1803 where.append('_%s is NULL'%k)
1804 else:
1805 where.append('_%s=%s'%(k, a))
1806 args.append(v)
1807 elif isinstance(propclass, Date):
1808 if isinstance(v, type([])):
1809 s = ','.join([a for x in v])
1810 where.append('_%s in (%s)'%(k, s))
1811 args = args + [date.Date(x).serialise() for x in v]
1812 else:
1813 where.append('_%s=%s'%(k, a))
1814 args.append(date.Date(v).serialise())
1815 elif isinstance(propclass, Interval):
1816 if isinstance(v, type([])):
1817 s = ','.join([a for x in v])
1818 where.append('_%s in (%s)'%(k, s))
1819 args = args + [date.Interval(x).serialise() for x in v]
1820 else:
1821 where.append('_%s=%s'%(k, a))
1822 args.append(date.Interval(v).serialise())
1823 else:
1824 if isinstance(v, type([])):
1825 s = ','.join([a for x in v])
1826 where.append('_%s in (%s)'%(k, s))
1827 args = args + v
1828 else:
1829 where.append('_%s=%s'%(k, a))
1830 args.append(v)
1832 # add results of full text search
1833 if search_matches is not None:
1834 v = search_matches.keys()
1835 s = ','.join([a for x in v])
1836 where.append('id in (%s)'%s)
1837 args = args + v
1839 # "grouping" is just the first-order sorting in the SQL fetch
1840 # can modify it...)
1841 orderby = []
1842 ordercols = []
1843 if group[0] is not None and group[1] is not None:
1844 if group[0] != '-':
1845 orderby.append('_'+group[1])
1846 ordercols.append('_'+group[1])
1847 else:
1848 orderby.append('_'+group[1]+' desc')
1849 ordercols.append('_'+group[1])
1851 # now add in the sorting
1852 group = ''
1853 if sort[0] is not None and sort[1] is not None:
1854 direction, colname = sort
1855 if direction != '-':
1856 if colname == 'id':
1857 orderby.append(colname)
1858 else:
1859 orderby.append('_'+colname)
1860 ordercols.append('_'+colname)
1861 else:
1862 if colname == 'id':
1863 orderby.append(colname+' desc')
1864 ordercols.append(colname)
1865 else:
1866 orderby.append('_'+colname+' desc')
1867 ordercols.append('_'+colname)
1869 # construct the SQL
1870 frum = ','.join(frum)
1871 if where:
1872 where = ' where ' + (' and '.join(where))
1873 else:
1874 where = ''
1875 cols = ['id']
1876 if orderby:
1877 cols = cols + ordercols
1878 order = ' order by %s'%(','.join(orderby))
1879 else:
1880 order = ''
1881 cols = ','.join(cols)
1882 sql = 'select %s from %s %s%s%s'%(cols, frum, where, group, order)
1883 args = tuple(args)
1884 if __debug__:
1885 print >>hyperdb.DEBUG, 'filter', (self, sql, args)
1886 self.db.cursor.execute(sql, args)
1887 l = self.db.cursor.fetchall()
1889 # return the IDs (the first column)
1890 # XXX The filter(None, l) bit is sqlite-specific... if there's _NO_
1891 # XXX matches to a fetch, it returns NULL instead of nothing!?!
1892 return filter(None, [row[0] for row in l])
1894 def count(self):
1895 '''Get the number of nodes in this class.
1897 If the returned integer is 'numnodes', the ids of all the nodes
1898 in this class run from 1 to numnodes, and numnodes+1 will be the
1899 id of the next node to be created in this class.
1900 '''
1901 return self.db.countnodes(self.classname)
1903 # Manipulating properties:
1904 def getprops(self, protected=1):
1905 '''Return a dictionary mapping property names to property objects.
1906 If the "protected" flag is true, we include protected properties -
1907 those which may not be modified.
1908 '''
1909 d = self.properties.copy()
1910 if protected:
1911 d['id'] = String()
1912 d['creation'] = hyperdb.Date()
1913 d['activity'] = hyperdb.Date()
1914 d['creator'] = hyperdb.Link('user')
1915 return d
1917 def addprop(self, **properties):
1918 '''Add properties to this class.
1920 The keyword arguments in 'properties' must map names to property
1921 objects, or a TypeError is raised. None of the keys in 'properties'
1922 may collide with the names of existing properties, or a ValueError
1923 is raised before any properties have been added.
1924 '''
1925 for key in properties.keys():
1926 if self.properties.has_key(key):
1927 raise ValueError, key
1928 self.properties.update(properties)
1930 def index(self, nodeid):
1931 '''Add (or refresh) the node to search indexes
1932 '''
1933 # find all the String properties that have indexme
1934 for prop, propclass in self.getprops().items():
1935 if isinstance(propclass, String) and propclass.indexme:
1936 try:
1937 value = str(self.get(nodeid, prop))
1938 except IndexError:
1939 # node no longer exists - entry should be removed
1940 self.db.indexer.purge_entry((self.classname, nodeid, prop))
1941 else:
1942 # and index them under (classname, nodeid, property)
1943 self.db.indexer.add_text((self.classname, nodeid, prop),
1944 value)
1947 #
1948 # Detector interface
1949 #
1950 def audit(self, event, detector):
1951 '''Register a detector
1952 '''
1953 l = self.auditors[event]
1954 if detector not in l:
1955 self.auditors[event].append(detector)
1957 def fireAuditors(self, action, nodeid, newvalues):
1958 '''Fire all registered auditors.
1959 '''
1960 for audit in self.auditors[action]:
1961 audit(self.db, self, nodeid, newvalues)
1963 def react(self, event, detector):
1964 '''Register a detector
1965 '''
1966 l = self.reactors[event]
1967 if detector not in l:
1968 self.reactors[event].append(detector)
1970 def fireReactors(self, action, nodeid, oldvalues):
1971 '''Fire all registered reactors.
1972 '''
1973 for react in self.reactors[action]:
1974 react(self.db, self, nodeid, oldvalues)
1976 class FileClass(Class):
1977 '''This class defines a large chunk of data. To support this, it has a
1978 mandatory String property "content" which is typically saved off
1979 externally to the hyperdb.
1981 The default MIME type of this data is defined by the
1982 "default_mime_type" class attribute, which may be overridden by each
1983 node if the class defines a "type" String property.
1984 '''
1985 default_mime_type = 'text/plain'
1987 def create(self, **propvalues):
1988 ''' snaffle the file propvalue and store in a file
1989 '''
1990 content = propvalues['content']
1991 del propvalues['content']
1992 newid = Class.create(self, **propvalues)
1993 self.db.storefile(self.classname, newid, None, content)
1994 return newid
1996 def import_list(self, propnames, proplist):
1997 ''' Trap the "content" property...
1998 '''
1999 # dupe this list so we don't affect others
2000 propnames = propnames[:]
2002 # extract the "content" property from the proplist
2003 i = propnames.index('content')
2004 content = eval(proplist[i])
2005 del propnames[i]
2006 del proplist[i]
2008 # do the normal import
2009 newid = Class.import_list(self, propnames, proplist)
2011 # save off the "content" file
2012 self.db.storefile(self.classname, newid, None, content)
2013 return newid
2015 _marker = []
2016 def get(self, nodeid, propname, default=_marker, cache=1):
2017 ''' trap the content propname and get it from the file
2018 '''
2020 poss_msg = 'Possibly a access right configuration problem.'
2021 if propname == 'content':
2022 try:
2023 return self.db.getfile(self.classname, nodeid, None)
2024 except IOError, (strerror):
2025 # BUG: by catching this we donot see an error in the log.
2026 return 'ERROR reading file: %s%s\n%s\n%s'%(
2027 self.classname, nodeid, poss_msg, strerror)
2028 if default is not self._marker:
2029 return Class.get(self, nodeid, propname, default, cache=cache)
2030 else:
2031 return Class.get(self, nodeid, propname, cache=cache)
2033 def getprops(self, protected=1):
2034 ''' In addition to the actual properties on the node, these methods
2035 provide the "content" property. If the "protected" flag is true,
2036 we include protected properties - those which may not be
2037 modified.
2038 '''
2039 d = Class.getprops(self, protected=protected).copy()
2040 d['content'] = hyperdb.String()
2041 return d
2043 def index(self, nodeid):
2044 ''' Index the node in the search index.
2046 We want to index the content in addition to the normal String
2047 property indexing.
2048 '''
2049 # perform normal indexing
2050 Class.index(self, nodeid)
2052 # get the content to index
2053 content = self.get(nodeid, 'content')
2055 # figure the mime type
2056 if self.properties.has_key('type'):
2057 mime_type = self.get(nodeid, 'type')
2058 else:
2059 mime_type = self.default_mime_type
2061 # and index!
2062 self.db.indexer.add_text((self.classname, nodeid, 'content'), content,
2063 mime_type)
2065 # XXX deviation from spec - was called ItemClass
2066 class IssueClass(Class, roundupdb.IssueClass):
2067 # Overridden methods:
2068 def __init__(self, db, classname, **properties):
2069 '''The newly-created class automatically includes the "messages",
2070 "files", "nosy", and "superseder" properties. If the 'properties'
2071 dictionary attempts to specify any of these properties or a
2072 "creation" or "activity" property, a ValueError is raised.
2073 '''
2074 if not properties.has_key('title'):
2075 properties['title'] = hyperdb.String(indexme='yes')
2076 if not properties.has_key('messages'):
2077 properties['messages'] = hyperdb.Multilink("msg")
2078 if not properties.has_key('files'):
2079 properties['files'] = hyperdb.Multilink("file")
2080 if not properties.has_key('nosy'):
2081 # note: journalling is turned off as it really just wastes
2082 # space. this behaviour may be overridden in an instance
2083 properties['nosy'] = hyperdb.Multilink("user", do_journal="no")
2084 if not properties.has_key('superseder'):
2085 properties['superseder'] = hyperdb.Multilink(classname)
2086 Class.__init__(self, db, classname, **properties)