diff -r 058bb3dc685f -r 0b59724cb3f2 cubicweb/hooks/integrity.py --- /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 . +"""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)