Skip to content

Commit 0aba318

Browse files
authored
feat: add isolation level support and sample (#652)
* feat: add isolation level support and sample Add support for specifying the transaction isolation level. Spanner currently supports two isolation levels: - SERIALIZABLE (default) - REPEATABLE READ * fix: convert unspecified to serializable * test: only use repeatable read on the emulator
1 parent c18a0fe commit 0aba318

File tree

7 files changed

+345
-11
lines changed

7 files changed

+345
-11
lines changed

google/cloud/sqlalchemy_spanner/requirements.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,10 @@ def get_isolation_levels(self, _):
102102
Returns:
103103
dict: isolation levels description.
104104
"""
105-
return {"default": "SERIALIZABLE", "supported": ["SERIALIZABLE", "AUTOCOMMIT"]}
105+
return {
106+
"default": "SERIALIZABLE",
107+
"supported": ["SERIALIZABLE", "REPEATABLE READ", "AUTOCOMMIT"],
108+
}
106109

107110
@property
108111
def precision_numerics_enotation_large(self):

google/cloud/sqlalchemy_spanner/sqlalchemy_spanner.py

Lines changed: 48 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
)
2626
from google.api_core.client_options import ClientOptions
2727
from google.auth.credentials import AnonymousCredentials
28-
from google.cloud.spanner_v1 import Client
28+
from google.cloud.spanner_v1 import Client, TransactionOptions
2929
from sqlalchemy.exc import NoSuchTableError
3030
from sqlalchemy.sql import elements
3131
from sqlalchemy import ForeignKeyConstraint, types, TypeDecorator, PickleType
@@ -218,6 +218,16 @@ def pre_exec(self):
218218
if request_tag:
219219
self.cursor.request_tag = request_tag
220220

221+
ignore_transaction_warnings = self.execution_options.get(
222+
"ignore_transaction_warnings"
223+
)
224+
if ignore_transaction_warnings is not None:
225+
conn = self._dbapi_connection.connection
226+
if conn is not None and hasattr(conn, "_connection_variables"):
227+
conn._connection_variables[
228+
"ignore_transaction_warnings"
229+
] = ignore_transaction_warnings
230+
221231
def fire_sequence(self, seq, type_):
222232
"""Builds a statement for fetching next value of the sequence."""
223233
return self._execute_scalar(
@@ -777,6 +787,7 @@ class SpannerDialect(DefaultDialect):
777787
encoding = "utf-8"
778788
max_identifier_length = 256
779789
_legacy_binary_type_literal_encoding = "utf-8"
790+
_default_isolation_level = "SERIALIZABLE"
780791

781792
execute_sequence_format = list
782793

@@ -828,12 +839,11 @@ def default_isolation_level(self):
828839
Returns:
829840
str: default isolation level.
830841
"""
831-
return "SERIALIZABLE"
842+
return self._default_isolation_level
832843

833844
@default_isolation_level.setter
834845
def default_isolation_level(self, value):
835-
"""Default isolation level should not be changed."""
836-
pass
846+
self._default_isolation_level = value
837847

838848
def _check_unicode_returns(self, connection, additional_tests=None):
839849
"""Ensure requests are returning Unicode responses."""
@@ -1682,15 +1692,21 @@ def set_isolation_level(self, conn_proxy, level):
16821692
spanner_dbapi.connection.Connection,
16831693
]
16841694
):
1685-
Database connection proxy object or the connection iself.
1695+
Database connection proxy object or the connection itself.
16861696
level (string): Isolation level.
16871697
"""
16881698
if isinstance(conn_proxy, spanner_dbapi.Connection):
16891699
conn = conn_proxy
16901700
else:
16911701
conn = conn_proxy.connection
16921702

1693-
conn.autocommit = level == "AUTOCOMMIT"
1703+
if level == "AUTOCOMMIT":
1704+
conn.autocommit = True
1705+
else:
1706+
if isinstance(level, str):
1707+
level = self._string_to_isolation_level(level)
1708+
conn.isolation_level = level
1709+
conn.autocommit = False
16941710

16951711
def get_isolation_level(self, conn_proxy):
16961712
"""Get the connection isolation level.
@@ -1702,7 +1718,7 @@ def get_isolation_level(self, conn_proxy):
17021718
spanner_dbapi.connection.Connection,
17031719
]
17041720
):
1705-
Database connection proxy object or the connection iself.
1721+
Database connection proxy object or the connection itself.
17061722
17071723
Returns:
17081724
str: the connection isolation level.
@@ -1712,7 +1728,31 @@ def get_isolation_level(self, conn_proxy):
17121728
else:
17131729
conn = conn_proxy.connection
17141730

1715-
return "AUTOCOMMIT" if conn.autocommit else "SERIALIZABLE"
1731+
if conn.autocommit:
1732+
return "AUTOCOMMIT"
1733+
1734+
level = conn.isolation_level
1735+
if level == TransactionOptions.IsolationLevel.ISOLATION_LEVEL_UNSPECIFIED:
1736+
level = TransactionOptions.IsolationLevel.SERIALIZABLE
1737+
if isinstance(level, TransactionOptions.IsolationLevel):
1738+
level = self._isolation_level_to_string(level)
1739+
1740+
return level
1741+
1742+
def _string_to_isolation_level(self, name):
1743+
try:
1744+
# SQLAlchemy guarantees that the isolation level string will:
1745+
# 1. Be all upper case.
1746+
# 2. Contain spaces instead of underscores.
1747+
# We change the spaces into underscores to get the enum value.
1748+
return TransactionOptions.IsolationLevel[name.replace(" ", "_")]
1749+
except KeyError:
1750+
raise ValueError("Invalid isolation level name '%s'" % name)
1751+
1752+
def _isolation_level_to_string(self, level):
1753+
# SQLAlchemy expects isolation level names to contain spaces,
1754+
# and not underscores, so we remove those before returning.
1755+
return level.name.replace("_", " ")
17161756

17171757
def do_rollback(self, dbapi_connection):
17181758
"""

samples/isolation_level_sample.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# Copyright 2025 Google LLC All rights reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import uuid
16+
17+
from sqlalchemy import create_engine
18+
from sqlalchemy.orm import Session
19+
20+
from sample_helper import run_sample
21+
from model import Singer
22+
23+
24+
# Shows how to set the isolation level for a read/write transaction.
25+
# Spanner supports the following isolation levels:
26+
# - SERIALIZABLE (default)
27+
# - REPEATABLE READ
28+
def isolation_level_sample():
29+
engine = create_engine(
30+
"spanner:///projects/sample-project/"
31+
"instances/sample-instance/"
32+
"databases/sample-database",
33+
# You can set a default isolation level for an engine.
34+
isolation_level="REPEATABLE READ",
35+
echo=True,
36+
)
37+
# You can override the default isolation level of the connection
38+
# by setting it in the execution_options.
39+
with Session(engine.execution_options(isolation_level="SERIALIZABLE")) as session:
40+
singer_id = str(uuid.uuid4())
41+
singer = Singer(id=singer_id, first_name="John", last_name="Doe")
42+
session.add(singer)
43+
session.commit()
44+
45+
46+
if __name__ == "__main__":
47+
run_sample(isolation_level_sample)

samples/noxfile.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,11 @@ def transaction(session):
6262
_sample(session)
6363

6464

65+
@nox.session()
66+
def isolation_level(session):
67+
_sample(session)
68+
69+
6570
@nox.session()
6671
def stale_read(session):
6772
_sample(session)
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# Copyright 2025 Google LLC All rights reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from sqlalchemy import String, BigInteger
16+
from sqlalchemy.orm import DeclarativeBase
17+
from sqlalchemy.orm import Mapped
18+
from sqlalchemy.orm import mapped_column
19+
20+
21+
class Base(DeclarativeBase):
22+
pass
23+
24+
25+
class Singer(Base):
26+
__tablename__ = "singers"
27+
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
28+
name: Mapped[str] = mapped_column(String)

0 commit comments

Comments
 (0)