[editcontrollers] Ensure entities are created in an order satisfying schema constraints. Closes #3031719 stable
authorSylvain Thénault <sylvain.thenault@logilab.fr>
Wed, 24 Jul 2013 08:43:16 +0200
branchstable
changeset 9179 570208f74a84
parent 9178 b5762ac9a82e
child 9180 13ed6de41774
[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
web/formfields.py
web/test/data/schema.py
web/test/unittest_views_basecontrollers.py
web/views/autoform.py
web/views/editcontroller.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
--- 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))