Skip to content

Commit 5b2da81

Browse files
fix(sqlalchemy-spanner): fix get_multi_indexes crash on SEARCH indexes (#17907)
Thank you for opening a Pull Request! Before submitting your PR, there are a few things you can do to make sure it goes smoothly: - [ ] Make sure to open an issue as a [bug/issue](https://github.com/googleapis/google-cloud-python/issues) before writing your code! That way we can discuss the change, evaluate designs, and agree on the general idea - [ ] Ensure the tests and linter pass - [ ] Code coverage does not decrease (if any source code was changed) - [ ] Appropriate docs were updated (if necessary) Fixes #<issue_number_goes_here> 🦕
1 parent 011d192 commit 5b2da81

4 files changed

Lines changed: 111 additions & 5 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
[run]
2+
branch = True
3+
source = google/cloud/sqlalchemy_spanner
4+
5+
[report]
6+
fail_under = 30

packages/sqlalchemy-spanner/google/cloud/sqlalchemy_spanner/sqlalchemy_spanner.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -426,8 +426,10 @@ def returning_clause(self, stmt, returning_cols, **kw):
426426
)
427427
for c in expression._select_iterables(
428428
filter(
429-
lambda col: not col.dialect_options.get("spanner", {}).get(
430-
"exclude_from_returning", False
429+
lambda col: (
430+
not col.dialect_options.get("spanner", {}).get(
431+
"exclude_from_returning", False
432+
)
431433
),
432434
returning_cols,
433435
)
@@ -1300,6 +1302,7 @@ def get_multi_indexes(
13001302
{table_type_query}
13011303
{schema_filter_query}
13021304
i.index_type != 'PRIMARY_KEY'
1305+
AND i.index_type != 'SEARCH'
13031306
AND i.spanner_is_managed = FALSE
13041307
GROUP BY i.table_catalog, i.table_schema, i.table_name,
13051308
i.index_name, i.is_unique
@@ -1324,7 +1327,9 @@ def get_multi_indexes(
13241327
"column_names": row[3],
13251328
"unique": row[4],
13261329
"column_sorting": {
1327-
col: order.lower() for col, order in zip(row[3], row[5])
1330+
col: order.lower()
1331+
for col, order in zip(row[3], row[5] or [])
1332+
if order
13281333
},
13291334
"include_columns": include_columns if include_columns else [],
13301335
"dialect_options": dialect_options,

packages/sqlalchemy-spanner/noxfile.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ class = StreamHandler
8585
UNIT_TEST_STANDARD_DEPENDENCIES = [
8686
"mock",
8787
"pytest",
88+
"pytest-cov",
8889
]
8990

9091
UNIT_TEST_EXTERNAL_DEPENDENCIES = [
@@ -361,14 +362,22 @@ def unit(session, test_type):
361362
return
362363

363364
if test_type == "unit":
364-
# Run SQLAlchemy dialect compliance test suite with OpenTelemetry.
365+
# Run SQLAlchemy dialect unit tests with pytest-cov if COVERAGE_FILE is set.
365366
session.install(
366367
*UNIT_TEST_STANDARD_DEPENDENCIES,
367368
*UNIT_TEST_EXTERNAL_DEPENDENCIES,
368369
*UNIT_TEST_DEPENDENCIES,
369370
)
370371
session.install(".")
371-
session.run("py.test", "--quiet", os.path.join("tests/unit"), *session.posargs)
372+
pytest_args = ["--quiet", os.path.join("tests/unit")]
373+
if "COVERAGE_FILE" in os.environ:
374+
pytest_args.extend(
375+
[
376+
"--cov=google.cloud.sqlalchemy_spanner",
377+
"--cov-config=.coveragerc",
378+
]
379+
)
380+
session.run("py.test", *pytest_args, *session.posargs)
372381
return
373382

374383

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
# Copyright 2026 Google LLC
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 unittest.mock import MagicMock
16+
from sqlalchemy.testing import eq_
17+
from sqlalchemy.testing.plugin.plugin_base import fixtures
18+
from google.cloud.sqlalchemy_spanner.sqlalchemy_spanner import SpannerDialect
19+
20+
21+
class TestSpannerDialect(fixtures.TestBase):
22+
def test_get_multi_indexes_excludes_search_indexes_sql(self):
23+
"""Test that get_multi_indexes SQL query excludes SEARCH indexes."""
24+
dialect = SpannerDialect()
25+
connection = MagicMock()
26+
mock_snapshot = MagicMock()
27+
mock_snapshot.execute_sql.return_value = []
28+
connection.connection.database.snapshot.return_value.__enter__.return_value = (
29+
mock_snapshot
30+
)
31+
32+
dialect.get_multi_indexes(connection)
33+
34+
# Retrieve the SQL executed by snapshot
35+
executed_sql = mock_snapshot.execute_sql.call_args[0][0]
36+
assert "i.index_type != 'SEARCH'" in executed_sql
37+
38+
def test_get_multi_indexes_handles_none_column_ordering(self):
39+
"""Test get_multi_indexes with None column ordering."""
40+
dialect = SpannerDialect()
41+
connection = MagicMock()
42+
mock_snapshot = MagicMock()
43+
# Mock row: schema, table, index_name, columns,
44+
# is_unique, column_orderings, storing_columns
45+
mock_row = [
46+
"public",
47+
"my_table",
48+
"idx_search",
49+
["col1"],
50+
False,
51+
[None], # column_ordering is None
52+
[],
53+
]
54+
mock_snapshot.execute_sql.return_value = [mock_row]
55+
connection.connection.database.snapshot.return_value.__enter__.return_value = (
56+
mock_snapshot
57+
)
58+
59+
res = dialect.get_multi_indexes(connection)
60+
assert ("public", "my_table") in res
61+
index_info = res[("public", "my_table")][0]
62+
eq_(index_info["column_sorting"], {})
63+
64+
def test_get_multi_indexes_handles_null_column_orderings_array(self):
65+
"""Test get_multi_indexes when column_orderings array is None."""
66+
dialect = SpannerDialect()
67+
connection = MagicMock()
68+
mock_snapshot = MagicMock()
69+
mock_row = [
70+
"public",
71+
"my_table",
72+
"idx_test",
73+
["col1"],
74+
False,
75+
None, # row[5] is None
76+
[],
77+
]
78+
mock_snapshot.execute_sql.return_value = [mock_row]
79+
connection.connection.database.snapshot.return_value.__enter__.return_value = (
80+
mock_snapshot
81+
)
82+
83+
res = dialect.get_multi_indexes(connection)
84+
assert ("public", "my_table") in res
85+
index_info = res[("public", "my_table")][0]
86+
eq_(index_info["column_sorting"], {})

0 commit comments

Comments
 (0)