1 # $Id: rdbms_common.py,v 1.38 2003-02-28 03:33:46 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 def doSaveNode(self, classname, nodeid, node):
841 ''' dummy that just generates a reindex event
842 '''
843 # return the classname, nodeid so we reindex this content
844 return (classname, nodeid)
846 def close(self):
847 ''' Close off the connection.
848 '''
849 self.conn.close()
850 if self.lockfile is not None:
851 locking.release_lock(self.lockfile)
852 if self.lockfile is not None:
853 self.lockfile.close()
854 self.lockfile = None
856 #
857 # The base Class class
858 #
859 class Class(hyperdb.Class):
860 ''' The handle to a particular class of nodes in a hyperdatabase.
862 All methods except __repr__ and getnode must be implemented by a
863 concrete backend Class.
864 '''
866 def __init__(self, db, classname, **properties):
867 '''Create a new class with a given name and property specification.
869 'classname' must not collide with the name of an existing class,
870 or a ValueError is raised. The keyword arguments in 'properties'
871 must map names to property objects, or a TypeError is raised.
872 '''
873 if (properties.has_key('creation') or properties.has_key('activity')
874 or properties.has_key('creator')):
875 raise ValueError, '"creation", "activity" and "creator" are '\
876 'reserved'
878 self.classname = classname
879 self.properties = properties
880 self.db = weakref.proxy(db) # use a weak ref to avoid circularity
881 self.key = ''
883 # should we journal changes (default yes)
884 self.do_journal = 1
886 # do the db-related init stuff
887 db.addclass(self)
889 self.auditors = {'create': [], 'set': [], 'retire': []}
890 self.reactors = {'create': [], 'set': [], 'retire': []}
892 def schema(self):
893 ''' A dumpable version of the schema that we can store in the
894 database
895 '''
896 return (self.key, [(x, repr(y)) for x,y in self.properties.items()])
898 def enableJournalling(self):
899 '''Turn journalling on for this class
900 '''
901 self.do_journal = 1
903 def disableJournalling(self):
904 '''Turn journalling off for this class
905 '''
906 self.do_journal = 0
908 # Editing nodes:
909 def create(self, **propvalues):
910 ''' Create a new node of this class and return its id.
912 The keyword arguments in 'propvalues' map property names to values.
914 The values of arguments must be acceptable for the types of their
915 corresponding properties or a TypeError is raised.
917 If this class has a key property, it must be present and its value
918 must not collide with other key strings or a ValueError is raised.
920 Any other properties on this class that are missing from the
921 'propvalues' dictionary are set to None.
923 If an id in a link or multilink property does not refer to a valid
924 node, an IndexError is raised.
925 '''
926 self.fireAuditors('create', None, propvalues)
927 newid = self.create_inner(**propvalues)
928 self.fireReactors('create', newid, None)
929 return newid
931 def create_inner(self, **propvalues):
932 ''' Called by create, in-between the audit and react calls.
933 '''
934 if propvalues.has_key('id'):
935 raise KeyError, '"id" is reserved'
937 if self.db.journaltag is None:
938 raise DatabaseError, 'Database open read-only'
940 if propvalues.has_key('creation') or propvalues.has_key('activity'):
941 raise KeyError, '"creation" and "activity" are reserved'
943 # new node's id
944 newid = self.db.newid(self.classname)
946 # validate propvalues
947 num_re = re.compile('^\d+$')
948 for key, value in propvalues.items():
949 if key == self.key:
950 try:
951 self.lookup(value)
952 except KeyError:
953 pass
954 else:
955 raise ValueError, 'node with key "%s" exists'%value
957 # try to handle this property
958 try:
959 prop = self.properties[key]
960 except KeyError:
961 raise KeyError, '"%s" has no property "%s"'%(self.classname,
962 key)
964 if value is not None and isinstance(prop, Link):
965 if type(value) != type(''):
966 raise ValueError, 'link value must be String'
967 link_class = self.properties[key].classname
968 # if it isn't a number, it's a key
969 if not num_re.match(value):
970 try:
971 value = self.db.classes[link_class].lookup(value)
972 except (TypeError, KeyError):
973 raise IndexError, 'new property "%s": %s not a %s'%(
974 key, value, link_class)
975 elif not self.db.getclass(link_class).hasnode(value):
976 raise IndexError, '%s has no node %s'%(link_class, value)
978 # save off the value
979 propvalues[key] = value
981 # register the link with the newly linked node
982 if self.do_journal and self.properties[key].do_journal:
983 self.db.addjournal(link_class, value, 'link',
984 (self.classname, newid, key))
986 elif isinstance(prop, Multilink):
987 if type(value) != type([]):
988 raise TypeError, 'new property "%s" not a list of ids'%key
990 # clean up and validate the list of links
991 link_class = self.properties[key].classname
992 l = []
993 for entry in value:
994 if type(entry) != type(''):
995 raise ValueError, '"%s" multilink value (%r) '\
996 'must contain Strings'%(key, value)
997 # if it isn't a number, it's a key
998 if not num_re.match(entry):
999 try:
1000 entry = self.db.classes[link_class].lookup(entry)
1001 except (TypeError, KeyError):
1002 raise IndexError, 'new property "%s": %s not a %s'%(
1003 key, entry, self.properties[key].classname)
1004 l.append(entry)
1005 value = l
1006 propvalues[key] = value
1008 # handle additions
1009 for nodeid in value:
1010 if not self.db.getclass(link_class).hasnode(nodeid):
1011 raise IndexError, '%s has no node %s'%(link_class,
1012 nodeid)
1013 # register the link with the newly linked node
1014 if self.do_journal and self.properties[key].do_journal:
1015 self.db.addjournal(link_class, nodeid, 'link',
1016 (self.classname, newid, key))
1018 elif isinstance(prop, String):
1019 if type(value) != type('') and type(value) != type(u''):
1020 raise TypeError, 'new property "%s" not a string'%key
1022 elif isinstance(prop, Password):
1023 if not isinstance(value, password.Password):
1024 raise TypeError, 'new property "%s" not a Password'%key
1026 elif isinstance(prop, Date):
1027 if value is not None and not isinstance(value, date.Date):
1028 raise TypeError, 'new property "%s" not a Date'%key
1030 elif isinstance(prop, Interval):
1031 if value is not None and not isinstance(value, date.Interval):
1032 raise TypeError, 'new property "%s" not an Interval'%key
1034 elif value is not None and isinstance(prop, Number):
1035 try:
1036 float(value)
1037 except ValueError:
1038 raise TypeError, 'new property "%s" not numeric'%key
1040 elif value is not None and isinstance(prop, Boolean):
1041 try:
1042 int(value)
1043 except ValueError:
1044 raise TypeError, 'new property "%s" not boolean'%key
1046 # make sure there's data where there needs to be
1047 for key, prop in self.properties.items():
1048 if propvalues.has_key(key):
1049 continue
1050 if key == self.key:
1051 raise ValueError, 'key property "%s" is required'%key
1052 if isinstance(prop, Multilink):
1053 propvalues[key] = []
1054 else:
1055 propvalues[key] = None
1057 # done
1058 self.db.addnode(self.classname, newid, propvalues)
1059 if self.do_journal:
1060 self.db.addjournal(self.classname, newid, 'create', {})
1062 return newid
1064 def export_list(self, propnames, nodeid):
1065 ''' Export a node - generate a list of CSV-able data in the order
1066 specified by propnames for the given node.
1067 '''
1068 properties = self.getprops()
1069 l = []
1070 for prop in propnames:
1071 proptype = properties[prop]
1072 value = self.get(nodeid, prop)
1073 # "marshal" data where needed
1074 if value is None:
1075 pass
1076 elif isinstance(proptype, hyperdb.Date):
1077 value = value.get_tuple()
1078 elif isinstance(proptype, hyperdb.Interval):
1079 value = value.get_tuple()
1080 elif isinstance(proptype, hyperdb.Password):
1081 value = str(value)
1082 l.append(repr(value))
1083 l.append(self.is_retired(nodeid))
1084 return l
1086 def import_list(self, propnames, proplist):
1087 ''' Import a node - all information including "id" is present and
1088 should not be sanity checked. Triggers are not triggered. The
1089 journal should be initialised using the "creator" and "created"
1090 information.
1092 Return the nodeid of the node imported.
1093 '''
1094 if self.db.journaltag is None:
1095 raise DatabaseError, 'Database open read-only'
1096 properties = self.getprops()
1098 # make the new node's property map
1099 d = {}
1100 for i in range(len(propnames)):
1101 # Use eval to reverse the repr() used to output the CSV
1102 value = eval(proplist[i])
1104 # Figure the property for this column
1105 propname = propnames[i]
1106 prop = properties[propname]
1108 # "unmarshal" where necessary
1109 if propname == 'id':
1110 newid = value
1111 continue
1112 elif value is None:
1113 # don't set Nones
1114 continue
1115 elif isinstance(prop, hyperdb.Date):
1116 value = date.Date(value)
1117 elif isinstance(prop, hyperdb.Interval):
1118 value = date.Interval(value)
1119 elif isinstance(prop, hyperdb.Password):
1120 pwd = password.Password()
1121 pwd.unpack(value)
1122 value = pwd
1123 d[propname] = value
1125 # retire?
1126 if int(proplist[-1]):
1127 # use the arg for __retired__ to cope with any odd database type
1128 # conversion (hello, sqlite)
1129 sql = 'update _%s set __retired__=%s where id=%s'%(self.classname,
1130 self.db.arg, self.db.arg)
1131 if __debug__:
1132 print >>hyperdb.DEBUG, 'retire', (self, sql, newid)
1133 self.db.cursor.execute(sql, (1, newid))
1135 # add the node and journal
1136 self.db.addnode(self.classname, newid, d)
1138 # extract the extraneous journalling gumpf and nuke it
1139 if d.has_key('creator'):
1140 creator = d['creator']
1141 del d['creator']
1142 else:
1143 creator = None
1144 if d.has_key('creation'):
1145 creation = d['creation']
1146 del d['creation']
1147 else:
1148 creation = None
1149 if d.has_key('activity'):
1150 del d['activity']
1151 self.db.addjournal(self.classname, newid, 'create', {}, creator,
1152 creation)
1153 return newid
1155 _marker = []
1156 def get(self, nodeid, propname, default=_marker, cache=1):
1157 '''Get the value of a property on an existing node of this class.
1159 'nodeid' must be the id of an existing node of this class or an
1160 IndexError is raised. 'propname' must be the name of a property
1161 of this class or a KeyError is raised.
1163 'cache' indicates whether the transaction cache should be queried
1164 for the node. If the node has been modified and you need to
1165 determine what its values prior to modification are, you need to
1166 set cache=0.
1167 '''
1168 if propname == 'id':
1169 return nodeid
1171 # get the node's dict
1172 d = self.db.getnode(self.classname, nodeid)
1174 if propname == 'creation':
1175 if d.has_key('creation'):
1176 return d['creation']
1177 else:
1178 return date.Date()
1179 if propname == 'activity':
1180 if d.has_key('activity'):
1181 return d['activity']
1182 else:
1183 return date.Date()
1184 if propname == 'creator':
1185 if d.has_key('creator'):
1186 return d['creator']
1187 else:
1188 return self.db.curuserid
1190 # get the property (raises KeyErorr if invalid)
1191 prop = self.properties[propname]
1193 if not d.has_key(propname):
1194 if default is self._marker:
1195 if isinstance(prop, Multilink):
1196 return []
1197 else:
1198 return None
1199 else:
1200 return default
1202 # don't pass our list to other code
1203 if isinstance(prop, Multilink):
1204 return d[propname][:]
1206 return d[propname]
1208 def getnode(self, nodeid, cache=1):
1209 ''' Return a convenience wrapper for the node.
1211 'nodeid' must be the id of an existing node of this class or an
1212 IndexError is raised.
1214 'cache' indicates whether the transaction cache should be queried
1215 for the node. If the node has been modified and you need to
1216 determine what its values prior to modification are, you need to
1217 set cache=0.
1218 '''
1219 return Node(self, nodeid, cache=cache)
1221 def set(self, nodeid, **propvalues):
1222 '''Modify a property on an existing node of this class.
1224 'nodeid' must be the id of an existing node of this class or an
1225 IndexError is raised.
1227 Each key in 'propvalues' must be the name of a property of this
1228 class or a KeyError is raised.
1230 All values in 'propvalues' must be acceptable types for their
1231 corresponding properties or a TypeError is raised.
1233 If the value of the key property is set, it must not collide with
1234 other key strings or a ValueError is raised.
1236 If the value of a Link or Multilink property contains an invalid
1237 node id, a ValueError is raised.
1238 '''
1239 if not propvalues:
1240 return propvalues
1242 if propvalues.has_key('creation') or propvalues.has_key('activity'):
1243 raise KeyError, '"creation" and "activity" are reserved'
1245 if propvalues.has_key('id'):
1246 raise KeyError, '"id" is reserved'
1248 if self.db.journaltag is None:
1249 raise DatabaseError, 'Database open read-only'
1251 self.fireAuditors('set', nodeid, propvalues)
1252 # Take a copy of the node dict so that the subsequent set
1253 # operation doesn't modify the oldvalues structure.
1254 # XXX used to try the cache here first
1255 oldvalues = copy.deepcopy(self.db.getnode(self.classname, nodeid))
1257 node = self.db.getnode(self.classname, nodeid)
1258 if self.is_retired(nodeid):
1259 raise IndexError, 'Requested item is retired'
1260 num_re = re.compile('^\d+$')
1262 # if the journal value is to be different, store it in here
1263 journalvalues = {}
1265 # remember the add/remove stuff for multilinks, making it easier
1266 # for the Database layer to do its stuff
1267 multilink_changes = {}
1269 for propname, value in propvalues.items():
1270 # check to make sure we're not duplicating an existing key
1271 if propname == self.key and node[propname] != value:
1272 try:
1273 self.lookup(value)
1274 except KeyError:
1275 pass
1276 else:
1277 raise ValueError, 'node with key "%s" exists'%value
1279 # this will raise the KeyError if the property isn't valid
1280 # ... we don't use getprops() here because we only care about
1281 # the writeable properties.
1282 try:
1283 prop = self.properties[propname]
1284 except KeyError:
1285 raise KeyError, '"%s" has no property named "%s"'%(
1286 self.classname, propname)
1288 # if the value's the same as the existing value, no sense in
1289 # doing anything
1290 current = node.get(propname, None)
1291 if value == current:
1292 del propvalues[propname]
1293 continue
1294 journalvalues[propname] = current
1296 # do stuff based on the prop type
1297 if isinstance(prop, Link):
1298 link_class = prop.classname
1299 # if it isn't a number, it's a key
1300 if value is not None and not isinstance(value, type('')):
1301 raise ValueError, 'property "%s" link value be a string'%(
1302 propname)
1303 if isinstance(value, type('')) and not num_re.match(value):
1304 try:
1305 value = self.db.classes[link_class].lookup(value)
1306 except (TypeError, KeyError):
1307 raise IndexError, 'new property "%s": %s not a %s'%(
1308 propname, value, prop.classname)
1310 if (value is not None and
1311 not self.db.getclass(link_class).hasnode(value)):
1312 raise IndexError, '%s has no node %s'%(link_class, value)
1314 if self.do_journal and prop.do_journal:
1315 # register the unlink with the old linked node
1316 if node[propname] is not None:
1317 self.db.addjournal(link_class, node[propname], 'unlink',
1318 (self.classname, nodeid, propname))
1320 # register the link with the newly linked node
1321 if value is not None:
1322 self.db.addjournal(link_class, value, 'link',
1323 (self.classname, nodeid, propname))
1325 elif isinstance(prop, Multilink):
1326 if type(value) != type([]):
1327 raise TypeError, 'new property "%s" not a list of'\
1328 ' ids'%propname
1329 link_class = self.properties[propname].classname
1330 l = []
1331 for entry in value:
1332 # if it isn't a number, it's a key
1333 if type(entry) != type(''):
1334 raise ValueError, 'new property "%s" link value ' \
1335 'must be a string'%propname
1336 if not num_re.match(entry):
1337 try:
1338 entry = self.db.classes[link_class].lookup(entry)
1339 except (TypeError, KeyError):
1340 raise IndexError, 'new property "%s": %s not a %s'%(
1341 propname, entry,
1342 self.properties[propname].classname)
1343 l.append(entry)
1344 value = l
1345 propvalues[propname] = value
1347 # figure the journal entry for this property
1348 add = []
1349 remove = []
1351 # handle removals
1352 if node.has_key(propname):
1353 l = node[propname]
1354 else:
1355 l = []
1356 for id in l[:]:
1357 if id in value:
1358 continue
1359 # register the unlink with the old linked node
1360 if self.do_journal and self.properties[propname].do_journal:
1361 self.db.addjournal(link_class, id, 'unlink',
1362 (self.classname, nodeid, propname))
1363 l.remove(id)
1364 remove.append(id)
1366 # handle additions
1367 for id in value:
1368 if not self.db.getclass(link_class).hasnode(id):
1369 raise IndexError, '%s has no node %s'%(link_class, id)
1370 if id in l:
1371 continue
1372 # register the link with the newly linked node
1373 if self.do_journal and self.properties[propname].do_journal:
1374 self.db.addjournal(link_class, id, 'link',
1375 (self.classname, nodeid, propname))
1376 l.append(id)
1377 add.append(id)
1379 # figure the journal entry
1380 l = []
1381 if add:
1382 l.append(('+', add))
1383 if remove:
1384 l.append(('-', remove))
1385 multilink_changes[propname] = (add, remove)
1386 if l:
1387 journalvalues[propname] = tuple(l)
1389 elif isinstance(prop, String):
1390 if value is not None and type(value) != type('') and type(value) != type(u''):
1391 raise TypeError, 'new property "%s" not a string'%propname
1393 elif isinstance(prop, Password):
1394 if not isinstance(value, password.Password):
1395 raise TypeError, 'new property "%s" not a Password'%propname
1396 propvalues[propname] = value
1398 elif value is not None and isinstance(prop, Date):
1399 if not isinstance(value, date.Date):
1400 raise TypeError, 'new property "%s" not a Date'% propname
1401 propvalues[propname] = value
1403 elif value is not None and isinstance(prop, Interval):
1404 if not isinstance(value, date.Interval):
1405 raise TypeError, 'new property "%s" not an '\
1406 'Interval'%propname
1407 propvalues[propname] = value
1409 elif value is not None and isinstance(prop, Number):
1410 try:
1411 float(value)
1412 except ValueError:
1413 raise TypeError, 'new property "%s" not numeric'%propname
1415 elif value is not None and isinstance(prop, Boolean):
1416 try:
1417 int(value)
1418 except ValueError:
1419 raise TypeError, 'new property "%s" not boolean'%propname
1421 # nothing to do?
1422 if not propvalues:
1423 return propvalues
1425 # do the set, and journal it
1426 self.db.setnode(self.classname, nodeid, propvalues, multilink_changes)
1428 if self.do_journal:
1429 self.db.addjournal(self.classname, nodeid, 'set', journalvalues)
1431 self.fireReactors('set', nodeid, oldvalues)
1433 return propvalues
1435 def retire(self, nodeid):
1436 '''Retire a node.
1438 The properties on the node remain available from the get() method,
1439 and the node's id is never reused.
1441 Retired nodes are not returned by the find(), list(), or lookup()
1442 methods, and other nodes may reuse the values of their key properties.
1443 '''
1444 if self.db.journaltag is None:
1445 raise DatabaseError, 'Database open read-only'
1447 self.fireAuditors('retire', nodeid, None)
1449 # use the arg for __retired__ to cope with any odd database type
1450 # conversion (hello, sqlite)
1451 sql = 'update _%s set __retired__=%s where id=%s'%(self.classname,
1452 self.db.arg, self.db.arg)
1453 if __debug__:
1454 print >>hyperdb.DEBUG, 'retire', (self, sql, nodeid)
1455 self.db.cursor.execute(sql, (1, nodeid))
1457 self.fireReactors('retire', nodeid, None)
1459 def is_retired(self, nodeid):
1460 '''Return true if the node is rerired
1461 '''
1462 sql = 'select __retired__ from _%s where id=%s'%(self.classname,
1463 self.db.arg)
1464 if __debug__:
1465 print >>hyperdb.DEBUG, 'is_retired', (self, sql, nodeid)
1466 self.db.cursor.execute(sql, (nodeid,))
1467 return int(self.db.sql_fetchone()[0])
1469 def destroy(self, nodeid):
1470 '''Destroy a node.
1472 WARNING: this method should never be used except in extremely rare
1473 situations where there could never be links to the node being
1474 deleted
1475 WARNING: use retire() instead
1476 WARNING: the properties of this node will not be available ever again
1477 WARNING: really, use retire() instead
1479 Well, I think that's enough warnings. This method exists mostly to
1480 support the session storage of the cgi interface.
1482 The node is completely removed from the hyperdb, including all journal
1483 entries. It will no longer be available, and will generally break code
1484 if there are any references to the node.
1485 '''
1486 if self.db.journaltag is None:
1487 raise DatabaseError, 'Database open read-only'
1488 self.db.destroynode(self.classname, nodeid)
1490 def history(self, nodeid):
1491 '''Retrieve the journal of edits on a particular node.
1493 'nodeid' must be the id of an existing node of this class or an
1494 IndexError is raised.
1496 The returned list contains tuples of the form
1498 (nodeid, date, tag, action, params)
1500 'date' is a Timestamp object specifying the time of the change and
1501 'tag' is the journaltag specified when the database was opened.
1502 '''
1503 if not self.do_journal:
1504 raise ValueError, 'Journalling is disabled for this class'
1505 return self.db.getjournal(self.classname, nodeid)
1507 # Locating nodes:
1508 def hasnode(self, nodeid):
1509 '''Determine if the given nodeid actually exists
1510 '''
1511 return self.db.hasnode(self.classname, nodeid)
1513 def setkey(self, propname):
1514 '''Select a String property of this class to be the key property.
1516 'propname' must be the name of a String property of this class or
1517 None, or a TypeError is raised. The values of the key property on
1518 all existing nodes must be unique or a ValueError is raised.
1519 '''
1520 # XXX create an index on the key prop column
1521 prop = self.getprops()[propname]
1522 if not isinstance(prop, String):
1523 raise TypeError, 'key properties must be String'
1524 self.key = propname
1526 def getkey(self):
1527 '''Return the name of the key property for this class or None.'''
1528 return self.key
1530 def labelprop(self, default_to_id=0):
1531 ''' Return the property name for a label for the given node.
1533 This method attempts to generate a consistent label for the node.
1534 It tries the following in order:
1535 1. key property
1536 2. "name" property
1537 3. "title" property
1538 4. first property from the sorted property name list
1539 '''
1540 k = self.getkey()
1541 if k:
1542 return k
1543 props = self.getprops()
1544 if props.has_key('name'):
1545 return 'name'
1546 elif props.has_key('title'):
1547 return 'title'
1548 if default_to_id:
1549 return 'id'
1550 props = props.keys()
1551 props.sort()
1552 return props[0]
1554 def lookup(self, keyvalue):
1555 '''Locate a particular node by its key property and return its id.
1557 If this class has no key property, a TypeError is raised. If the
1558 'keyvalue' matches one of the values for the key property among
1559 the nodes in this class, the matching node's id is returned;
1560 otherwise a KeyError is raised.
1561 '''
1562 if not self.key:
1563 raise TypeError, 'No key property set for class %s'%self.classname
1565 # use the arg to handle any odd database type conversion (hello,
1566 # sqlite)
1567 sql = "select id from _%s where _%s=%s and __retired__ <> %s"%(
1568 self.classname, self.key, self.db.arg, self.db.arg)
1569 self.db.sql(sql, (keyvalue, 1))
1571 # see if there was a result that's not retired
1572 row = self.db.sql_fetchone()
1573 if not row:
1574 raise KeyError, 'No key (%s) value "%s" for "%s"'%(self.key,
1575 keyvalue, self.classname)
1577 # return the id
1578 return row[0]
1580 def find(self, **propspec):
1581 '''Get the ids of nodes in this class which link to the given nodes.
1583 'propspec' consists of keyword args propname=nodeid or
1584 propname={nodeid:1, }
1585 'propname' must be the name of a property in this class, or a
1586 KeyError is raised. That property must be a Link or Multilink
1587 property, or a TypeError is raised.
1589 Any node in this class whose 'propname' property links to any of the
1590 nodeids will be returned. Used by the full text indexing, which knows
1591 that "foo" occurs in msg1, msg3 and file7, so we have hits on these
1592 issues:
1594 db.issue.find(messages={'1':1,'3':1}, files={'7':1})
1595 '''
1596 if __debug__:
1597 print >>hyperdb.DEBUG, 'find', (self, propspec)
1599 # shortcut
1600 if not propspec:
1601 return []
1603 # validate the args
1604 props = self.getprops()
1605 propspec = propspec.items()
1606 for propname, nodeids in propspec:
1607 # check the prop is OK
1608 prop = props[propname]
1609 if not isinstance(prop, Link) and not isinstance(prop, Multilink):
1610 raise TypeError, "'%s' not a Link/Multilink property"%propname
1612 # first, links
1613 where = []
1614 allvalues = ()
1615 a = self.db.arg
1616 for prop, values in propspec:
1617 if not isinstance(props[prop], hyperdb.Link):
1618 continue
1619 if type(values) is type(''):
1620 allvalues += (values,)
1621 where.append('_%s = %s'%(prop, a))
1622 else:
1623 allvalues += tuple(values.keys())
1624 where.append('_%s in (%s)'%(prop, ','.join([a]*len(values))))
1625 tables = []
1626 if where:
1627 tables.append('select id as nodeid from _%s where %s'%(
1628 self.classname, ' and '.join(where)))
1630 # now multilinks
1631 for prop, values in propspec:
1632 if not isinstance(props[prop], hyperdb.Multilink):
1633 continue
1634 if type(values) is type(''):
1635 allvalues += (values,)
1636 s = a
1637 else:
1638 allvalues += tuple(values.keys())
1639 s = ','.join([a]*len(values))
1640 tables.append('select nodeid from %s_%s where linkid in (%s)'%(
1641 self.classname, prop, s))
1642 sql = '\nunion\n'.join(tables)
1643 self.db.sql(sql, allvalues)
1644 l = [x[0] for x in self.db.sql_fetchall()]
1645 if __debug__:
1646 print >>hyperdb.DEBUG, 'find ... ', l
1647 return l
1649 def stringFind(self, **requirements):
1650 '''Locate a particular node by matching a set of its String
1651 properties in a caseless search.
1653 If the property is not a String property, a TypeError is raised.
1655 The return is a list of the id of all nodes that match.
1656 '''
1657 where = []
1658 args = []
1659 for propname in requirements.keys():
1660 prop = self.properties[propname]
1661 if isinstance(not prop, String):
1662 raise TypeError, "'%s' not a String property"%propname
1663 where.append(propname)
1664 args.append(requirements[propname].lower())
1666 # generate the where clause
1667 s = ' and '.join(['_%s=%s'%(col, self.db.arg) for col in where])
1668 sql = 'select id from _%s where %s'%(self.classname, s)
1669 self.db.sql(sql, tuple(args))
1670 l = [x[0] for x in self.db.sql_fetchall()]
1671 if __debug__:
1672 print >>hyperdb.DEBUG, 'find ... ', l
1673 return l
1675 def list(self):
1676 ''' Return a list of the ids of the active nodes in this class.
1677 '''
1678 return self.getnodeids(retired=0)
1680 def getnodeids(self, retired=None):
1681 ''' Retrieve all the ids of the nodes for a particular Class.
1683 Set retired=None to get all nodes. Otherwise it'll get all the
1684 retired or non-retired nodes, depending on the flag.
1685 '''
1686 # flip the sense of the flag if we don't want all of them
1687 if retired is not None:
1688 retired = not retired
1689 sql = 'select id from _%s where __retired__ <> %s'%(self.classname,
1690 self.db.arg)
1691 if __debug__:
1692 print >>hyperdb.DEBUG, 'getnodeids', (self, sql, retired)
1693 self.db.cursor.execute(sql, (retired,))
1694 return [x[0] for x in self.db.cursor.fetchall()]
1696 def filter(self, search_matches, filterspec, sort=(None,None),
1697 group=(None,None)):
1698 ''' Return a list of the ids of the active nodes in this class that
1699 match the 'filter' spec, sorted by the group spec and then the
1700 sort spec
1702 "filterspec" is {propname: value(s)}
1703 "sort" and "group" are (dir, prop) where dir is '+', '-' or None
1704 and prop is a prop name or None
1705 "search_matches" is {nodeid: marker}
1707 The filter must match all properties specificed - but if the
1708 property value to match is a list, any one of the values in the
1709 list may match for that property to match.
1710 '''
1711 # just don't bother if the full-text search matched diddly
1712 if search_matches == {}:
1713 return []
1715 cn = self.classname
1717 # figure the WHERE clause from the filterspec
1718 props = self.getprops()
1719 frum = ['_'+cn]
1720 where = []
1721 args = []
1722 a = self.db.arg
1723 for k, v in filterspec.items():
1724 propclass = props[k]
1725 # now do other where clause stuff
1726 if isinstance(propclass, Multilink):
1727 tn = '%s_%s'%(cn, k)
1728 frum.append(tn)
1729 if isinstance(v, type([])):
1730 s = ','.join([a for x in v])
1731 where.append('id=%s.nodeid and %s.linkid in (%s)'%(tn,tn,s))
1732 args = args + v
1733 else:
1734 where.append('id=%s.nodeid and %s.linkid = %s'%(tn, tn, a))
1735 args.append(v)
1736 elif k == 'id':
1737 if isinstance(v, type([])):
1738 s = ','.join([a for x in v])
1739 where.append('%s in (%s)'%(k, s))
1740 args = args + v
1741 else:
1742 where.append('%s=%s'%(k, a))
1743 args.append(v)
1744 elif isinstance(propclass, String):
1745 if not isinstance(v, type([])):
1746 v = [v]
1748 # Quote the bits in the string that need it and then embed
1749 # in a "substring" search. Note - need to quote the '%' so
1750 # they make it through the python layer happily
1751 v = ['%%'+self.db.sql_stringquote(s)+'%%' for s in v]
1753 # now add to the where clause
1754 where.append(' or '.join(["_%s LIKE '%s'"%(k, s) for s in v]))
1755 # note: args are embedded in the query string now
1756 elif isinstance(propclass, Link):
1757 if isinstance(v, type([])):
1758 if '-1' in v:
1759 v.remove('-1')
1760 xtra = ' or _%s is NULL'%k
1761 else:
1762 xtra = ''
1763 if v:
1764 s = ','.join([a for x in v])
1765 where.append('(_%s in (%s)%s)'%(k, s, xtra))
1766 args = args + v
1767 else:
1768 where.append('_%s is NULL'%k)
1769 else:
1770 if v == '-1':
1771 v = None
1772 where.append('_%s is NULL'%k)
1773 else:
1774 where.append('_%s=%s'%(k, a))
1775 args.append(v)
1776 elif isinstance(propclass, Date):
1777 if isinstance(v, type([])):
1778 s = ','.join([a for x in v])
1779 where.append('_%s in (%s)'%(k, s))
1780 args = args + [date.Date(x).serialise() for x in v]
1781 else:
1782 where.append('_%s=%s'%(k, a))
1783 args.append(date.Date(v).serialise())
1784 elif isinstance(propclass, Interval):
1785 if isinstance(v, type([])):
1786 s = ','.join([a for x in v])
1787 where.append('_%s in (%s)'%(k, s))
1788 args = args + [date.Interval(x).serialise() for x in v]
1789 else:
1790 where.append('_%s=%s'%(k, a))
1791 args.append(date.Interval(v).serialise())
1792 else:
1793 if isinstance(v, type([])):
1794 s = ','.join([a for x in v])
1795 where.append('_%s in (%s)'%(k, s))
1796 args = args + v
1797 else:
1798 where.append('_%s=%s'%(k, a))
1799 args.append(v)
1801 # add results of full text search
1802 if search_matches is not None:
1803 v = search_matches.keys()
1804 s = ','.join([a for x in v])
1805 where.append('id in (%s)'%s)
1806 args = args + v
1808 # "grouping" is just the first-order sorting in the SQL fetch
1809 # can modify it...)
1810 orderby = []
1811 ordercols = []
1812 if group[0] is not None and group[1] is not None:
1813 if group[0] != '-':
1814 orderby.append('_'+group[1])
1815 ordercols.append('_'+group[1])
1816 else:
1817 orderby.append('_'+group[1]+' desc')
1818 ordercols.append('_'+group[1])
1820 # now add in the sorting
1821 group = ''
1822 if sort[0] is not None and sort[1] is not None:
1823 direction, colname = sort
1824 if direction != '-':
1825 if colname == 'id':
1826 orderby.append(colname)
1827 else:
1828 orderby.append('_'+colname)
1829 ordercols.append('_'+colname)
1830 else:
1831 if colname == 'id':
1832 orderby.append(colname+' desc')
1833 ordercols.append(colname)
1834 else:
1835 orderby.append('_'+colname+' desc')
1836 ordercols.append('_'+colname)
1838 # construct the SQL
1839 frum = ','.join(frum)
1840 if where:
1841 where = ' where ' + (' and '.join(where))
1842 else:
1843 where = ''
1844 cols = ['id']
1845 if orderby:
1846 cols = cols + ordercols
1847 order = ' order by %s'%(','.join(orderby))
1848 else:
1849 order = ''
1850 cols = ','.join(cols)
1851 sql = 'select %s from %s %s%s%s'%(cols, frum, where, group, order)
1852 args = tuple(args)
1853 if __debug__:
1854 print >>hyperdb.DEBUG, 'filter', (self, sql, args)
1855 self.db.cursor.execute(sql, args)
1856 l = self.db.cursor.fetchall()
1858 # return the IDs (the first column)
1859 # XXX The filter(None, l) bit is sqlite-specific... if there's _NO_
1860 # XXX matches to a fetch, it returns NULL instead of nothing!?!
1861 return filter(None, [row[0] for row in l])
1863 def count(self):
1864 '''Get the number of nodes in this class.
1866 If the returned integer is 'numnodes', the ids of all the nodes
1867 in this class run from 1 to numnodes, and numnodes+1 will be the
1868 id of the next node to be created in this class.
1869 '''
1870 return self.db.countnodes(self.classname)
1872 # Manipulating properties:
1873 def getprops(self, protected=1):
1874 '''Return a dictionary mapping property names to property objects.
1875 If the "protected" flag is true, we include protected properties -
1876 those which may not be modified.
1877 '''
1878 d = self.properties.copy()
1879 if protected:
1880 d['id'] = String()
1881 d['creation'] = hyperdb.Date()
1882 d['activity'] = hyperdb.Date()
1883 d['creator'] = hyperdb.Link('user')
1884 return d
1886 def addprop(self, **properties):
1887 '''Add properties to this class.
1889 The keyword arguments in 'properties' must map names to property
1890 objects, or a TypeError is raised. None of the keys in 'properties'
1891 may collide with the names of existing properties, or a ValueError
1892 is raised before any properties have been added.
1893 '''
1894 for key in properties.keys():
1895 if self.properties.has_key(key):
1896 raise ValueError, key
1897 self.properties.update(properties)
1899 def index(self, nodeid):
1900 '''Add (or refresh) the node to search indexes
1901 '''
1902 # find all the String properties that have indexme
1903 for prop, propclass in self.getprops().items():
1904 if isinstance(propclass, String) and propclass.indexme:
1905 try:
1906 value = str(self.get(nodeid, prop))
1907 except IndexError:
1908 # node no longer exists - entry should be removed
1909 self.db.indexer.purge_entry((self.classname, nodeid, prop))
1910 else:
1911 # and index them under (classname, nodeid, property)
1912 self.db.indexer.add_text((self.classname, nodeid, prop),
1913 value)
1916 #
1917 # Detector interface
1918 #
1919 def audit(self, event, detector):
1920 '''Register a detector
1921 '''
1922 l = self.auditors[event]
1923 if detector not in l:
1924 self.auditors[event].append(detector)
1926 def fireAuditors(self, action, nodeid, newvalues):
1927 '''Fire all registered auditors.
1928 '''
1929 for audit in self.auditors[action]:
1930 audit(self.db, self, nodeid, newvalues)
1932 def react(self, event, detector):
1933 '''Register a detector
1934 '''
1935 l = self.reactors[event]
1936 if detector not in l:
1937 self.reactors[event].append(detector)
1939 def fireReactors(self, action, nodeid, oldvalues):
1940 '''Fire all registered reactors.
1941 '''
1942 for react in self.reactors[action]:
1943 react(self.db, self, nodeid, oldvalues)
1945 class FileClass(Class, hyperdb.FileClass):
1946 '''This class defines a large chunk of data. To support this, it has a
1947 mandatory String property "content" which is typically saved off
1948 externally to the hyperdb.
1950 The default MIME type of this data is defined by the
1951 "default_mime_type" class attribute, which may be overridden by each
1952 node if the class defines a "type" String property.
1953 '''
1954 default_mime_type = 'text/plain'
1956 def create(self, **propvalues):
1957 ''' snaffle the file propvalue and store in a file
1958 '''
1959 # we need to fire the auditors now, or the content property won't
1960 # be in propvalues for the auditors to play with
1961 self.fireAuditors('create', None, propvalues)
1963 # now remove the content property so it's not stored in the db
1964 content = propvalues['content']
1965 del propvalues['content']
1967 # do the database create
1968 newid = Class.create_inner(self, **propvalues)
1970 # fire reactors
1971 self.fireReactors('create', newid, None)
1973 # store off the content as a file
1974 self.db.storefile(self.classname, newid, None, content)
1975 return newid
1977 def import_list(self, propnames, proplist):
1978 ''' Trap the "content" property...
1979 '''
1980 # dupe this list so we don't affect others
1981 propnames = propnames[:]
1983 # extract the "content" property from the proplist
1984 i = propnames.index('content')
1985 content = eval(proplist[i])
1986 del propnames[i]
1987 del proplist[i]
1989 # do the normal import
1990 newid = Class.import_list(self, propnames, proplist)
1992 # save off the "content" file
1993 self.db.storefile(self.classname, newid, None, content)
1994 return newid
1996 _marker = []
1997 def get(self, nodeid, propname, default=_marker, cache=1):
1998 ''' trap the content propname and get it from the file
1999 '''
2000 poss_msg = 'Possibly a access right configuration problem.'
2001 if propname == 'content':
2002 try:
2003 return self.db.getfile(self.classname, nodeid, None)
2004 except IOError, (strerror):
2005 # BUG: by catching this we donot see an error in the log.
2006 return 'ERROR reading file: %s%s\n%s\n%s'%(
2007 self.classname, nodeid, poss_msg, strerror)
2008 if default is not self._marker:
2009 return Class.get(self, nodeid, propname, default, cache=cache)
2010 else:
2011 return Class.get(self, nodeid, propname, cache=cache)
2013 def getprops(self, protected=1):
2014 ''' In addition to the actual properties on the node, these methods
2015 provide the "content" property. If the "protected" flag is true,
2016 we include protected properties - those which may not be
2017 modified.
2018 '''
2019 d = Class.getprops(self, protected=protected).copy()
2020 d['content'] = hyperdb.String()
2021 return d
2023 def index(self, nodeid):
2024 ''' Index the node in the search index.
2026 We want to index the content in addition to the normal String
2027 property indexing.
2028 '''
2029 # perform normal indexing
2030 Class.index(self, nodeid)
2032 # get the content to index
2033 content = self.get(nodeid, 'content')
2035 # figure the mime type
2036 if self.properties.has_key('type'):
2037 mime_type = self.get(nodeid, 'type')
2038 else:
2039 mime_type = self.default_mime_type
2041 # and index!
2042 self.db.indexer.add_text((self.classname, nodeid, 'content'), content,
2043 mime_type)
2045 # XXX deviation from spec - was called ItemClass
2046 class IssueClass(Class, roundupdb.IssueClass):
2047 # Overridden methods:
2048 def __init__(self, db, classname, **properties):
2049 '''The newly-created class automatically includes the "messages",
2050 "files", "nosy", and "superseder" properties. If the 'properties'
2051 dictionary attempts to specify any of these properties or a
2052 "creation" or "activity" property, a ValueError is raised.
2053 '''
2054 if not properties.has_key('title'):
2055 properties['title'] = hyperdb.String(indexme='yes')
2056 if not properties.has_key('messages'):
2057 properties['messages'] = hyperdb.Multilink("msg")
2058 if not properties.has_key('files'):
2059 properties['files'] = hyperdb.Multilink("file")
2060 if not properties.has_key('nosy'):
2061 # note: journalling is turned off as it really just wastes
2062 # space. this behaviour may be overridden in an instance
2063 properties['nosy'] = hyperdb.Multilink("user", do_journal="no")
2064 if not properties.has_key('superseder'):
2065 properties['superseder'] = hyperdb.Multilink(classname)
2066 Class.__init__(self, db, classname, **properties)