Skip to content

Commit 704006e

Browse files
fix(spanner, sqlalchemy-spanner): fix reflection crashes, add native UUID support, and improve JsonObject
- Exclude SEARCH indexes and guard None column_sorting in SpannerDialect.get_multi_indexes to prevent reflection AttributeError crashes. - Register TOKENLIST in _type_map to enable table reflection for TOKENLIST columns without KeyError. - Add native UUID support in SpannerDialect (_type_map, _type_map_inv, SpannerDDLCompiler.visit_UUID, SpannerTypeCompiler.visit_UUID/visit_uuid) while preserving STRING(36) backward compatibility. - Fix spanner_storing column resolution in SpannerDDLCompiler.visit_create_index for unbound columns in Alembic batch mode. - Add to_python() method and public properties (is_null, is_array, is_scalar) to JsonObject in google-cloud-spanner. - Add unit tests in test_dialect.py and mockserver integration tests in test_dialect_integration.py.
1 parent 4f21b8b commit 704006e

5 files changed

Lines changed: 274 additions & 8 deletions

File tree

packages/google-cloud-spanner/google/cloud/spanner_v1/data_types.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,37 @@ def __init__(self, *args, **kwargs):
5757
if not self._is_null:
5858
super(JsonObject, self).__init__(*args, **kwargs)
5959

60+
@property
61+
def is_null(self):
62+
"""Return True if JsonObject represents JSON null."""
63+
return self._is_null
64+
65+
@property
66+
def is_array(self):
67+
"""Return True if JsonObject represents a JSON array."""
68+
return self._is_array
69+
70+
@property
71+
def is_scalar(self):
72+
"""Return True if JsonObject represents a JSON scalar value."""
73+
return self._is_scalar_value
74+
75+
def to_python(self):
76+
"""Return unwrapped native Python object representation (dict, list, scalar, or None)."""
77+
if self._is_null:
78+
return None
79+
if self._is_array:
80+
return [
81+
item.to_python() if isinstance(item, JsonObject) else item
82+
for item in self._array_value
83+
]
84+
if self._is_scalar_value:
85+
return self._simple_value
86+
return {
87+
k: (v.to_python() if isinstance(v, JsonObject) else v)
88+
for k, v in self.items()
89+
}
90+
6091
def __repr__(self):
6192
if self._is_array:
6293
return str(self._array_value)

packages/google-cloud-spanner/tests/unit/test_datatypes.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,3 +96,26 @@ def test_w_JsonObject_of_list_of_simple_JsonData(self):
9696
expected = json.dumps(data, sort_keys=True, separators=(",", ":"))
9797
data_jsonobject = JsonObject(JsonObject(data))
9898
self.assertEqual(data_jsonobject.serialize(), expected)
99+
100+
def test_to_python_dict(self):
101+
obj = JsonObject({"a": 1, "b": [2, 3]})
102+
self.assertFalse(obj.is_null)
103+
self.assertFalse(obj.is_array)
104+
self.assertFalse(obj.is_scalar)
105+
self.assertEqual(obj.to_python(), {"a": 1, "b": [2, 3]})
106+
107+
def test_to_python_array(self):
108+
obj = JsonObject([{"a": 1}, 2, "str"])
109+
self.assertFalse(obj.is_null)
110+
self.assertTrue(obj.is_array)
111+
self.assertFalse(obj.is_scalar)
112+
self.assertEqual(obj.to_python(), [{"a": 1}, 2, "str"])
113+
114+
def test_to_python_scalar_and_null(self):
115+
scalar_obj = JsonObject("hello")
116+
self.assertTrue(scalar_obj.is_scalar)
117+
self.assertEqual(scalar_obj.to_python(), "hello")
118+
119+
null_obj = JsonObject(None)
120+
self.assertTrue(null_obj.is_null)
121+
self.assertIsNone(null_obj.to_python())

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

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,8 @@ def process(value):
120120
"TIMESTAMP": types.TIMESTAMP,
121121
"ARRAY": types.ARRAY,
122122
"JSON": types.JSON,
123+
"TOKENLIST": types.String,
124+
"UUID": types.UUID,
123125
}
124126

125127

@@ -136,6 +138,7 @@ def process(value):
136138
types.String: "STRING",
137139
types.TIME: "TIME",
138140
types.TIMESTAMP: "TIMESTAMP",
141+
types.UUID: "UUID",
139142
types.Integer: "INT64",
140143
types.NullType: "INT64",
141144
}
@@ -426,8 +429,10 @@ def returning_clause(self, stmt, returning_cols, **kw):
426429
)
427430
for c in expression._select_iterables(
428431
filter(
429-
lambda col: not col.dialect_options.get("spanner", {}).get(
430-
"exclude_from_returning", False
432+
lambda col: (
433+
not col.dialect_options.get("spanner", {}).get(
434+
"exclude_from_returning", False
435+
)
431436
),
432437
returning_cols,
433438
)
@@ -709,12 +714,16 @@ def visit_create_index(
709714
options = index.dialect_options["spanner"]
710715
if "storing" in options:
711716
storing = options["storing"]
712-
storing_columns = [
713-
index.table.c[col] if isinstance(col, str) else col
714-
for col in storing
715-
]
717+
storing_names = []
718+
for col in storing:
719+
if isinstance(col, str):
720+
storing_names.append(col)
721+
elif hasattr(col, "name"):
722+
storing_names.append(col.name)
723+
else:
724+
storing_names.append(str(col))
716725
text += " STORING (%s)" % ", ".join(
717-
[self.preparer.quote(c.name) for c in storing_columns]
726+
[self.preparer.quote(name) for name in storing_names]
718727
)
719728

720729
interleave_in = options.get("interleave_in")
@@ -814,6 +823,12 @@ def visit_NUMERIC(self, type_, **kw):
814823
def visit_BIGINT(self, type_, **kw):
815824
return "INT64"
816825

826+
def visit_UUID(self, type_, **kw):
827+
return "UUID"
828+
829+
def visit_uuid(self, type_, **kw):
830+
return "UUID"
831+
817832
def visit_JSON(self, type_, **kw):
818833
return "JSON"
819834

@@ -1300,6 +1315,7 @@ def get_multi_indexes(
13001315
{table_type_query}
13011316
{schema_filter_query}
13021317
i.index_type != 'PRIMARY_KEY'
1318+
AND i.index_type != 'SEARCH'
13031319
AND i.spanner_is_managed = FALSE
13041320
GROUP BY i.table_catalog, i.table_schema, i.table_name,
13051321
i.index_name, i.is_unique
@@ -1324,7 +1340,8 @@ def get_multi_indexes(
13241340
"column_names": row[3],
13251341
"unique": row[4],
13261342
"column_sorting": {
1327-
col: order.lower() for col, order in zip(row[3], row[5])
1343+
col: (order.lower() if order else None)
1344+
for col, order in zip(row[3], row[5])
13281345
},
13291346
"include_columns": include_columns if include_columns else [],
13301347
"dialect_options": dialect_options,
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
# Copyright 2026 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 google.cloud.spanner_admin_database_v1 import UpdateDatabaseDdlRequest
16+
from google.cloud.spanner_v1 import ResultSet
17+
from sqlalchemy import Column, Index, MetaData, Table, Uuid, types
18+
from sqlalchemy.testing import eq_, is_instance_of
19+
20+
from tests.mockserver_tests.mock_server_test_base import (
21+
MockServerTestBase,
22+
add_result,
23+
)
24+
25+
26+
class TestDialectIntegration(MockServerTestBase):
27+
def test_create_table_with_native_uuid(self):
28+
"""Integration test verifying native UUID and TOKENLIST DDL generation."""
29+
add_result(
30+
"""SELECT true
31+
FROM INFORMATION_SCHEMA.TABLES
32+
WHERE TABLE_SCHEMA="" AND TABLE_NAME="products"
33+
LIMIT 1
34+
""",
35+
ResultSet(),
36+
)
37+
engine = self.create_engine()
38+
metadata = MetaData()
39+
Table(
40+
"products",
41+
metadata,
42+
Column("product_id", Uuid, primary_key=True),
43+
Column("token_data", types.String()),
44+
)
45+
metadata.create_all(engine)
46+
requests = self.database_admin_service.requests
47+
eq_(1, len(requests))
48+
is_instance_of(requests[0], UpdateDatabaseDdlRequest)
49+
statement = requests[0].statements[0]
50+
assert "product_id UUID NOT NULL" in statement
51+
52+
def test_create_index_with_storing_clause(self):
53+
"""Integration test verifying DDL generation for indexes with STORING clause."""
54+
add_result(
55+
"""SELECT true
56+
FROM INFORMATION_SCHEMA.TABLES
57+
WHERE TABLE_SCHEMA="" AND TABLE_NAME="items"
58+
LIMIT 1
59+
""",
60+
ResultSet(),
61+
)
62+
engine = self.create_engine()
63+
metadata = MetaData()
64+
items = Table(
65+
"items",
66+
metadata,
67+
Column("id", Uuid, primary_key=True),
68+
Column("category", types.String(50)),
69+
Column("name", types.String(100)),
70+
Column("description", types.String(500)),
71+
)
72+
Index(
73+
"ix_items_category",
74+
items.c.category,
75+
spanner_storing=["name", "description"],
76+
)
77+
metadata.create_all(engine)
78+
requests = self.database_admin_service.requests
79+
eq_(1, len(requests))
80+
is_instance_of(requests[0], UpdateDatabaseDdlRequest)
81+
statements = requests[0].statements
82+
create_index_statement = [s for s in statements if "CREATE INDEX" in s][0]
83+
assert "STORING (name, description)" in create_index_statement
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
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 import Column, Index, Table, MetaData, Uuid, types
17+
from sqlalchemy.schema import CreateIndex
18+
from sqlalchemy.testing import eq_
19+
from sqlalchemy.testing.plugin.plugin_base import fixtures
20+
from google.cloud.sqlalchemy_spanner.sqlalchemy_spanner import (
21+
_type_map,
22+
_type_map_inv,
23+
SpannerDialect,
24+
SpannerDDLCompiler,
25+
)
26+
27+
28+
class TestSpannerDialect(fixtures.TestBase):
29+
def test_tokenlist_in_type_map(self):
30+
"""Test that TOKENLIST is registered in _type_map to prevent KeyError during reflection."""
31+
assert "TOKENLIST" in _type_map
32+
eq_(_type_map["TOKENLIST"], types.String)
33+
34+
def test_uuid_in_type_map(self):
35+
"""Test that native UUID is registered in _type_map."""
36+
assert "UUID" in _type_map
37+
eq_(_type_map["UUID"], types.UUID)
38+
39+
def test_uuid_in_type_map_inv(self):
40+
"""Test that types.UUID maps to 'UUID' in _type_map_inv."""
41+
assert types.UUID in _type_map_inv
42+
eq_(_type_map_inv[types.UUID], "UUID")
43+
44+
def test_visit_uuid_compilation(self):
45+
"""Test that SpannerTypeCompiler compiles types.UUID and Uuid to 'UUID'."""
46+
dialect = SpannerDialect()
47+
eq_(dialect.type_compiler.process(types.UUID()), "UUID")
48+
eq_(dialect.type_compiler.process(Uuid()), "UUID")
49+
50+
def test_string36_backward_compatibility(self):
51+
"""Test that existing String(36) compiles to STRING(36) without regression."""
52+
dialect = SpannerDialect()
53+
processed = dialect.type_compiler.process(types.String(36))
54+
eq_(processed, "STRING(36)")
55+
eq_(_type_map["STRING"], types.String)
56+
57+
def test_get_multi_indexes_excludes_search_indexes_sql(self):
58+
"""Test that get_multi_indexes SQL query excludes SEARCH indexes."""
59+
dialect = SpannerDialect()
60+
connection = MagicMock()
61+
mock_snapshot = MagicMock()
62+
mock_snapshot.execute_sql.return_value = []
63+
connection.connection.database.snapshot.return_value.__enter__.return_value = (
64+
mock_snapshot
65+
)
66+
67+
dialect.get_multi_indexes(connection)
68+
69+
# Retrieve the SQL executed by snapshot
70+
executed_sql = mock_snapshot.execute_sql.call_args[0][0]
71+
assert "i.index_type != 'SEARCH'" in executed_sql
72+
73+
def test_get_multi_indexes_handles_none_column_ordering(self):
74+
"""Test that get_multi_indexes does not crash when column_ordering has None elements."""
75+
dialect = SpannerDialect()
76+
connection = MagicMock()
77+
mock_snapshot = MagicMock()
78+
# Mock row: schema, table, index_name, columns, is_unique, column_orderings, storing_columns
79+
mock_row = [
80+
"public",
81+
"my_table",
82+
"idx_search",
83+
["col1"],
84+
False,
85+
[None], # column_ordering is None
86+
[],
87+
]
88+
mock_snapshot.execute_sql.return_value = [mock_row]
89+
connection.connection.database.snapshot.return_value.__enter__.return_value = (
90+
mock_snapshot
91+
)
92+
93+
res = dialect.get_multi_indexes(connection)
94+
assert ("public", "my_table") in res
95+
index_info = res[("public", "my_table")][0]
96+
eq_(index_info["column_sorting"]["col1"], None)
97+
98+
def test_visit_create_index_storing_unbound_columns(self):
99+
"""Test creating index with spanner_storing when storing columns are string names or unbound objects."""
100+
compiler = SpannerDDLCompiler(SpannerDialect(), None)
101+
metadata = MetaData()
102+
t = Table("t", metadata, Column("col1", types.String(100)))
103+
# In batch mode, storing columns may be string names not in t.c, or Column objects without t.c mapping
104+
idx = Index(
105+
"ix_test",
106+
t.c.col1,
107+
spanner_storing=["storing_col1", Column("storing_col2", types.String(50))],
108+
)
109+
110+
create_index_op = CreateIndex(idx)
111+
ddl = compiler.visit_create_index(create_index_op)
112+
assert "STORING (storing_col1, storing_col2)" in ddl

0 commit comments

Comments
 (0)