Skip to content

Commit c565ae1

Browse files
waltaskewolavloite
andauthored
feat: support informational foreign keys (googleapis#719)
* feat: support informational foreign keys Add support for informational foreign keys with the 'not enforced' keyword: https://cloud.google.com/spanner/docs/foreign-keys/overview#create-table-with-informational-fk Fixes googleapis#718 * chore: create runnable sample --------- Co-authored-by: Knut Olav Løite <koloite@gmail.com>
1 parent 8f2e97e commit c565ae1

File tree

7 files changed

+244
-8
lines changed

7 files changed

+244
-8
lines changed

google/cloud/sqlalchemy_spanner/sqlalchemy_spanner.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -652,6 +652,12 @@ def visit_unique_constraint(self, constraint, **kw):
652652
"Create UNIQUE indexes instead."
653653
)
654654

655+
def visit_foreign_key_constraint(self, constraint, **kw):
656+
text = super().visit_foreign_key_constraint(constraint, **kw)
657+
if constraint.dialect_options.get("spanner", {}).get("not_enforced", False):
658+
text += " NOT ENFORCED"
659+
return text
660+
655661
def post_create_table(self, table):
656662
"""Build statements to be executed after CREATE TABLE.
657663

samples/informational_fk_sample.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
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+
import uuid
17+
18+
from sqlalchemy import create_engine
19+
from sqlalchemy.orm import Session
20+
21+
from sample_helper import run_sample
22+
from model import Singer, Concert, Venue, TicketSale
23+
24+
25+
# Shows how to create a non-enforced foreign key.
26+
#
27+
# The TicketSale model contains two foreign keys that are not enforced by Spanner.
28+
# This allows the related records to be deleted without the need to delete the
29+
# corresponding TicketSale record.
30+
#
31+
# __table_args__ = (
32+
# ForeignKeyConstraint(
33+
# ["venue_code", "start_time", "singer_id"],
34+
# ["concerts.venue_code", "concerts.start_time", "concerts.singer_id"],
35+
# spanner_not_enforced=True,
36+
# ),
37+
# )
38+
# singer_id: Mapped[str] = mapped_column(String(36), ForeignKey("singers.id", spanner_not_enforced=True))
39+
#
40+
# See https://cloud.google.com/spanner/docs/foreign-keys/overview#informational-foreign-keys
41+
# for more information on informational foreign key constrains.
42+
def informational_fk_sample():
43+
engine = create_engine(
44+
"spanner:///projects/sample-project/"
45+
"instances/sample-instance/"
46+
"databases/sample-database",
47+
echo=True,
48+
)
49+
# First create a singer, venue, concert and ticket_sale.
50+
singer_id = str(uuid.uuid4())
51+
ticket_sale_id = None
52+
with Session(engine) as session:
53+
singer = Singer(id=singer_id, first_name="John", last_name="Doe")
54+
venue = Venue(code="CH", name="Concert Hall", active=True)
55+
concert = Concert(
56+
venue=venue,
57+
start_time=datetime.datetime(2024, 11, 7, 19, 30, 0),
58+
singer=singer,
59+
title="John Doe - Live in Concert Hall",
60+
)
61+
ticket_sale = TicketSale(
62+
concert=concert, customer_name="Alice Doe", seats=["A010", "A011", "A012"]
63+
)
64+
session.add_all([singer, venue, concert, ticket_sale])
65+
session.commit()
66+
ticket_sale_id = ticket_sale.id
67+
68+
# Now delete both the singer and concert that are referenced by the ticket_sale record.
69+
# This is possible as the foreign key constraints between ticket_sales and singers/concerts
70+
# are not enforced.
71+
with Session(engine) as session:
72+
session.delete(concert)
73+
session.delete(singer)
74+
session.commit()
75+
76+
# Verify that the ticket_sale record still exists, while the concert and singer have been
77+
# deleted.
78+
with Session(engine) as session:
79+
ticket_sale = session.get(TicketSale, ticket_sale_id)
80+
singer = session.get(Singer, singer_id)
81+
concert = session.get(
82+
Concert, ("CH", datetime.datetime(2024, 11, 7, 19, 30, 0), singer_id)
83+
)
84+
print(
85+
"Ticket sale found: {}\nSinger found: {}\nConcert found: {}\n".format(
86+
ticket_sale is not None, singer is not None, concert is not None
87+
)
88+
)
89+
90+
91+
if __name__ == "__main__":
92+
run_sample(informational_fk_sample)

samples/model.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,9 @@ class Concert(Base):
154154
title: Mapped[str] = mapped_column(String(200), nullable=False)
155155
singer: Mapped["Singer"] = relationship(back_populates="concerts")
156156
venue: Mapped["Venue"] = relationship(back_populates="concerts")
157-
ticket_sales: Mapped[List["TicketSale"]] = relationship(back_populates="concert")
157+
ticket_sales: Mapped[List["TicketSale"]] = relationship(
158+
back_populates="concert", passive_deletes=True
159+
)
158160

159161

160162
class TicketSale(Base):
@@ -163,6 +165,7 @@ class TicketSale(Base):
163165
ForeignKeyConstraint(
164166
["venue_code", "start_time", "singer_id"],
165167
["concerts.venue_code", "concerts.start_time", "concerts.singer_id"],
168+
spanner_not_enforced=True,
166169
),
167170
)
168171
id: Mapped[int] = mapped_column(
@@ -178,7 +181,12 @@ class TicketSale(Base):
178181
start_time: Mapped[Optional[datetime.datetime]] = mapped_column(
179182
DateTime, nullable=False
180183
)
181-
singer_id: Mapped[str] = mapped_column(String(36), ForeignKey("singers.id"))
184+
# Create an informational foreign key that is not enforced by Spanner.
185+
# See https://cloud.google.com/spanner/docs/foreign-keys/overview#informational-foreign-keys
186+
# for more information.
187+
singer_id: Mapped[str] = mapped_column(
188+
String(36), ForeignKey("singers.id", spanner_not_enforced=True)
189+
)
182190
# Create a commit timestamp column and set a client-side default of
183191
# PENDING_COMMIT_TIMESTAMP() An event handler below is responsible for
184192
# setting PENDING_COMMIT_TIMESTAMP() on updates. If using SQLAlchemy

samples/noxfile.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,11 @@ def database_role(session):
8787
_sample(session)
8888

8989

90+
@nox.session()
91+
def informational_fk(session):
92+
_sample(session)
93+
94+
9095
@nox.session()
9196
def _all_samples(session):
9297
_sample(session)

samples/sample_helper.py

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,10 @@
1616
from typing import Callable
1717

1818
from google.api_core.client_options import ClientOptions
19+
from google.api_core.exceptions import AlreadyExists
1920
from google.auth.credentials import AnonymousCredentials
2021
from google.cloud.spanner_v1 import Client
22+
from google.cloud.spanner_v1.database import Database
2123
from sqlalchemy import create_engine
2224
from sqlalchemy.dialects import registry
2325
from testcontainers.core.container import DockerContainer
@@ -32,9 +34,15 @@ def run_sample(sample_method: Callable):
3234
"google.cloud.sqlalchemy_spanner.sqlalchemy_spanner",
3335
"SpannerDialect",
3436
)
35-
os.environ["SPANNER_EMULATOR_HOST"] = ""
36-
emulator, port = start_emulator()
37-
os.environ["SPANNER_EMULATOR_HOST"] = "localhost:" + str(port)
37+
emulator = None
38+
if os.getenv("USE_EXISTING_EMULATOR") == "true":
39+
if os.getenv("SPANNER_EMULATOR_HOST") is None:
40+
os.environ["SPANNER_EMULATOR_HOST"] = "localhost:9010"
41+
_create_instance_and_database("9010")
42+
else:
43+
os.environ["SPANNER_EMULATOR_HOST"] = ""
44+
emulator, port = start_emulator()
45+
os.environ["SPANNER_EMULATOR_HOST"] = "localhost:" + str(port)
3846
try:
3947
_create_tables()
4048
sample_method()
@@ -68,10 +76,16 @@ def _create_instance_and_database(port: str):
6876
database_id = "sample-database"
6977

7078
instance = client.instance(instance_id, instance_config)
71-
created_op = instance.create()
72-
created_op.result(1800) # block until completion
79+
try:
80+
created_op = instance.create()
81+
created_op.result(1800) # block until completion
82+
except AlreadyExists:
83+
# Ignore
84+
print("Using existing instance")
7385

74-
database = instance.database(database_id)
86+
database: Database = instance.database(database_id)
87+
if database.exists():
88+
database.drop()
7589
created_op = database.create()
7690
created_op.result(1800)
7791

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
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 ForeignKey
16+
from sqlalchemy.orm import DeclarativeBase
17+
from sqlalchemy.orm import Mapped
18+
from sqlalchemy.orm import mapped_column
19+
20+
21+
class Base(DeclarativeBase):
22+
pass
23+
24+
25+
class Singer(Base):
26+
__tablename__ = "singers"
27+
id: Mapped[str] = mapped_column(primary_key=True)
28+
name: Mapped[str]
29+
30+
31+
class Album(Base):
32+
__tablename__ = "albums"
33+
id: Mapped[str] = mapped_column(primary_key=True)
34+
name: Mapped[str]
35+
singer_id: Mapped[str] = mapped_column(
36+
ForeignKey("singers.id", spanner_not_enforced=True)
37+
)
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
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 TestNotEnforcedFK(MockServerTestBase):
29+
"""Ensure we emit correct DDL for not enforced foreign keys."""
30+
31+
def test_create_table(self):
32+
from test.mockserver_tests.not_enforced_fk_model import Base
33+
34+
add_result(
35+
"""SELECT true
36+
FROM INFORMATION_SCHEMA.TABLES
37+
WHERE TABLE_SCHEMA="" AND TABLE_NAME="singers"
38+
LIMIT 1
39+
""",
40+
ResultSet(),
41+
)
42+
add_result(
43+
"""SELECT true
44+
FROM INFORMATION_SCHEMA.TABLES
45+
WHERE TABLE_SCHEMA="" AND TABLE_NAME="albums"
46+
LIMIT 1
47+
""",
48+
ResultSet(),
49+
)
50+
engine = create_engine(
51+
"spanner:///projects/p/instances/i/databases/d",
52+
connect_args={"client": self.client, "pool": FixedSizePool(size=10)},
53+
)
54+
Base.metadata.create_all(engine)
55+
requests = self.database_admin_service.requests
56+
eq_(1, len(requests))
57+
is_instance_of(requests[0], UpdateDatabaseDdlRequest)
58+
eq_(2, len(requests[0].statements))
59+
eq_(
60+
"CREATE TABLE singers (\n"
61+
"\tid STRING(MAX) NOT NULL, \n"
62+
"\tname STRING(MAX) NOT NULL\n"
63+
") PRIMARY KEY (id)",
64+
requests[0].statements[0],
65+
)
66+
eq_(
67+
"CREATE TABLE albums (\n"
68+
"\tid STRING(MAX) NOT NULL, \n"
69+
"\tname STRING(MAX) NOT NULL, \n"
70+
"\tsinger_id STRING(MAX) NOT NULL, \n"
71+
"\tFOREIGN KEY(singer_id) REFERENCES singers (id) NOT ENFORCED\n"
72+
") PRIMARY KEY (id)",
73+
requests[0].statements[1],
74+
)

0 commit comments

Comments
 (0)