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

Commit 7390c80

Browse files
committed
feat: support commit timestamp option
Add support for columns with commit timestamps: https://cloud.google.com/spanner/docs/commit-timestamp Fixes: #695
1 parent 0f40a16 commit 7390c80

6 files changed

Lines changed: 191 additions & 1 deletion

File tree

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
@@ -75,6 +77,21 @@ class Singer(Base):
7577
concerts: Mapped[List["Concert"]] = relationship(
7678
back_populates="singer", cascade="all, delete-orphan"
7779
)
80+
# Create a commit timestamp column and set a client-side default of
81+
# PENDING_COMMIT_TIMESTAMP() An event handler below is responsible for
82+
# setting PENDING_COMMIT_TIMESTAMP() on updates. If using SQLAlchemy
83+
# core rather than the ORM, callers will need to supply their own
84+
# PENDING_COMMIT_TIMESTAMP() values in their inserts & updates.
85+
last_update_time: Mapped[datetime.datetime] = mapped_column(
86+
spanner_allow_commit_timestamp=True,
87+
default=text("PENDING_COMMIT_TIMESTAMP()"),
88+
)
89+
90+
91+
@event.listens_for(TimestampUser, "before_update")
92+
def singer_before_update(mapper, connection, target):
93+
"""Updates the commit timestamp when the row is updated."""
94+
target.updated_at = text("PENDING_COMMIT_TIMESTAMP()")
7895

7996

8097
class Album(Base):
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 (
@@ -27,10 +28,11 @@
2728
select,
2829
update,
2930
delete,
31+
event,
3032
)
3133
from sqlalchemy.orm import Session, DeclarativeBase, Mapped, mapped_column
3234
from sqlalchemy.types import REAL
33-
from sqlalchemy.testing import eq_, is_true
35+
from sqlalchemy.testing import eq_, is_true, is_not_none
3436
from sqlalchemy.testing.plugin.plugin_base import fixtures
3537

3638

@@ -238,3 +240,57 @@ class User(Base):
238240

239241
eq_(len(inserted_rows), len(selected_rows))
240242
eq_(set(inserted_rows), set(selected_rows))
243+
244+
def test_commit_timestamp(self, connection):
245+
"""Ensures commit timestamps are set."""
246+
247+
class Base(DeclarativeBase):
248+
pass
249+
250+
class TimestampUser(Base):
251+
__tablename__ = "timestamp_users"
252+
ID: Mapped[int] = mapped_column(primary_key=True)
253+
name: Mapped[str]
254+
updated_at: Mapped[datetime.datetime] = mapped_column(
255+
spanner_allow_commit_timestamp=True,
256+
default=text("PENDING_COMMIT_TIMESTAMP()"),
257+
)
258+
259+
@event.listens_for(TimestampUser, "before_update")
260+
def before_update(mapper, connection, target):
261+
target.updated_at = text("PENDING_COMMIT_TIMESTAMP()")
262+
263+
engine = connection.engine
264+
Base.metadata.create_all(engine)
265+
try:
266+
with Session(engine) as session:
267+
session.add(TimestampUser(name="name"))
268+
session.commit()
269+
270+
with Session(engine) as session:
271+
users = list(
272+
session.scalars(
273+
select(TimestampUser).where(TimestampUser.name == "name")
274+
)
275+
)
276+
user = users[0]
277+
278+
is_not_none(user.updated_at)
279+
created_at = user.updated_at
280+
281+
user.name = "new-name"
282+
session.commit()
283+
284+
with Session(engine) as session:
285+
users = list(
286+
session.scalars(
287+
select(TimestampUser).where(TimestampUser.name == "new-name")
288+
)
289+
)
290+
user = users[0]
291+
292+
is_not_none(user.updated_at)
293+
is_true(user.updated_at > created_at)
294+
295+
finally:
296+
Base.metadata.drop_all(engine)

0 commit comments

Comments
 (0)