From f5d94cd15cba43684fc584072018ab3bc826f457 Mon Sep 17 00:00:00 2001 From: Walt Askew Date: Fri, 30 May 2025 08:48:03 -0400 Subject: [PATCH 1/6] feat: support multi-row inserts (#671) Support multi-row inserts like ```sql INSERT INTO tbl VALUES ('a'), ('b') ``` fixes: #670 --- .../sqlalchemy_spanner/sqlalchemy_spanner.py | 1 + test/system/test_basics.py | 29 +++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/google/cloud/sqlalchemy_spanner/sqlalchemy_spanner.py b/google/cloud/sqlalchemy_spanner/sqlalchemy_spanner.py index f2add3a1..9db46b65 100644 --- a/google/cloud/sqlalchemy_spanner/sqlalchemy_spanner.py +++ b/google/cloud/sqlalchemy_spanner/sqlalchemy_spanner.py @@ -807,6 +807,7 @@ class SpannerDialect(DefaultDialect): insert_returning = True update_returning = True delete_returning = True + supports_multivalues_insert = True ddl_compiler = SpannerDDLCompiler preparer = SpannerIdentifierPreparer diff --git a/test/system/test_basics.py b/test/system/test_basics.py index 693617b1..c2b2f6da 100644 --- a/test/system/test_basics.py +++ b/test/system/test_basics.py @@ -209,3 +209,32 @@ class SchemaUser(Base): select(SchemaUser).where(SchemaUser.name == "NewName") ).all() eq_(0, len(users)) + + def test_multi_row_insert(self, connection): + """Ensures we can perform multi-row inserts.""" + + class Base(DeclarativeBase): + pass + + class User(Base): + __tablename__ = "users" + ID: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(20)) + + with connection.engine.begin() as conn: + inserted_rows = list( + conn.execute( + User.__table__.insert() + .values([{"name": "a"}, {"name": "b"}]) + .returning(User.__table__.c.ID, User.__table__.c.name) + ) + ) + + eq_(2, len(inserted_rows)) + eq_({"a", "b"}, {row.name for row in inserted_rows}) + + with connection.engine.connect() as conn: + selected_rows = list(conn.execute(User.__table__.select())) + + eq_(len(inserted_rows), len(selected_rows)) + eq_(set(inserted_rows), set(selected_rows)) From 1cc02bf77321ac8f8c9c78296138468073a1d543 Mon Sep 17 00:00:00 2001 From: Mend Renovate Date: Fri, 30 May 2025 17:35:33 +0200 Subject: [PATCH 2/6] chore(deps): update dependency protobuf to v5.29.5 (#674) --- requirements.txt | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/requirements.txt b/requirements.txt index ffdde8a8..bcfca60d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -340,18 +340,18 @@ proto-plus==1.26.1 \ # via # google-api-core # google-cloud-spanner -protobuf==5.29.4 \ - --hash=sha256:13eb236f8eb9ec34e63fc8b1d6efd2777d062fa6aaa68268fb67cf77f6839ad7 \ - --hash=sha256:1832f0515b62d12d8e6ffc078d7e9eb06969aa6dc13c13e1036e39d73bebc2de \ - --hash=sha256:307ecba1d852ec237e9ba668e087326a67564ef83e45a0189a772ede9e854dd0 \ - --hash=sha256:3fde11b505e1597f71b875ef2fc52062b6a9740e5f7c8997ce878b6009145862 \ - --hash=sha256:476cb7b14914c780605a8cf62e38c2a85f8caff2e28a6a0bad827ec7d6c85d68 \ - --hash=sha256:4f1dfcd7997b31ef8f53ec82781ff434a28bf71d9102ddde14d076adcfc78c99 \ - --hash=sha256:678974e1e3a9b975b8bc2447fca458db5f93a2fb6b0c8db46b6675b5b5346812 \ - --hash=sha256:aec4962f9ea93c431d5714ed1be1c93f13e1a8618e70035ba2b0564d9e633f2e \ - --hash=sha256:bcefcdf3976233f8a502d265eb65ea740c989bacc6c30a58290ed0e519eb4b8d \ - --hash=sha256:d7d3f7d1d5a66ed4942d4fefb12ac4b14a29028b209d4bfb25c68ae172059922 \ - --hash=sha256:fd32223020cb25a2cc100366f1dedc904e2d71d9322403224cdde5fdced0dabe +protobuf==5.29.5 \ + --hash=sha256:3f1c6468a2cfd102ff4703976138844f78ebd1fb45f49011afc5139e9e283079 \ + --hash=sha256:3f76e3a3675b4a4d867b52e4a5f5b78a2ef9565549d4037e06cf7b0942b1d3fc \ + --hash=sha256:470f3af547ef17847a28e1f47200a1cbf0ba3ff57b7de50d22776607cd2ea353 \ + --hash=sha256:63848923da3325e1bf7e9003d680ce6e14b07e55d0473253a690c3a8b8fd6e61 \ + --hash=sha256:6cf42630262c59b2d8de33954443d94b746c952b01434fc58a417fdbd2e84bd5 \ + --hash=sha256:6f642dc9a61782fa72b90878af134c5afe1917c89a568cd3476d758d3c3a0736 \ + --hash=sha256:7318608d56b6402d2ea7704ff1e1e4597bee46d760e7e4dd42a3d45e24b87f2e \ + --hash=sha256:bc1463bafd4b0929216c35f437a8e28731a2b7fe3d98bb77a600efced5a15c84 \ + --hash=sha256:e38c5add5a311f2a6eb0340716ef9b039c1dfa428b28f25a7838ac329204a671 \ + --hash=sha256:ef91363ad4faba7b25d844ef1ada59ff1604184c0bcd8b39b8a6bef15e1af238 \ + --hash=sha256:fa18533a299d7ab6c55a238bf8629311439995f2e7eca5caaff08663606e9015 # via # google-api-core # google-cloud-spanner From 47aa27c489cb7051cb55468ab4d6b79f8c0ce1f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Knut=20Olav=20L=C3=B8ite?= Date: Mon, 2 Jun 2025 09:47:15 +0200 Subject: [PATCH 3/6] feat: support database role in connect arguments (#667) * feat: support database role in connect arguments * chore: update minimum Spanner version + add sample to nox --- samples/database_role_sample.py | 42 ++++++++++++++++++++++++++++ samples/noxfile.py | 5 ++++ setup.py | 2 +- test/mockserver_tests/test_basics.py | 19 +++++++++++++ 4 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 samples/database_role_sample.py diff --git a/samples/database_role_sample.py b/samples/database_role_sample.py new file mode 100644 index 00000000..6edd8040 --- /dev/null +++ b/samples/database_role_sample.py @@ -0,0 +1,42 @@ +# Copyright 2025 Google LLC All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import uuid + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session + +from sample_helper import run_sample +from model import Singer + + +# Shows how to set the database role for a connection. +def database_role_sample(): + engine = create_engine( + "spanner:///projects/sample-project/" + "instances/sample-instance/" + "databases/sample-database", + # You can set the database role in the connect arguments. + connect_args={"database_role": "my_role"}, + echo=True, + ) + with Session(engine) as session: + singer_id = str(uuid.uuid4()) + singer = Singer(id=singer_id, first_name="John", last_name="Doe") + session.add(singer) + session.commit() + + +if __name__ == "__main__": + run_sample(database_role_sample) diff --git a/samples/noxfile.py b/samples/noxfile.py index 82019f5b..2ea2c37a 100644 --- a/samples/noxfile.py +++ b/samples/noxfile.py @@ -77,6 +77,11 @@ def read_only_transaction(session): _sample(session) +@nox.session() +def database_role(session): + _sample(session) + + @nox.session() def _all_samples(session): _sample(session) diff --git a/setup.py b/setup.py index 763df270..74ad4214 100644 --- a/setup.py +++ b/setup.py @@ -25,7 +25,7 @@ description = "SQLAlchemy dialect integrated into Cloud Spanner database" dependencies = [ "sqlalchemy>=1.1.13", - "google-cloud-spanner>=3.54.0", + "google-cloud-spanner>=3.55.0", "alembic", ] extras = { diff --git a/test/mockserver_tests/test_basics.py b/test/mockserver_tests/test_basics.py index cffbda0d..3e422885 100644 --- a/test/mockserver_tests/test_basics.py +++ b/test/mockserver_tests/test_basics.py @@ -267,6 +267,25 @@ class Singer(Base): session.add(singer) session.commit() + def test_database_role(self): + add_select1_result() + engine = create_engine( + "spanner:///projects/p/instances/i/databases/d", + connect_args={ + "client": self.client, + "pool": FixedSizePool(size=10), + "database_role": "my_role", + }, + ) + with Session(engine.execution_options(isolation_level="autocommit")) as session: + session.execute(select(1)) + requests = self.spanner_service.requests + eq_(2, len(requests)) + is_instance_of(requests[0], BatchCreateSessionsRequest) + is_instance_of(requests[1], ExecuteSqlRequest) + request: BatchCreateSessionsRequest = requests[0] + eq_("my_role", request.session_template.creator_role) + def test_select_table_in_named_schema(self): class Base(DeclarativeBase): pass From 270852ec9ca1e7afce7da0c1b7909059b980e247 Mon Sep 17 00:00:00 2001 From: Mend Renovate Date: Mon, 2 Jun 2025 09:52:29 +0200 Subject: [PATCH 4/6] chore(deps): update dependency google-cloud-spanner to v3.55.0 (#675) --- requirements.txt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/requirements.txt b/requirements.txt index bcfca60d..db96d071 100644 --- a/requirements.txt +++ b/requirements.txt @@ -144,9 +144,9 @@ google-cloud-core==2.4.3 \ --hash=sha256:1fab62d7102844b278fe6dead3af32408b1df3eb06f5c7e8634cbd40edc4da53 \ --hash=sha256:5130f9f4c14b4fafdff75c79448f9495cfade0d8775facf1b09c3bf67e027f6e # via google-cloud-spanner -google-cloud-spanner==3.54.0 \ - --hash=sha256:81987b3fc7d9930e03f51bcb6c6567db62838b00bdfa82aeb708584f0536fc0c \ - --hash=sha256:eef44f1207d6fae52819099cadfb225a19596e6551216831de6cbc245725efe4 +google-cloud-spanner==3.55.0 \ + --hash=sha256:bec170c6619f667cc657e977f87391d76975559be70b155d90a2902613662b3c \ + --hash=sha256:fc9f717b612924f5e9bfae9514aa0d5cd30e6b40e8d472d030f84b16de2c18fe # via -r requirements.in googleapis-common-protos[grpc]==1.70.0 \ --hash=sha256:0e1b44e0ea153e6594f9f394fef15193a68aaaea2d843f83e2742717ca753257 \ From c78ad04dc7a3e1c773bde21ef927d5250f47992d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Knut=20Olav=20L=C3=B8ite?= Date: Mon, 2 Jun 2025 11:02:36 +0200 Subject: [PATCH 5/6] feat: document the use of statement and transaction tags (#676) Add a sample that shows how to use statement and transaction tags with SQLAlchemy. --- samples/noxfile.py | 5 +++ samples/tags_sample.py | 58 ++++++++++++++++++++++++++++++ test/mockserver_tests/test_tags.py | 38 +++++++++++++++++--- 3 files changed, 96 insertions(+), 5 deletions(-) create mode 100644 samples/tags_sample.py diff --git a/samples/noxfile.py b/samples/noxfile.py index 2ea2c37a..8c95f052 100644 --- a/samples/noxfile.py +++ b/samples/noxfile.py @@ -62,6 +62,11 @@ def transaction(session): _sample(session) +@nox.session() +def tags(session): + _sample(session) + + @nox.session() def isolation_level(session): _sample(session) diff --git a/samples/tags_sample.py b/samples/tags_sample.py new file mode 100644 index 00000000..a75bddd5 --- /dev/null +++ b/samples/tags_sample.py @@ -0,0 +1,58 @@ +# Copyright 2025 Google LLC All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import uuid + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session + +from sample_helper import run_sample +from model import Singer + + +# Shows how to transaction tags and statement tags with Spanner and SQLAlchemy. +def tags_sample(): + engine = create_engine( + "spanner:///projects/sample-project/" + "instances/sample-instance/" + "databases/sample-database", + echo=True, + ) + # Set a transaction_tag in the execution options for the session to set + # a transaction tag. + with Session( + engine.execution_options(transaction_tag="my_transaction_tag") + ) as session: + # The transaction that is automatically started by SQLAlchemy will use the + # transaction tag that is specified in the execution options. + + # Execute a query with a request tag. + singer_id = str(uuid.uuid4()) + singer = session.get( + Singer, singer_id, execution_options={"request_tag": "my_tag_1"} + ) + + # Add the singer if it was not found. + if singer is None: + # The session.Add(..) function does not support execution_options, but we can + # set the execution_options on the connection of this session. This will be + # propagated to the next statement that is executed on the connection. + session.connection().execution_options(request_tag="insert_singer") + singer = Singer(id=singer_id, first_name="John", last_name="Doe") + session.add(singer) + session.commit() + + +if __name__ == "__main__": + run_sample(tags_sample) diff --git a/test/mockserver_tests/test_tags.py b/test/mockserver_tests/test_tags.py index c422bc5e..8c157154 100644 --- a/test/mockserver_tests/test_tags.py +++ b/test/mockserver_tests/test_tags.py @@ -65,6 +65,11 @@ def test_transaction_tag(self): from test.mockserver_tests.tags_model import Singer add_singer_query_result("SELECT singers.id, singers.name\n" + "FROM singers") + add_single_singer_query_result( + "SELECT singers.id AS singers_id, singers.name AS singers_name\n" + "FROM singers\n" + "WHERE singers.id = @a0" + ) add_update_count("INSERT INTO singers (id, name) VALUES (@a0, @a1)", 1) engine = create_engine( "spanner:///projects/p/instances/i/databases/d", @@ -75,26 +80,32 @@ def test_transaction_tag(self): engine.execution_options(transaction_tag="my-transaction-tag") ) as session: # Execute a query and an insert statement in a read/write transaction. + session.get(Singer, 1, execution_options={"request_tag": "my-tag-1"}) session.scalars( - select(Singer).execution_options(request_tag="my-tag-1") + select(Singer).execution_options(request_tag="my-tag-2") ).all() + session.connection().execution_options(request_tag="insert-singer") session.add(Singer(id=1, name="Some Singer")) session.commit() # Verify the requests that we got. requests = self.spanner_service.requests - eq_(5, len(requests)) + eq_(6, len(requests)) is_instance_of(requests[0], BatchCreateSessionsRequest) is_instance_of(requests[1], BeginTransactionRequest) is_instance_of(requests[2], ExecuteSqlRequest) is_instance_of(requests[3], ExecuteSqlRequest) - is_instance_of(requests[4], CommitRequest) + is_instance_of(requests[4], ExecuteSqlRequest) + is_instance_of(requests[5], CommitRequest) for request in requests[2:]: eq_("my-transaction-tag", request.request_options.transaction_tag) + eq_("my-tag-1", requests[2].request_options.request_tag) + eq_("my-tag-2", requests[3].request_options.request_tag) + eq_("insert-singer", requests[4].request_options.request_tag) -def add_singer_query_result(sql: str): - result = result_set.ResultSet( +def empty_singer_result_set(): + return result_set.ResultSet( dict( metadata=result_set.ResultSetMetadata( dict( @@ -124,6 +135,10 @@ def add_singer_query_result(sql: str): ), ) ) + + +def add_singer_query_result(sql: str): + result = empty_singer_result_set() result.rows.extend( [ ( @@ -137,3 +152,16 @@ def add_singer_query_result(sql: str): ] ) add_result(sql, result) + + +def add_single_singer_query_result(sql: str): + result = empty_singer_result_set() + result.rows.extend( + [ + ( + "1", + "Jane Doe", + ), + ] + ) + add_result(sql, result) From 5077ddb7e71cf60bdb14cef8d34793ab94acb730 Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Mon, 2 Jun 2025 11:10:10 +0200 Subject: [PATCH 6/6] chore(main): release 1.12.0 (#673) Co-authored-by: release-please[bot] <55107282+release-please[bot]@users.noreply.github.com> --- CHANGELOG.md | 9 +++++++++ version.py | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b857594b..0bfb4ae3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## [1.12.0](https://github.com/googleapis/python-spanner-sqlalchemy/compare/v1.11.1...v1.12.0) (2025-06-02) + + +### Features + +* Document the use of statement and transaction tags ([#676](https://github.com/googleapis/python-spanner-sqlalchemy/issues/676)) ([c78ad04](https://github.com/googleapis/python-spanner-sqlalchemy/commit/c78ad04dc7a3e1c773bde21ef927d5250f47992d)) +* Support database role in connect arguments ([#667](https://github.com/googleapis/python-spanner-sqlalchemy/issues/667)) ([47aa27c](https://github.com/googleapis/python-spanner-sqlalchemy/commit/47aa27c489cb7051cb55468ab4d6b79f8c0ce1f3)) +* Support multi-row inserts ([#671](https://github.com/googleapis/python-spanner-sqlalchemy/issues/671)) ([f5d94cd](https://github.com/googleapis/python-spanner-sqlalchemy/commit/f5d94cd15cba43684fc584072018ab3bc826f457)), closes [#670](https://github.com/googleapis/python-spanner-sqlalchemy/issues/670) + ## [1.11.1](https://github.com/googleapis/python-spanner-sqlalchemy/compare/v1.11.0...v1.11.1) (2025-05-27) diff --git a/version.py b/version.py index 7f878bcb..ecbccc60 100644 --- a/version.py +++ b/version.py @@ -4,4 +4,4 @@ # license that can be found in the LICENSE file or at # https://developers.google.com/open-source/licenses/bsd -__version__ = "1.11.1" +__version__ = "1.12.0"