[repoapi] introduce a basic ClientConnection class
authorPierre-Yves David <pierre-yves.david@logilab.fr>
Tue, 25 Jun 2013 11:06:57 +0200
changeset 9052 4cba5f2cd57b
parent 9051 944d66870c6a
child 9053 862040061173
[repoapi] introduce a basic ClientConnection class This is the new official way to access the repo from client side. It still access Session object directly as the server side connection is not up yet (and it's not up because it would have no user). Multiple follow up commit will install compatibility with the DBAPI. This will ease the migration from dbapi to repoapi. ClientConnection has no user yet but later commit will use it in the whole Web stack. related to #2503918
repoapi.py
server/session.py
test/unittest_repoapi.py
transaction.py
--- a/repoapi.py	Tue Jun 25 17:25:47 2013 +0200
+++ b/repoapi.py	Tue Jun 25 11:06:57 2013 +0200
@@ -18,7 +18,10 @@
 """Official API to access the content of a repository
 """
 from cubicweb.utils import parse_repo_uri
-from cubicweb import ConnectionError
+from cubicweb import ConnectionError, ProgrammingError
+from uuid import uuid4
+from contextlib import contextmanager
+from cubicweb.req import RequestSessionBase
 
 ### private function for specific method ############################
 
@@ -71,3 +74,216 @@
     else:
         raise ConnectionError('unknown protocol: `%s`' % protocol)
 
+def _srv_cnx_func(name):
+    """Decorate ClientConnection method blindly forward to Connection
+    THIS TRANSITIONAL PURPOSE
+
+    will be dropped when we have standalone connection"""
+    def proxy(clt_cnx, *args, **kwargs):
+        # the ``with`` dance is transitional. We do not have Standalone
+        # Connection yet so we use this trick to unsure the session have the
+        # proper cnx loaded. This can be simplified one we have Standalone
+        # Connection object
+        with clt_cnx._srv_cnx as cnx:
+            return getattr(cnx, name)(*args, **kwargs)
+    return proxy
+
+class ClientConnection(RequestSessionBase):
+    """A Connection object to be used Client side.
+
+    This object is aimed to be used client side (so potential communication
+    with the repo through RTC) and aims to offer some compatibility with the
+    cubicweb.dbapi.Connection interface.
+    """
+    # make exceptions available through the connection object
+    ProgrammingError = ProgrammingError
+    # attributes that may be overriden per connection instance
+    anonymous_connection = False # XXX really needed ?
+    is_repo_in_memory = True # BC, always true
+
+    def __init__(self, session):
+        self._session = session
+        self._cnxid = None
+        self._open = None
+        self._web_request = False
+        self.vreg = session.vreg
+        self._set_user(session.user)
+
+    def __enter__(self):
+        assert self._open is None
+        self._open = True
+        self._cnxid = '%s-%s' % (self._session.id, uuid4().hex)
+        self._session.set_cnx(self._cnxid)
+        self._session._cnx.ctx_count += 1
+
+    def __exit__(self, exc_type, exc_val, exc_tb):
+        self._open = False
+        cnxid = self._cnxid
+        self._cnxid = None
+        self._session._cnx.ctx_count -= 1
+        self._session.close_cnx(cnxid)
+
+
+    # begin silly BC
+    @property
+    def _closed(self):
+        return not self._open
+
+    def close(self):
+        if self._open:
+            self.__exit__(None, None, None)
+
+    def __repr__(self):
+        if self.anonymous_connection:
+            return '<Connection %s (anonymous)>' % self._cnxid
+        return '<Connection %s>' % self._cnxid
+    # end silly BC
+
+    @property
+    @contextmanager
+    def _srv_cnx(self):
+        """ensure that the session is locked to the right transaction
+
+        TRANSITIONAL PURPOSE, This will be dropped once we use standalone
+        session object"""
+        if not self._open:
+            raise ProgrammingError('Closed connection %s' % self._cnxid)
+        session = self._session
+        old_cnx = session._current_cnx_id
+        try:
+            session.set_cnx(self._cnxid)
+            session.set_cnxset()
+            try:
+                yield session
+            finally:
+                session.free_cnxset()
+        finally:
+            if old_cnx is not None:
+                session.set_cnx(old_cnx)
+
+    # Main Connection purpose in life #########################################
+
+    call_service = _srv_cnx_func('call_service')
+
+    def execute(self, *args, **kwargs):
+        # the ``with`` dance is transitional. We do not have Standalone
+        # Connection yet so we use this trick to unsure the session have the
+        # proper cnx loaded. This can be simplified one we have Standalone
+        # Connection object
+        with self._srv_cnx as cnx:
+            rset = cnx.execute(*args, **kwargs)
+        rset.req = self
+        return rset
+
+    commit = _srv_cnx_func('commit')
+    rollback = _srv_cnx_func('rollback')
+
+    # session data methods #####################################################
+
+    get_shared_data = _srv_cnx_func('get_shared_data')
+    set_shared_data = _srv_cnx_func('set_shared_data')
+
+    # meta-data accessors ######################################################
+
+    def source_defs(self):
+        """Return the definition of sources used by the repository."""
+        return self._session.repo.source_defs()
+
+    def get_schema(self):
+        """Return the schema currently used by the repository."""
+        return self._session.repo.source_defs()
+
+    def get_option_value(self, option, foreid=None):
+        """Return the value for `option` in the configuration. If `foreid` is
+        specified, the actual repository to which this entity belongs is
+        dereferenced and the option value retrieved from it.
+        """
+        return self._session.repo.get_option_value(option, foreid)
+
+    describe = _srv_cnx_func('describe')
+
+    # undo support ############################################################
+
+    def undoable_transactions(self, ueid=None, req=None, **actionfilters):
+        """Return a list of undoable transaction objects by the connection's
+        user, ordered by descendant transaction time.
+
+        Managers may filter according to user (eid) who has done the transaction
+        using the `ueid` argument. Others will only see their own transactions.
+
+        Additional filtering capabilities is provided by using the following
+        named arguments:
+
+        * `etype` to get only transactions creating/updating/deleting entities
+          of the given type
+
+        * `eid` to get only transactions applied to entity of the given eid
+
+        * `action` to get only transactions doing the given action (action in
+          'C', 'U', 'D', 'A', 'R'). If `etype`, action can only be 'C', 'U' or
+          'D'.
+
+        * `public`: when additional filtering is provided, their are by default
+          only searched in 'public' actions, unless a `public` argument is given
+          and set to false.
+        """
+        # the ``with`` dance is transitional. We do not have Standalone
+        # Connection yet so we use this trick to unsure the session have the
+        # proper cnx loaded. This can be simplified one we have Standalone
+        # Connection object
+        with self._srv_cnx as cnx:
+            source = cnx.repo.system_source
+            txinfos = source.undoable_transactions(cnx, ueid, **actionfilters)
+        for txinfo in txinfos:
+            txinfo.req = req or self  # XXX mostly wrong
+        return txinfos
+
+    def transaction_info(self, txuuid, req=None):
+        """Return transaction object for the given uid.
+
+        raise `NoSuchTransaction` if not found or if session's user is not
+        allowed (eg not in managers group and the transaction doesn't belong to
+        him).
+        """
+        # the ``with`` dance is transitional. We do not have Standalone
+        # Connection yet so we use this trick to unsure the session have the
+        # proper cnx loaded. This can be simplified one we have Standalone
+        # Connection object
+        with self._srv_cnx as cnx:
+            txinfo = cnx.repo.system_source.tx_info(cnx, txuuid)
+        if req:
+            txinfo.req = req
+        else:
+            txinfo.cnx = self
+        return txinfo
+
+    def transaction_actions(self, txuuid, public=True):
+        """Return an ordered list of action effectued during that transaction.
+
+        If public is true, return only 'public' actions, eg not ones triggered
+        under the cover by hooks, else return all actions.
+
+        raise `NoSuchTransaction` if the transaction is not found or if
+        session's user is not allowed (eg not in managers group and the
+        transaction doesn't belong to him).
+        """
+        # the ``with`` dance is transitional. We do not have Standalone
+        # Connection yet so we use this trick to unsure the session have the
+        # proper cnx loaded. This can be simplified one we have Standalone
+        # Connection object
+        with self._srv_cnx as cnx:
+            return cnx.repo.system_source.tx_actions(cnx, txuuid, public)
+
+    def undo_transaction(self, txuuid):
+        """Undo the given transaction. Return potential restoration errors.
+
+        raise `NoSuchTransaction` if not found or if session's user is not
+        allowed (eg not in managers group and the transaction doesn't belong to
+        him).
+        """
+        # the ``with`` dance is transitional. We do not have Standalone
+        # Connection yet so we use this trick to unsure the session have the
+        # proper cnx loaded. This can be simplified one we have Standalone
+        # Connection object
+        with self._srv_cnx as cnx:
+            return cnx.repo.system_source.undo_transaction(cnx, txuuid)
--- a/server/session.py	Tue Jun 25 17:25:47 2013 +0200
+++ b/server/session.py	Tue Jun 25 11:06:57 2013 +0200
@@ -935,6 +935,14 @@
             self.set_cnx()
             return self.__threaddata.cnx
 
+    @property
+    def _current_cnx_id(self):
+        """TRANSITIONAL PURPOSE"""
+        try:
+            return self.__threaddata.cnx.transactionid
+        except AttributeError:
+            return None
+
     def get_option_value(self, option, foreid=None):
         return self.repo.get_option_value(option, foreid)
 
@@ -1212,6 +1220,14 @@
         rset.req = self
         return rset
 
+    def close_cnx(self, cnxid):
+        cnx = self._cnxs.get(cnxid, None)
+        if cnx is not None:
+            cnx.free_cnxset(ignoremode=True)
+            self._clear_thread_storage(cnx)
+            self._clear_cnx_storage(cnx)
+
+
     def _clear_thread_data(self, free_cnxset=True):
         """remove everything from the thread local storage, except connections set
         which is explicitly removed by free_cnxset, and mode which is set anyway
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/test/unittest_repoapi.py	Tue Jun 25 11:06:57 2013 +0200
@@ -0,0 +1,70 @@
+# copyright 2013-2013 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
+# contact http://www.logilab.fr/ -- mailto:contact@logilab.fr
+#
+# This file is part of CubicWeb.
+#
+# CubicWeb is free software: you can redistribute it and/or modify it under the
+# terms of the GNU Lesser General Public License as published by the Free
+# Software Foundation, either version 2.1 of the License, or (at your option)
+# any later version.
+#
+# CubicWeb is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public License for more
+# details.
+#
+# You should have received a copy of the GNU Lesser General Public License along
+# with CubicWeb.  If not, see <http://www.gnu.org/licenses/>.
+"""unittest for cubicweb.dbapi"""
+
+
+from cubicweb.devtools.testlib import CubicWebTC
+
+from cubicweb import ProgrammingError
+from cubicweb.repoapi import ClientConnection
+
+
+class REPOAPITC(CubicWebTC):
+
+    def test_clt_cnx_basic_usage(self):
+        """Test that a client connection can be used to access the data base"""
+        cltcnx = ClientConnection(self.session)
+        with cltcnx:
+            # (1) some RQL request
+            rset = cltcnx.execute('Any X WHERE X is CWUser')
+            self.assertTrue(rset)
+            # (2) ORM usage
+            random_user = rset.get_entity(0, 0)
+            # (3) Write operation
+            random_user.cw_set(surname=u'babar')
+            # (4) commit
+            cltcnx.commit()
+            rset = cltcnx.execute('''Any X WHERE X is CWUser,
+                                                 X surname "babar"
+                                  ''')
+            self.assertTrue(rset)
+            # prepare test for implicite rollback
+            random_user = rset.get_entity(0, 0)
+            random_user.cw_set(surname=u'celestine')
+        # implicite rollback on exit
+        rset = self.session.execute('''Any X WHERE X is CWUser,
+                                                 X surname "babar"
+                                    ''')
+        self.assertTrue(rset)
+
+    def test_clt_cnx_life_cycle(self):
+        """Check that ClientConnection requires explicite open and  close
+        """
+        cltcnx = ClientConnection(self.session)
+        # connection not open yet
+        with self.assertRaises(ProgrammingError):
+            cltcnx.execute('Any X WHERE X is CWUser')
+        # connection open and working
+        with cltcnx:
+            cltcnx.execute('Any X WHERE X is CWUser')
+        # connection closed
+        with self.assertRaises(ProgrammingError):
+            cltcnx.execute('Any X WHERE X is CWUser')
+
+
+
--- a/transaction.py	Tue Jun 25 17:25:47 2013 +0200
+++ b/transaction.py	Tue Jun 25 11:06:57 2013 +0200
@@ -53,7 +53,17 @@
         self.datetime = time
         self.user_eid = ueid
         # should be set by the dbapi connection
-        self.req = None
+        self.req = None  # old style
+        self.cnx = None  # new style
+
+    def _execute(self, *args, **kwargs):
+        """execute a query using either the req or the cnx"""
+        if self.req is None:
+            execute = self.cnx.execute
+        else:
+            execute = self.req
+        return execute(*args, **kwargs)
+
 
     def __repr__(self):
         return '<Transaction %s by %s on %s>' % (
@@ -63,8 +73,8 @@
         """return the user entity which has done the transaction,
         none if not found.
         """
-        return self.req.execute('Any X WHERE X eid %(x)s',
-                                {'x': self.user_eid}).get_entity(0, 0)
+        return self._execute('Any X WHERE X eid %(x)s',
+                             {'x': self.user_eid}).get_entity(0, 0)
 
     def actions_list(self, public=True):
         """return an ordered list of action effectued during that transaction
@@ -72,7 +82,11 @@
         if public is true, return only 'public' action, eg not ones triggered
         under the cover by hooks.
         """
-        return self.req.cnx.transaction_actions(self.uuid, public)
+        if self.req is not None:
+            cnx = self.req.cnx
+        else:
+            cnx = self.cnx
+        return cnx.transaction_actions(self.uuid, public)
 
 
 class AbstractAction(object):