Skip to content
This repository was archived by the owner on Mar 13, 2026. It is now read-only.

Commit a5cd41f

Browse files
committed
feat: enable SQLAlchemy 2.0's insertmany feature
- Enable the use_insertmanyvalues flag on the dialect to support SQLAlchemy 2.0's insertmany feature, which allows multiple ORM objects to be inserted together in bulk, even if the table has server-side generated values which must be included in a THEN RETURN clause - Provide an example for using the feature with client-side supplied UUIDs and insert_sentinel columns. - Ensure that the feature is not enables for bit-reversed primary keys. In other dialects, an incrementing primary key can be used rather than a sentinel column. In Spanner, the bit-reversed integers do not meet the ordering requirement to be used as implicit sentinels https://docs.sqlalchemy.org/en/20/core/internals.html#sqlalchemy.engine.default.DefaultDialect.use_insertmanyvalues https://docs.sqlalchemy.org/en/20/core/connections.html#insert-many-values-behavior-for-insert-statements https://docs.sqlalchemy.org/en/20/core/connections.html#configuring-sentinel-columns Fixes: #720
1 parent 170379f commit a5cd41f

File tree

4 files changed

+306
-0
lines changed

4 files changed

+306
-0
lines changed

google/cloud/sqlalchemy_spanner/sqlalchemy_spanner.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -825,6 +825,7 @@ class SpannerDialect(DefaultDialect):
825825
update_returning = True
826826
delete_returning = True
827827
supports_multivalues_insert = True
828+
use_insertmanyvalues = True
828829

829830
ddl_compiler = SpannerDDLCompiler
830831
preparer = SpannerIdentifierPreparer

samples/insertmany_sample.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
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+
16+
from datetime import datetime
17+
import uuid
18+
from sqlalchemy import text, String
19+
from sqlalchemy.orm import DeclarativeBase
20+
from sqlalchemy.orm import Mapped
21+
from sqlalchemy.orm import mapped_column
22+
23+
24+
class Base(DeclarativeBase):
25+
pass
26+
27+
28+
# To use SQLAlchemy 2.0's insertmany feature, models must have a
29+
# unique column marked as an "insert_sentinal" with client-side
30+
# generated values passed into it. This allows SQLAlchemy to perform a
31+
# single bulk insert, even if the table has columns with server-side
32+
# defaults which must be retrieved from a THEN RETURN clause, for
33+
# operations like:
34+
#
35+
# with Session.begin() as session:
36+
# session.add(Singer(name="a"))
37+
# session.add(Singer(name="b"))
38+
#
39+
# Read more in the SQLAlchemy documentation of this feature:
40+
# https://docs.sqlalchemy.org/en/20/core/connections.html#configuring-sentinel-columns
41+
42+
43+
class Singer(Base):
44+
__tablename__ = "singers"
45+
id: Mapped[str] = mapped_column(
46+
String(36),
47+
primary_key=True,
48+
# Supply a unique UUID client-side
49+
default=lambda: str(uuid.uuid4()),
50+
# The column is unqiue and can be used as an insert_sentinel
51+
insert_sentinel=True,
52+
# Set a server-side default for write outside SQLAlchemy
53+
server_default=text("GENERATE_UUID()"),
54+
)
55+
name: Mapped[str]
56+
inserted_at: Mapped[datetime] = mapped_column(
57+
server_default=text("CURRENT_TIMESTAMP()")
58+
)
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
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 datetime import datetime
16+
import uuid
17+
from sqlalchemy import text, String
18+
from sqlalchemy.orm import DeclarativeBase
19+
from sqlalchemy.orm import Mapped
20+
from sqlalchemy.orm import mapped_column
21+
22+
23+
class Base(DeclarativeBase):
24+
pass
25+
26+
27+
class SingerUUID(Base):
28+
__tablename__ = "singers_uuid"
29+
id: Mapped[str] = mapped_column(
30+
String(36),
31+
primary_key=True,
32+
server_default=text("GENERATE_UUID()"),
33+
default=lambda: str(uuid.uuid4()),
34+
insert_sentinel=True,
35+
)
36+
name: Mapped[str]
37+
inserted_at: Mapped[datetime] = mapped_column(
38+
server_default=text("CURRENT_TIMESTAMP()")
39+
)
40+
41+
42+
class SingerIntID(Base):
43+
__tablename__ = "singers_int_id"
44+
id: Mapped[int] = mapped_column(primary_key=True)
45+
name: Mapped[str] = mapped_column(String)
46+
inserted_at: Mapped[datetime] = mapped_column(
47+
server_default=text("CURRENT_TIMESTAMP()")
48+
)
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
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+
from unittest import mock
17+
18+
import sqlalchemy
19+
from sqlalchemy import create_engine
20+
from sqlalchemy.orm import Session
21+
from sqlalchemy.testing import eq_, is_instance_of
22+
from google.cloud.spanner_v1 import (
23+
FixedSizePool,
24+
BatchCreateSessionsRequest,
25+
ExecuteSqlRequest,
26+
CommitRequest,
27+
RollbackRequest,
28+
BeginTransactionRequest,
29+
)
30+
from test.mockserver_tests.mock_server_test_base import (
31+
MockServerTestBase,
32+
add_result,
33+
)
34+
import google.cloud.spanner_v1.types.type as spanner_type
35+
import google.cloud.spanner_v1.types.result_set as result_set
36+
37+
38+
class TestInsertmany(MockServerTestBase):
39+
@mock.patch.object(uuid, "uuid4", mock.MagicMock(side_effect=["a", "b"]))
40+
def test_insertmany_with_uuid_sentinels(self):
41+
"""Ensures one bulk insert for ORM objects distinguished by uuid."""
42+
from test.mockserver_tests.insertmany_model import SingerUUID
43+
44+
self.add_uuid_insert_result(
45+
"INSERT INTO singers_uuid (id, name) "
46+
"VALUES (@a0, @a1), (@a2, @a3) "
47+
"THEN RETURN inserted_at, id"
48+
)
49+
engine = create_engine(
50+
"spanner:///projects/p/instances/i/databases/d",
51+
connect_args={"client": self.client, "pool": FixedSizePool(size=10)},
52+
)
53+
54+
with Session(engine) as session:
55+
session.add(SingerUUID(name="a"))
56+
session.add(SingerUUID(name="b"))
57+
session.commit()
58+
59+
# Verify the requests that we got.
60+
requests = self.spanner_service.requests
61+
eq_(4, len(requests))
62+
is_instance_of(requests[0], BatchCreateSessionsRequest)
63+
is_instance_of(requests[1], BeginTransactionRequest)
64+
is_instance_of(requests[2], ExecuteSqlRequest)
65+
is_instance_of(requests[3], CommitRequest)
66+
67+
def test_no_insertmany_with_bit_reversed_id(self):
68+
"""Ensures we don't try to bulk insert rows with bit-reversed PKs.
69+
70+
SQLAlchemy's insertmany support requires either incrementing
71+
PKs or client-side supplied sentinel values such as UUIDs.
72+
Spanner's bit-reversed integer PKs don't meet the ordering
73+
requirement, so we need to make sure we don't try to bulk
74+
insert with them.
75+
"""
76+
from test.mockserver_tests.insertmany_model import SingerIntID
77+
78+
self.add_int_id_insert_result(
79+
"INSERT INTO singers_int_id (name) "
80+
"VALUES (@a0) "
81+
"THEN RETURN id, inserted_at"
82+
)
83+
engine = create_engine(
84+
"spanner:///projects/p/instances/i/databases/d",
85+
connect_args={"client": self.client, "pool": FixedSizePool(size=10)},
86+
)
87+
88+
with Session(engine) as session:
89+
session.add(SingerIntID(name="a"))
90+
session.add(SingerIntID(name="b"))
91+
try:
92+
session.commit()
93+
except sqlalchemy.exc.SAWarning:
94+
# This will fail because we're returning the same PK
95+
# for two rows. The mock server doesn't currently
96+
# support associating the same query with two
97+
# different results. For our purposes that's okay --
98+
# we just want to ensure we generate two INSERTs, not
99+
# one.
100+
pass
101+
102+
# Verify the requests that we got.
103+
requests = self.spanner_service.requests
104+
eq_(5, len(requests))
105+
is_instance_of(requests[0], BatchCreateSessionsRequest)
106+
is_instance_of(requests[1], BeginTransactionRequest)
107+
is_instance_of(requests[2], ExecuteSqlRequest)
108+
is_instance_of(requests[3], ExecuteSqlRequest)
109+
is_instance_of(requests[4], RollbackRequest)
110+
111+
def add_uuid_insert_result(self, sql):
112+
result = result_set.ResultSet(
113+
dict(
114+
metadata=result_set.ResultSetMetadata(
115+
dict(
116+
row_type=spanner_type.StructType(
117+
dict(
118+
fields=[
119+
spanner_type.StructType.Field(
120+
dict(
121+
name="inserted_at",
122+
type=spanner_type.Type(
123+
dict(
124+
code=spanner_type.TypeCode.TIMESTAMP
125+
)
126+
),
127+
)
128+
),
129+
spanner_type.StructType.Field(
130+
dict(
131+
name="id",
132+
type=spanner_type.Type(
133+
dict(code=spanner_type.TypeCode.STRING)
134+
),
135+
)
136+
),
137+
]
138+
)
139+
)
140+
)
141+
),
142+
)
143+
)
144+
result.rows.extend(
145+
[
146+
(
147+
"2020-06-02T23:58:40Z",
148+
"a",
149+
),
150+
(
151+
"2020-06-02T23:58:41Z",
152+
"b",
153+
),
154+
]
155+
)
156+
add_result(sql, result)
157+
158+
def add_int_id_insert_result(self, sql):
159+
result = result_set.ResultSet(
160+
dict(
161+
metadata=result_set.ResultSetMetadata(
162+
dict(
163+
row_type=spanner_type.StructType(
164+
dict(
165+
fields=[
166+
spanner_type.StructType.Field(
167+
dict(
168+
name="id",
169+
type=spanner_type.Type(
170+
dict(code=spanner_type.TypeCode.INT64)
171+
),
172+
)
173+
),
174+
spanner_type.StructType.Field(
175+
dict(
176+
name="inserted_at",
177+
type=spanner_type.Type(
178+
dict(
179+
code=spanner_type.TypeCode.TIMESTAMP
180+
)
181+
),
182+
)
183+
),
184+
]
185+
)
186+
)
187+
)
188+
),
189+
)
190+
)
191+
result.rows.extend(
192+
[
193+
(
194+
"1",
195+
"2020-06-02T23:58:40Z",
196+
),
197+
]
198+
)
199+
add_result(sql, result)

0 commit comments

Comments
 (0)