From 6ea0a26ca82565858d8049cc5403a4475edcce33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Knut=20Olav=20L=C3=B8ite?= Date: Mon, 8 Feb 2021 02:37:48 +0100 Subject: [PATCH] feat: Support Array conversion to ResultSet (#326) * feat: add support for array as ResultSet * feat: add support for array as ResultSet JDBC arrays can optionally be converted to ResultSets. This feature was not implemented for the Cloud Spanner JDBC driver. This would cause DBeaver to show an error message instead of the actual data when fetching a NUMERIC array. Other arrays would be fetched correctly by DBeaver, as those array types do not use the conversion to a ResultSet. * test: add tests for invalid data types --- .../google/cloud/spanner/jdbc/JdbcArray.java | 85 ++++++- .../cloud/spanner/jdbc/JdbcArrayTest.java | 212 ++++++++++++++++-- 2 files changed, 270 insertions(+), 27 deletions(-) diff --git a/src/main/java/com/google/cloud/spanner/jdbc/JdbcArray.java b/src/main/java/com/google/cloud/spanner/jdbc/JdbcArray.java index 67d8e0c1..2a9d9e77 100644 --- a/src/main/java/com/google/cloud/spanner/jdbc/JdbcArray.java +++ b/src/main/java/com/google/cloud/spanner/jdbc/JdbcArray.java @@ -16,11 +16,21 @@ package com.google.cloud.spanner.jdbc; +import com.google.cloud.ByteArray; +import com.google.cloud.spanner.ResultSets; +import com.google.cloud.spanner.Struct; +import com.google.cloud.spanner.Type; +import com.google.cloud.spanner.Type.StructField; +import com.google.cloud.spanner.ValueBinder; +import com.google.common.collect.ImmutableList; import com.google.rpc.Code; +import java.math.BigDecimal; import java.sql.Array; +import java.sql.Date; import java.sql.ResultSet; import java.sql.SQLException; import java.sql.SQLFeatureNotSupportedException; +import java.sql.Timestamp; import java.util.Arrays; import java.util.List; import java.util.Map; @@ -137,28 +147,89 @@ public Object getArray(long index, int count, Map> map) throws return null; } - private static final String RESULTSET_NOT_SUPPORTED = - "Getting a ResultSet from an array is not supported"; + private static final String RESULTSET_WITH_TYPE_MAPPING_NOT_SUPPORTED = + "Getting a ResultSet with a custom type mapping from an array is not supported"; @Override public ResultSet getResultSet() throws SQLException { - throw new SQLFeatureNotSupportedException(RESULTSET_NOT_SUPPORTED); + return getResultSet(1L, Integer.MAX_VALUE); } @Override public ResultSet getResultSet(Map> map) throws SQLException { - throw new SQLFeatureNotSupportedException(RESULTSET_NOT_SUPPORTED); + throw new SQLFeatureNotSupportedException(RESULTSET_WITH_TYPE_MAPPING_NOT_SUPPORTED); } @Override - public ResultSet getResultSet(long index, int count) throws SQLException { - throw new SQLFeatureNotSupportedException(RESULTSET_NOT_SUPPORTED); + public ResultSet getResultSet(long startIndex, int count) throws SQLException { + JdbcPreconditions.checkArgument( + startIndex + count - 1L <= Integer.MAX_VALUE, + String.format("End index cannot exceed %d", Integer.MAX_VALUE)); + JdbcPreconditions.checkArgument(startIndex >= 1L, "Start index must be >= 1"); + JdbcPreconditions.checkArgument(count >= 0, "Count must be >= 0"); + checkFree(); + ImmutableList.Builder rows = ImmutableList.builder(); + int added = 0; + if (data != null) { + // Note that array index in JDBC is base-one. + for (int index = (int) startIndex; + added < count && index <= ((Object[]) data).length; + index++) { + Object value = ((Object[]) data)[index - 1]; + ValueBinder binder = + Struct.newBuilder().set("INDEX").to(index).set("VALUE"); + Struct.Builder builder = null; + switch (type.getCode()) { + case BOOL: + builder = binder.to((Boolean) value); + break; + case BYTES: + builder = binder.to(ByteArray.copyFrom((byte[]) value)); + break; + case DATE: + builder = binder.to(JdbcTypeConverter.toGoogleDate((Date) value)); + break; + case FLOAT64: + builder = binder.to((Double) value); + break; + case INT64: + builder = binder.to((Long) value); + break; + case NUMERIC: + builder = binder.to((BigDecimal) value); + break; + case STRING: + builder = binder.to((String) value); + break; + case TIMESTAMP: + builder = binder.to(JdbcTypeConverter.toGoogleTimestamp((Timestamp) value)); + break; + case ARRAY: + case STRUCT: + default: + throw new SQLFeatureNotSupportedException( + String.format( + "Array of type %s cannot be converted to a ResultSet", type.getCode().name())); + } + rows.add(builder.build()); + added++; + if (added == count) { + break; + } + } + } + return JdbcResultSet.of( + ResultSets.forRows( + Type.struct( + StructField.of("INDEX", Type.int64()), + StructField.of("VALUE", type.getSpannerType())), + rows.build())); } @Override public ResultSet getResultSet(long index, int count, Map> map) throws SQLException { - throw new SQLFeatureNotSupportedException(RESULTSET_NOT_SUPPORTED); + throw new SQLFeatureNotSupportedException(RESULTSET_WITH_TYPE_MAPPING_NOT_SUPPORTED); } @Override diff --git a/src/test/java/com/google/cloud/spanner/jdbc/JdbcArrayTest.java b/src/test/java/com/google/cloud/spanner/jdbc/JdbcArrayTest.java index 01184c2d..24e3e573 100644 --- a/src/test/java/com/google/cloud/spanner/jdbc/JdbcArrayTest.java +++ b/src/test/java/com/google/cloud/spanner/jdbc/JdbcArrayTest.java @@ -16,10 +16,16 @@ package com.google.cloud.spanner.jdbc; -import static org.junit.Assert.assertEquals; +import static com.google.cloud.spanner.jdbc.JdbcTypeConverter.toSqlDate; +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.fail; +import com.google.cloud.spanner.ErrorCode; +import com.google.cloud.spanner.jdbc.JdbcSqlExceptionFactory.JdbcSqlExceptionImpl; import java.math.BigDecimal; import java.sql.Date; +import java.sql.ResultSet; +import java.sql.ResultSetMetaData; import java.sql.SQLException; import java.sql.Timestamp; import java.sql.Types; @@ -35,42 +41,208 @@ public void testCreateArrayTypeName() throws SQLException { // Note that JDBC array indices start at 1. JdbcArray array; array = JdbcArray.createArray("BOOL", new Boolean[] {true, false, true}); - assertEquals(array.getBaseType(), Types.BOOLEAN); - assertEquals(((Boolean[]) array.getArray(1, 1))[0], Boolean.TRUE); + assertThat(array.getBaseType()).isEqualTo(Types.BOOLEAN); + assertThat(((Boolean[]) array.getArray(1, 1))[0]).isEqualTo(Boolean.TRUE); + try (ResultSet rs = array.getResultSet()) { + assertThat(rs.next()).isTrue(); + assertThat(rs.getBoolean(2)).isEqualTo(true); + assertThat(rs.next()).isTrue(); + assertThat(rs.getBoolean(2)).isEqualTo(false); + assertThat(rs.next()).isTrue(); + assertThat(rs.getBoolean(2)).isEqualTo(true); + assertThat(rs.next()).isFalse(); + } array = JdbcArray.createArray("BYTES", new byte[][] {new byte[] {1, 2}, new byte[] {3, 4}}); - assertEquals(array.getBaseType(), Types.BINARY); - assertEquals(((byte[][]) array.getArray(1, 1))[0][1], (byte) 2); + assertThat(array.getBaseType()).isEqualTo(Types.BINARY); + assertThat(((byte[][]) array.getArray(1, 1))[0][1]).isEqualTo((byte) 2); + try (ResultSet rs = array.getResultSet()) { + assertThat(rs.next()).isTrue(); + assertThat(rs.getBytes(2)).isEqualTo(new byte[] {1, 2}); + assertThat(rs.next()).isTrue(); + assertThat(rs.getBytes(2)).isEqualTo(new byte[] {3, 4}); + assertThat(rs.next()).isFalse(); + } array = - JdbcArray.createArray("DATE", new Date[] {new Date(1L), new Date(100L), new Date(1000L)}); - assertEquals(array.getBaseType(), Types.DATE); - assertEquals(((Date[]) array.getArray(1, 1))[0], new Date(1L)); + JdbcArray.createArray( + "DATE", + new Date[] { + toSqlDate(com.google.cloud.Date.fromYearMonthDay(2021, 1, 18)), + toSqlDate(com.google.cloud.Date.fromYearMonthDay(2000, 2, 29)), + toSqlDate(com.google.cloud.Date.fromYearMonthDay(2019, 8, 31)) + }); + assertThat(array.getBaseType()).isEqualTo(Types.DATE); + assertThat(((Date[]) array.getArray(1, 1))[0]) + .isEqualTo(toSqlDate(com.google.cloud.Date.fromYearMonthDay(2021, 1, 18))); + try (ResultSet rs = array.getResultSet()) { + assertThat(rs.next()).isTrue(); + assertThat(rs.getDate(2)) + .isEqualTo(toSqlDate(com.google.cloud.Date.fromYearMonthDay(2021, 1, 18))); + assertThat(rs.next()).isTrue(); + assertThat(rs.getDate(2)) + .isEqualTo(toSqlDate(com.google.cloud.Date.fromYearMonthDay(2000, 2, 29))); + assertThat(rs.next()).isTrue(); + assertThat(rs.getDate(2)) + .isEqualTo(toSqlDate(com.google.cloud.Date.fromYearMonthDay(2019, 8, 31))); + assertThat(rs.next()).isFalse(); + } array = JdbcArray.createArray("FLOAT64", new Double[] {1.1D, 2.2D, Math.PI}); - assertEquals(array.getBaseType(), Types.DOUBLE); - assertEquals(((Double[]) array.getArray(1, 3))[2], Double.valueOf(Math.PI)); + assertThat(array.getBaseType()).isEqualTo(Types.DOUBLE); + assertThat(((Double[]) array.getArray(1, 3))[2]).isEqualTo(Double.valueOf(Math.PI)); + try (ResultSet rs = array.getResultSet()) { + assertThat(rs.next()).isTrue(); + assertThat(rs.getDouble(2)).isEqualTo(1.1D); + assertThat(rs.next()).isTrue(); + assertThat(rs.getDouble(2)).isEqualTo(2.2D); + assertThat(rs.next()).isTrue(); + assertThat(rs.getDouble(2)).isEqualTo(Math.PI); + assertThat(rs.next()).isFalse(); + } array = JdbcArray.createArray("INT64", new Long[] {1L, 2L, 3L}); - assertEquals(array.getBaseType(), Types.BIGINT); - assertEquals(((Long[]) array.getArray(1, 1))[0], Long.valueOf(1L)); + assertThat(array.getBaseType()).isEqualTo(Types.BIGINT); + assertThat(((Long[]) array.getArray(1, 1))[0]).isEqualTo(Long.valueOf(1L)); + try (ResultSet rs = array.getResultSet()) { + assertThat(rs.next()).isTrue(); + assertThat(rs.getLong(2)).isEqualTo(1L); + assertThat(rs.next()).isTrue(); + assertThat(rs.getLong(2)).isEqualTo(2L); + assertThat(rs.next()).isTrue(); + assertThat(rs.getLong(2)).isEqualTo(3L); + assertThat(rs.next()).isFalse(); + } array = JdbcArray.createArray("NUMERIC", new BigDecimal[] {BigDecimal.ONE, null, BigDecimal.TEN}); - assertEquals(array.getBaseType(), Types.NUMERIC); - assertEquals(((BigDecimal[]) array.getArray(1, 1))[0], BigDecimal.ONE); - assertEquals(((BigDecimal[]) array.getArray(2, 1))[0], null); - assertEquals(((BigDecimal[]) array.getArray(3, 1))[0], BigDecimal.TEN); + assertThat(array.getBaseType()).isEqualTo(Types.NUMERIC); + assertThat(((BigDecimal[]) array.getArray(1, 1))[0]).isEqualTo(BigDecimal.ONE); + assertThat(((BigDecimal[]) array.getArray(2, 1))[0]).isNull(); + assertThat(((BigDecimal[]) array.getArray(3, 1))[0]).isEqualTo(BigDecimal.TEN); + try (ResultSet rs = array.getResultSet()) { + assertThat(rs.next()).isTrue(); + assertThat(rs.getBigDecimal(2)).isEqualTo(BigDecimal.ONE); + assertThat(rs.next()).isTrue(); + assertThat(rs.getBigDecimal(2)).isNull(); + assertThat(rs.next()).isTrue(); + assertThat(rs.getBigDecimal(2)).isEqualTo(BigDecimal.TEN); + assertThat(rs.next()).isFalse(); + } array = JdbcArray.createArray("STRING", new String[] {"foo", "bar", "baz"}); - assertEquals(array.getBaseType(), Types.NVARCHAR); - assertEquals(((String[]) array.getArray(1, 1))[0], "foo"); + assertThat(array.getBaseType()).isEqualTo(Types.NVARCHAR); + assertThat(((String[]) array.getArray(1, 1))[0]).isEqualTo("foo"); + try (ResultSet rs = array.getResultSet()) { + assertThat(rs.next()).isTrue(); + assertThat(rs.getString(2)).isEqualTo("foo"); + assertThat(rs.next()).isTrue(); + assertThat(rs.getString(2)).isEqualTo("bar"); + assertThat(rs.next()).isTrue(); + assertThat(rs.getString(2)).isEqualTo("baz"); + assertThat(rs.next()).isFalse(); + } array = JdbcArray.createArray( "TIMESTAMP", new Timestamp[] {new Timestamp(1L), new Timestamp(100L), new Timestamp(1000L)}); - assertEquals(array.getBaseType(), Types.TIMESTAMP); - assertEquals(((Timestamp[]) array.getArray(1, 1))[0], new Timestamp(1L)); + assertThat(array.getBaseType()).isEqualTo(Types.TIMESTAMP); + assertThat(((Timestamp[]) array.getArray(1, 1))[0]).isEqualTo(new Timestamp(1L)); + try (ResultSet rs = array.getResultSet()) { + assertThat(rs.next()).isTrue(); + assertThat(rs.getTimestamp(2)).isEqualTo(new Timestamp(1L)); + assertThat(rs.next()).isTrue(); + assertThat(rs.getTimestamp(2)).isEqualTo(new Timestamp(100L)); + assertThat(rs.next()).isTrue(); + assertThat(rs.getTimestamp(2)).isEqualTo(new Timestamp(1000L)); + assertThat(rs.next()).isFalse(); + } + } + + @Test + public void testCreateArrayOfArray() { + try { + JdbcArray.createArray("ARRAY", new String[][] {{}}); + fail("missing expected exception"); + } catch (SQLException e) { + assertThat((Exception) e).isInstanceOf(JdbcSqlException.class); + JdbcSqlException jse = (JdbcSqlException) e; + assertThat(jse.getErrorCode()) + .isEqualTo(ErrorCode.INVALID_ARGUMENT.getGrpcStatusCode().value()); + } + } + + @Test + public void testCreateArrayOfStruct() { + try { + JdbcArray.createArray("STRUCT", new Object[] {}); + fail("missing expected exception"); + } catch (SQLException e) { + assertThat((Exception) e).isInstanceOf(JdbcSqlException.class); + JdbcSqlException jse = (JdbcSqlException) e; + assertThat(jse.getErrorCode()) + .isEqualTo(ErrorCode.INVALID_ARGUMENT.getGrpcStatusCode().value()); + } + } + + @Test + public void testGetResultSetMetadata() throws SQLException { + JdbcArray array = JdbcArray.createArray("STRING", new String[] {"foo", "bar", "baz"}); + try (ResultSet rs = array.getResultSet()) { + ResultSetMetaData metadata = rs.getMetaData(); + assertThat(metadata.getColumnCount()).isEqualTo(2); + assertThat(metadata.getColumnType(1)).isEqualTo(Types.BIGINT); + assertThat(metadata.getColumnType(2)).isEqualTo(Types.NVARCHAR); + assertThat(metadata.getColumnName(1)).isEqualTo("INDEX"); + assertThat(metadata.getColumnName(2)).isEqualTo("VALUE"); + } + } + + @Test + public void testGetResultSetWithIndex() throws SQLException { + JdbcArray array = JdbcArray.createArray("STRING", new String[] {"foo", "bar", "baz"}); + try (ResultSet rs = array.getResultSet(2L, 1)) { + assertThat(rs.next()).isTrue(); + assertThat(rs.getLong("INDEX")).isEqualTo(2L); + assertThat(rs.getString("VALUE")).isEqualTo("bar"); + assertThat(rs.next()).isFalse(); + } + + try (ResultSet rs = array.getResultSet(1L, 5)) { + assertThat(rs.next()).isTrue(); + assertThat(rs.getString(2)).isEqualTo("foo"); + assertThat(rs.next()).isTrue(); + assertThat(rs.getString(2)).isEqualTo("bar"); + assertThat(rs.next()).isTrue(); + assertThat(rs.getString(2)).isEqualTo("baz"); + assertThat(rs.next()).isFalse(); + } + + try (ResultSet rs = array.getResultSet(1L, 0)) { + assertThat(rs.next()).isFalse(); + } + } + + @Test + public void testGetResultSetWithInvalidIndex() throws SQLException { + JdbcArray array = JdbcArray.createArray("STRING", new String[] {"foo", "bar", "baz"}); + try (ResultSet rs = array.getResultSet(0L, 1)) { + fail("missing expected exception"); + } catch (JdbcSqlExceptionImpl e) { + assertThat(e.getErrorCode()) + .isEqualTo(ErrorCode.INVALID_ARGUMENT.getGrpcStatusCode().value()); + } + } + + @Test + public void testGetResultSetWithInvalidCount() throws SQLException { + JdbcArray array = JdbcArray.createArray("STRING", new String[] {"foo", "bar", "baz"}); + try (ResultSet rs = array.getResultSet(1L, -1)) { + fail("missing expected exception"); + } catch (JdbcSqlExceptionImpl e) { + assertThat(e.getErrorCode()) + .isEqualTo(ErrorCode.INVALID_ARGUMENT.getGrpcStatusCode().value()); + } } }