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

Commit 0e9334a

Browse files
committed
chore: create runnable sample
1 parent e3222d6 commit 0e9334a

5 files changed

Lines changed: 120 additions & 48 deletions

File tree

samples/informational_fk.py

Lines changed: 0 additions & 39 deletions
This file was deleted.

samples/informational_fk_sample.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
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(Concert, ("CH", datetime.datetime(2024, 11, 7, 19, 30, 0), singer_id))
82+
print("Ticket sale found: {}\nSinger found: {}\nConcert found: {}\n"
83+
.format(ticket_sale is not None,
84+
singer is not None,
85+
concert is not None))
86+
87+
if __name__ == "__main__":
88+
run_sample(informational_fk_sample)

samples/model.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ 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(back_populates="concert", passive_deletes=True)
158158

159159

160160
class TicketSale(Base):
@@ -163,6 +163,7 @@ class TicketSale(Base):
163163
ForeignKeyConstraint(
164164
["venue_code", "start_time", "singer_id"],
165165
["concerts.venue_code", "concerts.start_time", "concerts.singer_id"],
166+
spanner_not_enforced=True,
166167
),
167168
)
168169
id: Mapped[int] = mapped_column(
@@ -178,7 +179,10 @@ class TicketSale(Base):
178179
start_time: Mapped[Optional[datetime.datetime]] = mapped_column(
179180
DateTime, nullable=False
180181
)
181-
singer_id: Mapped[str] = mapped_column(String(36), ForeignKey("singers.id"))
182+
# Create an informational foreign key that is not enforced by Spanner.
183+
# See https://cloud.google.com/spanner/docs/foreign-keys/overview#informational-foreign-keys
184+
# for more information.
185+
singer_id: Mapped[str] = mapped_column(String(36), ForeignKey("singers.id", spanner_not_enforced=True))
182186
# Create a commit timestamp column and set a client-side default of
183187
# PENDING_COMMIT_TIMESTAMP() An event handler below is responsible for
184188
# 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: 21 additions & 7 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()
@@ -50,7 +58,7 @@ def start_emulator() -> (DockerContainer, str):
5058
emulator.start()
5159
wait_for_logs(emulator, "gRPC server listening at 0.0.0.0:9010")
5260
port = emulator.get_exposed_port(9010)
53-
_create_instance_and_database(port)
61+
_create_instance_and_database(str(port))
5462
return emulator, port
5563

5664

@@ -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

0 commit comments

Comments
 (0)