hooks/integrity.py
changeset 11057 0b59724cb3f2
parent 11052 058bb3dc685f
child 11058 23eb30449fe5
equal deleted inserted replaced
11052:058bb3dc685f 11057:0b59724cb3f2
     1 # copyright 2003-2014 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
       
     2 # contact http://www.logilab.fr/ -- mailto:contact@logilab.fr
       
     3 #
       
     4 # This file is part of CubicWeb.
       
     5 #
       
     6 # CubicWeb is free software: you can redistribute it and/or modify it under the
       
     7 # terms of the GNU Lesser General Public License as published by the Free
       
     8 # Software Foundation, either version 2.1 of the License, or (at your option)
       
     9 # any later version.
       
    10 #
       
    11 # CubicWeb is distributed in the hope that it will be useful, but WITHOUT
       
    12 # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
       
    13 # FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public License for more
       
    14 # details.
       
    15 #
       
    16 # You should have received a copy of the GNU Lesser General Public License along
       
    17 # with CubicWeb.  If not, see <http://www.gnu.org/licenses/>.
       
    18 """Core hooks: check for data integrity according to the instance'schema
       
    19 validity
       
    20 """
       
    21 
       
    22 __docformat__ = "restructuredtext en"
       
    23 from cubicweb import _
       
    24 
       
    25 from threading import Lock
       
    26 
       
    27 from six import text_type
       
    28 
       
    29 from cubicweb import validation_error, neg_role
       
    30 from cubicweb.schema import (META_RTYPES, WORKFLOW_RTYPES,
       
    31                              RQLConstraint, RQLUniqueConstraint)
       
    32 from cubicweb.predicates import is_instance, composite_etype
       
    33 from cubicweb.uilib import soup2xhtml
       
    34 from cubicweb.server import hook
       
    35 
       
    36 # special relations that don't have to be checked for integrity, usually
       
    37 # because they are handled internally by hooks (so we trust ourselves)
       
    38 DONT_CHECK_RTYPES_ON_ADD = META_RTYPES | WORKFLOW_RTYPES
       
    39 DONT_CHECK_RTYPES_ON_DEL = META_RTYPES | WORKFLOW_RTYPES
       
    40 
       
    41 _UNIQUE_CONSTRAINTS_LOCK = Lock()
       
    42 _UNIQUE_CONSTRAINTS_HOLDER = None
       
    43 
       
    44 
       
    45 def _acquire_unique_cstr_lock(cnx):
       
    46     """acquire the _UNIQUE_CONSTRAINTS_LOCK for the cnx.
       
    47 
       
    48     This lock used to avoid potential integrity pb when checking
       
    49     RQLUniqueConstraint in two different transactions, as explained in
       
    50     https://extranet.logilab.fr/3577926
       
    51     """
       
    52     if 'uniquecstrholder' in cnx.transaction_data:
       
    53         return
       
    54     _UNIQUE_CONSTRAINTS_LOCK.acquire()
       
    55     cnx.transaction_data['uniquecstrholder'] = True
       
    56     # register operation responsible to release the lock on commit/rollback
       
    57     _ReleaseUniqueConstraintsOperation(cnx)
       
    58 
       
    59 def _release_unique_cstr_lock(cnx):
       
    60     if 'uniquecstrholder' in cnx.transaction_data:
       
    61         del cnx.transaction_data['uniquecstrholder']
       
    62         _UNIQUE_CONSTRAINTS_LOCK.release()
       
    63 
       
    64 class _ReleaseUniqueConstraintsOperation(hook.Operation):
       
    65     def postcommit_event(self):
       
    66         _release_unique_cstr_lock(self.cnx)
       
    67     def rollback_event(self):
       
    68         _release_unique_cstr_lock(self.cnx)
       
    69 
       
    70 
       
    71 class _CheckRequiredRelationOperation(hook.DataOperationMixIn,
       
    72                                       hook.LateOperation):
       
    73     """checking relation cardinality has to be done after commit in case the
       
    74     relation is being replaced
       
    75     """
       
    76     containercls = list
       
    77     role = key = base_rql = None
       
    78 
       
    79     def precommit_event(self):
       
    80         cnx = self.cnx
       
    81         pendingeids = cnx.transaction_data.get('pendingeids', ())
       
    82         pendingrtypes = cnx.transaction_data.get('pendingrtypes', ())
       
    83         for eid, rtype in self.get_data():
       
    84             # recheck pending eids / relation types
       
    85             if eid in pendingeids:
       
    86                 continue
       
    87             if rtype in pendingrtypes:
       
    88                 continue
       
    89             if not cnx.execute(self.base_rql % rtype, {'x': eid}):
       
    90                 etype = cnx.entity_metas(eid)['type']
       
    91                 msg = _('at least one relation %(rtype)s is required on '
       
    92                         '%(etype)s (%(eid)s)')
       
    93                 raise validation_error(eid, {(rtype, self.role): msg},
       
    94                                        {'rtype': rtype, 'etype': etype, 'eid': eid},
       
    95                                        ['rtype', 'etype'])
       
    96 
       
    97 
       
    98 class _CheckSRelationOp(_CheckRequiredRelationOperation):
       
    99     """check required subject relation"""
       
   100     role = 'subject'
       
   101     base_rql = 'Any O WHERE S eid %%(x)s, S %s O'
       
   102 
       
   103 class _CheckORelationOp(_CheckRequiredRelationOperation):
       
   104     """check required object relation"""
       
   105     role = 'object'
       
   106     base_rql = 'Any S WHERE O eid %%(x)s, S %s O'
       
   107 
       
   108 
       
   109 class IntegrityHook(hook.Hook):
       
   110     __abstract__ = True
       
   111     category = 'integrity'
       
   112 
       
   113 
       
   114 class _EnsureSymmetricRelationsAdd(hook.Hook):
       
   115     """ ensure X r Y => Y r X iff r is symmetric """
       
   116     __regid__ = 'cw.add_ensure_symmetry'
       
   117     __abstract__ = True
       
   118     category = 'activeintegrity'
       
   119     events = ('after_add_relation',)
       
   120     # __select__ is set in the registration callback
       
   121 
       
   122     def __call__(self):
       
   123         self._cw.repo.system_source.add_relation(self._cw, self.eidto,
       
   124                                                  self.rtype, self.eidfrom)
       
   125 
       
   126 
       
   127 class _EnsureSymmetricRelationsDelete(hook.Hook):
       
   128     """ ensure X r Y => Y r X iff r is symmetric """
       
   129     __regid__ = 'cw.delete_ensure_symmetry'
       
   130     __abstract__ = True
       
   131     category = 'activeintegrity'
       
   132     events = ('after_delete_relation',)
       
   133     # __select__ is set in the registration callback
       
   134 
       
   135     def __call__(self):
       
   136         self._cw.repo.system_source.delete_relation(self._cw, self.eidto,
       
   137                                                     self.rtype, self.eidfrom)
       
   138 
       
   139 
       
   140 class CheckCardinalityHookBeforeDeleteRelation(IntegrityHook):
       
   141     """check cardinalities are satisfied"""
       
   142     __regid__ = 'checkcard_before_delete_relation'
       
   143     events = ('before_delete_relation',)
       
   144 
       
   145     def __call__(self):
       
   146         rtype = self.rtype
       
   147         if rtype in DONT_CHECK_RTYPES_ON_DEL:
       
   148             return
       
   149         cnx = self._cw
       
   150         eidfrom, eidto = self.eidfrom, self.eidto
       
   151         rdef = cnx.rtype_eids_rdef(rtype, eidfrom, eidto)
       
   152         if (rdef.subject, rtype, rdef.object) in cnx.transaction_data.get('pendingrdefs', ()):
       
   153             return
       
   154         card = rdef.cardinality
       
   155         if card[0] in '1+' and not cnx.deleted_in_transaction(eidfrom):
       
   156             _CheckSRelationOp.get_instance(cnx).add_data((eidfrom, rtype))
       
   157         if card[1] in '1+' and not cnx.deleted_in_transaction(eidto):
       
   158             _CheckORelationOp.get_instance(cnx).add_data((eidto, rtype))
       
   159 
       
   160 
       
   161 class CheckCardinalityHookAfterAddEntity(IntegrityHook):
       
   162     """check cardinalities are satisfied"""
       
   163     __regid__ = 'checkcard_after_add_entity'
       
   164     events = ('after_add_entity',)
       
   165 
       
   166     def __call__(self):
       
   167         eid = self.entity.eid
       
   168         eschema = self.entity.e_schema
       
   169         for rschema, targetschemas, role in eschema.relation_definitions():
       
   170             # skip automatically handled relations
       
   171             if rschema.type in DONT_CHECK_RTYPES_ON_ADD:
       
   172                 continue
       
   173             rdef = rschema.role_rdef(eschema, targetschemas[0], role)
       
   174             if rdef.role_cardinality(role) in '1+':
       
   175                 if role == 'subject':
       
   176                     op = _CheckSRelationOp.get_instance(self._cw)
       
   177                 else:
       
   178                     op = _CheckORelationOp.get_instance(self._cw)
       
   179                 op.add_data((eid, rschema.type))
       
   180 
       
   181 
       
   182 class _CheckConstraintsOp(hook.DataOperationMixIn, hook.LateOperation):
       
   183     """ check a new relation satisfy its constraints """
       
   184     containercls = list
       
   185     def precommit_event(self):
       
   186         cnx = self.cnx
       
   187         for values in self.get_data():
       
   188             eidfrom, rtype, eidto, constraints = values
       
   189             # first check related entities have not been deleted in the same
       
   190             # transaction
       
   191             if cnx.deleted_in_transaction(eidfrom):
       
   192                 continue
       
   193             if cnx.deleted_in_transaction(eidto):
       
   194                 continue
       
   195             for constraint in constraints:
       
   196                 # XXX
       
   197                 # * lock RQLConstraint as well?
       
   198                 # * use a constraint id to use per constraint lock and avoid
       
   199                 #   unnecessary commit serialization ?
       
   200                 if isinstance(constraint, RQLUniqueConstraint):
       
   201                     _acquire_unique_cstr_lock(cnx)
       
   202                 try:
       
   203                     constraint.repo_check(cnx, eidfrom, rtype, eidto)
       
   204                 except NotImplementedError:
       
   205                     self.critical('can\'t check constraint %s, not supported',
       
   206                                   constraint)
       
   207 
       
   208 
       
   209 class CheckConstraintHook(IntegrityHook):
       
   210     """check the relation satisfy its constraints
       
   211 
       
   212     this is delayed to a precommit time operation since other relation which
       
   213     will make constraint satisfied (or unsatisfied) may be added later.
       
   214     """
       
   215     __regid__ = 'checkconstraint'
       
   216     events = ('after_add_relation',)
       
   217 
       
   218     def __call__(self):
       
   219         # XXX get only RQL[Unique]Constraints?
       
   220         rdef = self._cw.rtype_eids_rdef(self.rtype, self.eidfrom, self.eidto)
       
   221         constraints = rdef.constraints
       
   222         if constraints:
       
   223             _CheckConstraintsOp.get_instance(self._cw).add_data(
       
   224                 (self.eidfrom, self.rtype, self.eidto, constraints))
       
   225 
       
   226 
       
   227 class CheckAttributeConstraintHook(IntegrityHook):
       
   228     """check the attribute relation satisfy its constraints
       
   229 
       
   230     this is delayed to a precommit time operation since other relation which
       
   231     will make constraint satisfied (or unsatisfied) may be added later.
       
   232     """
       
   233     __regid__ = 'checkattrconstraint'
       
   234     events = ('after_add_entity', 'after_update_entity')
       
   235 
       
   236     def __call__(self):
       
   237         eschema = self.entity.e_schema
       
   238         for attr in self.entity.cw_edited:
       
   239             if eschema.subjrels[attr].final:
       
   240                 constraints = [c for c in eschema.rdef(attr).constraints
       
   241                                if isinstance(c, (RQLUniqueConstraint, RQLConstraint))]
       
   242                 if constraints:
       
   243                     _CheckConstraintsOp.get_instance(self._cw).add_data(
       
   244                         (self.entity.eid, attr, None, constraints))
       
   245 
       
   246 
       
   247 class CheckUniqueHook(IntegrityHook):
       
   248     __regid__ = 'checkunique'
       
   249     events = ('before_add_entity', 'before_update_entity')
       
   250 
       
   251     def __call__(self):
       
   252         entity = self.entity
       
   253         eschema = entity.e_schema
       
   254         for attr, val in entity.cw_edited.items():
       
   255             if eschema.subjrels[attr].final and eschema.has_unique_values(attr):
       
   256                 if val is None:
       
   257                     continue
       
   258                 rql = '%s X WHERE X %s %%(val)s' % (entity.e_schema, attr)
       
   259                 rset = self._cw.execute(rql, {'val': val})
       
   260                 if rset and rset[0][0] != entity.eid:
       
   261                     msg = _('the value "%s" is already used, use another one')
       
   262                     raise validation_error(entity, {(attr, 'subject'): msg},
       
   263                                            (val,))
       
   264 
       
   265 
       
   266 class DontRemoveOwnersGroupHook(IntegrityHook):
       
   267     """delete the composed of a composite relation when this relation is deleted
       
   268     """
       
   269     __regid__ = 'checkownersgroup'
       
   270     __select__ = IntegrityHook.__select__ & is_instance('CWGroup')
       
   271     events = ('before_delete_entity', 'before_update_entity')
       
   272 
       
   273     def __call__(self):
       
   274         entity = self.entity
       
   275         if self.event == 'before_delete_entity' and entity.name == 'owners':
       
   276             raise validation_error(entity, {None: _("can't be deleted")})
       
   277         elif self.event == 'before_update_entity' \
       
   278                  and 'name' in entity.cw_edited:
       
   279             oldname, newname = entity.cw_edited.oldnewvalue('name')
       
   280             if oldname == 'owners' and newname != oldname:
       
   281                 raise validation_error(entity, {('name', 'subject'): _("can't be changed")})
       
   282 
       
   283 
       
   284 class TidyHtmlFields(IntegrityHook):
       
   285     """tidy HTML in rich text strings"""
       
   286     __regid__ = 'htmltidy'
       
   287     events = ('before_add_entity', 'before_update_entity')
       
   288 
       
   289     def __call__(self):
       
   290         entity = self.entity
       
   291         metaattrs = entity.e_schema.meta_attributes()
       
   292         edited = entity.cw_edited
       
   293         for metaattr, (metadata, attr) in metaattrs.items():
       
   294             if metadata == 'format' and attr in edited:
       
   295                 try:
       
   296                     value = edited[attr]
       
   297                 except KeyError:
       
   298                     continue # no text to tidy
       
   299                 if isinstance(value, text_type): # filter out None and Binary
       
   300                     if getattr(entity, str(metaattr)) == 'text/html':
       
   301                         edited[attr] = soup2xhtml(value, self._cw.encoding)
       
   302 
       
   303 
       
   304 class StripCWUserLoginHook(IntegrityHook):
       
   305     """ensure user logins are stripped"""
       
   306     __regid__ = 'stripuserlogin'
       
   307     __select__ = IntegrityHook.__select__ & is_instance('CWUser')
       
   308     events = ('before_add_entity', 'before_update_entity',)
       
   309 
       
   310     def __call__(self):
       
   311         login = self.entity.cw_edited.get('login')
       
   312         if login:
       
   313             self.entity.cw_edited['login'] = login.strip()
       
   314 
       
   315 
       
   316 class DeleteCompositeOrphanHook(hook.Hook):
       
   317     """Delete the composed of a composite relation when the composite is
       
   318     deleted (this is similar to the cascading ON DELETE CASCADE
       
   319     semantics of sql).
       
   320     """
       
   321     __regid__ = 'deletecomposite'
       
   322     __select__ = hook.Hook.__select__ & composite_etype()
       
   323     events = ('before_delete_entity',)
       
   324     category = 'activeintegrity'
       
   325     # give the application's before_delete_entity hooks a chance to run before we cascade
       
   326     order = 99
       
   327 
       
   328     def __call__(self):
       
   329         eid = self.entity.eid
       
   330         for rdef, role in self.entity.e_schema.composite_rdef_roles:
       
   331             rtype = rdef.rtype.type
       
   332             target = getattr(rdef, neg_role(role))
       
   333             expr = ('C %s X' % rtype) if role == 'subject' else ('X %s C' % rtype)
       
   334             self._cw.execute('DELETE %s X WHERE C eid %%(c)s, %s' % (target, expr),
       
   335                              {'c': eid})
       
   336 
       
   337 
       
   338 def registration_callback(vreg):
       
   339     vreg.register_all(globals().values(), __name__)
       
   340     symmetric_rtypes = [rschema.type for rschema in vreg.schema.relations()
       
   341                         if rschema.symmetric]
       
   342     class EnsureSymmetricRelationsAdd(_EnsureSymmetricRelationsAdd):
       
   343         __select__ = _EnsureSymmetricRelationsAdd.__select__ & hook.match_rtype(*symmetric_rtypes)
       
   344     vreg.register(EnsureSymmetricRelationsAdd)
       
   345     class EnsureSymmetricRelationsDelete(_EnsureSymmetricRelationsDelete):
       
   346         __select__ = _EnsureSymmetricRelationsDelete.__select__ & hook.match_rtype(*symmetric_rtypes)
       
   347     vreg.register(EnsureSymmetricRelationsDelete)