diff --git a/pom.xml b/pom.xml index 01c6d79db..0eb8d7c25 100644 --- a/pom.xml +++ b/pom.xml @@ -61,7 +61,7 @@ com.google.cloud google-cloud-spanner-bom - 6.91.1 + 6.92.0 pom import diff --git a/samples/spring-data-jdbc/googlesql/pom.xml b/samples/spring-data-jdbc/googlesql/pom.xml index 57b0dea29..a84f3067f 100644 --- a/samples/spring-data-jdbc/googlesql/pom.xml +++ b/samples/spring-data-jdbc/googlesql/pom.xml @@ -30,7 +30,7 @@ com.google.cloud google-cloud-spanner-bom - 6.91.1 + 6.92.0 import pom diff --git a/samples/spring-data-jdbc/postgresql/pom.xml b/samples/spring-data-jdbc/postgresql/pom.xml index 5e04a256d..7bedf6ce2 100644 --- a/samples/spring-data-jdbc/postgresql/pom.xml +++ b/samples/spring-data-jdbc/postgresql/pom.xml @@ -30,7 +30,7 @@ com.google.cloud google-cloud-spanner-bom - 6.91.1 + 6.92.0 import pom diff --git a/samples/spring-data-mybatis/googlesql/pom.xml b/samples/spring-data-mybatis/googlesql/pom.xml index 1b7978344..b4cc9e0b9 100644 --- a/samples/spring-data-mybatis/googlesql/pom.xml +++ b/samples/spring-data-mybatis/googlesql/pom.xml @@ -35,7 +35,7 @@ com.google.cloud google-cloud-spanner-bom - 6.91.1 + 6.92.0 import pom diff --git a/src/main/java/com/google/cloud/spanner/jdbc/JdbcDataType.java b/src/main/java/com/google/cloud/spanner/jdbc/JdbcDataType.java index d73233c32..6a77cecc2 100644 --- a/src/main/java/com/google/cloud/spanner/jdbc/JdbcDataType.java +++ b/src/main/java/com/google/cloud/spanner/jdbc/JdbcDataType.java @@ -29,6 +29,7 @@ import java.util.HashSet; import java.util.List; import java.util.Set; +import java.util.UUID; /** Enum for mapping Cloud Spanner data types to Java classes and JDBC SQL {@link Types}. */ enum JdbcDataType { @@ -379,6 +380,32 @@ public Type getSpannerType() { return Type.timestamp(); } }, + UUID { + @Override + public int getSqlType() { + return UuidType.VENDOR_TYPE_NUMBER; + } + + @Override + public Class getJavaClass() { + return UUID.class; + } + + @Override + public Code getCode() { + return Code.UUID; + } + + @Override + public List getArrayElements(ResultSet rs, int columnIndex) { + return rs.getUuidList(columnIndex); + } + + @Override + public Type getSpannerType() { + return Type.uuid(); + } + }, STRUCT { @Override public int getSqlType() { diff --git a/src/main/java/com/google/cloud/spanner/jdbc/JdbcParameterStore.java b/src/main/java/com/google/cloud/spanner/jdbc/JdbcParameterStore.java index 47d096261..0964db087 100644 --- a/src/main/java/com/google/cloud/spanner/jdbc/JdbcParameterStore.java +++ b/src/main/java/com/google/cloud/spanner/jdbc/JdbcParameterStore.java @@ -729,6 +729,12 @@ private Builder setParamWithUnknownType(ValueBinder binder, Object valu } else if (Time.class.isAssignableFrom(value.getClass())) { Time timeValue = (Time) value; return binder.to(JdbcTypeConverter.toGoogleTimestamp(new Timestamp(timeValue.getTime()))); + } else if (UUID.class.isAssignableFrom(value.getClass())) { + // Bind UUID values as untyped strings to allow them to be used with all types that support + // string values (e.g. STRING, UUID). + return binder.to( + Value.untyped( + com.google.protobuf.Value.newBuilder().setStringValue(value.toString()).build())); } else if (String.class.isAssignableFrom(value.getClass())) { String stringVal = (String) value; return binder.to(stringVal); @@ -853,6 +859,9 @@ private Builder setArrayValue(ValueBinder binder, int type, Object valu com.google.protobuf.Value.newBuilder() .setNullValue(NullValue.NULL_VALUE) .build())); + case UuidType.VENDOR_TYPE_NUMBER: + case UuidType.SHORT_VENDOR_TYPE_NUMBER: + return binder.toUuidArray(null); default: return binder.to( Value.untyped( @@ -907,6 +916,8 @@ private Builder setArrayValue(ValueBinder binder, int type, Object valu return binder.toDateArray(JdbcTypeConverter.toGoogleDates((Date[]) value)); } else if (Timestamp[].class.isAssignableFrom(value.getClass())) { return binder.toTimestampArray(JdbcTypeConverter.toGoogleTimestamps((Timestamp[]) value)); + } else if (UUID[].class.isAssignableFrom(value.getClass())) { + return binder.toUuidArray(Arrays.asList((UUID[]) value)); } else if (String[].class.isAssignableFrom(value.getClass())) { if (type == JsonType.VENDOR_TYPE_NUMBER || type == JsonType.SHORT_VENDOR_TYPE_NUMBER) { return binder.toJsonArray(Arrays.asList((String[]) value)); diff --git a/src/main/java/com/google/cloud/spanner/jdbc/JdbcResultSet.java b/src/main/java/com/google/cloud/spanner/jdbc/JdbcResultSet.java index 00df25116..d9bfe818b 100644 --- a/src/main/java/com/google/cloud/spanner/jdbc/JdbcResultSet.java +++ b/src/main/java/com/google/cloud/spanner/jdbc/JdbcResultSet.java @@ -52,6 +52,7 @@ import java.util.List; import java.util.Map; import java.util.NoSuchElementException; +import java.util.UUID; import javax.annotation.Nonnull; /** Implementation of {@link ResultSet} for Cloud Spanner */ @@ -623,6 +624,38 @@ public Timestamp getTimestamp(int columnIndex) throws SQLException { } } + public UUID getUUID(int columnIndex) throws SQLException { + checkClosedAndValidRow(); + if (isNull(columnIndex)) { + return null; + } + int spannerIndex = columnIndex - 1; + Code type = getMainTypeCode(spanner.getColumnType(spannerIndex)); + switch (type) { + case UUID: + return spanner.getUuid(spannerIndex); + case STRING: + return UUID.fromString(spanner.getString(spannerIndex)); + case BYTES: + case DATE: + case TIMESTAMP: + case BOOL: + case FLOAT32: + case FLOAT64: + case INT64: + case NUMERIC: + case PG_NUMERIC: + case JSON: + case PG_JSONB: + case STRUCT: + case PROTO: + case ENUM: + case ARRAY: + default: + throw createInvalidToGetAs("uuid", type); + } + } + private InputStream getInputStream(String val, Charset charset) { if (val == null) return null; byte[] b = val.getBytes(charset); @@ -764,35 +797,46 @@ public Object getObject(int columnIndex) throws SQLException { } private Object getObject(Type type, int columnIndex) throws SQLException { - // TODO: Refactor to check based on type code. - if (type == Type.bool()) return getBoolean(columnIndex); - if (type == Type.bytes()) return getBytes(columnIndex); - if (type == Type.date()) return getDate(columnIndex); - if (type == Type.float32()) { - return getFloat(columnIndex); - } - if (type == Type.float64()) return getDouble(columnIndex); - if (type == Type.int64() || type == Type.pgOid()) { - return getLong(columnIndex); - } - if (type == Type.numeric()) return getBigDecimal(columnIndex); - if (type == Type.pgNumeric()) { - final String value = getString(columnIndex); - try { - return parseBigDecimal(value); - } catch (Exception e) { - return parseDouble(value); - } - } - if (type == Type.string()) return getString(columnIndex); - if (type == Type.json() || type == Type.pgJsonb()) { - return getString(columnIndex); + JdbcPreconditions.checkArgument(type != null, "type is null"); + switch (type.getCode()) { + case BOOL: + return getBoolean(columnIndex); + case BYTES: + case PROTO: + return getBytes(columnIndex); + case DATE: + return getDate(columnIndex); + case FLOAT32: + return getFloat(columnIndex); + case FLOAT64: + return getDouble(columnIndex); + case INT64: + case PG_OID: + case ENUM: + return getLong(columnIndex); + case NUMERIC: + return getBigDecimal(columnIndex); + case PG_NUMERIC: + final String value = getString(columnIndex); + try { + return parseBigDecimal(value); + } catch (Exception e) { + return parseDouble(value); + } + case STRING: + case JSON: + case PG_JSONB: + return getString(columnIndex); + case TIMESTAMP: + return getTimestamp(columnIndex); + case UUID: + return getUUID(columnIndex); + case ARRAY: + return getArray(columnIndex); + default: + throw JdbcSqlExceptionFactory.of( + "Unknown type: " + type, com.google.rpc.Code.INVALID_ARGUMENT); } - if (type == Type.timestamp()) return getTimestamp(columnIndex); - if (type.getCode() == Code.PROTO) return getBytes(columnIndex); - if (type.getCode() == Code.ENUM) return getLong(columnIndex); - if (type.getCode() == Code.ARRAY) return getArray(columnIndex); - throw JdbcSqlExceptionFactory.of("Unknown type: " + type, com.google.rpc.Code.INVALID_ARGUMENT); } @Override diff --git a/src/main/java/com/google/cloud/spanner/jdbc/JdbcTypeConverter.java b/src/main/java/com/google/cloud/spanner/jdbc/JdbcTypeConverter.java index b98340332..942be1587 100644 --- a/src/main/java/com/google/cloud/spanner/jdbc/JdbcTypeConverter.java +++ b/src/main/java/com/google/cloud/spanner/jdbc/JdbcTypeConverter.java @@ -82,6 +82,9 @@ static Object convert(Object value, Type type, Class targetType) throws SQLEx if (value == null) { return null; } + if (value.getClass().equals(targetType)) { + return value; + } try { if (targetType.equals(Value.class)) { return convertToSpannerValue(value, type); @@ -370,7 +373,9 @@ private static Value convertToSpannerValue(Object value, Type type) throws SQLEx private static void checkValidTypeAndValueForConvert(Type type, Object value) throws SQLException { - if (value == null) return; + if (value == null) { + return; + } JdbcPreconditions.checkArgument( type.getCode() != Code.ARRAY || Array.class.isAssignableFrom(value.getClass()), "input type is array, but input value is not an instance of java.sql.Array"); diff --git a/src/main/java/com/google/cloud/spanner/jdbc/UuidType.java b/src/main/java/com/google/cloud/spanner/jdbc/UuidType.java new file mode 100644 index 000000000..46317b435 --- /dev/null +++ b/src/main/java/com/google/cloud/spanner/jdbc/UuidType.java @@ -0,0 +1,61 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.cloud.spanner.jdbc; + +import com.google.spanner.v1.TypeCode; +import java.sql.DatabaseMetaData; +import java.sql.PreparedStatement; +import java.sql.SQLType; + +/** + * Custom SQL type for Spanner UUID data type. This type (or the vendor type number) must be used + * when setting a UUID parameter using {@link PreparedStatement#setObject(int, Object, SQLType)}. + */ +public class UuidType implements SQLType { + public static final UuidType INSTANCE = new UuidType(); + /** + * Spanner does not have any type numbers, but the code values are unique. Add 100,000 to avoid + * conflicts with the type numbers in java.sql.Types. + */ + public static final int VENDOR_TYPE_NUMBER = 100_000 + TypeCode.UUID_VALUE; + /** + * Define a short type number as well, as this is what is expected to be returned in {@link + * DatabaseMetaData#getTypeInfo()}. + */ + public static final short SHORT_VENDOR_TYPE_NUMBER = (short) VENDOR_TYPE_NUMBER; + + private UuidType() {} + + @Override + public String getName() { + return "UUID"; + } + + @Override + public String getVendor() { + return UuidType.class.getPackage().getName(); + } + + @Override + public Integer getVendorTypeNumber() { + return VENDOR_TYPE_NUMBER; + } + + public String toString() { + return getName(); + } +} diff --git a/src/test/java/com/google/cloud/spanner/jdbc/AllTypesMockServerTest.java b/src/test/java/com/google/cloud/spanner/jdbc/AllTypesMockServerTest.java index c38489339..7cb9729dc 100644 --- a/src/test/java/com/google/cloud/spanner/jdbc/AllTypesMockServerTest.java +++ b/src/test/java/com/google/cloud/spanner/jdbc/AllTypesMockServerTest.java @@ -40,6 +40,7 @@ import java.util.Arrays; import java.util.Base64; import java.util.Map; +import java.util.UUID; import java.util.stream.Collectors; import org.junit.Test; import org.junit.runner.RunWith; @@ -74,6 +75,7 @@ public void testSelectAllTypes() { new java.sql.Date( DATE_VALUE.getYear() - 1900, DATE_VALUE.getMonth() - 1, DATE_VALUE.getDayOfMonth()), resultSet.getDate(++col)); + assertEquals(UUID_VALUE, resultSet.getObject(++col, UUID.class)); assertEquals(TIMESTAMP_VALUE.toSqlTimestamp(), resultSet.getTimestamp(++col)); if (dialect == Dialect.POSTGRESQL) { assertEquals(PG_OID_VALUE, resultSet.getLong(++col)); @@ -120,6 +122,8 @@ public void testSelectAllTypes() { date.getYear() - 1900, date.getMonth() - 1, date.getDayOfMonth())) .collect(Collectors.toList()), Arrays.asList((Date[]) resultSet.getArray(++col).getArray())); + assertEquals( + UUID_ARRAY_VALUE, Arrays.asList((UUID[]) resultSet.getArray(++col).getArray())); assertEquals( TIMESTAMP_ARRAY_VALUE.stream() .map(timestamp -> timestamp == null ? null : timestamp.toSqlTimestamp()) @@ -140,13 +144,13 @@ public void testSelectAllTypes() { @Override @Test public void testInsertAllTypes() { - // TODO: Remove when PG_NUMERIC NaN is supported. + Statement insertStatement = createInsertStatement(dialect); if (dialect == Dialect.POSTGRESQL) { - Statement insertStatement = createInsertStatement(dialect); + // TODO: Remove when PG_NUMERIC NaN is supported. insertStatement = insertStatement.toBuilder() .replace(insertStatement.getSql().replaceAll("@p", "\\$")) - .bind("p16") + .bind("p17") .to( com.google.cloud.spanner.Value.pgNumericArray( NUMERIC_ARRAY_VALUE.stream() @@ -155,8 +159,10 @@ public void testInsertAllTypes() { bigDecimal == null ? null : bigDecimal.toEngineeringString()) .collect(Collectors.toList()))) .build(); - mockSpanner.putStatementResult(StatementResult.update(insertStatement, 1L)); } + // The JDBC driver binds UUID values as untyped strings, so we need to add it as 'partial' + // result, meaning that the match will only be made based on the SQL string. + mockSpanner.putPartialStatementResult(StatementResult.update(insertStatement, 1L)); try (Connection connection = createJdbcConnection()) { try (PreparedStatement statement = connection.prepareStatement( @@ -184,6 +190,7 @@ public void testInsertAllTypes() { DATE_VALUE.getYear() - 1900, DATE_VALUE.getMonth() - 1, DATE_VALUE.getDayOfMonth())); + statement.setObject(++param, UUID_VALUE); statement.setTimestamp(++param, TIMESTAMP_VALUE.toSqlTimestamp()); if (dialect == Dialect.POSTGRESQL) { statement.setLong(++param, PG_OID_VALUE); @@ -243,6 +250,8 @@ public void testInsertAllTypes() { date.getMonth() - 1, date.getDayOfMonth())) .toArray(Date[]::new))); + statement.setArray( + ++param, connection.createArrayOf("UUID", UUID_ARRAY_VALUE.toArray(new UUID[0]))); statement.setArray( ++param, connection.createArrayOf( @@ -262,8 +271,10 @@ public void testInsertAllTypes() { ExecuteSqlRequest request = mockSpanner.getRequestsOfType(ExecuteSqlRequest.class).get(0); Map paramTypes = request.getParamTypesMap(); Map params = request.getParams().getFieldsMap(); - assertEquals(dialect == Dialect.POSTGRESQL ? 22 : 20, paramTypes.size()); - assertEquals(dialect == Dialect.POSTGRESQL ? 22 : 20, params.size()); + // UUID is sent without any type information to allow it to be used with any type of column + // that accepts a STRING value. + assertEquals(dialect == Dialect.POSTGRESQL ? 23 : 21, paramTypes.size()); + assertEquals(dialect == Dialect.POSTGRESQL ? 24 : 22, params.size()); // Verify param types. ImmutableList expectedTypes = @@ -277,18 +288,25 @@ public void testInsertAllTypes() { TypeCode.JSON, TypeCode.BYTES, TypeCode.DATE, + TypeCode.TYPE_CODE_UNSPECIFIED, // UUID TypeCode.TIMESTAMP); if (dialect == Dialect.POSTGRESQL) { expectedTypes = ImmutableList.builder().addAll(expectedTypes).add(TypeCode.INT64).build(); } for (int col = 0; col < expectedTypes.size(); col++) { - assertEquals(expectedTypes.get(col), paramTypes.get("p" + (col + 1)).getCode()); + TypeCode expectedType = expectedTypes.get(col); + if (expectedType == TypeCode.TYPE_CODE_UNSPECIFIED) { + assertFalse(paramTypes.containsKey("p" + (col + 1))); + } else { + assertEquals(expectedType, paramTypes.get("p" + (col + 1)).getCode()); + } int arrayCol = col + expectedTypes.size(); assertEquals(TypeCode.ARRAY, paramTypes.get("p" + (arrayCol + 1)).getCode()); - assertEquals( - expectedTypes.get(col), - paramTypes.get("p" + (arrayCol + 1)).getArrayElementType().getCode()); + if (expectedType != TypeCode.TYPE_CODE_UNSPECIFIED) { + assertEquals( + expectedType, paramTypes.get("p" + (arrayCol + 1)).getArrayElementType().getCode()); + } } // Verify param values. @@ -306,6 +324,7 @@ public void testInsertAllTypes() { Base64.getEncoder().encodeToString(BYTES_VALUE), params.get("p" + ++col).getStringValue()); assertEquals(DATE_VALUE.toString(), params.get("p" + ++col).getStringValue()); + assertEquals(UUID_VALUE.toString(), params.get("p" + ++col).getStringValue()); assertEquals(TIMESTAMP_VALUE.toString(), params.get("p" + ++col).getStringValue()); if (dialect == Dialect.POSTGRESQL) { assertEquals(String.valueOf(PG_OID_VALUE), params.get("p" + ++col).getStringValue()); @@ -376,6 +395,11 @@ public void testInsertAllTypes() { ? null : com.google.cloud.Date.parseDate(value.getStringValue())) .collect(Collectors.toList())); + assertEquals( + UUID_ARRAY_VALUE, + params.get("p" + ++col).getListValue().getValuesList().stream() + .map(value -> value.hasNullValue() ? null : UUID.fromString(value.getStringValue())) + .collect(Collectors.toList())); assertEquals( TIMESTAMP_ARRAY_VALUE, params.get("p" + ++col).getListValue().getValuesList().stream() diff --git a/src/test/java/com/google/cloud/spanner/jdbc/JdbcParameterStoreTest.java b/src/test/java/com/google/cloud/spanner/jdbc/JdbcParameterStoreTest.java index d77c932c1..699f36c99 100644 --- a/src/test/java/com/google/cloud/spanner/jdbc/JdbcParameterStoreTest.java +++ b/src/test/java/com/google/cloud/spanner/jdbc/JdbcParameterStoreTest.java @@ -729,7 +729,12 @@ public void testSetParameterWithoutType() throws SQLException { verifyParameter(params, Value.string("test")); params.setParameter(1, UUID.fromString("83b988cf-1f4e-428a-be3d-cc712621942e"), (Integer) null); assertEquals(UUID.fromString("83b988cf-1f4e-428a-be3d-cc712621942e"), params.getParameter(1)); - verifyParameter(params, Value.string("83b988cf-1f4e-428a-be3d-cc712621942e")); + verifyParameter( + params, + Value.untyped( + com.google.protobuf.Value.newBuilder() + .setStringValue("83b988cf-1f4e-428a-be3d-cc712621942e") + .build())); String jsonString = "{\"test\": \"value\"}"; params.setParameter(1, Value.json(jsonString), (Integer) null); diff --git a/src/test/java/com/google/cloud/spanner/jdbc/PartitionedQueryMockServerTest.java b/src/test/java/com/google/cloud/spanner/jdbc/PartitionedQueryMockServerTest.java index 1ee80983b..42c59b4c3 100644 --- a/src/test/java/com/google/cloud/spanner/jdbc/PartitionedQueryMockServerTest.java +++ b/src/test/java/com/google/cloud/spanner/jdbc/PartitionedQueryMockServerTest.java @@ -74,7 +74,7 @@ public void clearRequests() { private int getExpectedColumnCount(Dialect dialect) { // GoogleSQL also adds 4 PROTO columns. // PostgreSQL adds 2 OID columns. - return dialect == Dialect.GOOGLE_STANDARD_SQL ? 24 : 22; + return dialect == Dialect.GOOGLE_STANDARD_SQL ? 26 : 24; } private String createUrl() { diff --git a/src/test/java/com/google/cloud/spanner/jdbc/RandomResultSetTest.java b/src/test/java/com/google/cloud/spanner/jdbc/RandomResultSetTest.java index e00907ee9..a6b585578 100644 --- a/src/test/java/com/google/cloud/spanner/jdbc/RandomResultSetTest.java +++ b/src/test/java/com/google/cloud/spanner/jdbc/RandomResultSetTest.java @@ -28,6 +28,7 @@ import java.sql.ResultSetMetaData; import java.sql.SQLException; import java.sql.Types; +import java.util.UUID; import org.junit.After; import org.junit.Before; import org.junit.Test; @@ -77,6 +78,7 @@ public void testSelectRandomResults() throws SQLException { assertEquals(Types.NVARCHAR, metadata.getColumnType(++col)); assertEquals(Types.BINARY, metadata.getColumnType(++col)); assertEquals(Types.DATE, metadata.getColumnType(++col)); + assertEquals(Types.OTHER, metadata.getColumnType(++col)); assertEquals(Types.TIMESTAMP, metadata.getColumnType(++col)); if (dialect == Dialect.POSTGRESQL) { assertEquals(Types.BIGINT, metadata.getColumnType(++col)); @@ -91,6 +93,7 @@ public void testSelectRandomResults() throws SQLException { assertEquals(Types.ARRAY, metadata.getColumnType(++col)); // nvarchar assertEquals(Types.ARRAY, metadata.getColumnType(++col)); // binary assertEquals(Types.ARRAY, metadata.getColumnType(++col)); // date + assertEquals(Types.ARRAY, metadata.getColumnType(++col)); // uuid assertEquals(Types.ARRAY, metadata.getColumnType(++col)); // timestamp if (dialect == Dialect.POSTGRESQL) { assertEquals(Types.ARRAY, metadata.getColumnType(++col)); // oid @@ -112,9 +115,7 @@ public void testSelectRandomResults() throws SQLException { while (resultSet.next()) { // Verify that we can get all columns as an object. for (col = 1; col <= resultSet.getMetaData().getColumnCount(); col++) { - if (dialect == Dialect.GOOGLE_STANDARD_SQL && col > 20) { - // Proto columns are not yet supported, so skipping. - } else if (dialect == Dialect.POSTGRESQL && col == 16) { + if (dialect == Dialect.POSTGRESQL && col == 17) { // getObject for ARRAY tries to get the array as a List. // That fails if the array contains a NaN, so skipping. } else { @@ -133,6 +134,7 @@ public void testSelectRandomResults() throws SQLException { resultSet.getString(++col); // JSON resultSet.getBytes(++col); resultSet.getDate(++col); + resultSet.getObject(++col, UUID.class); resultSet.getTimestamp(++col); if (dialect == Dialect.POSTGRESQL) { resultSet.getLong(++col); // oid @@ -154,6 +156,7 @@ public void testSelectRandomResults() throws SQLException { resultSet.getArray(++col); resultSet.getArray(++col); resultSet.getArray(++col); + resultSet.getArray(++col); if (dialect == Dialect.POSTGRESQL) { resultSet.getArray(++col); // oid[] }