# HG changeset patch # User Sylvain Thénault # Date 1269940711 -7200 # Node ID b0e6134b43244b6213d726ea6b122eb41ce5b1b0 # Parent a4b735e76c660b95eb1022be84ae7d3c1660c0cf [undo] basic support for undoing of entity creation / relation addition diff -r a4b735e76c66 -r b0e6134b4324 server/sources/native.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 ################################################# diff -r a4b735e76c66 -r b0e6134b4324 server/test/unittest_undo.py --- 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