7388dbcb88d6807e35712e3ef049e3b441d04b9e
1 # $Id: rdbms_common.py,v 1.39 2003-03-06 06:03:51 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 addjournal(self, classname, nodeid, action, params, creator=None,
719 creation=None):
720 ''' Journal the Action
721 'action' may be:
723 'create' or 'set' -- 'params' is a dictionary of property values
724 'link' or 'unlink' -- 'params' is (classname, nodeid, propname)
725 'retire' -- 'params' is None
726 '''
727 # serialise the parameters now if necessary
728 if isinstance(params, type({})):
729 if action in ('set', 'create'):
730 params = self.serialise(classname, params)
732 # handle supply of the special journalling parameters (usually
733 # supplied on importing an existing database)
734 if creator:
735 journaltag = creator
736 else:
737 journaltag = self.curuserid
738 if creation:
739 journaldate = creation.serialise()
740 else:
741 journaldate = date.Date().serialise()
743 # create the journal entry
744 cols = ','.join('nodeid date tag action params'.split())
746 if __debug__:
747 print >>hyperdb.DEBUG, 'addjournal', (nodeid, journaldate,
748 journaltag, action, params)
750 self.save_journal(classname, cols, nodeid, journaldate,
751 journaltag, action, params)
753 def save_journal(self, classname, cols, nodeid, journaldate,
754 journaltag, action, params):
755 ''' Save the journal entry to the database
756 '''
757 raise NotImplemented
759 def getjournal(self, classname, nodeid):
760 ''' get the journal for id
761 '''
762 # make sure the node exists
763 if not self.hasnode(classname, nodeid):
764 raise IndexError, '%s has no node %s'%(classname, nodeid)
766 cols = ','.join('nodeid date tag action params'.split())
767 return self.load_journal(classname, cols, nodeid)
769 def load_journal(self, classname, cols, nodeid):
770 ''' Load the journal from the database
771 '''
772 raise NotImplemented
774 def pack(self, pack_before):
775 ''' Delete all journal entries except "create" before 'pack_before'.
776 '''
777 # get a 'yyyymmddhhmmss' version of the date
778 date_stamp = pack_before.serialise()
780 # do the delete
781 for classname in self.classes.keys():
782 sql = "delete from %s__journal where date<%s and "\
783 "action<>'create'"%(classname, self.arg)
784 if __debug__:
785 print >>hyperdb.DEBUG, 'pack', (self, sql, date_stamp)
786 self.cursor.execute(sql, (date_stamp,))
788 def sql_commit(self):
789 ''' Actually commit to the database.
790 '''
791 self.conn.commit()
793 def commit(self):
794 ''' Commit the current transactions.
796 Save all data changed since the database was opened or since the
797 last commit() or rollback().
798 '''
799 if __debug__:
800 print >>hyperdb.DEBUG, 'commit', (self,)
802 # commit the database
803 self.sql_commit()
805 # now, do all the other transaction stuff
806 reindex = {}
807 for method, args in self.transactions:
808 reindex[method(*args)] = 1
810 # reindex the nodes that request it
811 for classname, nodeid in filter(None, reindex.keys()):
812 print >>hyperdb.DEBUG, 'commit.reindex', (classname, nodeid)
813 self.getclass(classname).index(nodeid)
815 # save the indexer state
816 self.indexer.save_index()
818 # clear out the transactions
819 self.transactions = []
821 def rollback(self):
822 ''' Reverse all actions from the current transaction.
824 Undo all the changes made since the database was opened or the last
825 commit() or rollback() was performed.
826 '''
827 if __debug__:
828 print >>hyperdb.DEBUG, 'rollback', (self,)
830 # roll back
831 self.conn.rollback()
833 # roll back "other" transaction stuff
834 for method, args in self.transactions:
835 # delete temporary files
836 if method == self.doStoreFile:
837 self.rollbackStoreFile(*args)
838 self.transactions = []
840 # clear the cache
841 self.clearCache()
843 def doSaveNode(self, classname, nodeid, node):
844 ''' dummy that just generates a reindex event
845 '''
846 # return the classname, nodeid so we reindex this content
847 return (classname, nodeid)
849 def close(self):
850 ''' Close off the connection.
851 '''
852 self.conn.close()
853 if self.lockfile is not None:
854 locking.release_lock(self.lockfile)
855 if self.lockfile is not None:
856 self.lockfile.close()
857 self.lockfile = None
859 #
860 # The base Class class
861 #
862 class Class(hyperdb.Class):
863 ''' The handle to a particular class of nodes in a hyperdatabase.
865 All methods except __repr__ and getnode must be implemented by a
866 concrete backend Class.
867 '''
869 def __init__(self, db, classname, **properties):
870 '''Create a new class with a given name and property specification.
872 'classname' must not collide with the name of an existing class,
873 or a ValueError is raised. The keyword arguments in 'properties'
874 must map names to property objects, or a TypeError is raised.
875 '''
876 if (properties.has_key('creation') or properties.has_key('activity')
877 or properties.has_key('creator')):
878 raise ValueError, '"creation", "activity" and "creator" are '\
879 'reserved'
881 self.classname = classname
882 self.properties = properties
883 self.db = weakref.proxy(db) # use a weak ref to avoid circularity
884 self.key = ''
886 # should we journal changes (default yes)
887 self.do_journal = 1
889 # do the db-related init stuff
890 db.addclass(self)
892 self.auditors = {'create': [], 'set': [], 'retire': []}
893 self.reactors = {'create': [], 'set': [], 'retire': []}
895 def schema(self):
896 ''' A dumpable version of the schema that we can store in the
897 database
898 '''
899 return (self.key, [(x, repr(y)) for x,y in self.properties.items()])
901 def enableJournalling(self):
902 '''Turn journalling on for this class
903 '''
904 self.do_journal = 1
906 def disableJournalling(self):
907 '''Turn journalling off for this class
908 '''
909 self.do_journal = 0
911 # Editing nodes:
912 def create(self, **propvalues):
913 ''' Create a new node of this class and return its id.
915 The keyword arguments in 'propvalues' map property names to values.
917 The values of arguments must be acceptable for the types of their
918 corresponding properties or a TypeError is raised.
920 If this class has a key property, it must be present and its value
921 must not collide with other key strings or a ValueError is raised.
923 Any other properties on this class that are missing from the
924 'propvalues' dictionary are set to None.
926 If an id in a link or multilink property does not refer to a valid
927 node, an IndexError is raised.
928 '''
929 self.fireAuditors('create', None, propvalues)
930 newid = self.create_inner(**propvalues)
931 self.fireReactors('create', newid, None)
932 return newid
934 def create_inner(self, **propvalues):
935 ''' Called by create, in-between the audit and react calls.
936 '''
937 if propvalues.has_key('id'):
938 raise KeyError, '"id" is reserved'
940 if self.db.journaltag is None:
941 raise DatabaseError, 'Database open read-only'
943 if propvalues.has_key('creation') or propvalues.has_key('activity'):
944 raise KeyError, '"creation" and "activity" are reserved'
946 # new node's id
947 newid = self.db.newid(self.classname)
949 # validate propvalues
950 num_re = re.compile('^\d+$')
951 for key, value in propvalues.items():
952 if key == self.key:
953 try:
954 self.lookup(value)
955 except KeyError:
956 pass
957 else:
958 raise ValueError, 'node with key "%s" exists'%value
960 # try to handle this property
961 try:
962 prop = self.properties[key]
963 except KeyError:
964 raise KeyError, '"%s" has no property "%s"'%(self.classname,
965 key)
967 if value is not None and isinstance(prop, Link):
968 if type(value) != type(''):
969 raise ValueError, 'link value must be String'
970 link_class = self.properties[key].classname
971 # if it isn't a number, it's a key
972 if not num_re.match(value):
973 try:
974 value = self.db.classes[link_class].lookup(value)
975 except (TypeError, KeyError):
976 raise IndexError, 'new property "%s": %s not a %s'%(
977 key, value, link_class)
978 elif not self.db.getclass(link_class).hasnode(value):
979 raise IndexError, '%s has no node %s'%(link_class, value)
981 # save off the value
982 propvalues[key] = value
984 # register the link with the newly linked node
985 if self.do_journal and self.properties[key].do_journal:
986 self.db.addjournal(link_class, value, 'link',
987 (self.classname, newid, key))
989 elif isinstance(prop, Multilink):
990 if type(value) != type([]):
991 raise TypeError, 'new property "%s" not a list of ids'%key
993 # clean up and validate the list of links
994 link_class = self.properties[key].classname
995 l = []
996 for entry in value:
997 if type(entry) != type(''):
998 raise ValueError, '"%s" multilink value (%r) '\
999 'must contain Strings'%(key, value)
1000 # if it isn't a number, it's a key
1001 if not num_re.match(entry):
1002 try:
1003 entry = self.db.classes[link_class].lookup(entry)
1004 except (TypeError, KeyError):
1005 raise IndexError, 'new property "%s": %s not a %s'%(
1006 key, entry, self.properties[key].classname)
1007 l.append(entry)
1008 value = l
1009 propvalues[key] = value
1011 # handle additions
1012 for nodeid in value:
1013 if not self.db.getclass(link_class).hasnode(nodeid):
1014 raise IndexError, '%s has no node %s'%(link_class,
1015 nodeid)
1016 # register the link with the newly linked node
1017 if self.do_journal and self.properties[key].do_journal:
1018 self.db.addjournal(link_class, nodeid, 'link',
1019 (self.classname, newid, key))
1021 elif isinstance(prop, String):
1022 if type(value) != type('') and type(value) != type(u''):
1023 raise TypeError, 'new property "%s" not a string'%key
1025 elif isinstance(prop, Password):
1026 if not isinstance(value, password.Password):
1027 raise TypeError, 'new property "%s" not a Password'%key
1029 elif isinstance(prop, Date):
1030 if value is not None and not isinstance(value, date.Date):
1031 raise TypeError, 'new property "%s" not a Date'%key
1033 elif isinstance(prop, Interval):
1034 if value is not None and not isinstance(value, date.Interval):
1035 raise TypeError, 'new property "%s" not an Interval'%key
1037 elif value is not None and isinstance(prop, Number):
1038 try:
1039 float(value)
1040 except ValueError:
1041 raise TypeError, 'new property "%s" not numeric'%key
1043 elif value is not None and isinstance(prop, Boolean):
1044 try:
1045 int(value)
1046 except ValueError:
1047 raise TypeError, 'new property "%s" not boolean'%key
1049 # make sure there's data where there needs to be
1050 for key, prop in self.properties.items():
1051 if propvalues.has_key(key):
1052 continue
1053 if key == self.key:
1054 raise ValueError, 'key property "%s" is required'%key
1055 if isinstance(prop, Multilink):
1056 propvalues[key] = []
1057 else:
1058 propvalues[key] = None
1060 # done
1061 self.db.addnode(self.classname, newid, propvalues)
1062 if self.do_journal:
1063 self.db.addjournal(self.classname, newid, 'create', {})
1065 return newid
1067 def export_list(self, propnames, nodeid):
1068 ''' Export a node - generate a list of CSV-able data in the order
1069 specified by propnames for the given node.
1070 '''
1071 properties = self.getprops()
1072 l = []
1073 for prop in propnames:
1074 proptype = properties[prop]
1075 value = self.get(nodeid, prop)
1076 # "marshal" data where needed
1077 if value is None:
1078 pass
1079 elif isinstance(proptype, hyperdb.Date):
1080 value = value.get_tuple()
1081 elif isinstance(proptype, hyperdb.Interval):
1082 value = value.get_tuple()
1083 elif isinstance(proptype, hyperdb.Password):
1084 value = str(value)
1085 l.append(repr(value))
1086 l.append(self.is_retired(nodeid))
1087 return l
1089 def import_list(self, propnames, proplist):
1090 ''' Import a node - all information including "id" is present and
1091 should not be sanity checked. Triggers are not triggered. The
1092 journal should be initialised using the "creator" and "created"
1093 information.
1095 Return the nodeid of the node imported.
1096 '''
1097 if self.db.journaltag is None:
1098 raise DatabaseError, 'Database open read-only'
1099 properties = self.getprops()
1101 # make the new node's property map
1102 d = {}
1103 for i in range(len(propnames)):
1104 # Use eval to reverse the repr() used to output the CSV
1105 value = eval(proplist[i])
1107 # Figure the property for this column
1108 propname = propnames[i]
1109 prop = properties[propname]
1111 # "unmarshal" where necessary
1112 if propname == 'id':
1113 newid = value
1114 continue
1115 elif value is None:
1116 # don't set Nones
1117 continue
1118 elif isinstance(prop, hyperdb.Date):
1119 value = date.Date(value)
1120 elif isinstance(prop, hyperdb.Interval):
1121 value = date.Interval(value)
1122 elif isinstance(prop, hyperdb.Password):
1123 pwd = password.Password()
1124 pwd.unpack(value)
1125 value = pwd
1126 d[propname] = value
1128 # retire?
1129 if int(proplist[-1]):
1130 # use the arg for __retired__ to cope with any odd database type
1131 # conversion (hello, sqlite)
1132 sql = 'update _%s set __retired__=%s where id=%s'%(self.classname,
1133 self.db.arg, self.db.arg)
1134 if __debug__:
1135 print >>hyperdb.DEBUG, 'retire', (self, sql, newid)
1136 self.db.cursor.execute(sql, (1, newid))
1138 # add the node and journal
1139 self.db.addnode(self.classname, newid, d)
1141 # extract the extraneous journalling gumpf and nuke it
1142 if d.has_key('creator'):
1143 creator = d['creator']
1144 del d['creator']
1145 else:
1146 creator = None
1147 if d.has_key('creation'):
1148 creation = d['creation']
1149 del d['creation']
1150 else:
1151 creation = None
1152 if d.has_key('activity'):
1153 del d['activity']
1154 self.db.addjournal(self.classname, newid, 'create', {}, creator,
1155 creation)
1156 return newid
1158 _marker = []
1159 def get(self, nodeid, propname, default=_marker, cache=1):
1160 '''Get the value of a property on an existing node of this class.
1162 'nodeid' must be the id of an existing node of this class or an
1163 IndexError is raised. 'propname' must be the name of a property
1164 of this class or a KeyError is raised.
1166 'cache' indicates whether the transaction cache should be queried
1167 for the node. If the node has been modified and you need to
1168 determine what its values prior to modification are, you need to
1169 set cache=0.
1170 '''
1171 if propname == 'id':
1172 return nodeid
1174 # get the node's dict
1175 d = self.db.getnode(self.classname, nodeid)
1177 if propname == 'creation':
1178 if d.has_key('creation'):
1179 return d['creation']
1180 else:
1181 return date.Date()
1182 if propname == 'activity':
1183 if d.has_key('activity'):
1184 return d['activity']
1185 else:
1186 return date.Date()
1187 if propname == 'creator':
1188 if d.has_key('creator'):
1189 return d['creator']
1190 else:
1191 return self.db.curuserid
1193 # get the property (raises KeyErorr if invalid)
1194 prop = self.properties[propname]
1196 if not d.has_key(propname):
1197 if default is self._marker:
1198 if isinstance(prop, Multilink):
1199 return []
1200 else:
1201 return None
1202 else:
1203 return default
1205 # don't pass our list to other code
1206 if isinstance(prop, Multilink):
1207 return d[propname][:]
1209 return d[propname]
1211 def getnode(self, nodeid, cache=1):
1212 ''' Return a convenience wrapper for the node.
1214 'nodeid' must be the id of an existing node of this class or an
1215 IndexError is raised.
1217 'cache' indicates whether the transaction cache should be queried
1218 for the node. If the node has been modified and you need to
1219 determine what its values prior to modification are, you need to
1220 set cache=0.
1221 '''
1222 return Node(self, nodeid, cache=cache)
1224 def set(self, nodeid, **propvalues):
1225 '''Modify a property on an existing node of this class.
1227 'nodeid' must be the id of an existing node of this class or an
1228 IndexError is raised.
1230 Each key in 'propvalues' must be the name of a property of this
1231 class or a KeyError is raised.
1233 All values in 'propvalues' must be acceptable types for their
1234 corresponding properties or a TypeError is raised.
1236 If the value of the key property is set, it must not collide with
1237 other key strings or a ValueError is raised.
1239 If the value of a Link or Multilink property contains an invalid
1240 node id, a ValueError is raised.
1241 '''
1242 if not propvalues:
1243 return propvalues
1245 if propvalues.has_key('creation') or propvalues.has_key('activity'):
1246 raise KeyError, '"creation" and "activity" are reserved'
1248 if propvalues.has_key('id'):
1249 raise KeyError, '"id" is reserved'
1251 if self.db.journaltag is None:
1252 raise DatabaseError, 'Database open read-only'
1254 self.fireAuditors('set', nodeid, propvalues)
1255 # Take a copy of the node dict so that the subsequent set
1256 # operation doesn't modify the oldvalues structure.
1257 # XXX used to try the cache here first
1258 oldvalues = copy.deepcopy(self.db.getnode(self.classname, nodeid))
1260 node = self.db.getnode(self.classname, nodeid)
1261 if self.is_retired(nodeid):
1262 raise IndexError, 'Requested item is retired'
1263 num_re = re.compile('^\d+$')
1265 # if the journal value is to be different, store it in here
1266 journalvalues = {}
1268 # remember the add/remove stuff for multilinks, making it easier
1269 # for the Database layer to do its stuff
1270 multilink_changes = {}
1272 for propname, value in propvalues.items():
1273 # check to make sure we're not duplicating an existing key
1274 if propname == self.key and node[propname] != value:
1275 try:
1276 self.lookup(value)
1277 except KeyError:
1278 pass
1279 else:
1280 raise ValueError, 'node with key "%s" exists'%value
1282 # this will raise the KeyError if the property isn't valid
1283 # ... we don't use getprops() here because we only care about
1284 # the writeable properties.
1285 try:
1286 prop = self.properties[propname]
1287 except KeyError:
1288 raise KeyError, '"%s" has no property named "%s"'%(
1289 self.classname, propname)
1291 # if the value's the same as the existing value, no sense in
1292 # doing anything
1293 current = node.get(propname, None)
1294 if value == current:
1295 del propvalues[propname]
1296 continue
1297 journalvalues[propname] = current
1299 # do stuff based on the prop type
1300 if isinstance(prop, Link):
1301 link_class = prop.classname
1302 # if it isn't a number, it's a key
1303 if value is not None and not isinstance(value, type('')):
1304 raise ValueError, 'property "%s" link value be a string'%(
1305 propname)
1306 if isinstance(value, type('')) and not num_re.match(value):
1307 try:
1308 value = self.db.classes[link_class].lookup(value)
1309 except (TypeError, KeyError):
1310 raise IndexError, 'new property "%s": %s not a %s'%(
1311 propname, value, prop.classname)
1313 if (value is not None and
1314 not self.db.getclass(link_class).hasnode(value)):
1315 raise IndexError, '%s has no node %s'%(link_class, value)
1317 if self.do_journal and prop.do_journal:
1318 # register the unlink with the old linked node
1319 if node[propname] is not None:
1320 self.db.addjournal(link_class, node[propname], 'unlink',
1321 (self.classname, nodeid, propname))
1323 # register the link with the newly linked node
1324 if value is not None:
1325 self.db.addjournal(link_class, value, 'link',
1326 (self.classname, nodeid, propname))
1328 elif isinstance(prop, Multilink):
1329 if type(value) != type([]):
1330 raise TypeError, 'new property "%s" not a list of'\
1331 ' ids'%propname
1332 link_class = self.properties[propname].classname
1333 l = []
1334 for entry in value:
1335 # if it isn't a number, it's a key
1336 if type(entry) != type(''):
1337 raise ValueError, 'new property "%s" link value ' \
1338 'must be a string'%propname
1339 if not num_re.match(entry):
1340 try:
1341 entry = self.db.classes[link_class].lookup(entry)
1342 except (TypeError, KeyError):
1343 raise IndexError, 'new property "%s": %s not a %s'%(
1344 propname, entry,
1345 self.properties[propname].classname)
1346 l.append(entry)
1347 value = l
1348 propvalues[propname] = value
1350 # figure the journal entry for this property
1351 add = []
1352 remove = []
1354 # handle removals
1355 if node.has_key(propname):
1356 l = node[propname]
1357 else:
1358 l = []
1359 for id in l[:]:
1360 if id in value:
1361 continue
1362 # register the unlink with the old linked node
1363 if self.do_journal and self.properties[propname].do_journal:
1364 self.db.addjournal(link_class, id, 'unlink',
1365 (self.classname, nodeid, propname))
1366 l.remove(id)
1367 remove.append(id)
1369 # handle additions
1370 for id in value:
1371 if not self.db.getclass(link_class).hasnode(id):
1372 raise IndexError, '%s has no node %s'%(link_class, id)
1373 if id in l:
1374 continue
1375 # register the link with the newly linked node
1376 if self.do_journal and self.properties[propname].do_journal:
1377 self.db.addjournal(link_class, id, 'link',
1378 (self.classname, nodeid, propname))
1379 l.append(id)
1380 add.append(id)
1382 # figure the journal entry
1383 l = []
1384 if add:
1385 l.append(('+', add))
1386 if remove:
1387 l.append(('-', remove))
1388 multilink_changes[propname] = (add, remove)
1389 if l:
1390 journalvalues[propname] = tuple(l)
1392 elif isinstance(prop, String):
1393 if value is not None and type(value) != type('') and type(value) != type(u''):
1394 raise TypeError, 'new property "%s" not a string'%propname
1396 elif isinstance(prop, Password):
1397 if not isinstance(value, password.Password):
1398 raise TypeError, 'new property "%s" not a Password'%propname
1399 propvalues[propname] = value
1401 elif value is not None and isinstance(prop, Date):
1402 if not isinstance(value, date.Date):
1403 raise TypeError, 'new property "%s" not a Date'% propname
1404 propvalues[propname] = value
1406 elif value is not None and isinstance(prop, Interval):
1407 if not isinstance(value, date.Interval):
1408 raise TypeError, 'new property "%s" not an '\
1409 'Interval'%propname
1410 propvalues[propname] = value
1412 elif value is not None and isinstance(prop, Number):
1413 try:
1414 float(value)
1415 except ValueError:
1416 raise TypeError, 'new property "%s" not numeric'%propname
1418 elif value is not None and isinstance(prop, Boolean):
1419 try:
1420 int(value)
1421 except ValueError:
1422 raise TypeError, 'new property "%s" not boolean'%propname
1424 # nothing to do?
1425 if not propvalues:
1426 return propvalues
1428 # do the set, and journal it
1429 self.db.setnode(self.classname, nodeid, propvalues, multilink_changes)
1431 if self.do_journal:
1432 self.db.addjournal(self.classname, nodeid, 'set', journalvalues)
1434 self.fireReactors('set', nodeid, oldvalues)
1436 return propvalues
1438 def retire(self, nodeid):
1439 '''Retire a node.
1441 The properties on the node remain available from the get() method,
1442 and the node's id is never reused.
1444 Retired nodes are not returned by the find(), list(), or lookup()
1445 methods, and other nodes may reuse the values of their key properties.
1446 '''
1447 if self.db.journaltag is None:
1448 raise DatabaseError, 'Database open read-only'
1450 self.fireAuditors('retire', nodeid, None)
1452 # use the arg for __retired__ to cope with any odd database type
1453 # conversion (hello, sqlite)
1454 sql = 'update _%s set __retired__=%s where id=%s'%(self.classname,
1455 self.db.arg, self.db.arg)
1456 if __debug__:
1457 print >>hyperdb.DEBUG, 'retire', (self, sql, nodeid)
1458 self.db.cursor.execute(sql, (1, nodeid))
1460 self.fireReactors('retire', nodeid, None)
1462 def is_retired(self, nodeid):
1463 '''Return true if the node is rerired
1464 '''
1465 sql = 'select __retired__ from _%s where id=%s'%(self.classname,
1466 self.db.arg)
1467 if __debug__:
1468 print >>hyperdb.DEBUG, 'is_retired', (self, sql, nodeid)
1469 self.db.cursor.execute(sql, (nodeid,))
1470 return int(self.db.sql_fetchone()[0])
1472 def destroy(self, nodeid):
1473 '''Destroy a node.
1475 WARNING: this method should never be used except in extremely rare
1476 situations where there could never be links to the node being
1477 deleted
1478 WARNING: use retire() instead
1479 WARNING: the properties of this node will not be available ever again
1480 WARNING: really, use retire() instead
1482 Well, I think that's enough warnings. This method exists mostly to
1483 support the session storage of the cgi interface.
1485 The node is completely removed from the hyperdb, including all journal
1486 entries. It will no longer be available, and will generally break code
1487 if there are any references to the node.
1488 '''
1489 if self.db.journaltag is None:
1490 raise DatabaseError, 'Database open read-only'
1491 self.db.destroynode(self.classname, nodeid)
1493 def history(self, nodeid):
1494 '''Retrieve the journal of edits on a particular node.
1496 'nodeid' must be the id of an existing node of this class or an
1497 IndexError is raised.
1499 The returned list contains tuples of the form
1501 (nodeid, date, tag, action, params)
1503 'date' is a Timestamp object specifying the time of the change and
1504 'tag' is the journaltag specified when the database was opened.
1505 '''
1506 if not self.do_journal:
1507 raise ValueError, 'Journalling is disabled for this class'
1508 return self.db.getjournal(self.classname, nodeid)
1510 # Locating nodes:
1511 def hasnode(self, nodeid):
1512 '''Determine if the given nodeid actually exists
1513 '''
1514 return self.db.hasnode(self.classname, nodeid)
1516 def setkey(self, propname):
1517 '''Select a String property of this class to be the key property.
1519 'propname' must be the name of a String property of this class or
1520 None, or a TypeError is raised. The values of the key property on
1521 all existing nodes must be unique or a ValueError is raised.
1522 '''
1523 # XXX create an index on the key prop column
1524 prop = self.getprops()[propname]
1525 if not isinstance(prop, String):
1526 raise TypeError, 'key properties must be String'
1527 self.key = propname
1529 def getkey(self):
1530 '''Return the name of the key property for this class or None.'''
1531 return self.key
1533 def labelprop(self, default_to_id=0):
1534 ''' Return the property name for a label for the given node.
1536 This method attempts to generate a consistent label for the node.
1537 It tries the following in order:
1538 1. key property
1539 2. "name" property
1540 3. "title" property
1541 4. first property from the sorted property name list
1542 '''
1543 k = self.getkey()
1544 if k:
1545 return k
1546 props = self.getprops()
1547 if props.has_key('name'):
1548 return 'name'
1549 elif props.has_key('title'):
1550 return 'title'
1551 if default_to_id:
1552 return 'id'
1553 props = props.keys()
1554 props.sort()
1555 return props[0]
1557 def lookup(self, keyvalue):
1558 '''Locate a particular node by its key property and return its id.
1560 If this class has no key property, a TypeError is raised. If the
1561 'keyvalue' matches one of the values for the key property among
1562 the nodes in this class, the matching node's id is returned;
1563 otherwise a KeyError is raised.
1564 '''
1565 if not self.key:
1566 raise TypeError, 'No key property set for class %s'%self.classname
1568 # use the arg to handle any odd database type conversion (hello,
1569 # sqlite)
1570 sql = "select id from _%s where _%s=%s and __retired__ <> %s"%(
1571 self.classname, self.key, self.db.arg, self.db.arg)
1572 self.db.sql(sql, (keyvalue, 1))
1574 # see if there was a result that's not retired
1575 row = self.db.sql_fetchone()
1576 if not row:
1577 raise KeyError, 'No key (%s) value "%s" for "%s"'%(self.key,
1578 keyvalue, self.classname)
1580 # return the id
1581 return row[0]
1583 def find(self, **propspec):
1584 '''Get the ids of nodes in this class which link to the given nodes.
1586 'propspec' consists of keyword args propname=nodeid or
1587 propname={nodeid:1, }
1588 'propname' must be the name of a property in this class, or a
1589 KeyError is raised. That property must be a Link or Multilink
1590 property, or a TypeError is raised.
1592 Any node in this class whose 'propname' property links to any of the
1593 nodeids will be returned. Used by the full text indexing, which knows
1594 that "foo" occurs in msg1, msg3 and file7, so we have hits on these
1595 issues:
1597 db.issue.find(messages={'1':1,'3':1}, files={'7':1})
1598 '''
1599 if __debug__:
1600 print >>hyperdb.DEBUG, 'find', (self, propspec)
1602 # shortcut
1603 if not propspec:
1604 return []
1606 # validate the args
1607 props = self.getprops()
1608 propspec = propspec.items()
1609 for propname, nodeids in propspec:
1610 # check the prop is OK
1611 prop = props[propname]
1612 if not isinstance(prop, Link) and not isinstance(prop, Multilink):
1613 raise TypeError, "'%s' not a Link/Multilink property"%propname
1615 # first, links
1616 where = []
1617 allvalues = ()
1618 a = self.db.arg
1619 for prop, values in propspec:
1620 if not isinstance(props[prop], hyperdb.Link):
1621 continue
1622 if type(values) is type(''):
1623 allvalues += (values,)
1624 where.append('_%s = %s'%(prop, a))
1625 else:
1626 allvalues += tuple(values.keys())
1627 where.append('_%s in (%s)'%(prop, ','.join([a]*len(values))))
1628 tables = []
1629 if where:
1630 tables.append('select id as nodeid from _%s where %s'%(
1631 self.classname, ' and '.join(where)))
1633 # now multilinks
1634 for prop, values in propspec:
1635 if not isinstance(props[prop], hyperdb.Multilink):
1636 continue
1637 if type(values) is type(''):
1638 allvalues += (values,)
1639 s = a
1640 else:
1641 allvalues += tuple(values.keys())
1642 s = ','.join([a]*len(values))
1643 tables.append('select nodeid from %s_%s where linkid in (%s)'%(
1644 self.classname, prop, s))
1645 sql = '\nunion\n'.join(tables)
1646 self.db.sql(sql, allvalues)
1647 l = [x[0] for x in self.db.sql_fetchall()]
1648 if __debug__:
1649 print >>hyperdb.DEBUG, 'find ... ', l
1650 return l
1652 def stringFind(self, **requirements):
1653 '''Locate a particular node by matching a set of its String
1654 properties in a caseless search.
1656 If the property is not a String property, a TypeError is raised.
1658 The return is a list of the id of all nodes that match.
1659 '''
1660 where = []
1661 args = []
1662 for propname in requirements.keys():
1663 prop = self.properties[propname]
1664 if isinstance(not prop, String):
1665 raise TypeError, "'%s' not a String property"%propname
1666 where.append(propname)
1667 args.append(requirements[propname].lower())
1669 # generate the where clause
1670 s = ' and '.join(['_%s=%s'%(col, self.db.arg) for col in where])
1671 sql = 'select id from _%s where %s'%(self.classname, s)
1672 self.db.sql(sql, tuple(args))
1673 l = [x[0] for x in self.db.sql_fetchall()]
1674 if __debug__:
1675 print >>hyperdb.DEBUG, 'find ... ', l
1676 return l
1678 def list(self):
1679 ''' Return a list of the ids of the active nodes in this class.
1680 '''
1681 return self.getnodeids(retired=0)
1683 def getnodeids(self, retired=None):
1684 ''' Retrieve all the ids of the nodes for a particular Class.
1686 Set retired=None to get all nodes. Otherwise it'll get all the
1687 retired or non-retired nodes, depending on the flag.
1688 '''
1689 # flip the sense of the flag if we don't want all of them
1690 if retired is not None:
1691 retired = not retired
1692 sql = 'select id from _%s where __retired__ <> %s'%(self.classname,
1693 self.db.arg)
1694 if __debug__:
1695 print >>hyperdb.DEBUG, 'getnodeids', (self, sql, retired)
1696 self.db.cursor.execute(sql, (retired,))
1697 return [x[0] for x in self.db.cursor.fetchall()]
1699 def filter(self, search_matches, filterspec, sort=(None,None),
1700 group=(None,None)):
1701 ''' Return a list of the ids of the active nodes in this class that
1702 match the 'filter' spec, sorted by the group spec and then the
1703 sort spec
1705 "filterspec" is {propname: value(s)}
1706 "sort" and "group" are (dir, prop) where dir is '+', '-' or None
1707 and prop is a prop name or None
1708 "search_matches" is {nodeid: marker}
1710 The filter must match all properties specificed - but if the
1711 property value to match is a list, any one of the values in the
1712 list may match for that property to match.
1713 '''
1714 # just don't bother if the full-text search matched diddly
1715 if search_matches == {}:
1716 return []
1718 cn = self.classname
1720 # figure the WHERE clause from the filterspec
1721 props = self.getprops()
1722 frum = ['_'+cn]
1723 where = []
1724 args = []
1725 a = self.db.arg
1726 for k, v in filterspec.items():
1727 propclass = props[k]
1728 # now do other where clause stuff
1729 if isinstance(propclass, Multilink):
1730 tn = '%s_%s'%(cn, k)
1731 frum.append(tn)
1732 if isinstance(v, type([])):
1733 s = ','.join([a for x in v])
1734 where.append('id=%s.nodeid and %s.linkid in (%s)'%(tn,tn,s))
1735 args = args + v
1736 else:
1737 where.append('id=%s.nodeid and %s.linkid = %s'%(tn, tn, a))
1738 args.append(v)
1739 elif k == 'id':
1740 if isinstance(v, type([])):
1741 s = ','.join([a for x in v])
1742 where.append('%s in (%s)'%(k, s))
1743 args = args + v
1744 else:
1745 where.append('%s=%s'%(k, a))
1746 args.append(v)
1747 elif isinstance(propclass, String):
1748 if not isinstance(v, type([])):
1749 v = [v]
1751 # Quote the bits in the string that need it and then embed
1752 # in a "substring" search. Note - need to quote the '%' so
1753 # they make it through the python layer happily
1754 v = ['%%'+self.db.sql_stringquote(s)+'%%' for s in v]
1756 # now add to the where clause
1757 where.append(' or '.join(["_%s LIKE '%s'"%(k, s) for s in v]))
1758 # note: args are embedded in the query string now
1759 elif isinstance(propclass, Link):
1760 if isinstance(v, type([])):
1761 if '-1' in v:
1762 v.remove('-1')
1763 xtra = ' or _%s is NULL'%k
1764 else:
1765 xtra = ''
1766 if v:
1767 s = ','.join([a for x in v])
1768 where.append('(_%s in (%s)%s)'%(k, s, xtra))
1769 args = args + v
1770 else:
1771 where.append('_%s is NULL'%k)
1772 else:
1773 if v == '-1':
1774 v = None
1775 where.append('_%s is NULL'%k)
1776 else:
1777 where.append('_%s=%s'%(k, a))
1778 args.append(v)
1779 elif isinstance(propclass, Date):
1780 if isinstance(v, type([])):
1781 s = ','.join([a for x in v])
1782 where.append('_%s in (%s)'%(k, s))
1783 args = args + [date.Date(x).serialise() for x in v]
1784 else:
1785 where.append('_%s=%s'%(k, a))
1786 args.append(date.Date(v).serialise())
1787 elif isinstance(propclass, Interval):
1788 if isinstance(v, type([])):
1789 s = ','.join([a for x in v])
1790 where.append('_%s in (%s)'%(k, s))
1791 args = args + [date.Interval(x).serialise() for x in v]
1792 else:
1793 where.append('_%s=%s'%(k, a))
1794 args.append(date.Interval(v).serialise())
1795 else:
1796 if isinstance(v, type([])):
1797 s = ','.join([a for x in v])
1798 where.append('_%s in (%s)'%(k, s))
1799 args = args + v
1800 else:
1801 where.append('_%s=%s'%(k, a))
1802 args.append(v)
1804 # add results of full text search
1805 if search_matches is not None:
1806 v = search_matches.keys()
1807 s = ','.join([a for x in v])
1808 where.append('id in (%s)'%s)
1809 args = args + v
1811 # "grouping" is just the first-order sorting in the SQL fetch
1812 # can modify it...)
1813 orderby = []
1814 ordercols = []
1815 if group[0] is not None and group[1] is not None:
1816 if group[0] != '-':
1817 orderby.append('_'+group[1])
1818 ordercols.append('_'+group[1])
1819 else:
1820 orderby.append('_'+group[1]+' desc')
1821 ordercols.append('_'+group[1])
1823 # now add in the sorting
1824 group = ''
1825 if sort[0] is not None and sort[1] is not None:
1826 direction, colname = sort
1827 if direction != '-':
1828 if colname == 'id':
1829 orderby.append(colname)
1830 else:
1831 orderby.append('_'+colname)
1832 ordercols.append('_'+colname)
1833 else:
1834 if colname == 'id':
1835 orderby.append(colname+' desc')
1836 ordercols.append(colname)
1837 else:
1838 orderby.append('_'+colname+' desc')
1839 ordercols.append('_'+colname)
1841 # construct the SQL
1842 frum = ','.join(frum)
1843 if where:
1844 where = ' where ' + (' and '.join(where))
1845 else:
1846 where = ''
1847 cols = ['id']
1848 if orderby:
1849 cols = cols + ordercols
1850 order = ' order by %s'%(','.join(orderby))
1851 else:
1852 order = ''
1853 cols = ','.join(cols)
1854 sql = 'select %s from %s %s%s%s'%(cols, frum, where, group, order)
1855 args = tuple(args)
1856 if __debug__:
1857 print >>hyperdb.DEBUG, 'filter', (self, sql, args)
1858 self.db.cursor.execute(sql, args)
1859 l = self.db.cursor.fetchall()
1861 # return the IDs (the first column)
1862 # XXX The filter(None, l) bit is sqlite-specific... if there's _NO_
1863 # XXX matches to a fetch, it returns NULL instead of nothing!?!
1864 return filter(None, [row[0] for row in l])
1866 def count(self):
1867 '''Get the number of nodes in this class.
1869 If the returned integer is 'numnodes', the ids of all the nodes
1870 in this class run from 1 to numnodes, and numnodes+1 will be the
1871 id of the next node to be created in this class.
1872 '''
1873 return self.db.countnodes(self.classname)
1875 # Manipulating properties:
1876 def getprops(self, protected=1):
1877 '''Return a dictionary mapping property names to property objects.
1878 If the "protected" flag is true, we include protected properties -
1879 those which may not be modified.
1880 '''
1881 d = self.properties.copy()
1882 if protected:
1883 d['id'] = String()
1884 d['creation'] = hyperdb.Date()
1885 d['activity'] = hyperdb.Date()
1886 d['creator'] = hyperdb.Link('user')
1887 return d
1889 def addprop(self, **properties):
1890 '''Add properties to this class.
1892 The keyword arguments in 'properties' must map names to property
1893 objects, or a TypeError is raised. None of the keys in 'properties'
1894 may collide with the names of existing properties, or a ValueError
1895 is raised before any properties have been added.
1896 '''
1897 for key in properties.keys():
1898 if self.properties.has_key(key):
1899 raise ValueError, key
1900 self.properties.update(properties)
1902 def index(self, nodeid):
1903 '''Add (or refresh) the node to search indexes
1904 '''
1905 # find all the String properties that have indexme
1906 for prop, propclass in self.getprops().items():
1907 if isinstance(propclass, String) and propclass.indexme:
1908 try:
1909 value = str(self.get(nodeid, prop))
1910 except IndexError:
1911 # node no longer exists - entry should be removed
1912 self.db.indexer.purge_entry((self.classname, nodeid, prop))
1913 else:
1914 # and index them under (classname, nodeid, property)
1915 self.db.indexer.add_text((self.classname, nodeid, prop),
1916 value)
1919 #
1920 # Detector interface
1921 #
1922 def audit(self, event, detector):
1923 '''Register a detector
1924 '''
1925 l = self.auditors[event]
1926 if detector not in l:
1927 self.auditors[event].append(detector)
1929 def fireAuditors(self, action, nodeid, newvalues):
1930 '''Fire all registered auditors.
1931 '''
1932 for audit in self.auditors[action]:
1933 audit(self.db, self, nodeid, newvalues)
1935 def react(self, event, detector):
1936 '''Register a detector
1937 '''
1938 l = self.reactors[event]
1939 if detector not in l:
1940 self.reactors[event].append(detector)
1942 def fireReactors(self, action, nodeid, oldvalues):
1943 '''Fire all registered reactors.
1944 '''
1945 for react in self.reactors[action]:
1946 react(self.db, self, nodeid, oldvalues)
1948 class FileClass(Class, hyperdb.FileClass):
1949 '''This class defines a large chunk of data. To support this, it has a
1950 mandatory String property "content" which is typically saved off
1951 externally to the hyperdb.
1953 The default MIME type of this data is defined by the
1954 "default_mime_type" class attribute, which may be overridden by each
1955 node if the class defines a "type" String property.
1956 '''
1957 default_mime_type = 'text/plain'
1959 def create(self, **propvalues):
1960 ''' snaffle the file propvalue and store in a file
1961 '''
1962 # we need to fire the auditors now, or the content property won't
1963 # be in propvalues for the auditors to play with
1964 self.fireAuditors('create', None, propvalues)
1966 # now remove the content property so it's not stored in the db
1967 content = propvalues['content']
1968 del propvalues['content']
1970 # do the database create
1971 newid = Class.create_inner(self, **propvalues)
1973 # fire reactors
1974 self.fireReactors('create', newid, None)
1976 # store off the content as a file
1977 self.db.storefile(self.classname, newid, None, content)
1978 return newid
1980 def import_list(self, propnames, proplist):
1981 ''' Trap the "content" property...
1982 '''
1983 # dupe this list so we don't affect others
1984 propnames = propnames[:]
1986 # extract the "content" property from the proplist
1987 i = propnames.index('content')
1988 content = eval(proplist[i])
1989 del propnames[i]
1990 del proplist[i]
1992 # do the normal import
1993 newid = Class.import_list(self, propnames, proplist)
1995 # save off the "content" file
1996 self.db.storefile(self.classname, newid, None, content)
1997 return newid
1999 _marker = []
2000 def get(self, nodeid, propname, default=_marker, cache=1):
2001 ''' trap the content propname and get it from the file
2002 '''
2003 poss_msg = 'Possibly a access right configuration problem.'
2004 if propname == 'content':
2005 try:
2006 return self.db.getfile(self.classname, nodeid, None)
2007 except IOError, (strerror):
2008 # BUG: by catching this we donot see an error in the log.
2009 return 'ERROR reading file: %s%s\n%s\n%s'%(
2010 self.classname, nodeid, poss_msg, strerror)
2011 if default is not self._marker:
2012 return Class.get(self, nodeid, propname, default, cache=cache)
2013 else:
2014 return Class.get(self, nodeid, propname, cache=cache)
2016 def getprops(self, protected=1):
2017 ''' In addition to the actual properties on the node, these methods
2018 provide the "content" property. If the "protected" flag is true,
2019 we include protected properties - those which may not be
2020 modified.
2021 '''
2022 d = Class.getprops(self, protected=protected).copy()
2023 d['content'] = hyperdb.String()
2024 return d
2026 def index(self, nodeid):
2027 ''' Index the node in the search index.
2029 We want to index the content in addition to the normal String
2030 property indexing.
2031 '''
2032 # perform normal indexing
2033 Class.index(self, nodeid)
2035 # get the content to index
2036 content = self.get(nodeid, 'content')
2038 # figure the mime type
2039 if self.properties.has_key('type'):
2040 mime_type = self.get(nodeid, 'type')
2041 else:
2042 mime_type = self.default_mime_type
2044 # and index!
2045 self.db.indexer.add_text((self.classname, nodeid, 'content'), content,
2046 mime_type)
2048 # XXX deviation from spec - was called ItemClass
2049 class IssueClass(Class, roundupdb.IssueClass):
2050 # Overridden methods:
2051 def __init__(self, db, classname, **properties):
2052 '''The newly-created class automatically includes the "messages",
2053 "files", "nosy", and "superseder" properties. If the 'properties'
2054 dictionary attempts to specify any of these properties or a
2055 "creation" or "activity" property, a ValueError is raised.
2056 '''
2057 if not properties.has_key('title'):
2058 properties['title'] = hyperdb.String(indexme='yes')
2059 if not properties.has_key('messages'):
2060 properties['messages'] = hyperdb.Multilink("msg")
2061 if not properties.has_key('files'):
2062 properties['files'] = hyperdb.Multilink("file")
2063 if not properties.has_key('nosy'):
2064 # note: journalling is turned off as it really just wastes
2065 # space. this behaviour may be overridden in an instance
2066 properties['nosy'] = hyperdb.Multilink("user", do_journal="no")
2067 if not properties.has_key('superseder'):
2068 properties['superseder'] = hyperdb.Multilink(classname)
2069 Class.__init__(self, db, classname, **properties)