Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions google/cloud/sqlalchemy_spanner/sqlalchemy_spanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -652,6 +652,12 @@ def visit_unique_constraint(self, constraint, **kw):
"Create UNIQUE indexes instead."
)

def visit_foreign_key_constraint(self, constraint, **kw):
text = super().visit_foreign_key_constraint(constraint, **kw)
if constraint.dialect_options.get("spanner", {}).get("not_enforced", False):
text += " NOT ENFORCED"
return text

def post_create_table(self, table):
"""Build statements to be executed after CREATE TABLE.

Expand Down
92 changes: 92 additions & 0 deletions samples/informational_fk_sample.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# 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
import uuid

from sqlalchemy import create_engine
from sqlalchemy.orm import Session

from sample_helper import run_sample
from model import Singer, Concert, Venue, TicketSale


# Shows how to create a non-enforced foreign key.
#
# The TicketSale model contains two foreign keys that are not enforced by Spanner.
# This allows the related records to be deleted without the need to delete the
# corresponding TicketSale record.
#
# __table_args__ = (
# ForeignKeyConstraint(
# ["venue_code", "start_time", "singer_id"],
# ["concerts.venue_code", "concerts.start_time", "concerts.singer_id"],
# spanner_not_enforced=True,
# ),
# )
# singer_id: Mapped[str] = mapped_column(String(36), ForeignKey("singers.id", spanner_not_enforced=True))
#
# See https://cloud.google.com/spanner/docs/foreign-keys/overview#informational-foreign-keys
# for more information on informational foreign key constrains.
def informational_fk_sample():
engine = create_engine(
"spanner:///projects/sample-project/"
"instances/sample-instance/"
"databases/sample-database",
echo=True,
)
# First create a singer, venue, concert and ticket_sale.
singer_id = str(uuid.uuid4())
ticket_sale_id = None
with Session(engine) as session:
singer = Singer(id=singer_id, first_name="John", last_name="Doe")
venue = Venue(code="CH", name="Concert Hall", active=True)
concert = Concert(
venue=venue,
start_time=datetime.datetime(2024, 11, 7, 19, 30, 0),
singer=singer,
title="John Doe - Live in Concert Hall",
)
ticket_sale = TicketSale(
concert=concert, customer_name="Alice Doe", seats=["A010", "A011", "A012"]
)
session.add_all([singer, venue, concert, ticket_sale])
session.commit()
ticket_sale_id = ticket_sale.id

# Now delete both the singer and concert that are referenced by the ticket_sale record.
# This is possible as the foreign key constraints between ticket_sales and singers/concerts
# are not enforced.
with Session(engine) as session:
session.delete(concert)
session.delete(singer)
session.commit()

# Verify that the ticket_sale record still exists, while the concert and singer have been
# deleted.
with Session(engine) as session:
ticket_sale = session.get(TicketSale, ticket_sale_id)
singer = session.get(Singer, singer_id)
concert = session.get(
Concert, ("CH", datetime.datetime(2024, 11, 7, 19, 30, 0), singer_id)
)
print(
"Ticket sale found: {}\nSinger found: {}\nConcert found: {}\n".format(
ticket_sale is not None, singer is not None, concert is not None
)
)


if __name__ == "__main__":
run_sample(informational_fk_sample)
12 changes: 10 additions & 2 deletions samples/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,9 @@ class Concert(Base):
title: Mapped[str] = mapped_column(String(200), nullable=False)
singer: Mapped["Singer"] = relationship(back_populates="concerts")
venue: Mapped["Venue"] = relationship(back_populates="concerts")
ticket_sales: Mapped[List["TicketSale"]] = relationship(back_populates="concert")
ticket_sales: Mapped[List["TicketSale"]] = relationship(
back_populates="concert", passive_deletes=True
)


class TicketSale(Base):
Expand All @@ -163,6 +165,7 @@ class TicketSale(Base):
ForeignKeyConstraint(
["venue_code", "start_time", "singer_id"],
["concerts.venue_code", "concerts.start_time", "concerts.singer_id"],
spanner_not_enforced=True,
),
)
id: Mapped[int] = mapped_column(
Expand All @@ -178,7 +181,12 @@ class TicketSale(Base):
start_time: Mapped[Optional[datetime.datetime]] = mapped_column(
DateTime, nullable=False
)
singer_id: Mapped[str] = mapped_column(String(36), ForeignKey("singers.id"))
# Create an informational foreign key that is not enforced by Spanner.
# See https://cloud.google.com/spanner/docs/foreign-keys/overview#informational-foreign-keys
# for more information.
singer_id: Mapped[str] = mapped_column(
String(36), ForeignKey("singers.id", spanner_not_enforced=True)
)
# 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
Expand Down
5 changes: 5 additions & 0 deletions samples/noxfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,11 @@ def database_role(session):
_sample(session)


@nox.session()
def informational_fk(session):
_sample(session)


@nox.session()
def _all_samples(session):
_sample(session)
Expand Down
26 changes: 20 additions & 6 deletions samples/sample_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@
from typing import Callable

from google.api_core.client_options import ClientOptions
from google.api_core.exceptions import AlreadyExists
from google.auth.credentials import AnonymousCredentials
from google.cloud.spanner_v1 import Client
from google.cloud.spanner_v1.database import Database
from sqlalchemy import create_engine
from sqlalchemy.dialects import registry
from testcontainers.core.container import DockerContainer
Expand All @@ -32,9 +34,15 @@ def run_sample(sample_method: Callable):
"google.cloud.sqlalchemy_spanner.sqlalchemy_spanner",
"SpannerDialect",
)
os.environ["SPANNER_EMULATOR_HOST"] = ""
emulator, port = start_emulator()
os.environ["SPANNER_EMULATOR_HOST"] = "localhost:" + str(port)
emulator = None
if os.getenv("USE_EXISTING_EMULATOR") == "true":
if os.getenv("SPANNER_EMULATOR_HOST") is None:
os.environ["SPANNER_EMULATOR_HOST"] = "localhost:9010"
_create_instance_and_database("9010")
else:
os.environ["SPANNER_EMULATOR_HOST"] = ""
emulator, port = start_emulator()
os.environ["SPANNER_EMULATOR_HOST"] = "localhost:" + str(port)
try:
_create_tables()
sample_method()
Expand Down Expand Up @@ -68,10 +76,16 @@ def _create_instance_and_database(port: str):
database_id = "sample-database"

instance = client.instance(instance_id, instance_config)
created_op = instance.create()
created_op.result(1800) # block until completion
try:
created_op = instance.create()
created_op.result(1800) # block until completion
except AlreadyExists:
# Ignore
print("Using existing instance")

database = instance.database(database_id)
database: Database = instance.database(database_id)
if database.exists():
database.drop()
created_op = database.create()
created_op.result(1800)

Expand Down
37 changes: 37 additions & 0 deletions test/mockserver_tests/not_enforced_fk_model.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# 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 ForeignKey
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]


class Album(Base):
__tablename__ = "albums"
id: Mapped[str] = mapped_column(primary_key=True)
name: Mapped[str]
singer_id: Mapped[str] = mapped_column(
ForeignKey("singers.id", spanner_not_enforced=True)
)
74 changes: 74 additions & 0 deletions test/mockserver_tests/test_not_enforced_fk.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# 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 TestNotEnforcedFK(MockServerTestBase):
"""Ensure we emit correct DDL for not enforced foreign keys."""

def test_create_table(self):
from test.mockserver_tests.not_enforced_fk_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.TABLES
WHERE TABLE_SCHEMA="" AND TABLE_NAME="albums"
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_(2, len(requests[0].statements))
eq_(
"CREATE TABLE singers (\n"
"\tid STRING(MAX) NOT NULL, \n"
"\tname STRING(MAX) NOT NULL\n"
") PRIMARY KEY (id)",
requests[0].statements[0],
)
eq_(
"CREATE TABLE albums (\n"
"\tid STRING(MAX) NOT NULL, \n"
"\tname STRING(MAX) NOT NULL, \n"
"\tsinger_id STRING(MAX) NOT NULL, \n"
"\tFOREIGN KEY(singer_id) REFERENCES singers (id) NOT ENFORCED\n"
") PRIMARY KEY (id)",
requests[0].statements[1],
)