Code

fix SQL generation for empty WHERE clause.
[roundup.git] / roundup / backends / rdbms_common.py
1 #
2 # Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/)
3 # This module is free software, and you may redistribute it and/or modify
4 # under the same terms as Python, so long as this copyright message and
5 # disclaimer are retained in their original form.
6 #
7 # IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR
8 # DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING
9 # OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE
10 # POSSIBILITY OF SUCH DAMAGE.
11 #
12 # BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING,
13 # BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
14 # FOR A PARTICULAR PURPOSE.  THE CODE PROVIDED HEREUNDER IS ON AN "AS IS"
15 # BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
16 # SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
17 #
18 """ Relational database (SQL) backend common code.
20 Basics:
22 - map roundup classes to relational tables
23 - automatically detect schema changes and modify the table schemas
24   appropriately (we store the "database version" of the schema in the
25   database itself as the only row of the "schema" table)
26 - multilinks (which represent a many-to-many relationship) are handled through
27   intermediate tables
28 - journals are stored adjunct to the per-class tables
29 - table names and columns have "_" prepended so the names can't clash with
30   restricted names (like "order")
31 - retirement is determined by the __retired__ column being > 0
33 Database-specific changes may generally be pushed out to the overridable
34 sql_* methods, since everything else should be fairly generic. There's
35 probably a bit of work to be done if a database is used that actually
36 honors column typing, since the initial databases don't (sqlite stores
37 everything as a string.)
39 The schema of the hyperdb being mapped to the database is stored in the
40 database itself as a repr()'ed dictionary of information about each Class
41 that maps to a table. If that information differs from the hyperdb schema,
42 then we update it. We also store in the schema dict a version which
43 allows us to upgrade the database schema when necessary. See upgrade_db().
45 To force a unqiueness constraint on the key properties we put the item
46 id into the __retired__ column duing retirement (so it's 0 for "active"
47 items) and place a unqiueness constraint on key + __retired__. This is
48 particularly important for the users class where multiple users may
49 try to have the same username, with potentially many retired users with
50 the same name.
51 """
52 __docformat__ = 'restructuredtext'
54 # standard python modules
55 import sys, os, time, re, errno, weakref, copy, logging
57 # roundup modules
58 from roundup import hyperdb, date, password, roundupdb, security, support
59 from roundup.hyperdb import String, Password, Date, Interval, Link, \
60     Multilink, DatabaseError, Boolean, Number, Node
61 from roundup.backends import locking
62 from roundup.support import reversed
63 from roundup.i18n import _
65 # support
66 from roundup.backends.blobfiles import FileStorage
67 try:
68     from roundup.backends.indexer_xapian import Indexer
69 except ImportError:
70     from roundup.backends.indexer_rdbms import Indexer
71 from roundup.backends.sessions_rdbms import Sessions, OneTimeKeys
72 from roundup.date import Range
74 # dummy value meaning "argument not passed"
75 _marker = []
77 def _num_cvt(num):
78     num = str(num)
79     try:
80         return int(num)
81     except:
82         return float(num)
84 def _bool_cvt(value):
85     if value in ('TRUE', 'FALSE'):
86         return {'TRUE': 1, 'FALSE': 0}[value]
87     # assume it's a number returned from the db API
88     return int(value)
90 def connection_dict(config, dbnamestr=None):
91     """ Used by Postgresql and MySQL to detemine the keyword args for
92     opening the database connection."""
93     d = { }
94     if dbnamestr:
95         d[dbnamestr] = config.RDBMS_NAME
96     for name in ('host', 'port', 'password', 'user', 'read_default_group',
97             'read_default_file'):
98         cvar = 'RDBMS_'+name.upper()
99         if config[cvar] is not None:
100             d[name] = config[cvar]
101     return d
103 class Database(FileStorage, hyperdb.Database, roundupdb.Database):
104     """ Wrapper around an SQL database that presents a hyperdb interface.
106         - some functionality is specific to the actual SQL database, hence
107           the sql_* methods that are NotImplemented
108         - we keep a cache of the latest N row fetches (where N is configurable).
109     """
110     def __init__(self, config, journaltag=None):
111         """ Open the database and load the schema from it.
112         """
113         FileStorage.__init__(self, config.UMASK)
114         self.config, self.journaltag = config, journaltag
115         self.dir = config.DATABASE
116         self.classes = {}
117         self.indexer = Indexer(self)
118         self.security = security.Security(self)
120         # additional transaction support for external files and the like
121         self.transactions = []
123         # keep a cache of the N most recently retrieved rows of any kind
124         # (classname, nodeid) = row
125         self.cache_size = config.RDBMS_CACHE_SIZE
126         self.cache = {}
127         self.cache_lru = []
128         self.stats = {'cache_hits': 0, 'cache_misses': 0, 'get_items': 0,
129             'filtering': 0}
131         # database lock
132         self.lockfile = None
134         # open a connection to the database, creating the "conn" attribute
135         self.open_connection()
137     def clearCache(self):
138         self.cache = {}
139         self.cache_lru = []
141     def getSessionManager(self):
142         return Sessions(self)
144     def getOTKManager(self):
145         return OneTimeKeys(self)
147     def open_connection(self):
148         """ Open a connection to the database, creating it if necessary.
150             Must call self.load_dbschema()
151         """
152         raise NotImplemented
154     def sql(self, sql, args=None):
155         """ Execute the sql with the optional args.
156         """
157         self.log_debug('SQL %r %r'%(sql, args))
158         if args:
159             self.cursor.execute(sql, args)
160         else:
161             self.cursor.execute(sql)
163     def sql_fetchone(self):
164         """ Fetch a single row. If there's nothing to fetch, return None.
165         """
166         return self.cursor.fetchone()
168     def sql_fetchall(self):
169         """ Fetch all rows. If there's nothing to fetch, return [].
170         """
171         return self.cursor.fetchall()
173     def sql_stringquote(self, value):
174         """ Quote the string so it's safe to put in the 'sql quotes'
175         """
176         return re.sub("'", "''", str(value))
178     def init_dbschema(self):
179         self.database_schema = {
180             'version': self.current_db_version,
181             'tables': {}
182         }
184     def load_dbschema(self):
185         """ Load the schema definition that the database currently implements
186         """
187         self.cursor.execute('select schema from schema')
188         schema = self.cursor.fetchone()
189         if schema:
190             self.database_schema = eval(schema[0])
191         else:
192             self.database_schema = {}
194     def save_dbschema(self):
195         """ Save the schema definition that the database currently implements
196         """
197         s = repr(self.database_schema)
198         self.sql('delete from schema')
199         self.sql('insert into schema values (%s)'%self.arg, (s,))
201     def post_init(self):
202         """ Called once the schema initialisation has finished.
204             We should now confirm that the schema defined by our "classes"
205             attribute actually matches the schema in the database.
206         """
207         save = 0
209         # handle changes in the schema
210         tables = self.database_schema['tables']
211         for classname, spec in self.classes.iteritems():
212             if classname in tables:
213                 dbspec = tables[classname]
214                 if self.update_class(spec, dbspec):
215                     tables[classname] = spec.schema()
216                     save = 1
217             else:
218                 self.create_class(spec)
219                 tables[classname] = spec.schema()
220                 save = 1
222         for classname, spec in list(tables.items()):
223             if classname not in self.classes:
224                 self.drop_class(classname, tables[classname])
225                 del tables[classname]
226                 save = 1
228         # now upgrade the database for column type changes, new internal
229         # tables, etc.
230         save = save | self.upgrade_db()
232         # update the database version of the schema
233         if save:
234             self.save_dbschema()
236         # reindex the db if necessary
237         if self.indexer.should_reindex():
238             self.reindex()
240         # commit
241         self.sql_commit()
243     # update this number when we need to make changes to the SQL structure
244     # of the backen database
245     current_db_version = 5
246     db_version_updated = False
247     def upgrade_db(self):
248         """ Update the SQL database to reflect changes in the backend code.
250             Return boolean whether we need to save the schema.
251         """
252         version = self.database_schema.get('version', 1)
253         if version > self.current_db_version:
254             raise DatabaseError('attempting to run rev %d DATABASE with rev '
255                 '%d CODE!'%(version, self.current_db_version))
256         if version == self.current_db_version:
257             # nothing to do
258             return 0
260         if version < 2:
261             self.log_info('upgrade to version 2')
262             # change the schema structure
263             self.database_schema = {'tables': self.database_schema}
265             # version 1 didn't have the actor column (note that in
266             # MySQL this will also transition the tables to typed columns)
267             self.add_new_columns_v2()
269             # version 1 doesn't have the OTK, session and indexing in the
270             # database
271             self.create_version_2_tables()
273         if version < 3:
274             self.log_info('upgrade to version 3')
275             self.fix_version_2_tables()
277         if version < 4:
278             self.fix_version_3_tables()
280         if version < 5:
281             self.fix_version_4_tables()
283         self.database_schema['version'] = self.current_db_version
284         self.db_version_updated = True
285         return 1
287     def fix_version_3_tables(self):
288         # drop the shorter VARCHAR OTK column and add a new TEXT one
289         for name in ('otk', 'session'):
290             self.sql('DELETE FROM %ss'%name)
291             self.sql('ALTER TABLE %ss DROP %s_value'%(name, name))
292             self.sql('ALTER TABLE %ss ADD %s_value TEXT'%(name, name))
294     def fix_version_2_tables(self):
295         # Default (used by sqlite): NOOP
296         pass
298     def fix_version_4_tables(self):
299         # note this is an explicit call now
300         c = self.cursor
301         for cn, klass in self.classes.iteritems():
302             c.execute('select id from _%s where __retired__<>0'%(cn,))
303             for (id,) in c.fetchall():
304                 c.execute('update _%s set __retired__=%s where id=%s'%(cn,
305                     self.arg, self.arg), (id, id))
307             if klass.key:
308                 self.add_class_key_required_unique_constraint(cn, klass.key)
310     def _convert_journal_tables(self):
311         """Get current journal table contents, drop the table and re-create"""
312         c = self.cursor
313         cols = ','.join('nodeid date tag action params'.split())
314         for klass in self.classes.itervalues():
315             # slurp and drop
316             sql = 'select %s from %s__journal order by date'%(cols,
317                 klass.classname)
318             c.execute(sql)
319             contents = c.fetchall()
320             self.drop_journal_table_indexes(klass.classname)
321             c.execute('drop table %s__journal'%klass.classname)
323             # re-create and re-populate
324             self.create_journal_table(klass)
325             a = self.arg
326             sql = 'insert into %s__journal (%s) values (%s,%s,%s,%s,%s)'%(
327                 klass.classname, cols, a, a, a, a, a)
328             for row in contents:
329                 # no data conversion needed
330                 self.cursor.execute(sql, row)
332     def _convert_string_properties(self):
333         """Get current Class tables that contain String properties, and
334         convert the VARCHAR columns to TEXT"""
335         c = self.cursor
336         for klass in self.classes.itervalues():
337             # slurp and drop
338             cols, mls = self.determine_columns(list(klass.properties.iteritems()))
339             scols = ','.join([i[0] for i in cols])
340             sql = 'select id,%s from _%s'%(scols, klass.classname)
341             c.execute(sql)
342             contents = c.fetchall()
343             self.drop_class_table_indexes(klass.classname, klass.getkey())
344             c.execute('drop table _%s'%klass.classname)
346             # re-create and re-populate
347             self.create_class_table(klass, create_sequence=0)
348             a = ','.join([self.arg for i in range(len(cols)+1)])
349             sql = 'insert into _%s (id,%s) values (%s)'%(klass.classname,
350                 scols, a)
351             for row in contents:
352                 l = []
353                 for entry in row:
354                     # mysql will already be a string - psql needs "help"
355                     if entry is not None and not isinstance(entry, type('')):
356                         entry = str(entry)
357                     l.append(entry)
358                 self.cursor.execute(sql, l)
360     def refresh_database(self):
361         self.post_init()
364     def reindex(self, classname=None, show_progress=False):
365         if classname:
366             classes = [self.getclass(classname)]
367         else:
368             classes = list(self.classes.itervalues())
369         for klass in classes:
370             if show_progress:
371                 for nodeid in support.Progress('Reindex %s'%klass.classname,
372                         klass.list()):
373                     klass.index(nodeid)
374             else:
375                 for nodeid in klass.list():
376                     klass.index(nodeid)
377         self.indexer.save_index()
379     hyperdb_to_sql_datatypes = {
380         hyperdb.String : 'TEXT',
381         hyperdb.Date   : 'TIMESTAMP',
382         hyperdb.Link   : 'INTEGER',
383         hyperdb.Interval  : 'VARCHAR(255)',
384         hyperdb.Password  : 'VARCHAR(255)',
385         hyperdb.Boolean   : 'BOOLEAN',
386         hyperdb.Number    : 'REAL',
387     }
389     def hyperdb_to_sql_datatype(self, propclass):
391         datatype = self.hyperdb_to_sql_datatypes.get(propclass)
392         if datatype:
393             return datatype
394         
395         for k, v in self.hyperdb_to_sql_datatypes.iteritems():
396             if issubclass(propclass, k):
397                 return v
399         raise ValueError('%r is not a hyperdb property class' % propclass)
400     
401     def determine_columns(self, properties):
402         """ Figure the column names and multilink properties from the spec
404             "properties" is a list of (name, prop) where prop may be an
405             instance of a hyperdb "type" _or_ a string repr of that type.
406         """
407         cols = [
408             ('_actor', self.hyperdb_to_sql_datatype(hyperdb.Link)),
409             ('_activity', self.hyperdb_to_sql_datatype(hyperdb.Date)),
410             ('_creator', self.hyperdb_to_sql_datatype(hyperdb.Link)),
411             ('_creation', self.hyperdb_to_sql_datatype(hyperdb.Date)),
412         ]
413         mls = []
414         # add the multilinks separately
415         for col, prop in properties:
416             if isinstance(prop, Multilink):
417                 mls.append(col)
418                 continue
420             if isinstance(prop, type('')):
421                 raise ValueError("string property spec!")
422                 #and prop.find('Multilink') != -1:
423                 #mls.append(col)
425             datatype = self.hyperdb_to_sql_datatype(prop.__class__)
426             cols.append(('_'+col, datatype))
428             # Intervals stored as two columns
429             if isinstance(prop, Interval):
430                 cols.append(('__'+col+'_int__', 'BIGINT'))
432         cols.sort()
433         return cols, mls
435     def update_class(self, spec, old_spec, force=0):
436         """ Determine the differences between the current spec and the
437             database version of the spec, and update where necessary.
439             If 'force' is true, update the database anyway.
440         """
441         new_spec = spec.schema()
442         new_spec[1].sort()
443         old_spec[1].sort()
444         if not force and new_spec == old_spec:
445             # no changes
446             return 0
448         logger = logging.getLogger('roundup.hyperdb')
449         logger.info('update_class %s'%spec.classname)
451         logger.debug('old_spec %r'%(old_spec,))
452         logger.debug('new_spec %r'%(new_spec,))
454         # detect key prop change for potential index change
455         keyprop_changes = {}
456         if new_spec[0] != old_spec[0]:
457             if old_spec[0]:
458                 keyprop_changes['remove'] = old_spec[0]
459             if new_spec[0]:
460                 keyprop_changes['add'] = new_spec[0]
462         # detect multilinks that have been removed, and drop their table
463         old_has = {}
464         for name, prop in old_spec[1]:
465             old_has[name] = 1
466             if name in spec.properties:
467                 continue
469             if prop.find('Multilink to') != -1:
470                 # first drop indexes.
471                 self.drop_multilink_table_indexes(spec.classname, name)
473                 # now the multilink table itself
474                 sql = 'drop table %s_%s'%(spec.classname, name)
475             else:
476                 # if this is the key prop, drop the index first
477                 if old_spec[0] == prop:
478                     self.drop_class_table_key_index(spec.classname, name)
479                     del keyprop_changes['remove']
481                 # drop the column
482                 sql = 'alter table _%s drop column _%s'%(spec.classname, name)
484             self.sql(sql)
486         # if we didn't remove the key prop just then, but the key prop has
487         # changed, we still need to remove the old index
488         if 'remove' in keyprop_changes:
489             self.drop_class_table_key_index(spec.classname,
490                 keyprop_changes['remove'])
492         # add new columns
493         for propname, prop in new_spec[1]:
494             if propname in old_has:
495                 continue
496             prop = spec.properties[propname]
497             if isinstance(prop, Multilink):
498                 self.create_multilink_table(spec, propname)
499             else:
500                 # add the column
501                 coltype = self.hyperdb_to_sql_datatype(prop.__class__)
502                 sql = 'alter table _%s add column _%s %s'%(
503                     spec.classname, propname, coltype)
504                 self.sql(sql)
506                 # extra Interval column
507                 if isinstance(prop, Interval):
508                     sql = 'alter table _%s add column __%s_int__ BIGINT'%(
509                         spec.classname, propname)
510                     self.sql(sql)
512                 # if the new column is a key prop, we need an index!
513                 if new_spec[0] == propname:
514                     self.create_class_table_key_index(spec.classname, propname)
515                     del keyprop_changes['add']
517         # if we didn't add the key prop just then, but the key prop has
518         # changed, we still need to add the new index
519         if 'add' in keyprop_changes:
520             self.create_class_table_key_index(spec.classname,
521                 keyprop_changes['add'])
523         return 1
525     def determine_all_columns(self, spec):
526         """Figure out the columns from the spec and also add internal columns
528         """
529         cols, mls = self.determine_columns(list(spec.properties.iteritems()))
531         # add on our special columns
532         cols.append(('id', 'INTEGER PRIMARY KEY'))
533         cols.append(('__retired__', 'INTEGER DEFAULT 0'))
534         return cols, mls
536     def create_class_table(self, spec):
537         """Create the class table for the given Class "spec". Creates the
538         indexes too."""
539         cols, mls = self.determine_all_columns(spec)
541         # create the base table
542         scols = ','.join(['%s %s'%x for x in cols])
543         sql = 'create table _%s (%s)'%(spec.classname, scols)
544         self.sql(sql)
546         self.create_class_table_indexes(spec)
548         return cols, mls
550     def create_class_table_indexes(self, spec):
551         """ create the class table for the given spec
552         """
553         # create __retired__ index
554         index_sql2 = 'create index _%s_retired_idx on _%s(__retired__)'%(
555                         spec.classname, spec.classname)
556         self.sql(index_sql2)
558         # create index for key property
559         if spec.key:
560             index_sql3 = 'create index _%s_%s_idx on _%s(_%s)'%(
561                         spec.classname, spec.key,
562                         spec.classname, spec.key)
563             self.sql(index_sql3)
565             # and the unique index for key / retired(id)
566             self.add_class_key_required_unique_constraint(spec.classname,
567                 spec.key)
569         # TODO: create indexes on (selected?) Link property columns, as
570         # they're more likely to be used for lookup
572     def add_class_key_required_unique_constraint(self, cn, key):
573         sql = '''create unique index _%s_key_retired_idx
574             on _%s(__retired__, _%s)'''%(cn, cn, key)
575         self.sql(sql)
577     def drop_class_table_indexes(self, cn, key):
578         # drop the old table indexes first
579         l = ['_%s_id_idx'%cn, '_%s_retired_idx'%cn]
580         if key:
581             l.append('_%s_%s_idx'%(cn, key))
583         table_name = '_%s'%cn
584         for index_name in l:
585             if not self.sql_index_exists(table_name, index_name):
586                 continue
587             index_sql = 'drop index '+index_name
588             self.sql(index_sql)
590     def create_class_table_key_index(self, cn, key):
591         """ create the class table for the given spec
592         """
593         sql = 'create index _%s_%s_idx on _%s(_%s)'%(cn, key, cn, key)
594         self.sql(sql)
596     def drop_class_table_key_index(self, cn, key):
597         table_name = '_%s'%cn
598         index_name = '_%s_%s_idx'%(cn, key)
599         if self.sql_index_exists(table_name, index_name):
600             sql = 'drop index '+index_name
601             self.sql(sql)
603         # and now the retired unique index too
604         index_name = '_%s_key_retired_idx'%cn
605         if self.sql_index_exists(table_name, index_name):
606             sql = 'drop index '+index_name
607             self.sql(sql)
609     def create_journal_table(self, spec):
610         """ create the journal table for a class given the spec and
611             already-determined cols
612         """
613         # journal table
614         cols = ','.join(['%s varchar'%x
615             for x in 'nodeid date tag action params'.split()])
616         sql = """create table %s__journal (
617             nodeid integer, date %s, tag varchar(255),
618             action varchar(255), params text)""" % (spec.classname,
619             self.hyperdb_to_sql_datatype(hyperdb.Date))
620         self.sql(sql)
621         self.create_journal_table_indexes(spec)
623     def create_journal_table_indexes(self, spec):
624         # index on nodeid
625         sql = 'create index %s_journ_idx on %s__journal(nodeid)'%(
626                         spec.classname, spec.classname)
627         self.sql(sql)
629     def drop_journal_table_indexes(self, classname):
630         index_name = '%s_journ_idx'%classname
631         if not self.sql_index_exists('%s__journal'%classname, index_name):
632             return
633         index_sql = 'drop index '+index_name
634         self.sql(index_sql)
636     def create_multilink_table(self, spec, ml):
637         """ Create a multilink table for the "ml" property of the class
638             given by the spec
639         """
640         # create the table
641         sql = 'create table %s_%s (linkid INTEGER, nodeid INTEGER)'%(
642             spec.classname, ml)
643         self.sql(sql)
644         self.create_multilink_table_indexes(spec, ml)
646     def create_multilink_table_indexes(self, spec, ml):
647         # create index on linkid
648         index_sql = 'create index %s_%s_l_idx on %s_%s(linkid)'%(
649             spec.classname, ml, spec.classname, ml)
650         self.sql(index_sql)
652         # create index on nodeid
653         index_sql = 'create index %s_%s_n_idx on %s_%s(nodeid)'%(
654             spec.classname, ml, spec.classname, ml)
655         self.sql(index_sql)
657     def drop_multilink_table_indexes(self, classname, ml):
658         l = [
659             '%s_%s_l_idx'%(classname, ml),
660             '%s_%s_n_idx'%(classname, ml)
661         ]
662         table_name = '%s_%s'%(classname, ml)
663         for index_name in l:
664             if not self.sql_index_exists(table_name, index_name):
665                 continue
666             index_sql = 'drop index %s'%index_name
667             self.sql(index_sql)
669     def create_class(self, spec):
670         """ Create a database table according to the given spec.
671         """
672         cols, mls = self.create_class_table(spec)
673         self.create_journal_table(spec)
675         # now create the multilink tables
676         for ml in mls:
677             self.create_multilink_table(spec, ml)
679     def drop_class(self, cn, spec):
680         """ Drop the given table from the database.
682             Drop the journal and multilink tables too.
683         """
684         properties = spec[1]
685         # figure the multilinks
686         mls = []
687         for propname, prop in properties:
688             if isinstance(prop, Multilink):
689                 mls.append(propname)
691         # drop class table and indexes
692         self.drop_class_table_indexes(cn, spec[0])
694         self.drop_class_table(cn)
696         # drop journal table and indexes
697         self.drop_journal_table_indexes(cn)
698         sql = 'drop table %s__journal'%cn
699         self.sql(sql)
701         for ml in mls:
702             # drop multilink table and indexes
703             self.drop_multilink_table_indexes(cn, ml)
704             sql = 'drop table %s_%s'%(spec.classname, ml)
705             self.sql(sql)
707     def drop_class_table(self, cn):
708         sql = 'drop table _%s'%cn
709         self.sql(sql)
711     #
712     # Classes
713     #
714     def __getattr__(self, classname):
715         """ A convenient way of calling self.getclass(classname).
716         """
717         if classname in self.classes:
718             return self.classes[classname]
719         raise AttributeError(classname)
721     def addclass(self, cl):
722         """ Add a Class to the hyperdatabase.
723         """
724         cn = cl.classname
725         if cn in self.classes:
726             raise ValueError(cn)
727         self.classes[cn] = cl
729         # add default Edit and View permissions
730         self.security.addPermission(name="Create", klass=cn,
731             description="User is allowed to create "+cn)
732         self.security.addPermission(name="Edit", klass=cn,
733             description="User is allowed to edit "+cn)
734         self.security.addPermission(name="View", klass=cn,
735             description="User is allowed to access "+cn)
737     def getclasses(self):
738         """ Return a list of the names of all existing classes.
739         """
740         return sorted(self.classes)
742     def getclass(self, classname):
743         """Get the Class object representing a particular class.
745         If 'classname' is not a valid class name, a KeyError is raised.
746         """
747         try:
748             return self.classes[classname]
749         except KeyError:
750             raise KeyError('There is no class called "%s"'%classname)
752     def clear(self):
753         """Delete all database contents.
755         Note: I don't commit here, which is different behaviour to the
756               "nuke from orbit" behaviour in the dbs.
757         """
758         logging.getLogger('roundup.hyperdb').info('clear')
759         for cn in self.classes:
760             sql = 'delete from _%s'%cn
761             self.sql(sql)
763     #
764     # Nodes
765     #
767     hyperdb_to_sql_value = {
768         hyperdb.String : str,
769         # fractional seconds by default
770         hyperdb.Date   : lambda x: x.formal(sep=' ', sec='%06.3f'),
771         hyperdb.Link   : int,
772         hyperdb.Interval  : str,
773         hyperdb.Password  : str,
774         hyperdb.Boolean   : lambda x: x and 'TRUE' or 'FALSE',
775         hyperdb.Number    : lambda x: x,
776         hyperdb.Multilink : lambda x: x,    # used in journal marshalling
777     }
779     def to_sql_value(self, propklass):
781         fn = self.hyperdb_to_sql_value.get(propklass)
782         if fn:
783             return fn
785         for k, v in self.hyperdb_to_sql_value.iteritems():
786             if issubclass(propklass, k):
787                 return v
789         raise ValueError('%r is not a hyperdb property class' % propklass)
791     def addnode(self, classname, nodeid, node):
792         """ Add the specified node to its class's db.
793         """
794         self.log_debug('addnode %s%s %r'%(classname,
795             nodeid, node))
797         # determine the column definitions and multilink tables
798         cl = self.classes[classname]
799         cols, mls = self.determine_columns(list(cl.properties.iteritems()))
801         # we'll be supplied these props if we're doing an import
802         values = node.copy()
803         if 'creator' not in values:
804             # add in the "calculated" properties (dupe so we don't affect
805             # calling code's node assumptions)
806             values['creation'] = values['activity'] = date.Date()
807             values['actor'] = values['creator'] = self.getuid()
809         cl = self.classes[classname]
810         props = cl.getprops(protected=1)
811         del props['id']
813         # default the non-multilink columns
814         for col, prop in props.iteritems():
815             if col not in values:
816                 if isinstance(prop, Multilink):
817                     values[col] = []
818                 else:
819                     values[col] = None
821         # clear this node out of the cache if it's in there
822         key = (classname, nodeid)
823         if key in self.cache:
824             del self.cache[key]
825             self.cache_lru.remove(key)
827         # figure the values to insert
828         vals = []
829         for col,dt in cols:
830             # this is somewhat dodgy....
831             if col.endswith('_int__'):
832                 # XXX eugh, this test suxxors
833                 value = values[col[2:-6]]
834                 # this is an Interval special "int" column
835                 if value is not None:
836                     vals.append(value.as_seconds())
837                 else:
838                     vals.append(value)
839                 continue
841             prop = props[col[1:]]
842             value = values[col[1:]]
843             if value is not None:
844                 value = self.to_sql_value(prop.__class__)(value)
845             vals.append(value)
846         vals.append(nodeid)
847         vals = tuple(vals)
849         # make sure the ordering is correct for column name -> column value
850         s = ','.join([self.arg for x in cols]) + ',%s'%self.arg
851         cols = ','.join([col for col,dt in cols]) + ',id'
853         # perform the inserts
854         sql = 'insert into _%s (%s) values (%s)'%(classname, cols, s)
855         self.sql(sql, vals)
857         # insert the multilink rows
858         for col in mls:
859             t = '%s_%s'%(classname, col)
860             for entry in node[col]:
861                 sql = 'insert into %s (linkid, nodeid) values (%s,%s)'%(t,
862                     self.arg, self.arg)
863                 self.sql(sql, (entry, nodeid))
865     def setnode(self, classname, nodeid, values, multilink_changes={}):
866         """ Change the specified node.
867         """
868         self.log_debug('setnode %s%s %r'
869             % (classname, nodeid, values))
871         # clear this node out of the cache if it's in there
872         key = (classname, nodeid)
873         if key in self.cache:
874             del self.cache[key]
875             self.cache_lru.remove(key)
877         cl = self.classes[classname]
878         props = cl.getprops()
880         cols = []
881         mls = []
882         # add the multilinks separately
883         for col in values:
884             prop = props[col]
885             if isinstance(prop, Multilink):
886                 mls.append(col)
887             elif isinstance(prop, Interval):
888                 # Intervals store the seconds value too
889                 cols.append(col)
890                 # extra leading '_' added by code below
891                 cols.append('_' +col + '_int__')
892             else:
893                 cols.append(col)
894         cols.sort()
896         # figure the values to insert
897         vals = []
898         for col in cols:
899             if col.endswith('_int__'):
900                 # XXX eugh, this test suxxors
901                 # Intervals store the seconds value too
902                 col = col[1:-6]
903                 prop = props[col]
904                 value = values[col]
905                 if value is None:
906                     vals.append(None)
907                 else:
908                     vals.append(value.as_seconds())
909             else:
910                 prop = props[col]
911                 value = values[col]
912                 if value is None:
913                     e = None
914                 else:
915                     e = self.to_sql_value(prop.__class__)(value)
916                 vals.append(e)
918         vals.append(int(nodeid))
919         vals = tuple(vals)
921         # if there's any updates to regular columns, do them
922         if cols:
923             # make sure the ordering is correct for column name -> column value
924             s = ','.join(['_%s=%s'%(x, self.arg) for x in cols])
925             cols = ','.join(cols)
927             # perform the update
928             sql = 'update _%s set %s where id=%s'%(classname, s, self.arg)
929             self.sql(sql, vals)
931         # we're probably coming from an import, not a change
932         if not multilink_changes:
933             for name in mls:
934                 prop = props[name]
935                 value = values[name]
937                 t = '%s_%s'%(classname, name)
939                 # clear out previous values for this node
940                 # XXX numeric ids
941                 self.sql('delete from %s where nodeid=%s'%(t, self.arg),
942                         (nodeid,))
944                 # insert the values for this node
945                 for entry in values[name]:
946                     sql = 'insert into %s (linkid, nodeid) values (%s,%s)'%(t,
947                         self.arg, self.arg)
948                     # XXX numeric ids
949                     self.sql(sql, (entry, nodeid))
951         # we have multilink changes to apply
952         for col, (add, remove) in multilink_changes.iteritems():
953             tn = '%s_%s'%(classname, col)
954             if add:
955                 sql = 'insert into %s (nodeid, linkid) values (%s,%s)'%(tn,
956                     self.arg, self.arg)
957                 for addid in add:
958                     # XXX numeric ids
959                     self.sql(sql, (int(nodeid), int(addid)))
960             if remove:
961                 s = ','.join([self.arg]*len(remove))
962                 sql = 'delete from %s where nodeid=%s and linkid in (%s)'%(tn,
963                     self.arg, s)
964                 # XXX numeric ids
965                 self.sql(sql, [int(nodeid)] + remove)
967     sql_to_hyperdb_value = {
968         hyperdb.String : str,
969         hyperdb.Date   : lambda x:date.Date(str(x).replace(' ', '.')),
970 #        hyperdb.Link   : int,      # XXX numeric ids
971         hyperdb.Link   : str,
972         hyperdb.Interval  : date.Interval,
973         hyperdb.Password  : lambda x: password.Password(encrypted=x),
974         hyperdb.Boolean   : _bool_cvt,
975         hyperdb.Number    : _num_cvt,
976         hyperdb.Multilink : lambda x: x,    # used in journal marshalling
977     }
979     def to_hyperdb_value(self, propklass):
981         fn = self.sql_to_hyperdb_value.get(propklass)
982         if fn:
983             return fn
985         for k, v in self.sql_to_hyperdb_value.iteritems():
986             if issubclass(propklass, k):
987                 return v
989         raise ValueError('%r is not a hyperdb property class' % propklass)
991     def getnode(self, classname, nodeid):
992         """ Get a node from the database.
993         """
994         # see if we have this node cached
995         key = (classname, nodeid)
996         if key in self.cache:
997             # push us back to the top of the LRU
998             self.cache_lru.remove(key)
999             self.cache_lru.insert(0, key)
1000             if __debug__:
1001                 self.stats['cache_hits'] += 1
1002             # return the cached information
1003             return self.cache[key]
1005         if __debug__:
1006             self.stats['cache_misses'] += 1
1007             start_t = time.time()
1009         # figure the columns we're fetching
1010         cl = self.classes[classname]
1011         cols, mls = self.determine_columns(list(cl.properties.iteritems()))
1012         scols = ','.join([col for col,dt in cols])
1014         # perform the basic property fetch
1015         sql = 'select %s from _%s where id=%s'%(scols, classname, self.arg)
1016         self.sql(sql, (nodeid,))
1018         values = self.sql_fetchone()
1019         if values is None:
1020             raise IndexError('no such %s node %s'%(classname, nodeid))
1022         # make up the node
1023         node = {}
1024         props = cl.getprops(protected=1)
1025         for col in range(len(cols)):
1026             name = cols[col][0][1:]
1027             if name.endswith('_int__'):
1028                 # XXX eugh, this test suxxors
1029                 # ignore the special Interval-as-seconds column
1030                 continue
1031             value = values[col]
1032             if value is not None:
1033                 value = self.to_hyperdb_value(props[name].__class__)(value)
1034             node[name] = value
1037         # now the multilinks
1038         for col in mls:
1039             # get the link ids
1040             sql = 'select linkid from %s_%s where nodeid=%s'%(classname, col,
1041                 self.arg)
1042             self.sql(sql, (nodeid,))
1043             # extract the first column from the result
1044             # XXX numeric ids
1045             items = [int(x[0]) for x in self.cursor.fetchall()]
1046             items.sort ()
1047             node[col] = [str(x) for x in items]
1049         # save off in the cache
1050         key = (classname, nodeid)
1051         self.cache[key] = node
1052         # update the LRU
1053         self.cache_lru.insert(0, key)
1054         if len(self.cache_lru) > self.cache_size:
1055             del self.cache[self.cache_lru.pop()]
1057         if __debug__:
1058             self.stats['get_items'] += (time.time() - start_t)
1060         return node
1062     def destroynode(self, classname, nodeid):
1063         """Remove a node from the database. Called exclusively by the
1064            destroy() method on Class.
1065         """
1066         logging.getLogger('roundup.hyperdb').info('destroynode %s%s'%(
1067             classname, nodeid))
1069         # make sure the node exists
1070         if not self.hasnode(classname, nodeid):
1071             raise IndexError('%s has no node %s'%(classname, nodeid))
1073         # see if we have this node cached
1074         if (classname, nodeid) in self.cache:
1075             del self.cache[(classname, nodeid)]
1077         # see if there's any obvious commit actions that we should get rid of
1078         for entry in self.transactions[:]:
1079             if entry[1][:2] == (classname, nodeid):
1080                 self.transactions.remove(entry)
1082         # now do the SQL
1083         sql = 'delete from _%s where id=%s'%(classname, self.arg)
1084         self.sql(sql, (nodeid,))
1086         # remove from multilnks
1087         cl = self.getclass(classname)
1088         x, mls = self.determine_columns(list(cl.properties.iteritems()))
1089         for col in mls:
1090             # get the link ids
1091             sql = 'delete from %s_%s where nodeid=%s'%(classname, col, self.arg)
1092             self.sql(sql, (nodeid,))
1094         # remove journal entries
1095         sql = 'delete from %s__journal where nodeid=%s'%(classname, self.arg)
1096         self.sql(sql, (nodeid,))
1098         # cleanup any blob filestorage when we commit
1099         self.transactions.append((FileStorage.destroy, (self, classname, nodeid)))
1101     def hasnode(self, classname, nodeid):
1102         """ Determine if the database has a given node.
1103         """
1104         # If this node is in the cache, then we do not need to go to
1105         # the database.  (We don't consider this an LRU hit, though.)
1106         if (classname, nodeid) in self.cache:
1107             # Return 1, not True, to match the type of the result of
1108             # the SQL operation below.
1109             return 1
1110         sql = 'select count(*) from _%s where id=%s'%(classname, self.arg)
1111         self.sql(sql, (nodeid,))
1112         return int(self.cursor.fetchone()[0])
1114     def countnodes(self, classname):
1115         """ Count the number of nodes that exist for a particular Class.
1116         """
1117         sql = 'select count(*) from _%s'%classname
1118         self.sql(sql)
1119         return self.cursor.fetchone()[0]
1121     def addjournal(self, classname, nodeid, action, params, creator=None,
1122             creation=None):
1123         """ Journal the Action
1124         'action' may be:
1126             'create' or 'set' -- 'params' is a dictionary of property values
1127             'link' or 'unlink' -- 'params' is (classname, nodeid, propname)
1128             'retire' -- 'params' is None
1129         """
1130         # handle supply of the special journalling parameters (usually
1131         # supplied on importing an existing database)
1132         if creator:
1133             journaltag = creator
1134         else:
1135             journaltag = self.getuid()
1136         if creation:
1137             journaldate = creation
1138         else:
1139             journaldate = date.Date()
1141         # create the journal entry
1142         cols = 'nodeid,date,tag,action,params'
1144         self.log_debug('addjournal %s%s %r %s %s %r'%(classname,
1145             nodeid, journaldate, journaltag, action, params))
1147         # make the journalled data marshallable
1148         if isinstance(params, type({})):
1149             self._journal_marshal(params, classname)
1151         params = repr(params)
1153         dc = self.to_sql_value(hyperdb.Date)
1154         journaldate = dc(journaldate)
1156         self.save_journal(classname, cols, nodeid, journaldate,
1157             journaltag, action, params)
1159     def setjournal(self, classname, nodeid, journal):
1160         """Set the journal to the "journal" list."""
1161         # clear out any existing entries
1162         self.sql('delete from %s__journal where nodeid=%s'%(classname,
1163             self.arg), (nodeid,))
1165         # create the journal entry
1166         cols = 'nodeid,date,tag,action,params'
1168         dc = self.to_sql_value(hyperdb.Date)
1169         for nodeid, journaldate, journaltag, action, params in journal:
1170             self.log_debug('addjournal %s%s %r %s %s %r'%(
1171                 classname, nodeid, journaldate, journaltag, action,
1172                 params))
1174             # make the journalled data marshallable
1175             if isinstance(params, type({})):
1176                 self._journal_marshal(params, classname)
1177             params = repr(params)
1179             self.save_journal(classname, cols, nodeid, dc(journaldate),
1180                 journaltag, action, params)
1182     def _journal_marshal(self, params, classname):
1183         """Convert the journal params values into safely repr'able and
1184         eval'able values."""
1185         properties = self.getclass(classname).getprops()
1186         for param, value in params.iteritems():
1187             if not value:
1188                 continue
1189             property = properties[param]
1190             cvt = self.to_sql_value(property.__class__)
1191             if isinstance(property, Password):
1192                 params[param] = cvt(value)
1193             elif isinstance(property, Date):
1194                 params[param] = cvt(value)
1195             elif isinstance(property, Interval):
1196                 params[param] = cvt(value)
1197             elif isinstance(property, Boolean):
1198                 params[param] = cvt(value)
1200     def getjournal(self, classname, nodeid):
1201         """ get the journal for id
1202         """
1203         # make sure the node exists
1204         if not self.hasnode(classname, nodeid):
1205             raise IndexError('%s has no node %s'%(classname, nodeid))
1207         cols = ','.join('nodeid date tag action params'.split())
1208         journal = self.load_journal(classname, cols, nodeid)
1210         # now unmarshal the data
1211         dc = self.to_hyperdb_value(hyperdb.Date)
1212         res = []
1213         properties = self.getclass(classname).getprops()
1214         for nodeid, date_stamp, user, action, params in journal:
1215             params = eval(params)
1216             if isinstance(params, type({})):
1217                 for param, value in params.iteritems():
1218                     if not value:
1219                         continue
1220                     property = properties.get(param, None)
1221                     if property is None:
1222                         # deleted property
1223                         continue
1224                     cvt = self.to_hyperdb_value(property.__class__)
1225                     if isinstance(property, Password):
1226                         params[param] = cvt(value)
1227                     elif isinstance(property, Date):
1228                         params[param] = cvt(value)
1229                     elif isinstance(property, Interval):
1230                         params[param] = cvt(value)
1231                     elif isinstance(property, Boolean):
1232                         params[param] = cvt(value)
1233             # XXX numeric ids
1234             res.append((str(nodeid), dc(date_stamp), user, action, params))
1235         return res
1237     def save_journal(self, classname, cols, nodeid, journaldate,
1238             journaltag, action, params):
1239         """ Save the journal entry to the database
1240         """
1241         entry = (nodeid, journaldate, journaltag, action, params)
1243         # do the insert
1244         a = self.arg
1245         sql = 'insert into %s__journal (%s) values (%s,%s,%s,%s,%s)'%(
1246             classname, cols, a, a, a, a, a)
1247         self.sql(sql, entry)
1249     def load_journal(self, classname, cols, nodeid):
1250         """ Load the journal from the database
1251         """
1252         # now get the journal entries
1253         sql = 'select %s from %s__journal where nodeid=%s order by date'%(
1254             cols, classname, self.arg)
1255         self.sql(sql, (nodeid,))
1256         return self.cursor.fetchall()
1258     def pack(self, pack_before):
1259         """ Delete all journal entries except "create" before 'pack_before'.
1260         """
1261         date_stamp = self.to_sql_value(Date)(pack_before)
1263         # do the delete
1264         for classname in self.classes:
1265             sql = "delete from %s__journal where date<%s and "\
1266                 "action<>'create'"%(classname, self.arg)
1267             self.sql(sql, (date_stamp,))
1269     def sql_commit(self, fail_ok=False):
1270         """ Actually commit to the database.
1271         """
1272         logging.getLogger('roundup.hyperdb').info('commit')
1274         self.conn.commit()
1276         # open a new cursor for subsequent work
1277         self.cursor = self.conn.cursor()
1279     def commit(self, fail_ok=False):
1280         """ Commit the current transactions.
1282         Save all data changed since the database was opened or since the
1283         last commit() or rollback().
1285         fail_ok indicates that the commit is allowed to fail. This is used
1286         in the web interface when committing cleaning of the session
1287         database. We don't care if there's a concurrency issue there.
1289         The only backend this seems to affect is postgres.
1290         """
1291         # commit the database
1292         self.sql_commit(fail_ok)
1294         # now, do all the other transaction stuff
1295         for method, args in self.transactions:
1296             method(*args)
1298         # save the indexer
1299         self.indexer.save_index()
1301         # clear out the transactions
1302         self.transactions = []
1304         # clear the cache: Don't carry over cached values from one
1305         # transaction to the next (there may be other changes from other
1306         # transactions)
1307         self.clearCache()
1309     def sql_rollback(self):
1310         self.conn.rollback()
1312     def rollback(self):
1313         """ Reverse all actions from the current transaction.
1315         Undo all the changes made since the database was opened or the last
1316         commit() or rollback() was performed.
1317         """
1318         logging.getLogger('roundup.hyperdb').info('rollback')
1320         self.sql_rollback()
1322         # roll back "other" transaction stuff
1323         for method, args in self.transactions:
1324             # delete temporary files
1325             if method == self.doStoreFile:
1326                 self.rollbackStoreFile(*args)
1327         self.transactions = []
1329         # clear the cache
1330         self.clearCache()
1332     def sql_close(self):
1333         logging.getLogger('roundup.hyperdb').info('close')
1334         self.conn.close()
1336     def close(self):
1337         """ Close off the connection.
1338         """
1339         self.indexer.close()
1340         self.sql_close()
1343 # The base Class class
1345 class Class(hyperdb.Class):
1346     """ The handle to a particular class of nodes in a hyperdatabase.
1348         All methods except __repr__ and getnode must be implemented by a
1349         concrete backend Class.
1350     """
1352     def schema(self):
1353         """ A dumpable version of the schema that we can store in the
1354             database
1355         """
1356         return (self.key, [(x, repr(y)) for x,y in self.properties.iteritems()])
1358     def enableJournalling(self):
1359         """Turn journalling on for this class
1360         """
1361         self.do_journal = 1
1363     def disableJournalling(self):
1364         """Turn journalling off for this class
1365         """
1366         self.do_journal = 0
1368     # Editing nodes:
1369     def create(self, **propvalues):
1370         """ Create a new node of this class and return its id.
1372         The keyword arguments in 'propvalues' map property names to values.
1374         The values of arguments must be acceptable for the types of their
1375         corresponding properties or a TypeError is raised.
1377         If this class has a key property, it must be present and its value
1378         must not collide with other key strings or a ValueError is raised.
1380         Any other properties on this class that are missing from the
1381         'propvalues' dictionary are set to None.
1383         If an id in a link or multilink property does not refer to a valid
1384         node, an IndexError is raised.
1385         """
1386         self.fireAuditors('create', None, propvalues)
1387         newid = self.create_inner(**propvalues)
1388         self.fireReactors('create', newid, None)
1389         return newid
1391     def create_inner(self, **propvalues):
1392         """ Called by create, in-between the audit and react calls.
1393         """
1394         if 'id' in propvalues:
1395             raise KeyError('"id" is reserved')
1397         if self.db.journaltag is None:
1398             raise DatabaseError(_('Database open read-only'))
1400         if ('creator' in propvalues or 'actor' in propvalues or 
1401              'creation' in propvalues or 'activity' in propvalues):
1402             raise KeyError('"creator", "actor", "creation" and '
1403                 '"activity" are reserved')
1405         # new node's id
1406         newid = self.db.newid(self.classname)
1408         # validate propvalues
1409         num_re = re.compile('^\d+$')
1410         for key, value in propvalues.iteritems():
1411             if key == self.key:
1412                 try:
1413                     self.lookup(value)
1414                 except KeyError:
1415                     pass
1416                 else:
1417                     raise ValueError('node with key "%s" exists'%value)
1419             # try to handle this property
1420             try:
1421                 prop = self.properties[key]
1422             except KeyError:
1423                 raise KeyError('"%s" has no property "%s"'%(self.classname,
1424                     key))
1426             if value is not None and isinstance(prop, Link):
1427                 if type(value) != type(''):
1428                     raise ValueError('link value must be String')
1429                 link_class = self.properties[key].classname
1430                 # if it isn't a number, it's a key
1431                 if not num_re.match(value):
1432                     try:
1433                         value = self.db.classes[link_class].lookup(value)
1434                     except (TypeError, KeyError):
1435                         raise IndexError('new property "%s": %s not a %s'%(
1436                             key, value, link_class))
1437                 elif not self.db.getclass(link_class).hasnode(value):
1438                     raise IndexError('%s has no node %s'%(link_class,
1439                         value))
1441                 # save off the value
1442                 propvalues[key] = value
1444                 # register the link with the newly linked node
1445                 if self.do_journal and self.properties[key].do_journal:
1446                     self.db.addjournal(link_class, value, 'link',
1447                         (self.classname, newid, key))
1449             elif isinstance(prop, Multilink):
1450                 if value is None:
1451                     value = []
1452                 if not hasattr(value, '__iter__'):
1453                     raise TypeError('new property "%s" not an iterable of ids'%key) 
1454                 # clean up and validate the list of links
1455                 link_class = self.properties[key].classname
1456                 l = []
1457                 for entry in value:
1458                     if type(entry) != type(''):
1459                         raise ValueError('"%s" multilink value (%r) '
1460                             'must contain Strings'%(key, value))
1461                     # if it isn't a number, it's a key
1462                     if not num_re.match(entry):
1463                         try:
1464                             entry = self.db.classes[link_class].lookup(entry)
1465                         except (TypeError, KeyError):
1466                             raise IndexError('new property "%s": %s not a %s'%(
1467                                 key, entry, self.properties[key].classname))
1468                     l.append(entry)
1469                 value = l
1470                 propvalues[key] = value
1472                 # handle additions
1473                 for nodeid in value:
1474                     if not self.db.getclass(link_class).hasnode(nodeid):
1475                         raise IndexError('%s has no node %s'%(link_class,
1476                             nodeid))
1477                     # register the link with the newly linked node
1478                     if self.do_journal and self.properties[key].do_journal:
1479                         self.db.addjournal(link_class, nodeid, 'link',
1480                             (self.classname, newid, key))
1482             elif isinstance(prop, String):
1483                 if type(value) != type('') and type(value) != type(u''):
1484                     raise TypeError('new property "%s" not a string'%key)
1485                 if prop.indexme:
1486                     self.db.indexer.add_text((self.classname, newid, key),
1487                         value)
1489             elif isinstance(prop, Password):
1490                 if not isinstance(value, password.Password):
1491                     raise TypeError('new property "%s" not a Password'%key)
1493             elif isinstance(prop, Date):
1494                 if value is not None and not isinstance(value, date.Date):
1495                     raise TypeError('new property "%s" not a Date'%key)
1497             elif isinstance(prop, Interval):
1498                 if value is not None and not isinstance(value, date.Interval):
1499                     raise TypeError('new property "%s" not an Interval'%key)
1501             elif value is not None and isinstance(prop, Number):
1502                 try:
1503                     float(value)
1504                 except ValueError:
1505                     raise TypeError('new property "%s" not numeric'%key)
1507             elif value is not None and isinstance(prop, Boolean):
1508                 try:
1509                     int(value)
1510                 except ValueError:
1511                     raise TypeError('new property "%s" not boolean'%key)
1513         # make sure there's data where there needs to be
1514         for key, prop in self.properties.iteritems():
1515             if key in propvalues:
1516                 continue
1517             if key == self.key:
1518                 raise ValueError('key property "%s" is required'%key)
1519             if isinstance(prop, Multilink):
1520                 propvalues[key] = []
1521             else:
1522                 propvalues[key] = None
1524         # done
1525         self.db.addnode(self.classname, newid, propvalues)
1526         if self.do_journal:
1527             self.db.addjournal(self.classname, newid, ''"create", {})
1529         # XXX numeric ids
1530         return str(newid)
1532     def get(self, nodeid, propname, default=_marker, cache=1):
1533         """Get the value of a property on an existing node of this class.
1535         'nodeid' must be the id of an existing node of this class or an
1536         IndexError is raised.  'propname' must be the name of a property
1537         of this class or a KeyError is raised.
1539         'cache' exists for backwards compatibility, and is not used.
1540         """
1541         if propname == 'id':
1542             return nodeid
1544         # get the node's dict
1545         d = self.db.getnode(self.classname, nodeid)
1547         if propname == 'creation':
1548             if 'creation' in d:
1549                 return d['creation']
1550             else:
1551                 return date.Date()
1552         if propname == 'activity':
1553             if 'activity' in d:
1554                 return d['activity']
1555             else:
1556                 return date.Date()
1557         if propname == 'creator':
1558             if 'creator' in d:
1559                 return d['creator']
1560             else:
1561                 return self.db.getuid()
1562         if propname == 'actor':
1563             if 'actor' in d:
1564                 return d['actor']
1565             else:
1566                 return self.db.getuid()
1568         # get the property (raises KeyErorr if invalid)
1569         prop = self.properties[propname]
1571         # handle there being no value in the table for the property
1572         if propname not in d or d[propname] is None:
1573             if default is _marker:
1574                 if isinstance(prop, Multilink):
1575                     return []
1576                 else:
1577                     return None
1578             else:
1579                 return default
1581         # don't pass our list to other code
1582         if isinstance(prop, Multilink):
1583             return d[propname][:]
1585         return d[propname]
1587     def set(self, nodeid, **propvalues):
1588         """Modify a property on an existing node of this class.
1590         'nodeid' must be the id of an existing node of this class or an
1591         IndexError is raised.
1593         Each key in 'propvalues' must be the name of a property of this
1594         class or a KeyError is raised.
1596         All values in 'propvalues' must be acceptable types for their
1597         corresponding properties or a TypeError is raised.
1599         If the value of the key property is set, it must not collide with
1600         other key strings or a ValueError is raised.
1602         If the value of a Link or Multilink property contains an invalid
1603         node id, a ValueError is raised.
1604         """
1605         self.fireAuditors('set', nodeid, propvalues)
1606         oldvalues = copy.deepcopy(self.db.getnode(self.classname, nodeid))
1607         propvalues = self.set_inner(nodeid, **propvalues)
1608         self.fireReactors('set', nodeid, oldvalues)
1609         return propvalues
1611     def set_inner(self, nodeid, **propvalues):
1612         """ Called by set, in-between the audit and react calls.
1613         """
1614         if not propvalues:
1615             return propvalues
1617         if ('creator' in propvalues or 'actor' in propvalues or 
1618              'creation' in propvalues or 'activity' in propvalues):
1619             raise KeyError('"creator", "actor", "creation" and '
1620                 '"activity" are reserved')
1622         if 'id' in propvalues:
1623             raise KeyError('"id" is reserved')
1625         if self.db.journaltag is None:
1626             raise DatabaseError(_('Database open read-only'))
1628         node = self.db.getnode(self.classname, nodeid)
1629         if self.is_retired(nodeid):
1630             raise IndexError('Requested item is retired')
1631         num_re = re.compile('^\d+$')
1633         # make a copy of the values dictionary - we'll modify the contents
1634         propvalues = propvalues.copy()
1636         # if the journal value is to be different, store it in here
1637         journalvalues = {}
1639         # remember the add/remove stuff for multilinks, making it easier
1640         # for the Database layer to do its stuff
1641         multilink_changes = {}
1643         for propname, value in list(propvalues.items()):
1644             # check to make sure we're not duplicating an existing key
1645             if propname == self.key and node[propname] != value:
1646                 try:
1647                     self.lookup(value)
1648                 except KeyError:
1649                     pass
1650                 else:
1651                     raise ValueError('node with key "%s" exists'%value)
1653             # this will raise the KeyError if the property isn't valid
1654             # ... we don't use getprops() here because we only care about
1655             # the writeable properties.
1656             try:
1657                 prop = self.properties[propname]
1658             except KeyError:
1659                 raise KeyError('"%s" has no property named "%s"'%(
1660                     self.classname, propname))
1662             # if the value's the same as the existing value, no sense in
1663             # doing anything
1664             current = node.get(propname, None)
1665             if value == current:
1666                 del propvalues[propname]
1667                 continue
1668             journalvalues[propname] = current
1670             # do stuff based on the prop type
1671             if isinstance(prop, Link):
1672                 link_class = prop.classname
1673                 # if it isn't a number, it's a key
1674                 if value is not None and not isinstance(value, type('')):
1675                     raise ValueError('property "%s" link value be a string'%(
1676                         propname))
1677                 if isinstance(value, type('')) and not num_re.match(value):
1678                     try:
1679                         value = self.db.classes[link_class].lookup(value)
1680                     except (TypeError, KeyError):
1681                         raise IndexError('new property "%s": %s not a %s'%(
1682                             propname, value, prop.classname))
1684                 if (value is not None and
1685                         not self.db.getclass(link_class).hasnode(value)):
1686                     raise IndexError('%s has no node %s'%(link_class,
1687                         value))
1689                 if self.do_journal and prop.do_journal:
1690                     # register the unlink with the old linked node
1691                     if node[propname] is not None:
1692                         self.db.addjournal(link_class, node[propname],
1693                             ''"unlink", (self.classname, nodeid, propname))
1695                     # register the link with the newly linked node
1696                     if value is not None:
1697                         self.db.addjournal(link_class, value, ''"link",
1698                             (self.classname, nodeid, propname))
1700             elif isinstance(prop, Multilink):
1701                 if value is None:
1702                     value = []
1703                 if not hasattr(value, '__iter__'):
1704                     raise TypeError('new property "%s" not an iterable of'
1705                         ' ids'%propname)
1706                 link_class = self.properties[propname].classname
1707                 l = []
1708                 for entry in value:
1709                     # if it isn't a number, it's a key
1710                     if type(entry) != type(''):
1711                         raise ValueError('new property "%s" link value '
1712                             'must be a string'%propname)
1713                     if not num_re.match(entry):
1714                         try:
1715                             entry = self.db.classes[link_class].lookup(entry)
1716                         except (TypeError, KeyError):
1717                             raise IndexError('new property "%s": %s not a %s'%(
1718                                 propname, entry,
1719                                 self.properties[propname].classname))
1720                     l.append(entry)
1721                 value = l
1722                 propvalues[propname] = value
1724                 # figure the journal entry for this property
1725                 add = []
1726                 remove = []
1728                 # handle removals
1729                 if propname in node:
1730                     l = node[propname]
1731                 else:
1732                     l = []
1733                 for id in l[:]:
1734                     if id in value:
1735                         continue
1736                     # register the unlink with the old linked node
1737                     if self.do_journal and self.properties[propname].do_journal:
1738                         self.db.addjournal(link_class, id, 'unlink',
1739                             (self.classname, nodeid, propname))
1740                     l.remove(id)
1741                     remove.append(id)
1743                 # handle additions
1744                 for id in value:
1745                     if id in l:
1746                         continue
1747                     # We can safely check this condition after
1748                     # checking that this is an addition to the
1749                     # multilink since the condition was checked for
1750                     # existing entries at the point they were added to
1751                     # the multilink.  Since the hasnode call will
1752                     # result in a SQL query, it is more efficient to
1753                     # avoid the check if possible.
1754                     if not self.db.getclass(link_class).hasnode(id):
1755                         raise IndexError('%s has no node %s'%(link_class,
1756                             id))
1757                     # register the link with the newly linked node
1758                     if self.do_journal and self.properties[propname].do_journal:
1759                         self.db.addjournal(link_class, id, 'link',
1760                             (self.classname, nodeid, propname))
1761                     l.append(id)
1762                     add.append(id)
1764                 # figure the journal entry
1765                 l = []
1766                 if add:
1767                     l.append(('+', add))
1768                 if remove:
1769                     l.append(('-', remove))
1770                 multilink_changes[propname] = (add, remove)
1771                 if l:
1772                     journalvalues[propname] = tuple(l)
1774             elif isinstance(prop, String):
1775                 if value is not None and type(value) != type('') and type(value) != type(u''):
1776                     raise TypeError('new property "%s" not a string'%propname)
1777                 if prop.indexme:
1778                     if value is None: value = ''
1779                     self.db.indexer.add_text((self.classname, nodeid, propname),
1780                         value)
1782             elif isinstance(prop, Password):
1783                 if not isinstance(value, password.Password):
1784                     raise TypeError('new property "%s" not a Password'%propname)
1785                 propvalues[propname] = value
1787             elif value is not None and isinstance(prop, Date):
1788                 if not isinstance(value, date.Date):
1789                     raise TypeError('new property "%s" not a Date'% propname)
1790                 propvalues[propname] = value
1792             elif value is not None and isinstance(prop, Interval):
1793                 if not isinstance(value, date.Interval):
1794                     raise TypeError('new property "%s" not an '
1795                         'Interval'%propname)
1796                 propvalues[propname] = value
1798             elif value is not None and isinstance(prop, Number):
1799                 try:
1800                     float(value)
1801                 except ValueError:
1802                     raise TypeError('new property "%s" not numeric'%propname)
1804             elif value is not None and isinstance(prop, Boolean):
1805                 try:
1806                     int(value)
1807                 except ValueError:
1808                     raise TypeError('new property "%s" not boolean'%propname)
1810         # nothing to do?
1811         if not propvalues:
1812             return propvalues
1814         # update the activity time
1815         propvalues['activity'] = date.Date()
1816         propvalues['actor'] = self.db.getuid()
1818         # do the set
1819         self.db.setnode(self.classname, nodeid, propvalues, multilink_changes)
1821         # remove the activity props now they're handled
1822         del propvalues['activity']
1823         del propvalues['actor']
1825         # journal the set
1826         if self.do_journal:
1827             self.db.addjournal(self.classname, nodeid, ''"set", journalvalues)
1829         return propvalues
1831     def retire(self, nodeid):
1832         """Retire a node.
1834         The properties on the node remain available from the get() method,
1835         and the node's id is never reused.
1837         Retired nodes are not returned by the find(), list(), or lookup()
1838         methods, and other nodes may reuse the values of their key properties.
1839         """
1840         if self.db.journaltag is None:
1841             raise DatabaseError(_('Database open read-only'))
1843         self.fireAuditors('retire', nodeid, None)
1845         # use the arg for __retired__ to cope with any odd database type
1846         # conversion (hello, sqlite)
1847         sql = 'update _%s set __retired__=%s where id=%s'%(self.classname,
1848             self.db.arg, self.db.arg)
1849         self.db.sql(sql, (nodeid, nodeid))
1850         if self.do_journal:
1851             self.db.addjournal(self.classname, nodeid, ''"retired", None)
1853         self.fireReactors('retire', nodeid, None)
1855     def restore(self, nodeid):
1856         """Restore a retired node.
1858         Make node available for all operations like it was before retirement.
1859         """
1860         if self.db.journaltag is None:
1861             raise DatabaseError(_('Database open read-only'))
1863         node = self.db.getnode(self.classname, nodeid)
1864         # check if key property was overrided
1865         key = self.getkey()
1866         try:
1867             id = self.lookup(node[key])
1868         except KeyError:
1869             pass
1870         else:
1871             raise KeyError("Key property (%s) of retired node clashes "
1872                 "with existing one (%s)" % (key, node[key]))
1874         self.fireAuditors('restore', nodeid, None)
1875         # use the arg for __retired__ to cope with any odd database type
1876         # conversion (hello, sqlite)
1877         sql = 'update _%s set __retired__=%s where id=%s'%(self.classname,
1878             self.db.arg, self.db.arg)
1879         self.db.sql(sql, (0, nodeid))
1880         if self.do_journal:
1881             self.db.addjournal(self.classname, nodeid, ''"restored", None)
1883         self.fireReactors('restore', nodeid, None)
1885     def is_retired(self, nodeid):
1886         """Return true if the node is rerired
1887         """
1888         sql = 'select __retired__ from _%s where id=%s'%(self.classname,
1889             self.db.arg)
1890         self.db.sql(sql, (nodeid,))
1891         return int(self.db.sql_fetchone()[0]) > 0
1893     def destroy(self, nodeid):
1894         """Destroy a node.
1896         WARNING: this method should never be used except in extremely rare
1897                  situations where there could never be links to the node being
1898                  deleted
1900         WARNING: use retire() instead
1902         WARNING: the properties of this node will not be available ever again
1904         WARNING: really, use retire() instead
1906         Well, I think that's enough warnings. This method exists mostly to
1907         support the session storage of the cgi interface.
1909         The node is completely removed from the hyperdb, including all journal
1910         entries. It will no longer be available, and will generally break code
1911         if there are any references to the node.
1912         """
1913         if self.db.journaltag is None:
1914             raise DatabaseError(_('Database open read-only'))
1915         self.db.destroynode(self.classname, nodeid)
1917     def history(self, nodeid):
1918         """Retrieve the journal of edits on a particular node.
1920         'nodeid' must be the id of an existing node of this class or an
1921         IndexError is raised.
1923         The returned list contains tuples of the form
1925             (nodeid, date, tag, action, params)
1927         'date' is a Timestamp object specifying the time of the change and
1928         'tag' is the journaltag specified when the database was opened.
1929         """
1930         if not self.do_journal:
1931             raise ValueError('Journalling is disabled for this class')
1932         return self.db.getjournal(self.classname, nodeid)
1934     # Locating nodes:
1935     def hasnode(self, nodeid):
1936         """Determine if the given nodeid actually exists
1937         """
1938         return self.db.hasnode(self.classname, nodeid)
1940     def setkey(self, propname):
1941         """Select a String property of this class to be the key property.
1943         'propname' must be the name of a String property of this class or
1944         None, or a TypeError is raised.  The values of the key property on
1945         all existing nodes must be unique or a ValueError is raised.
1946         """
1947         prop = self.getprops()[propname]
1948         if not isinstance(prop, String):
1949             raise TypeError('key properties must be String')
1950         self.key = propname
1952     def getkey(self):
1953         """Return the name of the key property for this class or None."""
1954         return self.key
1956     def lookup(self, keyvalue):
1957         """Locate a particular node by its key property and return its id.
1959         If this class has no key property, a TypeError is raised.  If the
1960         'keyvalue' matches one of the values for the key property among
1961         the nodes in this class, the matching node's id is returned;
1962         otherwise a KeyError is raised.
1963         """
1964         if not self.key:
1965             raise TypeError('No key property set for class %s'%self.classname)
1967         # use the arg to handle any odd database type conversion (hello,
1968         # sqlite)
1969         sql = "select id from _%s where _%s=%s and __retired__=%s"%(
1970             self.classname, self.key, self.db.arg, self.db.arg)
1971         self.db.sql(sql, (str(keyvalue), 0))
1973         # see if there was a result that's not retired
1974         row = self.db.sql_fetchone()
1975         if not row:
1976             raise KeyError('No key (%s) value "%s" for "%s"'%(self.key,
1977                 keyvalue, self.classname))
1979         # return the id
1980         # XXX numeric ids
1981         return str(row[0])
1983     def find(self, **propspec):
1984         """Get the ids of nodes in this class which link to the given nodes.
1986         'propspec' consists of keyword args propname=nodeid or
1987                    propname={nodeid:1, }
1988         'propname' must be the name of a property in this class, or a
1989                    KeyError is raised.  That property must be a Link or
1990                    Multilink property, or a TypeError is raised.
1992         Any node in this class whose 'propname' property links to any of
1993         the nodeids will be returned. Examples::
1995             db.issue.find(messages='1')
1996             db.issue.find(messages={'1':1,'3':1}, files={'7':1})
1997         """
1998         # shortcut
1999         if not propspec:
2000             return []
2002         # validate the args
2003         props = self.getprops()
2004         for propname, nodeids in propspec.iteritems():
2005             # check the prop is OK
2006             prop = props[propname]
2007             if not isinstance(prop, Link) and not isinstance(prop, Multilink):
2008                 raise TypeError("'%s' not a Link/Multilink property"%propname)
2010         # first, links
2011         a = self.db.arg
2012         allvalues = ()
2013         sql = []
2014         where = []
2015         for prop, values in propspec.iteritems():
2016             if not isinstance(props[prop], hyperdb.Link):
2017                 continue
2018             if type(values) is type({}) and len(values) == 1:
2019                 values = list(values)[0]
2020             if type(values) is type(''):
2021                 allvalues += (values,)
2022                 where.append('_%s = %s'%(prop, a))
2023             elif values is None:
2024                 where.append('_%s is NULL'%prop)
2025             else:
2026                 values = list(values)
2027                 s = ''
2028                 if None in values:
2029                     values.remove(None)
2030                     s = '_%s is NULL or '%prop
2031                 allvalues += tuple(values)
2032                 s += '_%s in (%s)'%(prop, ','.join([a]*len(values)))
2033                 where.append('(' + s +')')
2034         if where:
2035             allvalues = (0, ) + allvalues
2036             sql.append("""select id from _%s where  __retired__=%s
2037                 and %s"""%(self.classname, a, ' and '.join(where)))
2039         # now multilinks
2040         for prop, values in propspec.iteritems():
2041             if not isinstance(props[prop], hyperdb.Multilink):
2042                 continue
2043             if not values:
2044                 continue
2045             allvalues += (0, )
2046             if type(values) is type(''):
2047                 allvalues += (values,)
2048                 s = a
2049             else:
2050                 allvalues += tuple(values)
2051                 s = ','.join([a]*len(values))
2052             tn = '%s_%s'%(self.classname, prop)
2053             sql.append("""select id from _%s, %s where  __retired__=%s
2054                   and id = %s.nodeid and %s.linkid in (%s)"""%(self.classname,
2055                   tn, a, tn, tn, s))
2057         if not sql:
2058             return []
2059         sql = ' union '.join(sql)
2060         self.db.sql(sql, allvalues)
2061         # XXX numeric ids
2062         l = [str(x[0]) for x in self.db.sql_fetchall()]
2063         return l
2065     def stringFind(self, **requirements):
2066         """Locate a particular node by matching a set of its String
2067         properties in a caseless search.
2069         If the property is not a String property, a TypeError is raised.
2071         The return is a list of the id of all nodes that match.
2072         """
2073         where = []
2074         args = []
2075         for propname in requirements:
2076             prop = self.properties[propname]
2077             if not isinstance(prop, String):
2078                 raise TypeError("'%s' not a String property"%propname)
2079             where.append(propname)
2080             args.append(requirements[propname].lower())
2082         # generate the where clause
2083         s = ' and '.join(['lower(_%s)=%s'%(col, self.db.arg) for col in where])
2084         sql = 'select id from _%s where %s and __retired__=%s'%(
2085             self.classname, s, self.db.arg)
2086         args.append(0)
2087         self.db.sql(sql, tuple(args))
2088         # XXX numeric ids
2089         l = [str(x[0]) for x in self.db.sql_fetchall()]
2090         return l
2092     def list(self):
2093         """ Return a list of the ids of the active nodes in this class.
2094         """
2095         return self.getnodeids(retired=0)
2097     def getnodeids(self, retired=None):
2098         """ Retrieve all the ids of the nodes for a particular Class.
2100             Set retired=None to get all nodes. Otherwise it'll get all the
2101             retired or non-retired nodes, depending on the flag.
2102         """
2103         # flip the sense of the 'retired' flag if we don't want all of them
2104         if retired is not None:
2105             args = (0, )
2106             if retired:
2107                 compare = '>'
2108             else:
2109                 compare = '='
2110             sql = 'select id from _%s where __retired__%s%s'%(self.classname,
2111                 compare, self.db.arg)
2112         else:
2113             args = ()
2114             sql = 'select id from _%s'%self.classname
2115         self.db.sql(sql, args)
2116         # XXX numeric ids
2117         ids = [str(x[0]) for x in self.db.cursor.fetchall()]
2118         return ids
2120     def _subselect(self, classname, multilink_table):
2121         """Create a subselect. This is factored out because some
2122            databases (hmm only one, so far) doesn't support subselects
2123            look for "I can't believe it's not a toy RDBMS" in the mysql
2124            backend.
2125         """
2126         return '_%s.id not in (select nodeid from %s)'%(classname,
2127             multilink_table)
2129     # Some DBs order NULL values last. Set this variable in the backend
2130     # for prepending an order by clause for each attribute that causes
2131     # correct sort order for NULLs. Examples:
2132     # order_by_null_values = '(%s is not NULL)'
2133     # order_by_null_values = 'notnull(%s)'
2134     # The format parameter is replaced with the attribute.
2135     order_by_null_values = None
2137     def filter(self, search_matches, filterspec, sort=[], group=[]):
2138         """Return a list of the ids of the active nodes in this class that
2139         match the 'filter' spec, sorted by the group spec and then the
2140         sort spec
2142         "filterspec" is {propname: value(s)}
2144         "sort" and "group" are [(dir, prop), ...] where dir is '+', '-'
2145         or None and prop is a prop name or None. Note that for
2146         backward-compatibility reasons a single (dir, prop) tuple is
2147         also allowed.
2149         "search_matches" is a container type or None
2151         The filter must match all properties specificed. If the property
2152         value to match is a list:
2154         1. String properties must match all elements in the list, and
2155         2. Other properties must match any of the elements in the list.
2156         """
2157         # we can't match anything if search_matches is empty
2158         if not search_matches and search_matches is not None:
2159             return []
2161         if __debug__:
2162             start_t = time.time()
2164         icn = self.classname
2166         # vars to hold the components of the SQL statement
2167         frum = []       # FROM clauses
2168         loj = []        # LEFT OUTER JOIN clauses
2169         where = []      # WHERE clauses
2170         args = []       # *any* positional arguments
2171         a = self.db.arg
2173         # figure the WHERE clause from the filterspec
2174         mlfilt = 0      # are we joining with Multilink tables?
2175         sortattr = self._sortattr (group = group, sort = sort)
2176         proptree = self._proptree(filterspec, sortattr)
2177         mlseen = 0
2178         for pt in reversed(proptree.sortattr):
2179             p = pt
2180             while p.parent:
2181                 if isinstance (p.propclass, Multilink):
2182                     mlseen = True
2183                 if mlseen:
2184                     p.sort_ids_needed = True
2185                     p.tree_sort_done = False
2186                 p = p.parent
2187             if not mlseen:
2188                 pt.attr_sort_done = pt.tree_sort_done = True
2189         proptree.compute_sort_done()
2191         ordercols = []
2192         auxcols = {}
2193         mlsort = []
2194         rhsnum = 0
2195         for p in proptree:
2196             oc = None
2197             cn = p.classname
2198             ln = p.uniqname
2199             pln = p.parent.uniqname
2200             pcn = p.parent.classname
2201             k = p.name
2202             v = p.val
2203             propclass = p.propclass
2204             if p.sort_type > 0:
2205                 oc = ac = '_%s._%s'%(pln, k)
2206             if isinstance(propclass, Multilink):
2207                 if p.sort_type < 2:
2208                     mlfilt = 1
2209                     tn = '%s_%s'%(pcn, k)
2210                     if v in ('-1', ['-1'], []):
2211                         # only match rows that have count(linkid)=0 in the
2212                         # corresponding multilink table)
2213                         where.append(self._subselect(pcn, tn))
2214                     else:
2215                         frum.append(tn)
2216                         where.append('_%s.id=%s.nodeid'%(pln,tn))
2217                         if p.children:
2218                             frum.append('_%s as _%s' % (cn, ln))
2219                             where.append('%s.linkid=_%s.id'%(tn, ln))
2220                         if p.has_values:
2221                             if isinstance(v, type([])):
2222                                 s = ','.join([a for x in v])
2223                                 where.append('%s.linkid in (%s)'%(tn, s))
2224                                 args = args + v
2225                             else:
2226                                 where.append('%s.linkid=%s'%(tn, a))
2227                                 args.append(v)
2228                 if p.sort_type > 0:
2229                     assert not p.attr_sort_done and not p.sort_ids_needed
2230             elif k == 'id':
2231                 if p.sort_type < 2:
2232                     if isinstance(v, type([])):
2233                         # If there are no permitted values, then the
2234                         # where clause will always be false, and we
2235                         # can optimize the query away.
2236                         if not v:
2237                             return []
2238                         s = ','.join([a for x in v])
2239                         where.append('_%s.%s in (%s)'%(pln, k, s))
2240                         args = args + v
2241                     else:
2242                         where.append('_%s.%s=%s'%(pln, k, a))
2243                         args.append(v)
2244                 if p.sort_type > 0:
2245                     oc = ac = '_%s.id'%pln
2246             elif isinstance(propclass, String):
2247                 if p.sort_type < 2:
2248                     if not isinstance(v, type([])):
2249                         v = [v]
2251                     # Quote the bits in the string that need it and then embed
2252                     # in a "substring" search. Note - need to quote the '%' so
2253                     # they make it through the python layer happily
2254                     v = ['%%'+self.db.sql_stringquote(s)+'%%' for s in v]
2256                     # now add to the where clause
2257                     where.append('('
2258                         +' and '.join(["_%s._%s LIKE '%s'"%(pln, k, s) for s in v])
2259                         +')')
2260                     # note: args are embedded in the query string now
2261                 if p.sort_type > 0:
2262                     oc = ac = 'lower(_%s._%s)'%(pln, k)
2263             elif isinstance(propclass, Link):
2264                 if p.sort_type < 2:
2265                     if p.children:
2266                         if p.sort_type == 0:
2267                             frum.append('_%s as _%s' % (cn, ln))
2268                         where.append('_%s._%s=_%s.id'%(pln, k, ln))
2269                     if p.has_values:
2270                         if isinstance(v, type([])):
2271                             d = {}
2272                             for entry in v:
2273                                 if entry == '-1':
2274                                     entry = None
2275                                 d[entry] = entry
2276                             l = []
2277                             if None in d or not d:
2278                                 if None in d: del d[None]
2279                                 l.append('_%s._%s is NULL'%(pln, k))
2280                             if d:
2281                                 v = list(d)
2282                                 s = ','.join([a for x in v])
2283                                 l.append('(_%s._%s in (%s))'%(pln, k, s))
2284                                 args = args + v
2285                             if l:
2286                                 where.append('(' + ' or '.join(l) +')')
2287                         else:
2288                             if v in ('-1', None):
2289                                 v = None
2290                                 where.append('_%s._%s is NULL'%(pln, k))
2291                             else:
2292                                 where.append('_%s._%s=%s'%(pln, k, a))
2293                                 args.append(v)
2294                 if p.sort_type > 0:
2295                     lp = p.cls.labelprop()
2296                     oc = ac = '_%s._%s'%(pln, k)
2297                     if lp != 'id':
2298                         if p.tree_sort_done and p.sort_type > 0:
2299                             loj.append(
2300                                 'LEFT OUTER JOIN _%s as _%s on _%s._%s=_%s.id'%(
2301                                 cn, ln, pln, k, ln))
2302                         oc = '_%s._%s'%(ln, lp)
2303             elif isinstance(propclass, Date) and p.sort_type < 2:
2304                 dc = self.db.to_sql_value(hyperdb.Date)
2305                 if isinstance(v, type([])):
2306                     s = ','.join([a for x in v])
2307                     where.append('_%s._%s in (%s)'%(pln, k, s))
2308                     args = args + [dc(date.Date(x)) for x in v]
2309                 else:
2310                     try:
2311                         # Try to filter on range of dates
2312                         date_rng = propclass.range_from_raw(v, self.db)
2313                         if date_rng.from_value:
2314                             where.append('_%s._%s >= %s'%(pln, k, a))
2315                             args.append(dc(date_rng.from_value))
2316                         if date_rng.to_value:
2317                             where.append('_%s._%s <= %s'%(pln, k, a))
2318                             args.append(dc(date_rng.to_value))
2319                     except ValueError:
2320                         # If range creation fails - ignore that search parameter
2321                         pass
2322             elif isinstance(propclass, Interval):
2323                 # filter/sort using the __<prop>_int__ column
2324                 if p.sort_type < 2:
2325                     if isinstance(v, type([])):
2326                         s = ','.join([a for x in v])
2327                         where.append('_%s.__%s_int__ in (%s)'%(pln, k, s))
2328                         args = args + [date.Interval(x).as_seconds() for x in v]
2329                     else:
2330                         try:
2331                             # Try to filter on range of intervals
2332                             date_rng = Range(v, date.Interval)
2333                             if date_rng.from_value:
2334                                 where.append('_%s.__%s_int__ >= %s'%(pln, k, a))
2335                                 args.append(date_rng.from_value.as_seconds())
2336                             if date_rng.to_value:
2337                                 where.append('_%s.__%s_int__ <= %s'%(pln, k, a))
2338                                 args.append(date_rng.to_value.as_seconds())
2339                         except ValueError:
2340                             # If range creation fails - ignore search parameter
2341                             pass
2342                 if p.sort_type > 0:
2343                     oc = ac = '_%s.__%s_int__'%(pln,k)
2344             elif isinstance(propclass, Boolean) and p.sort_type < 2:
2345                 if type(v) == type(""):
2346                     v = v.split(',')
2347                 if type(v) != type([]):
2348                     v = [v]
2349                 bv = []
2350                 for val in v:
2351                     if type(val) is type(''):
2352                         bv.append(propclass.from_raw (val))
2353                     else:
2354                         bv.append(bool(val))
2355                 if len(bv) == 1:
2356                     where.append('_%s._%s=%s'%(pln, k, a))
2357                     args = args + bv
2358                 else:
2359                     s = ','.join([a for x in v])
2360                     where.append('_%s._%s in (%s)'%(pln, k, s))
2361                     args = args + bv
2362             elif p.sort_type < 2:
2363                 if isinstance(v, type([])):
2364                     s = ','.join([a for x in v])
2365                     where.append('_%s._%s in (%s)'%(pln, k, s))
2366                     args = args + v
2367                 else:
2368                     where.append('_%s._%s=%s'%(pln, k, a))
2369                     args.append(v)
2370             if oc:
2371                 if p.sort_ids_needed:
2372                     auxcols[ac] = p
2373                 if p.tree_sort_done and p.sort_direction:
2374                     # Don't select top-level id twice
2375                     if p.name != 'id' or p.parent != proptree:
2376                         ordercols.append(oc)
2377                     desc = ['', ' desc'][p.sort_direction == '-']
2378                     # Some SQL dbs sort NULL values last -- we want them first.
2379                     if (self.order_by_null_values and p.name != 'id'):
2380                         nv = self.order_by_null_values % oc
2381                         ordercols.append(nv)
2382                         p.orderby.append(nv + desc)
2383                     p.orderby.append(oc + desc)
2385         props = self.getprops()
2387         # don't match retired nodes
2388         where.append('_%s.__retired__=0'%icn)
2390         # add results of full text search
2391         if search_matches is not None:
2392             s = ','.join([a for x in search_matches])
2393             where.append('_%s.id in (%s)'%(icn, s))
2394             args = args + [x for x in search_matches]
2396         # construct the SQL
2397         frum.append('_'+icn)
2398         frum = ','.join(frum)
2399         if where:
2400             where = ' where ' + (' and '.join(where))
2401         else:
2402             where = ''
2403         if mlfilt:
2404             # we're joining tables on the id, so we will get dupes if we
2405             # don't distinct()
2406             cols = ['distinct(_%s.id)'%icn]
2407         else:
2408             cols = ['_%s.id'%icn]
2409         if ordercols:
2410             cols = cols + ordercols
2411         order = []
2412         # keep correct sequence of order attributes.
2413         for sa in proptree.sortattr:
2414             if not sa.attr_sort_done:
2415                 continue
2416             order.extend(sa.orderby)
2417         if order:
2418             order = ' order by %s'%(','.join(order))
2419         else:
2420             order = ''
2421         for o, p in auxcols.iteritems ():
2422             cols.append (o)
2423             p.auxcol = len (cols) - 1
2425         cols = ','.join(cols)
2426         loj = ' '.join(loj)
2427         sql = 'select %s from %s %s %s%s'%(cols, frum, loj, where, order)
2428         args = tuple(args)
2429         __traceback_info__ = (sql, args)
2430         self.db.sql(sql, args)
2431         l = self.db.sql_fetchall()
2433         # Compute values needed for sorting in proptree.sort
2434         for p in auxcols.itervalues():
2435             p.sort_ids = p.sort_result = [row[p.auxcol] for row in l]
2436         # return the IDs (the first column)
2437         # XXX numeric ids
2438         l = [str(row[0]) for row in l]
2439         l = proptree.sort (l)
2441         if __debug__:
2442             self.db.stats['filtering'] += (time.time() - start_t)
2443         return l
2445     def filter_sql(self, sql):
2446         """Return a list of the ids of the items in this class that match
2447         the SQL provided. The SQL is a complete "select" statement.
2449         The SQL select must include the item id as the first column.
2451         This function DOES NOT filter out retired items, add on a where
2452         clause "__retired__=0" if you don't want retired nodes.
2453         """
2454         if __debug__:
2455             start_t = time.time()
2457         self.db.sql(sql)
2458         l = self.db.sql_fetchall()
2460         if __debug__:
2461             self.db.stats['filtering'] += (time.time() - start_t)
2462         return l
2464     def count(self):
2465         """Get the number of nodes in this class.
2467         If the returned integer is 'numnodes', the ids of all the nodes
2468         in this class run from 1 to numnodes, and numnodes+1 will be the
2469         id of the next node to be created in this class.
2470         """
2471         return self.db.countnodes(self.classname)
2473     # Manipulating properties:
2474     def getprops(self, protected=1):
2475         """Return a dictionary mapping property names to property objects.
2476            If the "protected" flag is true, we include protected properties -
2477            those which may not be modified.
2478         """
2479         d = self.properties.copy()
2480         if protected:
2481             d['id'] = String()
2482             d['creation'] = hyperdb.Date()
2483             d['activity'] = hyperdb.Date()
2484             d['creator'] = hyperdb.Link('user')
2485             d['actor'] = hyperdb.Link('user')
2486         return d
2488     def addprop(self, **properties):
2489         """Add properties to this class.
2491         The keyword arguments in 'properties' must map names to property
2492         objects, or a TypeError is raised.  None of the keys in 'properties'
2493         may collide with the names of existing properties, or a ValueError
2494         is raised before any properties have been added.
2495         """
2496         for key in properties:
2497             if key in self.properties:
2498                 raise ValueError(key)
2499         self.properties.update(properties)
2501     def index(self, nodeid):
2502         """Add (or refresh) the node to search indexes
2503         """
2504         # find all the String properties that have indexme
2505         for prop, propclass in self.getprops().iteritems():
2506             if isinstance(propclass, String) and propclass.indexme:
2507                 self.db.indexer.add_text((self.classname, nodeid, prop),
2508                     str(self.get(nodeid, prop)))
2510     #
2511     # import / export support
2512     #
2513     def export_list(self, propnames, nodeid):
2514         """ Export a node - generate a list of CSV-able data in the order
2515             specified by propnames for the given node.
2516         """
2517         properties = self.getprops()
2518         l = []
2519         for prop in propnames:
2520             proptype = properties[prop]
2521             value = self.get(nodeid, prop)
2522             # "marshal" data where needed
2523             if value is None:
2524                 pass
2525             elif isinstance(proptype, hyperdb.Date):
2526                 value = value.get_tuple()
2527             elif isinstance(proptype, hyperdb.Interval):
2528                 value = value.get_tuple()
2529             elif isinstance(proptype, hyperdb.Password):
2530                 value = str(value)
2531             l.append(repr(value))
2532         l.append(repr(self.is_retired(nodeid)))
2533         return l
2535     def import_list(self, propnames, proplist):
2536         """ Import a node - all information including "id" is present and
2537             should not be sanity checked. Triggers are not triggered. The
2538             journal should be initialised using the "creator" and "created"
2539             information.
2541             Return the nodeid of the node imported.
2542         """
2543         if self.db.journaltag is None:
2544             raise DatabaseError(_('Database open read-only'))
2545         properties = self.getprops()
2547         # make the new node's property map
2548         d = {}
2549         retire = 0
2550         if not "id" in propnames:
2551             newid = self.db.newid(self.classname)
2552         else:
2553             newid = eval(proplist[propnames.index("id")])
2554         for i in range(len(propnames)):
2555             # Use eval to reverse the repr() used to output the CSV
2556             value = eval(proplist[i])
2558             # Figure the property for this column
2559             propname = propnames[i]
2561             # "unmarshal" where necessary
2562             if propname == 'id':
2563                 continue
2564             elif propname == 'is retired':
2565                 # is the item retired?
2566                 if int(value):
2567                     retire = 1
2568                 continue
2569             elif value is None:
2570                 d[propname] = None
2571                 continue
2573             prop = properties[propname]
2574             if value is None:
2575                 # don't set Nones
2576                 continue
2577             elif isinstance(prop, hyperdb.Date):
2578                 value = date.Date(value)
2579             elif isinstance(prop, hyperdb.Interval):
2580                 value = date.Interval(value)
2581             elif isinstance(prop, hyperdb.Password):
2582                 pwd = password.Password()
2583                 pwd.unpack(value)
2584                 value = pwd
2585             elif isinstance(prop, String):
2586                 if isinstance(value, unicode):
2587                     value = value.encode('utf8')
2588                 if not isinstance(value, str):
2589                     raise TypeError('new property "%(propname)s" not a '
2590                         'string: %(value)r'%locals())
2591                 if prop.indexme:
2592                     self.db.indexer.add_text((self.classname, newid, propname),
2593                         value)
2594             d[propname] = value
2596         # get a new id if necessary
2597         if newid is None:
2598             newid = self.db.newid(self.classname)
2600         # insert new node or update existing?
2601         if not self.hasnode(newid):
2602             self.db.addnode(self.classname, newid, d) # insert
2603         else:
2604             self.db.setnode(self.classname, newid, d) # update
2606         # retire?
2607         if retire:
2608             # use the arg for __retired__ to cope with any odd database type
2609             # conversion (hello, sqlite)
2610             sql = 'update _%s set __retired__=%s where id=%s'%(self.classname,
2611                 self.db.arg, self.db.arg)
2612             self.db.sql(sql, (newid, newid))
2613         return newid
2615     def export_journals(self):
2616         """Export a class's journal - generate a list of lists of
2617         CSV-able data:
2619             nodeid, date, user, action, params
2621         No heading here - the columns are fixed.
2622         """
2623         properties = self.getprops()
2624         r = []
2625         for nodeid in self.getnodeids():
2626             for nodeid, date, user, action, params in self.history(nodeid):
2627                 date = date.get_tuple()
2628                 if action == 'set':
2629                     export_data = {}
2630                     for propname, value in params.iteritems():
2631                         if propname not in properties:
2632                             # property no longer in the schema
2633                             continue
2635                         prop = properties[propname]
2636                         # make sure the params are eval()'able
2637                         if value is None:
2638                             pass
2639                         elif isinstance(prop, Date):
2640                             value = value.get_tuple()
2641                         elif isinstance(prop, Interval):
2642                             value = value.get_tuple()
2643                         elif isinstance(prop, Password):
2644                             value = str(value)
2645                         export_data[propname] = value
2646                     params = export_data
2647                 elif action == 'create' and params:
2648                     # old tracker with data stored in the create!
2649                     params = {}
2650                 l = [nodeid, date, user, action, params]
2651                 r.append(list(map(repr, l)))
2652         return r
2654 class FileClass(hyperdb.FileClass, Class):
2655     """This class defines a large chunk of data. To support this, it has a
2656        mandatory String property "content" which is typically saved off
2657        externally to the hyperdb.
2659        The default MIME type of this data is defined by the
2660        "default_mime_type" class attribute, which may be overridden by each
2661        node if the class defines a "type" String property.
2662     """
2663     def __init__(self, db, classname, **properties):
2664         """The newly-created class automatically includes the "content"
2665         and "type" properties.
2666         """
2667         if 'content' not in properties:
2668             properties['content'] = hyperdb.String(indexme='yes')
2669         if 'type' not in properties:
2670             properties['type'] = hyperdb.String()
2671         Class.__init__(self, db, classname, **properties)
2673     def create(self, **propvalues):
2674         """ snaffle the file propvalue and store in a file
2675         """
2676         # we need to fire the auditors now, or the content property won't
2677         # be in propvalues for the auditors to play with
2678         self.fireAuditors('create', None, propvalues)
2680         # now remove the content property so it's not stored in the db
2681         content = propvalues['content']
2682         del propvalues['content']
2684         # do the database create
2685         newid = self.create_inner(**propvalues)
2687         # figure the mime type
2688         mime_type = propvalues.get('type', self.default_mime_type)
2690         # and index!
2691         if self.properties['content'].indexme:
2692             self.db.indexer.add_text((self.classname, newid, 'content'),
2693                 content, mime_type)
2695         # store off the content as a file
2696         self.db.storefile(self.classname, newid, None, content)
2698         # fire reactors
2699         self.fireReactors('create', newid, None)
2701         return newid
2703     def get(self, nodeid, propname, default=_marker, cache=1):
2704         """ Trap the content propname and get it from the file
2706         'cache' exists for backwards compatibility, and is not used.
2707         """
2708         poss_msg = 'Possibly a access right configuration problem.'
2709         if propname == 'content':
2710             try:
2711                 return self.db.getfile(self.classname, nodeid, None)
2712             except IOError, strerror:
2713                 # BUG: by catching this we donot see an error in the log.
2714                 return 'ERROR reading file: %s%s\n%s\n%s'%(
2715                         self.classname, nodeid, poss_msg, strerror)
2716         if default is not _marker:
2717             return Class.get(self, nodeid, propname, default)
2718         else:
2719             return Class.get(self, nodeid, propname)
2721     def set(self, itemid, **propvalues):
2722         """ Snarf the "content" propvalue and update it in a file
2723         """
2724         self.fireAuditors('set', itemid, propvalues)
2725         oldvalues = copy.deepcopy(self.db.getnode(self.classname, itemid))
2727         # now remove the content property so it's not stored in the db
2728         content = None
2729         if 'content' in propvalues:
2730             content = propvalues['content']
2731             del propvalues['content']
2733         # do the database create
2734         propvalues = self.set_inner(itemid, **propvalues)
2736         # do content?
2737         if content:
2738             # store and possibly index
2739             self.db.storefile(self.classname, itemid, None, content)
2740             if self.properties['content'].indexme:
2741                 mime_type = self.get(itemid, 'type', self.default_mime_type)
2742                 self.db.indexer.add_text((self.classname, itemid, 'content'),
2743                     content, mime_type)
2744             propvalues['content'] = content
2746         # fire reactors
2747         self.fireReactors('set', itemid, oldvalues)
2748         return propvalues
2750     def index(self, nodeid):
2751         """ Add (or refresh) the node to search indexes.
2753         Use the content-type property for the content property.
2754         """
2755         # find all the String properties that have indexme
2756         for prop, propclass in self.getprops().iteritems():
2757             if prop == 'content' and propclass.indexme:
2758                 mime_type = self.get(nodeid, 'type', self.default_mime_type)
2759                 self.db.indexer.add_text((self.classname, nodeid, 'content'),
2760                     str(self.get(nodeid, 'content')), mime_type)
2761             elif isinstance(propclass, hyperdb.String) and propclass.indexme:
2762                 # index them under (classname, nodeid, property)
2763                 try:
2764                     value = str(self.get(nodeid, prop))
2765                 except IndexError:
2766                     # node has been destroyed
2767                     continue
2768                 self.db.indexer.add_text((self.classname, nodeid, prop), value)
2770 # XXX deviation from spec - was called ItemClass
2771 class IssueClass(Class, roundupdb.IssueClass):
2772     # Overridden methods:
2773     def __init__(self, db, classname, **properties):
2774         """The newly-created class automatically includes the "messages",
2775         "files", "nosy", and "superseder" properties.  If the 'properties'
2776         dictionary attempts to specify any of these properties or a
2777         "creation", "creator", "activity" or "actor" property, a ValueError
2778         is raised.
2779         """
2780         if 'title' not in properties:
2781             properties['title'] = hyperdb.String(indexme='yes')
2782         if 'messages' not in properties:
2783             properties['messages'] = hyperdb.Multilink("msg")
2784         if 'files' not in properties:
2785             properties['files'] = hyperdb.Multilink("file")
2786         if 'nosy' not in properties:
2787             # note: journalling is turned off as it really just wastes
2788             # space. this behaviour may be overridden in an instance
2789             properties['nosy'] = hyperdb.Multilink("user", do_journal="no")
2790         if 'superseder' not in properties:
2791             properties['superseder'] = hyperdb.Multilink(classname)
2792         Class.__init__(self, db, classname, **properties)
2794 # vim: set et sts=4 sw=4 :