Skip to content

Commit 77b86f1

Browse files
waltaskewolavloite
andauthored
feat: Add Support for Interleaved Indexes (#762)
fixes: #761 Co-authored-by: Knut Olav Løite <koloite@gmail.com>
1 parent 8d015c6 commit 77b86f1

File tree

4 files changed

+196
-5
lines changed

4 files changed

+196
-5
lines changed

google/cloud/sqlalchemy_spanner/sqlalchemy_spanner.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -719,6 +719,10 @@ def visit_create_index(
719719
[self.preparer.quote(c.name) for c in storing_columns]
720720
)
721721

722+
interleave_in = options.get("interleave_in")
723+
if interleave_in is not None:
724+
text += f", INTERLEAVE IN {self.preparer.quote(interleave_in)}"
725+
722726
if options.get("null_filtered", False):
723727
text = re.sub(
724728
r"(^\s*CREATE\s+(?:UNIQUE\s+)?)INDEX",

samples/model.py

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -95,11 +95,22 @@ class Album(Base):
9595

9696
class Track(Base):
9797
__tablename__ = "tracks"
98-
# This interleaves the table `tracks` in its parent `albums`.
99-
__table_args__ = {
100-
"spanner_interleave_in": "albums",
101-
"spanner_interleave_on_delete_cascade": True,
102-
}
98+
__table_args__ = (
99+
# Use the spanner_interleave_in argument to add an INTERLEAVED IN clause to the index.
100+
# You can read additional details at:
101+
# https://cloud.google.com/spanner/docs/secondary-indexes#indexes_and_interleaving
102+
Index(
103+
"idx_tracks_id_title",
104+
"id",
105+
"title",
106+
spanner_interleave_in="albums",
107+
),
108+
# This interleaves the table `tracks` in its parent `albums`.
109+
{
110+
"spanner_interleave_in": "albums",
111+
"spanner_interleave_on_delete_cascade": True,
112+
},
113+
)
103114
id: Mapped[str] = mapped_column(String(36), primary_key=True)
104115
track_number: Mapped[int] = mapped_column(Integer, primary_key=True)
105116
title: Mapped[str] = mapped_column(String(200), nullable=False)
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 ForeignKey, Index, String
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+
28+
singer_id: Mapped[str] = mapped_column(String(36), primary_key=True)
29+
first_name: Mapped[str]
30+
last_name: Mapped[str]
31+
32+
33+
class Album(Base):
34+
__tablename__ = "albums"
35+
__table_args__ = {
36+
"spanner_interleave_in": "singers",
37+
"spanner_interleave_on_delete_cascade": True,
38+
}
39+
40+
singer_id: Mapped[str] = mapped_column(
41+
ForeignKey("singers.singer_id"), primary_key=True
42+
)
43+
album_id: Mapped[str] = mapped_column(String(36), primary_key=True)
44+
album_title: Mapped[str]
45+
46+
47+
class Track(Base):
48+
__tablename__ = "tracks"
49+
__table_args__ = (
50+
Index(
51+
"idx_name",
52+
"singer_id",
53+
"album_id",
54+
"song_name",
55+
spanner_interleave_in="albums",
56+
),
57+
{
58+
"spanner_interleave_in": "albums",
59+
"spanner_interleave_on_delete_cascade": True,
60+
},
61+
)
62+
63+
singer_id: Mapped[str] = mapped_column(
64+
ForeignKey("singers.singer_id"), primary_key=True
65+
)
66+
album_id: Mapped[str] = mapped_column(
67+
ForeignKey("albums.album_id"), primary_key=True
68+
)
69+
track_id: Mapped[str] = mapped_column(String(36), primary_key=True)
70+
song_name: Mapped[str]
71+
72+
73+
Album.__table__.add_is_dependent_on(Singer.__table__)
74+
Track.__table__.add_is_dependent_on(Album.__table__)
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
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 TestNullFilteredIndex(MockServerTestBase):
29+
"""Ensure we emit correct DDL for not null filtered indexes."""
30+
31+
def test_create_table(self):
32+
from test.mockserver_tests.interleaved_index 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+
add_result(
51+
"""SELECT true
52+
FROM INFORMATION_SCHEMA.TABLES
53+
WHERE TABLE_SCHEMA="" AND TABLE_NAME="tracks"
54+
LIMIT 1
55+
""",
56+
ResultSet(),
57+
)
58+
engine = create_engine(
59+
"spanner:///projects/p/instances/i/databases/d",
60+
connect_args={"client": self.client, "pool": FixedSizePool(size=10)},
61+
)
62+
Base.metadata.create_all(engine)
63+
requests = self.database_admin_service.requests
64+
eq_(1, len(requests))
65+
is_instance_of(requests[0], UpdateDatabaseDdlRequest)
66+
eq_(4, len(requests[0].statements))
67+
eq_(
68+
"CREATE TABLE singers (\n"
69+
"\tsinger_id STRING(36) NOT NULL, \n"
70+
"\tfirst_name STRING(MAX) NOT NULL, \n"
71+
"\tlast_name STRING(MAX) NOT NULL\n"
72+
") PRIMARY KEY (singer_id)",
73+
requests[0].statements[0],
74+
)
75+
eq_(
76+
"CREATE TABLE albums (\n"
77+
"\tsinger_id STRING(36) NOT NULL, \n"
78+
"\talbum_id STRING(36) NOT NULL, \n"
79+
"\talbum_title STRING(MAX) NOT NULL, \n"
80+
"\tFOREIGN KEY(singer_id) REFERENCES singers (singer_id)\n"
81+
") PRIMARY KEY (singer_id, album_id),\n"
82+
"INTERLEAVE IN PARENT singers ON DELETE CASCADE",
83+
requests[0].statements[1],
84+
)
85+
eq_(
86+
"CREATE TABLE tracks (\n"
87+
"\tsinger_id STRING(36) NOT NULL, \n"
88+
"\talbum_id STRING(36) NOT NULL, \n"
89+
"\ttrack_id STRING(36) NOT NULL, \n"
90+
"\tsong_name STRING(MAX) NOT NULL, \n"
91+
"\tFOREIGN KEY(singer_id) REFERENCES singers (singer_id), \n"
92+
"\tFOREIGN KEY(album_id) REFERENCES albums (album_id)\n"
93+
") PRIMARY KEY (singer_id, album_id, track_id),\n"
94+
"INTERLEAVE IN PARENT albums ON DELETE CASCADE",
95+
requests[0].statements[2],
96+
)
97+
eq_(
98+
"CREATE INDEX idx_name ON tracks "
99+
"(singer_id, album_id, song_name), "
100+
"INTERLEAVE IN albums",
101+
requests[0].statements[3],
102+
)

0 commit comments

Comments
 (0)