From a50e4fa4a45c1fdd2812c05f9f39edc28c153797 Mon Sep 17 00:00:00 2001 From: Mend Renovate Date: Fri, 27 Jun 2025 12:16:08 +0200 Subject: [PATCH 1/7] chore(deps): update dependency grpcio to v1.73.1 (#707) --- requirements.txt | 104 +++++++++++++++++++++++------------------------ 1 file changed, 52 insertions(+), 52 deletions(-) diff --git a/requirements.txt b/requirements.txt index 287e4926..8818a8df 100644 --- a/requirements.txt +++ b/requirements.txt @@ -219,58 +219,58 @@ grpc-interceptor==0.15.4 \ --hash=sha256:0035f33228693ed3767ee49d937bac424318db173fef4d2d0170b3215f254d9d \ --hash=sha256:1f45c0bcb58b6f332f37c637632247c9b02bc6af0fdceb7ba7ce8d2ebbfb0926 # via google-cloud-spanner -grpcio==1.73.0 \ - --hash=sha256:068ecc415f79408d57a7f146f54cdf9f0acb4b301a52a9e563973dc981e82f3d \ - --hash=sha256:072d8154b8f74300ed362c01d54af8b93200c1a9077aeaea79828d48598514f1 \ - --hash=sha256:07ad7c57233c2109e4ac999cb9c2710c3b8e3f491a73b058b0ce431f31ed8145 \ - --hash=sha256:085ebe876373ca095e24ced95c8f440495ed0b574c491f7f4f714ff794bbcd10 \ - --hash=sha256:0e092a4b28eefb63eec00d09ef33291cd4c3a0875cde29aec4d11d74434d222c \ - --hash=sha256:0eb5df4f41ea10bda99a802b2a292d85be28958ede2a50f2beb8c7fc9a738419 \ - --hash=sha256:10e8edc035724aba0346a432060fd192b42bd03675d083c01553cab071a28da5 \ - --hash=sha256:12787c791c3993d0ea1cc8bf90393647e9a586066b3b322949365d2772ba965b \ - --hash=sha256:1284850607901cfe1475852d808e5a102133461ec9380bc3fc9ebc0686ee8e32 \ - --hash=sha256:128ba2ebdac41e41554d492b82c34586a90ebd0766f8ebd72160c0e3a57b9155 \ - --hash=sha256:1dd7fa7276dcf061e2d5f9316604499eea06b1b23e34a9380572d74fe59915a8 \ - --hash=sha256:275e23d4c428c26b51857bbd95fcb8e528783597207ec592571e4372b300a29f \ - --hash=sha256:2a9c957dc65e5d474378d7bcc557e9184576605d4b4539e8ead6e351d7ccce20 \ - --hash=sha256:2c17771e884fddf152f2a0df12478e8d02853e5b602a10a9a9f1f52fa02b1d32 \ - --hash=sha256:2d1510c4ea473110cb46a010555f2c1a279d1c256edb276e17fa571ba1e8927c \ - --hash=sha256:33577fe7febffe8ebad458744cfee8914e0c10b09f0ff073a6b149a84df8ab8f \ - --hash=sha256:36bf93f6a657f37c131d9dd2c391b867abf1426a86727c3575393e9e11dadb0d \ - --hash=sha256:38cf518cc54cd0c47c9539cefa8888549fcc067db0b0c66a46535ca8032020c4 \ - --hash=sha256:3902b71407d021163ea93c70c8531551f71ae742db15b66826cf8825707d2908 \ - --hash=sha256:3af4c30918a7f0d39de500d11255f8d9da4f30e94a2033e70fe2a720e184bd8e \ - --hash=sha256:483c507c2328ed0e01bc1adb13d1eada05cc737ec301d8e5a8f4a90f387f1790 \ - --hash=sha256:4dd8d8d092efede7d6f48d695ba2592046acd04ccf421436dd7ed52677a9ad29 \ - --hash=sha256:51036f641f171eebe5fa7aaca5abbd6150f0c338dab3a58f9111354240fe36ec \ - --hash=sha256:60813d8a16420d01fa0da1fc7ebfaaa49a7e5051b0337cd48f4f950eb249a08e \ - --hash=sha256:6228f7eb6d9f785f38b589d49957fca5df3d5b5349e77d2d89b14e390165344c \ - --hash=sha256:6ddc1cfb2240f84d35d559ade18f69dcd4257dbaa5ba0de1a565d903aaab2968 \ - --hash=sha256:70176093d0a95b44d24baa9c034bb67bfe2b6b5f7ebc2836f4093c97010e17fd \ - --hash=sha256:859f70c8e435e8e1fa060e04297c6818ffc81ca9ebd4940e180490958229a45a \ - --hash=sha256:965a16b71a8eeef91fc4df1dc40dc39c344887249174053814f8a8e18449c4c3 \ - --hash=sha256:9ffc972b530bf73ef0f948f799482a1bf12d9b6f33406a8e6387c0ca2098a833 \ - --hash=sha256:a73c72922dfd30b396a5f25bb3a4590195ee45ecde7ee068acb0892d2900cf07 \ - --hash=sha256:b71a7b4483d1f753bbc11089ff0f6fa63b49c97a9cc20552cded3fcad466d23b \ - --hash=sha256:bbf45d59d090bf69f1e4e1594832aaf40aa84b31659af3c5e2c3f6a35202791a \ - --hash=sha256:c0811331b469e3f15dda5f90ab71bcd9681189a83944fd6dc908e2c9249041ef \ - --hash=sha256:c201a34aa960c962d0ce23fe5f423f97e9d4b518ad605eae6d0a82171809caaa \ - --hash=sha256:c98ba1d928a178ce33f3425ff823318040a2b7ef875d30a0073565e5ceb058d9 \ - --hash=sha256:ce953d9d2100e1078a76a9dc2b7338d5415924dc59c69a15bf6e734db8a0f1ca \ - --hash=sha256:cfc556c1d6aef02c727ec7d0016827a73bfe67193e47c546f7cadd3ee6bf1a60 \ - --hash=sha256:d050197eeed50f858ef6c51ab09514856f957dba7b1f7812698260fc9cc417f6 \ - --hash=sha256:d0a1517b2005ba1235a1190b98509264bf72e231215dfeef8db9a5a92868789e \ - --hash=sha256:d12bbb88381ea00bdd92c55aff3da3391fd85bc902c41275c8447b86f036ce0f \ - --hash=sha256:d84000367508ade791d90c2bafbd905574b5ced8056397027a77a215d601ba15 \ - --hash=sha256:da1d677018ef423202aca6d73a8d3b2cb245699eb7f50eb5f74cae15a8e1f724 \ - --hash=sha256:e0084d4559ee3dbdcce9395e1bc90fdd0262529b32c417a39ecbc18da8074ac7 \ - --hash=sha256:e2459a27c6886e7e687e4e407778425f3c6a971fa17a16420227bda39574d64b \ - --hash=sha256:e53007f70d9783f53b41b4cf38ed39a8e348011437e4c287eee7dd1d39d54b2f \ - --hash=sha256:ebb8d5f4b0200916fb292a964a4d41210de92aba9007e33d8551d85800ea16cb \ - --hash=sha256:ebd8d269df64aff092b2cec5e015d8ae09c7e90888b5c35c24fdca719a2c9f35 \ - --hash=sha256:ef5fff73d5f724755693a464d444ee0a448c6cdfd3c1616a9223f736c622617d \ - --hash=sha256:f5cdc332b503c33b1643b12ea933582c7b081957c8bc2ea4cc4bc58054a09288 \ - --hash=sha256:fb9d7c27089d9ba3746f18d2109eb530ef2a37452d2ff50f5a6696cd39167d3b +grpcio==1.73.1 \ + --hash=sha256:052e28fe9c41357da42250a91926a3e2f74c046575c070b69659467ca5aa976b \ + --hash=sha256:07f08705a5505c9b5b0cbcbabafb96462b5a15b7236bbf6bbcc6b0b91e1cbd7e \ + --hash=sha256:0a9f3ea8dce9eae9d7cb36827200133a72b37a63896e0e61a9d5ec7d61a59ab1 \ + --hash=sha256:0ab860d5bfa788c5a021fba264802e2593688cd965d1374d31d2b1a34cacd854 \ + --hash=sha256:105492124828911f85127e4825d1c1234b032cb9d238567876b5515d01151379 \ + --hash=sha256:10af9f2ab98a39f5b6c1896c6fc2036744b5b41d12739d48bed4c3e15b6cf900 \ + --hash=sha256:1c0bf15f629b1497436596b1cbddddfa3234273490229ca29561209778ebe182 \ + --hash=sha256:1c502c2e950fc7e8bf05c047e8a14522ef7babac59abbfde6dbf46b7a0d9c71e \ + --hash=sha256:24e06a5319e33041e322d32c62b1e728f18ab8c9dbc91729a3d9f9e3ed336642 \ + --hash=sha256:277b426a0ed341e8447fbf6c1d6b68c952adddf585ea4685aa563de0f03df887 \ + --hash=sha256:2d70f4ddd0a823436c2624640570ed6097e40935c9194482475fe8e3d9754d55 \ + --hash=sha256:303c8135d8ab176f8038c14cc10d698ae1db9c480f2b2823f7a987aa2a4c5646 \ + --hash=sha256:3841a8a5a66830261ab6a3c2a3dc539ed84e4ab019165f77b3eeb9f0ba621f26 \ + --hash=sha256:42f0660bce31b745eb9d23f094a332d31f210dcadd0fc8e5be7e4c62a87ce86b \ + --hash=sha256:45cf17dcce5ebdb7b4fe9e86cb338fa99d7d1bb71defc78228e1ddf8d0de8cbb \ + --hash=sha256:4a68f8c9966b94dff693670a5cf2b54888a48a5011c5d9ce2295a1a1465ee84f \ + --hash=sha256:5b9b1805a7d61c9e90541cbe8dfe0a593dfc8c5c3a43fe623701b6a01b01d710 \ + --hash=sha256:610e19b04f452ba6f402ac9aa94eb3d21fbc94553368008af634812c4a85a99e \ + --hash=sha256:628c30f8e77e0258ab788750ec92059fc3d6628590fb4b7cea8c102503623ed7 \ + --hash=sha256:65b0458a10b100d815a8426b1442bd17001fdb77ea13665b2f7dc9e8587fdc6b \ + --hash=sha256:67a0468256c9db6d5ecb1fde4bf409d016f42cef649323f0a08a72f352d1358b \ + --hash=sha256:686231cdd03a8a8055f798b2b54b19428cdf18fa1549bee92249b43607c42668 \ + --hash=sha256:68b84d65bbdebd5926eb5c53b0b9ec3b3f83408a30e4c20c373c5337b4219ec5 \ + --hash=sha256:6957025a4608bb0a5ff42abd75bfbb2ed99eda29d5992ef31d691ab54b753643 \ + --hash=sha256:6a2b372e65fad38842050943f42ce8fee00c6f2e8ea4f7754ba7478d26a356ee \ + --hash=sha256:6a6037891cd2b1dd1406b388660522e1565ed340b1fea2955b0234bdd941a862 \ + --hash=sha256:6abfc0f9153dc4924536f40336f88bd4fe7bd7494f028675e2e04291b8c2c62a \ + --hash=sha256:75fc8e543962ece2f7ecd32ada2d44c0c8570ae73ec92869f9af8b944863116d \ + --hash=sha256:7fce2cd1c0c1116cf3850564ebfc3264fba75d3c74a7414373f1238ea365ef87 \ + --hash=sha256:83a6c2cce218e28f5040429835fa34a29319071079e3169f9543c3fbeff166d2 \ + --hash=sha256:89018866a096e2ce21e05eabed1567479713ebe57b1db7cbb0f1e3b896793ba4 \ + --hash=sha256:8f5a6df3fba31a3485096ac85b2e34b9666ffb0590df0cd044f58694e6a1f6b5 \ + --hash=sha256:921b25618b084e75d424a9f8e6403bfeb7abef074bb6c3174701e0f2542debcf \ + --hash=sha256:96c112333309493c10e118d92f04594f9055774757f5d101b39f8150f8c25582 \ + --hash=sha256:ad1d958c31cc91ab050bd8a91355480b8e0683e21176522bacea225ce51163f2 \ + --hash=sha256:ad5c958cc3d98bb9d71714dc69f1c13aaf2f4b53e29d4cc3f1501ef2e4d129b2 \ + --hash=sha256:b310824ab5092cf74750ebd8a8a8981c1810cb2b363210e70d06ef37ad80d4f9 \ + --hash=sha256:b3215f69a0670a8cfa2ab53236d9e8026bfb7ead5d4baabe7d7dc11d30fda967 \ + --hash=sha256:b4adc97d2d7f5c660a5498bda978ebb866066ad10097265a5da0511323ae9f50 \ + --hash=sha256:ba2cea9f7ae4bc21f42015f0ec98f69ae4179848ad744b210e7685112fa507a1 \ + --hash=sha256:bc5eccfd9577a5dc7d5612b2ba90cca4ad14c6d949216c68585fdec9848befb1 \ + --hash=sha256:c45a28a0cfb6ddcc7dc50a29de44ecac53d115c3388b2782404218db51cb2df3 \ + --hash=sha256:c54796ca22b8349cc594d18b01099e39f2b7ffb586ad83217655781a350ce4da \ + --hash=sha256:cce7265b9617168c2d08ae570fcc2af4eaf72e84f8c710ca657cc546115263af \ + --hash=sha256:d60588ab6ba0ac753761ee0e5b30a29398306401bfbceffe7d68ebb21193f9d4 \ + --hash=sha256:d74c3f4f37b79e746271aa6cdb3a1d7e4432aea38735542b23adcabaaee0c097 \ + --hash=sha256:dc7d7fd520614fce2e6455ba89791458020a39716951c7c07694f9dbae28e9c0 \ + --hash=sha256:de18769aea47f18e782bf6819a37c1c528914bfd5683b8782b9da356506190c8 \ + --hash=sha256:ed451a0e39c8e51eb1612b78686839efd1a920666d1666c1adfdb4fd51680c0f \ + --hash=sha256:f43ffb3bd415c57224c7427bfb9e6c46a0b6e998754bfa0d00f408e1873dcbb5 \ + --hash=sha256:f48e862aed925ae987eb7084409a80985de75243389dc9d9c271dd711e589918 # via # google-api-core # googleapis-common-protos From af79305554ddda79d4c35fa0cf8a6cc7ec022972 Mon Sep 17 00:00:00 2001 From: Mend Renovate Date: Fri, 27 Jun 2025 12:32:20 +0200 Subject: [PATCH 2/7] chore(deps): update dependency grpcio-status to v1.73.1 (#708) --- requirements.txt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/requirements.txt b/requirements.txt index 8818a8df..a4f6d24f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -277,9 +277,9 @@ grpcio==1.73.1 \ # grpc-google-iam-v1 # grpc-interceptor # grpcio-status -grpcio-status==1.73.0 \ - --hash=sha256:a2b7f430568217f884fe52a5a0133b6f4c9338beae33fb5370134a8eaf58f974 \ - --hash=sha256:a3f3a9994b44c364f014e806114ba44cc52e50c426779f958c8b22f14ff0d892 +grpcio-status==1.73.1 \ + --hash=sha256:538595c32a6c819c32b46a621a51e9ae4ffcd7e7e1bce35f728ef3447e9809b6 \ + --hash=sha256:928f49ccf9688db5f20cd9e45c4578a1d01ccca29aeaabf066f2ac76aa886668 # via google-api-core idna==3.10 \ --hash=sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9 \ From bcb8144724274841272191337ee7c196b3897cad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Knut=20Olav=20L=C3=B8ite?= Date: Fri, 27 Jun 2025 12:49:33 +0200 Subject: [PATCH 3/7] chore: make the fk query joins consistent (#703) --- google/cloud/sqlalchemy_spanner/sqlalchemy_spanner.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/google/cloud/sqlalchemy_spanner/sqlalchemy_spanner.py b/google/cloud/sqlalchemy_spanner/sqlalchemy_spanner.py index aa6ee2b5..3c603824 100644 --- a/google/cloud/sqlalchemy_spanner/sqlalchemy_spanner.py +++ b/google/cloud/sqlalchemy_spanner/sqlalchemy_spanner.py @@ -1481,11 +1481,11 @@ def get_multi_foreign_keys( ) FROM information_schema.table_constraints AS tc JOIN information_schema.constraint_column_usage AS ccu - ON ccu.table_catalog = tc.table_catalog + ON ccu.constraint_catalog = tc.table_catalog and ccu.constraint_schema = tc.table_schema and ccu.constraint_name = tc.constraint_name JOIN information_schema.constraint_table_usage AS ctu - ON ctu.table_catalog = tc.table_catalog + ON ctu.constraint_catalog = tc.table_catalog and ctu.constraint_schema = tc.table_schema and ctu.constraint_name = tc.constraint_name JOIN information_schema.key_column_usage AS kcu From 17e3abaf063c68f70e5dbf8ef455cf140d89c328 Mon Sep 17 00:00:00 2001 From: Walt Askew Date: Fri, 27 Jun 2025 08:35:31 -0400 Subject: [PATCH 4/7] feat: Support Server-Side Checks for Enums (#694) Spanner uses protos for enums. Creating a column like Column("an_enum", Enum("A", "B", "C")) will result in a String column. Setting supports_native_enum to False allows SQLAlchemy to generate check constraints to enforce the enum values if the create_constraint=True flag is passed to the Enum constructor. Fixes: #686 --- google/cloud/sqlalchemy_spanner/sqlalchemy_spanner.py | 7 ++++++- test/mockserver_tests/test_basics.py | 6 +++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/google/cloud/sqlalchemy_spanner/sqlalchemy_spanner.py b/google/cloud/sqlalchemy_spanner/sqlalchemy_spanner.py index 3c603824..e59f26d8 100644 --- a/google/cloud/sqlalchemy_spanner/sqlalchemy_spanner.py +++ b/google/cloud/sqlalchemy_spanner/sqlalchemy_spanner.py @@ -807,10 +807,15 @@ class SpannerDialect(DefaultDialect): supports_sequences = True sequences_optional = False supports_identity_columns = True - supports_native_enum = True supports_native_boolean = True supports_native_decimal = True supports_statement_cache = True + # Spanner uses protos for enums. Creating a column like + # Column("an_enum", Enum("A", "B", "C")) will result in a String + # column. Setting supports_native_enum to False allows SQLAlchemy + # to generate check constraints to enforce the enum values if the + # create_constraint=True flag is passed to the Enum constructor. + supports_native_enum = False postfetch_lastrowid = False insert_returning = True diff --git a/test/mockserver_tests/test_basics.py b/test/mockserver_tests/test_basics.py index 3e422885..7d40e874 100644 --- a/test/mockserver_tests/test_basics.py +++ b/test/mockserver_tests/test_basics.py @@ -27,6 +27,7 @@ func, text, BigInteger, + Enum, ) from sqlalchemy.orm import Session, DeclarativeBase, Mapped, mapped_column from sqlalchemy.testing import eq_, is_instance_of @@ -120,6 +121,7 @@ def test_create_table(self): metadata, Column("user_id", Integer, primary_key=True), Column("user_name", String(16), nullable=False), + Column("status", Enum("a", "b", "cee", create_constraint=True)), ) metadata.create_all(engine) requests = self.database_admin_service.requests @@ -130,7 +132,9 @@ def test_create_table(self): "CREATE TABLE users (\n" "\tuser_id INT64 NOT NULL " "GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), \n" - "\tuser_name STRING(16) NOT NULL\n" + "\tuser_name STRING(16) NOT NULL, \n" + "\tstatus STRING(3), \n" + "\tCHECK (status IN ('a', 'b', 'cee'))\n" ") PRIMARY KEY (user_id)", requests[0].statements[0], ) From 82bb8ed583a5fd91c8a10fb73c85a6a5f45269f6 Mon Sep 17 00:00:00 2001 From: Walt Askew Date: Fri, 27 Jun 2025 08:35:42 -0400 Subject: [PATCH 5/7] feat: support commit timestamp option (#697) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: support commit timestamp option Add support for columns with commit timestamps: https://cloud.google.com/spanner/docs/commit-timestamp Fixes: #695 * chore: use Singer in sample --------- Co-authored-by: Knut Olav Løite --- README.rst | 16 +++++ .../sqlalchemy_spanner/sqlalchemy_spanner.py | 5 ++ samples/model.py | 17 +++++ .../commit_timestamp_model.py | 32 ++++++++++ .../mockserver_tests/test_commit_timestamp.py | 64 +++++++++++++++++++ test/system/test_basics.py | 58 ++++++++++++++++- 6 files changed, 191 insertions(+), 1 deletion(-) create mode 100644 test/mockserver_tests/commit_timestamp_model.py create mode 100644 test/mockserver_tests/test_commit_timestamp.py diff --git a/README.rst b/README.rst index 3e08970f..ae8a3109 100644 --- a/README.rst +++ b/README.rst @@ -234,6 +234,22 @@ tables with this feature, make sure to call ``add_is_dependent_on()`` on the child table to request SQLAlchemy to create the parent table before the child table. +Commit timestamps +~~~~~~~~~~~~~~~~~~ + +The dialect offers the ``spanner_allow_commit_timestamp`` option to +column constructors for creating commit timestamp columns. + +.. code:: python + + Table( + "table", + metadata, + Column("last_update_time", DateTime, spanner_allow_commit_timestamp=True), + ) + +`See this documentation page for more details `__. + Unique constraints ~~~~~~~~~~~~~~~~~~ diff --git a/google/cloud/sqlalchemy_spanner/sqlalchemy_spanner.py b/google/cloud/sqlalchemy_spanner/sqlalchemy_spanner.py index e59f26d8..c34ec0b0 100644 --- a/google/cloud/sqlalchemy_spanner/sqlalchemy_spanner.py +++ b/google/cloud/sqlalchemy_spanner/sqlalchemy_spanner.py @@ -578,6 +578,11 @@ def get_column_specification(self, column, **kwargs): elif hasattr(column, "computed") and column.computed is not None: colspec += " " + self.process(column.computed) + if column.dialect_options.get("spanner", {}).get( + "allow_commit_timestamp", False + ): + colspec += " OPTIONS (allow_commit_timestamp=true)" + return colspec def visit_computed_column(self, generated, **kw): diff --git a/samples/model.py b/samples/model.py index 2b231ca6..c7c68301 100644 --- a/samples/model.py +++ b/samples/model.py @@ -33,6 +33,8 @@ TextClause, Index, PickleType, + text, + event, ) from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship from google.cloud.sqlalchemy_spanner.sqlalchemy_spanner import SpannerPickleType @@ -177,3 +179,18 @@ class TicketSale(Base): DateTime, nullable=False ) singer_id: Mapped[str] = mapped_column(String(36), ForeignKey("singers.id")) + # Create a commit timestamp column and set a client-side default of + # PENDING_COMMIT_TIMESTAMP() An event handler below is responsible for + # setting PENDING_COMMIT_TIMESTAMP() on updates. If using SQLAlchemy + # core rather than the ORM, callers will need to supply their own + # PENDING_COMMIT_TIMESTAMP() values in their inserts & updates. + last_update_time: Mapped[datetime.datetime] = mapped_column( + spanner_allow_commit_timestamp=True, + default=text("PENDING_COMMIT_TIMESTAMP()"), + ) + + +@event.listens_for(TicketSale, "before_update") +def ticket_sale_before_update(mapper, connection, target): + """Updates the commit timestamp when the row is updated.""" + target.last_update_time = text("PENDING_COMMIT_TIMESTAMP()") diff --git a/test/mockserver_tests/commit_timestamp_model.py b/test/mockserver_tests/commit_timestamp_model.py new file mode 100644 index 00000000..28c58b86 --- /dev/null +++ b/test/mockserver_tests/commit_timestamp_model.py @@ -0,0 +1,32 @@ +# 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 datetime + +from sqlalchemy.orm import DeclarativeBase +from sqlalchemy.orm import Mapped +from sqlalchemy.orm import mapped_column + + +class Base(DeclarativeBase): + pass + + +class Singer(Base): + __tablename__ = "singers" + id: Mapped[str] = mapped_column(primary_key=True) + name: Mapped[str] + updated_at: Mapped[datetime.datetime] = mapped_column( + spanner_allow_commit_timestamp=True + ) diff --git a/test/mockserver_tests/test_commit_timestamp.py b/test/mockserver_tests/test_commit_timestamp.py new file mode 100644 index 00000000..3872bde1 --- /dev/null +++ b/test/mockserver_tests/test_commit_timestamp.py @@ -0,0 +1,64 @@ +# 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. + +from sqlalchemy import create_engine +from sqlalchemy.testing import eq_, is_instance_of +from google.cloud.spanner_v1 import ( + FixedSizePool, + ResultSet, +) +from test.mockserver_tests.mock_server_test_base import ( + MockServerTestBase, + add_result, +) +from google.cloud.spanner_admin_database_v1 import UpdateDatabaseDdlRequest + + +class TestCommitTimestamp(MockServerTestBase): + def test_create_table(self): + from test.mockserver_tests.commit_timestamp_model import Base + + add_result( + """SELECT true +FROM INFORMATION_SCHEMA.TABLES +WHERE TABLE_SCHEMA="" AND TABLE_NAME="singers" +LIMIT 1 +""", + ResultSet(), + ) + add_result( + """SELECT true + FROM INFORMATION_SCHEMA.SEQUENCES + WHERE NAME="singer_id" + AND SCHEMA="" + LIMIT 1""", + ResultSet(), + ) + engine = create_engine( + "spanner:///projects/p/instances/i/databases/d", + connect_args={"client": self.client, "pool": FixedSizePool(size=10)}, + ) + Base.metadata.create_all(engine) + requests = self.database_admin_service.requests + eq_(1, len(requests)) + is_instance_of(requests[0], UpdateDatabaseDdlRequest) + eq_(1, len(requests[0].statements)) + eq_( + "CREATE TABLE singers (\n" + "\tid STRING(MAX) NOT NULL, \n" + "\tname STRING(MAX) NOT NULL, \n" + "\tupdated_at TIMESTAMP NOT NULL OPTIONS (allow_commit_timestamp=true)\n" + ") PRIMARY KEY (id)", + requests[0].statements[0], + ) diff --git a/test/system/test_basics.py b/test/system/test_basics.py index bb2ae9a8..7ea6fa2b 100644 --- a/test/system/test_basics.py +++ b/test/system/test_basics.py @@ -11,6 +11,7 @@ # 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 datetime import os from typing import Optional from sqlalchemy import ( @@ -29,10 +30,11 @@ select, update, delete, + event, ) from sqlalchemy.orm import Session, DeclarativeBase, Mapped, mapped_column from sqlalchemy.types import REAL -from sqlalchemy.testing import eq_, is_true +from sqlalchemy.testing import eq_, is_true, is_not_none from sqlalchemy.testing.plugin.plugin_base import fixtures @@ -300,3 +302,57 @@ def test_cross_schema_fk_lookups(self, connection): filter_names=["number_colors"], schema="schema" ), ) + + def test_commit_timestamp(self, connection): + """Ensures commit timestamps are set.""" + + class Base(DeclarativeBase): + pass + + class TimestampUser(Base): + __tablename__ = "timestamp_users" + ID: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] + updated_at: Mapped[datetime.datetime] = mapped_column( + spanner_allow_commit_timestamp=True, + default=text("PENDING_COMMIT_TIMESTAMP()"), + ) + + @event.listens_for(TimestampUser, "before_update") + def before_update(mapper, connection, target): + target.updated_at = text("PENDING_COMMIT_TIMESTAMP()") + + engine = connection.engine + Base.metadata.create_all(engine) + try: + with Session(engine) as session: + session.add(TimestampUser(name="name")) + session.commit() + + with Session(engine) as session: + users = list( + session.scalars( + select(TimestampUser).where(TimestampUser.name == "name") + ) + ) + user = users[0] + + is_not_none(user.updated_at) + created_at = user.updated_at + + user.name = "new-name" + session.commit() + + with Session(engine) as session: + users = list( + session.scalars( + select(TimestampUser).where(TimestampUser.name == "new-name") + ) + ) + user = users[0] + + is_not_none(user.updated_at) + is_true(user.updated_at > created_at) + + finally: + Base.metadata.drop_all(engine) From 4f86eb38181508dbf43bbc8128b843e204b4a39c Mon Sep 17 00:00:00 2001 From: Walt Askew Date: Fri, 27 Jun 2025 08:35:53 -0400 Subject: [PATCH 6/7] bug: don't rollback non-spanner connections on reset (#709) This is the simplest way to address bug #706 and address the `AttributeError: 'Connection' object has no attribute 'rollback'` I'm seeing when connections from BigQuery engines get caught by this event handler. I think we should be able to rely on SQLAlchemy's [reset on return](https://docs.sqlalchemy.org/en/20/core/pooling.html#reset-on-return) behaviour to rollback transactions when they're reset rather than call rollback ourselves. That behaviour is also something end users can configure, so it'd be good to respect their settings if they disable the behaviour. Fixes: #706 --- google/cloud/sqlalchemy_spanner/sqlalchemy_spanner.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/google/cloud/sqlalchemy_spanner/sqlalchemy_spanner.py b/google/cloud/sqlalchemy_spanner/sqlalchemy_spanner.py index c34ec0b0..d868daf9 100644 --- a/google/cloud/sqlalchemy_spanner/sqlalchemy_spanner.py +++ b/google/cloud/sqlalchemy_spanner/sqlalchemy_spanner.py @@ -71,8 +71,6 @@ def reset_connection(dbapi_conn, connection_record, reset_state=None): dbapi_conn.staleness = None dbapi_conn.read_only = False - else: - dbapi_conn.rollback() # register a method to get a single value of a JSON object From 170379fc295a4bf0f32e049f70849fb0d43ef596 Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Fri, 27 Jun 2025 15:37:15 +0200 Subject: [PATCH 7/7] chore(main): release 1.14.0 (#710) Co-authored-by: release-please[bot] <55107282+release-please[bot]@users.noreply.github.com> --- CHANGELOG.md | 7 +++++++ google/cloud/sqlalchemy_spanner/version.py | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 44e1517b..fc7918b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [1.14.0](https://github.com/googleapis/python-spanner-sqlalchemy/compare/v1.13.1...v1.14.0) (2025-06-27) + + +### Features + +* Support commit timestamp option ([#697](https://github.com/googleapis/python-spanner-sqlalchemy/issues/697)) ([82bb8ed](https://github.com/googleapis/python-spanner-sqlalchemy/commit/82bb8ed583a5fd91c8a10fb73c85a6a5f45269f6)) + ## [1.13.1](https://github.com/googleapis/python-spanner-sqlalchemy/compare/v1.13.0...v1.13.1) (2025-06-20) diff --git a/google/cloud/sqlalchemy_spanner/version.py b/google/cloud/sqlalchemy_spanner/version.py index 7628b0ec..af1f26ba 100644 --- a/google/cloud/sqlalchemy_spanner/version.py +++ b/google/cloud/sqlalchemy_spanner/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.13.1" +__version__ = "1.14.0"