Skip to content

Commit 82bb8ed

Browse files
waltaskewolavloite
andauthored
feat: support commit timestamp option (#697)
* 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 <[email protected]>
1 parent 17e3aba commit 82bb8ed

File tree

6 files changed

+191
-1
lines changed

6 files changed

+191
-1
lines changed

README.rst

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,22 @@ tables with this feature, make sure to call ``add_is_dependent_on()`` on
234234
the child table to request SQLAlchemy to create the parent table before
235235
the child table.
236236

237+
Commit timestamps
238+
~~~~~~~~~~~~~~~~~~
239+
240+
The dialect offers the ``spanner_allow_commit_timestamp`` option to
241+
column constructors for creating commit timestamp columns.
242+
243+
.. code:: python
244+
245+
Table(
246+
"table",
247+
metadata,
248+
Column("last_update_time", DateTime, spanner_allow_commit_timestamp=True),
249+
)
250+
251+
`See this documentation page for more details <https://cloud.google.com/spanner/docs/commit-timestamp>`__.
252+
237253
Unique constraints
238254
~~~~~~~~~~~~~~~~~~
239255

google/cloud/sqlalchemy_spanner/sqlalchemy_spanner.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -578,6 +578,11 @@ def get_column_specification(self, column, **kwargs):
578578
elif hasattr(column, "computed") and column.computed is not None:
579579
colspec += " " + self.process(column.computed)
580580

581+
if column.dialect_options.get("spanner", {}).get(
582+
"allow_commit_timestamp", False
583+
):
584+
colspec += " OPTIONS (allow_commit_timestamp=true)"
585+
581586
return colspec
582587

583588
def visit_computed_column(self, generated, **kw):

samples/model.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@
3333
TextClause,
3434
Index,
3535
PickleType,
36+
text,
37+
event,
3638
)
3739
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
3840
from google.cloud.sqlalchemy_spanner.sqlalchemy_spanner import SpannerPickleType
@@ -177,3 +179,18 @@ class TicketSale(Base):
177179
DateTime, nullable=False
178180
)
179181
singer_id: Mapped[str] = mapped_column(String(36), ForeignKey("singers.id"))
182+
# Create a commit timestamp column and set a client-side default of
183+
# PENDING_COMMIT_TIMESTAMP() An event handler below is responsible for
184+
# setting PENDING_COMMIT_TIMESTAMP() on updates. If using SQLAlchemy
185+
# core rather than the ORM, callers will need to supply their own
186+
# PENDING_COMMIT_TIMESTAMP() values in their inserts & updates.
187+
last_update_time: Mapped[datetime.datetime] = mapped_column(
188+
spanner_allow_commit_timestamp=True,
189+
default=text("PENDING_COMMIT_TIMESTAMP()"),
190+
)
191+
192+
193+
@event.listens_for(TicketSale, "before_update")
194+
def ticket_sale_before_update(mapper, connection, target):
195+
"""Updates the commit timestamp when the row is updated."""
196+
target.last_update_time = text("PENDING_COMMIT_TIMESTAMP()")
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
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 datetime
16+
17+
from sqlalchemy.orm import DeclarativeBase
18+
from sqlalchemy.orm import Mapped
19+
from sqlalchemy.orm import mapped_column
20+
21+
22+
class Base(DeclarativeBase):
23+
pass
24+
25+
26+
class Singer(Base):
27+
__tablename__ = "singers"
28+
id: Mapped[str] = mapped_column(primary_key=True)
29+
name: Mapped[str]
30+
updated_at: Mapped[datetime.datetime] = mapped_column(
31+
spanner_allow_commit_timestamp=True
32+
)
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
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.testing import eq_, is_instance_of
17+
from google.cloud.spanner_v1 import (
18+
FixedSizePool,
19+
ResultSet,
20+
)
21+
from test.mockserver_tests.mock_server_test_base import (
22+
MockServerTestBase,
23+
add_result,
24+
)
25+
from google.cloud.spanner_admin_database_v1 import UpdateDatabaseDdlRequest
26+
27+
28+
class TestCommitTimestamp(MockServerTestBase):
29+
def test_create_table(self):
30+
from test.mockserver_tests.commit_timestamp_model import Base
31+
32+
add_result(
33+
"""SELECT true
34+
FROM INFORMATION_SCHEMA.TABLES
35+
WHERE TABLE_SCHEMA="" AND TABLE_NAME="singers"
36+
LIMIT 1
37+
""",
38+
ResultSet(),
39+
)
40+
add_result(
41+
"""SELECT true
42+
FROM INFORMATION_SCHEMA.SEQUENCES
43+
WHERE NAME="singer_id"
44+
AND SCHEMA=""
45+
LIMIT 1""",
46+
ResultSet(),
47+
)
48+
engine = create_engine(
49+
"spanner:///projects/p/instances/i/databases/d",
50+
connect_args={"client": self.client, "pool": FixedSizePool(size=10)},
51+
)
52+
Base.metadata.create_all(engine)
53+
requests = self.database_admin_service.requests
54+
eq_(1, len(requests))
55+
is_instance_of(requests[0], UpdateDatabaseDdlRequest)
56+
eq_(1, len(requests[0].statements))
57+
eq_(
58+
"CREATE TABLE singers (\n"
59+
"\tid STRING(MAX) NOT NULL, \n"
60+
"\tname STRING(MAX) NOT NULL, \n"
61+
"\tupdated_at TIMESTAMP NOT NULL OPTIONS (allow_commit_timestamp=true)\n"
62+
") PRIMARY KEY (id)",
63+
requests[0].statements[0],
64+
)

test/system/test_basics.py

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
14+
import datetime
1415
import os
1516
from typing import Optional
1617
from sqlalchemy import (
@@ -29,10 +30,11 @@
2930
select,
3031
update,
3132
delete,
33+
event,
3234
)
3335
from sqlalchemy.orm import Session, DeclarativeBase, Mapped, mapped_column
3436
from sqlalchemy.types import REAL
35-
from sqlalchemy.testing import eq_, is_true
37+
from sqlalchemy.testing import eq_, is_true, is_not_none
3638
from sqlalchemy.testing.plugin.plugin_base import fixtures
3739

3840

@@ -300,3 +302,57 @@ def test_cross_schema_fk_lookups(self, connection):
300302
filter_names=["number_colors"], schema="schema"
301303
),
302304
)
305+
306+
def test_commit_timestamp(self, connection):
307+
"""Ensures commit timestamps are set."""
308+
309+
class Base(DeclarativeBase):
310+
pass
311+
312+
class TimestampUser(Base):
313+
__tablename__ = "timestamp_users"
314+
ID: Mapped[int] = mapped_column(primary_key=True)
315+
name: Mapped[str]
316+
updated_at: Mapped[datetime.datetime] = mapped_column(
317+
spanner_allow_commit_timestamp=True,
318+
default=text("PENDING_COMMIT_TIMESTAMP()"),
319+
)
320+
321+
@event.listens_for(TimestampUser, "before_update")
322+
def before_update(mapper, connection, target):
323+
target.updated_at = text("PENDING_COMMIT_TIMESTAMP()")
324+
325+
engine = connection.engine
326+
Base.metadata.create_all(engine)
327+
try:
328+
with Session(engine) as session:
329+
session.add(TimestampUser(name="name"))
330+
session.commit()
331+
332+
with Session(engine) as session:
333+
users = list(
334+
session.scalars(
335+
select(TimestampUser).where(TimestampUser.name == "name")
336+
)
337+
)
338+
user = users[0]
339+
340+
is_not_none(user.updated_at)
341+
created_at = user.updated_at
342+
343+
user.name = "new-name"
344+
session.commit()
345+
346+
with Session(engine) as session:
347+
users = list(
348+
session.scalars(
349+
select(TimestampUser).where(TimestampUser.name == "new-name")
350+
)
351+
)
352+
user = users[0]
353+
354+
is_not_none(user.updated_at)
355+
is_true(user.updated_at > created_at)
356+
357+
finally:
358+
Base.metadata.drop_all(engine)

0 commit comments

Comments
 (0)