[undo] basic support for undoing of entity creation / relation addition stable
authorSylvain Thénault <sylvain.thenault@logilab.fr>
Tue, 30 Mar 2010 11:18:31 +0200
branchstable
changeset 5076 b0e6134b4324
parent 5075 a4b735e76c66
child 5077 dc448c9ad9dd
[undo] basic support for undoing of entity creation / relation addition
server/sources/native.py
server/test/unittest_undo.py
--- a/server/sources/native.py	Tue Mar 30 11:17:50 2010 +0200
+++ b/server/sources/native.py	Tue Mar 30 11:18:31 2010 +0200
@@ -28,14 +28,15 @@
 from logilab.common.shellutils import getlogin
 from logilab.database import get_db_helper
 
-from cubicweb import UnknownEid, AuthenticationError, Binary, server, neg_role
-from cubicweb import transaction as tx
+from cubicweb import UnknownEid, AuthenticationError, ValidationError, Binary
+from cubicweb import transaction as tx, server, neg_role
 from cubicweb.schema import VIRTUAL_RTYPES
 from cubicweb.cwconfig import CubicWebNoAppConfiguration
 from cubicweb.server import hook
-from cubicweb.server.utils import crypt_password
+from cubicweb.server.utils import crypt_password, eschema_eid
 from cubicweb.server.sqlutils import SQL_PREFIX, SQLAdapterMixIn
 from cubicweb.server.rqlannotation import set_qdata
+from cubicweb.server.hook import CleanupDeletedEidsCacheOp
 from cubicweb.server.session import hooks_control, security_enabled
 from cubicweb.server.sources import AbstractSource, dbg_st_search, dbg_results
 from cubicweb.server.sources.rql2sql import SQLGenerator
@@ -128,6 +129,45 @@
                                'rtype': rdef.rtype,
                                'eid': tentity.eid})
 
+def _undo_rel_info(session, subj, rtype, obj):
+    entities = []
+    for role, eid in (('subject', subj), ('object', obj)):
+        try:
+            entities.append(session.entity_from_eid(eid))
+        except UnknownEid:
+            raise UndoException(session._(
+                "Can't restore relation %(rtype)s, %(role)s entity %(eid)s"
+                " doesn't exist anymore.")
+                                % {'role': session._(role),
+                                   'rtype': session._(rtype),
+                                   'eid': eid})
+    sentity, oentity = entities
+    try:
+        rschema = session.vreg.schema.rschema(rtype)
+        rdef = rschema.rdefs[(sentity.__regid__, oentity.__regid__)]
+    except KeyError:
+        raise UndoException(session._(
+            "Can't restore relation %(rtype)s between %(subj)s and "
+            "%(obj)s, that relation does not exists anymore in the "
+            "schema.")
+                            % {'rtype': session._(rtype),
+                               'subj': subj,
+                               'obj': obj})
+    return sentity, oentity, rdef
+
+def _undo_has_later_transaction(session, eid):
+    return session.system_sql('''\
+SELECT T.tx_uuid FROM transactions AS TREF, transactions AS T
+WHERE TREF.tx_uuid='%(txuuid)s' AND T.tx_uuid!='%(txuuid)s'
+AND T.tx_time>=TREF.tx_time
+AND (EXISTS(SELECT 1 FROM tx_entity_actions AS TEA
+            WHERE TEA.tx_uuid=T.tx_uuid AND TEA.eid=%(eid)s)
+     OR EXISTS(SELECT 1 FROM tx_relation_actions as TRA
+               WHERE TRA.tx_uuid=T.tx_uuid AND (
+                   TRA.eid_from=%(eid)s OR TRA.eid_to=%(eid)s))
+     )''' % {'txuuid': session.transaction_data['undoing_uuid'],
+             'eid': eid}).fetchone()
+
 
 class NativeSQLSource(SQLAdapterMixIn, AbstractSource):
     """adapter for source using the native cubicweb schema (see below)
@@ -507,7 +547,14 @@
     def delete_relation(self, session, subject, rtype, object):
         """delete a relation from the source"""
         rschema = self.schema.rschema(rtype)
-        if rschema.inlined:
+        self._delete_relation(session, subject, rtype, object, rschema.inlined)
+        if session.undoable_action('R', rtype):
+            self._record_tx_action(session, 'tx_relation_actions', 'R',
+                                   eid_from=subject, rtype=rtype, eid_to=object)
+
+    def _delete_relation(self, session, subject, rtype, object, inlined=False):
+        """delete a relation from the source"""
+        if inlined:
             table = SQL_PREFIX + session.describe(subject)[0]
             column = SQL_PREFIX + rtype
             sql = 'UPDATE %s SET %s=NULL WHERE %seid=%%(eid)s' % (table, column,
@@ -517,9 +564,6 @@
             attrs = {'eid_from': subject, 'eid_to': object}
             sql = self.sqlgen.delete('%s_relation' % rtype, attrs)
         self.doexec(session, sql, attrs)
-        if session.undoable_action('R', rtype):
-            self._record_tx_action(session, 'tx_relation_actions', 'R',
-                                   eid_from=subject, rtype=rtype, eid_to=object)
 
     def doexec(self, session, query, args=None, rollback=True):
         """Execute a query.
@@ -947,54 +991,66 @@
     def _undo_r(self, session, action):
         """undo a relation removal"""
         errors = []
-        err = errors.append
-        _ = session._
         subj, rtype, obj = action.eid_from, action.rtype, action.eid_to
-        entities = []
-        for role, eid in (('subject', subj), ('object', obj)):
-            try:
-                entities.append(session.entity_from_eid(eid))
-            except UnknownEid:
-                err(_("Can't restore relation %(rtype)s, %(role)s entity %(eid)s"
-                      " doesn't exist anymore.")
-                    % {'role': _(role),
-                       'rtype': _(rtype),
-                       'eid': eid})
-        if not len(entities) == 2:
-            return errors
-        sentity, oentity = entities
         try:
-            rschema = self.schema.rschema(rtype)
-            rdef = rschema.rdefs[(sentity.__regid__, oentity.__regid__)]
-        except KeyError:
-            err(_("Can't restore relation %(rtype)s between %(subj)s and "
-                  "%(obj)s, that relation does not exists anymore in the "
-                  "schema.")
-                % {'rtype': rtype,
-                   'subj': subj,
-                   'obj': obj})
+            sentity, oentity, rdef = _undo_rel_info(session, subj, rtype, obj)
+        except UndoException, ex:
+            errors.append(unicode(ex))
         else:
             for role, entity in (('subject', sentity),
                                  ('object', oentity)):
                 try:
                     _undo_check_relation_target(entity, rdef, role)
                 except UndoException, ex:
-                    err(unicode(ex))
+                    errors.append(unicode(ex))
                     continue
         if not errors:
             self.repo.hm.call_hooks('before_add_relation', session,
                                     eidfrom=subj, rtype=rtype, eidto=obj)
             # add relation in the database
-            self._add_relation(session, subj, rtype, obj, rschema.inlined)
+            self._add_relation(session, subj, rtype, obj, rdef.rtype.inlined)
             # set related cache
-            session.update_rel_cache_add(subj, rtype, obj, rschema.symmetric)
+            session.update_rel_cache_add(subj, rtype, obj, rdef.rtype.symmetric)
             self.repo.hm.call_hooks('after_add_relation', session,
                                     eidfrom=subj, rtype=rtype, eidto=obj)
         return errors
 
     def _undo_c(self, session, action):
         """undo an entity creation"""
-        return ['undoing of entity creation not yet supported.']
+        eid = action.eid
+        # XXX done to avoid fetching all remaining relation for the entity
+        # we should find an efficient way to do this (keeping current veolidf
+        # massive deletion performance)
+        if _undo_has_later_transaction(session, eid):
+            msg = session._('some later transaction(s) touch entity, undo them '
+                            'first')
+            raise ValidationError(eid, {None: msg})
+        etype = action.etype
+        # get an entity instance
+        try:
+            entity = self.repo.vreg['etypes'].etype_class(etype)(session)
+        except Exception:
+            return [session._(
+                "Can't undo creation of entity %s of type %s, type "
+                "no more supported" % (eid, etype))]
+        entity.set_eid(eid)
+        # for proper eid/type cache update
+        hook.set_operation(session, 'pendingeids', eid,
+                           CleanupDeletedEidsCacheOp)
+        self.repo.hm.call_hooks('before_delete_entity', session, entity=entity)
+        # remove is / is_instance_of which are added using sql by hooks, hence
+        # unvisible as transaction action
+        self.doexec(session, 'DELETE FROM is_relation WHERE eid_from=%s' % eid)
+        self.doexec(session, 'DELETE FROM is_instance_of_relation WHERE eid_from=%s' % eid)
+        # XXX check removal of inlined relation?
+        # delete the entity
+        attrs = {'cw_eid': eid}
+        sql = self.sqlgen.delete(SQL_PREFIX + entity.__regid__, attrs)
+        self.doexec(session, sql, attrs)
+        # remove record from entities (will update fti if needed)
+        self.delete_info(session, entity, self.uri, None)
+        self.repo.hm.call_hooks('after_delete_entity', session, entity=entity)
+        return ()
 
     def _undo_u(self, session, action):
         """undo an entity update"""
@@ -1002,7 +1058,35 @@
 
     def _undo_a(self, session, action):
         """undo a relation addition"""
-        return ['undoing of relation addition not yet supported.']
+        errors = []
+        subj, rtype, obj = action.eid_from, action.rtype, action.eid_to
+        try:
+            sentity, oentity, rdef = _undo_rel_info(session, subj, rtype, obj)
+        except UndoException, ex:
+            errors.append(unicode(ex))
+        else:
+            rschema = rdef.rtype
+            if rschema.inlined:
+                sql = 'SELECT 1 FROM cw_%s WHERE cw_eid=%s and cw_%s=%s'\
+                      % (sentity.__regid__, subj, rtype, obj)
+            else:
+                sql = 'SELECT 1 FROM %s_relation WHERE eid_from=%s and eid_to=%s'\
+                      % (rtype, subj, obj)
+            cu = self.doexec(session, sql)
+            if cu.fetchone() is None:
+                errors.append(session._(
+                    "Can't undo addition of relation %s from %s to %s, doesn't "
+                    "exist anymore" % (rtype, subj, obj)))
+        if not errors:
+            self.repo.hm.call_hooks('before_delete_relation', session,
+                                    eidfrom=subj, rtype=rtype, eidto=obj)
+            # delete relation from the database
+            self._delete_relation(session, subj, rtype, obj, rschema.inlined)
+            # set related cache
+            session.update_rel_cache_del(subj, rtype, obj, rschema.symmetric)
+            self.repo.hm.call_hooks('after_delete_relation', session,
+                                    eidfrom=subj, rtype=rtype, eidto=obj)
+        return errors
 
     # full text index handling #################################################
 
--- a/server/test/unittest_undo.py	Tue Mar 30 11:17:50 2010 +0200
+++ b/server/test/unittest_undo.py	Tue Mar 30 11:18:31 2010 +0200
@@ -24,6 +24,15 @@
         self.session.undo_support = set()
         super(UndoableTransactionTC, self).tearDown()
 
+    def check_transaction_deleted(self, txuuid):
+        # also check transaction actions have been properly deleted
+        cu = self.session.system_sql(
+            "SELECT * from tx_entity_actions WHERE tx_uuid='%s'" % txuuid)
+        self.failIf(cu.fetchall())
+        cu = self.session.system_sql(
+            "SELECT * from tx_relation_actions WHERE tx_uuid='%s'" % txuuid)
+        self.failIf(cu.fetchall())
+
     def test_undo_api(self):
         self.failUnless(self.txuuid)
         # test transaction api
@@ -154,13 +163,7 @@
         self.assertEquals(len(txs), 2)
         self.assertRaises(NoSuchTransaction,
                           self.cnx.transaction_info, txuuid)
-        # also check transaction actions have been properly deleted
-        cu = self.session.system_sql(
-            "SELECT * from tx_entity_actions WHERE tx_uuid='%s'" % txuuid)
-        self.failIf(cu.fetchall())
-        cu = self.session.system_sql(
-            "SELECT * from tx_relation_actions WHERE tx_uuid='%s'" % txuuid)
-        self.failIf(cu.fetchall())
+        self.check_transaction_deleted(txuuid)
         # the final test: check we can login with the previously deleted user
         self.login('toto')
 
@@ -196,11 +199,74 @@
         g.delete()
         self.commit()
         errors = self.cnx.undo_transaction(txuuid)
-        self.assertRaises(ValidationError, self.commit)
+        self.assertEquals(errors,
+                          [u"Can't restore relation in_group, object entity "
+                          "%s doesn't exist anymore." % g.eid])
+        ex = self.assertRaises(ValidationError, self.commit)
+        self.assertEquals(ex.entity, self.toto.eid)
+        self.assertEquals(ex.errors,
+                          {'in_group-subject': u'at least one relation in_group is '
+                           'required on CWUser (%s)' % self.toto.eid})
+
+    def test_undo_creation_1(self):
+        session = self.session
+        c = session.create_entity('Card', title=u'hop', content=u'hop')
+        p = session.create_entity('Personne', nom=u'louis', fiche=c)
+        txuuid = self.commit()
+        errors = self.cnx.undo_transaction(txuuid)
+        self.commit()
+        self.failIf(errors)
+        self.failIf(self.execute('Any X WHERE X eid %(x)s', {'x': c.eid}, 'x'))
+        self.failIf(self.execute('Any X WHERE X eid %(x)s', {'x': p.eid}, 'x'))
+        self.failIf(self.execute('Any X,Y WHERE X fiche Y'))
+        self.session.set_pool()
+        for eid in (p.eid, c.eid):
+            self.failIf(session.system_sql(
+                'SELECT * FROM entities WHERE eid=%s' % eid).fetchall())
+            self.failIf(session.system_sql(
+                'SELECT 1 FROM owned_by_relation WHERE eid_from=%s' % eid).fetchall())
+            # added by sql in hooks (except when using dataimport)
+            self.failIf(session.system_sql(
+                'SELECT 1 FROM is_relation WHERE eid_from=%s' % eid).fetchall())
+            self.failIf(session.system_sql(
+                'SELECT 1 FROM is_instance_of_relation WHERE eid_from=%s' % eid).fetchall())
+        self.check_transaction_deleted(txuuid)
+
 
-    def test_undo_creation(self):
-        # XXX what about relation / composite entities which have been created
-        # afterwhile and linked to the undoed addition ?
-        self.skip('not implemented')
+    def test_undo_creation_integrity_1(self):
+        session = self.session
+        tutu = self.create_user('tutu', commit=False)
+        txuuid = self.commit()
+        email = self.request().create_entity('EmailAddress', address=u'tutu@cubicweb.org')
+        prop = self.request().create_entity('CWProperty', pkey=u'ui.default-text-format',
+                                            value=u'text/html')
+        tutu.set_relations(use_email=email, reverse_for_user=prop)
+        self.commit()
+        ex = self.assertRaises(ValidationError,
+                               self.cnx.undo_transaction, txuuid)
+        self.assertEquals(ex.entity, tutu.eid)
+        self.assertEquals(ex.errors,
+                          {None: 'some later transaction(s) touch entity, undo them first'})
+
+    def test_undo_creation_integrity_2(self):
+        session = self.session
+        g = session.create_entity('CWGroup', name=u'staff')
+        txuuid = self.commit()
+        session.execute('DELETE U in_group G WHERE U eid %(x)s', {'x': self.toto.eid})
+        self.toto.set_relations(in_group=g)
+        self.commit()
+        ex = self.assertRaises(ValidationError,
+                               self.cnx.undo_transaction, txuuid)
+        self.assertEquals(ex.entity, g.eid)
+        self.assertEquals(ex.errors,
+                          {None: 'some later transaction(s) touch entity, undo them first'})
+        # self.assertEquals(errors,
+        #                   [u"Can't restore relation in_group, object entity "
+        #                   "%s doesn't exist anymore." % g.eid])
+        # ex = self.assertRaises(ValidationError, self.commit)
+        # self.assertEquals(ex.entity, self.toto.eid)
+        # self.assertEquals(ex.errors,
+        #                   {'in_group-subject': u'at least one relation in_group is '
+        #                    'required on CWUser (%s)' % self.toto.eid})
 
     # test implicit 'replacement' of an inlined relation