Skip to content

Commit 2959f26

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 2959f26

7 files changed

Lines changed: 351 additions & 9 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())
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# -*- coding: utf-8 -*-
2+
#
3+
# Copyright 2026 Google LLC
4+
#
5+
# Licensed under the Apache License, Version 2.0 (the "License");
6+
# you may not use this file except in compliance with the License.
7+
# You may obtain a copy of the License at
8+
#
9+
# https://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
17+
[run]
18+
branch = True
19+
source =
20+
google/cloud/sqlalchemy_spanner
21+
22+
[paths]
23+
source =
24+
google/cloud/sqlalchemy_spanner
25+
*/site-packages/google/cloud/sqlalchemy_spanner
26+
27+
[report]
28+
show_missing = True
29+
exclude_lines =
30+
pragma: NO COVER
31+
def __repr__
32+
raise NotImplementedError
33+
omit =
34+
tests/*
35+
*/tests/*

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] or [])
13281345
},
13291346
"include_columns": include_columns if include_columns else [],
13301347
"dialect_options": dialect_options,

packages/sqlalchemy-spanner/noxfile.py

Lines changed: 13 additions & 1 deletion
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 = [
@@ -275,6 +276,9 @@ def mockserver(session):
275276
session.run(
276277
"py.test",
277278
"--quiet",
279+
"--cov=google.cloud.sqlalchemy_spanner",
280+
"--cov-append",
281+
"--cov-config=.coveragerc",
278282
os.path.join("tests", "mockserver_tests"),
279283
*session.posargs,
280284
)
@@ -368,7 +372,15 @@ def unit(session, test_type):
368372
*UNIT_TEST_DEPENDENCIES,
369373
)
370374
session.install(".")
371-
session.run("py.test", "--quiet", os.path.join("tests/unit"), *session.posargs)
375+
session.run(
376+
"py.test",
377+
"--quiet",
378+
"--cov=google.cloud.sqlalchemy_spanner",
379+
"--cov-append",
380+
"--cov-config=.coveragerc",
381+
os.path.join("tests/unit"),
382+
*session.posargs,
383+
)
372384
return
373385

374386

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

0 commit comments

Comments
 (0)