From e2cc831d65ea2e2df234d790cdef227d4863bb1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Knut=20Olav=20L=C3=B8ite?= Date: Fri, 11 Feb 2022 17:06:08 +0100 Subject: [PATCH] fix: support null parameters - Adds more integration tests using the native PG JDBC driver. - Adds support for null values in query parameters. The PG JDBC driver sends DATE/TIMESTAMP parameters with type code Oid.UNSPECIFIED. Untyped NULL values are currently not supported by the Spangres backend, and also not by the Java client library / JDBC driver. A patch for the Java client library has been submitted here: https://github.com/googleapis/java-spanner/pull/1680 --- .../pgadapter/parsers/ArrayParser.java | 20 +- .../pgadapter/parsers/BinaryParser.java | 34 ++- .../pgadapter/parsers/BooleanParser.java | 48 ++-- .../spanner/pgadapter/parsers/DateParser.java | 32 ++- .../pgadapter/parsers/DoubleParser.java | 25 +- .../pgadapter/parsers/IntegerParser.java | 25 +- .../spanner/pgadapter/parsers/LongParser.java | 25 +- .../pgadapter/parsers/NumericParser.java | 35 +-- .../spanner/pgadapter/parsers/Parser.java | 11 +- .../pgadapter/parsers/StringParser.java | 9 +- .../pgadapter/parsers/TimestampParser.java | 58 ++-- .../pgadapter/parsers/UnspecifiedParser.java | 45 +++ .../IntermediatePreparedStatement.java | 5 +- .../cloud/spanner/pgadapter/ITJdbcTest.java | 256 +++++++++++++++++- 14 files changed, 487 insertions(+), 141 deletions(-) create mode 100644 src/main/java/com/google/cloud/spanner/pgadapter/parsers/UnspecifiedParser.java diff --git a/src/main/java/com/google/cloud/spanner/pgadapter/parsers/ArrayParser.java b/src/main/java/com/google/cloud/spanner/pgadapter/parsers/ArrayParser.java index e6dd3c8e8..bc3379192 100644 --- a/src/main/java/com/google/cloud/spanner/pgadapter/parsers/ArrayParser.java +++ b/src/main/java/com/google/cloud/spanner/pgadapter/parsers/ArrayParser.java @@ -39,19 +39,21 @@ public class ArrayParser extends Parser { boolean isStringEquivalent; public ArrayParser(ResultSet item, int position) throws SQLException { - this.item = Arrays.asList((Object[]) item.getArray(position).getArray()); - this.arrayType = item.getArray(position).getBaseType(); - if (this.arrayType == Types.ARRAY) { - throw new IllegalArgumentException( - "Spanner does not support embedded Arrays." - + " If you are seeing this, something went wrong!"); + if (item != null) { + this.item = Arrays.asList((Object[]) item.getArray(position).getArray()); + this.arrayType = item.getArray(position).getBaseType(); + if (this.arrayType == Types.ARRAY) { + throw new IllegalArgumentException( + "Spanner does not support embedded Arrays." + + " If you are seeing this, something went wrong!"); + } + this.isStringEquivalent = stringEquivalence(this.arrayType); } - this.isStringEquivalent = stringEquivalence(this.arrayType); } @Override - public List getItem() { - return this.item; + public int getSqlType() { + return Types.ARRAY; } /** diff --git a/src/main/java/com/google/cloud/spanner/pgadapter/parsers/BinaryParser.java b/src/main/java/com/google/cloud/spanner/pgadapter/parsers/BinaryParser.java index cdbe2a7ef..49d3ad0c3 100644 --- a/src/main/java/com/google/cloud/spanner/pgadapter/parsers/BinaryParser.java +++ b/src/main/java/com/google/cloud/spanner/pgadapter/parsers/BinaryParser.java @@ -17,6 +17,7 @@ import java.nio.charset.StandardCharsets; import java.sql.ResultSet; import java.sql.SQLException; +import java.sql.Types; import org.postgresql.util.PGbytea; /** @@ -34,23 +35,30 @@ public BinaryParser(Object item) { } public BinaryParser(byte[] item, FormatCode formatCode) { - switch (formatCode) { - case TEXT: - try { - this.item = PGbytea.toBytes(item); + if (item != null) { + switch (formatCode) { + case TEXT: + try { + this.item = PGbytea.toBytes(item); + break; + } catch (SQLException e) { + throw new IllegalArgumentException( + "Invalid binary value: " + new String(item, StandardCharsets.UTF_8), e); + } + case BINARY: + this.item = item; break; - } catch (SQLException e) { - throw new IllegalArgumentException( - "Invalid binary value: " + new String(item, StandardCharsets.UTF_8), e); - } - case BINARY: - this.item = item; - break; - default: - throw new IllegalArgumentException("Unsupported format: " + formatCode); + default: + throw new IllegalArgumentException("Unsupported format: " + formatCode); + } } } + @Override + public int getSqlType() { + return Types.BINARY; + } + @Override protected String stringParse() { return PGbytea.toPGString(this.item); diff --git a/src/main/java/com/google/cloud/spanner/pgadapter/parsers/BooleanParser.java b/src/main/java/com/google/cloud/spanner/pgadapter/parsers/BooleanParser.java index 2f4070a6d..fbeae34d9 100644 --- a/src/main/java/com/google/cloud/spanner/pgadapter/parsers/BooleanParser.java +++ b/src/main/java/com/google/cloud/spanner/pgadapter/parsers/BooleanParser.java @@ -14,8 +14,12 @@ package com.google.cloud.spanner.pgadapter.parsers; +import com.google.common.collect.ImmutableSet; import java.sql.ResultSet; import java.sql.SQLException; +import java.sql.Types; +import java.util.Locale; +import java.util.Set; import org.postgresql.util.ByteConverter; /** @@ -23,9 +27,13 @@ * bit, or simply returning the bit representation. */ public class BooleanParser extends Parser { - - private static String TRUE_KEY = "t"; - private static String FALSE_KEY = "f"; + private static final String TRUE_VALUE = "t"; + private static final String FALSE_VALUE = "f"; + // See https://www.postgresql.org/docs/current/datatype-boolean.html + private static final Set TRUE_VALUES = + ImmutableSet.of("t", "tr", "tru", "true", "y", "ye", "yes", "on", "1"); + private static final Set FALSE_VALUES = + ImmutableSet.of("f", "fa", "fal", "fals", "false", "n", "no", "of", "off"); public BooleanParser(ResultSet item, int position) throws SQLException { this.item = item.getBoolean(position); @@ -36,27 +44,35 @@ public BooleanParser(Object item) { } public BooleanParser(byte[] item, FormatCode formatCode) { - switch (formatCode) { - case TEXT: - String stringValue = new String(item, UTF8); - this.item = stringValue.equals(TRUE_KEY); - break; - case BINARY: - this.item = ByteConverter.bool(item, 0); - break; - default: - throw new IllegalArgumentException("Unsupported format: " + formatCode); + if (item != null) { + switch (formatCode) { + case TEXT: + String stringValue = new String(item, UTF8).toLowerCase(Locale.ENGLISH); + if (TRUE_VALUES.contains(stringValue)) { + this.item = true; + } else if (FALSE_VALUES.contains(stringValue)) { + this.item = false; + } else { + throw new IllegalArgumentException(stringValue + " is not a valid boolean value"); + } + break; + case BINARY: + this.item = ByteConverter.bool(item, 0); + break; + default: + throw new IllegalArgumentException("Unsupported format: " + formatCode); + } } } @Override - public Boolean getItem() { - return this.item; + public int getSqlType() { + return Types.BOOLEAN; } @Override protected String stringParse() { - return this.item ? TRUE_KEY : FALSE_KEY; + return this.item ? TRUE_VALUE : FALSE_VALUE; } @Override diff --git a/src/main/java/com/google/cloud/spanner/pgadapter/parsers/DateParser.java b/src/main/java/com/google/cloud/spanner/pgadapter/parsers/DateParser.java index 03b1d1056..bbde61abb 100644 --- a/src/main/java/com/google/cloud/spanner/pgadapter/parsers/DateParser.java +++ b/src/main/java/com/google/cloud/spanner/pgadapter/parsers/DateParser.java @@ -17,6 +17,7 @@ import com.google.common.base.Preconditions; import java.sql.ResultSet; import java.sql.SQLException; +import java.sql.Types; import java.text.ParseException; import java.text.SimpleDateFormat; import java.time.LocalDate; @@ -34,18 +35,20 @@ public DateParser(Object item) { } public DateParser(byte[] item, FormatCode formatCode) { - switch (formatCode) { - case TEXT: - String stringValue = new String(item, UTF8); - this.item = java.sql.Date.valueOf(stringValue); - break; - case BINARY: - long days = ByteConverter.int4(item, 0) + PG_EPOCH_DAYS; - this.validateRange(days); - this.item = java.sql.Date.valueOf(LocalDate.ofEpochDay(days)); - break; - default: - throw new IllegalArgumentException("Unsupported format: " + formatCode); + if (item != null) { + switch (formatCode) { + case TEXT: + String stringValue = new String(item, UTF8); + this.item = java.sql.Date.valueOf(stringValue); + break; + case BINARY: + long days = ByteConverter.int4(item, 0) + PG_EPOCH_DAYS; + this.validateRange(days); + this.item = java.sql.Date.valueOf(LocalDate.ofEpochDay(days)); + break; + default: + throw new IllegalArgumentException("Unsupported format: " + formatCode); + } } } @@ -76,6 +79,11 @@ public static boolean isDate(String value) { return false; } + @Override + public int getSqlType() { + return Types.DATE; + } + @Override protected String stringParse() { return item.toString(); diff --git a/src/main/java/com/google/cloud/spanner/pgadapter/parsers/DoubleParser.java b/src/main/java/com/google/cloud/spanner/pgadapter/parsers/DoubleParser.java index d899c4e86..ee24125c9 100644 --- a/src/main/java/com/google/cloud/spanner/pgadapter/parsers/DoubleParser.java +++ b/src/main/java/com/google/cloud/spanner/pgadapter/parsers/DoubleParser.java @@ -16,6 +16,7 @@ import java.sql.ResultSet; import java.sql.SQLException; +import java.sql.Types; import org.postgresql.util.ByteConverter; /** Translate from wire protocol to double. */ @@ -30,21 +31,23 @@ public DoubleParser(Object item) { } public DoubleParser(byte[] item, FormatCode formatCode) { - switch (formatCode) { - case TEXT: - this.item = Double.valueOf(new String(item)); - break; - case BINARY: - this.item = ByteConverter.float8(item, 0); - break; - default: - throw new IllegalArgumentException("Unsupported format: " + formatCode); + if (item != null) { + switch (formatCode) { + case TEXT: + this.item = Double.valueOf(new String(item)); + break; + case BINARY: + this.item = ByteConverter.float8(item, 0); + break; + default: + throw new IllegalArgumentException("Unsupported format: " + formatCode); + } } } @Override - public Double getItem() { - return this.item; + public int getSqlType() { + return Types.DOUBLE; } @Override diff --git a/src/main/java/com/google/cloud/spanner/pgadapter/parsers/IntegerParser.java b/src/main/java/com/google/cloud/spanner/pgadapter/parsers/IntegerParser.java index d4ca90415..d521dc260 100644 --- a/src/main/java/com/google/cloud/spanner/pgadapter/parsers/IntegerParser.java +++ b/src/main/java/com/google/cloud/spanner/pgadapter/parsers/IntegerParser.java @@ -16,6 +16,7 @@ import java.sql.ResultSet; import java.sql.SQLException; +import java.sql.Types; import org.postgresql.util.ByteConverter; /** Translate from wire protocol to int. */ @@ -30,21 +31,23 @@ public IntegerParser(Object item) { } public IntegerParser(byte[] item, FormatCode formatCode) { - switch (formatCode) { - case TEXT: - this.item = Integer.valueOf(new String(item)); - break; - case BINARY: - this.item = ByteConverter.int4(item, 0); - break; - default: - throw new IllegalArgumentException("Unsupported format: " + formatCode); + if (item != null) { + switch (formatCode) { + case TEXT: + this.item = Integer.valueOf(new String(item)); + break; + case BINARY: + this.item = ByteConverter.int4(item, 0); + break; + default: + throw new IllegalArgumentException("Unsupported format: " + formatCode); + } } } @Override - public Integer getItem() { - return this.item; + public int getSqlType() { + return Types.INTEGER; } @Override diff --git a/src/main/java/com/google/cloud/spanner/pgadapter/parsers/LongParser.java b/src/main/java/com/google/cloud/spanner/pgadapter/parsers/LongParser.java index 3839efa49..57ff2c843 100644 --- a/src/main/java/com/google/cloud/spanner/pgadapter/parsers/LongParser.java +++ b/src/main/java/com/google/cloud/spanner/pgadapter/parsers/LongParser.java @@ -16,6 +16,7 @@ import java.sql.ResultSet; import java.sql.SQLException; +import java.sql.Types; import org.postgresql.util.ByteConverter; /** Translate from wire protocol to long. */ @@ -30,21 +31,23 @@ public LongParser(Object item) { } public LongParser(byte[] item, FormatCode formatCode) { - switch (formatCode) { - case TEXT: - this.item = Long.valueOf(new String(item)); - break; - case BINARY: - this.item = ByteConverter.int8(item, 0); - break; - default: - throw new IllegalArgumentException("Unsupported format: " + formatCode); + if (item != null) { + switch (formatCode) { + case TEXT: + this.item = Long.valueOf(new String(item)); + break; + case BINARY: + this.item = ByteConverter.int8(item, 0); + break; + default: + throw new IllegalArgumentException("Unsupported format: " + formatCode); + } } } @Override - public Long getItem() { - return this.item; + public int getSqlType() { + return Types.BIGINT; } @Override diff --git a/src/main/java/com/google/cloud/spanner/pgadapter/parsers/NumericParser.java b/src/main/java/com/google/cloud/spanner/pgadapter/parsers/NumericParser.java index 0ce9706aa..ada0cfede 100644 --- a/src/main/java/com/google/cloud/spanner/pgadapter/parsers/NumericParser.java +++ b/src/main/java/com/google/cloud/spanner/pgadapter/parsers/NumericParser.java @@ -18,6 +18,7 @@ import java.nio.charset.StandardCharsets; import java.sql.ResultSet; import java.sql.SQLException; +import java.sql.Types; import org.postgresql.util.ByteConverter; /** Translate from wire protocol to {@link Number}. */ @@ -32,26 +33,28 @@ public NumericParser(Object item) { } public NumericParser(byte[] item, FormatCode formatCode) { - switch (formatCode) { - case TEXT: - String stringValue = new String(item); - if (stringValue.equalsIgnoreCase("NaN")) { - this.item = Double.NaN; - } else { - this.item = new BigDecimal(new String(item)); - } - break; - case BINARY: - this.item = ByteConverter.numeric(item, 0, item.length); - break; - default: - throw new IllegalArgumentException("Unsupported format: " + formatCode); + if (item != null) { + switch (formatCode) { + case TEXT: + String stringValue = new String(item); + if (stringValue.equalsIgnoreCase("NaN")) { + this.item = Double.NaN; + } else { + this.item = new BigDecimal(new String(item)); + } + break; + case BINARY: + this.item = ByteConverter.numeric(item, 0, item.length); + break; + default: + throw new IllegalArgumentException("Unsupported format: " + formatCode); + } } } @Override - public Number getItem() { - return this.item; + public int getSqlType() { + return Types.NUMERIC; } @Override diff --git a/src/main/java/com/google/cloud/spanner/pgadapter/parsers/Parser.java b/src/main/java/com/google/cloud/spanner/pgadapter/parsers/Parser.java index 62c2ed3da..64fabae2a 100644 --- a/src/main/java/com/google/cloud/spanner/pgadapter/parsers/Parser.java +++ b/src/main/java/com/google/cloud/spanner/pgadapter/parsers/Parser.java @@ -61,7 +61,7 @@ public static FormatCode of(short code) { * no type could be guessed. */ private static int guessType(byte[] item, FormatCode formatCode) { - if (formatCode == FormatCode.TEXT) { + if (formatCode == FormatCode.TEXT && item != null) { String value = new String(item, StandardCharsets.UTF_8); if (TimestampParser.isTimestamp(value)) { return Oid.TIMESTAMPTZ; @@ -103,11 +103,11 @@ public static Parser create(byte[] item, int oidType, FormatCode formatCode) { case Oid.TIMESTAMPTZ: return new TimestampParser(item, formatCode); case Oid.UNSPECIFIED: + // Try to guess the type based on the value. Use an unspecified parser if no type could be + // determined. int type = guessType(item, formatCode); if (type == Oid.UNSPECIFIED) { - throw new IllegalArgumentException( - String.format( - "Could not guess type of value %s", new String(item, StandardCharsets.UTF_8))); + return new UnspecifiedParser(item, formatCode); } return create(item, type, formatCode); default: @@ -191,6 +191,9 @@ public T getItem() { return this.item; } + /** Returns the corresponding JDBC SQL type. */ + public abstract int getSqlType(); + /** * Parses data based on specified data format (Spanner, text, or binary) * diff --git a/src/main/java/com/google/cloud/spanner/pgadapter/parsers/StringParser.java b/src/main/java/com/google/cloud/spanner/pgadapter/parsers/StringParser.java index 2c4ea161d..b410199ce 100644 --- a/src/main/java/com/google/cloud/spanner/pgadapter/parsers/StringParser.java +++ b/src/main/java/com/google/cloud/spanner/pgadapter/parsers/StringParser.java @@ -17,6 +17,7 @@ import java.nio.charset.StandardCharsets; import java.sql.ResultSet; import java.sql.SQLException; +import java.sql.Types; /** Translate from wire protocol to string. */ public class StringParser extends Parser { @@ -30,12 +31,14 @@ public StringParser(Object item) { } public StringParser(byte[] item, FormatCode formatCode) { - this.item = new String(item, UTF8); + if (item != null) { + this.item = new String(item, UTF8); + } } @Override - public String getItem() { - return this.item; + public int getSqlType() { + return Types.VARCHAR; } @Override diff --git a/src/main/java/com/google/cloud/spanner/pgadapter/parsers/TimestampParser.java b/src/main/java/com/google/cloud/spanner/pgadapter/parsers/TimestampParser.java index f6096f225..e842d6d26 100644 --- a/src/main/java/com/google/cloud/spanner/pgadapter/parsers/TimestampParser.java +++ b/src/main/java/com/google/cloud/spanner/pgadapter/parsers/TimestampParser.java @@ -19,8 +19,11 @@ import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Timestamp; +import java.sql.Types; import java.time.Instant; import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeFormatterBuilder; +import java.time.temporal.ChronoField; import java.time.temporal.TemporalAccessor; import java.util.regex.Pattern; import org.postgresql.util.ByteConverter; @@ -46,10 +49,12 @@ public class TimestampParser extends Parser { private static final Pattern TIMESTAMP_PATTERN = Pattern.compile(TIMESTAMP_REGEX); - private static final DateTimeFormatter TIMESTAMP_WITHOUT_FRACTION_FORMATTER = - DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ssX"); - private static final DateTimeFormatter TIMESTAMP_WITH_FRACTION_FORMATTER = - DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSSSSSSSSX"); + private static final DateTimeFormatter TIMESTAMP_FORMATTER = + new DateTimeFormatterBuilder() + .appendPattern("yyyy-MM-dd HH:mm:ss") + .appendFraction(ChronoField.MICRO_OF_SECOND, 0, 9, true) + .appendOffset("+HH:mm", "Z") + .toFormatter(); public TimestampParser(ResultSet item, int position) throws SQLException { this.item = item.getTimestamp(position); @@ -60,28 +65,25 @@ public TimestampParser(Object item) { } public TimestampParser(byte[] item, FormatCode formatCode) { - switch (formatCode) { - case TEXT: - String stringValue = new String(item, StandardCharsets.UTF_8); - TemporalAccessor temporalAccessor; - if (stringValue.contains(".")) { - temporalAccessor = TIMESTAMP_WITH_FRACTION_FORMATTER.parse(stringValue); - } else { - temporalAccessor = TIMESTAMP_WITHOUT_FRACTION_FORMATTER.parse(stringValue); - } - this.item = Timestamp.from(Instant.from(temporalAccessor)); - break; - case BINARY: - long pgMicros = ByteConverter.int8(item, 0); - com.google.cloud.Timestamp ts = com.google.cloud.Timestamp.ofTimeMicroseconds(pgMicros); - long javaSeconds = ts.getSeconds() + PG_EPOCH_SECONDS; - int javaNanos = ts.getNanos(); - this.item = - com.google.cloud.Timestamp.ofTimeSecondsAndNanos(javaSeconds, javaNanos) - .toSqlTimestamp(); - break; - default: - throw new IllegalArgumentException("Unsupported format: " + formatCode); + if (item != null) { + switch (formatCode) { + case TEXT: + String stringValue = new String(item, StandardCharsets.UTF_8); + TemporalAccessor temporalAccessor = TIMESTAMP_FORMATTER.parse(stringValue); + this.item = Timestamp.from(Instant.from(temporalAccessor)); + break; + case BINARY: + long pgMicros = ByteConverter.int8(item, 0); + com.google.cloud.Timestamp ts = com.google.cloud.Timestamp.ofTimeMicroseconds(pgMicros); + long javaSeconds = ts.getSeconds() + PG_EPOCH_SECONDS; + int javaNanos = ts.getNanos(); + this.item = + com.google.cloud.Timestamp.ofTimeSecondsAndNanos(javaSeconds, javaNanos) + .toSqlTimestamp(); + break; + default: + throw new IllegalArgumentException("Unsupported format: " + formatCode); + } } } @@ -97,8 +99,8 @@ public static boolean isTimestamp(String value) { } @Override - public Timestamp getItem() { - return item; + public int getSqlType() { + return Types.TIMESTAMP_WITH_TIMEZONE; } @Override diff --git a/src/main/java/com/google/cloud/spanner/pgadapter/parsers/UnspecifiedParser.java b/src/main/java/com/google/cloud/spanner/pgadapter/parsers/UnspecifiedParser.java new file mode 100644 index 000000000..3d0d88fbb --- /dev/null +++ b/src/main/java/com/google/cloud/spanner/pgadapter/parsers/UnspecifiedParser.java @@ -0,0 +1,45 @@ +// Copyright 2022 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.pgadapter.parsers; + +import com.google.cloud.spanner.Value; +import java.nio.charset.StandardCharsets; +import java.sql.Types; + +/** + * Parser for values with unspecified type. Any non-null values will be stored as a string, but the + * SQL type will be reported as {@link Types#OTHER}. + */ +public class UnspecifiedParser extends Parser { + + public UnspecifiedParser(byte[] item, FormatCode formatCode) { + this.item = Value.string(item == null ? null : new String(item, UTF8)); + } + + @Override + public int getSqlType() { + return Types.OTHER; + } + + @Override + protected String stringParse() { + return this.item.isNull() ? null : this.item.getString(); + } + + @Override + protected byte[] binaryParse() { + return this.item.isNull() ? null : this.item.getString().getBytes(StandardCharsets.UTF_8); + } +} diff --git a/src/main/java/com/google/cloud/spanner/pgadapter/statements/IntermediatePreparedStatement.java b/src/main/java/com/google/cloud/spanner/pgadapter/statements/IntermediatePreparedStatement.java index 880932371..53cb2ea91 100644 --- a/src/main/java/com/google/cloud/spanner/pgadapter/statements/IntermediatePreparedStatement.java +++ b/src/main/java/com/google/cloud/spanner/pgadapter/statements/IntermediatePreparedStatement.java @@ -142,8 +142,9 @@ public IntermediatePortalStatement bind( short formatCode = portal.getParameterFormatCode(index); int type = this.parseType(parameters, index); for (Integer position : parameterIndexToPositions.get(index)) { - Object value = Parser.create(parameters[index], type, FormatCode.of(formatCode)).getItem(); - ((PreparedStatement) portal.statement).setObject(position, value); + Parser parser = Parser.create(parameters[index], type, FormatCode.of(formatCode)); + ((PreparedStatement) portal.statement) + .setObject(position, parser.getItem(), parser.getSqlType()); } } return portal; diff --git a/src/test/java/com/google/cloud/spanner/pgadapter/ITJdbcTest.java b/src/test/java/com/google/cloud/spanner/pgadapter/ITJdbcTest.java index 29718731e..6b649e36a 100644 --- a/src/test/java/com/google/cloud/spanner/pgadapter/ITJdbcTest.java +++ b/src/test/java/com/google/cloud/spanner/pgadapter/ITJdbcTest.java @@ -14,8 +14,10 @@ package com.google.cloud.spanner.pgadapter; +import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; import com.google.cloud.ByteArray; @@ -26,10 +28,13 @@ import com.google.cloud.spanner.pgadapter.metadata.OptionsMetadata; import com.google.common.collect.ImmutableList; import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; import java.sql.Connection; import java.sql.DriverManager; +import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; +import java.sql.Types; import java.util.Arrays; import java.util.Collections; import org.junit.AfterClass; @@ -38,15 +43,24 @@ import org.junit.Test; import org.junit.experimental.categories.Category; import org.junit.runner.RunWith; -import org.junit.runners.JUnit4; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameter; +import org.junit.runners.Parameterized.Parameters; @Category(IntegrationTest.class) -@RunWith(JUnit4.class) +@RunWith(Parameterized.class) public class ITJdbcTest implements IntegrationTest { private static final PgAdapterTestEnv testEnv = new PgAdapterTestEnv(); private static ProxyServer server; private static Database database; + @Parameter public String preferredQueryMode; + + @Parameters(name = "preferredQueryMode = {0}") + public static Object[] data() { + return new Object[] {"extended", "simple"}; + } + @BeforeClass public static void setup() throws Exception { // Make sure the PG JDBC driver is loaded. @@ -132,11 +146,15 @@ public void insertTestData() { .build())); } + private String getConnectionUrl() { + return String.format( + "jdbc:postgresql://localhost:%d/?preferredQueryMode=%s", + server.getLocalPort(), preferredQueryMode); + } + @Test public void testSelectHelloWorld() throws SQLException { - try (Connection connection = - DriverManager.getConnection( - String.format("jdbc:postgresql://localhost:%d/", server.getLocalPort()))) { + try (Connection connection = DriverManager.getConnection(getConnectionUrl())) { try (ResultSet resultSet = connection.createStatement().executeQuery("SELECT 'Hello World!'")) { assertTrue(resultSet.next()); @@ -145,4 +163,232 @@ public void testSelectHelloWorld() throws SQLException { } } } + + @Test + public void testSelectWithParameters() throws SQLException { + try (Connection connection = DriverManager.getConnection(getConnectionUrl())) { + try (PreparedStatement statement = + connection.prepareStatement( + "select col_bigint, col_bool, col_bytea, col_float8, col_int, col_numeric, col_timestamptz, col_varchar " + + "from all_types " + + "where col_bigint=? " + + "and col_bool=? " + + "and col_bytea=? " + + "and col_float8=? " + + "and col_int=? " + + "and col_numeric=? " + + "and col_timestamptz=? " + + "and col_varchar=?")) { + + int index = 0; + statement.setLong(++index, 1); + statement.setBoolean(++index, true); + statement.setBytes(++index, "test".getBytes(StandardCharsets.UTF_8)); + statement.setDouble(++index, 3.14d); + statement.setInt(++index, 1); + statement.setBigDecimal(++index, new BigDecimal("3.14")); + statement.setTimestamp( + ++index, Timestamp.parseTimestamp("2022-01-27T17:51:30+01:00").toSqlTimestamp()); + statement.setString(++index, "test"); + + try (ResultSet resultSet = statement.executeQuery()) { + assertTrue(resultSet.next()); + + assertEquals(1, resultSet.getLong(1)); + assertTrue(resultSet.getBoolean(2)); + assertArrayEquals("test".getBytes(StandardCharsets.UTF_8), resultSet.getBytes(3)); + assertEquals(3.14d, resultSet.getDouble(4), 0.0d); + assertEquals(1, resultSet.getInt(5)); + assertEquals(new BigDecimal("3.14"), resultSet.getBigDecimal(6)); + assertEquals( + Timestamp.parseTimestamp("2022-01-27T17:51:30+01:00").toSqlTimestamp(), + resultSet.getTimestamp(7)); + assertEquals("test", resultSet.getString(8)); + + assertFalse(resultSet.next()); + } + } + } + } + + @Test + public void testInsertWithParameters() throws SQLException { + try (Connection connection = DriverManager.getConnection(getConnectionUrl())) { + try (PreparedStatement statement = + connection.prepareStatement( + "insert into all_types " + + "(col_bigint, col_bool, col_bytea, col_float8, col_int, col_numeric, col_timestamptz, col_varchar) " + + "values (?, ?, ?, ?, ?, ?, ?, ?)")) { + int index = 0; + statement.setLong(++index, 2); + statement.setBoolean(++index, true); + statement.setBytes(++index, "bytes_test".getBytes(StandardCharsets.UTF_8)); + statement.setDouble(++index, 10.1); + statement.setInt(++index, 100); + statement.setBigDecimal(++index, new BigDecimal("6.626")); + statement.setTimestamp( + ++index, Timestamp.parseTimestamp("2022-02-11T13:45:00.123456+01:00").toSqlTimestamp()); + statement.setString(++index, "string_test"); + + assertEquals(1, statement.executeUpdate()); + } + + try (ResultSet resultSet = + connection.createStatement().executeQuery("select * from all_types where col_bigint=2")) { + assertTrue(resultSet.next()); + + assertEquals(2, resultSet.getLong(1)); + assertTrue(resultSet.getBoolean(2)); + assertArrayEquals("bytes_test".getBytes(StandardCharsets.UTF_8), resultSet.getBytes(3)); + assertEquals(10.1d, resultSet.getDouble(4), 0.0d); + assertEquals(100, resultSet.getInt(5)); + assertEquals(new BigDecimal("6.626"), resultSet.getBigDecimal(6)); + assertEquals( + Timestamp.parseTimestamp("2022-02-11T13:45:00.123456+01:00").toSqlTimestamp(), + resultSet.getTimestamp(7)); + assertEquals("string_test", resultSet.getString(8)); + + assertFalse(resultSet.next()); + } + } + } + + @Test + public void testUpdateWithParameters() throws SQLException { + try (Connection connection = DriverManager.getConnection(getConnectionUrl())) { + try (PreparedStatement statement = + connection.prepareStatement( + "update all_types set " + + "col_bool=?, " + + "col_bytea=?, " + + "col_float8=?, " + + "col_int=?, " + + "col_numeric=?, " + + "col_timestamptz=?, " + + "col_varchar=? " + + "where col_bigint=?")) { + int index = 0; + statement.setBoolean(++index, false); + statement.setBytes(++index, "updated".getBytes(StandardCharsets.UTF_8)); + statement.setDouble(++index, 3.14d * 2d); + statement.setInt(++index, 2); + statement.setBigDecimal(++index, new BigDecimal("10.0")); + // Note that PostgreSQL does not support nanosecond precision, so the JDBC driver therefore + // truncates this value before it is sent to PG. + statement.setTimestamp( + ++index, + Timestamp.parseTimestamp("2022-02-11T14:04:59.123456789+01:00").toSqlTimestamp()); + statement.setString(++index, "updated"); + statement.setLong(++index, 1); + + assertEquals(1, statement.executeUpdate()); + } + + try (ResultSet resultSet = + connection.createStatement().executeQuery("select * from all_types where col_bigint=1")) { + assertTrue(resultSet.next()); + + assertEquals(1, resultSet.getLong(1)); + assertFalse(resultSet.getBoolean(2)); + assertArrayEquals("updated".getBytes(StandardCharsets.UTF_8), resultSet.getBytes(3)); + assertEquals(3.14d * 2d, resultSet.getDouble(4), 0.0d); + assertEquals(2, resultSet.getInt(5)); + assertEquals(new BigDecimal("10.0"), resultSet.getBigDecimal(6)); + // Note: The JDBC driver already truncated the timestamp value before it was sent to PG. + // So here we read back the truncated value. + assertEquals( + Timestamp.parseTimestamp("2022-02-11T14:04:59.123457+01:00").toSqlTimestamp(), + resultSet.getTimestamp(7)); + assertEquals("updated", resultSet.getString(8)); + + assertFalse(resultSet.next()); + } + } + } + + @Test + public void testNullValues() throws SQLException { + try (Connection connection = DriverManager.getConnection(getConnectionUrl())) { + // TODO: Add col_timestamptz to the statement when PgAdapter allows untyped null values. + // The PG JDBC driver sends date and timestamp parameters with type code Oid.UNSPECIFIED. + try (PreparedStatement statement = + connection.prepareStatement( + "insert into all_types " + + "(col_bigint, col_bool, col_bytea, col_float8, col_int, col_numeric, col_varchar) " + + "values (?, ?, ?, ?, ?, ?, ?)")) { + int index = 0; + statement.setLong(++index, 2); + statement.setNull(++index, Types.BOOLEAN); + statement.setNull(++index, Types.BINARY); + statement.setNull(++index, Types.DOUBLE); + statement.setNull(++index, Types.INTEGER); + statement.setNull(++index, Types.NUMERIC); + // TODO: Enable the next line when PgAdapter allows untyped null values. + // This is currently blocked on both the Spangres backend allowing untyped null values in + // SQL statements, as well as the Java client library and/or JDBC driver allowing untyped + // null values. + // See also https://github.com/googleapis/java-spanner/pull/1680 + // statement.setNull(++index, Types.TIMESTAMP_WITH_TIMEZONE); + statement.setNull(++index, Types.VARCHAR); + + assertEquals(1, statement.executeUpdate()); + } + + try (ResultSet resultSet = + connection.createStatement().executeQuery("select * from all_types where col_bigint=2")) { + assertTrue(resultSet.next()); + + int index = 0; + assertEquals(2, resultSet.getLong(++index)); + + // Note: JDBC returns the zero-value for primitive types if the value is NULL, and you have + // to call wasNull() to determine whether the value was NULL or zero. + assertFalse(resultSet.getBoolean(++index)); + assertTrue(resultSet.wasNull()); + assertNull(resultSet.getBytes(++index)); + assertTrue(resultSet.wasNull()); + assertEquals(0d, resultSet.getDouble(++index), 0.0d); + assertTrue(resultSet.wasNull()); + assertEquals(0, resultSet.getInt(++index)); + assertTrue(resultSet.wasNull()); + assertNull(resultSet.getBigDecimal(++index)); + assertTrue(resultSet.wasNull()); + assertNull(resultSet.getTimestamp(++index)); + assertTrue(resultSet.wasNull()); + assertNull(resultSet.getString(++index)); + assertTrue(resultSet.wasNull()); + + assertFalse(resultSet.next()); + } + } + } + + @Test + public void testInsertWithLiterals() throws SQLException { + try (Connection connection = DriverManager.getConnection(getConnectionUrl())) { + int updateCount = + connection + .createStatement() + .executeUpdate("insert into numbers (num, name) values (2, 'Two')"); + assertEquals(1, updateCount); + } + } + + @Test + public void testUpdateWithLiterals() throws SQLException { + try (Connection connection = DriverManager.getConnection(getConnectionUrl())) { + int updateCount = + connection + .createStatement() + .executeUpdate("update numbers set name='One - updated' where num=1"); + assertEquals(1, updateCount); + + // This should return a zero update count, as there is no row 2. + int noUpdateCount = + connection + .createStatement() + .executeUpdate("update numbers set name='Two - updated' where num=2"); + assertEquals(0, noUpdateCount); + } + } }