Skip to content

Commit 869e7b8

Browse files
committed
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
1 parent 41905e2 commit 869e7b8

File tree

6 files changed

+272
-9
lines changed

6 files changed

+272
-9
lines changed

google/cloud/sqlalchemy_spanner/sqlalchemy_spanner.py

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
)
2525
from google.api_core.client_options import ClientOptions
2626
from google.auth.credentials import AnonymousCredentials
27-
from google.cloud.spanner_v1 import Client
27+
from google.cloud.spanner_v1 import Client, TransactionOptions
2828
from sqlalchemy.exc import NoSuchTableError
2929
from sqlalchemy.sql import elements
3030
from sqlalchemy import ForeignKeyConstraint, types
@@ -648,6 +648,7 @@ class SpannerDialect(DefaultDialect):
648648
encoding = "utf-8"
649649
max_identifier_length = 256
650650
_legacy_binary_type_literal_encoding = "utf-8"
651+
_default_isolation_level = "SERIALIZABLE"
651652

652653
execute_sequence_format = list
653654

@@ -699,12 +700,11 @@ def default_isolation_level(self):
699700
Returns:
700701
str: default isolation level.
701702
"""
702-
return "SERIALIZABLE"
703+
return self._default_isolation_level
703704

704705
@default_isolation_level.setter
705706
def default_isolation_level(self, value):
706-
"""Default isolation level should not be changed."""
707-
pass
707+
self._default_isolation_level = value
708708

709709
def _check_unicode_returns(self, connection, additional_tests=None):
710710
"""Ensure requests are returning Unicode responses."""
@@ -1551,15 +1551,20 @@ def set_isolation_level(self, conn_proxy, level):
15511551
spanner_dbapi.connection.Connection,
15521552
]
15531553
):
1554-
Database connection proxy object or the connection iself.
1554+
Database connection proxy object or the connection itself.
15551555
level (string): Isolation level.
15561556
"""
15571557
if isinstance(conn_proxy, spanner_dbapi.Connection):
15581558
conn = conn_proxy
15591559
else:
15601560
conn = conn_proxy.connection
15611561

1562-
conn.autocommit = level == "AUTOCOMMIT"
1562+
if level == "AUTOCOMMIT":
1563+
conn.autocommit = True
1564+
else:
1565+
if isinstance(level, str):
1566+
level = self._string_to_isolation_level(level)
1567+
conn.isolation_level = level
15631568

15641569
def get_isolation_level(self, conn_proxy):
15651570
"""Get the connection isolation level.
@@ -1571,7 +1576,7 @@ def get_isolation_level(self, conn_proxy):
15711576
spanner_dbapi.connection.Connection,
15721577
]
15731578
):
1574-
Database connection proxy object or the connection iself.
1579+
Database connection proxy object or the connection itself.
15751580
15761581
Returns:
15771582
str: the connection isolation level.
@@ -1581,7 +1586,23 @@ def get_isolation_level(self, conn_proxy):
15811586
else:
15821587
conn = conn_proxy.connection
15831588

1584-
return "AUTOCOMMIT" if conn.autocommit else "SERIALIZABLE"
1589+
if conn.autocommit:
1590+
return "AUTOCOMMIT"
1591+
1592+
level = conn.isolation_level
1593+
if isinstance(level, TransactionOptions.IsolationLevel):
1594+
level = self._isolation_level_to_string(level)
1595+
1596+
return level
1597+
1598+
def _string_to_isolation_level(self, name):
1599+
try:
1600+
return TransactionOptions.IsolationLevel[name.upper().replace(" ", "_")]
1601+
except KeyError:
1602+
return TransactionOptions.IsolationLevel.ISOLATION_LEVEL_UNSPECIFIED
1603+
1604+
def _isolation_level_to_string(self, level):
1605+
return level.name.replace("_", " ")
15851606

15861607
def do_rollback(self, dbapi_connection):
15871608
"""

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)
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
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 create_engine
16+
from sqlalchemy.orm import Session
17+
from sqlalchemy.testing import eq_, is_instance_of
18+
from google.cloud.spanner_v1 import (
19+
FixedSizePool,
20+
BatchCreateSessionsRequest,
21+
ExecuteSqlRequest,
22+
CommitRequest,
23+
BeginTransactionRequest,
24+
TransactionOptions,
25+
)
26+
27+
from test.mockserver_tests.mock_server_test_base import (
28+
MockServerTestBase,
29+
add_result,
30+
)
31+
import google.cloud.spanner_v1.types.type as spanner_type
32+
import google.cloud.spanner_v1.types.result_set as result_set
33+
34+
35+
class TestIsolationLevel(MockServerTestBase):
36+
def test_default_isolation_level(self):
37+
from test.mockserver_tests.isolation_level_model import Singer
38+
39+
self.add_insert_result(
40+
"INSERT INTO singers (name) VALUES (@a0) THEN RETURN singers.id"
41+
)
42+
engine = create_engine(
43+
"spanner:///projects/p/instances/i/databases/d",
44+
connect_args={"client": self.client, "pool": FixedSizePool(size=10)},
45+
)
46+
47+
with Session(engine) as session:
48+
singer = Singer(name="Test")
49+
session.add(singer)
50+
session.commit()
51+
self.verify_isolation_level(
52+
TransactionOptions.IsolationLevel.ISOLATION_LEVEL_UNSPECIFIED
53+
)
54+
55+
def test_engine_isolation_level(self):
56+
from test.mockserver_tests.isolation_level_model import Singer
57+
58+
self.add_insert_result(
59+
"INSERT INTO singers (name) VALUES (@a0) THEN RETURN singers.id"
60+
)
61+
engine = create_engine(
62+
"spanner:///projects/p/instances/i/databases/d",
63+
connect_args={"client": self.client, "pool": FixedSizePool(size=10)},
64+
isolation_level="REPEATABLE READ",
65+
)
66+
67+
with Session(engine) as session:
68+
singer = Singer(name="Test")
69+
session.add(singer)
70+
session.commit()
71+
self.verify_isolation_level(TransactionOptions.IsolationLevel.REPEATABLE_READ)
72+
73+
def test_execution_options_isolation_level(self):
74+
from test.mockserver_tests.isolation_level_model import Singer
75+
76+
self.add_insert_result(
77+
"INSERT INTO singers (name) VALUES (@a0) THEN RETURN singers.id"
78+
)
79+
engine = create_engine(
80+
"spanner:///projects/p/instances/i/databases/d",
81+
connect_args={"client": self.client, "pool": FixedSizePool(size=10)},
82+
)
83+
84+
with Session(
85+
engine.execution_options(isolation_level="repeatable read")
86+
) as session:
87+
singer = Singer(name="Test")
88+
session.add(singer)
89+
session.commit()
90+
self.verify_isolation_level(TransactionOptions.IsolationLevel.REPEATABLE_READ)
91+
92+
def test_override_engine_isolation_level(self):
93+
from test.mockserver_tests.isolation_level_model import Singer
94+
95+
self.add_insert_result(
96+
"INSERT INTO singers (name) VALUES (@a0) THEN RETURN singers.id"
97+
)
98+
engine = create_engine(
99+
"spanner:///projects/p/instances/i/databases/d",
100+
connect_args={"client": self.client, "pool": FixedSizePool(size=10)},
101+
isolation_level="REPEATABLE READ",
102+
)
103+
104+
with Session(
105+
engine.execution_options(isolation_level="SERIALIZABLE")
106+
) as session:
107+
singer = Singer(name="Test")
108+
session.add(singer)
109+
session.commit()
110+
self.verify_isolation_level(TransactionOptions.IsolationLevel.SERIALIZABLE)
111+
112+
def verify_isolation_level(self, level):
113+
# Verify the requests that we got.
114+
requests = self.spanner_service.requests
115+
eq_(4, len(requests))
116+
is_instance_of(requests[0], BatchCreateSessionsRequest)
117+
is_instance_of(requests[1], BeginTransactionRequest)
118+
is_instance_of(requests[2], ExecuteSqlRequest)
119+
is_instance_of(requests[3], CommitRequest)
120+
begin_request: BeginTransactionRequest = requests[1]
121+
eq_(
122+
TransactionOptions(
123+
dict(
124+
isolation_level=level,
125+
read_write=TransactionOptions.ReadWrite(),
126+
)
127+
),
128+
begin_request.options,
129+
)
130+
131+
def add_insert_result(self, sql):
132+
result = result_set.ResultSet(
133+
dict(
134+
metadata=result_set.ResultSetMetadata(
135+
dict(
136+
row_type=spanner_type.StructType(
137+
dict(
138+
fields=[
139+
spanner_type.StructType.Field(
140+
dict(
141+
name="id",
142+
type=spanner_type.Type(
143+
dict(code=spanner_type.TypeCode.INT64)
144+
),
145+
)
146+
)
147+
]
148+
)
149+
)
150+
)
151+
),
152+
stats=result_set.ResultSetStats(
153+
dict(
154+
row_count_exact=1,
155+
)
156+
),
157+
)
158+
)
159+
result.rows.extend([("987654321",)])
160+
add_result(sql, result)

test/system/test_basics.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,9 @@ class User(Base):
147147
session.add(number)
148148
session.commit()
149149

150-
with Session(engine) as session:
150+
with Session(
151+
engine.execution_options(isolation_level="REPEATABLE READ")
152+
) as session:
151153
user = User(name="Test")
152154
session.add(user)
153155
session.commit()

0 commit comments

Comments
 (0)