Skip to content

Commit 83bf00a

Browse files
committed
fix(sqlalchemy-spanner): handle TOKENLIST and unknown column types in introspection
Spanner full-text search uses TOKENLIST columns, but _type_map does not include this type and _designate_type() crashes with KeyError on any unrecognized type string. This change: 1. Adds TOKENLIST to _type_map, mapped to NullType (it is an infrastructure-only type not read/written by applications). 2. Adds a fallback in _designate_type() so unrecognized types return NullType with a warning instead of crashing. This future-proofs against new Spanner types. The Go client had the same issue and fixed it in googleapis/google-cloud-go#11522 (released in spanner v1.78.0). Fixes #16621
1 parent c3bd6c0 commit 83bf00a

File tree

2 files changed

+71
-1
lines changed

2 files changed

+71
-1
lines changed

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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414
import base64
15+
import warnings
1516

1617
import re
1718

@@ -122,6 +123,7 @@ def process(value):
122123
"TIMESTAMP": types.TIMESTAMP,
123124
"ARRAY": types.ARRAY,
124125
"JSON": types.JSON,
126+
"TOKENLIST": types.NullType,
125127
}
126128

127129

@@ -1226,7 +1228,14 @@ def _designate_type(self, str_repr):
12261228
inner_type = self._designate_type(inner_type_str)
12271229
return _type_map["ARRAY"](inner_type)
12281230
else:
1229-
return _type_map[str_repr]
1231+
try:
1232+
return _type_map[str_repr]
1233+
except KeyError:
1234+
warnings.warn(
1235+
"Did not recognize Spanner type '%s', "
1236+
"mapping it to NullType" % str_repr
1237+
)
1238+
return types.NullType()
12301239

12311240
@engine_to_connection
12321241
def get_multi_indexes(
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
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+
import warnings
16+
17+
from sqlalchemy import types
18+
from sqlalchemy.testing.plugin.plugin_base import fixtures
19+
20+
from google.cloud.sqlalchemy_spanner import sqlalchemy_spanner
21+
22+
23+
class TestDesignateType(fixtures.TestBase):
24+
"""Unit tests for SpannerDialect._designate_type."""
25+
26+
def setup_method(self):
27+
self.dialect = sqlalchemy_spanner.SpannerDialect()
28+
29+
def test_known_types(self):
30+
assert isinstance(self.dialect._designate_type("BOOL"), types.Boolean)
31+
assert isinstance(self.dialect._designate_type("INT64"), types.BIGINT)
32+
assert isinstance(self.dialect._designate_type("FLOAT64"), types.Float)
33+
assert isinstance(self.dialect._designate_type("DATE"), types.DATE)
34+
assert isinstance(self.dialect._designate_type("TIMESTAMP"), types.TIMESTAMP)
35+
assert isinstance(self.dialect._designate_type("JSON"), types.JSON)
36+
37+
def test_string_with_length(self):
38+
result = self.dialect._designate_type("STRING(255)")
39+
assert isinstance(result, types.String)
40+
assert result.length == 255
41+
42+
def test_bytes_with_length(self):
43+
result = self.dialect._designate_type("BYTES(1024)")
44+
assert isinstance(result, types.LargeBinary)
45+
assert result.length == 1024
46+
47+
def test_tokenlist_maps_to_nulltype(self):
48+
result = self.dialect._designate_type("TOKENLIST")
49+
assert isinstance(result, types.NullType)
50+
51+
def test_unknown_type_returns_nulltype_with_warning(self):
52+
with warnings.catch_warnings(record=True) as caught:
53+
warnings.simplefilter("always")
54+
result = self.dialect._designate_type("SOME_FUTURE_TYPE")
55+
assert isinstance(result, types.NullType)
56+
assert len(caught) == 1
57+
assert "SOME_FUTURE_TYPE" in str(caught[0].message)
58+
59+
def test_array_of_known_type(self):
60+
result = self.dialect._designate_type("ARRAY<INT64>")
61+
assert isinstance(result, types.ARRAY)

0 commit comments

Comments
 (0)