1 # $Id: back_gadfly.py,v 1.20 2002-09-15 23:06:20 richard Exp $
2 __doc__ = '''
3 About Gadfly
4 ============
6 Gadfly is a collection of python modules that provides relational
7 database functionality entirely implemented in Python. It supports a
8 subset of the intergalactic standard RDBMS Structured Query Language
9 SQL.
12 Basic Structure
13 ===============
15 We map roundup classes to relational tables. Automatically detect schema
16 changes and modify the gadfly table schemas appropriately. Multilinks
17 (which represent a many-to-many relationship) are handled through
18 intermediate tables.
20 Journals are stored adjunct to the per-class tables.
22 Table names and columns have "_" prepended so the names can't
23 clash with restricted names (like "order"). Retirement is determined by the
24 __retired__ column being true.
26 All columns are defined as VARCHAR, since it really doesn't matter what
27 type they're defined as. We stuff all kinds of data in there ;) [as long as
28 it's marshallable, gadfly doesn't care]
31 Additional Instance Requirements
32 ================================
34 The instance configuration must specify where the database is. It does this
35 with GADFLY_DATABASE, which is used as the arguments to the gadfly.gadfly()
36 method:
38 Using an on-disk database directly (not a good idea):
39 GADFLY_DATABASE = (database name, directory)
41 Using a network database (much better idea):
42 GADFLY_DATABASE = (policy, password, address, port)
44 Because multiple accesses directly to a gadfly database aren't handled, but
45 multiple network accesses are, it's strongly advised that the latter setup be
46 used.
48 '''
50 # standard python modules
51 import sys, os, time, re, errno, weakref, copy
53 # roundup modules
54 from roundup import hyperdb, date, password, roundupdb, security
55 from roundup.hyperdb import String, Password, Date, Interval, Link, \
56 Multilink, DatabaseError, Boolean, Number
58 # the all-important gadfly :)
59 import gadfly
60 import gadfly.client
61 import gadfly.database
63 # support
64 from blobfiles import FileStorage
65 from roundup.indexer import Indexer
66 from sessions import Sessions
68 class Database(FileStorage, hyperdb.Database, roundupdb.Database):
69 # flag to set on retired entries
70 RETIRED_FLAG = '__hyperdb_retired'
72 def __init__(self, config, journaltag=None):
73 ''' Open the database and load the schema from it.
74 '''
75 self.config, self.journaltag = config, journaltag
76 self.dir = config.DATABASE
77 self.classes = {}
78 self.indexer = Indexer(self.dir)
79 self.sessions = Sessions(self.config)
80 self.security = security.Security(self)
82 # additional transaction support for external files and the like
83 self.transactions = []
85 db = getattr(config, 'GADFLY_DATABASE', ('database', self.dir))
86 if len(db) == 2:
87 # ensure files are group readable and writable
88 os.umask(0002)
89 try:
90 self.conn = gadfly.gadfly(*db)
91 except IOError, error:
92 if error.errno != errno.ENOENT:
93 raise
94 self.database_schema = {}
95 self.conn = gadfly.gadfly()
96 self.conn.startup(*db)
97 cursor = self.conn.cursor()
98 cursor.execute('create table schema (schema varchar)')
99 cursor.execute('create table ids (name varchar, num integer)')
100 else:
101 cursor = self.conn.cursor()
102 cursor.execute('select schema from schema')
103 self.database_schema = cursor.fetchone()[0]
104 else:
105 self.conn = gadfly.client.gfclient(*db)
106 cursor = self.conn.cursor()
107 cursor.execute('select schema from schema')
108 self.database_schema = cursor.fetchone()[0]
110 def __repr__(self):
111 return '<roundfly 0x%x>'%id(self)
113 def post_init(self):
114 ''' Called once the schema initialisation has finished.
116 We should now confirm that the schema defined by our "classes"
117 attribute actually matches the schema in the database.
118 '''
119 # now detect changes in the schema
120 for classname, spec in self.classes.items():
121 if self.database_schema.has_key(classname):
122 dbspec = self.database_schema[classname]
123 self.update_class(spec, dbspec)
124 self.database_schema[classname] = spec.schema()
125 else:
126 self.create_class(spec)
127 self.database_schema[classname] = spec.schema()
129 for classname in self.database_schema.keys():
130 if not self.classes.has_key(classname):
131 self.drop_class(classname)
133 # update the database version of the schema
134 cursor = self.conn.cursor()
135 cursor.execute('delete from schema')
136 cursor.execute('insert into schema values (?)', (self.database_schema,))
138 # reindex the db if necessary
139 if self.indexer.should_reindex():
140 self.reindex()
142 # commit
143 self.conn.commit()
145 def reindex(self):
146 for klass in self.classes.values():
147 for nodeid in klass.list():
148 klass.index(nodeid)
149 self.indexer.save_index()
151 def determine_columns(self, spec):
152 ''' Figure the column names and multilink properties from the spec
153 '''
154 cols = []
155 mls = []
156 # add the multilinks separately
157 for col, prop in spec.properties.items():
158 if isinstance(prop, Multilink):
159 mls.append(col)
160 else:
161 cols.append('_'+col)
162 cols.sort()
163 return cols, mls
165 def update_class(self, spec, dbspec):
166 ''' Determine the differences between the current spec and the
167 database version of the spec, and update where necessary
169 NOTE that this doesn't work for adding/deleting properties!
170 ... until gadfly grows an ALTER TABLE command, it's not going to!
171 '''
172 spec_schema = spec.schema()
173 if spec_schema == dbspec:
174 return
175 if __debug__:
176 print >>hyperdb.DEBUG, 'update_class FIRING'
178 # key property changed?
179 if dbspec[0] != spec_schema[0]:
180 if __debug__:
181 print >>hyperdb.DEBUG, 'update_class setting keyprop', `spec[0]`
182 # XXX turn on indexing for the key property
184 # dict 'em up
185 spec_propnames,spec_props = [],{}
186 for propname,prop in spec_schema[1]:
187 spec_propnames.append(propname)
188 spec_props[propname] = prop
189 dbspec_propnames,dbspec_props = [],{}
190 for propname,prop in dbspec[1]:
191 dbspec_propnames.append(propname)
192 dbspec_props[propname] = prop
194 # we're going to need one of these
195 cursor = self.conn.cursor()
197 # now compare
198 for propname in spec_propnames:
199 prop = spec_props[propname]
200 if __debug__:
201 print >>hyperdb.DEBUG, 'update_class ...', `prop`
202 if dbspec_props.has_key(propname) and prop==dbspec_props[propname]:
203 continue
204 if __debug__:
205 print >>hyperdb.DEBUG, 'update_class', `prop`
207 if not dbspec_props.has_key(propname):
208 # add the property
209 if isinstance(prop, Multilink):
210 sql = 'create table %s_%s (linkid varchar, nodeid '\
211 'varchar)'%(spec.classname, prop)
212 if __debug__:
213 print >>hyperdb.DEBUG, 'update_class', (self, sql)
214 cursor.execute(sql)
215 else:
216 # XXX gadfly doesn't have an ALTER TABLE command
217 raise NotImplementedError
218 sql = 'alter table _%s add column (_%s varchar)'%(
219 spec.classname, propname)
220 if __debug__:
221 print >>hyperdb.DEBUG, 'update_class', (self, sql)
222 cursor.execute(sql)
223 else:
224 # modify the property
225 if __debug__:
226 print >>hyperdb.DEBUG, 'update_class NOOP'
227 pass # NOOP in gadfly
229 # and the other way - only worry about deletions here
230 for propname in dbspec_propnames:
231 prop = dbspec_props[propname]
232 if spec_props.has_key(propname):
233 continue
234 if __debug__:
235 print >>hyperdb.DEBUG, 'update_class', `prop`
237 # delete the property
238 if isinstance(prop, Multilink):
239 sql = 'drop table %s_%s'%(spec.classname, prop)
240 if __debug__:
241 print >>hyperdb.DEBUG, 'update_class', (self, sql)
242 cursor.execute(sql)
243 else:
244 # XXX gadfly doesn't have an ALTER TABLE command
245 raise NotImplementedError
246 sql = 'alter table _%s delete column _%s'%(spec.classname,
247 propname)
248 if __debug__:
249 print >>hyperdb.DEBUG, 'update_class', (self, sql)
250 cursor.execute(sql)
252 def create_class(self, spec):
253 ''' Create a database table according to the given spec.
254 '''
255 cols, mls = self.determine_columns(spec)
257 # add on our special columns
258 cols.append('id')
259 cols.append('__retired__')
261 cursor = self.conn.cursor()
263 # create the base table
264 cols = ','.join(['%s varchar'%x for x in cols])
265 sql = 'create table _%s (%s)'%(spec.classname, cols)
266 if __debug__:
267 print >>hyperdb.DEBUG, 'create_class', (self, sql)
268 cursor.execute(sql)
270 # journal table
271 cols = ','.join(['%s varchar'%x
272 for x in 'nodeid date tag action params'.split()])
273 sql = 'create table %s__journal (%s)'%(spec.classname, cols)
274 if __debug__:
275 print >>hyperdb.DEBUG, 'create_class', (self, sql)
276 cursor.execute(sql)
278 # now create the multilink tables
279 for ml in mls:
280 sql = 'create table %s_%s (linkid varchar, nodeid varchar)'%(
281 spec.classname, ml)
282 if __debug__:
283 print >>hyperdb.DEBUG, 'create_class', (self, sql)
284 cursor.execute(sql)
286 # ID counter
287 sql = 'insert into ids (name, num) values (?,?)'
288 vals = (spec.classname, 1)
289 if __debug__:
290 print >>hyperdb.DEBUG, 'create_class', (self, sql, vals)
291 cursor.execute(sql, vals)
293 def drop_class(self, spec):
294 ''' Drop the given table from the database.
296 Drop the journal and multilink tables too.
297 '''
298 # figure the multilinks
299 mls = []
300 for col, prop in spec.properties.items():
301 if isinstance(prop, Multilink):
302 mls.append(col)
303 cursor = self.conn.cursor()
305 sql = 'drop table _%s'%spec.classname
306 if __debug__:
307 print >>hyperdb.DEBUG, 'drop_class', (self, sql)
308 cursor.execute(sql)
310 sql = 'drop table %s__journal'%spec.classname
311 if __debug__:
312 print >>hyperdb.DEBUG, 'drop_class', (self, sql)
313 cursor.execute(sql)
315 for ml in mls:
316 sql = 'drop table %s_%s'%(spec.classname, ml)
317 if __debug__:
318 print >>hyperdb.DEBUG, 'drop_class', (self, sql)
319 cursor.execute(sql)
321 #
322 # Classes
323 #
324 def __getattr__(self, classname):
325 ''' A convenient way of calling self.getclass(classname).
326 '''
327 if self.classes.has_key(classname):
328 if __debug__:
329 print >>hyperdb.DEBUG, '__getattr__', (self, classname)
330 return self.classes[classname]
331 raise AttributeError, classname
333 def addclass(self, cl):
334 ''' Add a Class to the hyperdatabase.
335 '''
336 if __debug__:
337 print >>hyperdb.DEBUG, 'addclass', (self, cl)
338 cn = cl.classname
339 if self.classes.has_key(cn):
340 raise ValueError, cn
341 self.classes[cn] = cl
343 def getclasses(self):
344 ''' Return a list of the names of all existing classes.
345 '''
346 if __debug__:
347 print >>hyperdb.DEBUG, 'getclasses', (self,)
348 l = self.classes.keys()
349 l.sort()
350 return l
352 def getclass(self, classname):
353 '''Get the Class object representing a particular class.
355 If 'classname' is not a valid class name, a KeyError is raised.
356 '''
357 if __debug__:
358 print >>hyperdb.DEBUG, 'getclass', (self, classname)
359 try:
360 return self.classes[classname]
361 except KeyError:
362 raise KeyError, 'There is no class called "%s"'%classname
364 def clear(self):
365 ''' Delete all database contents.
367 Note: I don't commit here, which is different behaviour to the
368 "nuke from orbit" behaviour in the *dbms.
369 '''
370 if __debug__:
371 print >>hyperdb.DEBUG, 'clear', (self,)
372 cursor = self.conn.cursor()
373 for cn in self.classes.keys():
374 sql = 'delete from _%s'%cn
375 if __debug__:
376 print >>hyperdb.DEBUG, 'clear', (self, sql)
377 cursor.execute(sql)
379 #
380 # Node IDs
381 #
382 def newid(self, classname):
383 ''' Generate a new id for the given class
384 '''
385 # get the next ID
386 cursor = self.conn.cursor()
387 sql = 'select num from ids where name=?'
388 if __debug__:
389 print >>hyperdb.DEBUG, 'newid', (self, sql, classname)
390 cursor.execute(sql, (classname, ))
391 newid = cursor.fetchone()[0]
393 # update the counter
394 sql = 'update ids set num=? where name=?'
395 vals = (newid+1, classname)
396 if __debug__:
397 print >>hyperdb.DEBUG, 'newid', (self, sql, vals)
398 cursor.execute(sql, vals)
400 # return as string
401 return str(newid)
403 def setid(self, classname, setid):
404 ''' Set the id counter: used during import of database
405 '''
406 cursor = self.conn.cursor()
407 sql = 'update ids set num=? where name=?'
408 vals = (setid, spec.classname)
409 if __debug__:
410 print >>hyperdb.DEBUG, 'setid', (self, sql, vals)
411 cursor.execute(sql, vals)
413 #
414 # Nodes
415 #
417 def addnode(self, classname, nodeid, node):
418 ''' Add the specified node to its class's db.
419 '''
420 if __debug__:
421 print >>hyperdb.DEBUG, 'addnode', (self, classname, nodeid, node)
422 # gadfly requires values for all non-multilink columns
423 cl = self.classes[classname]
424 cols, mls = self.determine_columns(cl)
426 # default the non-multilink columns
427 for col, prop in cl.properties.items():
428 if not isinstance(col, Multilink):
429 if not node.has_key(col):
430 node[col] = None
432 node = self.serialise(classname, node)
434 # make sure the ordering is correct for column name -> column value
435 vals = tuple([node[col[1:]] for col in cols]) + (nodeid, 0)
436 s = ','.join(['?' for x in cols]) + ',?,?'
437 cols = ','.join(cols) + ',id,__retired__'
439 # perform the inserts
440 cursor = self.conn.cursor()
441 sql = 'insert into _%s (%s) values (%s)'%(classname, cols, s)
442 if __debug__:
443 print >>hyperdb.DEBUG, 'addnode', (self, sql, vals)
444 cursor.execute(sql, vals)
446 # insert the multilink rows
447 for col in mls:
448 t = '%s_%s'%(classname, col)
449 for entry in node[col]:
450 sql = 'insert into %s (linkid, nodeid) values (?,?)'%t
451 vals = (entry, nodeid)
452 if __debug__:
453 print >>hyperdb.DEBUG, 'addnode', (self, sql, vals)
454 cursor.execute(sql, vals)
456 # make sure we do the commit-time extra stuff for this node
457 self.transactions.append((self.doSaveNode, (classname, nodeid, node)))
459 def setnode(self, classname, nodeid, node, multilink_changes):
460 ''' Change the specified node.
461 '''
462 if __debug__:
463 print >>hyperdb.DEBUG, 'setnode', (self, classname, nodeid, node)
464 node = self.serialise(classname, node)
466 cl = self.classes[classname]
467 cols = []
468 mls = []
469 # add the multilinks separately
470 for col in node.keys():
471 prop = cl.properties[col]
472 if isinstance(prop, Multilink):
473 mls.append(col)
474 else:
475 cols.append('_'+col)
476 cols.sort()
478 # make sure the ordering is correct for column name -> column value
479 vals = tuple([node[col[1:]] for col in cols])
480 s = ','.join(['%s=?'%x for x in cols])
481 cols = ','.join(cols)
483 # perform the update
484 cursor = self.conn.cursor()
485 sql = 'update _%s set %s'%(classname, s)
486 if __debug__:
487 print >>hyperdb.DEBUG, 'setnode', (self, sql, vals)
488 cursor.execute(sql, vals)
490 # now the fun bit, updating the multilinks ;)
491 for col, (add, remove) in multilink_changes.items():
492 tn = '%s_%s'%(classname, col)
493 if add:
494 sql = 'insert into %s (nodeid, linkid) values (?,?)'%tn
495 vals = [(nodeid, addid) for addid in add]
496 if __debug__:
497 print >>hyperdb.DEBUG, 'setnode (add)', (self, sql, vals)
498 cursor.execute(sql, vals)
499 if remove:
500 sql = 'delete from %s where nodeid=? and linkid=?'%tn
501 vals = [(nodeid, removeid) for removeid in remove]
502 if __debug__:
503 print >>hyperdb.DEBUG, 'setnode (rem)', (self, sql, vals)
504 cursor.execute(sql, vals)
506 # make sure we do the commit-time extra stuff for this node
507 self.transactions.append((self.doSaveNode, (classname, nodeid, node)))
509 def getnode(self, classname, nodeid):
510 ''' Get a node from the database.
511 '''
512 if __debug__:
513 print >>hyperdb.DEBUG, 'getnode', (self, classname, nodeid)
514 # figure the columns we're fetching
515 cl = self.classes[classname]
516 cols, mls = self.determine_columns(cl)
517 scols = ','.join(cols)
519 # perform the basic property fetch
520 cursor = self.conn.cursor()
521 sql = 'select %s from _%s where id=?'%(scols, classname)
522 if __debug__:
523 print >>hyperdb.DEBUG, 'getnode', (self, sql, nodeid)
524 cursor.execute(sql, (nodeid,))
525 try:
526 values = cursor.fetchone()
527 except gadfly.database.error, message:
528 if message == 'no more results':
529 raise IndexError, 'no such %s node %s'%(classname, nodeid)
530 raise
532 # make up the node
533 node = {}
534 for col in range(len(cols)):
535 node[cols[col][1:]] = values[col]
537 # now the multilinks
538 for col in mls:
539 # get the link ids
540 sql = 'select linkid from %s_%s where nodeid=?'%(classname, col)
541 if __debug__:
542 print >>hyperdb.DEBUG, 'getnode', (self, sql, nodeid)
543 cursor.execute(sql, (nodeid,))
544 # extract the first column from the result
545 node[col] = [x[0] for x in cursor.fetchall()]
547 return self.unserialise(classname, node)
549 def destroynode(self, classname, nodeid):
550 '''Remove a node from the database. Called exclusively by the
551 destroy() method on Class.
552 '''
553 if __debug__:
554 print >>hyperdb.DEBUG, 'destroynode', (self, classname, nodeid)
556 # make sure the node exists
557 if not self.hasnode(classname, nodeid):
558 raise IndexError, '%s has no node %s'%(classname, nodeid)
560 # see if there's any obvious commit actions that we should get rid of
561 for entry in self.transactions[:]:
562 if entry[1][:2] == (classname, nodeid):
563 self.transactions.remove(entry)
565 # now do the SQL
566 cursor = self.conn.cursor()
567 sql = 'delete from _%s where id=?'%(classname)
568 if __debug__:
569 print >>hyperdb.DEBUG, 'destroynode', (self, sql, nodeid)
570 cursor.execute(sql, (nodeid,))
572 def serialise(self, classname, node):
573 '''Copy the node contents, converting non-marshallable data into
574 marshallable data.
575 '''
576 if __debug__:
577 print >>hyperdb.DEBUG, 'serialise', classname, node
578 properties = self.getclass(classname).getprops()
579 d = {}
580 for k, v in node.items():
581 # if the property doesn't exist, or is the "retired" flag then
582 # it won't be in the properties dict
583 if not properties.has_key(k):
584 d[k] = v
585 continue
587 # get the property spec
588 prop = properties[k]
590 if isinstance(prop, Password):
591 d[k] = str(v)
592 elif isinstance(prop, Date) and v is not None:
593 d[k] = v.serialise()
594 elif isinstance(prop, Interval) and v is not None:
595 d[k] = v.serialise()
596 else:
597 d[k] = v
598 return d
600 def unserialise(self, classname, node):
601 '''Decode the marshalled node data
602 '''
603 if __debug__:
604 print >>hyperdb.DEBUG, 'unserialise', classname, node
605 properties = self.getclass(classname).getprops()
606 d = {}
607 for k, v in node.items():
608 # if the property doesn't exist, or is the "retired" flag then
609 # it won't be in the properties dict
610 if not properties.has_key(k):
611 d[k] = v
612 continue
614 # get the property spec
615 prop = properties[k]
617 if isinstance(prop, Date) and v is not None:
618 d[k] = date.Date(v)
619 elif isinstance(prop, Interval) and v is not None:
620 d[k] = date.Interval(v)
621 elif isinstance(prop, Password):
622 p = password.Password()
623 p.unpack(v)
624 d[k] = p
625 else:
626 d[k] = v
627 return d
629 def hasnode(self, classname, nodeid):
630 ''' Determine if the database has a given node.
631 '''
632 cursor = self.conn.cursor()
633 sql = 'select count(*) from _%s where id=?'%classname
634 if __debug__:
635 print >>hyperdb.DEBUG, 'hasnode', (self, sql, nodeid)
636 cursor.execute(sql, (nodeid,))
637 return cursor.fetchone()[0]
639 def countnodes(self, classname):
640 ''' Count the number of nodes that exist for a particular Class.
641 '''
642 cursor = self.conn.cursor()
643 sql = 'select count(*) from _%s'%classname
644 if __debug__:
645 print >>hyperdb.DEBUG, 'countnodes', (self, sql)
646 cursor.execute(sql)
647 return cursor.fetchone()[0]
649 def getnodeids(self, classname, retired=0):
650 ''' Retrieve all the ids of the nodes for a particular Class.
652 Set retired=None to get all nodes. Otherwise it'll get all the
653 retired or non-retired nodes, depending on the flag.
654 '''
655 cursor = self.conn.cursor()
656 # flip the sense of the flag if we don't want all of them
657 if retired is not None:
658 retired = not retired
659 sql = 'select id from _%s where __retired__ <> ?'%classname
660 if __debug__:
661 print >>hyperdb.DEBUG, 'getnodeids', (self, sql, retired)
662 cursor.execute(sql, (retired,))
663 return [x[0] for x in cursor.fetchall()]
665 def addjournal(self, classname, nodeid, action, params, creator=None,
666 creation=None):
667 ''' Journal the Action
668 'action' may be:
670 'create' or 'set' -- 'params' is a dictionary of property values
671 'link' or 'unlink' -- 'params' is (classname, nodeid, propname)
672 'retire' -- 'params' is None
673 '''
674 # serialise the parameters now if necessary
675 if isinstance(params, type({})):
676 if action in ('set', 'create'):
677 params = self.serialise(classname, params)
679 # handle supply of the special journalling parameters (usually
680 # supplied on importing an existing database)
681 if creator:
682 journaltag = creator
683 else:
684 journaltag = self.journaltag
685 if creation:
686 journaldate = creation.serialise()
687 else:
688 journaldate = date.Date().serialise()
690 # create the journal entry
691 cols = ','.join('nodeid date tag action params'.split())
692 entry = (nodeid, journaldate, journaltag, action, params)
694 if __debug__:
695 print >>hyperdb.DEBUG, 'addjournal', entry
697 # do the insert
698 cursor = self.conn.cursor()
699 sql = 'insert into %s__journal (%s) values (?,?,?,?,?)'%(classname,
700 cols)
701 if __debug__:
702 print >>hyperdb.DEBUG, 'addjournal', (self, sql, entry)
703 cursor.execute(sql, entry)
705 def getjournal(self, classname, nodeid):
706 ''' get the journal for id
707 '''
708 # make sure the node exists
709 if not self.hasnode(classname, nodeid):
710 raise IndexError, '%s has no node %s'%(classname, nodeid)
712 # now get the journal entries
713 cols = ','.join('nodeid date tag action params'.split())
714 cursor = self.conn.cursor()
715 sql = 'select %s from %s__journal where nodeid=?'%(cols, classname)
716 if __debug__:
717 print >>hyperdb.DEBUG, 'getjournal', (self, sql, nodeid)
718 cursor.execute(sql, (nodeid,))
719 res = []
720 for nodeid, date_stamp, user, action, params in cursor.fetchall():
721 res.append((nodeid, date.Date(date_stamp), user, action, params))
722 return res
724 def pack(self, pack_before):
725 ''' Delete all journal entries except "create" before 'pack_before'.
726 '''
727 # get a 'yyyymmddhhmmss' version of the date
728 date_stamp = pack_before.serialise()
730 # do the delete
731 cursor = self.conn.cursor()
732 for classname in self.classes.keys():
733 sql = "delete from %s__journal where date<? and "\
734 "action<>'create'"%classname
735 if __debug__:
736 print >>hyperdb.DEBUG, 'pack', (self, sql, date_stamp)
737 cursor.execute(sql, (date_stamp,))
739 def commit(self):
740 ''' Commit the current transactions.
742 Save all data changed since the database was opened or since the
743 last commit() or rollback().
744 '''
745 if __debug__:
746 print >>hyperdb.DEBUG, 'commit', (self,)
748 # commit gadfly
749 self.conn.commit()
751 # now, do all the other transaction stuff
752 reindex = {}
753 for method, args in self.transactions:
754 reindex[method(*args)] = 1
756 # reindex the nodes that request it
757 for classname, nodeid in filter(None, reindex.keys()):
758 print >>hyperdb.DEBUG, 'commit.reindex', (classname, nodeid)
759 self.getclass(classname).index(nodeid)
761 # save the indexer state
762 self.indexer.save_index()
764 # clear out the transactions
765 self.transactions = []
767 def rollback(self):
768 ''' Reverse all actions from the current transaction.
770 Undo all the changes made since the database was opened or the last
771 commit() or rollback() was performed.
772 '''
773 if __debug__:
774 print >>hyperdb.DEBUG, 'rollback', (self,)
776 # roll back gadfly
777 self.conn.rollback()
779 # roll back "other" transaction stuff
780 for method, args in self.transactions:
781 # delete temporary files
782 if method == self.doStoreFile:
783 self.rollbackStoreFile(*args)
784 self.transactions = []
786 def doSaveNode(self, classname, nodeid, node):
787 ''' dummy that just generates a reindex event
788 '''
789 # return the classname, nodeid so we reindex this content
790 return (classname, nodeid)
792 def close(self):
793 ''' Close off the connection.
794 '''
795 self.conn.close()
797 #
798 # The base Class class
799 #
800 class Class(hyperdb.Class):
801 ''' The handle to a particular class of nodes in a hyperdatabase.
803 All methods except __repr__ and getnode must be implemented by a
804 concrete backend Class.
805 '''
807 def __init__(self, db, classname, **properties):
808 '''Create a new class with a given name and property specification.
810 'classname' must not collide with the name of an existing class,
811 or a ValueError is raised. The keyword arguments in 'properties'
812 must map names to property objects, or a TypeError is raised.
813 '''
814 if (properties.has_key('creation') or properties.has_key('activity')
815 or properties.has_key('creator')):
816 raise ValueError, '"creation", "activity" and "creator" are '\
817 'reserved'
819 self.classname = classname
820 self.properties = properties
821 self.db = weakref.proxy(db) # use a weak ref to avoid circularity
822 self.key = ''
824 # should we journal changes (default yes)
825 self.do_journal = 1
827 # do the db-related init stuff
828 db.addclass(self)
830 self.auditors = {'create': [], 'set': [], 'retire': []}
831 self.reactors = {'create': [], 'set': [], 'retire': []}
833 def schema(self):
834 ''' A dumpable version of the schema that we can store in the
835 database
836 '''
837 return (self.key, [(x, repr(y)) for x,y in self.properties.items()])
839 def enableJournalling(self):
840 '''Turn journalling on for this class
841 '''
842 self.do_journal = 1
844 def disableJournalling(self):
845 '''Turn journalling off for this class
846 '''
847 self.do_journal = 0
849 # Editing nodes:
850 def create(self, **propvalues):
851 ''' Create a new node of this class and return its id.
853 The keyword arguments in 'propvalues' map property names to values.
855 The values of arguments must be acceptable for the types of their
856 corresponding properties or a TypeError is raised.
858 If this class has a key property, it must be present and its value
859 must not collide with other key strings or a ValueError is raised.
861 Any other properties on this class that are missing from the
862 'propvalues' dictionary are set to None.
864 If an id in a link or multilink property does not refer to a valid
865 node, an IndexError is raised.
866 '''
867 if propvalues.has_key('id'):
868 raise KeyError, '"id" is reserved'
870 if self.db.journaltag is None:
871 raise DatabaseError, 'Database open read-only'
873 if propvalues.has_key('creation') or propvalues.has_key('activity'):
874 raise KeyError, '"creation" and "activity" are reserved'
876 self.fireAuditors('create', None, propvalues)
878 # new node's id
879 newid = self.db.newid(self.classname)
881 # validate propvalues
882 num_re = re.compile('^\d+$')
883 for key, value in propvalues.items():
884 if key == self.key:
885 try:
886 self.lookup(value)
887 except KeyError:
888 pass
889 else:
890 raise ValueError, 'node with key "%s" exists'%value
892 # try to handle this property
893 try:
894 prop = self.properties[key]
895 except KeyError:
896 raise KeyError, '"%s" has no property "%s"'%(self.classname,
897 key)
899 if value is not None and isinstance(prop, Link):
900 if type(value) != type(''):
901 raise ValueError, 'link value must be String'
902 link_class = self.properties[key].classname
903 # if it isn't a number, it's a key
904 if not num_re.match(value):
905 try:
906 value = self.db.classes[link_class].lookup(value)
907 except (TypeError, KeyError):
908 raise IndexError, 'new property "%s": %s not a %s'%(
909 key, value, link_class)
910 elif not self.db.getclass(link_class).hasnode(value):
911 raise IndexError, '%s has no node %s'%(link_class, value)
913 # save off the value
914 propvalues[key] = value
916 # register the link with the newly linked node
917 if self.do_journal and self.properties[key].do_journal:
918 self.db.addjournal(link_class, value, 'link',
919 (self.classname, newid, key))
921 elif isinstance(prop, Multilink):
922 if type(value) != type([]):
923 raise TypeError, 'new property "%s" not a list of ids'%key
925 # clean up and validate the list of links
926 link_class = self.properties[key].classname
927 l = []
928 for entry in value:
929 if type(entry) != type(''):
930 raise ValueError, '"%s" multilink value (%r) '\
931 'must contain Strings'%(key, value)
932 # if it isn't a number, it's a key
933 if not num_re.match(entry):
934 try:
935 entry = self.db.classes[link_class].lookup(entry)
936 except (TypeError, KeyError):
937 raise IndexError, 'new property "%s": %s not a %s'%(
938 key, entry, self.properties[key].classname)
939 l.append(entry)
940 value = l
941 propvalues[key] = value
943 # handle additions
944 for nodeid in value:
945 if not self.db.getclass(link_class).hasnode(nodeid):
946 raise IndexError, '%s has no node %s'%(link_class,
947 nodeid)
948 # register the link with the newly linked node
949 if self.do_journal and self.properties[key].do_journal:
950 self.db.addjournal(link_class, nodeid, 'link',
951 (self.classname, newid, key))
953 elif isinstance(prop, String):
954 if type(value) != type(''):
955 raise TypeError, 'new property "%s" not a string'%key
957 elif isinstance(prop, Password):
958 if not isinstance(value, password.Password):
959 raise TypeError, 'new property "%s" not a Password'%key
961 elif isinstance(prop, Date):
962 if value is not None and not isinstance(value, date.Date):
963 raise TypeError, 'new property "%s" not a Date'%key
965 elif isinstance(prop, Interval):
966 if value is not None and not isinstance(value, date.Interval):
967 raise TypeError, 'new property "%s" not an Interval'%key
969 elif value is not None and isinstance(prop, Number):
970 try:
971 float(value)
972 except ValueError:
973 raise TypeError, 'new property "%s" not numeric'%key
975 elif value is not None and isinstance(prop, Boolean):
976 try:
977 int(value)
978 except ValueError:
979 raise TypeError, 'new property "%s" not boolean'%key
981 # make sure there's data where there needs to be
982 for key, prop in self.properties.items():
983 if propvalues.has_key(key):
984 continue
985 if key == self.key:
986 raise ValueError, 'key property "%s" is required'%key
987 if isinstance(prop, Multilink):
988 propvalues[key] = []
989 else:
990 propvalues[key] = None
992 # done
993 self.db.addnode(self.classname, newid, propvalues)
994 if self.do_journal:
995 self.db.addjournal(self.classname, newid, 'create', propvalues)
997 self.fireReactors('create', newid, None)
999 return newid
1001 def export_list(self, propnames, nodeid):
1002 ''' Export a node - generate a list of CSV-able data in the order
1003 specified by propnames for the given node.
1004 '''
1005 properties = self.getprops()
1006 l = []
1007 for prop in propnames:
1008 proptype = properties[prop]
1009 value = self.get(nodeid, prop)
1010 # "marshal" data where needed
1011 if value is None:
1012 pass
1013 elif isinstance(proptype, hyperdb.Date):
1014 value = value.get_tuple()
1015 elif isinstance(proptype, hyperdb.Interval):
1016 value = value.get_tuple()
1017 elif isinstance(proptype, hyperdb.Password):
1018 value = str(value)
1019 l.append(repr(value))
1020 return l
1022 def import_list(self, propnames, proplist):
1023 ''' Import a node - all information including "id" is present and
1024 should not be sanity checked. Triggers are not triggered. The
1025 journal should be initialised using the "creator" and "created"
1026 information.
1028 Return the nodeid of the node imported.
1029 '''
1030 if self.db.journaltag is None:
1031 raise DatabaseError, 'Database open read-only'
1032 properties = self.getprops()
1034 # make the new node's property map
1035 d = {}
1036 for i in range(len(propnames)):
1037 # Use eval to reverse the repr() used to output the CSV
1038 value = eval(proplist[i])
1040 # Figure the property for this column
1041 propname = propnames[i]
1042 prop = properties[propname]
1044 # "unmarshal" where necessary
1045 if propname == 'id':
1046 newid = value
1047 continue
1048 elif value is None:
1049 # don't set Nones
1050 continue
1051 elif isinstance(prop, hyperdb.Date):
1052 value = date.Date(value)
1053 elif isinstance(prop, hyperdb.Interval):
1054 value = date.Interval(value)
1055 elif isinstance(prop, hyperdb.Password):
1056 pwd = password.Password()
1057 pwd.unpack(value)
1058 value = pwd
1059 d[propname] = value
1061 # extract the extraneous journalling gumpf and nuke it
1062 if d.has_key('creator'):
1063 creator = d['creator']
1064 del d['creator']
1065 if d.has_key('creation'):
1066 creation = d['creation']
1067 del d['creation']
1068 if d.has_key('activity'):
1069 del d['activity']
1071 # add the node and journal
1072 self.db.addnode(self.classname, newid, d)
1073 self.db.addjournal(self.classname, newid, 'create', d, creator,
1074 creation)
1075 return newid
1077 _marker = []
1078 def get(self, nodeid, propname, default=_marker, cache=1):
1079 '''Get the value of a property on an existing node of this class.
1081 'nodeid' must be the id of an existing node of this class or an
1082 IndexError is raised. 'propname' must be the name of a property
1083 of this class or a KeyError is raised.
1085 'cache' indicates whether the transaction cache should be queried
1086 for the node. If the node has been modified and you need to
1087 determine what its values prior to modification are, you need to
1088 set cache=0.
1089 '''
1090 if propname == 'id':
1091 return nodeid
1093 if propname == 'creation':
1094 if not self.do_journal:
1095 raise ValueError, 'Journalling is disabled for this class'
1096 journal = self.db.getjournal(self.classname, nodeid)
1097 if journal:
1098 return self.db.getjournal(self.classname, nodeid)[0][1]
1099 else:
1100 # on the strange chance that there's no journal
1101 return date.Date()
1102 if propname == 'activity':
1103 if not self.do_journal:
1104 raise ValueError, 'Journalling is disabled for this class'
1105 journal = self.db.getjournal(self.classname, nodeid)
1106 if journal:
1107 return self.db.getjournal(self.classname, nodeid)[-1][1]
1108 else:
1109 # on the strange chance that there's no journal
1110 return date.Date()
1111 if propname == 'creator':
1112 if not self.do_journal:
1113 raise ValueError, 'Journalling is disabled for this class'
1114 journal = self.db.getjournal(self.classname, nodeid)
1115 if journal:
1116 name = self.db.getjournal(self.classname, nodeid)[0][2]
1117 else:
1118 return None
1119 try:
1120 return self.db.user.lookup(name)
1121 except KeyError:
1122 # the journaltag user doesn't exist any more
1123 return None
1125 # get the property (raises KeyErorr if invalid)
1126 prop = self.properties[propname]
1128 # get the node's dict
1129 d = self.db.getnode(self.classname, nodeid) #, cache=cache)
1131 if not d.has_key(propname):
1132 if default is self._marker:
1133 if isinstance(prop, Multilink):
1134 return []
1135 else:
1136 return None
1137 else:
1138 return default
1140 # don't pass our list to other code
1141 if isinstance(prop, Multilink):
1142 return d[propname][:]
1144 return d[propname]
1146 def getnode(self, nodeid, cache=1):
1147 ''' Return a convenience wrapper for the node.
1149 'nodeid' must be the id of an existing node of this class or an
1150 IndexError is raised.
1152 'cache' indicates whether the transaction cache should be queried
1153 for the node. If the node has been modified and you need to
1154 determine what its values prior to modification are, you need to
1155 set cache=0.
1156 '''
1157 return Node(self, nodeid, cache=cache)
1159 def set(self, nodeid, **propvalues):
1160 '''Modify 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.
1165 Each key in 'propvalues' must be the name of a property of this
1166 class or a KeyError is raised.
1168 All values in 'propvalues' must be acceptable types for their
1169 corresponding properties or a TypeError is raised.
1171 If the value of the key property is set, it must not collide with
1172 other key strings or a ValueError is raised.
1174 If the value of a Link or Multilink property contains an invalid
1175 node id, a ValueError is raised.
1176 '''
1177 if not propvalues:
1178 return propvalues
1180 if propvalues.has_key('creation') or propvalues.has_key('activity'):
1181 raise KeyError, '"creation" and "activity" are reserved'
1183 if propvalues.has_key('id'):
1184 raise KeyError, '"id" is reserved'
1186 if self.db.journaltag is None:
1187 raise DatabaseError, 'Database open read-only'
1189 self.fireAuditors('set', nodeid, propvalues)
1190 # Take a copy of the node dict so that the subsequent set
1191 # operation doesn't modify the oldvalues structure.
1192 # XXX used to try the cache here first
1193 oldvalues = copy.deepcopy(self.db.getnode(self.classname, nodeid))
1195 node = self.db.getnode(self.classname, nodeid)
1196 if self.is_retired(nodeid):
1197 raise IndexError
1198 num_re = re.compile('^\d+$')
1200 # if the journal value is to be different, store it in here
1201 journalvalues = {}
1203 # remember the add/remove stuff for multilinks, making it easier
1204 # for the Database layer to do its stuff
1205 multilink_changes = {}
1207 for propname, value in propvalues.items():
1208 # check to make sure we're not duplicating an existing key
1209 if propname == self.key and node[propname] != value:
1210 try:
1211 self.lookup(value)
1212 except KeyError:
1213 pass
1214 else:
1215 raise ValueError, 'node with key "%s" exists'%value
1217 # this will raise the KeyError if the property isn't valid
1218 # ... we don't use getprops() here because we only care about
1219 # the writeable properties.
1220 prop = self.properties[propname]
1222 # if the value's the same as the existing value, no sense in
1223 # doing anything
1224 if node.has_key(propname) and value == node[propname]:
1225 del propvalues[propname]
1226 continue
1228 # do stuff based on the prop type
1229 if isinstance(prop, Link):
1230 link_class = prop.classname
1231 # if it isn't a number, it's a key
1232 if value is not None and not isinstance(value, type('')):
1233 raise ValueError, 'property "%s" link value be a string'%(
1234 propname)
1235 if isinstance(value, type('')) and not num_re.match(value):
1236 try:
1237 value = self.db.classes[link_class].lookup(value)
1238 except (TypeError, KeyError):
1239 raise IndexError, 'new property "%s": %s not a %s'%(
1240 propname, value, prop.classname)
1242 if (value is not None and
1243 not self.db.getclass(link_class).hasnode(value)):
1244 raise IndexError, '%s has no node %s'%(link_class, value)
1246 if self.do_journal and prop.do_journal:
1247 # register the unlink with the old linked node
1248 if node[propname] is not None:
1249 self.db.addjournal(link_class, node[propname], 'unlink',
1250 (self.classname, nodeid, propname))
1252 # register the link with the newly linked node
1253 if value is not None:
1254 self.db.addjournal(link_class, value, 'link',
1255 (self.classname, nodeid, propname))
1257 elif isinstance(prop, Multilink):
1258 if type(value) != type([]):
1259 raise TypeError, 'new property "%s" not a list of'\
1260 ' ids'%propname
1261 link_class = self.properties[propname].classname
1262 l = []
1263 for entry in value:
1264 # if it isn't a number, it's a key
1265 if type(entry) != type(''):
1266 raise ValueError, 'new property "%s" link value ' \
1267 'must be a string'%propname
1268 if not num_re.match(entry):
1269 try:
1270 entry = self.db.classes[link_class].lookup(entry)
1271 except (TypeError, KeyError):
1272 raise IndexError, 'new property "%s": %s not a %s'%(
1273 propname, entry,
1274 self.properties[propname].classname)
1275 l.append(entry)
1276 value = l
1277 propvalues[propname] = value
1279 # figure the journal entry for this property
1280 add = []
1281 remove = []
1283 # handle removals
1284 if node.has_key(propname):
1285 l = node[propname]
1286 else:
1287 l = []
1288 for id in l[:]:
1289 if id in value:
1290 continue
1291 # register the unlink with the old linked node
1292 if self.do_journal and self.properties[propname].do_journal:
1293 self.db.addjournal(link_class, id, 'unlink',
1294 (self.classname, nodeid, propname))
1295 l.remove(id)
1296 remove.append(id)
1298 # handle additions
1299 for id in value:
1300 if not self.db.getclass(link_class).hasnode(id):
1301 raise IndexError, '%s has no node %s'%(link_class, id)
1302 if id in l:
1303 continue
1304 # register the link with the newly linked node
1305 if self.do_journal and self.properties[propname].do_journal:
1306 self.db.addjournal(link_class, id, 'link',
1307 (self.classname, nodeid, propname))
1308 l.append(id)
1309 add.append(id)
1311 # figure the journal entry
1312 l = []
1313 if add:
1314 l.append(('+', add))
1315 if remove:
1316 l.append(('-', remove))
1317 multilink_changes[propname] = (add, remove)
1318 if l:
1319 journalvalues[propname] = tuple(l)
1321 elif isinstance(prop, String):
1322 if value is not None and type(value) != type(''):
1323 raise TypeError, 'new property "%s" not a string'%propname
1325 elif isinstance(prop, Password):
1326 if not isinstance(value, password.Password):
1327 raise TypeError, 'new property "%s" not a Password'%propname
1328 propvalues[propname] = value
1330 elif value is not None and isinstance(prop, Date):
1331 if not isinstance(value, date.Date):
1332 raise TypeError, 'new property "%s" not a Date'% propname
1333 propvalues[propname] = value
1335 elif value is not None and isinstance(prop, Interval):
1336 if not isinstance(value, date.Interval):
1337 raise TypeError, 'new property "%s" not an '\
1338 'Interval'%propname
1339 propvalues[propname] = value
1341 elif value is not None and isinstance(prop, Number):
1342 try:
1343 float(value)
1344 except ValueError:
1345 raise TypeError, 'new property "%s" not numeric'%propname
1347 elif value is not None and isinstance(prop, Boolean):
1348 try:
1349 int(value)
1350 except ValueError:
1351 raise TypeError, 'new property "%s" not boolean'%propname
1353 node[propname] = value
1355 # nothing to do?
1356 if not propvalues:
1357 return propvalues
1359 # do the set, and journal it
1360 self.db.setnode(self.classname, nodeid, node, multilink_changes)
1362 if self.do_journal:
1363 propvalues.update(journalvalues)
1364 self.db.addjournal(self.classname, nodeid, 'set', propvalues)
1366 self.fireReactors('set', nodeid, oldvalues)
1368 return propvalues
1370 def retire(self, nodeid):
1371 '''Retire a node.
1373 The properties on the node remain available from the get() method,
1374 and the node's id is never reused.
1376 Retired nodes are not returned by the find(), list(), or lookup()
1377 methods, and other nodes may reuse the values of their key properties.
1378 '''
1379 if self.db.journaltag is None:
1380 raise DatabaseError, 'Database open read-only'
1382 cursor = self.db.conn.cursor()
1383 sql = 'update _%s set __retired__=1 where id=?'%self.classname
1384 if __debug__:
1385 print >>hyperdb.DEBUG, 'retire', (self, sql, nodeid)
1386 cursor.execute(sql, (nodeid,))
1388 def is_retired(self, nodeid):
1389 '''Return true if the node is rerired
1390 '''
1391 cursor = self.db.conn.cursor()
1392 sql = 'select __retired__ from _%s where id=?'%self.classname
1393 if __debug__:
1394 print >>hyperdb.DEBUG, 'is_retired', (self, sql, nodeid)
1395 cursor.execute(sql, (nodeid,))
1396 return cursor.fetchone()[0]
1398 def destroy(self, nodeid):
1399 '''Destroy a node.
1401 WARNING: this method should never be used except in extremely rare
1402 situations where there could never be links to the node being
1403 deleted
1404 WARNING: use retire() instead
1405 WARNING: the properties of this node will not be available ever again
1406 WARNING: really, use retire() instead
1408 Well, I think that's enough warnings. This method exists mostly to
1409 support the session storage of the cgi interface.
1411 The node is completely removed from the hyperdb, including all journal
1412 entries. It will no longer be available, and will generally break code
1413 if there are any references to the node.
1414 '''
1415 if self.db.journaltag is None:
1416 raise DatabaseError, 'Database open read-only'
1417 self.db.destroynode(self.classname, nodeid)
1419 def history(self, nodeid):
1420 '''Retrieve the journal of edits on a particular node.
1422 'nodeid' must be the id of an existing node of this class or an
1423 IndexError is raised.
1425 The returned list contains tuples of the form
1427 (date, tag, action, params)
1429 'date' is a Timestamp object specifying the time of the change and
1430 'tag' is the journaltag specified when the database was opened.
1431 '''
1432 if not self.do_journal:
1433 raise ValueError, 'Journalling is disabled for this class'
1434 return self.db.getjournal(self.classname, nodeid)
1436 # Locating nodes:
1437 def hasnode(self, nodeid):
1438 '''Determine if the given nodeid actually exists
1439 '''
1440 return self.db.hasnode(self.classname, nodeid)
1442 def setkey(self, propname):
1443 '''Select a String property of this class to be the key property.
1445 'propname' must be the name of a String property of this class or
1446 None, or a TypeError is raised. The values of the key property on
1447 all existing nodes must be unique or a ValueError is raised.
1448 '''
1449 # XXX create an index on the key prop column
1450 prop = self.getprops()[propname]
1451 if not isinstance(prop, String):
1452 raise TypeError, 'key properties must be String'
1453 self.key = propname
1455 def getkey(self):
1456 '''Return the name of the key property for this class or None.'''
1457 return self.key
1459 def labelprop(self, default_to_id=0):
1460 ''' Return the property name for a label for the given node.
1462 This method attempts to generate a consistent label for the node.
1463 It tries the following in order:
1464 1. key property
1465 2. "name" property
1466 3. "title" property
1467 4. first property from the sorted property name list
1468 '''
1469 k = self.getkey()
1470 if k:
1471 return k
1472 props = self.getprops()
1473 if props.has_key('name'):
1474 return 'name'
1475 elif props.has_key('title'):
1476 return 'title'
1477 if default_to_id:
1478 return 'id'
1479 props = props.keys()
1480 props.sort()
1481 return props[0]
1483 def lookup(self, keyvalue):
1484 '''Locate a particular node by its key property and return its id.
1486 If this class has no key property, a TypeError is raised. If the
1487 'keyvalue' matches one of the values for the key property among
1488 the nodes in this class, the matching node's id is returned;
1489 otherwise a KeyError is raised.
1490 '''
1491 if not self.key:
1492 raise TypeError, 'No key property set for class %s'%self.classname
1494 cursor = self.db.conn.cursor()
1495 sql = 'select id from _%s where _%s=?'%(self.classname, self.key)
1496 if __debug__:
1497 print >>hyperdb.DEBUG, 'lookup', (self, sql, keyvalue)
1498 cursor.execute(sql, (keyvalue,))
1500 # see if there was a result
1501 l = cursor.fetchall()
1502 if not l:
1503 raise KeyError, keyvalue
1505 # return the id
1506 return l[0][0]
1508 def find(self, **propspec):
1509 '''Get the ids of nodes in this class which link to the given nodes.
1511 'propspec' consists of keyword args propname={nodeid:1,}
1512 'propname' must be the name of a property in this class, or a
1513 KeyError is raised. That property must be a Link or Multilink
1514 property, or a TypeError is raised.
1516 Any node in this class whose 'propname' property links to any of the
1517 nodeids will be returned. Used by the full text indexing, which knows
1518 that "foo" occurs in msg1, msg3 and file7, so we have hits on these
1519 issues:
1521 db.issue.find(messages={'1':1,'3':1}, files={'7':1})
1522 '''
1523 if __debug__:
1524 print >>hyperdb.DEBUG, 'find', (self, propspec)
1525 if not propspec:
1526 return []
1527 queries = []
1528 tables = []
1529 allvalues = ()
1530 for prop, values in propspec.items():
1531 allvalues += tuple(values.keys())
1532 tables.append('select nodeid from %s_%s where linkid in (%s)'%(
1533 self.classname, prop, ','.join(['?' for x in values.keys()])))
1534 sql = '\nintersect\n'.join(tables)
1535 if __debug__:
1536 print >>hyperdb.DEBUG, 'find', (self, sql, allvalues)
1537 cursor = self.db.conn.cursor()
1538 cursor.execute(sql, allvalues)
1539 try:
1540 l = [x[0] for x in cursor.fetchall()]
1541 except gadfly.database.error, message:
1542 if message == 'no more results':
1543 l = []
1544 raise
1545 if __debug__:
1546 print >>hyperdb.DEBUG, 'find ... ', l
1547 return l
1549 def list(self):
1550 ''' Return a list of the ids of the active nodes in this class.
1551 '''
1552 return self.db.getnodeids(self.classname, retired=0)
1554 def filter(self, search_matches, filterspec, sort, group):
1555 ''' Return a list of the ids of the active nodes in this class that
1556 match the 'filter' spec, sorted by the group spec and then the
1557 sort spec
1559 "filterspec" is {propname: value(s)}
1560 "sort" and "group" are (dir, prop) where dir is '+', '-' or None
1561 and prop is a prop name or None
1562 "search_matches" is {nodeid: marker}
1563 '''
1564 cn = self.classname
1566 # figure the WHERE clause from the filterspec
1567 props = self.getprops()
1568 frum = ['_'+cn]
1569 where = []
1570 args = []
1571 for k, v in filterspec.items():
1572 propclass = props[k]
1573 if isinstance(propclass, Multilink):
1574 tn = '%s_%s'%(cn, k)
1575 frum.append(tn)
1576 if isinstance(v, type([])):
1577 s = ','.join(['?' for x in v])
1578 where.append('id=%s.nodeid and %s.linkid in (%s)'%(tn,tn,s))
1579 args = args + v
1580 else:
1581 where.append('id=%s.nodeid and %s.linkid = ?'%(tn, tn))
1582 args.append(v)
1583 else:
1584 if isinstance(v, type([])):
1585 s = ','.join(['?' for x in v])
1586 where.append('_%s in (%s)'%(k, s))
1587 args = args + v
1588 else:
1589 where.append('_%s=?'%k)
1590 args.append(v)
1592 # add results of full text search
1593 if search_matches is not None:
1594 v = search_matches.keys()
1595 s = ','.join(['?' for x in v])
1596 where.append('id in (%s)'%s)
1597 args = args + v
1599 # figure the order by clause
1600 orderby = []
1601 ordercols = []
1602 if sort[0] is not None and sort[1] is not None:
1603 if sort[0] != '-':
1604 orderby.append('_'+sort[1])
1605 ordercols.append(sort[1])
1606 else:
1607 orderby.append('_'+sort[1]+' desc')
1608 ordercols.append(sort[1])
1610 # figure the group by clause
1611 groupby = []
1612 groupcols = []
1613 if group[0] is not None and group[1] is not None:
1614 if group[0] != '-':
1615 groupby.append('_'+group[1])
1616 groupcols.append(group[1])
1617 else:
1618 groupby.append('_'+group[1]+' desc')
1619 groupcols.append(group[1])
1621 # construct the SQL
1622 frum = ','.join(frum)
1623 where = ' and '.join(where)
1624 cols = ['id']
1625 if orderby:
1626 cols = cols + ordercols
1627 order = ' order by %s'%(','.join(orderby))
1628 else:
1629 order = ''
1630 if groupby:
1631 cols = cols + groupcols
1632 group = ' group by %s'%(','.join(groupby))
1633 else:
1634 group = ''
1635 cols = ','.join(cols)
1636 sql = 'select %s from %s where %s%s%s'%(cols, frum, where, order,
1637 group)
1638 args = tuple(args)
1639 if __debug__:
1640 print >>hyperdb.DEBUG, 'find', (self, sql, args)
1641 cursor = self.db.conn.cursor()
1642 cursor.execute(sql, args)
1644 def count(self):
1645 '''Get the number of nodes in this class.
1647 If the returned integer is 'numnodes', the ids of all the nodes
1648 in this class run from 1 to numnodes, and numnodes+1 will be the
1649 id of the next node to be created in this class.
1650 '''
1651 return self.db.countnodes(self.classname)
1653 # Manipulating properties:
1654 def getprops(self, protected=1):
1655 '''Return a dictionary mapping property names to property objects.
1656 If the "protected" flag is true, we include protected properties -
1657 those which may not be modified.
1658 '''
1659 d = self.properties.copy()
1660 if protected:
1661 d['id'] = String()
1662 d['creation'] = hyperdb.Date()
1663 d['activity'] = hyperdb.Date()
1664 d['creator'] = hyperdb.Link("user")
1665 return d
1667 def addprop(self, **properties):
1668 '''Add properties to this class.
1670 The keyword arguments in 'properties' must map names to property
1671 objects, or a TypeError is raised. None of the keys in 'properties'
1672 may collide with the names of existing properties, or a ValueError
1673 is raised before any properties have been added.
1674 '''
1675 for key in properties.keys():
1676 if self.properties.has_key(key):
1677 raise ValueError, key
1678 self.properties.update(properties)
1680 def index(self, nodeid):
1681 '''Add (or refresh) the node to search indexes
1682 '''
1683 # find all the String properties that have indexme
1684 for prop, propclass in self.getprops().items():
1685 if isinstance(propclass, String) and propclass.indexme:
1686 try:
1687 value = str(self.get(nodeid, prop))
1688 except IndexError:
1689 # node no longer exists - entry should be removed
1690 self.db.indexer.purge_entry((self.classname, nodeid, prop))
1691 else:
1692 # and index them under (classname, nodeid, property)
1693 self.db.indexer.add_text((self.classname, nodeid, prop),
1694 value)
1697 #
1698 # Detector interface
1699 #
1700 def audit(self, event, detector):
1701 '''Register a detector
1702 '''
1703 l = self.auditors[event]
1704 if detector not in l:
1705 self.auditors[event].append(detector)
1707 def fireAuditors(self, action, nodeid, newvalues):
1708 '''Fire all registered auditors.
1709 '''
1710 for audit in self.auditors[action]:
1711 audit(self.db, self, nodeid, newvalues)
1713 def react(self, event, detector):
1714 '''Register a detector
1715 '''
1716 l = self.reactors[event]
1717 if detector not in l:
1718 self.reactors[event].append(detector)
1720 def fireReactors(self, action, nodeid, oldvalues):
1721 '''Fire all registered reactors.
1722 '''
1723 for react in self.reactors[action]:
1724 react(self.db, self, nodeid, oldvalues)
1726 class FileClass(Class):
1727 '''This class defines a large chunk of data. To support this, it has a
1728 mandatory String property "content" which is typically saved off
1729 externally to the hyperdb.
1731 The default MIME type of this data is defined by the
1732 "default_mime_type" class attribute, which may be overridden by each
1733 node if the class defines a "type" String property.
1734 '''
1735 default_mime_type = 'text/plain'
1737 def create(self, **propvalues):
1738 ''' snaffle the file propvalue and store in a file
1739 '''
1740 content = propvalues['content']
1741 del propvalues['content']
1742 newid = Class.create(self, **propvalues)
1743 self.db.storefile(self.classname, newid, None, content)
1744 return newid
1746 def import_list(self, propnames, proplist):
1747 ''' Trap the "content" property...
1748 '''
1749 # dupe this list so we don't affect others
1750 propnames = propnames[:]
1752 # extract the "content" property from the proplist
1753 i = propnames.index('content')
1754 content = eval(proplist[i])
1755 del propnames[i]
1756 del proplist[i]
1758 # do the normal import
1759 newid = Class.import_list(self, propnames, proplist)
1761 # save off the "content" file
1762 self.db.storefile(self.classname, newid, None, content)
1763 return newid
1765 _marker = []
1766 def get(self, nodeid, propname, default=_marker, cache=1):
1767 ''' trap the content propname and get it from the file
1768 '''
1770 poss_msg = 'Possibly a access right configuration problem.'
1771 if propname == 'content':
1772 try:
1773 return self.db.getfile(self.classname, nodeid, None)
1774 except IOError, (strerror):
1775 # BUG: by catching this we donot see an error in the log.
1776 return 'ERROR reading file: %s%s\n%s\n%s'%(
1777 self.classname, nodeid, poss_msg, strerror)
1778 if default is not self._marker:
1779 return Class.get(self, nodeid, propname, default, cache=cache)
1780 else:
1781 return Class.get(self, nodeid, propname, cache=cache)
1783 def getprops(self, protected=1):
1784 ''' In addition to the actual properties on the node, these methods
1785 provide the "content" property. If the "protected" flag is true,
1786 we include protected properties - those which may not be
1787 modified.
1788 '''
1789 d = Class.getprops(self, protected=protected).copy()
1790 if protected:
1791 d['content'] = hyperdb.String()
1792 return d
1794 def index(self, nodeid):
1795 ''' Index the node in the search index.
1797 We want to index the content in addition to the normal String
1798 property indexing.
1799 '''
1800 # perform normal indexing
1801 Class.index(self, nodeid)
1803 # get the content to index
1804 content = self.get(nodeid, 'content')
1806 # figure the mime type
1807 if self.properties.has_key('type'):
1808 mime_type = self.get(nodeid, 'type')
1809 else:
1810 mime_type = self.default_mime_type
1812 # and index!
1813 self.db.indexer.add_text((self.classname, nodeid, 'content'), content,
1814 mime_type)
1816 # XXX deviation from spec - was called ItemClass
1817 class IssueClass(Class, roundupdb.IssueClass):
1818 # Overridden methods:
1819 def __init__(self, db, classname, **properties):
1820 '''The newly-created class automatically includes the "messages",
1821 "files", "nosy", and "superseder" properties. If the 'properties'
1822 dictionary attempts to specify any of these properties or a
1823 "creation" or "activity" property, a ValueError is raised.
1824 '''
1825 if not properties.has_key('title'):
1826 properties['title'] = hyperdb.String(indexme='yes')
1827 if not properties.has_key('messages'):
1828 properties['messages'] = hyperdb.Multilink("msg")
1829 if not properties.has_key('files'):
1830 properties['files'] = hyperdb.Multilink("file")
1831 if not properties.has_key('nosy'):
1832 # note: journalling is turned off as it really just wastes
1833 # space. this behaviour may be overridden in an instance
1834 properties['nosy'] = hyperdb.Multilink("user", do_journal="no")
1835 if not properties.has_key('superseder'):
1836 properties['superseder'] = hyperdb.Multilink(classname)
1837 Class.__init__(self, db, classname, **properties)