merge changes from 3.21.6
authorJulien Cristau <julien.cristau@logilab.fr>
Tue, 16 Feb 2016 19:18:37 +0100
changeset 11127 6464edfa95bb
parent 11113 7f83e6a5acc3 (current diff)
parent 11124 e5207f392e7c (diff)
child 11129 97095348b3ee
child 11132 f1dade56087d
merge changes from 3.21.6
.hgtags
__pkginfo__.py
cubicweb.spec
debian/changelog
hooks/syncsession.py
hooks/test/unittest_syncsession.py
server/sources/native.py
web/formfields.py
web/test/data/schema.py
web/test/unittest_application.py
web/views/debug.py
web/views/editcontroller.py
--- a/.hgtags	Fri Feb 12 16:13:43 2016 +0100
+++ b/.hgtags	Tue Feb 16 19:18:37 2016 +0100
@@ -472,6 +472,9 @@
 f933a38d7ab5fc6f2ad593fe1cf9985ce9d7e873 3.19.13
 f933a38d7ab5fc6f2ad593fe1cf9985ce9d7e873 debian/3.19.13-1
 f933a38d7ab5fc6f2ad593fe1cf9985ce9d7e873 centos/3.19.13-1
+72a0f70879ac40ea57575be90bc6427f61ce3bd6 3.19.14
+72a0f70879ac40ea57575be90bc6427f61ce3bd6 debian/3.19.14-1
+72a0f70879ac40ea57575be90bc6427f61ce3bd6 centos/3.19.14-1
 7e6b7739afe6128589ad51b0318decb767cbae36 3.20.0
 7e6b7739afe6128589ad51b0318decb767cbae36 debian/3.20.0-1
 7e6b7739afe6128589ad51b0318decb767cbae36 centos/3.20.0-1
@@ -511,6 +514,9 @@
 03e8fc9f79a6e489a1b5c695eb0cd3fbb1afe9d4 3.20.12
 03e8fc9f79a6e489a1b5c695eb0cd3fbb1afe9d4 debian/3.20.12-1
 03e8fc9f79a6e489a1b5c695eb0cd3fbb1afe9d4 centos/3.20.12-1
+8c5dabbcd4d9505c3a617f9dbe2b10172bdc2b3a 3.20.13
+8c5dabbcd4d9505c3a617f9dbe2b10172bdc2b3a debian/3.20.13-1
+8c5dabbcd4d9505c3a617f9dbe2b10172bdc2b3a centos/3.20.13-1
 887c6eef807781560adcd4ecd2dea9011f5a6681 3.21.0
 887c6eef807781560adcd4ecd2dea9011f5a6681 debian/3.21.0-1
 887c6eef807781560adcd4ecd2dea9011f5a6681 centos/3.21.0-1
@@ -530,6 +536,9 @@
 e0572a786e6b4b0965d405dd95cf5bce754005a2 debian/3.21.5-1
 e0572a786e6b4b0965d405dd95cf5bce754005a2 centos/3.21.5-1
 228b6d2777e44d7bc158d0b4579d09960acea926 debian/3.21.5-2
+b3cbbb7690b6e193570ffe4846615d372868a923 3.21.6
+b3cbbb7690b6e193570ffe4846615d372868a923 debian/3.21.6-1
+b3cbbb7690b6e193570ffe4846615d372868a923 centos/3.21.6-1
 de472896fc0a18d6b831e6fed0eeda5921ec522c 3.22.0
 de472896fc0a18d6b831e6fed0eeda5921ec522c debian/3.22.0-1
 de472896fc0a18d6b831e6fed0eeda5921ec522c centos/3.22.0-1
--- a/debian/changelog	Fri Feb 12 16:13:43 2016 +0100
+++ b/debian/changelog	Tue Feb 16 19:18:37 2016 +0100
@@ -10,6 +10,12 @@
 
  -- Julien Cristau <julien.cristau@logilab.fr>  Mon, 04 Jan 2016 17:53:55 +0100
 
+cubicweb (3.21.6-1) unstable; urgency=medium
+
+  * new upstream release
+
+ -- Julien Cristau <julien.cristau@logilab.fr>  Tue, 16 Feb 2016 18:53:15 +0100
+
 cubicweb (3.21.5-2) unstable; urgency=medium
 
   * Fix conflict between cubicweb-server and cubicweb-dev.
@@ -52,6 +58,12 @@
 
  -- Julien Cristau <julien.cristau@logilab.fr>  Fri, 10 Jul 2015 17:04:11 +0200
 
+cubicweb (3.20.13-1) unstable; urgency=medium
+
+  * new upstream release
+
+ -- Julien Cristau <julien.cristau@logilab.fr>  Tue, 16 Feb 2016 17:53:29 +0100
+
 cubicweb (3.20.12-1) unstable; urgency=medium
 
   * new upstream release.
@@ -136,6 +148,12 @@
 
  -- Julien Cristau <julien.cristau@logilab.fr>  Tue, 06 Jan 2015 18:11:03 +0100
 
+cubicweb (3.19.14-1) unstable; urgency=medium
+
+  * new upstream release
+
+ -- Julien Cristau <julien.cristau@logilab.fr>  Tue, 16 Feb 2016 11:01:47 +0100
+
 cubicweb (3.19.13-1) unstable; urgency=medium
 
   * New upstream release.
--- a/doc/book/devrepo/migration.rst	Fri Feb 12 16:13:43 2016 +0100
+++ b/doc/book/devrepo/migration.rst	Tue Feb 16 19:18:37 2016 +0100
@@ -108,6 +108,7 @@
 If some of the added cubes are already used by an instance, they'll simply be
 silently skipped.
 
+To remove a cube use `drop_cube(cube, removedeps=False)`. 
 
 Schema migration
 ----------------
--- a/hooks/syncsession.py	Fri Feb 12 16:13:43 2016 +0100
+++ b/hooks/syncsession.py	Tue Feb 16 19:18:37 2016 +0100
@@ -148,7 +148,8 @@
         """the observed connections set has been commited"""
         cwprop = self.cwprop
         if not cwprop.for_user:
-            self.cnx.vreg['propertyvalues'][cwprop.pkey] = cwprop.value
+            self.cnx.vreg['propertyvalues'][cwprop.pkey] = \
+                self.cnx.vreg.typed_value(cwprop.pkey, cwprop.value)
         # if for_user is set, update is handled by a ChangeCWPropertyOp operation
 
 
--- a/hooks/test/unittest_syncsession.py	Fri Feb 12 16:13:43 2016 +0100
+++ b/hooks/test/unittest_syncsession.py	Tue Feb 16 19:18:37 2016 +0100
@@ -71,6 +71,15 @@
                 req.execute('INSERT CWProperty X: X pkey "ui.language", X value "hop"')
             self.assertEqual(cm.exception.errors, {'value-subject': u'unauthorized value'})
 
+    def test_vreg_propertyvalues_update(self):
+        self.vreg.register_property(
+            'test.int', type='Int', help='', sitewide=True)
+        with self.admin_access.repo_cnx() as cnx:
+            cnx.execute('INSERT CWProperty X: X pkey "test.int", X value "42"')
+            cnx.commit()
+        self.assertEqual(self.vreg.property_value('test.int'), 42)
+
+
 if __name__ == '__main__':
     from logilab.common.testlib import unittest_main
     unittest_main()
--- a/server/sources/native.py	Fri Feb 12 16:13:43 2016 +0100
+++ b/server/sources/native.py	Tue Feb 16 19:18:37 2016 +0100
@@ -43,7 +43,7 @@
 
 from logilab.common.decorators import cached, clear_cache
 from logilab.common.configuration import Method
-from logilab.common.shellutils import getlogin
+from logilab.common.shellutils import getlogin, ASK
 from logilab.database import get_db_helper, sqlgen
 
 from yams.schema import role_name
@@ -1737,15 +1737,20 @@
         tables = archive.read('tables.txt').splitlines()
         sequences = archive.read('sequences.txt').splitlines()
         numranges = archive.read('numranges.txt').splitlines()
-        file_versions = self._parse_versions(archive.read('versions.txt'))
-        versions = set(self._get_versions())
-        if file_versions != versions:
-            self.logger.critical('Unable to restore : versions do not match')
-            self.logger.critical('Expected:\n%s', '\n'.join('%s : %s' % (cube, ver)
-                                                            for cube, ver in sorted(versions)))
-            self.logger.critical('Found:\n%s', '\n'.join('%s : %s' % (cube, ver)
-                                                         for cube, ver in sorted(file_versions)))
-            raise ValueError('Unable to restore : versions do not match')
+        archive_versions = self._parse_versions(archive.read('versions.txt'))
+        db_versions = set(self._get_versions())
+        if archive_versions != db_versions:
+            self.logger.critical('Restore warning: versions do not match')
+            new_cubes = db_versions - archive_versions
+            if new_cubes:
+                self.logger.critical('In the db:\n%s', '\n'.join('%s: %s' % (cube, ver)
+                                                            for cube, ver in sorted(new_cubes)))
+            old_cubes = archive_versions - db_versions
+            if old_cubes:
+                self.logger.critical('In the archive:\n%s', '\n'.join('%s: %s' % (cube, ver)
+                                                            for cube, ver in sorted(old_cubes)))
+            if not ASK.confirm('Versions mismatch: continue anyway ?', False):
+                raise ValueError('Unable to restore: versions do not match')
         table_chunks = {}
         for name in archive.namelist():
             if not name.startswith('tables/'):
--- a/web/formfields.py	Fri Feb 12 16:13:43 2016 +0100
+++ b/web/formfields.py	Tue Feb 16 19:18:37 2016 +0100
@@ -78,7 +78,7 @@
 from yams.constraints import (SizeConstraint, StaticVocabularyConstraint,
                               FormatConstraint)
 
-from cubicweb import Binary, tags, uilib
+from cubicweb import Binary, tags, uilib, neg_role
 from cubicweb.utils import support_args
 from cubicweb.web import INTERNAL_FIELD_VALUE, ProcessFormError, eid_param, \
      formwidgets as fw
@@ -1205,10 +1205,13 @@
     else:
         targetschema = rdef.subject
     card = rdef.role_cardinality(role)
+    composite = getattr(rdef, 'composite', None)
     kwargs['name'] = rschema.type
     kwargs['role'] = role
     kwargs['eidparam'] = True
-    kwargs.setdefault('required', card in '1+')
+    # don't mark composite relation as required, we want the composite element
+    # to be removed when not linked to its parent
+    kwargs.setdefault('required', card in '1+' and composite != neg_role(role))
     if role == 'object':
         kwargs.setdefault('label', (eschema.type, rschema.type + '_object'))
     else:
--- a/web/test/data/schema.py	Fri Feb 12 16:13:43 2016 +0100
+++ b/web/test/data/schema.py	Tue Feb 16 19:18:37 2016 +0100
@@ -95,6 +95,33 @@
 class Ticket(EntityType):
     title = String(maxsize=32, required=True, fulltextindexed=True)
     concerns = SubjectRelation('Project', composite='object')
+    in_version = SubjectRelation('Version', composite='object',
+                                 cardinality='?*', inlined=True)
+
+class Version(EntityType):
+    name = String(required=True)
+
+class Filesystem(EntityType):
+    name = String()
+
+class DirectoryPermission(EntityType):
+    value = String()
+
+class parent_fs(RelationDefinition):
+    name = 'parent'
+    subject = 'Directory'
+    object = 'Filesystem'
+
+class Directory(EntityType):
+    name = String(required=True)
+    has_permission = SubjectRelation('DirectoryPermission', cardinality='*1',
+                                     composite='subject')
+
+class parent_directory(RelationDefinition):
+    name = 'parent'
+    subject = 'Directory'
+    object = 'Directory'
+    composite = 'object'
 
 class Folder(EntityType):
     name = String(required=True)
--- a/web/test/unittest_application.py	Fri Feb 12 16:13:43 2016 +0100
+++ b/web/test/unittest_application.py	Tue Feb 16 19:18:37 2016 +0100
@@ -26,7 +26,6 @@
 from logilab.common.testlib import TestCase, unittest_main
 from logilab.common.decorators import clear_cache, classproperty
 
-from cubicweb import AuthenticationError
 from cubicweb import view
 from cubicweb.devtools.testlib import CubicWebTC, real_error_handling
 from cubicweb.devtools.fake import FakeRequest
@@ -260,6 +259,259 @@
                               {'login-subject': u'the value "admin" is already used, use another one'})
             self.assertEqual(forminfo['values'], req.form)
 
+    def _edit_parent(self, dir_eid, parent_eid, role='subject',
+                     etype='Directory', **kwargs):
+        parent_eid = parent_eid or '__cubicweb_internal_field__'
+        with self.admin_access.web_request() as req:
+            req.form = {
+                'eid': unicode(dir_eid),
+                '__maineid': unicode(dir_eid),
+                '__type:%s' % dir_eid: etype,
+                'parent-%s:%s' % (role, dir_eid): parent_eid,
+            }
+            req.form.update(kwargs)
+            req.form['_cw_entity_fields:%s' % dir_eid] = ','.join(
+                ['parent-%s' % role] +
+                [key.split(':')[0]
+                 for key in kwargs.keys()
+                 if not key.startswith('_')])
+            self.expect_redirect_handle_request(req)
+
+    def _edit_in_version(self, ticket_eid, version_eid, **kwargs):
+        version_eid = version_eid or '__cubicweb_internal_field__'
+        with self.admin_access.web_request() as req:
+            req.form = {
+                'eid': unicode(ticket_eid),
+                '__maineid': unicode(ticket_eid),
+                '__type:%s' % ticket_eid: 'Ticket',
+                'in_version-subject:%s' % ticket_eid: version_eid,
+            }
+            req.form.update(kwargs)
+            req.form['_cw_entity_fields:%s' % ticket_eid] = ','.join(
+                ['in_version-subject'] +
+                [key.split(':')[0]
+                 for key in kwargs.keys()
+                 if not key.startswith('_')])
+            self.expect_redirect_handle_request(req)
+
+    def test_create_and_link_directories(self):
+        with self.admin_access.web_request() as req:
+            req.form = {
+                'eid': (u'A', u'B'),
+                '__maineid': u'A',
+                '__type:A': 'Directory',
+                '__type:B': 'Directory',
+                'parent-subject:B': u'A',
+                'name-subject:A': u'topd',
+                'name-subject:B': u'subd',
+                '_cw_entity_fields:A': 'name-subject',
+                '_cw_entity_fields:B': 'parent-subject,name-subject',
+            }
+            self.expect_redirect_handle_request(req)
+
+        with self.admin_access.repo_cnx() as cnx:
+            self.assertTrue(cnx.find('Directory', name=u'topd'))
+            self.assertTrue(cnx.find('Directory', name=u'subd'))
+            self.assertEqual(1, cnx.execute(
+                'Directory SUBD WHERE SUBD parent TOPD,'
+                ' SUBD name "subd", TOPD name "topd"').rowcount)
+
+    def test_create_subentity(self):
+        with self.admin_access.repo_cnx() as cnx:
+            topd = cnx.create_entity('Directory', name=u'topd')
+            cnx.commit()
+
+        with self.admin_access.web_request() as req:
+            req.form = {
+                'eid': (unicode(topd.eid), u'B'),
+                '__maineid': unicode(topd.eid),
+                '__type:%s' % topd.eid: 'Directory',
+                '__type:B': 'Directory',
+                'parent-object:%s' % topd.eid: u'B',
+                'name-subject:B': u'subd',
+                '_cw_entity_fields:%s' % topd.eid: 'parent-object',
+                '_cw_entity_fields:B': 'name-subject',
+            }
+            self.expect_redirect_handle_request(req)
+
+        with self.admin_access.repo_cnx() as cnx:
+            self.assertTrue(cnx.find('Directory', name=u'topd'))
+            self.assertTrue(cnx.find('Directory', name=u'subd'))
+            self.assertEqual(1, cnx.execute(
+                'Directory SUBD WHERE SUBD parent TOPD,'
+                ' SUBD name "subd", TOPD name "topd"').rowcount)
+
+    def test_subject_subentity_removal(self):
+        """Editcontroller: detaching a composite relation removes the subentity
+        (edit from the subject side)
+        """
+        with self.admin_access.repo_cnx() as cnx:
+            topd = cnx.create_entity('Directory', name=u'topd')
+            sub1 = cnx.create_entity('Directory', name=u'sub1', parent=topd)
+            sub2 = cnx.create_entity('Directory', name=u'sub2', parent=topd)
+            cnx.commit()
+
+        attrs = {'name-subject:%s' % sub1.eid: ''}
+        self._edit_parent(sub1.eid, parent_eid=None, **attrs)
+
+        with self.admin_access.repo_cnx() as cnx:
+            self.assertTrue(cnx.find('Directory', eid=topd.eid))
+            self.assertFalse(cnx.find('Directory', eid=sub1.eid))
+            self.assertTrue(cnx.find('Directory', eid=sub2.eid))
+
+    def test_object_subentity_removal(self):
+        """Editcontroller: detaching a composite relation removes the subentity
+        (edit from the object side)
+        """
+        with self.admin_access.repo_cnx() as cnx:
+            topd = cnx.create_entity('Directory', name=u'topd')
+            sub1 = cnx.create_entity('Directory', name=u'sub1', parent=topd)
+            sub2 = cnx.create_entity('Directory', name=u'sub2', parent=topd)
+            cnx.commit()
+
+        self._edit_parent(topd.eid, parent_eid=sub1.eid, role='object')
+
+        with self.admin_access.repo_cnx() as cnx:
+            self.assertTrue(cnx.find('Directory', eid=topd.eid))
+            self.assertTrue(cnx.find('Directory', eid=sub1.eid))
+            self.assertFalse(cnx.find('Directory', eid=sub2.eid))
+
+    def test_reparent_subentity(self):
+        "Editcontroller: re-parenting a subentity does not remove it"
+        with self.admin_access.repo_cnx() as cnx:
+            top1 = cnx.create_entity('Directory', name=u'top1')
+            top2 = cnx.create_entity('Directory', name=u'top2')
+            subd = cnx.create_entity('Directory', name=u'subd', parent=top1)
+            cnx.commit()
+
+        self._edit_parent(subd.eid, parent_eid=top2.eid)
+
+        with self.admin_access.repo_cnx() as cnx:
+            self.assertTrue(cnx.find('Directory', eid=top1.eid))
+            self.assertTrue(cnx.find('Directory', eid=top2.eid))
+            self.assertTrue(cnx.find('Directory', eid=subd.eid))
+            self.assertEqual(
+                cnx.find('Directory', eid=subd.eid).one().parent[0], top2)
+
+    def test_reparent_subentity_inlined(self):
+        """Editcontroller: re-parenting a subentity does not remove it
+        (inlined case)"""
+        with self.admin_access.repo_cnx() as cnx:
+            version1 = cnx.create_entity('Version', name=u'version1')
+            version2 = cnx.create_entity('Version', name=u'version2')
+            ticket = cnx.create_entity('Ticket', title=u'ticket',
+                                       in_version=version1)
+            cnx.commit()
+
+        self._edit_in_version(ticket.eid, version_eid=version2.eid)
+
+        with self.admin_access.repo_cnx() as cnx:
+            self.assertTrue(cnx.find('Version', eid=version1.eid))
+            self.assertTrue(cnx.find('Version', eid=version2.eid))
+            self.assertTrue(cnx.find('Ticket', eid=ticket.eid))
+            self.assertEqual(
+                cnx.find('Ticket', eid=ticket.eid).one().in_version[0], version2)
+
+    def test_subject_mixed_composite_subentity_removal_1(self):
+        """Editcontroller: detaching several subentities respects each rdef's
+        compositeness - Remove non composite
+        """
+        with self.admin_access.repo_cnx() as cnx:
+            topd = cnx.create_entity('Directory', name=u'topd')
+            fs = cnx.create_entity('Filesystem', name=u'/tmp')
+            subd = cnx.create_entity('Directory', name=u'subd',
+                                     parent=(topd, fs))
+            cnx.commit()
+
+        self._edit_parent(subd.eid, parent_eid=topd.eid)
+
+        with self.admin_access.repo_cnx() as cnx:
+            self.assertTrue(cnx.find('Directory', eid=topd.eid))
+            self.assertTrue(cnx.find('Directory', eid=subd.eid))
+            self.assertTrue(cnx.find('Filesystem', eid=fs.eid))
+            self.assertEqual(cnx.find('Directory', eid=subd.eid).one().parent,
+                             [topd,])
+
+    def test_subject_mixed_composite_subentity_removal_2(self):
+        """Editcontroller: detaching several subentities respects each rdef's
+        compositeness - Remove composite
+        """
+        with self.admin_access.repo_cnx() as cnx:
+            topd = cnx.create_entity('Directory', name=u'topd')
+            fs = cnx.create_entity('Filesystem', name=u'/tmp')
+            subd = cnx.create_entity('Directory', name=u'subd',
+                                     parent=(topd, fs))
+            cnx.commit()
+
+        self._edit_parent(subd.eid, parent_eid=fs.eid)
+
+        with self.admin_access.repo_cnx() as cnx:
+            self.assertTrue(cnx.find('Directory', eid=topd.eid))
+            self.assertFalse(cnx.find('Directory', eid=subd.eid))
+            self.assertTrue(cnx.find('Filesystem', eid=fs.eid))
+
+    def test_object_mixed_composite_subentity_removal_1(self):
+        """Editcontroller: detaching several subentities respects each rdef's
+        compositeness - Remove non composite
+        """
+        with self.admin_access.repo_cnx() as cnx:
+            topd = cnx.create_entity('Directory', name=u'topd')
+            fs = cnx.create_entity('Filesystem', name=u'/tmp')
+            subd = cnx.create_entity('Directory', name=u'subd',
+                                     parent=(topd, fs))
+            cnx.commit()
+
+        self._edit_parent(fs.eid, parent_eid=None, role='object',
+                          etype='Filesystem')
+
+        with self.admin_access.repo_cnx() as cnx:
+            self.assertTrue(cnx.find('Directory', eid=topd.eid))
+            self.assertTrue(cnx.find('Directory', eid=subd.eid))
+            self.assertTrue(cnx.find('Filesystem', eid=fs.eid))
+            self.assertEqual(cnx.find('Directory', eid=subd.eid).one().parent,
+                             [topd,])
+
+    def test_object_mixed_composite_subentity_removal_2(self):
+        """Editcontroller: detaching several subentities respects each rdef's
+        compositeness - Remove composite
+        """
+        with self.admin_access.repo_cnx() as cnx:
+            topd = cnx.create_entity('Directory', name=u'topd')
+            fs = cnx.create_entity('Filesystem', name=u'/tmp')
+            subd = cnx.create_entity('Directory', name=u'subd',
+                                     parent=(topd, fs))
+            cnx.commit()
+
+        self._edit_parent(topd.eid, parent_eid=None, role='object')
+
+        with self.admin_access.repo_cnx() as cnx:
+            self.assertTrue(cnx.find('Directory', eid=topd.eid))
+            self.assertFalse(cnx.find('Directory', eid=subd.eid))
+            self.assertTrue(cnx.find('Filesystem', eid=fs.eid))
+
+    def test_delete_mandatory_composite(self):
+        with self.admin_access.repo_cnx() as cnx:
+            perm = cnx.create_entity('DirectoryPermission')
+            mydir = cnx.create_entity('Directory', name=u'dir',
+                                      has_permission=perm)
+            cnx.commit()
+
+        with self.admin_access.web_request() as req:
+            dir_eid = unicode(mydir.eid)
+            perm_eid = unicode(perm.eid)
+            req.form = {
+                'eid': [dir_eid, perm_eid],
+                '__maineid' : dir_eid,
+                '__type:%s' % dir_eid: 'Directory',
+                '__type:%s' % perm_eid: 'DirectoryPermission',
+                '_cw_entity_fields:%s' % dir_eid: '',
+                '_cw_entity_fields:%s' % perm_eid: 'has_permission-object',
+                'has_permission-object:%s' % perm_eid: '',
+                }
+            path, _params = self.expect_redirect_handle_request(req, 'edit')
+            self.assertTrue(req.find('Directory', eid=mydir.eid))
+            self.assertFalse(req.find('DirectoryPermission', eid=perm.eid))
+
     def test_ajax_view_raise_arbitrary_error(self):
         class ErrorAjaxView(view.View):
             __regid__ = 'test.ajax.error'
--- a/web/views/editcontroller.py	Fri Feb 12 16:13:43 2016 +0100
+++ b/web/views/editcontroller.py	Tue Feb 16 19:18:37 2016 +0100
@@ -31,7 +31,7 @@
 
 from rql.utils import rqlvar_maker
 
-from cubicweb import _, Binary, ValidationError
+from cubicweb import _, Binary, ValidationError, UnknownEid
 from cubicweb.view import EntityAdapter
 from cubicweb.predicates import is_instance
 from cubicweb.web import (INTERNAL_FIELD_VALUE, RequestError, NothingToEdit,
@@ -79,12 +79,14 @@
         self.edited = []
         self.restrictions = []
         self.kwargs = {}
+        self.canceled = False
 
     def __repr__(self):
         return ('Query <edited=%r restrictions=%r kwargs=%r>' % (
             self.edited, self.restrictions, self.kwargs))
 
     def insert_query(self, etype):
+        assert not self.canceled
         if self.edited:
             rql = 'INSERT %s X: %s' % (etype, ','.join(self.edited))
         else:
@@ -94,6 +96,7 @@
         return rql
 
     def update_query(self, eid):
+        assert not self.canceled
         varmaker = rqlvar_maker()
         var = next(varmaker)
         while var in self.kwargs:
@@ -192,6 +195,7 @@
         # deserves special treatment
         req.data['pending_inlined'] = defaultdict(set)
         req.data['pending_others'] = set()
+        req.data['pending_composite_delete'] = set()
         try:
             for formparams in self._ordered_formparams():
                 eid = self.edit_entity(formparams)
@@ -210,6 +214,9 @@
         # then execute rql to set all relations
         for querydef in self.relations_rql:
             self._cw.execute(*querydef)
+        # delete pending composite
+        for entity in req.data['pending_composite_delete']:
+            entity.cw_delete()
         # XXX this processes *all* pending operations of *all* entities
         if '__delete' in req.form:
             todelete = req.list_form_param('__delete', req.form, pop=True)
@@ -266,14 +273,17 @@
         # 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(), text_type(ex)) for f, ex in self.errors)
-            raise ValidationError(valerror_eid(entity.eid), errors)
-        if eid is None: # creation or copy
-            entity.eid = eid = self._insert_entity(etype, formparams['eid'], rqlquery)
-        elif rqlquery.edited: # edition of an existant entity
-            self.check_concurrent_edition(formparams, eid)
-            self._update_entity(eid, rqlquery)
+        if not rqlquery.canceled:
+            if self.errors:
+                errors = dict((f.role_name(), text_type(ex)) for f, ex in self.errors)
+                raise ValidationError(valerror_eid(entity.eid), errors)
+            if eid is None: # creation or copy
+                entity.eid = eid = self._insert_entity(etype, formparams['eid'], rqlquery)
+            elif rqlquery.edited: # edition of an existant entity
+                self.check_concurrent_edition(formparams, eid)
+                self._update_entity(eid, rqlquery)
+        else:
+            self.errors = []
         if is_main_entity:
             self.notify_edited(entity)
         if '__delete' in formparams:
@@ -287,7 +297,8 @@
         return eid
 
     def handle_formfield(self, form, field, rqlquery=None):
-        eschema = form.edited_entity.e_schema
+        entity = form.edited_entity
+        eschema = entity.e_schema
         try:
             for field, value in field.process_posted(form):
                 if not (
@@ -295,25 +306,74 @@
                     or
                     (field.role == 'object' and field.name in eschema.objrels)):
                     continue
+
                 rschema = self._cw.vreg.schema.rschema(field.name)
                 if rschema.final:
                     rqlquery.set_attribute(field.name, value)
+                    continue
+
+                if entity.has_eid():
+                    origvalues = set(data[0] for data in entity.related(field.name, field.role).rows)
                 else:
-                    if form.edited_entity.has_eid():
-                        origvalues = set(entity.eid for entity in form.edited_entity.related(field.name, field.role, entities=True))
-                    else:
-                        origvalues = set()
-                    if value is None or value == origvalues:
-                        continue # not edited / not modified / to do later
-                    if rschema.inlined and rqlquery is not None and field.role == 'subject':
-                        self.handle_inlined_relation(form, field, value, origvalues, rqlquery)
-                    elif form.edited_entity.has_eid():
-                        self.handle_relation(form, field, value, origvalues)
-                    else:
-                        form._cw.data['pending_others'].add( (form, field) )
+                    origvalues = set()
+                if value is None or value == origvalues:
+                    continue # not edited / not modified / to do later
+
+                unlinked_eids = origvalues - value
+
+                if unlinked_eids:
+                    # Special handling of composite relation removal
+                    self.handle_composite_removal(
+                        form, field, unlinked_eids, value, rqlquery)
+
+                if rschema.inlined and rqlquery is not None and field.role == 'subject':
+                    self.handle_inlined_relation(form, field, value, origvalues, rqlquery)
+                elif form.edited_entity.has_eid():
+                    self.handle_relation(form, field, value, origvalues)
+                else:
+                    form._cw.data['pending_others'].add( (form, field) )
+
         except ProcessFormError as exc:
             self.errors.append((field, exc))
 
+    def handle_composite_removal(self, form, field,
+                                 removed_values, new_values, rqlquery):
+        """
+        In EditController-handled forms, when the user removes a composite
+        relation, it triggers the removal of the related entity in the
+        composite. This is where this happens.
+
+        See for instance test_subject_subentity_removal in
+        web/test/unittest_application.py.
+        """
+        rschema = self._cw.vreg.schema.rschema(field.name)
+        new_value_etypes = set()
+        # the user could have included nonexisting eids in the POST; don't crash.
+        for eid in new_values:
+            try:
+                new_value_etypes.add(self._cw.entity_from_eid(eid).cw_etype)
+            except UnknownEid:
+                continue
+        for unlinked_eid in removed_values:
+            unlinked_entity = self._cw.entity_from_eid(unlinked_eid)
+            rdef = rschema.role_rdef(form.edited_entity.cw_etype,
+                                     unlinked_entity.cw_etype,
+                                     field.role)
+            if rdef.composite is not None:
+                if rdef.composite == field.role:
+                    to_be_removed = unlinked_entity
+                else:
+                    if unlinked_entity.cw_etype in new_value_etypes:
+                        # This is a same-rdef re-parenting: do not remove the entity
+                        continue
+                    to_be_removed = form.edited_entity
+                    self.info('Edition of %s is cancelled (deletion requested)',
+                              to_be_removed)
+                    rqlquery.canceled = True
+                self.info('Scheduling removal of %s as composite relation '
+                          '%s was removed', to_be_removed, rdef)
+                form._cw.data['pending_composite_delete'].add(to_be_removed)
+
     def handle_inlined_relation(self, form, field, values, origvalues, rqlquery):
         """handle edition for the (rschema, x) relation of the given entity
         """