# HG changeset patch # User Sylvain Thénault # Date 1374648196 -7200 # Node ID 570208f74a840d4b4df382dca660268b9ba62046 # Parent b5762ac9a82ee5deaa84f474d5db8e190c66918d [editcontrollers] Ensure entities are created in an order satisfying schema constraints. Closes #3031719 changes below are also necessary to make the whole thing works: * stop considering InlinedFormFile as eidparam field since they don't hold any value * rework 'pendingfields' handling to have separate processing of inlined fields whose subject entity is created during the edition diff -r b5762ac9a82e -r 570208f74a84 web/formfields.py --- a/web/formfields.py Mon Jul 22 12:07:46 2013 +0200 +++ b/web/formfields.py Wed Jul 24 08:43:16 2013 +0200 @@ -1,4 +1,4 @@ -# copyright 2003-2012 LOGILAB S.A. (Paris, FRANCE), all rights reserved. +# copyright 2003-2013 LOGILAB S.A. (Paris, FRANCE), all rights reserved. # contact http://www.logilab.fr/ -- mailto:contact@logilab.fr # # This file is part of CubicWeb. @@ -1148,12 +1148,19 @@ elif not isinstance(values, list): values = (values,) eids = set() + rschema = form._cw.vreg.schema.rschema(self.name) for eid in values: if not eid or eid == INTERNAL_FIELD_VALUE: continue typed_eid = form.actual_eid(eid) + # if entity doesn't exist yet if typed_eid is None: - form._cw.data['pendingfields'].add( (form, self) ) + # inlined relations of to-be-created **subject entities** have + # to be handled separatly + if self.role == 'object' and rschema.inlined: + form._cw.data['pending_inlined'][eid].add( (form, self) ) + else: + form._cw.data['pending_others'].add( (form, self) ) return None eids.add(typed_eid) return eids diff -r b5762ac9a82e -r 570208f74a84 web/test/data/schema.py --- a/web/test/data/schema.py Mon Jul 22 12:07:46 2013 +0200 +++ b/web/test/data/schema.py Wed Jul 24 08:43:16 2013 +0200 @@ -24,7 +24,8 @@ from yams.constraints import IntervalBoundConstraint class Salesterm(EntityType): - described_by_test = SubjectRelation('File', cardinality='1*', composite='subject') + described_by_test = SubjectRelation('File', cardinality='1*', + composite='subject', inlined=True) amount = Int(constraints=[IntervalBoundConstraint(0, 100)]) reason = String(maxsize=20, vocabulary=[u'canceled', u'sold']) diff -r b5762ac9a82e -r 570208f74a84 web/test/unittest_views_basecontrollers.py --- a/web/test/unittest_views_basecontrollers.py Mon Jul 22 12:07:46 2013 +0200 +++ b/web/test/unittest_views_basecontrollers.py Wed Jul 24 08:43:16 2013 +0200 @@ -1,4 +1,4 @@ -# copyright 2003-2012 LOGILAB S.A. (Paris, FRANCE), all rights reserved. +# copyright 2003-2013 LOGILAB S.A. (Paris, FRANCE), all rights reserved. # contact http://www.logilab.fr/ -- mailto:contact@logilab.fr # # This file is part of CubicWeb. @@ -171,6 +171,30 @@ email = e.use_email[0] self.assertEqual(email.address, 'dima@logilab.fr') + def test_create_mandatory_inlined(self): + req = self.request() + req.form = {'eid': ['X', 'Y'], '__maineid' : 'X', + + '__type:X': 'Salesterm', + '_cw_entity_fields:X': '', + + '__type:Y': 'File', + '_cw_entity_fields:Y': 'data-subject,described_by_test-object', + 'data-subject:Y': (u'coucou.txt', Binary('coucou')), + 'described_by_test-object:Y': 'X', + } + path, params = self.expect_redirect_handle_request(req, 'edit') + self.assertTrue(path.startswith('salesterm/'), path) + eid = path.split('/')[1] + salesterm = req.entity_from_eid(eid) + # The NOT NULL constraint of mandatory relation implies that the File + # must be created before the Salesterm, otherwise Salesterm insertion + # will fail. + # NOTE: sqlite does have NOT NULL constraint, unlike Postgres so the + # insertion does not fail and we have to check dumbly that File is + # created before. + self.assertGreater(salesterm.eid, salesterm.described_by_test[0].eid) + def test_edit_multiple_linked(self): req = self.request() peid = u(self.create_user(req, 'adim').eid) diff -r b5762ac9a82e -r 570208f74a84 web/views/autoform.py --- a/web/views/autoform.py Mon Jul 22 12:07:46 2013 +0200 +++ b/web/views/autoform.py Wed Jul 24 08:43:16 2013 +0200 @@ -1,4 +1,4 @@ -# copyright 2003-2012 LOGILAB S.A. (Paris, FRANCE), all rights reserved. +# copyright 2003-2013 LOGILAB S.A. (Paris, FRANCE), all rights reserved. # contact http://www.logilab.fr/ -- mailto:contact@logilab.fr # # This file is part of CubicWeb. @@ -145,8 +145,11 @@ class InlinedFormField(ff.Field): def __init__(self, view=None, **kwargs): kwargs.setdefault('label', None) + # don't add eidparam=True since this field doesn't actually hold the + # relation value (the subform does) hence should not be listed in + # _cw_entity_fields super(InlinedFormField, self).__init__(name=view.rtype, role=view.role, - eidparam=True, **kwargs) + **kwargs) self.view = view def render(self, form, renderer): diff -r b5762ac9a82e -r 570208f74a84 web/views/editcontroller.py --- a/web/views/editcontroller.py Mon Jul 22 12:07:46 2013 +0200 +++ b/web/views/editcontroller.py Wed Jul 24 08:43:16 2013 +0200 @@ -20,8 +20,10 @@ __docformat__ = "restructuredtext en" from warnings import warn +from collections import defaultdict from logilab.common.deprecation import deprecated +from logilab.common.graph import ordered_nodes from rql.utils import rqlvar_maker @@ -129,6 +131,46 @@ self._default_publish() self.reset() + def _ordered_formparams(self): + """ Return form parameters dictionaries for each edited entity. + + We ensure that entities can be created in this order accounting for + mandatory inlined relations. + """ + req = self._cw + graph = {} + get_rschema = self._cw.vreg.schema.rschema + # minparams = 2, because at least __type and eid are needed + values_by_eid = dict((eid, req.extract_entity_params(eid, minparams=2)) + for eid in req.edited_eids()) + # iterate over all the edited entities + for eid, values in values_by_eid.iteritems(): + # add eid to the dependency graph + graph.setdefault(eid, set()) + # search entity's edited fields for mandatory inlined relation + for param in values['_cw_entity_fields'].split(','): + try: + rtype, role = param.split('-') + except ValueError: + # e.g. param='__type' + continue + rschema = get_rschema(rtype) + if rschema.inlined: + for target in rschema.targets(values['__type'], role): + rdef = rschema.role_rdef(values['__type'], target, role) + # if cardinality is 1 and if the target entity is being + # simultaneously edited, the current entity must be + # created before the target one + if rdef.cardinality[0] == '1': + target_eid = values[param] + if target_eid in values_by_eid: + # add dependency from the target entity to the + # current one + graph.setdefault(target_eid, set()).add(eid) + break + for eid in reversed(ordered_nodes(graph)): + yield values_by_eid[eid] + def _default_publish(self): req = self._cw self.errors = [] @@ -139,22 +181,27 @@ req.set_shared_data('__maineid', form['__maineid'], txdata=True) # no specific action, generic edition self._to_create = req.data['eidmap'] = {} - self._pending_fields = req.data['pendingfields'] = set() + # those two data variables are used to handle relation from/to entities + # which doesn't exist at time where the entity is edited and that + # deserves special treatment + req.data['pending_inlined'] = defaultdict(set) + req.data['pending_others'] = set() try: - for eid in req.edited_eids(): - # __type and eid - formparams = req.extract_entity_params(eid, minparams=2) + for formparams in self._ordered_formparams(): eid = self.edit_entity(formparams) except (RequestError, NothingToEdit) as ex: if '__linkto' in req.form and 'eid' in req.form: self.execute_linkto() elif not ('__delete' in req.form or '__insert' in req.form): raise ValidationError(None, {None: unicode(ex)}) - # handle relations in newly created entities - if self._pending_fields: - for form, field in self._pending_fields: - self.handle_formfield(form, field) - # execute rql to set all relations + # all pending inlined relations to newly created entities have been + # treated now (pop to ensure there are no attempt to add new ones) + pending_inlined = req.data.pop('pending_inlined') + assert not pending_inlined, pending_inlined + # handle all other remaining relations now + for form_, field in req.data.pop('pending_others'): + self.handle_formfield(form_, field) + # then execute rql to set all relations for querydef in self.relations_rql: self._cw.execute(*querydef) # XXX this processes *all* pending operations of *all* entities @@ -217,6 +264,10 @@ form.formvalues = {} # init fields value cache for field in form.iter_modified_fields(editedfields, entity): self.handle_formfield(form, field, rqlquery) + # if there are some inlined field which were waiting for this entity's + # creation, add relevant data to the rqlquery + for form_, field in req.data['pending_inlined'].pop(entity.eid, ()): + rqlquery.set_inlined(field.name, form_.edited_entity.eid) if self.errors: errors = dict((f.role_name(), unicode(ex)) for f, ex in self.errors) raise ValidationError(valerror_eid(entity.eid), errors) @@ -260,8 +311,7 @@ elif form.edited_entity.has_eid(): self.handle_relation(form, field, value, origvalues) else: - self._pending_fields.add( (form, field) ) - + form._cw.data['pending_others'].add( (form, field) ) except ProcessFormError as exc: self.errors.append((field, exc))