hooks/syncschema.py
author Sylvain Thénault <sylvain.thenault@logilab.fr>
Fri, 22 Jan 2010 11:40:09 +0100
changeset 4329 815e08c53548
parent 4252 6c4f109c2b03
child 4394 b98fbca17e9e
permissions -rw-r--r--
add a reminder

"""schema hooks:

- synchronize the living schema object with the persistent schema
- perform physical update on the source when necessary

checking for schema consistency is done in hooks.py

:organization: Logilab
:copyright: 2001-2010 LOGILAB S.A. (Paris, FRANCE), license is LGPL v2.
:contact: http://www.logilab.fr/ -- mailto:contact@logilab.fr
:license: GNU Lesser General Public License, v2.1 - http://www.gnu.org/licenses
"""
__docformat__ = "restructuredtext en"

from yams.schema import BASE_TYPES, RelationSchema, RelationDefinitionSchema
from yams.buildobjs import EntityType, RelationType, RelationDefinition
from yams.schema2sql import eschema2sql, rschema2sql, type_from_constraints

from logilab.common.decorators import clear_cache

from cubicweb import ValidationError, RepositoryError
from cubicweb.selectors import implements
from cubicweb.schema import META_RTYPES, VIRTUAL_RTYPES, CONSTRAINTS
from cubicweb.server import hook, schemaserial as ss
from cubicweb.server.sqlutils import SQL_PREFIX


TYPE_CONVERTER = { # XXX
    'Boolean': bool,
    'Int': int,
    'Float': float,
    'Password': str,
    'String': unicode,
    'Date' : unicode,
    'Datetime' : unicode,
    'Time' : unicode,
    }

# core entity and relation types which can't be removed
CORE_ETYPES = list(BASE_TYPES) + ['CWEType', 'CWRType', 'CWUser', 'CWGroup',
                                  'CWConstraint', 'CWAttribute', 'CWRelation']
CORE_RTYPES = ['eid', 'creation_date', 'modification_date', 'cwuri',
               'login', 'upassword', 'name',
               'is', 'instanceof', 'owned_by', 'created_by', 'in_group',
               'relation_type', 'from_entity', 'to_entity',
               'constrainted_by',
               'read_permission', 'add_permission',
               'delete_permission', 'updated_permission',
               ]

def get_constraints(session, entity):
    constraints = []
    for cstreid in session.transaction_data.get(entity.eid, ()):
        cstrent = session.entity_from_eid(cstreid)
        cstr = CONSTRAINTS[cstrent.type].deserialize(cstrent.value)
        cstr.eid = cstreid
        constraints.append(cstr)
    return constraints

def group_mapping(cw):
    try:
        return cw.transaction_data['groupmap']
    except KeyError:
        cw.transaction_data['groupmap'] = gmap = ss.group_mapping(cw)
        return gmap

def add_inline_relation_column(session, etype, rtype):
    """add necessary column and index for an inlined relation"""
    table = SQL_PREFIX + etype
    column = SQL_PREFIX + rtype
    try:
        session.system_sql(str('ALTER TABLE %s ADD COLUMN %s integer'
                               % (table, column)), rollback_on_failure=False)
        session.info('added column %s to table %s', column, table)
    except:
        # silent exception here, if this error has not been raised because the
        # column already exists, index creation will fail anyway
        session.exception('error while adding column %s to table %s',
                          table, column)
    # create index before alter table which may expectingly fail during test
    # (sqlite) while index creation should never fail (test for index existence
    # is done by the dbhelper)
    session.pool.source('system').create_index(session, table, column)
    session.info('added index on %s(%s)', table, column)
    session.transaction_data.setdefault('createdattrs', []).append(
        '%s.%s' % (etype, rtype))

def check_valid_changes(session, entity, ro_attrs=('name', 'final')):
    errors = {}
    # don't use getattr(entity, attr), we would get the modified value if any
    for attr in entity.edited_attributes:
        if attr in ro_attrs:
            newval = entity.pop(attr)
            origval = getattr(entity, attr)
            if newval != origval:
                errors[attr] = session._("can't change the %s attribute") % \
                               display_name(session, attr)
            entity[attr] = newval
    if errors:
        raise ValidationError(entity.eid, errors)


# operations for low-level database alteration  ################################

class DropTable(hook.Operation):
    """actually remove a database from the instance's schema"""
    table = None # make pylint happy
    def precommit_event(self):
        dropped = self.session.transaction_data.setdefault('droppedtables',
                                                           set())
        if self.table in dropped:
            return # already processed
        dropped.add(self.table)
        self.session.system_sql('DROP TABLE %s' % self.table)
        self.info('dropped table %s', self.table)


class DropRelationTable(DropTable):
    def __init__(self, session, rtype):
        super(DropRelationTable, self).__init__(
            session, table='%s_relation' % rtype)
        session.transaction_data.setdefault('pendingrtypes', set()).add(rtype)


class DropColumn(hook.Operation):
    """actually remove the attribut's column from entity table in the system
    database
    """
    table = column = None # make pylint happy
    def precommit_event(self):
        session, table, column = self.session, self.table, self.column
        # drop index if any
        session.pool.source('system').drop_index(session, table, column)
        try:
            session.system_sql('ALTER TABLE %s DROP COLUMN %s'
                               % (table, column), rollback_on_failure=False)
            self.info('dropped column %s from table %s', column, table)
        except Exception, ex:
            # not supported by sqlite for instance
            self.error('error while altering table %s: %s', table, ex)


# base operations for in-memory schema synchronization  ########################

class MemSchemaNotifyChanges(hook.SingleLastOperation):
    """the update schema operation:

    special operation which should be called once and after all other schema
    operations. It will trigger internal structures rebuilding to consider
    schema changes.
    """

    def __init__(self, session):
        hook.SingleLastOperation.__init__(self, session)

    def precommit_event(self):
        for eschema in self.session.repo.schema.entities():
            if not eschema.final:
                clear_cache(eschema, 'ordered_relations')

    def commit_event(self):
        rebuildinfered = self.session.data.get('rebuild-infered', True)
        repo = self.session.repo
        repo.set_schema(repo.schema, rebuildinfered=rebuildinfered)
        # CWUser class might have changed, update current session users
        cwuser_cls = self.session.vreg['etypes'].etype_class('CWUser')
        for session in repo._sessions.values():
            session.user.__class__ = cwuser_cls

    def rollback_event(self):
        self.precommit_event()


class MemSchemaOperation(hook.Operation):
    """base class for schema operations"""
    def __init__(self, session, kobj=None, **kwargs):
        self.kobj = kobj
        # once Operation.__init__ has been called, event may be triggered, so
        # do this last !
        hook.Operation.__init__(self, session, **kwargs)
        # every schema operation is triggering a schema update
        MemSchemaNotifyChanges(session)

    def prepare_constraints(self, subjtype, rtype, objtype):
        rdef = rtype.rdef(subjtype, objtype)
        constraints = rdef.constraints
        self.constraints = list(constraints)
        rdef.constraints = self.constraints


class MemSchemaEarlyOperation(MemSchemaOperation):
    def insert_index(self):
        """schema operation which are inserted at the begining of the queue
        (typically to add/remove entity or relation types)
        """
        i = -1
        for i, op in enumerate(self.session.pending_operations):
            if not isinstance(op, MemSchemaEarlyOperation):
                return i
        return i + 1


# operations for high-level source database alteration  ########################

class SourceDbCWETypeRename(hook.Operation):
    """this operation updates physical storage accordingly"""
    oldname = newname = None # make pylint happy

    def precommit_event(self):
        # we need sql to operate physical changes on the system database
        sqlexec = self.session.system_sql
        sqlexec('ALTER TABLE %s%s RENAME TO %s%s' % (SQL_PREFIX, self.oldname,
                                                     SQL_PREFIX, self.newname))
        self.info('renamed table %s to %s', self.oldname, self.newname)
        sqlexec('UPDATE entities SET type=%s WHERE type=%s',
                (self.newname, self.oldname))
        sqlexec('UPDATE deleted_entities SET type=%s WHERE type=%s',
                (self.newname, self.oldname))


class SourceDbCWRTypeUpdate(hook.Operation):
    """actually update some properties of a relation definition"""
    rschema = values = entity = None # make pylint happy

    def precommit_event(self):
        session = self.session
        rschema = self.rschema
        if rschema.final or not 'inlined' in self.values:
            return # nothing to do
        inlined = self.values['inlined']
        entity = self.entity
        # check in-lining is necessary / possible
        if not entity.inlined_changed(inlined):
            return # nothing to do
        # inlined changed, make necessary physical changes!
        sqlexec = self.session.system_sql
        rtype = rschema.type
        eidcolumn = SQL_PREFIX + 'eid'
        if not inlined:
            # need to create the relation if it has not been already done by
            # another event of the same transaction
            if not rschema.type in session.transaction_data.get('createdtables', ()):
                tablesql = rschema2sql(rschema)
                # create the necessary table
                for sql in tablesql.split(';'):
                    if sql.strip():
                        sqlexec(sql)
                session.transaction_data.setdefault('createdtables', []).append(
                    rschema.type)
            # copy existant data
            column = SQL_PREFIX + rtype
            for etype in rschema.subjects():
                table = SQL_PREFIX + str(etype)
                sqlexec('INSERT INTO %s_relation SELECT %s, %s FROM %s WHERE NOT %s IS NULL'
                        % (rtype, eidcolumn, column, table, column))
            # drop existant columns
            for etype in rschema.subjects():
                DropColumn(session, table=SQL_PREFIX + str(etype),
                             column=SQL_PREFIX + rtype)
        else:
            for etype in rschema.subjects():
                try:
                    add_inline_relation_column(session, str(etype), rtype)
                except Exception, ex:
                    # the column probably already exists. this occurs when the
                    # entity's type has just been added or if the column has not
                    # been previously dropped
                    self.error('error while altering table %s: %s', etype, ex)
                # copy existant data.
                # XXX don't use, it's not supported by sqlite (at least at when i tried it)
                #sqlexec('UPDATE %(etype)s SET %(rtype)s=eid_to '
                #        'FROM %(rtype)s_relation '
                #        'WHERE %(etype)s.eid=%(rtype)s_relation.eid_from'
                #        % locals())
                table = SQL_PREFIX + str(etype)
                cursor = sqlexec('SELECT eid_from, eid_to FROM %(table)s, '
                                 '%(rtype)s_relation WHERE %(table)s.%(eidcolumn)s='
                                 '%(rtype)s_relation.eid_from' % locals())
                args = [{'val': eid_to, 'x': eid} for eid, eid_to in cursor.fetchall()]
                if args:
                    column = SQL_PREFIX + rtype
                    cursor.executemany('UPDATE %s SET %s=%%(val)s WHERE %s=%%(x)s'
                                       % (table, column, eidcolumn), args)
                # drop existant table
                DropRelationTable(session, rtype)


class SourceDbCWAttributeAdd(hook.Operation):
    """an attribute relation (CWAttribute) has been added:
    * add the necessary column
    * set default on this column if any and possible
    * register an operation to add the relation definition to the
      instance's schema on commit

    constraints are handled by specific hooks
    """
    entity = None # make pylint happy

    def init_rdef(self, **kwargs):
        entity = self.entity
        fromentity = entity.stype
        self.session.execute('SET X ordernum Y+1 '
                             'WHERE X from_entity SE, SE eid %(se)s, X ordernum Y, '
                             'X ordernum >= %(order)s, NOT X eid %(x)s',
                             {'x': entity.eid, 'se': fromentity.eid,
                              'order': entity.ordernum or 0})
        subj = str(fromentity.name)
        rtype = entity.rtype.name
        obj = str(entity.otype.name)
        constraints = get_constraints(self.session, entity)
        rdef = RelationDefinition(subj, rtype, obj,
                                  description=entity.description,
                                  cardinality=entity.cardinality,
                                  constraints=constraints,
                                  order=entity.ordernum,
                                  eid=entity.eid,
                                  **kwargs)
        MemSchemaRDefAdd(self.session, rdef)
        return rdef

    def precommit_event(self):
        session = self.session
        entity = self.entity
        # entity.defaultval is a string or None, but we need a correctly typed
        # value
        default = entity.defaultval
        if default is not None:
            default = TYPE_CONVERTER[entity.otype.name](default)
        props = {'default': default,
                 'indexed': entity.indexed,
                 'fulltextindexed': entity.fulltextindexed,
                 'internationalizable': entity.internationalizable}
        rdef = self.init_rdef(**props)
        sysource = session.pool.source('system')
        attrtype = type_from_constraints(sysource.dbhelper, rdef.object,
                                         rdef.constraints)
        # XXX should be moved somehow into lgc.adbh: sqlite doesn't support to
        # add a new column with UNIQUE, it should be added after the ALTER TABLE
        # using ADD INDEX
        if sysource.dbdriver == 'sqlite' and 'UNIQUE' in attrtype:
            extra_unique_index = True
            attrtype = attrtype.replace(' UNIQUE', '')
        else:
            extra_unique_index = False
        # added some str() wrapping query since some backend (eg psycopg) don't
        # allow unicode queries
        table = SQL_PREFIX + rdef.subject
        column = SQL_PREFIX + rdef.name
        try:
            session.system_sql(str('ALTER TABLE %s ADD COLUMN %s %s'
                                   % (table, column, attrtype)),
                               rollback_on_failure=False)
            self.info('added column %s to table %s', table, column)
        except Exception, ex:
            # the column probably already exists. this occurs when
            # the entity's type has just been added or if the column
            # has not been previously dropped
            self.error('error while altering table %s: %s', table, ex)
        if extra_unique_index or entity.indexed:
            try:
                sysource.create_index(session, table, column,
                                      unique=extra_unique_index)
            except Exception, ex:
                self.error('error while creating index for %s.%s: %s',
                           table, column, ex)
        # final relations are not infered, propagate
        try:
            eschema = session.vreg.schema.eschema(rdef.subject)
        except KeyError:
            return # entity type currently being added
        # propagate attribute to children classes
        rschema = session.vreg.schema.rschema(rdef.name)
        # if relation type has been inserted in the same transaction, its final
        # attribute is still set to False, so we've to ensure it's False
        rschema.final = True
        # XXX 'infered': True/False, not clear actually
        props.update({'constraints': rdef.constraints,
                      'description': rdef.description,
                      'cardinality': rdef.cardinality,
                      'constraints': rdef.constraints,
                      'permissions': rdef.get_permissions(),
                      'order': rdef.order})
        groupmap = group_mapping(session)
        for specialization in eschema.specialized_by(False):
            if (specialization, rdef.object) in rschema.rdefs:
                continue
            sperdef = RelationDefinitionSchema(specialization, rschema, rdef.object, props)
            for rql, args in ss.rdef2rql(rschema, str(specialization),
                                         rdef.object, sperdef, groupmap=groupmap):
                session.execute(rql, args)
        # set default value, using sql for performance and to avoid
        # modification_date update
        if default:
            session.system_sql('UPDATE %s SET %s=%%(default)s' % (table, column),
                               {'default': default})


class SourceDbCWRelationAdd(SourceDbCWAttributeAdd):
    """an actual relation has been added:
    * if this is an inlined relation, add the necessary column
      else if it's the first instance of this relation type, add the
      necessary table and set default permissions
    * register an operation to add the relation definition to the
      instance's schema on commit

    constraints are handled by specific hooks
    """
    entity = None # make pylint happy

    def precommit_event(self):
        session = self.session
        entity = self.entity
        rdef = self.init_rdef(composite=entity.composite)
        schema = session.vreg.schema
        rtype = rdef.name
        rschema = session.vreg.schema.rschema(rtype)
        # this have to be done before permissions setting
        if rschema.inlined:
            # need to add a column if the relation is inlined and if this is the
            # first occurence of "Subject relation Something" whatever Something
            # and if it has not been added during other event of the same
            # transaction
            key = '%s.%s' % (rdef.subject, rtype)
            try:
                alreadythere = bool(rschema.objects(rdef.subject))
            except KeyError:
                alreadythere = False
            if not (alreadythere or
                    key in session.transaction_data.get('createdattrs', ())):
                add_inline_relation_column(session, rdef.subject, rtype)
        else:
            # need to create the relation if no relation definition in the
            # schema and if it has not been added during other event of the same
            # transaction
            if not (rschema.subjects() or
                    rtype in session.transaction_data.get('createdtables', ())):
                try:
                    rschema = session.vreg.schema.rschema(rtype)
                    tablesql = rschema2sql(rschema)
                except KeyError:
                    # fake we add it to the schema now to get a correctly
                    # initialized schema but remove it before doing anything
                    # more dangerous...
                    rschema = session.vreg.schema.add_relation_type(rdef)
                    tablesql = rschema2sql(rschema)
                    session.vreg.schema.del_relation_type(rtype)
                # create the necessary table
                for sql in tablesql.split(';'):
                    if sql.strip():
                        session.system_sql(sql)
                session.transaction_data.setdefault('createdtables', []).append(
                    rtype)


class SourceDbRDefUpdate(hook.Operation):
    """actually update some properties of a relation definition"""
    rschema = values = None # make pylint happy

    def precommit_event(self):
        etype = self.kobj[0]
        table = SQL_PREFIX + etype
        column = SQL_PREFIX + self.rschema.type
        if 'indexed' in self.values:
            sysource = self.session.pool.source('system')
            if self.values['indexed']:
                sysource.create_index(self.session, table, column)
            else:
                sysource.drop_index(self.session, table, column)
        if 'cardinality' in self.values and self.rschema.final:
            adbh = self.session.pool.source('system').dbhelper
            if not adbh.alter_column_support:
                # not supported (and NOT NULL not set by yams in that case, so
                # no worry)
                return
            atype = self.rschema.objects(etype)[0]
            constraints = self.rschema.rdef(etype, atype).constraints
            coltype = type_from_constraints(adbh, atype, constraints,
                                            creating=False)
            # XXX check self.values['cardinality'][0] actually changed?
            sql = adbh.sql_set_null_allowed(table, column, coltype,
                                            self.values['cardinality'][0] != '1')
            self.session.system_sql(sql)


class SourceDbCWConstraintAdd(hook.Operation):
    """actually update constraint of a relation definition"""
    entity = None # make pylint happy
    cancelled = False

    def precommit_event(self):
        rdef = self.entity.reverse_constrained_by[0]
        session = self.session
        # when the relation is added in the same transaction, the constraint
        # object is created by the operation adding the attribute or relation,
        # so there is nothing to do here
        if session.added_in_transaction(rdef.eid):
            return
        rdefschema = session.vreg.schema.schema_by_eid(rdef.eid)
        subjtype, rtype, objtype = rdefschema.as_triple()
        cstrtype = self.entity.type
        oldcstr = rtype.rdef(subjtype, objtype).constraint_by_type(cstrtype)
        newcstr = CONSTRAINTS[cstrtype].deserialize(self.entity.value)
        table = SQL_PREFIX + str(subjtype)
        column = SQL_PREFIX + str(rtype)
        # alter the physical schema on size constraint changes
        if newcstr.type() == 'SizeConstraint' and (
            oldcstr is None or oldcstr.max != newcstr.max):
            adbh = self.session.pool.source('system').dbhelper
            card = rtype.rdef(subjtype, objtype).cardinality
            coltype = type_from_constraints(adbh, objtype, [newcstr],
                                            creating=False)
            sql = adbh.sql_change_col_type(table, column, coltype, card != '1')
            try:
                session.system_sql(sql, rollback_on_failure=False)
                self.info('altered column %s of table %s: now VARCHAR(%s)',
                          column, table, newcstr.max)
            except Exception, ex:
                # not supported by sqlite for instance
                self.error('error while altering table %s: %s', table, ex)
        elif cstrtype == 'UniqueConstraint' and oldcstr is None:
            session.pool.source('system').create_index(
                self.session, table, column, unique=True)


class SourceDbCWConstraintDel(hook.Operation):
    """actually remove a constraint of a relation definition"""
    rtype = subjtype = objtype = None # make pylint happy

    def precommit_event(self):
        cstrtype = self.cstr.type()
        table = SQL_PREFIX + str(self.subjtype)
        column = SQL_PREFIX + str(self.rtype)
        # alter the physical schema on size/unique constraint changes
        if cstrtype == 'SizeConstraint':
            try:
                self.session.system_sql('ALTER TABLE %s ALTER COLUMN %s TYPE TEXT'
                                        % (table, column),
                                        rollback_on_failure=False)
                self.info('altered column %s of table %s: now TEXT',
                          column, table)
            except Exception, ex:
                # not supported by sqlite for instance
                self.error('error while altering table %s: %s', table, ex)
        elif cstrtype == 'UniqueConstraint':
            self.session.pool.source('system').drop_index(
                self.session, table, column, unique=True)


# operations for in-memory schema synchronization  #############################

class MemSchemaCWETypeAdd(MemSchemaEarlyOperation):
    """actually add the entity type to the instance's schema"""
    eid = None # make pylint happy
    def commit_event(self):
        self.session.vreg.schema.add_entity_type(self.kobj)


class MemSchemaCWETypeRename(MemSchemaOperation):
    """this operation updates physical storage accordingly"""
    oldname = newname = None # make pylint happy

    def commit_event(self):
        self.session.vreg.schema.rename_entity_type(self.oldname, self.newname)


class MemSchemaCWETypeDel(MemSchemaOperation):
    """actually remove the entity type from the instance's schema"""
    def commit_event(self):
        try:
            # del_entity_type also removes entity's relations
            self.session.vreg.schema.del_entity_type(self.kobj)
        except KeyError:
            # s/o entity type have already been deleted
            pass


class MemSchemaCWRTypeAdd(MemSchemaEarlyOperation):
    """actually add the relation type to the instance's schema"""
    eid = None # make pylint happy
    def commit_event(self):
        self.session.vreg.schema.add_relation_type(self.kobj)


class MemSchemaCWRTypeUpdate(MemSchemaOperation):
    """actually update some properties of a relation definition"""
    rschema = values = None # make pylint happy

    def commit_event(self):
        # structure should be clean, not need to remove entity's relations
        # at this point
        self.rschema.__dict__.update(self.values)


class MemSchemaCWRTypeDel(MemSchemaOperation):
    """actually remove the relation type from the instance's schema"""
    def commit_event(self):
        try:
            self.session.vreg.schema.del_relation_type(self.kobj)
        except KeyError:
            # s/o entity type have already been deleted
            pass


class MemSchemaRDefAdd(MemSchemaEarlyOperation):
    """actually add the attribute relation definition to the instance's
    schema
    """
    def commit_event(self):
        self.session.vreg.schema.add_relation_def(self.kobj)


class MemSchemaRDefUpdate(MemSchemaOperation):
    """actually update some properties of a relation definition"""
    rschema = values = None # make pylint happy

    def commit_event(self):
        # structure should be clean, not need to remove entity's relations
        # at this point
        self.rschema.rdefs[self.kobj].update(self.values)


class MemSchemaRDefDel(MemSchemaOperation):
    """actually remove the relation definition from the instance's schema"""
    def commit_event(self):
        subjtype, rtype, objtype = self.kobj
        try:
            self.session.vreg.schema.del_relation_def(subjtype, rtype, objtype)
        except KeyError:
            # relation type may have been already deleted
            pass


class MemSchemaCWConstraintAdd(MemSchemaOperation):
    """actually update constraint of a relation definition

    has to be called before SourceDbCWConstraintAdd
    """
    cancelled = False

    def precommit_event(self):
        rdef = self.entity.reverse_constrained_by[0]
        # when the relation is added in the same transaction, the constraint
        # object is created by the operation adding the attribute or relation,
        # so there is nothing to do here
        if self.session.added_in_transaction(rdef.eid):
            self.cancelled = True
            return
        rdef = self.session.vreg.schema.schema_by_eid(rdef.eid)
        subjtype, rtype, objtype = rdef.as_triple()
        self.prepare_constraints(subjtype, rtype, objtype)
        cstrtype = self.entity.type
        self.cstr = rtype.rdef(subjtype, objtype).constraint_by_type(cstrtype)
        self.newcstr = CONSTRAINTS[cstrtype].deserialize(self.entity.value)
        self.newcstr.eid = self.entity.eid

    def commit_event(self):
        if self.cancelled:
            return
        # in-place modification
        if not self.cstr is None:
            self.constraints.remove(self.cstr)
        self.constraints.append(self.newcstr)


class MemSchemaCWConstraintDel(MemSchemaOperation):
    """actually remove a constraint of a relation definition

    has to be called before SourceDbCWConstraintDel
    """
    rtype = subjtype = objtype = None # make pylint happy
    def precommit_event(self):
        self.prepare_constraints(self.subjtype, self.rtype, self.objtype)

    def commit_event(self):
        self.constraints.remove(self.cstr)


class MemSchemaPermissionAdd(MemSchemaOperation):
    """synchronize schema when a *_permission relation has been added on a group
    """

    def commit_event(self):
        """the observed connections pool has been commited"""
        try:
            erschema = self.session.vreg.schema.schema_by_eid(self.eid)
        except KeyError:
            # duh, schema not found, log error and skip operation
            self.error('no schema for %s', self.eid)
            return
        perms = list(erschema.action_permissions(self.action))
        if hasattr(self, 'group_eid'):
            perm = self.session.entity_from_eid(self.group_eid).name
        else:
            perm = erschema.rql_expression(self.expr)
        try:
            perms.index(perm)
            self.warning('%s already in permissions for %s on %s',
                         perm, self.action, erschema)
        except ValueError:
            perms.append(perm)
            erschema.set_action_permissions(self.action, perms)


class MemSchemaPermissionDel(MemSchemaPermissionAdd):
    """synchronize schema when a *_permission relation has been deleted from a
    group
    """

    def commit_event(self):
        """the observed connections pool has been commited"""
        try:
            erschema = self.session.vreg.schema.schema_by_eid(self.eid)
        except KeyError:
            # duh, schema not found, log error and skip operation
            self.error('no schema for %s', self.eid)
            return
        if isinstance(erschema, RelationSchema): # XXX 3.6 migration
            return
        perms = list(erschema.action_permissions(self.action))
        if hasattr(self, 'group_eid'):
            perm = self.session.entity_from_eid(self.group_eid).name
        else:
            perm = erschema.rql_expression(self.expr)
        try:
            perms.remove(perm)
            erschema.set_action_permissions(self.action, perms)
        except ValueError:
            self.error('can\'t remove permission %s for %s on %s',
                       perm, self.action, erschema)


class MemSchemaSpecializesAdd(MemSchemaOperation):

    def commit_event(self):
        eschema = self.session.vreg.schema.schema_by_eid(self.etypeeid)
        parenteschema = self.session.vreg.schema.schema_by_eid(self.parentetypeeid)
        eschema._specialized_type = parenteschema.type
        parenteschema._specialized_by.append(eschema.type)


class MemSchemaSpecializesDel(MemSchemaOperation):

    def commit_event(self):
        try:
            eschema = self.session.vreg.schema.schema_by_eid(self.etypeeid)
            parenteschema = self.session.vreg.schema.schema_by_eid(self.parentetypeeid)
        except KeyError:
            # etype removed, nothing to do
            return
        eschema._specialized_type = None
        parenteschema._specialized_by.remove(eschema.type)


class SyncSchemaHook(hook.Hook):
    __abstract__ = True
    category = 'syncschema'


# CWEType hooks ################################################################

class DelCWETypeHook(SyncSchemaHook):
    """before deleting a CWEType entity:
    * check that we don't remove a core entity type
    * cascade to delete related CWAttribute and CWRelation entities
    * instantiate an operation to delete the entity type on commit
    """
    __regid__ = 'syncdelcwetype'
    __select__ = SyncSchemaHook.__select__ & implements('CWEType')
    events = ('before_delete_entity',)

    def __call__(self):
        # final entities can't be deleted, don't care about that
        name = self.entity.name
        if name in CORE_ETYPES:
            raise ValidationError(self.entity.eid, {None: self._cw._('can\'t be deleted')})
        # delete every entities of this type
        self._cw.unsafe_execute('DELETE %s X' % name)
        DropTable(self._cw, table=SQL_PREFIX + name)
        MemSchemaCWETypeDel(self._cw, name)


class AfterDelCWETypeHook(DelCWETypeHook):
    __regid__ = 'wfcleanup'
    events = ('after_delete_entity',)

    def __call__(self):
        # workflow cleanup
        self._cw.execute('DELETE Workflow X WHERE NOT X workflow_of Y')


class AfterAddCWETypeHook(DelCWETypeHook):
    """after adding a CWEType entity:
    * create the necessary table
    * set creation_date and modification_date by creating the necessary
      CWAttribute entities
    * add owned_by relation by creating the necessary CWRelation entity
    * register an operation to add the entity type to the instance's
      schema on commit
    """
    __regid__ = 'syncaddcwetype'
    events = ('after_add_entity',)

    def __call__(self):
        entity = self.entity
        if entity.get('final'):
            return
        schema = self._cw.vreg.schema
        name = entity['name']
        etype = EntityType(name=name, description=entity.get('description'),
                           meta=entity.get('meta')) # don't care about final
        # fake we add it to the schema now to get a correctly initialized schema
        # but remove it before doing anything more dangerous...
        schema = self._cw.vreg.schema
        eschema = schema.add_entity_type(etype)
        # generate table sql and rql to add metadata
        tablesql = eschema2sql(self._cw.pool.source('system').dbhelper, eschema,
                               prefix=SQL_PREFIX)
        relrqls = []
        for rtype in (META_RTYPES - VIRTUAL_RTYPES):
            rschema = schema[rtype]
            sampletype = rschema.subjects()[0]
            desttype = rschema.objects()[0]
            props = rschema.rdef(sampletype, desttype)
            relrqls += list(ss.rdef2rql(rschema, name, desttype, props,
                                        groupmap=group_mapping(self._cw)))
        # now remove it !
        schema.del_entity_type(name)
        # create the necessary table
        for sql in tablesql.split(';'):
            if sql.strip():
                self._cw.system_sql(sql)
        # register operation to modify the schema on commit
        # this have to be done before adding other relations definitions
        # or permission settings
        etype.eid = entity.eid
        MemSchemaCWETypeAdd(self._cw, etype)
        # add meta relations
        for rql, kwargs in relrqls:
            self._cw.execute(rql, kwargs)


class BeforeUpdateCWETypeHook(DelCWETypeHook):
    """check name change, handle final"""
    __regid__ = 'syncupdatecwetype'
    events = ('before_update_entity',)

    def __call__(self):
        entity = self.entity
        check_valid_changes(self._cw, entity, ro_attrs=('final',))
        # don't use getattr(entity, attr), we would get the modified value if any
        if 'name' in entity.edited_attributes:
            newname = entity.pop('name')
            oldname = entity.name
            if newname.lower() != oldname.lower():
                SourceDbCWETypeRename(self._cw, oldname=oldname, newname=newname)
                MemSchemaCWETypeRename(self._cw, oldname=oldname, newname=newname)
            entity['name'] = newname


# CWRType hooks ################################################################

class DelCWRTypeHook(SyncSchemaHook):
    """before deleting a CWRType entity:
    * check that we don't remove a core relation type
    * cascade to delete related CWAttribute and CWRelation entities
    * instantiate an operation to delete the relation type on commit
    """
    __regid__ = 'syncdelcwrtype'
    __select__ = SyncSchemaHook.__select__ & implements('CWRType')
    events = ('before_delete_entity',)

    def __call__(self):
        name = self.entity.name
        if name in CORE_RTYPES:
            raise ValidationError(self.entity.eid, {None: self._cw._('can\'t be deleted')})
        # delete relation definitions using this relation type
        self._cw.execute('DELETE CWAttribute X WHERE X relation_type Y, Y eid %(x)s',
                        {'x': self.entity.eid})
        self._cw.execute('DELETE CWRelation X WHERE X relation_type Y, Y eid %(x)s',
                        {'x': self.entity.eid})
        MemSchemaCWRTypeDel(self._cw, name)


class AfterAddCWRTypeHook(DelCWRTypeHook):
    """after a CWRType entity has been added:
    * register an operation to add the relation type to the instance's
      schema on commit

    We don't know yet this point if a table is necessary
    """
    __regid__ = 'syncaddcwrtype'
    events = ('after_add_entity',)

    def __call__(self):
        entity = self.entity
        rtype = RelationType(name=entity.name,
                             description=entity.get('description'),
                             meta=entity.get('meta', False),
                             inlined=entity.get('inlined', False),
                             symetric=entity.get('symetric', False),
                             eid=entity.eid)
        MemSchemaCWRTypeAdd(self._cw, rtype)


class BeforeUpdateCWRTypeHook(DelCWRTypeHook):
    """check name change, handle final"""
    __regid__ = 'checkupdatecwrtype'
    events = ('before_update_entity',)

    def __call__(self):
        check_valid_changes(self._cw, self.entity)


class AfterUpdateCWRTypeHook(DelCWRTypeHook):
    __regid__ = 'syncupdatecwrtype'
    events = ('after_update_entity',)

    def __call__(self):
        entity = self.entity
        rschema = self._cw.vreg.schema.rschema(entity.name)
        newvalues = {}
        for prop in ('meta', 'symetric', 'inlined'):
            if prop in entity:
                newvalues[prop] = entity[prop]
        if newvalues:
            MemSchemaCWRTypeUpdate(self._cw, rschema=rschema, values=newvalues)
            SourceDbCWRTypeUpdate(self._cw, rschema=rschema, values=newvalues,
                                  entity=entity)

def check_valid_changes(session, entity, ro_attrs=('name', 'final')):
    errors = {}
    # don't use getattr(entity, attr), we would get the modified value if any
    for attr in ro_attrs:
        if attr in entity.edited_attributes:
            origval, newval = hook.entity_oldnewvalue(entity, attr)
            if newval != origval:
                errors[attr] = session._("can't change the %s attribute") % \
                               display_name(session, attr)
    if errors:
        raise ValidationError(entity.eid, errors)


class AfterDelRelationTypeHook(SyncSchemaHook):
    """before deleting a CWAttribute or CWRelation entity:
    * if this is a final or inlined relation definition, instantiate an
      operation to drop necessary column, else if this is the last instance
      of a non final relation, instantiate an operation to drop necessary
      table
    * instantiate an operation to delete the relation definition on commit
    * delete the associated relation type when necessary
    """
    __regid__ = 'syncdelrelationtype'
    __select__ = SyncSchemaHook.__select__ & hook.match_rtype('relation_type')
    events = ('after_delete_relation',)

    def __call__(self):
        session = self._cw
        rdef = session.vreg.schema.schema_by_eid(self.eidfrom)
        subjschema, rschema, objschema = rdef.as_triple()
        pendings = session.transaction_data.get('pendingeids', ())
        pendingrdefs = session.transaction_data.setdefault('pendingrdefs', set())
        # first delete existing relation if necessary
        if rschema.final:
            rdeftype = 'CWAttribute'
            pendingrdefs.add((subjschema, rschema))
        else:
            rdeftype = 'CWRelation'
            pendingrdefs.add((subjschema, rschema, objschema))
            if not (subjschema.eid in pendings or objschema.eid in pendings):
                session.execute('DELETE X %s Y WHERE X is %s, Y is %s'
                                % (rschema, subjschema, objschema))
        execute = session.unsafe_execute
        rset = execute('Any COUNT(X) WHERE X is %s, X relation_type R,'
                       'R eid %%(x)s' % rdeftype, {'x': self.eidto})
        lastrel = rset[0][0] == 0
        # we have to update physical schema systematically for final and inlined
        # relations, but only if it's the last instance for this relation type
        # for other relations

        if (rschema.final or rschema.inlined):
            rset = execute('Any COUNT(X) WHERE X is %s, X relation_type R, '
                           'R eid %%(x)s, X from_entity E, E name %%(name)s'
                           % rdeftype, {'x': self.eidto, 'name': str(subjschema)})
            if rset[0][0] == 0 and not subjschema.eid in pendings:
                ptypes = session.transaction_data.setdefault('pendingrtypes', set())
                ptypes.add(rschema.type)
                DropColumn(session, table=SQL_PREFIX + subjschema.type,
                           column=SQL_PREFIX + rschema.type)
        elif lastrel:
            DropRelationTable(session, rschema.type)
        # if this is the last instance, drop associated relation type
        if lastrel and not self.eidto in pendings:
            execute('DELETE CWRType X WHERE X eid %(x)s', {'x': self.eidto}, 'x')
        MemSchemaRDefDel(session, (subjschema, rschema, objschema))


# CWAttribute / CWRelation hooks ###############################################

class AfterAddCWAttributeHook(SyncSchemaHook):
    __regid__ = 'syncaddcwattribute'
    __select__ = SyncSchemaHook.__select__ & implements('CWAttribute')
    events = ('after_add_entity',)

    def __call__(self):
        SourceDbCWAttributeAdd(self._cw, entity=self.entity)


class AfterAddCWRelationHook(AfterAddCWAttributeHook):
    __regid__ = 'syncaddcwrelation'
    __select__ = SyncSchemaHook.__select__ & implements('CWRelation')

    def __call__(self):
        SourceDbCWRelationAdd(self._cw, entity=self.entity)


class AfterUpdateCWRDefHook(SyncSchemaHook):
    __regid__ = 'syncaddcwattribute'
    __select__ = SyncSchemaHook.__select__ & implements('CWAttribute',
                                                               'CWRelation')
    events = ('after_update_entity',)

    def __call__(self):
        entity = self.entity
        if self._cw.deleted_in_transaction(entity.eid):
            return
        desttype = entity.otype.name
        rschema = self._cw.vreg.schema[entity.rtype.name]
        newvalues = {}
        for prop in RelationDefinitionSchema.rproperty_defs(desttype):
            if prop == 'constraints':
                continue
            if prop == 'order':
                prop = 'ordernum'
            if prop in entity.edited_attributes:
                newvalues[prop] = entity[prop]
        if newvalues:
            subjtype = entity.stype.name
            MemSchemaRDefUpdate(self._cw, kobj=(subjtype, desttype),
                                rschema=rschema, values=newvalues)
            SourceDbRDefUpdate(self._cw, kobj=(subjtype, desttype),
                               rschema=rschema, values=newvalues)


# constraints synchronization hooks ############################################

class AfterAddCWConstraintHook(SyncSchemaHook):
    __regid__ = 'syncaddcwconstraint'
    __select__ = SyncSchemaHook.__select__ & implements('CWConstraint')
    events = ('after_add_entity', 'after_update_entity')

    def __call__(self):
        MemSchemaCWConstraintAdd(self._cw, entity=self.entity)
        SourceDbCWConstraintAdd(self._cw, entity=self.entity)


class AfterAddConstrainedByHook(SyncSchemaHook):
    __regid__ = 'syncdelconstrainedby'
    __select__ = SyncSchemaHook.__select__ & hook.match_rtype('constrained_by')
    events = ('after_add_relation',)

    def __call__(self):
        if self._cw.added_in_transaction(self.eidfrom):
            self._cw.transaction_data.setdefault(self.eidfrom, []).append(self.eidto)


class BeforeDeleteConstrainedByHook(AfterAddConstrainedByHook):
    __regid__ = 'syncdelconstrainedby'
    events = ('before_delete_relation',)

    def __call__(self):
        if self._cw.deleted_in_transaction(self.eidfrom):
            return
        schema = self._cw.vreg.schema
        entity = self._cw.entity_from_eid(self.eidto)
        rdef = schema.schema_by_eid(self.eidfrom)
        try:
            cstr = rdef.constraint_by_type(entity.type)
        except IndexError:
            self._cw.critical('constraint type no more accessible')
        else:
            subjtype, rtype, objtype = rdef.as_triple()
            SourceDbCWConstraintDel(self._cw, subjtype=subjtype, rtype=rtype,
                                    objtype=objtype, cstr=cstr)
            MemSchemaCWConstraintDel(self._cw, subjtype=subjtype, rtype=rtype,
                                     objtype=objtype, cstr=cstr)


# permissions synchronization hooks ############################################

class AfterAddPermissionHook(SyncSchemaHook):
    """added entity/relation *_permission, need to update schema"""
    __regid__ = 'syncaddperm'
    __select__ = SyncSchemaHook.__select__ & hook.match_rtype(
        'read_permission', 'add_permission', 'delete_permission',
        'update_permission')
    events = ('after_add_relation',)

    def __call__(self):
        action = self.rtype.split('_', 1)[0]
        if self._cw.describe(self.eidto)[0] == 'CWGroup':
            MemSchemaPermissionAdd(self._cw, action=action, eid=self.eidfrom,
                                   group_eid=self.eidto)
        else: # RQLExpression
            expr = self._cw.entity_from_eid(self.eidto).expression
            MemSchemaPermissionAdd(self._cw, action=action, eid=self.eidfrom,
                                   expr=expr)


class BeforeDelPermissionHook(AfterAddPermissionHook):
    """delete entity/relation *_permission, need to update schema

    skip the operation if the related type is being deleted
    """
    __regid__ = 'syncdelperm'
    events = ('before_delete_relation',)

    def __call__(self):
        if self._cw.deleted_in_transaction(self.eidfrom):
            return
        action = self.rtype.split('_', 1)[0]
        if self._cw.describe(self.eidto)[0] == 'CWGroup':
            MemSchemaPermissionDel(self._cw, action=action, eid=self.eidfrom,
                                   group_eid=self.eidto)
        else: # RQLExpression
            expr = self._cw.entity_from_eid(self.eidto).expression
            MemSchemaPermissionDel(self._cw, action=action, eid=self.eidfrom,
                                   expr=expr)


# specializes synchronization hooks ############################################


class AfterAddSpecializesHook(SyncSchemaHook):
    __regid__ = 'syncaddspecializes'
    __select__ = SyncSchemaHook.__select__ & hook.match_rtype('specializes')
    events = ('after_add_relation',)

    def __call__(self):
        MemSchemaSpecializesAdd(self._cw, etypeeid=self.eidfrom,
                                parentetypeeid=self.eidto)


class AfterDelSpecializesHook(SyncSchemaHook):
    __regid__ = 'syncdelspecializes'
    __select__ = SyncSchemaHook.__select__ & hook.match_rtype('specializes')
    events = ('after_delete_relation',)

    def __call__(self):
        MemSchemaSpecializesDel(self._cw, etypeeid=self.eidfrom,
                                parentetypeeid=self.eidto)