[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
--- 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
--- 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'])
--- 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)
--- 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):
--- 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))