diff --git a/README.md b/README.md index f601c077..155d3094 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,16 @@ eng = create_engine("spanner:///projects/project-id/instances/instance-id/databa autocommit_engine = eng.execution_options(isolation_level="AUTOCOMMIT") ``` +**ReadOnly transactions** +By default, transactions produced by a Spanner connection are in ReadWrite mode. However, some applications require an ability to grant ReadOnly access to users/methods; for these cases Spanner dialect supports the `read_only` execution option, which switches a connection into ReadOnly mode: +```python +with engine.connect().execution_options(read_only=True) as connection: + connection.execute(select(["*"], from_obj=table)).fetchall() +``` +Note that execution options are applied lazily - on the `execute()` method call, right before it. + +ReadOnly/ReadWrite mode of a connection can't be changed while a transaction is in progress - first you must commit or rollback it. + **DDL and transactions** DDL statements are executed outside the regular transactions mechanism, which means DDL statements will not be rolled back on normal transaction rollback. diff --git a/google/cloud/sqlalchemy_spanner/sqlalchemy_spanner.py b/google/cloud/sqlalchemy_spanner/sqlalchemy_spanner.py index 1d0cda07..a9abec59 100644 --- a/google/cloud/sqlalchemy_spanner/sqlalchemy_spanner.py +++ b/google/cloud/sqlalchemy_spanner/sqlalchemy_spanner.py @@ -24,7 +24,7 @@ ) from sqlalchemy import ForeignKeyConstraint, types, util from sqlalchemy.engine.base import Engine -from sqlalchemy.engine.default import DefaultDialect +from sqlalchemy.engine.default import DefaultDialect, DefaultExecutionContext from sqlalchemy.ext.compiler import compiles from sqlalchemy.sql.compiler import ( selectable, @@ -116,6 +116,19 @@ def wrapper(self, connection, *args, **kwargs): return wrapper +class SpannerExecutionContext(DefaultExecutionContext): + def pre_exec(self): + """ + Apply execution options to the DB API connection before + executing the next SQL operation. + """ + super(SpannerExecutionContext, self).pre_exec() + + read_only = self.execution_options.get("read_only", None) + if read_only is not None: + self._dbapi_connection.connection.read_only = read_only + + class SpannerIdentifierPreparer(IdentifierPreparer): """Identifiers compiler. @@ -393,6 +406,7 @@ class SpannerDialect(DefaultDialect): preparer = SpannerIdentifierPreparer statement_compiler = SpannerSQLCompiler type_compiler = SpannerTypeCompiler + execution_ctx_cls = SpannerExecutionContext @classmethod def dbapi(cls): diff --git a/test/test_suite.py b/test/test_suite.py index aa8d3f25..500114c3 100644 --- a/test/test_suite.py +++ b/test/test_suite.py @@ -1574,3 +1574,31 @@ def test_user_agent(self): connection.connection.instance._client._client_info.user_agent == dist.project_name + "/" + dist.version ) + + +class ExecutionOptionsTest(fixtures.TestBase): + """ + Check that `execution_options()` method correctly + sets parameters on the underlying DB API connection. + """ + + def setUp(self): + self._engine = create_engine( + "spanner:///projects/appdev-soda-spanner-staging/instances/" + "sqlalchemy-dialect-test/databases/compliance-test" + ) + self._metadata = MetaData(bind=self._engine) + + self._table = Table( + "execution_options", + self._metadata, + Column("opt_id", Integer, primary_key=True), + Column("opt_name", String(16), nullable=False), + ) + + self._metadata.create_all(self._engine) + + def test_read_only(self): + with self._engine.connect().execution_options(read_only=True) as connection: + connection.execute(select(["*"], from_obj=self._table)).fetchall() + assert connection.connection.read_only is True