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) |
|