fix(spanner): handle array values and intrinsic numeric DDL

Closes #6841
This commit is contained in:
t8y2 committed 2026-08-21 02:57:23 +00:00
1 parent ab0ff28fe4
commit 02402de53f
2 files changed
+197 -3

No files matched your search

@@ -24,9 +24,12 @@ import com.google.cloud.spanner.DatabaseId;
import com.google.cloud.spanner.Dialect;
import com.google.cloud.spanner.jdbc.CloudSpannerJdbcConnection;
import java.sql.Array;
import java.sql.Connection;
import java.sql.DatabaseMetaData;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Types;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
@@ -295,6 +298,37 @@ public final class SpannerAgent extends ConfiguredJdbcAgent {
return spannerColumns(requireConnection(), resolveSchema(schema), table);
}
@Override
protected Object resultValue(ResultSet rs, int index, int sqlType) {
if (sqlType != Types.ARRAY) {
return super.resultValue(rs, index, sqlType);
}
return unchecked(() -> {
Array array = rs.getArray(index);
if (array == null) {
return null;
}
try {
Object raw = array.getArray();
if (raw == null) {
return null;
}
int length = java.lang.reflect.Array.getLength(raw);
List<Object> values = new ArrayList<>(length);
for (int element = 0; element < length; element++) {
values.add(java.lang.reflect.Array.get(raw, element));
}
return values;
} finally {
try {
array.free();
} catch (SQLException ignored) {
// The value has already been read; cleanup failure must not discard it.
}
}
});
}
@Override
public List<IndexInfo> listIndexes(String schema, String table) {
return spannerIndexes(requireConnection(), resolveSchema(schema), table);
@@ -485,6 +519,8 @@ public final class SpannerAgent extends ConfiguredJdbcAgent {
String name = rs.getString("COLUMN_NAME");
// TYPE_NAME already carries the native Spanner type (STRING(100), ARRAY<INT64>).
String typeName = rs.getString("TYPE_NAME");
Integer columnSize = intOrNull(rs, "COLUMN_SIZE");
boolean intrinsicNumeric = isIntrinsicNumericType(typeName);
result.add(new ColumnInfo(
name,
typeName,
@@ -493,9 +529,9 @@ public final class SpannerAgent extends ConfiguredJdbcAgent {
primaryKeys.contains(name),
null,
rs.getString("REMARKS"),
intOrNull(rs, "COLUMN_SIZE"),
intOrNull(rs, "DECIMAL_DIGITS"),
characterLength(typeName, intOrNull(rs, "COLUMN_SIZE"))
intrinsicNumeric ? null : columnSize,
intrinsicNumeric ? null : intOrNull(rs, "DECIMAL_DIGITS"),
characterLength(typeName, columnSize)
));
}
}
@@ -726,6 +762,10 @@ public final class SpannerAgent extends ConfiguredJdbcAgent {
return columnSize;
}
private static boolean isIntrinsicNumericType(String typeName) {
return "NUMERIC".equalsIgnoreCase(typeName) || "DECIMAL".equalsIgnoreCase(typeName);
}
private static Integer intOrNull(ResultSet rs, String column) throws Exception {
Object value = rs.getObject(column);
return value instanceof Number ? ((Number) value).intValue() : null;
@@ -13,11 +13,15 @@ import org.junit.jupiter.api.Test;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.sql.Array;
import java.sql.Connection;
import java.sql.DatabaseMetaData;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Types;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
@@ -263,6 +267,107 @@ class SpannerAgentTest extends JdbcFakeExecutionBehaviorTest {
assertTrue(columns.get(1).getIs_nullable());
}
@Test
void readsArrayValuesInOrderWithoutUsingGetStringAndFreesTheArray() {
List<String> calls = new ArrayList<>();
Array array = fakeArray(new Object[]{7L, "two", null}, calls, false);
ResultSet resultSet = arrayResultSet(array, calls);
Object value = resultValue(new SpannerAgent(), resultSet, Types.ARRAY);
assertEquals(Arrays.asList(7L, "two", null), value);
assertEquals(Arrays.asList("getArray(1)", "array.getArray()", "array.free()"), calls);
}
@Test
void returnsNullForNullArraysWithoutUsingGetString() {
List<String> calls = new ArrayList<>();
ResultSet resultSet = arrayResultSet(null, calls);
assertNull(resultValue(new SpannerAgent(), resultSet, Types.ARRAY));
assertEquals(Collections.singletonList("getArray(1)"), calls);
}
@Test
void keepsTheReadArrayValueWhenFreeFails() {
List<String> calls = new ArrayList<>();
Array array = fakeArray(new String[]{"first", "second"}, calls, true);
Object value = resultValue(new SpannerAgent(), arrayResultSet(array, calls), Types.ARRAY);
assertEquals(Arrays.asList("first", "second"), value);
assertEquals(Arrays.asList("getArray(1)", "array.getArray()", "array.free()"), calls);
}
@Test
void delegatesScalarValuesToTheSharedReader() {
List<String> calls = new ArrayList<>();
ResultSet resultSet = proxy(ResultSet.class, (method, args) -> {
if ("getInt".equals(method.getName())) {
calls.add("getInt(" + args[0] + ")");
return 42;
}
if ("wasNull".equals(method.getName())) {
calls.add("wasNull()");
return false;
}
if ("getArray".equals(method.getName()) || "getString".equals(method.getName())) {
throw new AssertionError("Unexpected " + method.getName());
}
return defaultValue(method.getReturnType());
});
assertEquals(42, resultValue(new SpannerAgent(), resultSet, Types.INTEGER));
assertEquals(Arrays.asList("getInt(1)", "wasNull()"), calls);
}
@Test
void omitsIntrinsicNumericParametersWhileKeepingNativeArrayAndStringTypes() {
Map<String, List<Map<String, Object>>> rows = new LinkedHashMap<>();
rows.put("getColumns", Arrays.asList(
row(
"COLUMN_NAME", "amount",
"TYPE_NAME", "NUMERIC",
"NULLABLE", DatabaseMetaData.columnNullable,
"COLUMN_SIZE", 15,
"DECIMAL_DIGITS", 0
),
row(
"COLUMN_NAME", "ratio",
"TYPE_NAME", "DECIMAL",
"NULLABLE", DatabaseMetaData.columnNullable,
"COLUMN_SIZE", 15,
"DECIMAL_DIGITS", 0
),
row(
"COLUMN_NAME", "tags",
"TYPE_NAME", "ARRAY<STRING(MAX)>",
"NULLABLE", DatabaseMetaData.columnNullable
),
row(
"COLUMN_NAME", "nickname",
"TYPE_NAME", "STRING(100)",
"NULLABLE", DatabaseMetaData.columnNullable,
"COLUMN_SIZE", 100
)
));
SpannerAgent agent = connectedAgent(GOOGLE_SQL_PRODUCT, null, rows);
List<ColumnInfo> columns = agent.getColumns("", "singers");
String ddl = agent.getTableDdl("", "singers");
assertNull(columns.get(0).getNumeric_precision());
assertNull(columns.get(0).getNumeric_scale());
assertNull(columns.get(1).getNumeric_precision());
assertNull(columns.get(1).getNumeric_scale());
assertTrue(ddl.contains("`amount` NUMERIC"), ddl);
assertTrue(ddl.contains("`ratio` DECIMAL"), ddl);
assertFalse(ddl.contains("NUMERIC("), ddl);
assertFalse(ddl.contains("DECIMAL("), ddl);
assertTrue(ddl.contains("`tags` ARRAY<STRING(MAX)>"), ddl);
assertTrue(ddl.contains("`nickname` STRING(100)"), ddl);
}
@Test
void reportsTheConfiguredDatabaseOnlyOnce() {
SpannerAgent agent = connectedAgent(POSTGRES_PRODUCT, "public", new LinkedHashMap<>());
@@ -566,6 +671,55 @@ class SpannerAgentTest extends JdbcFakeExecutionBehaviorTest {
});
}
private static ResultSet arrayResultSet(Array array, List<String> calls) {
return proxy(ResultSet.class, (method, args) -> {
if ("getArray".equals(method.getName())) {
calls.add("getArray(" + args[0] + ")");
return array;
}
if ("getString".equals(method.getName())) {
throw new AssertionError("ARRAY values must not be read with getString");
}
return defaultValue(method.getReturnType());
});
}
private static Array fakeArray(Object value, List<String> calls, boolean failFree) {
return proxy(Array.class, (method, args) -> {
if ("getArray".equals(method.getName())) {
calls.add("array.getArray()");
return value;
}
if ("free".equals(method.getName())) {
calls.add("array.free()");
if (failFree) {
throw new SQLException("cleanup failed");
}
return null;
}
return defaultValue(method.getReturnType());
});
}
private static Object resultValue(SpannerAgent agent, ResultSet resultSet, int sqlType) {
try {
Method method = SpannerAgent.class.getDeclaredMethod("resultValue", ResultSet.class, int.class, int.class);
method.setAccessible(true);
return method.invoke(agent, resultSet, 1, sqlType);
} catch (InvocationTargetException e) {
Throwable cause = e.getCause();
if (cause instanceof RuntimeException) {
throw (RuntimeException) cause;
}
if (cause instanceof Error) {
throw (Error) cause;
}
throw new IllegalStateException(cause);
} catch (ReflectiveOperationException e) {
throw new IllegalStateException(e);
}
}
private static Map<String, Object> row(Object... keyValues) {
Map<String, Object> result = new LinkedHashMap<>();
for (int i = 0; i < keyValues.length; i += 2) {