From 02402de53f2935579cf88f37dc37df973b4be825 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Fri, 21 Aug 2026 02:54:54 +0000 Subject: [PATCH] fix(spanner): handle array values and intrinsic numeric DDL Closes #6841 --- .../com/dbx/agent/spanner/SpannerAgent.java | 46 +++++- .../dbx/agent/spanner/SpannerAgentTest.java | 154 ++++++++++++++++++ 2 files changed, 197 insertions(+), 3 deletions(-) diff --git a/agents/drivers/spanner/src/main/java/com/dbx/agent/spanner/SpannerAgent.java b/agents/drivers/spanner/src/main/java/com/dbx/agent/spanner/SpannerAgent.java index ba9615682a..7478b75bd9 100644 --- a/agents/drivers/spanner/src/main/java/com/dbx/agent/spanner/SpannerAgent.java +++ b/agents/drivers/spanner/src/main/java/com/dbx/agent/spanner/SpannerAgent.java @@ -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 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 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). 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; diff --git a/agents/drivers/spanner/src/test/java/com/dbx/agent/spanner/SpannerAgentTest.java b/agents/drivers/spanner/src/test/java/com/dbx/agent/spanner/SpannerAgentTest.java index d02a73b77e..9826b8f575 100644 --- a/agents/drivers/spanner/src/test/java/com/dbx/agent/spanner/SpannerAgentTest.java +++ b/agents/drivers/spanner/src/test/java/com/dbx/agent/spanner/SpannerAgentTest.java @@ -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 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 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 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 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>> 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", + "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 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"), 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 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 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 row(Object... keyValues) { Map result = new LinkedHashMap<>(); for (int i = 0; i < keyValues.length; i += 2) {