cubicweb/hooks/syncschema.py
changeset 11057 0b59724cb3f2
parent 11038 7cb02ab4f321
child 11129 97095348b3ee
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/cubicweb/hooks/syncschema.py	Sat Jan 16 13:48:51 2016 +0100
@@ -0,0 +1,1417 @@
+# copyright 2003-2015 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
+# contact http://www.logilab.fr/ -- mailto:contact@logilab.fr
+#
+# This file is part of CubicWeb.
+#
+# CubicWeb is free software: you can redistribute it and/or modify it under the
+# terms of the GNU Lesser General Public License as published by the Free
+# Software Foundation, either version 2.1 of the License, or (at your option)
+# any later version.
+#
+# CubicWeb is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public License for more
+# details.
+#
+# You should have received a copy of the GNU Lesser General Public License along
+# with CubicWeb.  If not, see <http://www.gnu.org/licenses/>.
+"""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
+"""
+
+__docformat__ = "restructuredtext en"
+from cubicweb import _
+
+import json
+from copy import copy
+from hashlib import md5
+
+from yams.schema import (BASE_TYPES, BadSchemaDefinition,
+                         RelationSchema, RelationDefinitionSchema)
+from yams import buildobjs as ybo, convert_default_value
+
+from logilab.common.decorators import clear_cache
+
+from cubicweb import validation_error
+from cubicweb.predicates import is_instance
+from cubicweb.schema import (SCHEMA_TYPES, META_RTYPES, VIRTUAL_RTYPES,
+                             CONSTRAINTS, UNIQUE_CONSTRAINTS, ETYPE_NAME_MAP)
+from cubicweb.server import hook, schemaserial as ss, schema2sql as y2sql
+from cubicweb.server.sqlutils import SQL_PREFIX
+from cubicweb.hooks.synccomputed import RecomputeAttributeOperation
+
+# core entity and relation types which can't be removed
+CORE_TYPES = BASE_TYPES | SCHEMA_TYPES | META_RTYPES | set(
+    ('CWUser', 'CWGroup','login', 'upassword', 'name', 'in_group'))
+
+
+def get_constraints(cnx, entity):
+    constraints = []
+    for cstreid in cnx.transaction_data.get(entity.eid, ()):
+        cstrent = cnx.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(cnx, etype, rtype):
+    """add necessary column and index for an inlined relation"""
+    attrkey = '%s.%s' % (etype, rtype)
+    createdattrs = cnx.transaction_data.setdefault('createdattrs', set())
+    if attrkey in createdattrs:
+        return
+    createdattrs.add(attrkey)
+    table = SQL_PREFIX + etype
+    column = SQL_PREFIX + rtype
+    try:
+        cnx.system_sql(str('ALTER TABLE %s ADD %s integer REFERENCES entities (eid)' % (table, column)),
+                       rollback_on_failure=False)
+        cnx.info('added column %s to table %s', column, table)
+    except Exception:
+        # silent exception here, if this error has not been raised because the
+        # column already exists, index creation will fail anyway
+        cnx.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)
+    cnx.repo.system_source.create_index(cnx, table, column)
+    cnx.info('added index on %s(%s)', table, column)
+
+
+def insert_rdef_on_subclasses(cnx, eschema, rschema, rdefdef, props):
+    # XXX 'infered': True/False, not clear actually
+    props.update({'constraints': rdefdef.constraints,
+                  'description': rdefdef.description,
+                  'cardinality': rdefdef.cardinality,
+                  'permissions': rdefdef.get_permissions(),
+                  'order': rdefdef.order,
+                  'infered': False, 'eid': None
+                  })
+    cstrtypemap = ss.cstrtype_mapping(cnx)
+    groupmap = group_mapping(cnx)
+    object = rschema.schema.eschema(rdefdef.object)
+    for specialization in eschema.specialized_by(False):
+        if (specialization, rdefdef.object) in rschema.rdefs:
+            continue
+        sperdef = RelationDefinitionSchema(specialization, rschema,
+                                           object, None, values=props)
+        ss.execschemarql(cnx.execute, sperdef,
+                         ss.rdef2rql(sperdef, cstrtypemap, groupmap))
+
+
+def check_valid_changes(cnx, entity, ro_attrs=('name', 'final')):
+    errors = {}
+    # don't use getattr(entity, attr), we would get the modified value if any
+    for attr in entity.cw_edited:
+        if attr in ro_attrs:
+            origval, newval = entity.cw_edited.oldnewvalue(attr)
+            if newval != origval:
+                errors[attr] = _("can't change this attribute")
+    if errors:
+        raise validation_error(entity, errors)
+
+
+class _MockEntity(object): # XXX use a named tuple with python 2.6
+    def __init__(self, eid):
+        self.eid = eid
+
+
+class SyncSchemaHook(hook.Hook):
+    """abstract class for schema synchronization hooks (in the `syncschema`
+    category)
+    """
+    __abstract__ = True
+    category = 'syncschema'
+
+
+# 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.cnx.transaction_data.setdefault('droppedtables',
+                                                           set())
+        if self.table in dropped:
+            return # already processed
+        dropped.add(self.table)
+        self.cnx.system_sql('DROP TABLE %s' % self.table)
+        self.info('dropped table %s', self.table)
+
+    # XXX revertprecommit_event
+
+
+class DropRelationTable(DropTable):
+    def __init__(self, cnx, rtype):
+        super(DropRelationTable, self).__init__(
+            cnx, table='%s_relation' % rtype)
+        cnx.transaction_data.setdefault('pendingrtypes', set()).add(rtype)
+
+
+class DropColumn(hook.DataOperationMixIn, hook.Operation):
+    """actually remove the attribut's column from entity table in the system
+    database
+    """
+    def precommit_event(self):
+        cnx = self.cnx
+        for etype, attr in self.get_data():
+            table = SQL_PREFIX + etype
+            column = SQL_PREFIX + attr
+            source = cnx.repo.system_source
+            # drop index if any
+            source.drop_index(cnx, table, column)
+            if source.dbhelper.alter_column_support:
+                cnx.system_sql('ALTER TABLE %s DROP COLUMN %s' % (table, column),
+                               rollback_on_failure=False)
+                self.info('dropped column %s from table %s', column, table)
+            else:
+                # not supported by sqlite for instance
+                self.error('dropping column not supported by the backend, handle '
+                           'it yourself (%s.%s)', table, column)
+
+    # XXX revertprecommit_event
+
+
+# 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, cnx):
+        hook.SingleLastOperation.__init__(self, cnx)
+
+    def precommit_event(self):
+        for eschema in self.cnx.repo.schema.entities():
+            if not eschema.final:
+                clear_cache(eschema, 'ordered_relations')
+
+    def postcommit_event(self):
+        repo = self.cnx.repo
+        # commit event should not raise error, while set_schema has chances to
+        # do so because it triggers full vreg reloading
+        try:
+            repo.schema.rebuild_infered_relations()
+            # trigger vreg reload
+            repo.set_schema(repo.schema)
+            # CWUser class might have changed, update current session users
+            cwuser_cls = self.cnx.vreg['etypes'].etype_class('CWUser')
+            for session in repo._sessions.values():
+                session.user.__class__ = cwuser_cls
+        except Exception:
+            self.critical('error while setting schema', exc_info=True)
+
+    def rollback_event(self):
+        self.precommit_event()
+
+
+class MemSchemaOperation(hook.Operation):
+    """base class for schema operations"""
+    def __init__(self, cnx, **kwargs):
+        hook.Operation.__init__(self, cnx, **kwargs)
+        # every schema operation is triggering a schema update
+        MemSchemaNotifyChanges(cnx)
+
+
+# operations for high-level source database alteration  ########################
+
+class CWETypeAddOp(MemSchemaOperation):
+    """after adding a CWEType entity:
+    * add it to the instance's schema
+    * create the necessary table
+    * set creation_date and modification_date by creating the necessary
+      CWAttribute entities
+    * add <meta rtype> relation by creating the necessary CWRelation entity
+    """
+    entity = None # make pylint happy
+
+    def precommit_event(self):
+        cnx = self.cnx
+        entity = self.entity
+        schema = cnx.vreg.schema
+        etype = ybo.EntityType(eid=entity.eid, name=entity.name,
+                               description=entity.description)
+        eschema = schema.add_entity_type(etype)
+        # create the necessary table
+        tablesql = y2sql.eschema2sql(cnx.repo.system_source.dbhelper,
+                                     eschema, prefix=SQL_PREFIX)
+        for sql in tablesql.split(';'):
+            if sql.strip():
+                cnx.system_sql(sql)
+        # add meta relations
+        gmap = group_mapping(cnx)
+        cmap = ss.cstrtype_mapping(cnx)
+        for rtype in (META_RTYPES - VIRTUAL_RTYPES):
+            try:
+                rschema = schema[rtype]
+            except KeyError:
+                self.critical('rtype %s was not handled at cwetype creation time', rtype)
+                continue
+            if not rschema.rdefs:
+                self.warning('rtype %s has no relation definition yet', rtype)
+                continue
+            sampletype = rschema.subjects()[0]
+            desttype = rschema.objects()[0]
+            try:
+                rdef = copy(rschema.rdef(sampletype, desttype))
+            except KeyError:
+                # this combo does not exist because this is not a universal META_RTYPE
+                continue
+            rdef.subject = _MockEntity(eid=entity.eid)
+            mock = _MockEntity(eid=None)
+            ss.execschemarql(cnx.execute, mock, ss.rdef2rql(rdef, cmap, gmap))
+
+    def revertprecommit_event(self):
+        # revert changes on in memory schema
+        self.cnx.vreg.schema.del_entity_type(self.entity.name)
+        # revert changes on database
+        self.cnx.system_sql('DROP TABLE %s%s' % (SQL_PREFIX, self.entity.name))
+
+
+class CWETypeRenameOp(MemSchemaOperation):
+    """this operation updates physical storage accordingly"""
+    oldname = newname = None # make pylint happy
+
+    def rename(self, oldname, newname):
+        self.cnx.vreg.schema.rename_entity_type(oldname, newname)
+        # we need sql to operate physical changes on the system database
+        sqlexec = self.cnx.system_sql
+        dbhelper = self.cnx.repo.system_source.dbhelper
+        sql = dbhelper.sql_rename_table(SQL_PREFIX+oldname,
+                                        SQL_PREFIX+newname)
+        sqlexec(sql)
+        self.info('renamed table %s to %s', oldname, newname)
+        sqlexec('UPDATE entities SET type=%(newname)s WHERE type=%(oldname)s',
+                {'newname': newname, 'oldname': oldname})
+        for eid, (etype, extid, auri) in self.cnx.repo._type_source_cache.items():
+            if etype == oldname:
+                self.cnx.repo._type_source_cache[eid] = (newname, extid, auri)
+        # XXX transaction records
+
+    def precommit_event(self):
+        self.rename(self.oldname, self.newname)
+
+    def revertprecommit_event(self):
+        self.rename(self.newname, self.oldname)
+
+
+class CWRTypeUpdateOp(MemSchemaOperation):
+    """actually update some properties of a relation definition"""
+    rschema = entity = values = None # make pylint happy
+    oldvalues = None
+
+    def precommit_event(self):
+        rschema = self.rschema
+        if rschema.final:
+            return # watched changes to final relation type are unexpected
+        cnx = self.cnx
+        if 'fulltext_container' in self.values:
+            op = UpdateFTIndexOp.get_instance(cnx)
+            for subjtype, objtype in rschema.rdefs:
+                if self.values['fulltext_container'] == 'subject':
+                    op.add_data(subjtype)
+                    op.add_data(objtype)
+                else:
+                    op.add_data(objtype)
+                    op.add_data(subjtype)
+        # update the in-memory schema first
+        self.oldvalues = dict( (attr, getattr(rschema, attr)) for attr in self.values)
+        self.rschema.__dict__.update(self.values)
+        # then make necessary changes to the system source database
+        if 'inlined' not in self.values:
+            return # nothing to do
+        inlined = self.values['inlined']
+        # check in-lining is possible when inlined
+        if inlined:
+            self.entity.check_inlined_allowed()
+        # inlined changed, make necessary physical changes!
+        sqlexec = self.cnx.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 cnx.transaction_data.get('createdtables', ()):
+                tablesql = y2sql.rschema2sql(rschema)
+                # create the necessary table
+                for sql in tablesql.split(';'):
+                    if sql.strip():
+                        sqlexec(sql)
+                cnx.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
+            #if cnx.repo.system_source.dbhelper.alter_column_support:
+            for etype in rschema.subjects():
+                DropColumn.get_instance(cnx).add_data((str(etype), rtype))
+        else:
+            for etype in rschema.subjects():
+                try:
+                    add_inline_relation_column(cnx, str(etype), rtype)
+                except Exception as 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 (eg sqlite)
+                    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(cnx, rtype)
+
+    def revertprecommit_event(self):
+        # revert changes on in memory schema
+        self.rschema.__dict__.update(self.oldvalues)
+        # XXX revert changes on database
+
+
+class CWComputedRTypeUpdateOp(MemSchemaOperation):
+    """actually update some properties of a computed relation definition"""
+    rschema = entity = rule = None # make pylint happy
+    old_rule = None
+
+    def precommit_event(self):
+        # update the in-memory schema first
+        self.old_rule = self.rschema.rule
+        self.rschema.rule = self.rule
+
+    def revertprecommit_event(self):
+        # revert changes on in memory schema
+        self.rschema.rule = self.old_rule
+
+
+class CWAttributeAddOp(MemSchemaOperation):
+    """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
+        rdefdef = self.rdefdef = ybo.RelationDefinition(
+            str(fromentity.name), entity.rtype.name, str(entity.otype.name),
+            description=entity.description, cardinality=entity.cardinality,
+            constraints=get_constraints(self.cnx, entity),
+            order=entity.ordernum, eid=entity.eid, **kwargs)
+        try:
+            self.cnx.vreg.schema.add_relation_def(rdefdef)
+        except BadSchemaDefinition:
+            # rdef has been infered then explicitly added (current consensus is
+            # not clear at all versus infered relation handling (and much
+            # probably buggy)
+            rdef = self.cnx.vreg.schema.rschema(rdefdef.name).rdefs[rdefdef.subject, rdefdef.object]
+            assert rdef.infered
+        else:
+            rdef = self.cnx.vreg.schema.rschema(rdefdef.name).rdefs[rdefdef.subject, rdefdef.object]
+
+        self.cnx.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})
+        return rdefdef, rdef
+
+    def precommit_event(self):
+        cnx = self.cnx
+        entity = self.entity
+        # entity.defaultval is a Binary or None, but we need a correctly typed
+        # value
+        default = entity.defaultval
+        if default is not None:
+            default = default.unzpickle()
+        props = {'default': default,
+                 'indexed': entity.indexed,
+                 'fulltextindexed': entity.fulltextindexed,
+                 'internationalizable': entity.internationalizable}
+        if entity.extra_props:
+            props.update(json.loads(entity.extra_props.getvalue().decode('ascii')))
+        # entity.formula may not exist yet if we're migrating to 3.20
+        if hasattr(entity, 'formula'):
+            props['formula'] = entity.formula
+        # update the in-memory schema first
+        rdefdef, rdef = self.init_rdef(**props)
+        # then make necessary changes to the system source database
+        syssource = cnx.repo.system_source
+        attrtype = y2sql.type_from_rdef(syssource.dbhelper, rdef)
+        # XXX should be moved somehow into lgdb: sqlite doesn't support to
+        # add a new column with UNIQUE, it should be added after the ALTER TABLE
+        # using ADD INDEX
+        if syssource.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 + rdefdef.subject
+        column = SQL_PREFIX + rdefdef.name
+        try:
+            cnx.system_sql(str('ALTER TABLE %s ADD %s %s'
+                               % (table, column, attrtype)),
+                           rollback_on_failure=False)
+            self.info('added column %s to table %s', column, table)
+        except Exception as 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:
+                syssource.create_index(cnx, table, column,
+                                      unique=extra_unique_index)
+            except Exception as ex:
+                self.error('error while creating index for %s.%s: %s',
+                           table, column, ex)
+        # final relations are not infered, propagate
+        schema = cnx.vreg.schema
+        try:
+            eschema = schema.eschema(rdefdef.subject)
+        except KeyError:
+            return # entity type currently being added
+        # propagate attribute to children classes
+        rschema = schema.rschema(rdefdef.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
+        insert_rdef_on_subclasses(cnx, eschema, rschema, rdefdef, props)
+        # update existing entities with the default value of newly added attribute
+        if default is not None:
+            default = convert_default_value(self.rdefdef, default)
+            cnx.system_sql('UPDATE %s SET %s=%%(default)s' % (table, column),
+                               {'default': default})
+        # if attribute is computed, compute it
+        if getattr(entity, 'formula', None):
+            # add rtype attribute for RelationDefinitionSchema api compat, this
+            # is what RecomputeAttributeOperation expect
+            rdefdef.rtype = rdefdef.name
+            RecomputeAttributeOperation.get_instance(cnx).add_data(rdefdef)
+
+    def revertprecommit_event(self):
+        # revert changes on in memory schema
+        if getattr(self, 'rdefdef', None) is None:
+            return
+        self.cnx.vreg.schema.del_relation_def(
+            self.rdefdef.subject, self.rdefdef.name, self.rdefdef.object)
+        # XXX revert changes on database
+
+
+class CWRelationAddOp(CWAttributeAddOp):
+    """an actual relation has been added:
+
+    * add the relation definition to the instance's schema
+
+    * 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
+
+    constraints are handled by specific hooks
+    """
+    entity = None # make pylint happy
+
+    def precommit_event(self):
+        cnx = self.cnx
+        entity = self.entity
+        # update the in-memory schema first
+        rdefdef, rdef = self.init_rdef(composite=entity.composite)
+        # then make necessary changes to the system source database
+        schema = cnx.vreg.schema
+        rtype = rdefdef.name
+        rschema = 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
+            if len(rschema.objects(rdefdef.subject)) == 1:
+                add_inline_relation_column(cnx, rdefdef.subject, rtype)
+            eschema = schema[rdefdef.subject]
+            insert_rdef_on_subclasses(cnx, eschema, rschema, rdefdef,
+                                      {'composite': entity.composite})
+        else:
+            if rschema.symmetric:
+                # for symmetric relations, rdefs will store relation definitions
+                # in both ways (i.e. (subj -> obj) and (obj -> subj))
+                relation_already_defined = len(rschema.rdefs) > 2
+            else:
+                relation_already_defined = len(rschema.rdefs) > 1
+            # 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 (relation_already_defined or
+                    rtype in cnx.transaction_data.get('createdtables', ())):
+                rschema = schema.rschema(rtype)
+                # create the necessary table
+                for sql in y2sql.rschema2sql(rschema).split(';'):
+                    if sql.strip():
+                        cnx.system_sql(sql)
+                cnx.transaction_data.setdefault('createdtables', []).append(
+                    rtype)
+
+    # XXX revertprecommit_event
+
+
+class RDefDelOp(MemSchemaOperation):
+    """an actual relation has been removed"""
+    rdef = None # make pylint happy
+
+    def precommit_event(self):
+        cnx = self.cnx
+        rdef = self.rdef
+        rschema = rdef.rtype
+        # make necessary changes to the system source database first
+        rdeftype = rschema.final and 'CWAttribute' or 'CWRelation'
+        execute = cnx.execute
+        rset = execute('Any COUNT(X) WHERE X is %s, X relation_type R,'
+                       'R eid %%(x)s' % rdeftype, {'x': rschema.eid})
+        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 %%(r)s, X from_entity E, E eid %%(e)s'
+                           % rdeftype,
+                           {'r': rschema.eid, 'e': rdef.subject.eid})
+            if rset[0][0] == 0 and not cnx.deleted_in_transaction(rdef.subject.eid):
+                ptypes = cnx.transaction_data.setdefault('pendingrtypes', set())
+                ptypes.add(rschema.type)
+                DropColumn.get_instance(cnx).add_data((str(rdef.subject), str(rschema)))
+            elif rschema.inlined:
+                cnx.system_sql('UPDATE %s%s SET %s%s=NULL WHERE '
+                               'EXISTS(SELECT 1 FROM entities '
+                               '       WHERE eid=%s%s AND type=%%(to_etype)s)'
+                               % (SQL_PREFIX, rdef.subject, SQL_PREFIX, rdef.rtype,
+                                  SQL_PREFIX, rdef.rtype),
+                               {'to_etype': rdef.object.type})
+        elif lastrel:
+            DropRelationTable(cnx, str(rschema))
+        else:
+            cnx.system_sql('DELETE FROM %s_relation WHERE '
+                           'EXISTS(SELECT 1 FROM entities '
+                           '       WHERE eid=eid_from AND type=%%(from_etype)s)'
+                           ' AND EXISTS(SELECT 1 FROM entities '
+                           '       WHERE eid=eid_to AND type=%%(to_etype)s)'
+                           % rschema,
+                           {'from_etype': rdef.subject.type, 'to_etype': rdef.object.type})
+        # then update the in-memory schema
+        if rdef.subject not in ETYPE_NAME_MAP and rdef.object not in ETYPE_NAME_MAP:
+            rschema.del_relation_def(rdef.subject, rdef.object)
+        # if this is the last relation definition of this type, drop associated
+        # relation type
+        if lastrel and not cnx.deleted_in_transaction(rschema.eid):
+            execute('DELETE CWRType X WHERE X eid %(x)s', {'x': rschema.eid})
+
+    def revertprecommit_event(self):
+        # revert changes on in memory schema
+        #
+        # Note: add_relation_def takes a RelationDefinition, not a
+        # RelationDefinitionSchema, needs to fake it
+        rdef = self.rdef
+        rdef.name = str(rdef.rtype)
+        if rdef.subject not in ETYPE_NAME_MAP and rdef.object not in ETYPE_NAME_MAP:
+            self.cnx.vreg.schema.add_relation_def(rdef)
+
+
+
+class RDefUpdateOp(MemSchemaOperation):
+    """actually update some properties of a relation definition"""
+    rschema = rdefkey = values = None # make pylint happy
+    rdef = oldvalues = None
+    indexed_changed = null_allowed_changed = False
+
+    def precommit_event(self):
+        cnx = self.cnx
+        rdef = self.rdef = self.rschema.rdefs[self.rdefkey]
+        # update the in-memory schema first
+        self.oldvalues = dict( (attr, getattr(rdef, attr)) for attr in self.values)
+        rdef.update(self.values)
+        # then make necessary changes to the system source database
+        syssource = cnx.repo.system_source
+        if 'indexed' in self.values:
+            syssource.update_rdef_indexed(cnx, rdef)
+            self.indexed_changed = True
+        if 'cardinality' in self.values and rdef.rtype.final \
+              and self.values['cardinality'][0] != self.oldvalues['cardinality'][0]:
+            syssource.update_rdef_null_allowed(self.cnx, rdef)
+            self.null_allowed_changed = True
+        if 'fulltextindexed' in self.values:
+            UpdateFTIndexOp.get_instance(cnx).add_data(rdef.subject)
+        if 'formula' in self.values:
+            RecomputeAttributeOperation.get_instance(cnx).add_data(rdef)
+
+    def revertprecommit_event(self):
+        if self.rdef is None:
+            return
+        # revert changes on in memory schema
+        self.rdef.update(self.oldvalues)
+        # revert changes on database
+        syssource = self.cnx.repo.system_source
+        if self.indexed_changed:
+            syssource.update_rdef_indexed(self.cnx, self.rdef)
+        if self.null_allowed_changed:
+            syssource.update_rdef_null_allowed(self.cnx, self.rdef)
+
+
+def _set_modifiable_constraints(rdef):
+    # for proper in-place modification of in-memory schema: if rdef.constraints
+    # is already a list, reuse it (we're updating multiple constraints of the
+    # same rdef in the same transaction)
+    if not isinstance(rdef.constraints, list):
+        rdef.constraints = list(rdef.constraints)
+
+
+class CWConstraintDelOp(MemSchemaOperation):
+    """actually remove a constraint of a relation definition"""
+    rdef = oldcstr = newcstr = None # make pylint happy
+    size_cstr_changed = unique_changed = False
+
+    def precommit_event(self):
+        cnx = self.cnx
+        rdef = self.rdef
+        # in-place modification of in-memory schema first
+        _set_modifiable_constraints(rdef)
+        if self.oldcstr in rdef.constraints:
+            rdef.constraints.remove(self.oldcstr)
+        else:
+            self.critical('constraint %s for rdef %s was missing or already removed',
+                          self.oldcstr, rdef)
+        if cnx.deleted_in_transaction(rdef.eid):
+            # don't try to alter a table that's going away (or is already gone)
+            return
+        # then update database: alter the physical schema on size/unique
+        # constraint changes
+        syssource = cnx.repo.system_source
+        cstrtype = self.oldcstr.type()
+        if cstrtype == 'SizeConstraint':
+            # if the size constraint is being replaced with a new max size, we'll
+            # call update_rdef_column in CWConstraintAddOp, skip it here
+            for cstr in cnx.transaction_data.get('newsizecstr', ()):
+                rdefentity = cstr.reverse_constrained_by[0]
+                cstrrdef = cnx.vreg.schema.schema_by_eid(rdefentity.eid)
+                if cstrrdef == rdef:
+                    return
+
+            # we found that the size constraint for this rdef is really gone,
+            # not just replaced by another
+            syssource.update_rdef_column(cnx, rdef)
+            self.size_cstr_changed = True
+        elif cstrtype == 'UniqueConstraint':
+            syssource.update_rdef_unique(cnx, rdef)
+            self.unique_changed = True
+        if cstrtype in ('BoundaryConstraint', 'IntervalBoundConstraint', 'StaticVocabularyConstraint'):
+            cstrname = 'cstr' + md5((rdef.subject.type + rdef.rtype.type + cstrtype +
+                                     (self.oldcstr.serialize() or '')).encode('utf-8')).hexdigest()
+            cnx.system_sql('ALTER TABLE %s%s DROP CONSTRAINT %s' % (SQL_PREFIX, rdef.subject.type, cstrname))
+
+    def revertprecommit_event(self):
+        # revert changes on in memory schema
+        if self.newcstr is not None:
+            self.rdef.constraints.remove(self.newcstr)
+        if self.oldcstr is not None:
+            self.rdef.constraints.append(self.oldcstr)
+        # revert changes on database
+        syssource = self.cnx.repo.system_source
+        if self.size_cstr_changed:
+            syssource.update_rdef_column(self.cnx, self.rdef)
+        if self.unique_changed:
+            syssource.update_rdef_unique(self.cnx, self.rdef)
+
+
+class CWConstraintAddOp(CWConstraintDelOp):
+    """actually update constraint of a relation definition"""
+    entity = None # make pylint happy
+
+    def precommit_event(self):
+        cnx = self.cnx
+        rdefentity = 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 cnx.added_in_transaction(rdefentity.eid):
+            return
+        rdef = self.rdef = cnx.vreg.schema.schema_by_eid(rdefentity.eid)
+        cstrtype = self.entity.type
+        if cstrtype in UNIQUE_CONSTRAINTS:
+            oldcstr = self.oldcstr = rdef.constraint_by_type(cstrtype)
+        else:
+            oldcstr = None
+        newcstr = self.newcstr = CONSTRAINTS[cstrtype].deserialize(self.entity.value)
+        # in-place modification of in-memory schema first
+        _set_modifiable_constraints(rdef)
+        newcstr.eid = self.entity.eid
+        if oldcstr is not None:
+            rdef.constraints.remove(oldcstr)
+        rdef.constraints.append(newcstr)
+        # then update database: alter the physical schema on size/unique
+        # constraint changes
+        syssource = cnx.repo.system_source
+        if cstrtype == 'SizeConstraint' and (oldcstr is None or
+                                             oldcstr.max != newcstr.max):
+            syssource.update_rdef_column(cnx, rdef)
+            self.size_cstr_changed = True
+        elif cstrtype == 'UniqueConstraint' and oldcstr is None:
+            syssource.update_rdef_unique(cnx, rdef)
+            self.unique_changed = True
+        if cstrtype in ('BoundaryConstraint', 'IntervalBoundConstraint', 'StaticVocabularyConstraint'):
+            if oldcstr is not None:
+                oldcstrname = 'cstr' + md5((rdef.subject.type + rdef.rtype.type + cstrtype +
+                                            (self.oldcstr.serialize() or '')).encode('ascii')).hexdigest()
+                cnx.system_sql('ALTER TABLE %s%s DROP CONSTRAINT %s' %
+                               (SQL_PREFIX, rdef.subject.type, oldcstrname))
+            cstrname, check = y2sql.check_constraint(rdef.subject, rdef.object, rdef.rtype.type,
+                    newcstr, syssource.dbhelper, prefix=SQL_PREFIX)
+            cnx.system_sql('ALTER TABLE %s%s ADD CONSTRAINT %s CHECK(%s)' %
+                           (SQL_PREFIX, rdef.subject.type, cstrname, check))
+
+
+class CWUniqueTogetherConstraintAddOp(MemSchemaOperation):
+    entity = None # make pylint happy
+
+    def precommit_event(self):
+        cnx = self.cnx
+        prefix = SQL_PREFIX
+        entity = self.entity
+        table = '%s%s' % (prefix, entity.constraint_of[0].name)
+        cols = ['%s%s' % (prefix, r.name) for r in entity.relations]
+        dbhelper = cnx.repo.system_source.dbhelper
+        sqls = dbhelper.sqls_create_multicol_unique_index(table, cols, entity.name)
+        for sql in sqls:
+            cnx.system_sql(sql)
+
+    def postcommit_event(self):
+        entity = self.entity
+        eschema = self.cnx.vreg.schema.schema_by_eid(entity.constraint_of[0].eid)
+        attrs = [r.name for r in entity.relations]
+        eschema._unique_together.append(attrs)
+
+
+class CWUniqueTogetherConstraintDelOp(MemSchemaOperation):
+    entity = cstrname = None # for pylint
+    cols = () # for pylint
+
+    def insert_index(self):
+        # We need to run before CWConstraintDelOp: if a size constraint is
+        # removed and the column is part of a unique_together constraint, we
+        # remove the unique_together index before changing the column's type.
+        # SQL Server does not support unique indices on unlimited text columns.
+        return 0
+
+    def precommit_event(self):
+        cnx = self.cnx
+        prefix = SQL_PREFIX
+        table = '%s%s' % (prefix, self.entity.type)
+        dbhelper = cnx.repo.system_source.dbhelper
+        cols = ['%s%s' % (prefix, c) for c in self.cols]
+        sqls = dbhelper.sqls_drop_multicol_unique_index(table, cols, self.cstrname)
+        for sql in sqls:
+            cnx.system_sql(sql)
+
+    def postcommit_event(self):
+        eschema = self.cnx.vreg.schema.schema_by_eid(self.entity.eid)
+        cols = set(self.cols)
+        unique_together = [ut for ut in eschema._unique_together
+                           if set(ut) != cols]
+        eschema._unique_together = unique_together
+
+
+# operations for in-memory schema synchronization  #############################
+
+class MemSchemaCWETypeDel(MemSchemaOperation):
+    """actually remove the entity type from the instance's schema"""
+    etype = None # make pylint happy
+
+    def postcommit_event(self):
+        # del_entity_type also removes entity's relations
+        self.cnx.vreg.schema.del_entity_type(self.etype)
+
+
+class MemSchemaCWRTypeAdd(MemSchemaOperation):
+    """actually add the relation type to the instance's schema"""
+    rtypedef = None # make pylint happy
+
+    def precommit_event(self):
+        self.cnx.vreg.schema.add_relation_type(self.rtypedef)
+
+    def revertprecommit_event(self):
+        self.cnx.vreg.schema.del_relation_type(self.rtypedef.name)
+
+
+class MemSchemaCWRTypeDel(MemSchemaOperation):
+    """actually remove the relation type from the instance's schema"""
+    rtype = None # make pylint happy
+
+    def postcommit_event(self):
+        try:
+            self.cnx.vreg.schema.del_relation_type(self.rtype)
+        except KeyError:
+            # s/o entity type have already been deleted
+            pass
+
+
+class MemSchemaPermissionAdd(MemSchemaOperation):
+    """synchronize schema when a *_permission relation has been added on a group
+    """
+    eid = action = group_eid = expr = None # make pylint happy
+
+    def precommit_event(self):
+        """the observed connections.cnxset has been commited"""
+        try:
+            erschema = self.cnx.vreg.schema.schema_by_eid(self.eid)
+        except KeyError:
+            # duh, schema not found, log error and skip operation
+            self.warning('no schema for %s', self.eid)
+            return
+        perms = list(erschema.action_permissions(self.action))
+        if self.group_eid is not None:
+            perm = self.cnx.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)
+
+    # XXX revertprecommit_event
+
+
+class MemSchemaPermissionDel(MemSchemaPermissionAdd):
+    """synchronize schema when a *_permission relation has been deleted from a
+    group
+    """
+
+    def precommit_event(self):
+        """the observed connections set has been commited"""
+        try:
+            erschema = self.cnx.vreg.schema.schema_by_eid(self.eid)
+        except KeyError:
+            # duh, schema not found, log error and skip operation
+            self.warning('no schema for %s', self.eid)
+            return
+        perms = list(erschema.action_permissions(self.action))
+        if self.group_eid is not None:
+            perm = self.cnx.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)
+
+    # XXX revertprecommit_event
+
+
+class MemSchemaSpecializesAdd(MemSchemaOperation):
+    etypeeid = parentetypeeid = None # make pylint happy
+
+    def precommit_event(self):
+        eschema = self.cnx.vreg.schema.schema_by_eid(self.etypeeid)
+        parenteschema = self.cnx.vreg.schema.schema_by_eid(self.parentetypeeid)
+        eschema._specialized_type = parenteschema.type
+        parenteschema._specialized_by.append(eschema.type)
+
+    # XXX revertprecommit_event
+
+
+class MemSchemaSpecializesDel(MemSchemaOperation):
+    etypeeid = parentetypeeid = None # make pylint happy
+
+    def precommit_event(self):
+        try:
+            eschema = self.cnx.vreg.schema.schema_by_eid(self.etypeeid)
+            parenteschema = self.cnx.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)
+
+    # XXX revertprecommit_event
+
+
+# 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__ & is_instance('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_TYPES:
+            raise validation_error(self.entity, {None: _("can't be deleted")})
+        # delete every entities of this type
+        if name not in ETYPE_NAME_MAP:
+            MemSchemaCWETypeDel(self._cw, etype=name)
+        DropTable(self._cw, table=SQL_PREFIX + 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.cw_edited.get('final'):
+            # final entity types don't need a table in the database and are
+            # systematically added by yams at schema initialization time so
+            # there is no need to do further processing. Simply assign its eid.
+            self._cw.vreg.schema[entity.name].eid = entity.eid
+            return
+        CWETypeAddOp(self._cw, entity=entity)
+
+
+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.cw_edited:
+            oldname, newname = entity.cw_edited.oldnewvalue('name')
+            if newname.lower() != oldname.lower():
+                CWETypeRenameOp(self._cw, oldname=oldname, newname=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__ & is_instance('CWRType')
+    events = ('before_delete_entity',)
+
+    def __call__(self):
+        name = self.entity.name
+        if name in CORE_TYPES:
+            raise validation_error(self.entity, {None: _("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, rtype=name)
+
+
+class AfterAddCWComputedRTypeHook(SyncSchemaHook):
+    """after a CWComputedRType 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__ = 'syncaddcwcomputedrtype'
+    __select__ = SyncSchemaHook.__select__ & is_instance('CWComputedRType')
+    events = ('after_add_entity',)
+
+    def __call__(self):
+        entity = self.entity
+        rtypedef = ybo.ComputedRelation(name=entity.name,
+                                        eid=entity.eid,
+                                        rule=entity.rule)
+        MemSchemaCWRTypeAdd(self._cw, rtypedef=rtypedef)
+
+
+class AfterAddCWRTypeHook(SyncSchemaHook):
+    """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'
+    __select__ = SyncSchemaHook.__select__ & is_instance('CWRType')
+    events = ('after_add_entity',)
+
+    def __call__(self):
+        entity = self.entity
+        rtypedef = ybo.RelationType(name=entity.name,
+                                    description=entity.description,
+                                    inlined=entity.cw_edited.get('inlined', False),
+                                    symmetric=entity.cw_edited.get('symmetric', False),
+                                    eid=entity.eid)
+        MemSchemaCWRTypeAdd(self._cw, rtypedef=rtypedef)
+
+
+class BeforeUpdateCWRTypeHook(SyncSchemaHook):
+    """check name change, handle final"""
+    __regid__ = 'syncupdatecwrtype'
+    __select__ = SyncSchemaHook.__select__ & is_instance('CWRType')
+    events = ('before_update_entity',)
+
+    def __call__(self):
+        entity = self.entity
+        check_valid_changes(self._cw, entity)
+        newvalues = {}
+        for prop in ('symmetric', 'inlined', 'fulltext_container'):
+            if prop in entity.cw_edited:
+                old, new = entity.cw_edited.oldnewvalue(prop)
+                if old != new:
+                    newvalues[prop] = new
+        if newvalues:
+            rschema = self._cw.vreg.schema.rschema(entity.name)
+            CWRTypeUpdateOp(self._cw, rschema=rschema, entity=entity,
+                            values=newvalues)
+
+
+class BeforeUpdateCWComputedRTypeHook(SyncSchemaHook):
+    """check name change, handle final"""
+    __regid__ = 'syncupdatecwcomputedrtype'
+    __select__ = SyncSchemaHook.__select__ & is_instance('CWComputedRType')
+    events = ('before_update_entity',)
+
+    def __call__(self):
+        entity = self.entity
+        check_valid_changes(self._cw, entity)
+        if 'rule' in entity.cw_edited:
+            old, new = entity.cw_edited.oldnewvalue('rule')
+            if old != new:
+                rschema = self._cw.vreg.schema.rschema(entity.name)
+                CWComputedRTypeUpdateOp(self._cw, rschema=rschema,
+                                        entity=entity, rule=new)
+
+
+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):
+        cnx = self._cw
+        try:
+            rdef = cnx.vreg.schema.schema_by_eid(self.eidfrom)
+        except KeyError:
+            self.critical('cant get schema rdef associated to %s', self.eidfrom)
+            return
+        subjschema, rschema, objschema = rdef.as_triple()
+        pendingrdefs = cnx.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))
+        RDefDelOp(cnx, rdef=rdef)
+
+
+# CWComputedRType hooks #######################################################
+
+class DelCWComputedRTypeHook(SyncSchemaHook):
+    """before deleting a CWComputedRType entity:
+    * check that we don't remove a core relation type
+    * instantiate an operation to delete the relation type on commit
+    """
+    __regid__ = 'syncdelcwcomputedrtype'
+    __select__ = SyncSchemaHook.__select__ & is_instance('CWComputedRType')
+    events = ('before_delete_entity',)
+
+    def __call__(self):
+        name = self.entity.name
+        if name in CORE_TYPES:
+            raise validation_error(self.entity, {None: _("can't be deleted")})
+        MemSchemaCWRTypeDel(self._cw, rtype=name)
+
+
+# CWAttribute / CWRelation hooks ###############################################
+
+class AfterAddCWAttributeHook(SyncSchemaHook):
+    __regid__ = 'syncaddcwattribute'
+    __select__ = SyncSchemaHook.__select__ & is_instance('CWAttribute')
+    events = ('after_add_entity',)
+
+    def __call__(self):
+        CWAttributeAddOp(self._cw, entity=self.entity)
+
+
+class AfterAddCWRelationHook(AfterAddCWAttributeHook):
+    __regid__ = 'syncaddcwrelation'
+    __select__ = SyncSchemaHook.__select__ & is_instance('CWRelation')
+
+    def __call__(self):
+        CWRelationAddOp(self._cw, entity=self.entity)
+
+
+class AfterUpdateCWRDefHook(SyncSchemaHook):
+    __regid__ = 'syncaddcwattribute'
+    __select__ = SyncSchemaHook.__select__ & is_instance('CWAttribute',
+                                                         'CWRelation')
+    events = ('before_update_entity',)
+
+    def __call__(self):
+        entity = self.entity
+        if self._cw.deleted_in_transaction(entity.eid):
+            return
+        subjtype = entity.stype.name
+        objtype = entity.otype.name
+        if subjtype in ETYPE_NAME_MAP or objtype in ETYPE_NAME_MAP:
+            return
+        rschema = self._cw.vreg.schema[entity.rtype.name]
+        # note: do not access schema rdef here, it may be added later by an
+        # operation
+        newvalues = {}
+        for prop in RelationDefinitionSchema.rproperty_defs(objtype):
+            if prop == 'constraints':
+                continue
+            if prop == 'order':
+                attr = 'ordernum'
+            else:
+                attr = prop
+            if attr in entity.cw_edited:
+                old, new = entity.cw_edited.oldnewvalue(attr)
+                if old != new:
+                    newvalues[prop] = new
+        if newvalues:
+            RDefUpdateOp(self._cw, rschema=rschema, rdefkey=(subjtype, objtype),
+                         values=newvalues)
+
+
+# constraints synchronization hooks ############################################
+
+class AfterAddCWConstraintHook(SyncSchemaHook):
+    __regid__ = 'syncaddcwconstraint'
+    __select__ = SyncSchemaHook.__select__ & is_instance('CWConstraint')
+    events = ('after_add_entity', 'after_update_entity')
+
+    def __call__(self):
+        if self.entity.cstrtype[0].name == 'SizeConstraint':
+            txdata = self._cw.transaction_data
+            if 'newsizecstr' not in txdata:
+                txdata['newsizecstr'] = set()
+            txdata['newsizecstr'].add(self.entity)
+        CWConstraintAddOp(self._cw, entity=self.entity)
+
+
+class AfterAddConstrainedByHook(SyncSchemaHook):
+    __regid__ = 'syncaddconstrainedby'
+    __select__ = SyncSchemaHook.__select__ & hook.match_rtype('constrained_by')
+    events = ('after_add_relation',)
+
+    def __call__(self):
+        if self._cw.added_in_transaction(self.eidfrom):
+            # used by get_constraints() which is called in CWAttributeAddOp
+            self._cw.transaction_data.setdefault(self.eidfrom, []).append(self.eidto)
+
+
+class BeforeDeleteCWConstraintHook(SyncSchemaHook):
+    __regid__ = 'syncdelcwconstraint'
+    __select__ = SyncSchemaHook.__select__ & is_instance('CWConstraint')
+    events = ('before_delete_entity',)
+
+    def __call__(self):
+        entity = self.entity
+        schema = self._cw.vreg.schema
+        try:
+            # KeyError, e.g. composite chain deletion
+            rdef = schema.schema_by_eid(entity.reverse_constrained_by[0].eid)
+            # IndexError
+            cstr = rdef.constraint_by_eid(entity.eid)
+        except (KeyError, IndexError):
+            self._cw.critical('constraint type no more accessible')
+        else:
+            CWConstraintDelOp(self._cw, rdef=rdef, oldcstr=cstr)
+
+# unique_together constraints
+# XXX: use setoperations and before_add_relation here (on constraint_of and relations)
+class AfterAddCWUniqueTogetherConstraintHook(SyncSchemaHook):
+    __regid__ = 'syncadd_cwuniquetogether_constraint'
+    __select__ = SyncSchemaHook.__select__ & is_instance('CWUniqueTogetherConstraint')
+    events = ('after_add_entity',)
+
+    def __call__(self):
+        CWUniqueTogetherConstraintAddOp(self._cw, entity=self.entity)
+
+
+class BeforeDeleteConstraintOfHook(SyncSchemaHook):
+    __regid__ = 'syncdelconstraintof'
+    __select__ = SyncSchemaHook.__select__ & hook.match_rtype('constraint_of')
+    events = ('before_delete_relation',)
+
+    def __call__(self):
+        if self._cw.deleted_in_transaction(self.eidto):
+            return
+        schema = self._cw.vreg.schema
+        cstr = self._cw.entity_from_eid(self.eidfrom)
+        entity = schema.schema_by_eid(self.eidto)
+        cols = tuple(r.name for r in cstr.relations)
+        CWUniqueTogetherConstraintDelOp(self._cw, entity=entity,
+                                        cstrname=cstr.name, cols=cols)
+
+
+# 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.entity_metas(self.eidto)['type'] == '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.entity_metas(self.eidto)['type'] == '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)
+
+
+
+class UpdateFTIndexOp(hook.DataOperationMixIn, hook.SingleLastOperation):
+    """operation to update full text indexation of entity whose schema change
+
+    We wait after the commit to as the schema in memory is only updated after
+    the commit.
+    """
+    containercls = list
+
+    def postcommit_event(self):
+        cnx = self.cnx
+        source = cnx.repo.system_source
+        schema = cnx.repo.vreg.schema
+        to_reindex = self.get_data()
+        self.info('%i etypes need full text indexed reindexation',
+                  len(to_reindex))
+        for etype in to_reindex:
+            rset = cnx.execute('Any X WHERE X is %s' % etype)
+            self.info('Reindexing full text index for %i entity of type %s',
+                      len(rset), etype)
+            still_fti = list(schema[etype].indexable_attributes())
+            for entity in rset.entities():
+                source.fti_unindex_entities(cnx, [entity])
+                for container in entity.cw_adapt_to('IFTIndexable').fti_containers():
+                    if still_fti or container is not entity:
+                        source.fti_unindex_entities(cnx, [container])
+                        source.fti_index_entities(cnx, [container])
+        if to_reindex:
+            # Transaction has already been committed
+            cnx.cnxset.commit()
+
+
+
+
+# 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)