cubicweb/hooks/integrity.py
changeset 11057 0b59724cb3f2
parent 10936 c3606b52092c
child 11366 80dec361a5d0
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/cubicweb/hooks/integrity.py	Sat Jan 16 13:48:51 2016 +0100
@@ -0,0 +1,347 @@
+# copyright 2003-2014 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/>.
+"""Core hooks: check for data integrity according to the instance'schema
+validity
+"""
+
+__docformat__ = "restructuredtext en"
+from cubicweb import _
+
+from threading import Lock
+
+from six import text_type
+
+from cubicweb import validation_error, neg_role
+from cubicweb.schema import (META_RTYPES, WORKFLOW_RTYPES,
+                             RQLConstraint, RQLUniqueConstraint)
+from cubicweb.predicates import is_instance, composite_etype
+from cubicweb.uilib import soup2xhtml
+from cubicweb.server import hook
+
+# special relations that don't have to be checked for integrity, usually
+# because they are handled internally by hooks (so we trust ourselves)
+DONT_CHECK_RTYPES_ON_ADD = META_RTYPES | WORKFLOW_RTYPES
+DONT_CHECK_RTYPES_ON_DEL = META_RTYPES | WORKFLOW_RTYPES
+
+_UNIQUE_CONSTRAINTS_LOCK = Lock()
+_UNIQUE_CONSTRAINTS_HOLDER = None
+
+
+def _acquire_unique_cstr_lock(cnx):
+    """acquire the _UNIQUE_CONSTRAINTS_LOCK for the cnx.
+
+    This lock used to avoid potential integrity pb when checking
+    RQLUniqueConstraint in two different transactions, as explained in
+    https://extranet.logilab.fr/3577926
+    """
+    if 'uniquecstrholder' in cnx.transaction_data:
+        return
+    _UNIQUE_CONSTRAINTS_LOCK.acquire()
+    cnx.transaction_data['uniquecstrholder'] = True
+    # register operation responsible to release the lock on commit/rollback
+    _ReleaseUniqueConstraintsOperation(cnx)
+
+def _release_unique_cstr_lock(cnx):
+    if 'uniquecstrholder' in cnx.transaction_data:
+        del cnx.transaction_data['uniquecstrholder']
+        _UNIQUE_CONSTRAINTS_LOCK.release()
+
+class _ReleaseUniqueConstraintsOperation(hook.Operation):
+    def postcommit_event(self):
+        _release_unique_cstr_lock(self.cnx)
+    def rollback_event(self):
+        _release_unique_cstr_lock(self.cnx)
+
+
+class _CheckRequiredRelationOperation(hook.DataOperationMixIn,
+                                      hook.LateOperation):
+    """checking relation cardinality has to be done after commit in case the
+    relation is being replaced
+    """
+    containercls = list
+    role = key = base_rql = None
+
+    def precommit_event(self):
+        cnx = self.cnx
+        pendingeids = cnx.transaction_data.get('pendingeids', ())
+        pendingrtypes = cnx.transaction_data.get('pendingrtypes', ())
+        for eid, rtype in self.get_data():
+            # recheck pending eids / relation types
+            if eid in pendingeids:
+                continue
+            if rtype in pendingrtypes:
+                continue
+            if not cnx.execute(self.base_rql % rtype, {'x': eid}):
+                etype = cnx.entity_metas(eid)['type']
+                msg = _('at least one relation %(rtype)s is required on '
+                        '%(etype)s (%(eid)s)')
+                raise validation_error(eid, {(rtype, self.role): msg},
+                                       {'rtype': rtype, 'etype': etype, 'eid': eid},
+                                       ['rtype', 'etype'])
+
+
+class _CheckSRelationOp(_CheckRequiredRelationOperation):
+    """check required subject relation"""
+    role = 'subject'
+    base_rql = 'Any O WHERE S eid %%(x)s, S %s O'
+
+class _CheckORelationOp(_CheckRequiredRelationOperation):
+    """check required object relation"""
+    role = 'object'
+    base_rql = 'Any S WHERE O eid %%(x)s, S %s O'
+
+
+class IntegrityHook(hook.Hook):
+    __abstract__ = True
+    category = 'integrity'
+
+
+class _EnsureSymmetricRelationsAdd(hook.Hook):
+    """ ensure X r Y => Y r X iff r is symmetric """
+    __regid__ = 'cw.add_ensure_symmetry'
+    __abstract__ = True
+    category = 'activeintegrity'
+    events = ('after_add_relation',)
+    # __select__ is set in the registration callback
+
+    def __call__(self):
+        self._cw.repo.system_source.add_relation(self._cw, self.eidto,
+                                                 self.rtype, self.eidfrom)
+
+
+class _EnsureSymmetricRelationsDelete(hook.Hook):
+    """ ensure X r Y => Y r X iff r is symmetric """
+    __regid__ = 'cw.delete_ensure_symmetry'
+    __abstract__ = True
+    category = 'activeintegrity'
+    events = ('after_delete_relation',)
+    # __select__ is set in the registration callback
+
+    def __call__(self):
+        self._cw.repo.system_source.delete_relation(self._cw, self.eidto,
+                                                    self.rtype, self.eidfrom)
+
+
+class CheckCardinalityHookBeforeDeleteRelation(IntegrityHook):
+    """check cardinalities are satisfied"""
+    __regid__ = 'checkcard_before_delete_relation'
+    events = ('before_delete_relation',)
+
+    def __call__(self):
+        rtype = self.rtype
+        if rtype in DONT_CHECK_RTYPES_ON_DEL:
+            return
+        cnx = self._cw
+        eidfrom, eidto = self.eidfrom, self.eidto
+        rdef = cnx.rtype_eids_rdef(rtype, eidfrom, eidto)
+        if (rdef.subject, rtype, rdef.object) in cnx.transaction_data.get('pendingrdefs', ()):
+            return
+        card = rdef.cardinality
+        if card[0] in '1+' and not cnx.deleted_in_transaction(eidfrom):
+            _CheckSRelationOp.get_instance(cnx).add_data((eidfrom, rtype))
+        if card[1] in '1+' and not cnx.deleted_in_transaction(eidto):
+            _CheckORelationOp.get_instance(cnx).add_data((eidto, rtype))
+
+
+class CheckCardinalityHookAfterAddEntity(IntegrityHook):
+    """check cardinalities are satisfied"""
+    __regid__ = 'checkcard_after_add_entity'
+    events = ('after_add_entity',)
+
+    def __call__(self):
+        eid = self.entity.eid
+        eschema = self.entity.e_schema
+        for rschema, targetschemas, role in eschema.relation_definitions():
+            # skip automatically handled relations
+            if rschema.type in DONT_CHECK_RTYPES_ON_ADD:
+                continue
+            rdef = rschema.role_rdef(eschema, targetschemas[0], role)
+            if rdef.role_cardinality(role) in '1+':
+                if role == 'subject':
+                    op = _CheckSRelationOp.get_instance(self._cw)
+                else:
+                    op = _CheckORelationOp.get_instance(self._cw)
+                op.add_data((eid, rschema.type))
+
+
+class _CheckConstraintsOp(hook.DataOperationMixIn, hook.LateOperation):
+    """ check a new relation satisfy its constraints """
+    containercls = list
+    def precommit_event(self):
+        cnx = self.cnx
+        for values in self.get_data():
+            eidfrom, rtype, eidto, constraints = values
+            # first check related entities have not been deleted in the same
+            # transaction
+            if cnx.deleted_in_transaction(eidfrom):
+                continue
+            if cnx.deleted_in_transaction(eidto):
+                continue
+            for constraint in constraints:
+                # XXX
+                # * lock RQLConstraint as well?
+                # * use a constraint id to use per constraint lock and avoid
+                #   unnecessary commit serialization ?
+                if isinstance(constraint, RQLUniqueConstraint):
+                    _acquire_unique_cstr_lock(cnx)
+                try:
+                    constraint.repo_check(cnx, eidfrom, rtype, eidto)
+                except NotImplementedError:
+                    self.critical('can\'t check constraint %s, not supported',
+                                  constraint)
+
+
+class CheckConstraintHook(IntegrityHook):
+    """check the relation satisfy its constraints
+
+    this is delayed to a precommit time operation since other relation which
+    will make constraint satisfied (or unsatisfied) may be added later.
+    """
+    __regid__ = 'checkconstraint'
+    events = ('after_add_relation',)
+
+    def __call__(self):
+        # XXX get only RQL[Unique]Constraints?
+        rdef = self._cw.rtype_eids_rdef(self.rtype, self.eidfrom, self.eidto)
+        constraints = rdef.constraints
+        if constraints:
+            _CheckConstraintsOp.get_instance(self._cw).add_data(
+                (self.eidfrom, self.rtype, self.eidto, constraints))
+
+
+class CheckAttributeConstraintHook(IntegrityHook):
+    """check the attribute relation satisfy its constraints
+
+    this is delayed to a precommit time operation since other relation which
+    will make constraint satisfied (or unsatisfied) may be added later.
+    """
+    __regid__ = 'checkattrconstraint'
+    events = ('after_add_entity', 'after_update_entity')
+
+    def __call__(self):
+        eschema = self.entity.e_schema
+        for attr in self.entity.cw_edited:
+            if eschema.subjrels[attr].final:
+                constraints = [c for c in eschema.rdef(attr).constraints
+                               if isinstance(c, (RQLUniqueConstraint, RQLConstraint))]
+                if constraints:
+                    _CheckConstraintsOp.get_instance(self._cw).add_data(
+                        (self.entity.eid, attr, None, constraints))
+
+
+class CheckUniqueHook(IntegrityHook):
+    __regid__ = 'checkunique'
+    events = ('before_add_entity', 'before_update_entity')
+
+    def __call__(self):
+        entity = self.entity
+        eschema = entity.e_schema
+        for attr, val in entity.cw_edited.items():
+            if eschema.subjrels[attr].final and eschema.has_unique_values(attr):
+                if val is None:
+                    continue
+                rql = '%s X WHERE X %s %%(val)s' % (entity.e_schema, attr)
+                rset = self._cw.execute(rql, {'val': val})
+                if rset and rset[0][0] != entity.eid:
+                    msg = _('the value "%s" is already used, use another one')
+                    raise validation_error(entity, {(attr, 'subject'): msg},
+                                           (val,))
+
+
+class DontRemoveOwnersGroupHook(IntegrityHook):
+    """delete the composed of a composite relation when this relation is deleted
+    """
+    __regid__ = 'checkownersgroup'
+    __select__ = IntegrityHook.__select__ & is_instance('CWGroup')
+    events = ('before_delete_entity', 'before_update_entity')
+
+    def __call__(self):
+        entity = self.entity
+        if self.event == 'before_delete_entity' and entity.name == 'owners':
+            raise validation_error(entity, {None: _("can't be deleted")})
+        elif self.event == 'before_update_entity' \
+                 and 'name' in entity.cw_edited:
+            oldname, newname = entity.cw_edited.oldnewvalue('name')
+            if oldname == 'owners' and newname != oldname:
+                raise validation_error(entity, {('name', 'subject'): _("can't be changed")})
+
+
+class TidyHtmlFields(IntegrityHook):
+    """tidy HTML in rich text strings"""
+    __regid__ = 'htmltidy'
+    events = ('before_add_entity', 'before_update_entity')
+
+    def __call__(self):
+        entity = self.entity
+        metaattrs = entity.e_schema.meta_attributes()
+        edited = entity.cw_edited
+        for metaattr, (metadata, attr) in metaattrs.items():
+            if metadata == 'format' and attr in edited:
+                try:
+                    value = edited[attr]
+                except KeyError:
+                    continue # no text to tidy
+                if isinstance(value, text_type): # filter out None and Binary
+                    if getattr(entity, str(metaattr)) == 'text/html':
+                        edited[attr] = soup2xhtml(value, self._cw.encoding)
+
+
+class StripCWUserLoginHook(IntegrityHook):
+    """ensure user logins are stripped"""
+    __regid__ = 'stripuserlogin'
+    __select__ = IntegrityHook.__select__ & is_instance('CWUser')
+    events = ('before_add_entity', 'before_update_entity',)
+
+    def __call__(self):
+        login = self.entity.cw_edited.get('login')
+        if login:
+            self.entity.cw_edited['login'] = login.strip()
+
+
+class DeleteCompositeOrphanHook(hook.Hook):
+    """Delete the composed of a composite relation when the composite is
+    deleted (this is similar to the cascading ON DELETE CASCADE
+    semantics of sql).
+    """
+    __regid__ = 'deletecomposite'
+    __select__ = hook.Hook.__select__ & composite_etype()
+    events = ('before_delete_entity',)
+    category = 'activeintegrity'
+    # give the application's before_delete_entity hooks a chance to run before we cascade
+    order = 99
+
+    def __call__(self):
+        eid = self.entity.eid
+        for rdef, role in self.entity.e_schema.composite_rdef_roles:
+            rtype = rdef.rtype.type
+            target = getattr(rdef, neg_role(role))
+            expr = ('C %s X' % rtype) if role == 'subject' else ('X %s C' % rtype)
+            self._cw.execute('DELETE %s X WHERE C eid %%(c)s, %s' % (target, expr),
+                             {'c': eid})
+
+
+def registration_callback(vreg):
+    vreg.register_all(globals().values(), __name__)
+    symmetric_rtypes = [rschema.type for rschema in vreg.schema.relations()
+                        if rschema.symmetric]
+    class EnsureSymmetricRelationsAdd(_EnsureSymmetricRelationsAdd):
+        __select__ = _EnsureSymmetricRelationsAdd.__select__ & hook.match_rtype(*symmetric_rtypes)
+    vreg.register(EnsureSymmetricRelationsAdd)
+    class EnsureSymmetricRelationsDelete(_EnsureSymmetricRelationsDelete):
+        __select__ = _EnsureSymmetricRelationsDelete.__select__ & hook.match_rtype(*symmetric_rtypes)
+    vreg.register(EnsureSymmetricRelationsDelete)