diff --git a/google/cloud/sqlalchemy_spanner/sqlalchemy_spanner.py b/google/cloud/sqlalchemy_spanner/sqlalchemy_spanner.py index 4ab387a9..78cd4fab 100644 --- a/google/cloud/sqlalchemy_spanner/sqlalchemy_spanner.py +++ b/google/cloud/sqlalchemy_spanner/sqlalchemy_spanner.py @@ -719,6 +719,10 @@ def visit_create_index( [self.preparer.quote(c.name) for c in storing_columns] ) + interleave_in = options.get("interleave_in") + if interleave_in is not None: + text += f", INTERLEAVE IN {self.preparer.quote(interleave_in)}" + if options.get("null_filtered", False): text = re.sub( r"(^\s*CREATE\s+(?:UNIQUE\s+)?)INDEX", diff --git a/samples/model.py b/samples/model.py index faa8a535..a4af86e4 100644 --- a/samples/model.py +++ b/samples/model.py @@ -95,11 +95,22 @@ class Album(Base): class Track(Base): __tablename__ = "tracks" - # This interleaves the table `tracks` in its parent `albums`. - __table_args__ = { - "spanner_interleave_in": "albums", - "spanner_interleave_on_delete_cascade": True, - } + __table_args__ = ( + # Use the spanner_interleave_in argument to add an INTERLEAVED IN clause to the index. + # You can read additional details at: + # https://cloud.google.com/spanner/docs/secondary-indexes#indexes_and_interleaving + Index( + "idx_tracks_id_title", + "id", + "title", + spanner_interleave_in="albums", + ), + # This interleaves the table `tracks` in its parent `albums`. + { + "spanner_interleave_in": "albums", + "spanner_interleave_on_delete_cascade": True, + }, + ) id: Mapped[str] = mapped_column(String(36), primary_key=True) track_number: Mapped[int] = mapped_column(Integer, primary_key=True) title: Mapped[str] = mapped_column(String(200), nullable=False) diff --git a/test/mockserver_tests/interleaved_index.py b/test/mockserver_tests/interleaved_index.py new file mode 100644 index 00000000..7e59b8e1 --- /dev/null +++ b/test/mockserver_tests/interleaved_index.py @@ -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 ForeignKey, Index, String +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" + + singer_id: Mapped[str] = mapped_column(String(36), primary_key=True) + first_name: Mapped[str] + last_name: Mapped[str] + + +class Album(Base): + __tablename__ = "albums" + __table_args__ = { + "spanner_interleave_in": "singers", + "spanner_interleave_on_delete_cascade": True, + } + + singer_id: Mapped[str] = mapped_column( + ForeignKey("singers.singer_id"), primary_key=True + ) + album_id: Mapped[str] = mapped_column(String(36), primary_key=True) + album_title: Mapped[str] + + +class Track(Base): + __tablename__ = "tracks" + __table_args__ = ( + Index( + "idx_name", + "singer_id", + "album_id", + "song_name", + spanner_interleave_in="albums", + ), + { + "spanner_interleave_in": "albums", + "spanner_interleave_on_delete_cascade": True, + }, + ) + + singer_id: Mapped[str] = mapped_column( + ForeignKey("singers.singer_id"), primary_key=True + ) + album_id: Mapped[str] = mapped_column( + ForeignKey("albums.album_id"), primary_key=True + ) + track_id: Mapped[str] = mapped_column(String(36), primary_key=True) + song_name: Mapped[str] + + +Album.__table__.add_is_dependent_on(Singer.__table__) +Track.__table__.add_is_dependent_on(Album.__table__) diff --git a/test/mockserver_tests/test_interleaved_index.py b/test/mockserver_tests/test_interleaved_index.py new file mode 100644 index 00000000..198f6431 --- /dev/null +++ b/test/mockserver_tests/test_interleaved_index.py @@ -0,0 +1,102 @@ +# 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 TestNullFilteredIndex(MockServerTestBase): + """Ensure we emit correct DDL for not null filtered indexes.""" + + def test_create_table(self): + from test.mockserver_tests.interleaved_index 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(), + ) + add_result( + """SELECT true +FROM INFORMATION_SCHEMA.TABLES +WHERE TABLE_SCHEMA="" AND TABLE_NAME="tracks" +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_(4, len(requests[0].statements)) + eq_( + "CREATE TABLE singers (\n" + "\tsinger_id STRING(36) NOT NULL, \n" + "\tfirst_name STRING(MAX) NOT NULL, \n" + "\tlast_name STRING(MAX) NOT NULL\n" + ") PRIMARY KEY (singer_id)", + requests[0].statements[0], + ) + eq_( + "CREATE TABLE albums (\n" + "\tsinger_id STRING(36) NOT NULL, \n" + "\talbum_id STRING(36) NOT NULL, \n" + "\talbum_title STRING(MAX) NOT NULL, \n" + "\tFOREIGN KEY(singer_id) REFERENCES singers (singer_id)\n" + ") PRIMARY KEY (singer_id, album_id),\n" + "INTERLEAVE IN PARENT singers ON DELETE CASCADE", + requests[0].statements[1], + ) + eq_( + "CREATE TABLE tracks (\n" + "\tsinger_id STRING(36) NOT NULL, \n" + "\talbum_id STRING(36) NOT NULL, \n" + "\ttrack_id STRING(36) NOT NULL, \n" + "\tsong_name STRING(MAX) NOT NULL, \n" + "\tFOREIGN KEY(singer_id) REFERENCES singers (singer_id), \n" + "\tFOREIGN KEY(album_id) REFERENCES albums (album_id)\n" + ") PRIMARY KEY (singer_id, album_id, track_id),\n" + "INTERLEAVE IN PARENT albums ON DELETE CASCADE", + requests[0].statements[2], + ) + eq_( + "CREATE INDEX idx_name ON tracks " + "(singer_id, album_id, song_name), " + "INTERLEAVE IN albums", + requests[0].statements[3], + )