1 # $Id: rdbms_common.py,v 1.37 2003-02-27 11:07:36 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, 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, 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
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.otks = OneTimeKeys(self.config)
57 self.security = security.Security(self)
59 # additional transaction support for external files and the like
60 self.transactions = []
62 # keep a cache of the N most recently retrieved rows of any kind
63 # (classname, nodeid) = row
64 self.cache = {}
65 self.cache_lru = []
67 # database lock
68 self.lockfile = None
70 # open a connection to the database, creating the "conn" attribute
71 self.open_connection()
73 def clearCache(self):
74 self.cache = {}
75 self.cache_lru = []
77 def open_connection(self):
78 ''' Open a connection to the database, creating it if necessary
79 '''
80 raise NotImplemented
82 def sql(self, sql, args=None):
83 ''' Execute the sql with the optional args.
84 '''
85 if __debug__:
86 print >>hyperdb.DEBUG, (self, sql, args)
87 if args:
88 self.cursor.execute(sql, args)
89 else:
90 self.cursor.execute(sql)
92 def sql_fetchone(self):
93 ''' Fetch a single row. If there's nothing to fetch, return None.
94 '''
95 raise NotImplemented
97 def sql_stringquote(self, value):
98 ''' Quote the string so it's safe to put in the 'sql quotes'
99 '''
100 return re.sub("'", "''", str(value))
102 def save_dbschema(self, schema):
103 ''' Save the schema definition that the database currently implements
104 '''
105 raise NotImplemented
107 def load_dbschema(self):
108 ''' Load the schema definition that the database currently implements
109 '''
110 raise NotImplemented
112 def post_init(self):
113 ''' Called once the schema initialisation has finished.
115 We should now confirm that the schema defined by our "classes"
116 attribute actually matches the schema in the database.
117 '''
118 # now detect changes in the schema
119 save = 0
120 for classname, spec in self.classes.items():
121 if self.database_schema.has_key(classname):
122 dbspec = self.database_schema[classname]
123 if self.update_class(spec, dbspec):
124 self.database_schema[classname] = spec.schema()
125 save = 1
126 else:
127 self.create_class(spec)
128 self.database_schema[classname] = spec.schema()
129 save = 1
131 for classname in self.database_schema.keys():
132 if not self.classes.has_key(classname):
133 self.drop_class(classname)
135 # update the database version of the schema
136 if save:
137 self.sql('delete from schema')
138 self.save_dbschema(self.database_schema)
140 # reindex the db if necessary
141 if self.indexer.should_reindex():
142 self.reindex()
144 # commit
145 self.conn.commit()
147 # figure the "curuserid"
148 if self.journaltag is None:
149 self.curuserid = None
150 elif self.journaltag == 'admin':
151 # admin user may not exist, but always has ID 1
152 self.curuserid = '1'
153 else:
154 self.curuserid = self.user.lookup(self.journaltag)
156 def reindex(self):
157 for klass in self.classes.values():
158 for nodeid in klass.list():
159 klass.index(nodeid)
160 self.indexer.save_index()
162 def determine_columns(self, properties):
163 ''' Figure the column names and multilink properties from the spec
165 "properties" is a list of (name, prop) where prop may be an
166 instance of a hyperdb "type" _or_ a string repr of that type.
167 '''
168 cols = ['_activity', '_creator', '_creation']
169 mls = []
170 # add the multilinks separately
171 for col, prop in properties:
172 if isinstance(prop, Multilink):
173 mls.append(col)
174 elif isinstance(prop, type('')) and prop.find('Multilink') != -1:
175 mls.append(col)
176 else:
177 cols.append('_'+col)
178 cols.sort()
179 return cols, mls
181 def update_class(self, spec, old_spec):
182 ''' Determine the differences between the current spec and the
183 database version of the spec, and update where necessary
184 '''
185 new_spec = spec
186 new_has = new_spec.properties.has_key
188 new_spec = new_spec.schema()
189 if new_spec == old_spec:
190 # no changes
191 return 0
193 if __debug__:
194 print >>hyperdb.DEBUG, 'update_class FIRING'
196 # key property changed?
197 if old_spec[0] != new_spec[0]:
198 if __debug__:
199 print >>hyperdb.DEBUG, 'update_class setting keyprop', `spec[0]`
200 # XXX turn on indexing for the key property
202 # detect multilinks that have been removed, and drop their table
203 old_has = {}
204 for name,prop in old_spec[1]:
205 old_has[name] = 1
206 if not new_has(name) and isinstance(prop, Multilink):
207 # it's a multilink, and it's been removed - drop the old
208 # table
209 sql = 'drop table %s_%s'%(spec.classname, prop)
210 if __debug__:
211 print >>hyperdb.DEBUG, 'update_class', (self, sql)
212 self.cursor.execute(sql)
213 continue
214 old_has = old_has.has_key
216 # now figure how we populate the new table
217 fetch = [] # fetch these from the old table
218 properties = spec.getprops()
219 for propname,x in new_spec[1]:
220 prop = properties[propname]
221 if isinstance(prop, Multilink):
222 if not old_has(propname):
223 # we need to create the new table
224 self.create_multilink_table(spec, propname)
225 elif old_has(propname):
226 # we copy this col over from the old table
227 fetch.append('_'+propname)
229 # select the data out of the old table
230 fetch.append('id')
231 fetch.append('__retired__')
232 fetchcols = ','.join(fetch)
233 cn = spec.classname
234 sql = 'select %s from _%s'%(fetchcols, cn)
235 if __debug__:
236 print >>hyperdb.DEBUG, 'update_class', (self, sql)
237 self.cursor.execute(sql)
238 olddata = self.cursor.fetchall()
240 # drop the old table
241 self.cursor.execute('drop table _%s'%cn)
243 # create the new table
244 self.create_class_table(spec)
246 if olddata:
247 # do the insert
248 args = ','.join([self.arg for x in fetch])
249 sql = 'insert into _%s (%s) values (%s)'%(cn, fetchcols, args)
250 if __debug__:
251 print >>hyperdb.DEBUG, 'update_class', (self, sql, olddata[0])
252 for entry in olddata:
253 self.cursor.execute(sql, *entry)
255 return 1
257 def create_class_table(self, spec):
258 ''' create the class table for the given spec
259 '''
260 cols, mls = self.determine_columns(spec.properties.items())
262 # add on our special columns
263 cols.append('id')
264 cols.append('__retired__')
266 # create the base table
267 scols = ','.join(['%s varchar'%x for x in cols])
268 sql = 'create table _%s (%s)'%(spec.classname, scols)
269 if __debug__:
270 print >>hyperdb.DEBUG, 'create_class', (self, sql)
271 self.cursor.execute(sql)
273 return cols, mls
275 def create_journal_table(self, spec):
276 ''' create the journal table for a class given the spec and
277 already-determined cols
278 '''
279 # journal table
280 cols = ','.join(['%s varchar'%x
281 for x in 'nodeid date tag action params'.split()])
282 sql = 'create table %s__journal (%s)'%(spec.classname, cols)
283 if __debug__:
284 print >>hyperdb.DEBUG, 'create_class', (self, sql)
285 self.cursor.execute(sql)
287 def create_multilink_table(self, spec, ml):
288 ''' Create a multilink table for the "ml" property of the class
289 given by the spec
290 '''
291 sql = 'create table %s_%s (linkid varchar, nodeid varchar)'%(
292 spec.classname, ml)
293 if __debug__:
294 print >>hyperdb.DEBUG, 'create_class', (self, sql)
295 self.cursor.execute(sql)
297 def create_class(self, spec):
298 ''' Create a database table according to the given spec.
299 '''
300 cols, mls = self.create_class_table(spec)
301 self.create_journal_table(spec)
303 # now create the multilink tables
304 for ml in mls:
305 self.create_multilink_table(spec, ml)
307 # ID counter
308 sql = 'insert into ids (name, num) values (%s,%s)'%(self.arg, self.arg)
309 vals = (spec.classname, 1)
310 if __debug__:
311 print >>hyperdb.DEBUG, 'create_class', (self, sql, vals)
312 self.cursor.execute(sql, vals)
314 def drop_class(self, spec):
315 ''' Drop the given table from the database.
317 Drop the journal and multilink tables too.
318 '''
319 # figure the multilinks
320 mls = []
321 for col, prop in spec.properties.items():
322 if isinstance(prop, Multilink):
323 mls.append(col)
325 sql = 'drop table _%s'%spec.classname
326 if __debug__:
327 print >>hyperdb.DEBUG, 'drop_class', (self, sql)
328 self.cursor.execute(sql)
330 sql = 'drop table %s__journal'%spec.classname
331 if __debug__:
332 print >>hyperdb.DEBUG, 'drop_class', (self, sql)
333 self.cursor.execute(sql)
335 for ml in mls:
336 sql = 'drop table %s_%s'%(spec.classname, ml)
337 if __debug__:
338 print >>hyperdb.DEBUG, 'drop_class', (self, sql)
339 self.cursor.execute(sql)
341 #
342 # Classes
343 #
344 def __getattr__(self, classname):
345 ''' A convenient way of calling self.getclass(classname).
346 '''
347 if self.classes.has_key(classname):
348 if __debug__:
349 print >>hyperdb.DEBUG, '__getattr__', (self, classname)
350 return self.classes[classname]
351 raise AttributeError, classname
353 def addclass(self, cl):
354 ''' Add a Class to the hyperdatabase.
355 '''
356 if __debug__:
357 print >>hyperdb.DEBUG, 'addclass', (self, cl)
358 cn = cl.classname
359 if self.classes.has_key(cn):
360 raise ValueError, cn
361 self.classes[cn] = cl
363 def getclasses(self):
364 ''' Return a list of the names of all existing classes.
365 '''
366 if __debug__:
367 print >>hyperdb.DEBUG, 'getclasses', (self,)
368 l = self.classes.keys()
369 l.sort()
370 return l
372 def getclass(self, classname):
373 '''Get the Class object representing a particular class.
375 If 'classname' is not a valid class name, a KeyError is raised.
376 '''
377 if __debug__:
378 print >>hyperdb.DEBUG, 'getclass', (self, classname)
379 try:
380 return self.classes[classname]
381 except KeyError:
382 raise KeyError, 'There is no class called "%s"'%classname
384 def clear(self):
385 ''' Delete all database contents.
387 Note: I don't commit here, which is different behaviour to the
388 "nuke from orbit" behaviour in the *dbms.
389 '''
390 if __debug__:
391 print >>hyperdb.DEBUG, 'clear', (self,)
392 for cn in self.classes.keys():
393 sql = 'delete from _%s'%cn
394 if __debug__:
395 print >>hyperdb.DEBUG, 'clear', (self, sql)
396 self.cursor.execute(sql)
398 #
399 # Node IDs
400 #
401 def newid(self, classname):
402 ''' Generate a new id for the given class
403 '''
404 # get the next ID
405 sql = 'select num from ids where name=%s'%self.arg
406 if __debug__:
407 print >>hyperdb.DEBUG, 'newid', (self, sql, classname)
408 self.cursor.execute(sql, (classname, ))
409 newid = self.cursor.fetchone()[0]
411 # update the counter
412 sql = 'update ids set num=%s where name=%s'%(self.arg, self.arg)
413 vals = (int(newid)+1, classname)
414 if __debug__:
415 print >>hyperdb.DEBUG, 'newid', (self, sql, vals)
416 self.cursor.execute(sql, vals)
418 # return as string
419 return str(newid)
421 def setid(self, classname, setid):
422 ''' Set the id counter: used during import of database
423 '''
424 sql = 'update ids set num=%s where name=%s'%(self.arg, self.arg)
425 vals = (setid, classname)
426 if __debug__:
427 print >>hyperdb.DEBUG, 'setid', (self, sql, vals)
428 self.cursor.execute(sql, vals)
430 #
431 # Nodes
432 #
434 def addnode(self, classname, nodeid, node):
435 ''' Add the specified node to its class's db.
436 '''
437 if __debug__:
438 print >>hyperdb.DEBUG, 'addnode', (self, classname, nodeid, node)
439 # gadfly requires values for all non-multilink columns
440 cl = self.classes[classname]
441 cols, mls = self.determine_columns(cl.properties.items())
443 # we'll be supplied these props if we're doing an import
444 if not node.has_key('creator'):
445 # add in the "calculated" properties (dupe so we don't affect
446 # calling code's node assumptions)
447 node = node.copy()
448 node['creation'] = node['activity'] = date.Date()
449 node['creator'] = self.curuserid
451 # default the non-multilink columns
452 for col, prop in cl.properties.items():
453 if not isinstance(col, Multilink):
454 if not node.has_key(col):
455 node[col] = None
457 # clear this node out of the cache if it's in there
458 key = (classname, nodeid)
459 if self.cache.has_key(key):
460 del self.cache[key]
461 self.cache_lru.remove(key)
463 # make the node data safe for the DB
464 node = self.serialise(classname, node)
466 # make sure the ordering is correct for column name -> column value
467 vals = tuple([node[col[1:]] for col in cols]) + (nodeid, 0)
468 s = ','.join([self.arg for x in cols]) + ',%s,%s'%(self.arg, self.arg)
469 cols = ','.join(cols) + ',id,__retired__'
471 # perform the inserts
472 sql = 'insert into _%s (%s) values (%s)'%(classname, cols, s)
473 if __debug__:
474 print >>hyperdb.DEBUG, 'addnode', (self, sql, vals)
475 self.cursor.execute(sql, vals)
477 # insert the multilink rows
478 for col in mls:
479 t = '%s_%s'%(classname, col)
480 for entry in node[col]:
481 sql = 'insert into %s (linkid, nodeid) values (%s,%s)'%(t,
482 self.arg, self.arg)
483 self.sql(sql, (entry, nodeid))
485 # make sure we do the commit-time extra stuff for this node
486 self.transactions.append((self.doSaveNode, (classname, nodeid, node)))
488 def setnode(self, classname, nodeid, values, multilink_changes):
489 ''' Change the specified node.
490 '''
491 if __debug__:
492 print >>hyperdb.DEBUG, 'setnode', (self, classname, nodeid, values)
494 # clear this node out of the cache if it's in there
495 key = (classname, nodeid)
496 if self.cache.has_key(key):
497 del self.cache[key]
498 self.cache_lru.remove(key)
500 # add the special props
501 values = values.copy()
502 values['activity'] = date.Date()
504 # make db-friendly
505 values = self.serialise(classname, values)
507 cl = self.classes[classname]
508 cols = []
509 mls = []
510 # add the multilinks separately
511 props = cl.getprops()
512 for col in values.keys():
513 prop = props[col]
514 if isinstance(prop, Multilink):
515 mls.append(col)
516 else:
517 cols.append('_'+col)
518 cols.sort()
520 # if there's any updates to regular columns, do them
521 if cols:
522 # make sure the ordering is correct for column name -> column value
523 sqlvals = tuple([values[col[1:]] for col in cols]) + (nodeid,)
524 s = ','.join(['%s=%s'%(x, self.arg) for x in cols])
525 cols = ','.join(cols)
527 # perform the update
528 sql = 'update _%s set %s where id=%s'%(classname, s, self.arg)
529 if __debug__:
530 print >>hyperdb.DEBUG, 'setnode', (self, sql, sqlvals)
531 self.cursor.execute(sql, sqlvals)
533 # now the fun bit, updating the multilinks ;)
534 for col, (add, remove) in multilink_changes.items():
535 tn = '%s_%s'%(classname, col)
536 if add:
537 sql = 'insert into %s (nodeid, linkid) values (%s,%s)'%(tn,
538 self.arg, self.arg)
539 for addid in add:
540 self.sql(sql, (nodeid, addid))
541 if remove:
542 sql = 'delete from %s where nodeid=%s and linkid=%s'%(tn,
543 self.arg, self.arg)
544 for removeid in remove:
545 self.sql(sql, (nodeid, removeid))
547 # make sure we do the commit-time extra stuff for this node
548 self.transactions.append((self.doSaveNode, (classname, nodeid, values)))
550 def getnode(self, classname, nodeid):
551 ''' Get a node from the database.
552 '''
553 if __debug__:
554 print >>hyperdb.DEBUG, 'getnode', (self, classname, nodeid)
556 # see if we have this node cached
557 key = (classname, nodeid)
558 if self.cache.has_key(key):
559 # push us back to the top of the LRU
560 self.cache_lru.remove(key)
561 self.cache_lru.insert(0, key)
562 # return the cached information
563 return self.cache[key]
565 # figure the columns we're fetching
566 cl = self.classes[classname]
567 cols, mls = self.determine_columns(cl.properties.items())
568 scols = ','.join(cols)
570 # perform the basic property fetch
571 sql = 'select %s from _%s where id=%s'%(scols, classname, self.arg)
572 self.sql(sql, (nodeid,))
574 values = self.sql_fetchone()
575 if values is None:
576 raise IndexError, 'no such %s node %s'%(classname, nodeid)
578 # make up the node
579 node = {}
580 for col in range(len(cols)):
581 node[cols[col][1:]] = values[col]
583 # now the multilinks
584 for col in mls:
585 # get the link ids
586 sql = 'select linkid from %s_%s where nodeid=%s'%(classname, col,
587 self.arg)
588 self.cursor.execute(sql, (nodeid,))
589 # extract the first column from the result
590 node[col] = [x[0] for x in self.cursor.fetchall()]
592 # un-dbificate the node data
593 node = self.unserialise(classname, node)
595 # save off in the cache
596 key = (classname, nodeid)
597 self.cache[key] = node
598 # update the LRU
599 self.cache_lru.insert(0, key)
600 if len(self.cache_lru) > ROW_CACHE_SIZE:
601 del self.cache[self.cache_lru.pop()]
603 return node
605 def destroynode(self, classname, nodeid):
606 '''Remove a node from the database. Called exclusively by the
607 destroy() method on Class.
608 '''
609 if __debug__:
610 print >>hyperdb.DEBUG, 'destroynode', (self, classname, nodeid)
612 # make sure the node exists
613 if not self.hasnode(classname, nodeid):
614 raise IndexError, '%s has no node %s'%(classname, nodeid)
616 # see if we have this node cached
617 if self.cache.has_key((classname, nodeid)):
618 del self.cache[(classname, nodeid)]
620 # see if there's any obvious commit actions that we should get rid of
621 for entry in self.transactions[:]:
622 if entry[1][:2] == (classname, nodeid):
623 self.transactions.remove(entry)
625 # now do the SQL
626 sql = 'delete from _%s where id=%s'%(classname, self.arg)
627 self.sql(sql, (nodeid,))
629 # remove from multilnks
630 cl = self.getclass(classname)
631 x, mls = self.determine_columns(cl.properties.items())
632 for col in mls:
633 # get the link ids
634 sql = 'delete from %s_%s where nodeid=%s'%(classname, col, self.arg)
635 self.cursor.execute(sql, (nodeid,))
637 # remove journal entries
638 sql = 'delete from %s__journal where nodeid=%s'%(classname, self.arg)
639 self.sql(sql, (nodeid,))
641 def serialise(self, classname, node):
642 '''Copy the node contents, converting non-marshallable data into
643 marshallable data.
644 '''
645 if __debug__:
646 print >>hyperdb.DEBUG, 'serialise', classname, node
647 properties = self.getclass(classname).getprops()
648 d = {}
649 for k, v in node.items():
650 # if the property doesn't exist, or is the "retired" flag then
651 # it won't be in the properties dict
652 if not properties.has_key(k):
653 d[k] = v
654 continue
656 # get the property spec
657 prop = properties[k]
659 if isinstance(prop, Password) and v is not None:
660 d[k] = str(v)
661 elif isinstance(prop, Date) and v is not None:
662 d[k] = v.serialise()
663 elif isinstance(prop, Interval) and v is not None:
664 d[k] = v.serialise()
665 else:
666 d[k] = v
667 return d
669 def unserialise(self, classname, node):
670 '''Decode the marshalled node data
671 '''
672 if __debug__:
673 print >>hyperdb.DEBUG, 'unserialise', classname, node
674 properties = self.getclass(classname).getprops()
675 d = {}
676 for k, v in node.items():
677 # if the property doesn't exist, or is the "retired" flag then
678 # it won't be in the properties dict
679 if not properties.has_key(k):
680 d[k] = v
681 continue
683 # get the property spec
684 prop = properties[k]
686 if isinstance(prop, Date) and v is not None:
687 d[k] = date.Date(v)
688 elif isinstance(prop, Interval) and v is not None:
689 d[k] = date.Interval(v)
690 elif isinstance(prop, Password) and v is not None:
691 p = password.Password()
692 p.unpack(v)
693 d[k] = p
694 elif (isinstance(prop, Boolean) or isinstance(prop, Number)) and v is not None:
695 d[k]=float(v)
696 else:
697 d[k] = v
698 return d
700 def hasnode(self, classname, nodeid):
701 ''' Determine if the database has a given node.
702 '''
703 sql = 'select count(*) from _%s where id=%s'%(classname, self.arg)
704 if __debug__:
705 print >>hyperdb.DEBUG, 'hasnode', (self, sql, nodeid)
706 self.cursor.execute(sql, (nodeid,))
707 return int(self.cursor.fetchone()[0])
709 def countnodes(self, classname):
710 ''' Count the number of nodes that exist for a particular Class.
711 '''
712 sql = 'select count(*) from _%s'%classname
713 if __debug__:
714 print >>hyperdb.DEBUG, 'countnodes', (self, sql)
715 self.cursor.execute(sql)
716 return self.cursor.fetchone()[0]
718 def getnodeids(self, classname, retired=0):
719 ''' Retrieve all the ids of the nodes for a particular Class.
721 Set retired=None to get all nodes. Otherwise it'll get all the
722 retired or non-retired nodes, depending on the flag.
723 '''
724 # flip the sense of the flag if we don't want all of them
725 if retired is not None:
726 retired = not retired
727 sql = 'select id from _%s where __retired__ <> %s'%(classname, self.arg)
728 if __debug__:
729 print >>hyperdb.DEBUG, 'getnodeids', (self, sql, retired)
730 self.cursor.execute(sql, (retired,))
731 return [x[0] for x in self.cursor.fetchall()]
733 def addjournal(self, classname, nodeid, action, params, creator=None,
734 creation=None):
735 ''' Journal the Action
736 'action' may be:
738 'create' or 'set' -- 'params' is a dictionary of property values
739 'link' or 'unlink' -- 'params' is (classname, nodeid, propname)
740 'retire' -- 'params' is None
741 '''
742 # serialise the parameters now if necessary
743 if isinstance(params, type({})):
744 if action in ('set', 'create'):
745 params = self.serialise(classname, params)
747 # handle supply of the special journalling parameters (usually
748 # supplied on importing an existing database)
749 if creator:
750 journaltag = creator
751 else:
752 journaltag = self.curuserid
753 if creation:
754 journaldate = creation.serialise()
755 else:
756 journaldate = date.Date().serialise()
758 # create the journal entry
759 cols = ','.join('nodeid date tag action params'.split())
761 if __debug__:
762 print >>hyperdb.DEBUG, 'addjournal', (nodeid, journaldate,
763 journaltag, action, params)
765 self.save_journal(classname, cols, nodeid, journaldate,
766 journaltag, action, params)
768 def save_journal(self, classname, cols, nodeid, journaldate,
769 journaltag, action, params):
770 ''' Save the journal entry to the database
771 '''
772 raise NotImplemented
774 def getjournal(self, classname, nodeid):
775 ''' get the journal for id
776 '''
777 # make sure the node exists
778 if not self.hasnode(classname, nodeid):
779 raise IndexError, '%s has no node %s'%(classname, nodeid)
781 cols = ','.join('nodeid date tag action params'.split())
782 return self.load_journal(classname, cols, nodeid)
784 def load_journal(self, classname, cols, nodeid):
785 ''' Load the journal from the database
786 '''
787 raise NotImplemented
789 def pack(self, pack_before):
790 ''' Delete all journal entries except "create" before 'pack_before'.
791 '''
792 # get a 'yyyymmddhhmmss' version of the date
793 date_stamp = pack_before.serialise()
795 # do the delete
796 for classname in self.classes.keys():
797 sql = "delete from %s__journal where date<%s and "\
798 "action<>'create'"%(classname, self.arg)
799 if __debug__:
800 print >>hyperdb.DEBUG, 'pack', (self, sql, date_stamp)
801 self.cursor.execute(sql, (date_stamp,))
803 def sql_commit(self):
804 ''' Actually commit to the database.
805 '''
806 self.conn.commit()
808 def commit(self):
809 ''' Commit the current transactions.
811 Save all data changed since the database was opened or since the
812 last commit() or rollback().
813 '''
814 if __debug__:
815 print >>hyperdb.DEBUG, 'commit', (self,)
817 # commit the database
818 self.sql_commit()
820 # now, do all the other transaction stuff
821 reindex = {}
822 for method, args in self.transactions:
823 reindex[method(*args)] = 1
825 # reindex the nodes that request it
826 for classname, nodeid in filter(None, reindex.keys()):
827 print >>hyperdb.DEBUG, 'commit.reindex', (classname, nodeid)
828 self.getclass(classname).index(nodeid)
830 # save the indexer state
831 self.indexer.save_index()
833 # clear out the transactions
834 self.transactions = []
836 def rollback(self):
837 ''' Reverse all actions from the current transaction.
839 Undo all the changes made since the database was opened or the last
840 commit() or rollback() was performed.
841 '''
842 if __debug__:
843 print >>hyperdb.DEBUG, 'rollback', (self,)
845 # roll back
846 self.conn.rollback()
848 # roll back "other" transaction stuff
849 for method, args in self.transactions:
850 # delete temporary files
851 if method == self.doStoreFile:
852 self.rollbackStoreFile(*args)
853 self.transactions = []
855 def doSaveNode(self, classname, nodeid, node):
856 ''' dummy that just generates a reindex event
857 '''
858 # return the classname, nodeid so we reindex this content
859 return (classname, nodeid)
861 def close(self):
862 ''' Close off the connection.
863 '''
864 self.conn.close()
865 if self.lockfile is not None:
866 locking.release_lock(self.lockfile)
867 if self.lockfile is not None:
868 self.lockfile.close()
869 self.lockfile = None
871 #
872 # The base Class class
873 #
874 class Class(hyperdb.Class):
875 ''' The handle to a particular class of nodes in a hyperdatabase.
877 All methods except __repr__ and getnode must be implemented by a
878 concrete backend Class.
879 '''
881 def __init__(self, db, classname, **properties):
882 '''Create a new class with a given name and property specification.
884 'classname' must not collide with the name of an existing class,
885 or a ValueError is raised. The keyword arguments in 'properties'
886 must map names to property objects, or a TypeError is raised.
887 '''
888 if (properties.has_key('creation') or properties.has_key('activity')
889 or properties.has_key('creator')):
890 raise ValueError, '"creation", "activity" and "creator" are '\
891 'reserved'
893 self.classname = classname
894 self.properties = properties
895 self.db = weakref.proxy(db) # use a weak ref to avoid circularity
896 self.key = ''
898 # should we journal changes (default yes)
899 self.do_journal = 1
901 # do the db-related init stuff
902 db.addclass(self)
904 self.auditors = {'create': [], 'set': [], 'retire': []}
905 self.reactors = {'create': [], 'set': [], 'retire': []}
907 def schema(self):
908 ''' A dumpable version of the schema that we can store in the
909 database
910 '''
911 return (self.key, [(x, repr(y)) for x,y in self.properties.items()])
913 def enableJournalling(self):
914 '''Turn journalling on for this class
915 '''
916 self.do_journal = 1
918 def disableJournalling(self):
919 '''Turn journalling off for this class
920 '''
921 self.do_journal = 0
923 # Editing nodes:
924 def create(self, **propvalues):
925 ''' Create a new node of this class and return its id.
927 The keyword arguments in 'propvalues' map property names to values.
929 The values of arguments must be acceptable for the types of their
930 corresponding properties or a TypeError is raised.
932 If this class has a key property, it must be present and its value
933 must not collide with other key strings or a ValueError is raised.
935 Any other properties on this class that are missing from the
936 'propvalues' dictionary are set to None.
938 If an id in a link or multilink property does not refer to a valid
939 node, an IndexError is raised.
940 '''
941 self.fireAuditors('create', None, propvalues)
942 newid = self.create_inner(**propvalues)
943 self.fireReactors('create', newid, None)
944 return newid
946 def create_inner(self, **propvalues):
947 ''' Called by create, in-between the audit and react calls.
948 '''
949 if propvalues.has_key('id'):
950 raise KeyError, '"id" is reserved'
952 if self.db.journaltag is None:
953 raise DatabaseError, 'Database open read-only'
955 if propvalues.has_key('creation') or propvalues.has_key('activity'):
956 raise KeyError, '"creation" and "activity" are reserved'
958 # new node's id
959 newid = self.db.newid(self.classname)
961 # validate propvalues
962 num_re = re.compile('^\d+$')
963 for key, value in propvalues.items():
964 if key == self.key:
965 try:
966 self.lookup(value)
967 except KeyError:
968 pass
969 else:
970 raise ValueError, 'node with key "%s" exists'%value
972 # try to handle this property
973 try:
974 prop = self.properties[key]
975 except KeyError:
976 raise KeyError, '"%s" has no property "%s"'%(self.classname,
977 key)
979 if value is not None and isinstance(prop, Link):
980 if type(value) != type(''):
981 raise ValueError, 'link value must be String'
982 link_class = self.properties[key].classname
983 # if it isn't a number, it's a key
984 if not num_re.match(value):
985 try:
986 value = self.db.classes[link_class].lookup(value)
987 except (TypeError, KeyError):
988 raise IndexError, 'new property "%s": %s not a %s'%(
989 key, value, link_class)
990 elif not self.db.getclass(link_class).hasnode(value):
991 raise IndexError, '%s has no node %s'%(link_class, value)
993 # save off the value
994 propvalues[key] = value
996 # register the link with the newly linked node
997 if self.do_journal and self.properties[key].do_journal:
998 self.db.addjournal(link_class, value, 'link',
999 (self.classname, newid, key))
1001 elif isinstance(prop, Multilink):
1002 if type(value) != type([]):
1003 raise TypeError, 'new property "%s" not a list of ids'%key
1005 # clean up and validate the list of links
1006 link_class = self.properties[key].classname
1007 l = []
1008 for entry in value:
1009 if type(entry) != type(''):
1010 raise ValueError, '"%s" multilink value (%r) '\
1011 'must contain Strings'%(key, value)
1012 # if it isn't a number, it's a key
1013 if not num_re.match(entry):
1014 try:
1015 entry = self.db.classes[link_class].lookup(entry)
1016 except (TypeError, KeyError):
1017 raise IndexError, 'new property "%s": %s not a %s'%(
1018 key, entry, self.properties[key].classname)
1019 l.append(entry)
1020 value = l
1021 propvalues[key] = value
1023 # handle additions
1024 for nodeid in value:
1025 if not self.db.getclass(link_class).hasnode(nodeid):
1026 raise IndexError, '%s has no node %s'%(link_class,
1027 nodeid)
1028 # register the link with the newly linked node
1029 if self.do_journal and self.properties[key].do_journal:
1030 self.db.addjournal(link_class, nodeid, 'link',
1031 (self.classname, newid, key))
1033 elif isinstance(prop, String):
1034 if type(value) != type('') and type(value) != type(u''):
1035 raise TypeError, 'new property "%s" not a string'%key
1037 elif isinstance(prop, Password):
1038 if not isinstance(value, password.Password):
1039 raise TypeError, 'new property "%s" not a Password'%key
1041 elif isinstance(prop, Date):
1042 if value is not None and not isinstance(value, date.Date):
1043 raise TypeError, 'new property "%s" not a Date'%key
1045 elif isinstance(prop, Interval):
1046 if value is not None and not isinstance(value, date.Interval):
1047 raise TypeError, 'new property "%s" not an Interval'%key
1049 elif value is not None and isinstance(prop, Number):
1050 try:
1051 float(value)
1052 except ValueError:
1053 raise TypeError, 'new property "%s" not numeric'%key
1055 elif value is not None and isinstance(prop, Boolean):
1056 try:
1057 int(value)
1058 except ValueError:
1059 raise TypeError, 'new property "%s" not boolean'%key
1061 # make sure there's data where there needs to be
1062 for key, prop in self.properties.items():
1063 if propvalues.has_key(key):
1064 continue
1065 if key == self.key:
1066 raise ValueError, 'key property "%s" is required'%key
1067 if isinstance(prop, Multilink):
1068 propvalues[key] = []
1069 else:
1070 propvalues[key] = None
1072 # done
1073 self.db.addnode(self.classname, newid, propvalues)
1074 if self.do_journal:
1075 self.db.addjournal(self.classname, newid, 'create', {})
1077 return newid
1079 def export_list(self, propnames, nodeid):
1080 ''' Export a node - generate a list of CSV-able data in the order
1081 specified by propnames for the given node.
1082 '''
1083 properties = self.getprops()
1084 l = []
1085 for prop in propnames:
1086 proptype = properties[prop]
1087 value = self.get(nodeid, prop)
1088 # "marshal" data where needed
1089 if value is None:
1090 pass
1091 elif isinstance(proptype, hyperdb.Date):
1092 value = value.get_tuple()
1093 elif isinstance(proptype, hyperdb.Interval):
1094 value = value.get_tuple()
1095 elif isinstance(proptype, hyperdb.Password):
1096 value = str(value)
1097 l.append(repr(value))
1098 l.append(self.is_retired(nodeid))
1099 return l
1101 def import_list(self, propnames, proplist):
1102 ''' Import a node - all information including "id" is present and
1103 should not be sanity checked. Triggers are not triggered. The
1104 journal should be initialised using the "creator" and "created"
1105 information.
1107 Return the nodeid of the node imported.
1108 '''
1109 if self.db.journaltag is None:
1110 raise DatabaseError, 'Database open read-only'
1111 properties = self.getprops()
1113 # make the new node's property map
1114 d = {}
1115 for i in range(len(propnames)):
1116 # Use eval to reverse the repr() used to output the CSV
1117 value = eval(proplist[i])
1119 # Figure the property for this column
1120 propname = propnames[i]
1121 prop = properties[propname]
1123 # "unmarshal" where necessary
1124 if propname == 'id':
1125 newid = value
1126 continue
1127 elif value is None:
1128 # don't set Nones
1129 continue
1130 elif isinstance(prop, hyperdb.Date):
1131 value = date.Date(value)
1132 elif isinstance(prop, hyperdb.Interval):
1133 value = date.Interval(value)
1134 elif isinstance(prop, hyperdb.Password):
1135 pwd = password.Password()
1136 pwd.unpack(value)
1137 value = pwd
1138 d[propname] = value
1140 # retire?
1141 if int(proplist[-1]):
1142 # use the arg for __retired__ to cope with any odd database type
1143 # conversion (hello, sqlite)
1144 sql = 'update _%s set __retired__=%s where id=%s'%(self.classname,
1145 self.db.arg, self.db.arg)
1146 if __debug__:
1147 print >>hyperdb.DEBUG, 'retire', (self, sql, newid)
1148 self.db.cursor.execute(sql, (1, newid))
1150 # add the node and journal
1151 self.db.addnode(self.classname, newid, d)
1153 # extract the extraneous journalling gumpf and nuke it
1154 if d.has_key('creator'):
1155 creator = d['creator']
1156 del d['creator']
1157 else:
1158 creator = None
1159 if d.has_key('creation'):
1160 creation = d['creation']
1161 del d['creation']
1162 else:
1163 creation = None
1164 if d.has_key('activity'):
1165 del d['activity']
1166 self.db.addjournal(self.classname, newid, 'create', {}, creator,
1167 creation)
1168 return newid
1170 _marker = []
1171 def get(self, nodeid, propname, default=_marker, cache=1):
1172 '''Get the value of a property on an existing node of this class.
1174 'nodeid' must be the id of an existing node of this class or an
1175 IndexError is raised. 'propname' must be the name of a property
1176 of this class or a KeyError is raised.
1178 'cache' indicates whether the transaction cache should be queried
1179 for the node. If the node has been modified and you need to
1180 determine what its values prior to modification are, you need to
1181 set cache=0.
1182 '''
1183 if propname == 'id':
1184 return nodeid
1186 # get the node's dict
1187 d = self.db.getnode(self.classname, nodeid)
1189 if propname == 'creation':
1190 if d.has_key('creation'):
1191 return d['creation']
1192 else:
1193 return date.Date()
1194 if propname == 'activity':
1195 if d.has_key('activity'):
1196 return d['activity']
1197 else:
1198 return date.Date()
1199 if propname == 'creator':
1200 if d.has_key('creator'):
1201 return d['creator']
1202 else:
1203 return self.db.curuserid
1205 # get the property (raises KeyErorr if invalid)
1206 prop = self.properties[propname]
1208 if not d.has_key(propname):
1209 if default is self._marker:
1210 if isinstance(prop, Multilink):
1211 return []
1212 else:
1213 return None
1214 else:
1215 return default
1217 # don't pass our list to other code
1218 if isinstance(prop, Multilink):
1219 return d[propname][:]
1221 return d[propname]
1223 def getnode(self, nodeid, cache=1):
1224 ''' Return a convenience wrapper for the node.
1226 'nodeid' must be the id of an existing node of this class or an
1227 IndexError is raised.
1229 'cache' indicates whether the transaction cache should be queried
1230 for the node. If the node has been modified and you need to
1231 determine what its values prior to modification are, you need to
1232 set cache=0.
1233 '''
1234 return Node(self, nodeid, cache=cache)
1236 def set(self, nodeid, **propvalues):
1237 '''Modify a property on an existing node of this class.
1239 'nodeid' must be the id of an existing node of this class or an
1240 IndexError is raised.
1242 Each key in 'propvalues' must be the name of a property of this
1243 class or a KeyError is raised.
1245 All values in 'propvalues' must be acceptable types for their
1246 corresponding properties or a TypeError is raised.
1248 If the value of the key property is set, it must not collide with
1249 other key strings or a ValueError is raised.
1251 If the value of a Link or Multilink property contains an invalid
1252 node id, a ValueError is raised.
1253 '''
1254 if not propvalues:
1255 return propvalues
1257 if propvalues.has_key('creation') or propvalues.has_key('activity'):
1258 raise KeyError, '"creation" and "activity" are reserved'
1260 if propvalues.has_key('id'):
1261 raise KeyError, '"id" is reserved'
1263 if self.db.journaltag is None:
1264 raise DatabaseError, 'Database open read-only'
1266 self.fireAuditors('set', nodeid, propvalues)
1267 # Take a copy of the node dict so that the subsequent set
1268 # operation doesn't modify the oldvalues structure.
1269 # XXX used to try the cache here first
1270 oldvalues = copy.deepcopy(self.db.getnode(self.classname, nodeid))
1272 node = self.db.getnode(self.classname, nodeid)
1273 if self.is_retired(nodeid):
1274 raise IndexError, 'Requested item is retired'
1275 num_re = re.compile('^\d+$')
1277 # if the journal value is to be different, store it in here
1278 journalvalues = {}
1280 # remember the add/remove stuff for multilinks, making it easier
1281 # for the Database layer to do its stuff
1282 multilink_changes = {}
1284 for propname, value in propvalues.items():
1285 # check to make sure we're not duplicating an existing key
1286 if propname == self.key and node[propname] != value:
1287 try:
1288 self.lookup(value)
1289 except KeyError:
1290 pass
1291 else:
1292 raise ValueError, 'node with key "%s" exists'%value
1294 # this will raise the KeyError if the property isn't valid
1295 # ... we don't use getprops() here because we only care about
1296 # the writeable properties.
1297 try:
1298 prop = self.properties[propname]
1299 except KeyError:
1300 raise KeyError, '"%s" has no property named "%s"'%(
1301 self.classname, propname)
1303 # if the value's the same as the existing value, no sense in
1304 # doing anything
1305 current = node.get(propname, None)
1306 if value == current:
1307 del propvalues[propname]
1308 continue
1309 journalvalues[propname] = current
1311 # do stuff based on the prop type
1312 if isinstance(prop, Link):
1313 link_class = prop.classname
1314 # if it isn't a number, it's a key
1315 if value is not None and not isinstance(value, type('')):
1316 raise ValueError, 'property "%s" link value be a string'%(
1317 propname)
1318 if isinstance(value, type('')) and not num_re.match(value):
1319 try:
1320 value = self.db.classes[link_class].lookup(value)
1321 except (TypeError, KeyError):
1322 raise IndexError, 'new property "%s": %s not a %s'%(
1323 propname, value, prop.classname)
1325 if (value is not None and
1326 not self.db.getclass(link_class).hasnode(value)):
1327 raise IndexError, '%s has no node %s'%(link_class, value)
1329 if self.do_journal and prop.do_journal:
1330 # register the unlink with the old linked node
1331 if node[propname] is not None:
1332 self.db.addjournal(link_class, node[propname], 'unlink',
1333 (self.classname, nodeid, propname))
1335 # register the link with the newly linked node
1336 if value is not None:
1337 self.db.addjournal(link_class, value, 'link',
1338 (self.classname, nodeid, propname))
1340 elif isinstance(prop, Multilink):
1341 if type(value) != type([]):
1342 raise TypeError, 'new property "%s" not a list of'\
1343 ' ids'%propname
1344 link_class = self.properties[propname].classname
1345 l = []
1346 for entry in value:
1347 # if it isn't a number, it's a key
1348 if type(entry) != type(''):
1349 raise ValueError, 'new property "%s" link value ' \
1350 'must be a string'%propname
1351 if not num_re.match(entry):
1352 try:
1353 entry = self.db.classes[link_class].lookup(entry)
1354 except (TypeError, KeyError):
1355 raise IndexError, 'new property "%s": %s not a %s'%(
1356 propname, entry,
1357 self.properties[propname].classname)
1358 l.append(entry)
1359 value = l
1360 propvalues[propname] = value
1362 # figure the journal entry for this property
1363 add = []
1364 remove = []
1366 # handle removals
1367 if node.has_key(propname):
1368 l = node[propname]
1369 else:
1370 l = []
1371 for id in l[:]:
1372 if id in value:
1373 continue
1374 # register the unlink with the old linked node
1375 if self.do_journal and self.properties[propname].do_journal:
1376 self.db.addjournal(link_class, id, 'unlink',
1377 (self.classname, nodeid, propname))
1378 l.remove(id)
1379 remove.append(id)
1381 # handle additions
1382 for id in value:
1383 if not self.db.getclass(link_class).hasnode(id):
1384 raise IndexError, '%s has no node %s'%(link_class, id)
1385 if id in l:
1386 continue
1387 # register the link with the newly linked node
1388 if self.do_journal and self.properties[propname].do_journal:
1389 self.db.addjournal(link_class, id, 'link',
1390 (self.classname, nodeid, propname))
1391 l.append(id)
1392 add.append(id)
1394 # figure the journal entry
1395 l = []
1396 if add:
1397 l.append(('+', add))
1398 if remove:
1399 l.append(('-', remove))
1400 multilink_changes[propname] = (add, remove)
1401 if l:
1402 journalvalues[propname] = tuple(l)
1404 elif isinstance(prop, String):
1405 if value is not None and type(value) != type('') and type(value) != type(u''):
1406 raise TypeError, 'new property "%s" not a string'%propname
1408 elif isinstance(prop, Password):
1409 if not isinstance(value, password.Password):
1410 raise TypeError, 'new property "%s" not a Password'%propname
1411 propvalues[propname] = value
1413 elif value is not None and isinstance(prop, Date):
1414 if not isinstance(value, date.Date):
1415 raise TypeError, 'new property "%s" not a Date'% propname
1416 propvalues[propname] = value
1418 elif value is not None and isinstance(prop, Interval):
1419 if not isinstance(value, date.Interval):
1420 raise TypeError, 'new property "%s" not an '\
1421 'Interval'%propname
1422 propvalues[propname] = value
1424 elif value is not None and isinstance(prop, Number):
1425 try:
1426 float(value)
1427 except ValueError:
1428 raise TypeError, 'new property "%s" not numeric'%propname
1430 elif value is not None and isinstance(prop, Boolean):
1431 try:
1432 int(value)
1433 except ValueError:
1434 raise TypeError, 'new property "%s" not boolean'%propname
1436 # nothing to do?
1437 if not propvalues:
1438 return propvalues
1440 # do the set, and journal it
1441 self.db.setnode(self.classname, nodeid, propvalues, multilink_changes)
1443 if self.do_journal:
1444 self.db.addjournal(self.classname, nodeid, 'set', journalvalues)
1446 self.fireReactors('set', nodeid, oldvalues)
1448 return propvalues
1450 def retire(self, nodeid):
1451 '''Retire a node.
1453 The properties on the node remain available from the get() method,
1454 and the node's id is never reused.
1456 Retired nodes are not returned by the find(), list(), or lookup()
1457 methods, and other nodes may reuse the values of their key properties.
1458 '''
1459 if self.db.journaltag is None:
1460 raise DatabaseError, 'Database open read-only'
1462 self.fireAuditors('retire', nodeid, None)
1464 # use the arg for __retired__ to cope with any odd database type
1465 # conversion (hello, sqlite)
1466 sql = 'update _%s set __retired__=%s where id=%s'%(self.classname,
1467 self.db.arg, self.db.arg)
1468 if __debug__:
1469 print >>hyperdb.DEBUG, 'retire', (self, sql, nodeid)
1470 self.db.cursor.execute(sql, (1, nodeid))
1472 self.fireReactors('retire', nodeid, None)
1474 def is_retired(self, nodeid):
1475 '''Return true if the node is rerired
1476 '''
1477 sql = 'select __retired__ from _%s where id=%s'%(self.classname,
1478 self.db.arg)
1479 if __debug__:
1480 print >>hyperdb.DEBUG, 'is_retired', (self, sql, nodeid)
1481 self.db.cursor.execute(sql, (nodeid,))
1482 return int(self.db.sql_fetchone()[0])
1484 def destroy(self, nodeid):
1485 '''Destroy a node.
1487 WARNING: this method should never be used except in extremely rare
1488 situations where there could never be links to the node being
1489 deleted
1490 WARNING: use retire() instead
1491 WARNING: the properties of this node will not be available ever again
1492 WARNING: really, use retire() instead
1494 Well, I think that's enough warnings. This method exists mostly to
1495 support the session storage of the cgi interface.
1497 The node is completely removed from the hyperdb, including all journal
1498 entries. It will no longer be available, and will generally break code
1499 if there are any references to the node.
1500 '''
1501 if self.db.journaltag is None:
1502 raise DatabaseError, 'Database open read-only'
1503 self.db.destroynode(self.classname, nodeid)
1505 def history(self, nodeid):
1506 '''Retrieve the journal of edits on a particular node.
1508 'nodeid' must be the id of an existing node of this class or an
1509 IndexError is raised.
1511 The returned list contains tuples of the form
1513 (nodeid, date, tag, action, params)
1515 'date' is a Timestamp object specifying the time of the change and
1516 'tag' is the journaltag specified when the database was opened.
1517 '''
1518 if not self.do_journal:
1519 raise ValueError, 'Journalling is disabled for this class'
1520 return self.db.getjournal(self.classname, nodeid)
1522 # Locating nodes:
1523 def hasnode(self, nodeid):
1524 '''Determine if the given nodeid actually exists
1525 '''
1526 return self.db.hasnode(self.classname, nodeid)
1528 def setkey(self, propname):
1529 '''Select a String property of this class to be the key property.
1531 'propname' must be the name of a String property of this class or
1532 None, or a TypeError is raised. The values of the key property on
1533 all existing nodes must be unique or a ValueError is raised.
1534 '''
1535 # XXX create an index on the key prop column
1536 prop = self.getprops()[propname]
1537 if not isinstance(prop, String):
1538 raise TypeError, 'key properties must be String'
1539 self.key = propname
1541 def getkey(self):
1542 '''Return the name of the key property for this class or None.'''
1543 return self.key
1545 def labelprop(self, default_to_id=0):
1546 ''' Return the property name for a label for the given node.
1548 This method attempts to generate a consistent label for the node.
1549 It tries the following in order:
1550 1. key property
1551 2. "name" property
1552 3. "title" property
1553 4. first property from the sorted property name list
1554 '''
1555 k = self.getkey()
1556 if k:
1557 return k
1558 props = self.getprops()
1559 if props.has_key('name'):
1560 return 'name'
1561 elif props.has_key('title'):
1562 return 'title'
1563 if default_to_id:
1564 return 'id'
1565 props = props.keys()
1566 props.sort()
1567 return props[0]
1569 def lookup(self, keyvalue):
1570 '''Locate a particular node by its key property and return its id.
1572 If this class has no key property, a TypeError is raised. If the
1573 'keyvalue' matches one of the values for the key property among
1574 the nodes in this class, the matching node's id is returned;
1575 otherwise a KeyError is raised.
1576 '''
1577 if not self.key:
1578 raise TypeError, 'No key property set for class %s'%self.classname
1580 # use the arg to handle any odd database type conversion (hello,
1581 # sqlite)
1582 sql = "select id from _%s where _%s=%s and __retired__ <> %s"%(
1583 self.classname, self.key, self.db.arg, self.db.arg)
1584 self.db.sql(sql, (keyvalue, 1))
1586 # see if there was a result that's not retired
1587 row = self.db.sql_fetchone()
1588 if not row:
1589 raise KeyError, 'No key (%s) value "%s" for "%s"'%(self.key,
1590 keyvalue, self.classname)
1592 # return the id
1593 return row[0]
1595 def find(self, **propspec):
1596 '''Get the ids of nodes in this class which link to the given nodes.
1598 'propspec' consists of keyword args propname=nodeid or
1599 propname={nodeid:1, }
1600 'propname' must be the name of a property in this class, or a
1601 KeyError is raised. That property must be a Link or Multilink
1602 property, or a TypeError is raised.
1604 Any node in this class whose 'propname' property links to any of the
1605 nodeids will be returned. Used by the full text indexing, which knows
1606 that "foo" occurs in msg1, msg3 and file7, so we have hits on these
1607 issues:
1609 db.issue.find(messages={'1':1,'3':1}, files={'7':1})
1610 '''
1611 if __debug__:
1612 print >>hyperdb.DEBUG, 'find', (self, propspec)
1614 # shortcut
1615 if not propspec:
1616 return []
1618 # validate the args
1619 props = self.getprops()
1620 propspec = propspec.items()
1621 for propname, nodeids in propspec:
1622 # check the prop is OK
1623 prop = props[propname]
1624 if not isinstance(prop, Link) and not isinstance(prop, Multilink):
1625 raise TypeError, "'%s' not a Link/Multilink property"%propname
1627 # first, links
1628 where = []
1629 allvalues = ()
1630 a = self.db.arg
1631 for prop, values in propspec:
1632 if not isinstance(props[prop], hyperdb.Link):
1633 continue
1634 if type(values) is type(''):
1635 allvalues += (values,)
1636 where.append('_%s = %s'%(prop, a))
1637 else:
1638 allvalues += tuple(values.keys())
1639 where.append('_%s in (%s)'%(prop, ','.join([a]*len(values))))
1640 tables = []
1641 if where:
1642 tables.append('select id as nodeid from _%s where %s'%(
1643 self.classname, ' and '.join(where)))
1645 # now multilinks
1646 for prop, values in propspec:
1647 if not isinstance(props[prop], hyperdb.Multilink):
1648 continue
1649 if type(values) is type(''):
1650 allvalues += (values,)
1651 s = a
1652 else:
1653 allvalues += tuple(values.keys())
1654 s = ','.join([a]*len(values))
1655 tables.append('select nodeid from %s_%s where linkid in (%s)'%(
1656 self.classname, prop, s))
1657 sql = '\nunion\n'.join(tables)
1658 self.db.sql(sql, allvalues)
1659 l = [x[0] for x in self.db.sql_fetchall()]
1660 if __debug__:
1661 print >>hyperdb.DEBUG, 'find ... ', l
1662 return l
1664 def stringFind(self, **requirements):
1665 '''Locate a particular node by matching a set of its String
1666 properties in a caseless search.
1668 If the property is not a String property, a TypeError is raised.
1670 The return is a list of the id of all nodes that match.
1671 '''
1672 where = []
1673 args = []
1674 for propname in requirements.keys():
1675 prop = self.properties[propname]
1676 if isinstance(not prop, String):
1677 raise TypeError, "'%s' not a String property"%propname
1678 where.append(propname)
1679 args.append(requirements[propname].lower())
1681 # generate the where clause
1682 s = ' and '.join(['_%s=%s'%(col, self.db.arg) for col in where])
1683 sql = 'select id from _%s where %s'%(self.classname, s)
1684 self.db.sql(sql, tuple(args))
1685 l = [x[0] for x in self.db.sql_fetchall()]
1686 if __debug__:
1687 print >>hyperdb.DEBUG, 'find ... ', l
1688 return l
1690 def list(self):
1691 ''' Return a list of the ids of the active nodes in this class.
1692 '''
1693 return self.db.getnodeids(self.classname, retired=0)
1695 def filter(self, search_matches, filterspec, sort=(None,None),
1696 group=(None,None)):
1697 ''' Return a list of the ids of the active nodes in this class that
1698 match the 'filter' spec, sorted by the group spec and then the
1699 sort spec
1701 "filterspec" is {propname: value(s)}
1702 "sort" and "group" are (dir, prop) where dir is '+', '-' or None
1703 and prop is a prop name or None
1704 "search_matches" is {nodeid: marker}
1706 The filter must match all properties specificed - but if the
1707 property value to match is a list, any one of the values in the
1708 list may match for that property to match.
1709 '''
1710 # just don't bother if the full-text search matched diddly
1711 if search_matches == {}:
1712 return []
1714 cn = self.classname
1716 # figure the WHERE clause from the filterspec
1717 props = self.getprops()
1718 frum = ['_'+cn]
1719 where = []
1720 args = []
1721 a = self.db.arg
1722 for k, v in filterspec.items():
1723 propclass = props[k]
1724 # now do other where clause stuff
1725 if isinstance(propclass, Multilink):
1726 tn = '%s_%s'%(cn, k)
1727 frum.append(tn)
1728 if isinstance(v, type([])):
1729 s = ','.join([a for x in v])
1730 where.append('id=%s.nodeid and %s.linkid in (%s)'%(tn,tn,s))
1731 args = args + v
1732 else:
1733 where.append('id=%s.nodeid and %s.linkid = %s'%(tn, tn, a))
1734 args.append(v)
1735 elif k == 'id':
1736 if isinstance(v, type([])):
1737 s = ','.join([a for x in v])
1738 where.append('%s in (%s)'%(k, s))
1739 args = args + v
1740 else:
1741 where.append('%s=%s'%(k, a))
1742 args.append(v)
1743 elif isinstance(propclass, String):
1744 if not isinstance(v, type([])):
1745 v = [v]
1747 # Quote the bits in the string that need it and then embed
1748 # in a "substring" search. Note - need to quote the '%' so
1749 # they make it through the python layer happily
1750 v = ['%%'+self.db.sql_stringquote(s)+'%%' for s in v]
1752 # now add to the where clause
1753 where.append(' or '.join(["_%s LIKE '%s'"%(k, s) for s in v]))
1754 # note: args are embedded in the query string now
1755 elif isinstance(propclass, Link):
1756 if isinstance(v, type([])):
1757 if '-1' in v:
1758 v.remove('-1')
1759 xtra = ' or _%s is NULL'%k
1760 else:
1761 xtra = ''
1762 if v:
1763 s = ','.join([a for x in v])
1764 where.append('(_%s in (%s)%s)'%(k, s, xtra))
1765 args = args + v
1766 else:
1767 where.append('_%s is NULL'%k)
1768 else:
1769 if v == '-1':
1770 v = None
1771 where.append('_%s is NULL'%k)
1772 else:
1773 where.append('_%s=%s'%(k, a))
1774 args.append(v)
1775 elif isinstance(propclass, Date):
1776 if isinstance(v, type([])):
1777 s = ','.join([a for x in v])
1778 where.append('_%s in (%s)'%(k, s))
1779 args = args + [date.Date(x).serialise() for x in v]
1780 else:
1781 where.append('_%s=%s'%(k, a))
1782 args.append(date.Date(v).serialise())
1783 elif isinstance(propclass, Interval):
1784 if isinstance(v, type([])):
1785 s = ','.join([a for x in v])
1786 where.append('_%s in (%s)'%(k, s))
1787 args = args + [date.Interval(x).serialise() for x in v]
1788 else:
1789 where.append('_%s=%s'%(k, a))
1790 args.append(date.Interval(v).serialise())
1791 else:
1792 if isinstance(v, type([])):
1793 s = ','.join([a for x in v])
1794 where.append('_%s in (%s)'%(k, s))
1795 args = args + v
1796 else:
1797 where.append('_%s=%s'%(k, a))
1798 args.append(v)
1800 # add results of full text search
1801 if search_matches is not None:
1802 v = search_matches.keys()
1803 s = ','.join([a for x in v])
1804 where.append('id in (%s)'%s)
1805 args = args + v
1807 # "grouping" is just the first-order sorting in the SQL fetch
1808 # can modify it...)
1809 orderby = []
1810 ordercols = []
1811 if group[0] is not None and group[1] is not None:
1812 if group[0] != '-':
1813 orderby.append('_'+group[1])
1814 ordercols.append('_'+group[1])
1815 else:
1816 orderby.append('_'+group[1]+' desc')
1817 ordercols.append('_'+group[1])
1819 # now add in the sorting
1820 group = ''
1821 if sort[0] is not None and sort[1] is not None:
1822 direction, colname = sort
1823 if direction != '-':
1824 if colname == 'id':
1825 orderby.append(colname)
1826 else:
1827 orderby.append('_'+colname)
1828 ordercols.append('_'+colname)
1829 else:
1830 if colname == 'id':
1831 orderby.append(colname+' desc')
1832 ordercols.append(colname)
1833 else:
1834 orderby.append('_'+colname+' desc')
1835 ordercols.append('_'+colname)
1837 # construct the SQL
1838 frum = ','.join(frum)
1839 if where:
1840 where = ' where ' + (' and '.join(where))
1841 else:
1842 where = ''
1843 cols = ['id']
1844 if orderby:
1845 cols = cols + ordercols
1846 order = ' order by %s'%(','.join(orderby))
1847 else:
1848 order = ''
1849 cols = ','.join(cols)
1850 sql = 'select %s from %s %s%s%s'%(cols, frum, where, group, order)
1851 args = tuple(args)
1852 if __debug__:
1853 print >>hyperdb.DEBUG, 'filter', (self, sql, args)
1854 self.db.cursor.execute(sql, args)
1855 l = self.db.cursor.fetchall()
1857 # return the IDs (the first column)
1858 # XXX The filter(None, l) bit is sqlite-specific... if there's _NO_
1859 # XXX matches to a fetch, it returns NULL instead of nothing!?!
1860 return filter(None, [row[0] for row in l])
1862 def count(self):
1863 '''Get the number of nodes in this class.
1865 If the returned integer is 'numnodes', the ids of all the nodes
1866 in this class run from 1 to numnodes, and numnodes+1 will be the
1867 id of the next node to be created in this class.
1868 '''
1869 return self.db.countnodes(self.classname)
1871 # Manipulating properties:
1872 def getprops(self, protected=1):
1873 '''Return a dictionary mapping property names to property objects.
1874 If the "protected" flag is true, we include protected properties -
1875 those which may not be modified.
1876 '''
1877 d = self.properties.copy()
1878 if protected:
1879 d['id'] = String()
1880 d['creation'] = hyperdb.Date()
1881 d['activity'] = hyperdb.Date()
1882 d['creator'] = hyperdb.Link('user')
1883 return d
1885 def addprop(self, **properties):
1886 '''Add properties to this class.
1888 The keyword arguments in 'properties' must map names to property
1889 objects, or a TypeError is raised. None of the keys in 'properties'
1890 may collide with the names of existing properties, or a ValueError
1891 is raised before any properties have been added.
1892 '''
1893 for key in properties.keys():
1894 if self.properties.has_key(key):
1895 raise ValueError, key
1896 self.properties.update(properties)
1898 def index(self, nodeid):
1899 '''Add (or refresh) the node to search indexes
1900 '''
1901 # find all the String properties that have indexme
1902 for prop, propclass in self.getprops().items():
1903 if isinstance(propclass, String) and propclass.indexme:
1904 try:
1905 value = str(self.get(nodeid, prop))
1906 except IndexError:
1907 # node no longer exists - entry should be removed
1908 self.db.indexer.purge_entry((self.classname, nodeid, prop))
1909 else:
1910 # and index them under (classname, nodeid, property)
1911 self.db.indexer.add_text((self.classname, nodeid, prop),
1912 value)
1915 #
1916 # Detector interface
1917 #
1918 def audit(self, event, detector):
1919 '''Register a detector
1920 '''
1921 l = self.auditors[event]
1922 if detector not in l:
1923 self.auditors[event].append(detector)
1925 def fireAuditors(self, action, nodeid, newvalues):
1926 '''Fire all registered auditors.
1927 '''
1928 for audit in self.auditors[action]:
1929 audit(self.db, self, nodeid, newvalues)
1931 def react(self, event, detector):
1932 '''Register a detector
1933 '''
1934 l = self.reactors[event]
1935 if detector not in l:
1936 self.reactors[event].append(detector)
1938 def fireReactors(self, action, nodeid, oldvalues):
1939 '''Fire all registered reactors.
1940 '''
1941 for react in self.reactors[action]:
1942 react(self.db, self, nodeid, oldvalues)
1944 class FileClass(Class, hyperdb.FileClass):
1945 '''This class defines a large chunk of data. To support this, it has a
1946 mandatory String property "content" which is typically saved off
1947 externally to the hyperdb.
1949 The default MIME type of this data is defined by the
1950 "default_mime_type" class attribute, which may be overridden by each
1951 node if the class defines a "type" String property.
1952 '''
1953 default_mime_type = 'text/plain'
1955 def create(self, **propvalues):
1956 ''' snaffle the file propvalue and store in a file
1957 '''
1958 # we need to fire the auditors now, or the content property won't
1959 # be in propvalues for the auditors to play with
1960 self.fireAuditors('create', None, propvalues)
1962 # now remove the content property so it's not stored in the db
1963 content = propvalues['content']
1964 del propvalues['content']
1966 # do the database create
1967 newid = Class.create_inner(self, **propvalues)
1969 # fire reactors
1970 self.fireReactors('create', newid, None)
1972 # store off the content as a file
1973 self.db.storefile(self.classname, newid, None, content)
1974 return newid
1976 def import_list(self, propnames, proplist):
1977 ''' Trap the "content" property...
1978 '''
1979 # dupe this list so we don't affect others
1980 propnames = propnames[:]
1982 # extract the "content" property from the proplist
1983 i = propnames.index('content')
1984 content = eval(proplist[i])
1985 del propnames[i]
1986 del proplist[i]
1988 # do the normal import
1989 newid = Class.import_list(self, propnames, proplist)
1991 # save off the "content" file
1992 self.db.storefile(self.classname, newid, None, content)
1993 return newid
1995 _marker = []
1996 def get(self, nodeid, propname, default=_marker, cache=1):
1997 ''' trap the content propname and get it from the file
1998 '''
1999 poss_msg = 'Possibly a access right configuration problem.'
2000 if propname == 'content':
2001 try:
2002 return self.db.getfile(self.classname, nodeid, None)
2003 except IOError, (strerror):
2004 # BUG: by catching this we donot see an error in the log.
2005 return 'ERROR reading file: %s%s\n%s\n%s'%(
2006 self.classname, nodeid, poss_msg, strerror)
2007 if default is not self._marker:
2008 return Class.get(self, nodeid, propname, default, cache=cache)
2009 else:
2010 return Class.get(self, nodeid, propname, cache=cache)
2012 def getprops(self, protected=1):
2013 ''' In addition to the actual properties on the node, these methods
2014 provide the "content" property. If the "protected" flag is true,
2015 we include protected properties - those which may not be
2016 modified.
2017 '''
2018 d = Class.getprops(self, protected=protected).copy()
2019 d['content'] = hyperdb.String()
2020 return d
2022 def index(self, nodeid):
2023 ''' Index the node in the search index.
2025 We want to index the content in addition to the normal String
2026 property indexing.
2027 '''
2028 # perform normal indexing
2029 Class.index(self, nodeid)
2031 # get the content to index
2032 content = self.get(nodeid, 'content')
2034 # figure the mime type
2035 if self.properties.has_key('type'):
2036 mime_type = self.get(nodeid, 'type')
2037 else:
2038 mime_type = self.default_mime_type
2040 # and index!
2041 self.db.indexer.add_text((self.classname, nodeid, 'content'), content,
2042 mime_type)
2044 # XXX deviation from spec - was called ItemClass
2045 class IssueClass(Class, roundupdb.IssueClass):
2046 # Overridden methods:
2047 def __init__(self, db, classname, **properties):
2048 '''The newly-created class automatically includes the "messages",
2049 "files", "nosy", and "superseder" properties. If the 'properties'
2050 dictionary attempts to specify any of these properties or a
2051 "creation" or "activity" property, a ValueError is raised.
2052 '''
2053 if not properties.has_key('title'):
2054 properties['title'] = hyperdb.String(indexme='yes')
2055 if not properties.has_key('messages'):
2056 properties['messages'] = hyperdb.Multilink("msg")
2057 if not properties.has_key('files'):
2058 properties['files'] = hyperdb.Multilink("file")
2059 if not properties.has_key('nosy'):
2060 # note: journalling is turned off as it really just wastes
2061 # space. this behaviour may be overridden in an instance
2062 properties['nosy'] = hyperdb.Multilink("user", do_journal="no")
2063 if not properties.has_key('superseder'):
2064 properties['superseder'] = hyperdb.Multilink(classname)
2065 Class.__init__(self, db, classname, **properties)