From f9daa19453b33252bf61160ff9cde1c37284ca2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Knut=20Olav=20L=C3=B8ite?= Date: Sat, 19 Feb 2022 12:20:10 +0100 Subject: [PATCH] feat: add support for PostgreSQL dialect (#739) * feat: add support for PostgreSQL dialect * fix: address review comments * deps: bump java-spanner to 6.19.1 * feat: support automatic dialect detection * deps: remove threeten + add gax * build: use owned instance for integration tests * build: use same instance as the client library --- clirr-ignored-differences.xml | 9 + pom.xml | 13 +- .../spanner/jdbc/AbstractJdbcConnection.java | 20 + .../jdbc/AbstractJdbcPreparedStatement.java | 5 +- .../spanner/jdbc/AbstractJdbcStatement.java | 5 +- .../spanner/jdbc/AbstractJdbcWrapper.java | 71 ++ .../jdbc/CloudSpannerJdbcConnection.java | 6 + .../cloud/spanner/jdbc/JdbcConnection.java | 5 +- .../cloud/spanner/jdbc/JdbcDataType.java | 26 + .../spanner/jdbc/JdbcParameterMetaData.java | 2 +- .../spanner/jdbc/JdbcParameterStore.java | 188 ++-- .../spanner/jdbc/JdbcPreparedStatement.java | 17 +- .../cloud/spanner/jdbc/JdbcResultSet.java | 67 +- .../spanner/jdbc/JdbcResultSetMetaData.java | 1 + .../cloud/spanner/jdbc/JdbcStatement.java | 9 +- .../cloud/spanner/jdbc/JdbcTypeConverter.java | 28 +- .../jdbc/DatabaseMetaData_GetColumns.sql | 3 +- .../DatabaseMetaData_GetCrossReferences.sql | 1 + .../jdbc/DatabaseMetaData_GetExportedKeys.sql | 1 + .../jdbc/DatabaseMetaData_GetImportedKeys.sql | 1 + .../jdbc/DatabaseMetaData_GetIndexInfo.sql | 1 + .../jdbc/DatabaseMetaData_GetPrimaryKeys.sql | 1 + .../jdbc/DatabaseMetaData_GetSchemas.sql | 1 + .../jdbc/DatabaseMetaData_GetTables.sql | 1 + .../spanner/jdbc/AbstractJdbcWrapperTest.java | 192 ++++- .../spanner/jdbc/ITAbstractJdbcTest.java | 59 +- .../JdbcConnectionGeneratedSqlScriptTest.java | 22 +- .../spanner/jdbc/JdbcConnectionTest.java | 75 +- ...cDatabaseMetaDataWithMockedServerTest.java | 154 +++- .../cloud/spanner/jdbc/JdbcGrpcErrorTest.java | 34 +- .../spanner/jdbc/JdbcParameterStoreTest.java | 233 ++++- .../jdbc/JdbcPreparedStatementTest.java | 46 +- .../cloud/spanner/jdbc/JdbcResultSetTest.java | 24 + .../spanner/jdbc/JdbcSqlScriptVerifier.java | 17 +- .../cloud/spanner/jdbc/JdbcStatementTest.java | 43 +- .../spanner/jdbc/JdbcTimeoutSqlTest.java | 17 +- .../jdbc/PgNumericPreparedStatementTest.java | 338 ++++++++ .../spanner/jdbc/PgNumericResultSetTest.java | 801 ++++++++++++++++++ .../spanner/jdbc/it/DialectTestParameter.java | 50 ++ .../cloud/spanner/jdbc/it/ITJdbcDdlTest.java | 36 +- .../spanner/jdbc/it/ITJdbcPgNumericTest.java | 271 ++++++ .../jdbc/it/ITJdbcPreparedStatementTest.java | 154 +++- .../jdbc/it/ITJdbcQueryOptionsTest.java | 43 +- .../spanner/jdbc/it/ITJdbcReadOnlyTest.java | 93 +- .../it/ITJdbcReadWriteAutocommitTest.java | 39 +- .../spanner/jdbc/it/ITJdbcScriptTest.java | 366 ++++++++ .../jdbc/it/ITJdbcSimpleStatementsTest.java | 86 +- .../jdbc/it/ITJdbcSqlMusicScriptTest.java | 37 +- .../spanner/jdbc/it/ITJdbcSqlScriptTest.java | 282 ------ .../spanner/jdbc/it/SpannerTestHost.java | 46 + .../spanner/jdbc/PostgreSQL/ITDdlTest.sql | 189 +++++ .../jdbc/PostgreSQL/ITReadOnlySpannerTest.sql | 145 ++++ .../ITReadOnlySpannerTest_CreateTables.sql | 24 + .../ITReadWriteAutocommitSpannerTest.sql | 236 ++++++ .../PostgreSQL/ITScriptTest_CreateTables.sql | 109 +++ .../ITScriptTest_InsertTestData.sql | 74 ++ .../ITScriptTest_TestAutoCommitDmlMode.sql | 87 ++ .../ITScriptTest_TestAutocommitReadOnly.sql | 64 ++ .../ITScriptTest_TestGetReadTimestamp.sql | 112 +++ .../ITScriptTest_TestInvalidStatements.sql | 32 + .../ITScriptTest_TestQueryOptions.sql | 51 ++ .../ITScriptTest_TestReadOnlyStaleness.sql | 262 ++++++ .../ITScriptTest_TestSetStatements.sql | 58 ++ ...ITScriptTest_TestTemporaryTransactions.sql | 67 ++ .../ITScriptTest_TestTransactionMode.sql | 155 ++++ ...criptTest_TestTransactionMode_ReadOnly.sql | 80 ++ .../jdbc/PostgreSQL/ITSqlMusicScriptTest.sql | 652 ++++++++++++++ .../spanner/jdbc/it/CreateMusicTables_PG.sql | 73 ++ 68 files changed, 5796 insertions(+), 714 deletions(-) create mode 100644 clirr-ignored-differences.xml create mode 100644 src/test/java/com/google/cloud/spanner/jdbc/PgNumericPreparedStatementTest.java create mode 100644 src/test/java/com/google/cloud/spanner/jdbc/PgNumericResultSetTest.java create mode 100644 src/test/java/com/google/cloud/spanner/jdbc/it/DialectTestParameter.java create mode 100644 src/test/java/com/google/cloud/spanner/jdbc/it/ITJdbcPgNumericTest.java create mode 100644 src/test/java/com/google/cloud/spanner/jdbc/it/ITJdbcScriptTest.java delete mode 100644 src/test/java/com/google/cloud/spanner/jdbc/it/ITJdbcSqlScriptTest.java create mode 100644 src/test/java/com/google/cloud/spanner/jdbc/it/SpannerTestHost.java create mode 100644 src/test/resources/com/google/cloud/spanner/jdbc/PostgreSQL/ITDdlTest.sql create mode 100644 src/test/resources/com/google/cloud/spanner/jdbc/PostgreSQL/ITReadOnlySpannerTest.sql create mode 100644 src/test/resources/com/google/cloud/spanner/jdbc/PostgreSQL/ITReadOnlySpannerTest_CreateTables.sql create mode 100644 src/test/resources/com/google/cloud/spanner/jdbc/PostgreSQL/ITReadWriteAutocommitSpannerTest.sql create mode 100644 src/test/resources/com/google/cloud/spanner/jdbc/PostgreSQL/ITScriptTest_CreateTables.sql create mode 100644 src/test/resources/com/google/cloud/spanner/jdbc/PostgreSQL/ITScriptTest_InsertTestData.sql create mode 100644 src/test/resources/com/google/cloud/spanner/jdbc/PostgreSQL/ITScriptTest_TestAutoCommitDmlMode.sql create mode 100644 src/test/resources/com/google/cloud/spanner/jdbc/PostgreSQL/ITScriptTest_TestAutocommitReadOnly.sql create mode 100644 src/test/resources/com/google/cloud/spanner/jdbc/PostgreSQL/ITScriptTest_TestGetReadTimestamp.sql create mode 100644 src/test/resources/com/google/cloud/spanner/jdbc/PostgreSQL/ITScriptTest_TestInvalidStatements.sql create mode 100644 src/test/resources/com/google/cloud/spanner/jdbc/PostgreSQL/ITScriptTest_TestQueryOptions.sql create mode 100644 src/test/resources/com/google/cloud/spanner/jdbc/PostgreSQL/ITScriptTest_TestReadOnlyStaleness.sql create mode 100644 src/test/resources/com/google/cloud/spanner/jdbc/PostgreSQL/ITScriptTest_TestSetStatements.sql create mode 100644 src/test/resources/com/google/cloud/spanner/jdbc/PostgreSQL/ITScriptTest_TestTemporaryTransactions.sql create mode 100644 src/test/resources/com/google/cloud/spanner/jdbc/PostgreSQL/ITScriptTest_TestTransactionMode.sql create mode 100644 src/test/resources/com/google/cloud/spanner/jdbc/PostgreSQL/ITScriptTest_TestTransactionMode_ReadOnly.sql create mode 100644 src/test/resources/com/google/cloud/spanner/jdbc/PostgreSQL/ITSqlMusicScriptTest.sql create mode 100644 src/test/resources/com/google/cloud/spanner/jdbc/it/CreateMusicTables_PG.sql diff --git a/clirr-ignored-differences.xml b/clirr-ignored-differences.xml new file mode 100644 index 000000000..235cb9074 --- /dev/null +++ b/clirr-ignored-differences.xml @@ -0,0 +1,9 @@ + + + + + 7012 + com/google/cloud/spanner/jdbc/CloudSpannerJdbcConnection + com.google.cloud.spanner.Dialect getDialect() + + diff --git a/pom.xml b/pom.xml index eb7a3b557..925c0204a 100644 --- a/pom.xml +++ b/pom.xml @@ -51,7 +51,6 @@ google-cloud-spanner-jdbc 4.13.2 3.0.2 - 1.4.4 1.1.3 4.3.1 2.2 @@ -63,7 +62,7 @@ com.google.cloud google-cloud-spanner-bom - 6.18.0 + 6.19.1 pom import @@ -103,6 +102,10 @@ com.google.api.grpc proto-google-common-protos + + com.google.api + gax + com.google.cloud google-cloud-spanner @@ -120,10 +123,6 @@ com.google.guava guava - - org.threeten - threetenbp - io.grpc grpc-netty-shaded @@ -234,7 +233,7 @@ com.google.cloud.spanner.GceTestEnvConfig - projects/gcloud-devel/instances/spanner-testing + projects/gcloud-devel/instances/spanner-testing-east1 2400 diff --git a/src/main/java/com/google/cloud/spanner/jdbc/AbstractJdbcConnection.java b/src/main/java/com/google/cloud/spanner/jdbc/AbstractJdbcConnection.java index a666a7490..1a6cfe2ed 100644 --- a/src/main/java/com/google/cloud/spanner/jdbc/AbstractJdbcConnection.java +++ b/src/main/java/com/google/cloud/spanner/jdbc/AbstractJdbcConnection.java @@ -16,6 +16,9 @@ package com.google.cloud.spanner.jdbc; +import com.google.cloud.spanner.Dialect; +import com.google.cloud.spanner.SpannerException; +import com.google.cloud.spanner.connection.AbstractStatementParser; import com.google.cloud.spanner.connection.ConnectionOptions; import com.google.common.annotations.VisibleForTesting; import com.google.rpc.Code; @@ -51,6 +54,7 @@ abstract class AbstractJdbcConnection extends AbstractJdbcWrapper private final ConnectionOptions options; private final com.google.cloud.spanner.connection.Connection spanner; private final Properties clientInfo; + private AbstractStatementParser parser; private SQLWarning firstWarning = null; private SQLWarning lastWarning = null; @@ -76,6 +80,22 @@ ConnectionOptions getConnectionOptions() { return options; } + @Override + public Dialect getDialect() { + return spanner.getDialect(); + } + + protected AbstractStatementParser getParser() throws SQLException { + if (parser == null) { + try { + parser = AbstractStatementParser.getInstance(spanner.getDialect()); + } catch (SpannerException e) { + throw JdbcSqlExceptionFactory.of(e); + } + } + return parser; + } + @Override public CallableStatement prepareCall(String sql) throws SQLException { return checkClosedAndThrowUnsupported(CALLABLE_STATEMENTS_UNSUPPORTED); diff --git a/src/main/java/com/google/cloud/spanner/jdbc/AbstractJdbcPreparedStatement.java b/src/main/java/com/google/cloud/spanner/jdbc/AbstractJdbcPreparedStatement.java index 2a905660b..847f54d38 100644 --- a/src/main/java/com/google/cloud/spanner/jdbc/AbstractJdbcPreparedStatement.java +++ b/src/main/java/com/google/cloud/spanner/jdbc/AbstractJdbcPreparedStatement.java @@ -42,10 +42,11 @@ abstract class AbstractJdbcPreparedStatement extends JdbcStatement implements PreparedStatement { private static final String METHOD_NOT_ON_PREPARED_STATEMENT = "This method may not be called on a PreparedStatement"; - private final JdbcParameterStore parameters = new JdbcParameterStore(); + private final JdbcParameterStore parameters; - AbstractJdbcPreparedStatement(JdbcConnection connection) { + AbstractJdbcPreparedStatement(JdbcConnection connection) throws SQLException { super(connection); + parameters = new JdbcParameterStore(connection.getDialect()); } JdbcParameterStore getParameters() { diff --git a/src/main/java/com/google/cloud/spanner/jdbc/AbstractJdbcStatement.java b/src/main/java/com/google/cloud/spanner/jdbc/AbstractJdbcStatement.java index 9c516609a..6b5848a4a 100644 --- a/src/main/java/com/google/cloud/spanner/jdbc/AbstractJdbcStatement.java +++ b/src/main/java/com/google/cloud/spanner/jdbc/AbstractJdbcStatement.java @@ -20,6 +20,7 @@ import com.google.cloud.spanner.Options.QueryOption; import com.google.cloud.spanner.ReadContext.QueryAnalyzeMode; import com.google.cloud.spanner.SpannerException; +import com.google.cloud.spanner.connection.AbstractStatementParser; import com.google.cloud.spanner.connection.Connection; import com.google.cloud.spanner.connection.StatementResult; import com.google.cloud.spanner.connection.StatementResult.ClientSideStatementType; @@ -35,14 +36,16 @@ abstract class AbstractJdbcStatement extends AbstractJdbcWrapper implements Statement { private static final String CURSORS_NOT_SUPPORTED = "Cursors are not supported"; private static final String ONLY_FETCH_FORWARD_SUPPORTED = "Only fetch_forward is supported"; + final AbstractStatementParser parser; private boolean closed; private boolean closeOnCompletion; private boolean poolable; private final JdbcConnection connection; private int queryTimeout; - AbstractJdbcStatement(JdbcConnection connection) { + AbstractJdbcStatement(JdbcConnection connection) throws SQLException { this.connection = connection; + this.parser = connection.getParser(); } @Override diff --git a/src/main/java/com/google/cloud/spanner/jdbc/AbstractJdbcWrapper.java b/src/main/java/com/google/cloud/spanner/jdbc/AbstractJdbcWrapper.java index 25ff70a6f..ce1f84760 100644 --- a/src/main/java/com/google/cloud/spanner/jdbc/AbstractJdbcWrapper.java +++ b/src/main/java/com/google/cloud/spanner/jdbc/AbstractJdbcWrapper.java @@ -47,6 +47,7 @@ static int extractColumnType(Type type) { if (type.equals(Type.float64())) return Types.DOUBLE; if (type.equals(Type.int64())) return Types.BIGINT; if (type.equals(Type.numeric())) return Types.NUMERIC; + if (type.equals(Type.pgNumeric())) return Types.NUMERIC; if (type.equals(Type.string())) return Types.NVARCHAR; if (type.equals(Type.json())) return Types.NVARCHAR; if (type.equals(Type.timestamp())) return Types.TIMESTAMP; @@ -106,6 +107,7 @@ static String getClassName(Type type) { if (type == Type.float64()) return Double.class.getName(); if (type == Type.int64()) return Long.class.getName(); if (type == Type.numeric()) return BigDecimal.class.getName(); + if (type == Type.pgNumeric()) return BigDecimal.class.getName(); if (type == Type.string()) return String.class.getName(); if (type == Type.json()) return String.class.getName(); if (type == Type.timestamp()) return Timestamp.class.getName(); @@ -116,6 +118,7 @@ static String getClassName(Type type) { if (type.getArrayElementType() == Type.float64()) return Double[].class.getName(); if (type.getArrayElementType() == Type.int64()) return Long[].class.getName(); if (type.getArrayElementType() == Type.numeric()) return BigDecimal[].class.getName(); + if (type.getArrayElementType() == Type.pgNumeric()) return BigDecimal[].class.getName(); if (type.getArrayElementType() == Type.string()) return String[].class.getName(); if (type.getArrayElementType() == Type.json()) return String[].class.getName(); if (type.getArrayElementType() == Type.timestamp()) return Timestamp[].class.getName(); @@ -145,6 +148,16 @@ static byte checkedCastToByte(BigDecimal val) throws SQLException { } } + /** Cast value and throw {@link SQLException} if out-of-range. */ + static byte checkedCastToByte(BigInteger val) throws SQLException { + try { + return val.byteValueExact(); + } catch (ArithmeticException e) { + throw JdbcSqlExceptionFactory.of( + String.format(OUT_OF_RANGE_MSG, "byte", val), com.google.rpc.Code.OUT_OF_RANGE); + } + } + /** Cast value and throw {@link SQLException} if out-of-range. */ static short checkedCastToShort(long val) throws SQLException { if (val > Short.MAX_VALUE || val < Short.MIN_VALUE) { @@ -164,6 +177,16 @@ static short checkedCastToShort(BigDecimal val) throws SQLException { } } + /** Cast value and throw {@link SQLException} if out-of-range. */ + static short checkedCastToShort(BigInteger val) throws SQLException { + try { + return val.shortValueExact(); + } catch (ArithmeticException e) { + throw JdbcSqlExceptionFactory.of( + String.format(OUT_OF_RANGE_MSG, "short", val), com.google.rpc.Code.OUT_OF_RANGE); + } + } + /** Cast value and throw {@link SQLException} if out-of-range. */ static int checkedCastToInt(long val) throws SQLException { if (val > Integer.MAX_VALUE || val < Integer.MIN_VALUE) { @@ -183,6 +206,16 @@ static int checkedCastToInt(BigDecimal val) throws SQLException { } } + /** Cast value and throw {@link SQLException} if out-of-range. */ + static int checkedCastToInt(BigInteger val) throws SQLException { + try { + return val.intValueExact(); + } catch (ArithmeticException e) { + throw JdbcSqlExceptionFactory.of( + String.format(OUT_OF_RANGE_MSG, "int", val), com.google.rpc.Code.OUT_OF_RANGE); + } + } + /** Cast value and throw {@link SQLException} if out-of-range. */ static float checkedCastToFloat(double val) throws SQLException { if (val > Float.MAX_VALUE || val < -Float.MAX_VALUE) { @@ -226,6 +259,16 @@ static long checkedCastToLong(BigDecimal val) throws SQLException { } } + /** Cast value and throw {@link SQLException} if out-of-range. */ + static long checkedCastToLong(BigInteger val) throws SQLException { + try { + return val.longValueExact(); + } catch (ArithmeticException e) { + throw JdbcSqlExceptionFactory.of( + String.format(OUT_OF_RANGE_MSG, "long", val), com.google.rpc.Code.OUT_OF_RANGE); + } + } + /** * Parses the given string value as a double. Throws {@link SQLException} if the string is not a * valid double value. @@ -240,6 +283,20 @@ static double parseDouble(String val) throws SQLException { } } + /** + * Parses the given string value as a float. Throws {@link SQLException} if the string is not a + * valid float value. + */ + static float parseFloat(String val) throws SQLException { + Preconditions.checkNotNull(val); + try { + return Float.parseFloat(val); + } catch (NumberFormatException e) { + throw JdbcSqlExceptionFactory.of( + String.format("%s is not a valid number", val), com.google.rpc.Code.INVALID_ARGUMENT, e); + } + } + /** * Parses the given string value as a {@link Date} value. Throws {@link SQLException} if the * string is not a valid {@link Date} value. @@ -332,6 +389,20 @@ static Timestamp parseTimestamp(String val, Calendar cal) throws SQLException { } } + /** + * Parses the given string value as a {@link BigDecimal} value. Throws {@link SQLException} if the + * string is not a valid {@link BigDecimal} value. + */ + static BigDecimal parseBigDecimal(String val) throws SQLException { + Preconditions.checkNotNull(val); + try { + return new BigDecimal(val); + } catch (NumberFormatException e) { + throw JdbcSqlExceptionFactory.of( + String.format("%s is not a valid number", val), com.google.rpc.Code.INVALID_ARGUMENT, e); + } + } + /** Should return true if this object has been closed */ public abstract boolean isClosed(); diff --git a/src/main/java/com/google/cloud/spanner/jdbc/CloudSpannerJdbcConnection.java b/src/main/java/com/google/cloud/spanner/jdbc/CloudSpannerJdbcConnection.java index 7801dcfdc..679fa8716 100644 --- a/src/main/java/com/google/cloud/spanner/jdbc/CloudSpannerJdbcConnection.java +++ b/src/main/java/com/google/cloud/spanner/jdbc/CloudSpannerJdbcConnection.java @@ -20,6 +20,7 @@ import com.google.cloud.spanner.AbortedException; import com.google.cloud.spanner.CommitResponse; import com.google.cloud.spanner.CommitStats; +import com.google.cloud.spanner.Dialect; import com.google.cloud.spanner.Mutation; import com.google.cloud.spanner.ResultSet; import com.google.cloud.spanner.TimestampBound; @@ -321,6 +322,11 @@ default String getStatementTag() throws SQLException { */ String getConnectionUrl(); + /** @return The {@link Dialect} that is used by this connection. */ + default Dialect getDialect() { + return Dialect.GOOGLE_STANDARD_SQL; + } + /** * @see * com.google.cloud.spanner.connection.Connection#addTransactionRetryListener(com.google.cloud.spanner.connection.TransactionRetryListener) diff --git a/src/main/java/com/google/cloud/spanner/jdbc/JdbcConnection.java b/src/main/java/com/google/cloud/spanner/jdbc/JdbcConnection.java index 9e4623cab..867695fa8 100644 --- a/src/main/java/com/google/cloud/spanner/jdbc/JdbcConnection.java +++ b/src/main/java/com/google/cloud/spanner/jdbc/JdbcConnection.java @@ -23,7 +23,6 @@ import com.google.cloud.spanner.TimestampBound; import com.google.cloud.spanner.connection.AutocommitDmlMode; import com.google.cloud.spanner.connection.ConnectionOptions; -import com.google.cloud.spanner.connection.StatementParser; import com.google.cloud.spanner.connection.TransactionMode; import com.google.common.collect.Iterators; import java.sql.Array; @@ -72,8 +71,8 @@ public JdbcPreparedStatement prepareStatement(String sql) throws SQLException { @Override public String nativeSQL(String sql) throws SQLException { checkClosed(); - return JdbcParameterStore.convertPositionalParametersToNamedParameters( - StatementParser.removeCommentsAndTrim(sql)) + return getParser() + .convertPositionalParametersToNamedParameters('?', getParser().removeCommentsAndTrim(sql)) .sqlWithNamedParameters; } 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 1a62775b7..7eecb2a67 100644 --- a/src/main/java/com/google/cloud/spanner/jdbc/JdbcDataType.java +++ b/src/main/java/com/google/cloud/spanner/jdbc/JdbcDataType.java @@ -203,6 +203,32 @@ public Type getSpannerType() { return Type.numeric(); } }, + PG_NUMERIC { + @Override + public int getSqlType() { + return Types.NUMERIC; + } + + @Override + public Class getJavaClass() { + return BigDecimal.class; + } + + @Override + public Code getCode() { + return Code.PG_NUMERIC; + } + + @Override + public List getArrayElements(ResultSet rs, int columnIndex) { + return rs.getValue(columnIndex).getNumericArray(); + } + + @Override + public Type getSpannerType() { + return Type.pgNumeric(); + } + }, STRING { @Override public int getSqlType() { diff --git a/src/main/java/com/google/cloud/spanner/jdbc/JdbcParameterMetaData.java b/src/main/java/com/google/cloud/spanner/jdbc/JdbcParameterMetaData.java index f5188653b..a520e221e 100644 --- a/src/main/java/com/google/cloud/spanner/jdbc/JdbcParameterMetaData.java +++ b/src/main/java/com/google/cloud/spanner/jdbc/JdbcParameterMetaData.java @@ -16,7 +16,7 @@ package com.google.cloud.spanner.jdbc; -import com.google.cloud.spanner.jdbc.JdbcParameterStore.ParametersInfo; +import com.google.cloud.spanner.connection.AbstractStatementParser.ParametersInfo; import java.math.BigDecimal; import java.sql.Date; import java.sql.ParameterMetaData; 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 a475646a6..9ba490faa 100644 --- a/src/main/java/com/google/cloud/spanner/jdbc/JdbcParameterStore.java +++ b/src/main/java/com/google/cloud/spanner/jdbc/JdbcParameterStore.java @@ -17,12 +17,12 @@ package com.google.cloud.spanner.jdbc; import com.google.cloud.ByteArray; +import com.google.cloud.spanner.Dialect; import com.google.cloud.spanner.Statement; import com.google.cloud.spanner.Statement.Builder; import com.google.cloud.spanner.Type; import com.google.cloud.spanner.Value; import com.google.cloud.spanner.ValueBinder; -import com.google.cloud.spanner.jdbc.JdbcSqlExceptionFactory.JdbcSqlExceptionImpl; import com.google.common.io.CharStreams; import com.google.rpc.Code; import java.io.IOException; @@ -45,10 +45,13 @@ import java.sql.Time; import java.sql.Timestamp; import java.sql.Types; +import java.time.LocalDate; +import java.time.OffsetDateTime; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.UUID; +import java.util.stream.Collectors; /** This class handles the parameters of a {@link PreparedStatement}. */ class JdbcParameterStore { @@ -78,7 +81,11 @@ private static final class JdbcParameter { */ private int highestIndex = 0; - JdbcParameterStore() {} + private final Dialect dialect; + + JdbcParameterStore(Dialect dialect) { + this.dialect = dialect; + } void clearParameters() { parametersList = new ArrayList<>(INITIAL_PARAMETERS_ARRAY_SIZE); @@ -300,11 +307,18 @@ private boolean isValidTypeAndValue(Object value, int sqlType) { || value instanceof Reader || value instanceof URL; case Types.DATE: + return value instanceof Date + || value instanceof Time + || value instanceof Timestamp + || value instanceof LocalDate; case Types.TIME: case Types.TIME_WITH_TIMEZONE: case Types.TIMESTAMP: case Types.TIMESTAMP_WITH_TIMEZONE: - return value instanceof Date || value instanceof Time || value instanceof Timestamp; + return value instanceof Date + || value instanceof Time + || value instanceof Timestamp + || value instanceof OffsetDateTime; case Types.BINARY: case Types.VARBINARY: case Types.LONGVARBINARY: @@ -365,102 +379,6 @@ private int getParameterArrayIndex(String columnName) { return -1; } - /** Parameter information with positional parameters translated to named parameters. */ - static class ParametersInfo { - final int numberOfParameters; - final String sqlWithNamedParameters; - - private ParametersInfo(int numberOfParameters, String sqlWithNamedParameters) { - this.numberOfParameters = numberOfParameters; - this.sqlWithNamedParameters = sqlWithNamedParameters; - } - } - - /** - * Converts all positional parameters (?) in the given sql string into named parameters. The - * parameters are named @p1, @p2, etc. This method is used when converting a JDBC statement that - * uses positional parameters to a Cloud Spanner {@link Statement} instance that requires named - * parameters. The input SQL string may not contain any comments. - * - * @param sql The sql string without comments that should be converted - * @return A {@link ParametersInfo} object containing a string with named parameters instead of - * positional parameters and the number of parameters. - * @throws JdbcSqlExceptionImpl If the input sql string contains an unclosed string/byte literal. - */ - static ParametersInfo convertPositionalParametersToNamedParameters(String sql) - throws SQLException { - final char POS_PARAM = '?'; - final char SINGLE_QUOTE = '\''; - final char DOUBLE_QUOTE = '"'; - final char BACKTICK_QUOTE = '`'; - boolean isInQuoted = false; - char startQuote = 0; - boolean lastCharWasEscapeChar = false; - boolean isTripleQuoted = false; - int paramIndex = 1; - StringBuilder named = new StringBuilder(sql.length() + countOccurrencesOf(POS_PARAM, sql)); - for (int index = 0; index < sql.length(); index++) { - char c = sql.charAt(index); - if (isInQuoted) { - if ((c == '\n' || c == '\r') && !isTripleQuoted) { - throw JdbcSqlExceptionFactory.of( - "SQL statement contains an unclosed literal: " + sql, Code.INVALID_ARGUMENT); - } else if (c == startQuote) { - if (lastCharWasEscapeChar) { - lastCharWasEscapeChar = false; - } else if (isTripleQuoted) { - if (sql.length() > index + 2 - && sql.charAt(index + 1) == startQuote - && sql.charAt(index + 2) == startQuote) { - isInQuoted = false; - startQuote = 0; - isTripleQuoted = false; - } - } else { - isInQuoted = false; - startQuote = 0; - } - } else { - lastCharWasEscapeChar = (c == '\\'); - } - named.append(c); - } else { - if (c == POS_PARAM) { - named.append("@p").append(paramIndex); - paramIndex++; - } else { - if (c == SINGLE_QUOTE || c == DOUBLE_QUOTE || c == BACKTICK_QUOTE) { - isInQuoted = true; - startQuote = c; - // check whether it is a triple-quote - if (sql.length() > index + 2 - && sql.charAt(index + 1) == startQuote - && sql.charAt(index + 2) == startQuote) { - isTripleQuoted = true; - } - } - named.append(c); - } - } - } - if (isInQuoted) { - throw JdbcSqlExceptionFactory.of( - "SQL statement contains an unclosed literal: " + sql, Code.INVALID_ARGUMENT); - } - return new ParametersInfo(paramIndex - 1, named.toString()); - } - - /** Convenience method that is used to estimate the number of parameters in a SQL statement. */ - private static int countOccurrencesOf(char c, String string) { - int res = 0; - for (int i = 0; i < string.length(); i++) { - if (string.charAt(i) == c) { - res++; - } - } - return res; - } - /** Bind a JDBC parameter to a parameter on a Spanner {@link Statement}. */ Builder bindParameterValue(ValueBinder binder, int index) throws SQLException { return setValue(binder, getParameter(index), getType(index)); @@ -531,18 +449,25 @@ private Builder setParamWithKnownType(ValueBinder binder, Object value, throw JdbcSqlExceptionFactory.of(value + " is not a valid double", Code.INVALID_ARGUMENT); case Types.NUMERIC: case Types.DECIMAL: - if (value instanceof Number) { - if (value instanceof BigDecimal) { - return binder.to((BigDecimal) value); + if (dialect == Dialect.POSTGRESQL) { + if (value instanceof Number) { + return binder.to(Value.pgNumeric(value.toString())); } - try { - return binder.to(new BigDecimal(value.toString())); - } catch (NumberFormatException e) { - // ignore and fall through to the exception. + throw JdbcSqlExceptionFactory.of(value + " is not a valid Number", Code.INVALID_ARGUMENT); + } else { + if (value instanceof Number) { + if (value instanceof BigDecimal) { + return binder.to((BigDecimal) value); + } + try { + return binder.to(new BigDecimal(value.toString())); + } catch (NumberFormatException e) { + // ignore and fall through to the exception. + } } + throw JdbcSqlExceptionFactory.of( + value + " is not a valid BigDecimal", Code.INVALID_ARGUMENT); } - throw JdbcSqlExceptionFactory.of( - value + " is not a valid BigDecimal", Code.INVALID_ARGUMENT); case Types.CHAR: case Types.VARCHAR: case Types.LONGVARCHAR: @@ -584,6 +509,11 @@ private Builder setParamWithKnownType(ValueBinder binder, Object value, return binder.to(JdbcTypeConverter.toGoogleDate((Time) value)); } else if (value instanceof Timestamp) { return binder.to(JdbcTypeConverter.toGoogleDate((Timestamp) value)); + } else if (value instanceof LocalDate) { + LocalDate localDate = (LocalDate) value; + return binder.to( + com.google.cloud.Date.fromYearMonthDay( + localDate.getYear(), localDate.getMonthValue(), localDate.getDayOfMonth())); } throw JdbcSqlExceptionFactory.of(value + " is not a valid date", Code.INVALID_ARGUMENT); case Types.TIME: @@ -596,6 +526,11 @@ private Builder setParamWithKnownType(ValueBinder binder, Object value, return binder.to(JdbcTypeConverter.toGoogleTimestamp((Time) value)); } else if (value instanceof Timestamp) { return binder.to(JdbcTypeConverter.toGoogleTimestamp((Timestamp) value)); + } else if (value instanceof OffsetDateTime) { + OffsetDateTime offsetDateTime = (OffsetDateTime) value; + return binder.to( + com.google.cloud.Timestamp.ofTimeSecondsAndNanos( + offsetDateTime.toEpochSecond(), offsetDateTime.getNano())); } throw JdbcSqlExceptionFactory.of( value + " is not a valid timestamp", Code.INVALID_ARGUMENT); @@ -698,12 +633,26 @@ private Builder setParamWithUnknownType(ValueBinder binder, Object valu } else if (Double.class.isAssignableFrom(value.getClass())) { return binder.to(((Double) value).doubleValue()); } else if (BigDecimal.class.isAssignableFrom(value.getClass())) { - return binder.to((BigDecimal) value); + if (dialect == Dialect.POSTGRESQL) { + return binder.to(Value.pgNumeric(value.toString())); + } else { + return binder.to((BigDecimal) value); + } } else if (Date.class.isAssignableFrom(value.getClass())) { Date dateValue = (Date) value; return binder.to(JdbcTypeConverter.toGoogleDate(dateValue)); + } else if (LocalDate.class.isAssignableFrom(value.getClass())) { + LocalDate localDate = (LocalDate) value; + return binder.to( + com.google.cloud.Date.fromYearMonthDay( + localDate.getYear(), localDate.getMonthValue(), localDate.getDayOfMonth())); } else if (Timestamp.class.isAssignableFrom(value.getClass())) { return binder.to(JdbcTypeConverter.toGoogleTimestamp((Timestamp) value)); + } else if (OffsetDateTime.class.isAssignableFrom(value.getClass())) { + OffsetDateTime offsetDateTime = (OffsetDateTime) value; + return binder.to( + com.google.cloud.Timestamp.ofTimeSecondsAndNanos( + offsetDateTime.toEpochSecond(), offsetDateTime.getNano())); } else if (Time.class.isAssignableFrom(value.getClass())) { Time timeValue = (Time) value; return binder.to(JdbcTypeConverter.toGoogleTimestamp(new Timestamp(timeValue.getTime()))); @@ -785,7 +734,11 @@ private Builder setArrayValue(ValueBinder binder, int type, Object valu return binder.toFloat64Array((double[]) null); case Types.NUMERIC: case Types.DECIMAL: - return binder.toNumericArray(null); + if (dialect == Dialect.POSTGRESQL) { + return binder.toPgNumericArray(null); + } else { + return binder.toNumericArray(null); + } case Types.CHAR: case Types.VARCHAR: case Types.LONGVARCHAR: @@ -850,7 +803,14 @@ private Builder setArrayValue(ValueBinder binder, int type, Object valu } else if (Double[].class.isAssignableFrom(value.getClass())) { return binder.toFloat64Array(toDoubleList((Double[]) value)); } else if (BigDecimal[].class.isAssignableFrom(value.getClass())) { - return binder.toNumericArray(Arrays.asList((BigDecimal[]) value)); + if (dialect == Dialect.POSTGRESQL) { + return binder.toPgNumericArray( + Arrays.stream((BigDecimal[]) value) + .map(bigDecimal -> bigDecimal == null ? null : bigDecimal.toString()) + .collect(Collectors.toList())); + } else { + return binder.toNumericArray(Arrays.asList((BigDecimal[]) value)); + } } else if (Date[].class.isAssignableFrom(value.getClass())) { return binder.toDateArray(JdbcTypeConverter.toGoogleDates((Date[]) value)); } else if (Timestamp[].class.isAssignableFrom(value.getClass())) { @@ -908,7 +868,11 @@ private Builder setNullValue(ValueBinder binder, Integer sqlType) { return binder.to((com.google.cloud.Date) null); case Types.NUMERIC: case Types.DECIMAL: - return binder.to((BigDecimal) null); + if (dialect == Dialect.POSTGRESQL) { + return binder.to(Value.pgNumeric(null)); + } else { + return binder.to((BigDecimal) null); + } case Types.DOUBLE: return binder.to((Double) null); case Types.FLOAT: diff --git a/src/main/java/com/google/cloud/spanner/jdbc/JdbcPreparedStatement.java b/src/main/java/com/google/cloud/spanner/jdbc/JdbcPreparedStatement.java index a25b39c04..a1b327b23 100644 --- a/src/main/java/com/google/cloud/spanner/jdbc/JdbcPreparedStatement.java +++ b/src/main/java/com/google/cloud/spanner/jdbc/JdbcPreparedStatement.java @@ -19,10 +19,10 @@ import com.google.cloud.spanner.Options.QueryOption; import com.google.cloud.spanner.ReadContext.QueryAnalyzeMode; import com.google.cloud.spanner.ResultSets; +import com.google.cloud.spanner.SpannerException; import com.google.cloud.spanner.Statement; import com.google.cloud.spanner.Type; -import com.google.cloud.spanner.connection.StatementParser; -import com.google.cloud.spanner.jdbc.JdbcParameterStore.ParametersInfo; +import com.google.cloud.spanner.connection.AbstractStatementParser.ParametersInfo; import com.google.common.annotations.VisibleForTesting; import com.google.common.collect.ImmutableList; import java.sql.PreparedStatement; @@ -32,6 +32,7 @@ /** Implementation of {@link PreparedStatement} for Cloud Spanner. */ class JdbcPreparedStatement extends AbstractJdbcPreparedStatement { + private static final char POS_PARAM_CHAR = '?'; private final String sql; private final String sqlWithoutComments; private final ParametersInfo parameters; @@ -39,9 +40,13 @@ class JdbcPreparedStatement extends AbstractJdbcPreparedStatement { JdbcPreparedStatement(JdbcConnection connection, String sql) throws SQLException { super(connection); this.sql = sql; - this.sqlWithoutComments = StatementParser.removeCommentsAndTrim(this.sql); - this.parameters = - JdbcParameterStore.convertPositionalParametersToNamedParameters(sqlWithoutComments); + try { + this.sqlWithoutComments = parser.removeCommentsAndTrim(this.sql); + this.parameters = + parser.convertPositionalParametersToNamedParameters(POS_PARAM_CHAR, sqlWithoutComments); + } catch (SpannerException e) { + throw JdbcSqlExceptionFactory.of(e); + } } ParametersInfo getParametersInfo() { @@ -102,7 +107,7 @@ public JdbcParameterMetaData getParameterMetaData() throws SQLException { @Override public ResultSetMetaData getMetaData() throws SQLException { checkClosed(); - if (StatementParser.INSTANCE.isUpdateStatement(sql)) { + if (getConnection().getParser().isUpdateStatement(sql)) { // Return metadata for an empty result set as DML statements do not return any results (as a // result set). com.google.cloud.spanner.ResultSet resultSet = 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 c06c147f8..089ef7b21 100644 --- a/src/main/java/com/google/cloud/spanner/jdbc/JdbcResultSet.java +++ b/src/main/java/com/google/cloud/spanner/jdbc/JdbcResultSet.java @@ -16,8 +16,11 @@ package com.google.cloud.spanner.jdbc; +import static com.google.cloud.spanner.Type.Code.PG_NUMERIC; + import com.google.cloud.spanner.Type; import com.google.cloud.spanner.Type.Code; +import com.google.cloud.spanner.Value; import com.google.common.base.Preconditions; import java.io.ByteArrayInputStream; import java.io.InputStream; @@ -121,6 +124,12 @@ SQLException createInvalidToGetAs(String sqlType, Code type) { com.google.rpc.Code.INVALID_ARGUMENT); } + SQLException createCastException(String sqlType, Object value) { + return JdbcSqlExceptionFactory.of( + String.format("Cannot cast to %s: %s", sqlType, value), + com.google.rpc.Code.INVALID_ARGUMENT); + } + @Override public String getString(int columnIndex) throws SQLException { checkClosedAndValidRow(); @@ -140,6 +149,8 @@ public String getString(int columnIndex) throws SQLException { return isNull ? null : Long.toString(spanner.getLong(spannerIndex)); case NUMERIC: return isNull ? null : spanner.getBigDecimal(spannerIndex).toString(); + case PG_NUMERIC: + return isNull ? null : spanner.getString(spannerIndex); case STRING: return isNull ? null : spanner.getString(spannerIndex); case JSON: @@ -168,6 +179,8 @@ public boolean getBoolean(int columnIndex) throws SQLException { return !isNull && spanner.getLong(spannerIndex) != 0L; case NUMERIC: return !isNull && !spanner.getBigDecimal(spannerIndex).equals(BigDecimal.ZERO); + case PG_NUMERIC: + return !isNull && !spanner.getString(spannerIndex).equals("0"); case STRING: return !isNull && Boolean.parseBoolean(spanner.getString(spannerIndex)); case BYTES: @@ -198,6 +211,10 @@ public byte getByte(int columnIndex) throws SQLException { return isNull ? (byte) 0 : checkedCastToByte(spanner.getLong(spannerIndex)); case NUMERIC: return isNull ? (byte) 0 : checkedCastToByte(spanner.getBigDecimal(spannerIndex)); + case PG_NUMERIC: + return isNull + ? (byte) 0 + : checkedCastToByte(parseBigDecimal(spanner.getString(spannerIndex)).toBigInteger()); case STRING: return isNull ? (byte) 0 : checkedCastToByte(parseLong(spanner.getString(spannerIndex))); case BYTES: @@ -228,6 +245,10 @@ public short getShort(int columnIndex) throws SQLException { return isNull ? 0 : checkedCastToShort(spanner.getLong(spannerIndex)); case NUMERIC: return isNull ? 0 : checkedCastToShort(spanner.getBigDecimal(spannerIndex)); + case PG_NUMERIC: + return isNull + ? 0 + : checkedCastToShort(parseBigDecimal(spanner.getString(spannerIndex)).toBigInteger()); case STRING: return isNull ? 0 : checkedCastToShort(parseLong(spanner.getString(spannerIndex))); case BYTES: @@ -258,6 +279,10 @@ public int getInt(int columnIndex) throws SQLException { return isNull ? 0 : checkedCastToInt(spanner.getLong(spannerIndex)); case NUMERIC: return isNull ? 0 : checkedCastToInt(spanner.getBigDecimal(spannerIndex)); + case PG_NUMERIC: + return isNull + ? 0 + : checkedCastToInt(parseBigDecimal(spanner.getString(spannerIndex)).toBigInteger()); case STRING: return isNull ? 0 : checkedCastToInt(parseLong(spanner.getString(spannerIndex))); case BYTES: @@ -285,7 +310,11 @@ public long getLong(int columnIndex) throws SQLException { case INT64: return isNull ? 0L : spanner.getLong(spannerIndex); case NUMERIC: - return isNull ? 0L : checkedCastToLong(spanner.getBigDecimal(spannerIndex)); + return isNull ? 0 : checkedCastToLong(parseBigDecimal(spanner.getString(spannerIndex))); + case PG_NUMERIC: + return isNull + ? 0L + : checkedCastToLong(parseBigDecimal(spanner.getString(spannerIndex)).toBigInteger()); case STRING: return isNull ? 0L : parseLong(spanner.getString(spannerIndex)); case BYTES: @@ -314,6 +343,8 @@ public float getFloat(int columnIndex) throws SQLException { return isNull ? 0 : checkedCastToFloat(spanner.getLong(spannerIndex)); case NUMERIC: return isNull ? 0 : spanner.getBigDecimal(spannerIndex).floatValue(); + case PG_NUMERIC: + return isNull ? 0 : parseFloat(spanner.getString(spannerIndex)); case STRING: return isNull ? 0 : checkedCastToFloat(parseDouble(spanner.getString(spannerIndex))); case BYTES: @@ -342,6 +373,8 @@ public double getDouble(int columnIndex) throws SQLException { return isNull ? 0 : spanner.getLong(spannerIndex); case NUMERIC: return isNull ? 0 : spanner.getBigDecimal(spannerIndex).doubleValue(); + case PG_NUMERIC: + return isNull ? 0 : parseDouble(spanner.getString(spannerIndex)); case STRING: return isNull ? 0 : parseDouble(spanner.getString(spannerIndex)); case BYTES: @@ -358,7 +391,9 @@ public double getDouble(int columnIndex) throws SQLException { @Override public byte[] getBytes(int columnIndex) throws SQLException { checkClosedAndValidRow(); - return isNull(columnIndex) ? null : spanner.getBytes(columnIndex - 1).toByteArray(); + final boolean isNull = isNull(columnIndex); + final int spannerIndex = columnIndex - 1; + return isNull ? null : spanner.getBytes(spannerIndex).toByteArray(); } @Override @@ -380,6 +415,7 @@ public Date getDate(int columnIndex) throws SQLException { case FLOAT64: case INT64: case NUMERIC: + case PG_NUMERIC: case BYTES: case JSON: case STRUCT: @@ -405,6 +441,7 @@ public Time getTime(int columnIndex) throws SQLException { case FLOAT64: case INT64: case NUMERIC: + case PG_NUMERIC: case BYTES: case JSON: case STRUCT: @@ -431,6 +468,7 @@ public Timestamp getTimestamp(int columnIndex) throws SQLException { case FLOAT64: case INT64: case NUMERIC: + case PG_NUMERIC: case BYTES: case JSON: case STRUCT: @@ -587,6 +625,14 @@ private Object getObject(Type type, int columnIndex) throws SQLException { if (type == Type.float64()) return getDouble(columnIndex); if (type == Type.int64()) 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()) return getString(columnIndex); if (type == Type.timestamp()) return getTimestamp(columnIndex); @@ -665,6 +711,9 @@ private BigDecimal getBigDecimal(int columnIndex, boolean fixedScale, int scale) case NUMERIC: res = isNull ? null : spanner.getBigDecimal(spannerIndex); break; + case PG_NUMERIC: + res = isNull ? null : parseBigDecimal(spanner.getString(spannerIndex)); + break; case STRING: try { res = isNull ? null : new BigDecimal(spanner.getString(spannerIndex)); @@ -735,10 +784,16 @@ public Array getArray(int columnIndex) throws SQLException { throw JdbcSqlExceptionFactory.of( "Column with index " + columnIndex + " does not contain an array", com.google.rpc.Code.INVALID_ARGUMENT); - JdbcDataType dataType = JdbcDataType.getType(type.getArrayElementType().getCode()); - List elements = dataType.getArrayElements(spanner, columnIndex - 1); - - return JdbcArray.createArray(dataType, elements); + final Code elementCode = type.getArrayElementType().getCode(); + final JdbcDataType dataType = JdbcDataType.getType(elementCode); + try { + List elements = dataType.getArrayElements(spanner, columnIndex - 1); + return JdbcArray.createArray(dataType, elements); + } catch (NumberFormatException e) { + final String sqlType = "ARRAY<" + type.getArrayElementType() + ">"; + final Value value = spanner.getValue(columnIndex - 1); + throw createCastException(sqlType, value); + } } @Override diff --git a/src/main/java/com/google/cloud/spanner/jdbc/JdbcResultSetMetaData.java b/src/main/java/com/google/cloud/spanner/jdbc/JdbcResultSetMetaData.java index 69162e807..ae312e909 100644 --- a/src/main/java/com/google/cloud/spanner/jdbc/JdbcResultSetMetaData.java +++ b/src/main/java/com/google/cloud/spanner/jdbc/JdbcResultSetMetaData.java @@ -137,6 +137,7 @@ public int getPrecision(int column) { case Types.DOUBLE: return 14; case Types.BIGINT: + case Types.INTEGER: return 10; case Types.NUMERIC: return 14; diff --git a/src/main/java/com/google/cloud/spanner/jdbc/JdbcStatement.java b/src/main/java/com/google/cloud/spanner/jdbc/JdbcStatement.java index 3674ae02b..5c4bbed9e 100644 --- a/src/main/java/com/google/cloud/spanner/jdbc/JdbcStatement.java +++ b/src/main/java/com/google/cloud/spanner/jdbc/JdbcStatement.java @@ -23,7 +23,6 @@ import com.google.cloud.spanner.Statement; import com.google.cloud.spanner.Type; import com.google.cloud.spanner.Type.StructField; -import com.google.cloud.spanner.connection.StatementParser; import com.google.cloud.spanner.connection.StatementResult; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Preconditions; @@ -49,7 +48,7 @@ enum BatchType { private BatchType currentBatchType = BatchType.NONE; final List batchedStatements = new ArrayList<>(); - JdbcStatement(JdbcConnection connection) { + JdbcStatement(JdbcConnection connection) throws SQLException { super(connection); } @@ -199,10 +198,10 @@ public int getFetchSize() throws SQLException { * @throws SQLException if the sql statement is not allowed for batching. */ private BatchType determineStatementBatchType(String sql) throws SQLException { - String sqlWithoutComments = StatementParser.removeCommentsAndTrim(sql); - if (StatementParser.INSTANCE.isDdlStatement(sqlWithoutComments)) { + String sqlWithoutComments = parser.removeCommentsAndTrim(sql); + if (parser.isDdlStatement(sqlWithoutComments)) { return BatchType.DDL; - } else if (StatementParser.INSTANCE.isUpdateStatement(sqlWithoutComments)) { + } else if (parser.isUpdateStatement(sqlWithoutComments)) { return BatchType.DML; } throw JdbcSqlExceptionFactory.of( 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 56043b041..f6cba1dcd 100644 --- a/src/main/java/com/google/cloud/spanner/jdbc/JdbcTypeConverter.java +++ b/src/main/java/com/google/cloud/spanner/jdbc/JdbcTypeConverter.java @@ -29,15 +29,17 @@ import java.sql.Array; import java.sql.SQLException; import java.sql.Time; +import java.time.Instant; +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.Arrays; import java.util.Calendar; import java.util.List; import java.util.concurrent.TimeUnit; -import org.threeten.bp.Instant; -import org.threeten.bp.ZoneId; -import org.threeten.bp.ZonedDateTime; -import org.threeten.bp.format.DateTimeFormatter; /** Convenience class for converting values between Java, JDBC and Cloud Spanner. */ class JdbcTypeConverter { @@ -144,9 +146,22 @@ static Object convert(Object value, Type type, Class targetType) throws SQLEx if (targetType.equals(java.sql.Date.class)) { if (type.getCode() == Code.DATE) return value; } + if (targetType.equals(LocalDate.class)) { + if (type.getCode() == Code.DATE) { + return ((java.sql.Date) value).toLocalDate(); + } + } if (targetType.equals(java.sql.Timestamp.class)) { if (type.getCode() == Code.TIMESTAMP) return value; } + if (targetType.equals(OffsetDateTime.class)) { + if (type.getCode() == Code.TIMESTAMP) { + Timestamp timestamp = Timestamp.of((java.sql.Timestamp) value); + return OffsetDateTime.ofInstant( + Instant.ofEpochSecond(timestamp.getSeconds(), timestamp.getNanos()), + ZoneId.systemDefault()); + } + } if (targetType.equals(java.sql.Array.class)) { if (type.getCode() == Code.ARRAY) return value; } @@ -182,6 +197,9 @@ private static Value convertToSpannerValue(Object value, Type type) throws SQLEx case NUMERIC: return Value.numericArray( Arrays.asList((BigDecimal[]) ((java.sql.Array) value).getArray())); + case PG_NUMERIC: + return Value.pgNumericArray( + Arrays.asList((String[]) ((java.sql.Array) value).getArray())); case STRING: return Value.stringArray(Arrays.asList((String[]) ((java.sql.Array) value).getArray())); case TIMESTAMP: @@ -206,6 +224,8 @@ private static Value convertToSpannerValue(Object value, Type type) throws SQLEx return Value.int64((Long) value); case NUMERIC: return Value.numeric((BigDecimal) value); + case PG_NUMERIC: + return Value.pgNumeric(value == null ? null : value.toString()); case STRING: return Value.string((String) value); case TIMESTAMP: diff --git a/src/main/resources/com/google/cloud/spanner/jdbc/DatabaseMetaData_GetColumns.sql b/src/main/resources/com/google/cloud/spanner/jdbc/DatabaseMetaData_GetColumns.sql index 14106ee1e..3ee102a8c 100644 --- a/src/main/resources/com/google/cloud/spanner/jdbc/DatabaseMetaData_GetColumns.sql +++ b/src/main/resources/com/google/cloud/spanner/jdbc/DatabaseMetaData_GetColumns.sql @@ -1,3 +1,4 @@ +/*GSQL*/ /* * Copyright 2019 Google LLC * @@ -84,4 +85,4 @@ WHERE UPPER(C.TABLE_CATALOG) LIKE ? AND UPPER(C.TABLE_SCHEMA) LIKE ? AND UPPER(C.TABLE_NAME) LIKE ? AND UPPER(C.COLUMN_NAME) LIKE ? -ORDER BY TABLE_CATALOG, TABLE_SCHEMA, TABLE_NAME, ORDINAL_POSITION \ No newline at end of file +ORDER BY TABLE_CATALOG, TABLE_SCHEMA, TABLE_NAME, ORDINAL_POSITION diff --git a/src/main/resources/com/google/cloud/spanner/jdbc/DatabaseMetaData_GetCrossReferences.sql b/src/main/resources/com/google/cloud/spanner/jdbc/DatabaseMetaData_GetCrossReferences.sql index 7d362ca58..d5c1620c2 100644 --- a/src/main/resources/com/google/cloud/spanner/jdbc/DatabaseMetaData_GetCrossReferences.sql +++ b/src/main/resources/com/google/cloud/spanner/jdbc/DatabaseMetaData_GetCrossReferences.sql @@ -1,3 +1,4 @@ +/*GSQL*/ /* * Copyright 2019 Google LLC * diff --git a/src/main/resources/com/google/cloud/spanner/jdbc/DatabaseMetaData_GetExportedKeys.sql b/src/main/resources/com/google/cloud/spanner/jdbc/DatabaseMetaData_GetExportedKeys.sql index 631886d71..ba6630c40 100644 --- a/src/main/resources/com/google/cloud/spanner/jdbc/DatabaseMetaData_GetExportedKeys.sql +++ b/src/main/resources/com/google/cloud/spanner/jdbc/DatabaseMetaData_GetExportedKeys.sql @@ -1,3 +1,4 @@ +/*GSQL*/ /* * Copyright 2019 Google LLC * diff --git a/src/main/resources/com/google/cloud/spanner/jdbc/DatabaseMetaData_GetImportedKeys.sql b/src/main/resources/com/google/cloud/spanner/jdbc/DatabaseMetaData_GetImportedKeys.sql index 50994a55b..4bc9296fb 100644 --- a/src/main/resources/com/google/cloud/spanner/jdbc/DatabaseMetaData_GetImportedKeys.sql +++ b/src/main/resources/com/google/cloud/spanner/jdbc/DatabaseMetaData_GetImportedKeys.sql @@ -1,3 +1,4 @@ +/*GSQL*/ /* * Copyright 2019 Google LLC * diff --git a/src/main/resources/com/google/cloud/spanner/jdbc/DatabaseMetaData_GetIndexInfo.sql b/src/main/resources/com/google/cloud/spanner/jdbc/DatabaseMetaData_GetIndexInfo.sql index e0b2a2f60..5fddb8b06 100644 --- a/src/main/resources/com/google/cloud/spanner/jdbc/DatabaseMetaData_GetIndexInfo.sql +++ b/src/main/resources/com/google/cloud/spanner/jdbc/DatabaseMetaData_GetIndexInfo.sql @@ -1,3 +1,4 @@ +/*GSQL*/ /* * Copyright 2019 Google LLC * diff --git a/src/main/resources/com/google/cloud/spanner/jdbc/DatabaseMetaData_GetPrimaryKeys.sql b/src/main/resources/com/google/cloud/spanner/jdbc/DatabaseMetaData_GetPrimaryKeys.sql index cec8e7e05..455c474c2 100644 --- a/src/main/resources/com/google/cloud/spanner/jdbc/DatabaseMetaData_GetPrimaryKeys.sql +++ b/src/main/resources/com/google/cloud/spanner/jdbc/DatabaseMetaData_GetPrimaryKeys.sql @@ -1,3 +1,4 @@ +/*GSQL*/ /* * Copyright 2019 Google LLC * diff --git a/src/main/resources/com/google/cloud/spanner/jdbc/DatabaseMetaData_GetSchemas.sql b/src/main/resources/com/google/cloud/spanner/jdbc/DatabaseMetaData_GetSchemas.sql index 1e302623f..3903236b1 100644 --- a/src/main/resources/com/google/cloud/spanner/jdbc/DatabaseMetaData_GetSchemas.sql +++ b/src/main/resources/com/google/cloud/spanner/jdbc/DatabaseMetaData_GetSchemas.sql @@ -1,3 +1,4 @@ +/*GSQL*/ /* * Copyright 2019 Google LLC * diff --git a/src/main/resources/com/google/cloud/spanner/jdbc/DatabaseMetaData_GetTables.sql b/src/main/resources/com/google/cloud/spanner/jdbc/DatabaseMetaData_GetTables.sql index 1d4855810..8b3083bda 100644 --- a/src/main/resources/com/google/cloud/spanner/jdbc/DatabaseMetaData_GetTables.sql +++ b/src/main/resources/com/google/cloud/spanner/jdbc/DatabaseMetaData_GetTables.sql @@ -1,3 +1,4 @@ +/*GSQL*/ /* * Copyright 2019 Google LLC * diff --git a/src/test/java/com/google/cloud/spanner/jdbc/AbstractJdbcWrapperTest.java b/src/test/java/com/google/cloud/spanner/jdbc/AbstractJdbcWrapperTest.java index 3569e67c5..372bbb090 100644 --- a/src/test/java/com/google/cloud/spanner/jdbc/AbstractJdbcWrapperTest.java +++ b/src/test/java/com/google/cloud/spanner/jdbc/AbstractJdbcWrapperTest.java @@ -17,10 +17,15 @@ package com.google.cloud.spanner.jdbc; import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; import com.google.cloud.Timestamp; import com.google.rpc.Code; +import java.math.BigDecimal; +import java.math.BigInteger; import java.sql.Date; import java.sql.SQLException; import java.sql.Time; @@ -67,10 +72,22 @@ public void testUnwrap() { assertThat(unwrapSucceeds(subject, getClass())).isFalse(); } - private static final class CheckedCastToByteChecker { - public boolean cast(Long val) { + @FunctionalInterface + private interface SqlFunction { + R apply(T value) throws SQLException; + } + + private static final class CheckedCastChecker { + + private final SqlFunction checker; + + public CheckedCastChecker(SqlFunction checker) { + this.checker = checker; + } + + public boolean cast(T value) { try { - AbstractJdbcWrapper.checkedCastToByte(val); + checker.apply(value); return true; } catch (SQLException e) { return false; @@ -80,7 +97,8 @@ public boolean cast(Long val) { @Test public void testCheckedCastToByte() { - CheckedCastToByteChecker checker = new CheckedCastToByteChecker(); + final CheckedCastChecker checker = + new CheckedCastChecker<>(AbstractJdbcWrapper::checkedCastToByte); assertThat(checker.cast(0L)).isTrue(); assertThat(checker.cast(1L)).isTrue(); assertThat(checker.cast((long) Byte.MAX_VALUE)).isTrue(); @@ -92,20 +110,38 @@ public void testCheckedCastToByte() { assertThat(checker.cast(Long.MIN_VALUE)).isFalse(); } - private static final class CheckedCastToShortChecker { - public boolean cast(Long val) { - try { - AbstractJdbcWrapper.checkedCastToShort(val); - return true; - } catch (SQLException e) { - return false; - } - } + @Test + public void testCheckedCastFromBigDecimalToByte() { + final CheckedCastChecker checker = + new CheckedCastChecker<>(AbstractJdbcWrapper::checkedCastToByte); + assertTrue(checker.cast(BigDecimal.ZERO)); + assertTrue(checker.cast(BigDecimal.ONE)); + assertTrue(checker.cast(BigDecimal.valueOf(-1))); + assertTrue(checker.cast(BigDecimal.valueOf(Byte.MIN_VALUE))); + assertTrue(checker.cast(BigDecimal.valueOf(Byte.MAX_VALUE))); + + assertFalse(checker.cast(BigDecimal.valueOf(Byte.MAX_VALUE).add(BigDecimal.ONE))); + assertFalse(checker.cast(BigDecimal.valueOf(Byte.MIN_VALUE).subtract(BigDecimal.ONE))); + } + + @Test + public void testCheckedCastFromBigIntegerToByte() { + final CheckedCastChecker checker = + new CheckedCastChecker<>(AbstractJdbcWrapper::checkedCastToByte); + assertTrue(checker.cast(BigInteger.ZERO)); + assertTrue(checker.cast(BigInteger.ONE)); + assertTrue(checker.cast(BigInteger.valueOf(-1))); + assertTrue(checker.cast(BigInteger.valueOf(Byte.MIN_VALUE))); + assertTrue(checker.cast(BigInteger.valueOf(Byte.MAX_VALUE))); + + assertFalse(checker.cast(BigInteger.valueOf(Byte.MAX_VALUE).add(BigInteger.ONE))); + assertFalse(checker.cast(BigInteger.valueOf(Byte.MIN_VALUE).subtract(BigInteger.ONE))); } @Test public void testCheckedCastToShort() { - CheckedCastToShortChecker checker = new CheckedCastToShortChecker(); + final CheckedCastChecker checker = + new CheckedCastChecker<>(AbstractJdbcWrapper::checkedCastToShort); assertThat(checker.cast(0L)).isTrue(); assertThat(checker.cast(1L)).isTrue(); assertThat(checker.cast((long) Short.MAX_VALUE)).isTrue(); @@ -117,20 +153,38 @@ public void testCheckedCastToShort() { assertThat(checker.cast(Long.MIN_VALUE)).isFalse(); } - private static final class CheckedCastToIntChecker { - public boolean cast(Long val) { - try { - AbstractJdbcWrapper.checkedCastToInt(val); - return true; - } catch (SQLException e) { - return false; - } - } + @Test + public void testCheckedCastFromBigDecimalToShort() { + final CheckedCastChecker checker = + new CheckedCastChecker<>(AbstractJdbcWrapper::checkedCastToShort); + assertTrue(checker.cast(BigDecimal.ZERO)); + assertTrue(checker.cast(BigDecimal.ONE)); + assertTrue(checker.cast(BigDecimal.valueOf(-1))); + assertTrue(checker.cast(BigDecimal.valueOf(Short.MIN_VALUE))); + assertTrue(checker.cast(BigDecimal.valueOf(Short.MAX_VALUE))); + + assertFalse(checker.cast(BigDecimal.valueOf(Short.MAX_VALUE).add(BigDecimal.ONE))); + assertFalse(checker.cast(BigDecimal.valueOf(Short.MIN_VALUE).subtract(BigDecimal.ONE))); + } + + @Test + public void testCheckedCastFromBigIntegerToShort() { + final CheckedCastChecker checker = + new CheckedCastChecker<>(AbstractJdbcWrapper::checkedCastToShort); + assertTrue(checker.cast(BigInteger.ZERO)); + assertTrue(checker.cast(BigInteger.ONE)); + assertTrue(checker.cast(BigInteger.valueOf(-1))); + assertTrue(checker.cast(BigInteger.valueOf(Short.MIN_VALUE))); + assertTrue(checker.cast(BigInteger.valueOf(Short.MAX_VALUE))); + + assertFalse(checker.cast(BigInteger.valueOf(Short.MAX_VALUE).add(BigInteger.ONE))); + assertFalse(checker.cast(BigInteger.valueOf(Short.MIN_VALUE).subtract(BigInteger.ONE))); } @Test public void testCheckedCastToInt() { - CheckedCastToIntChecker checker = new CheckedCastToIntChecker(); + final CheckedCastChecker checker = + new CheckedCastChecker<>(AbstractJdbcWrapper::checkedCastToInt); assertThat(checker.cast(0L)).isTrue(); assertThat(checker.cast(1L)).isTrue(); assertThat(checker.cast((long) Integer.MAX_VALUE)).isTrue(); @@ -142,20 +196,66 @@ public void testCheckedCastToInt() { assertThat(checker.cast(Long.MIN_VALUE)).isFalse(); } - private static final class CheckedCastToFloatChecker { - public boolean cast(Double val) { - try { - AbstractJdbcWrapper.checkedCastToFloat(val); - return true; - } catch (SQLException e) { - return false; - } - } + @Test + public void testCheckedCastFromBigDecimalToInt() { + final CheckedCastChecker checker = + new CheckedCastChecker<>(AbstractJdbcWrapper::checkedCastToInt); + assertTrue(checker.cast(BigDecimal.ZERO)); + assertTrue(checker.cast(BigDecimal.ONE)); + assertTrue(checker.cast(BigDecimal.valueOf(-1))); + assertTrue(checker.cast(BigDecimal.valueOf(Integer.MIN_VALUE))); + assertTrue(checker.cast(BigDecimal.valueOf(Integer.MAX_VALUE))); + + assertFalse(checker.cast(BigDecimal.valueOf(Integer.MAX_VALUE).add(BigDecimal.ONE))); + assertFalse(checker.cast(BigDecimal.valueOf(Integer.MIN_VALUE).subtract(BigDecimal.ONE))); + } + + @Test + public void testCheckedCastFromBigIntegerToInt() { + final CheckedCastChecker checker = + new CheckedCastChecker<>(AbstractJdbcWrapper::checkedCastToInt); + assertTrue(checker.cast(BigInteger.ZERO)); + assertTrue(checker.cast(BigInteger.ONE)); + assertTrue(checker.cast(BigInteger.valueOf(-1))); + assertTrue(checker.cast(BigInteger.valueOf(Integer.MIN_VALUE))); + assertTrue(checker.cast(BigInteger.valueOf(Integer.MAX_VALUE))); + + assertFalse(checker.cast(BigInteger.valueOf(Integer.MAX_VALUE).add(BigInteger.ONE))); + assertFalse(checker.cast(BigInteger.valueOf(Integer.MIN_VALUE).subtract(BigInteger.ONE))); + } + + @Test + public void testCheckedCastFromBigDecimalToLong() { + final CheckedCastChecker checker = + new CheckedCastChecker<>(AbstractJdbcWrapper::checkedCastToLong); + assertTrue(checker.cast(BigDecimal.ZERO)); + assertTrue(checker.cast(BigDecimal.ONE)); + assertTrue(checker.cast(BigDecimal.valueOf(-1))); + assertTrue(checker.cast(BigDecimal.valueOf(Long.MIN_VALUE))); + assertTrue(checker.cast(BigDecimal.valueOf(Long.MAX_VALUE))); + + assertFalse(checker.cast(BigDecimal.valueOf(Long.MAX_VALUE).add(BigDecimal.ONE))); + assertFalse(checker.cast(BigDecimal.valueOf(Long.MIN_VALUE).subtract(BigDecimal.ONE))); + } + + @Test + public void testCheckedCastFromBigIntegerToLong() { + final CheckedCastChecker checker = + new CheckedCastChecker<>(AbstractJdbcWrapper::checkedCastToLong); + assertTrue(checker.cast(BigInteger.ZERO)); + assertTrue(checker.cast(BigInteger.ONE)); + assertTrue(checker.cast(BigInteger.valueOf(-1))); + assertTrue(checker.cast(BigInteger.valueOf(Long.MIN_VALUE))); + assertTrue(checker.cast(BigInteger.valueOf(Long.MAX_VALUE))); + + assertFalse(checker.cast(BigInteger.valueOf(Long.MAX_VALUE).add(BigInteger.ONE))); + assertFalse(checker.cast(BigInteger.valueOf(Long.MIN_VALUE).subtract(BigInteger.ONE))); } @Test public void testCheckedCastToFloat() { - CheckedCastToFloatChecker checker = new CheckedCastToFloatChecker(); + final CheckedCastChecker checker = + new CheckedCastChecker<>(AbstractJdbcWrapper::checkedCastToFloat); assertThat(checker.cast(0D)).isTrue(); assertThat(checker.cast(1D)).isTrue(); assertThat(checker.cast((double) Float.MAX_VALUE)).isTrue(); @@ -167,6 +267,30 @@ public void testCheckedCastToFloat() { assertThat(checker.cast(-Double.MAX_VALUE)).isFalse(); } + @Test + public void testParseBigDecimal() throws SQLException { + assertEquals(BigDecimal.valueOf(123, 2), AbstractJdbcWrapper.parseBigDecimal("1.23")); + try { + AbstractJdbcWrapper.parseBigDecimal("NaN"); + fail("missing expected SQLException"); + } catch (SQLException e) { + assertTrue(e instanceof JdbcSqlException); + assertEquals(Code.INVALID_ARGUMENT.getNumber(), e.getErrorCode()); + } + } + + @Test + public void testParseFloat() throws SQLException { + assertEquals(3.14F, AbstractJdbcWrapper.parseFloat("3.14"), 0.001F); + try { + AbstractJdbcWrapper.parseFloat("invalid number"); + fail("missing expected SQLException"); + } catch (SQLException e) { + assertTrue(e instanceof JdbcSqlException); + assertEquals(Code.INVALID_ARGUMENT.getNumber(), e.getErrorCode()); + } + } + private boolean unwrapSucceeds(AbstractJdbcWrapper subject, Class iface) { try { subject.unwrap(iface); diff --git a/src/test/java/com/google/cloud/spanner/jdbc/ITAbstractJdbcTest.java b/src/test/java/com/google/cloud/spanner/jdbc/ITAbstractJdbcTest.java index efc2d8df1..2ab75e59b 100644 --- a/src/test/java/com/google/cloud/spanner/jdbc/ITAbstractJdbcTest.java +++ b/src/test/java/com/google/cloud/spanner/jdbc/ITAbstractJdbcTest.java @@ -17,6 +17,7 @@ package com.google.cloud.spanner.jdbc; import com.google.cloud.spanner.Database; +import com.google.cloud.spanner.Dialect; import com.google.cloud.spanner.GceTestEnvConfig; import com.google.cloud.spanner.IntegrationTestEnv; import com.google.cloud.spanner.connection.AbstractSqlScriptVerifier; @@ -33,6 +34,7 @@ import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; +import java.util.Arrays; import org.junit.AfterClass; import org.junit.Before; import org.junit.BeforeClass; @@ -48,7 +50,7 @@ public ITJdbcConnectionProvider() {} @Override public JdbcGenericConnection getConnection() { try { - return JdbcGenericConnection.of(createConnection()); + return JdbcGenericConnection.of(createConnection(getDialect())); } catch (SQLException e) { throw new RuntimeException(e); } @@ -66,7 +68,8 @@ protected void after() { }; private static final String DEFAULT_KEY_FILE = null; - private static Database database; + private static Database googleStandardSqlDatabase; + private static Database postgresDatabase; protected static String getKeyFile() { return System.getProperty(GceTestEnvConfig.GCE_CREDENTIALS_FILE, DEFAULT_KEY_FILE); @@ -81,12 +84,20 @@ protected static IntegrationTestEnv getTestEnv() { } protected static Database getDatabase() { - return database; + return getDatabase(Dialect.GOOGLE_STANDARD_SQL); + } + + protected static Database getDatabase(Dialect dialect) { + if (dialect == Dialect.POSTGRESQL) { + return postgresDatabase; + } + return googleStandardSqlDatabase; } @BeforeClass public static void setup() { - database = env.getTestHelper().createTestDatabase(); + googleStandardSqlDatabase = env.getTestHelper().createTestDatabase(); + postgresDatabase = env.getTestHelper().createTestDatabase(Dialect.POSTGRESQL, Arrays.asList()); } @AfterClass @@ -101,10 +112,11 @@ public static void teardown() { * * @return The newly opened JDBC connection. */ - public CloudSpannerJdbcConnection createConnection() throws SQLException { + public CloudSpannerJdbcConnection createConnection(Dialect dialect) throws SQLException { // Create a connection URL for the generic connection API. StringBuilder url = - ITAbstractSpannerTest.extractConnectionUrl(env.getTestHelper().getOptions(), getDatabase()); + ITAbstractSpannerTest.extractConnectionUrl( + env.getTestHelper().getOptions(), getDatabase(dialect)); // Prepend it with 'jdbc:' to make it a valid JDBC connection URL. url.insert(0, "jdbc:"); if (hasValidKeyFile()) { @@ -112,7 +124,12 @@ public CloudSpannerJdbcConnection createConnection() throws SQLException { } appendConnectionUri(url); - return DriverManager.getConnection(url.toString()).unwrap(CloudSpannerJdbcConnection.class); + return DriverManager.getConnection(url.toString() + ";dialect=" + dialect.name()) + .unwrap(CloudSpannerJdbcConnection.class); + } + + public CloudSpannerJdbcConnection createConnection() throws SQLException { + return createConnection(Dialect.GOOGLE_STANDARD_SQL); } protected void appendConnectionUri(StringBuilder uri) {} @@ -142,32 +159,42 @@ protected boolean doCreateMusicTables() { @Before public void createTestTable() throws SQLException { if (doCreateDefaultTestTable()) { - try (Connection connection = createConnection()) { + try (Connection connection = createConnection(getDialect())) { connection.setAutoCommit(true); if (!tableExists(connection, "TEST")) { connection.setAutoCommit(false); + String createTableDdl; + if (getDialect() == Dialect.GOOGLE_STANDARD_SQL) { + createTableDdl = + "CREATE TABLE TEST (ID INT64 NOT NULL, NAME STRING(100) NOT NULL) PRIMARY KEY (ID)"; + } else { + createTableDdl = + "CREATE TABLE TEST (ID BIGINT PRIMARY KEY, NAME VARCHAR(100) NOT NULL)"; + } connection.createStatement().execute("START BATCH DDL"); - connection - .createStatement() - .execute( - "CREATE TABLE TEST (ID INT64 NOT NULL, NAME STRING(100) NOT NULL) PRIMARY KEY (ID)"); + connection.createStatement().execute(createTableDdl); connection.createStatement().execute("RUN BATCH"); } } } } + public Dialect getDialect() { + return Dialect.GOOGLE_STANDARD_SQL; + } + @Before public void createMusicTables() throws SQLException { if (doCreateMusicTables()) { - try (Connection connection = createConnection()) { + try (Connection connection = createConnection(getDialect())) { connection.setAutoCommit(true); if (!tableExists(connection, "Singers")) { - String scriptFile; + String scriptFile = "CreateMusicTables.sql"; + if (getDialect() == Dialect.POSTGRESQL) { + scriptFile = "CreateMusicTables_PG.sql"; + } if (EmulatorSpannerHelper.isUsingEmulator()) { scriptFile = "CreateMusicTables_Emulator.sql"; - } else { - scriptFile = "CreateMusicTables.sql"; } for (String statement : AbstractSqlScriptVerifier.readStatementsFromFile(scriptFile, getClass())) { diff --git a/src/test/java/com/google/cloud/spanner/jdbc/JdbcConnectionGeneratedSqlScriptTest.java b/src/test/java/com/google/cloud/spanner/jdbc/JdbcConnectionGeneratedSqlScriptTest.java index 2ec3f1b08..c1c90e8f6 100644 --- a/src/test/java/com/google/cloud/spanner/jdbc/JdbcConnectionGeneratedSqlScriptTest.java +++ b/src/test/java/com/google/cloud/spanner/jdbc/JdbcConnectionGeneratedSqlScriptTest.java @@ -19,6 +19,7 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +import com.google.cloud.spanner.Dialect; import com.google.cloud.spanner.SpannerExceptionFactory; import com.google.cloud.spanner.connection.AbstractConnectionImplTest; import com.google.cloud.spanner.connection.AbstractSqlScriptVerifier.GenericConnection; @@ -30,7 +31,9 @@ import java.sql.SQLException; import org.junit.Test; 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; /** * This test executes a SQL script that has been generated from the log of all the subclasses of @@ -38,16 +41,29 @@ * connection reacts correctly in all possible states (i.e. DML statements should not be allowed * when the connection is in read-only mode, or when a read-only transaction has started etc.) */ -@RunWith(JUnit4.class) +@RunWith(Parameterized.class) public class JdbcConnectionGeneratedSqlScriptTest { + @Parameter public Dialect dialect; + + @Parameters(name = "dialect = {0}") + public static Object[] data() { + return Dialect.values(); + } static class TestConnectionProvider implements GenericConnectionProvider { + private final Dialect dialect; + + TestConnectionProvider(Dialect dialect) { + this.dialect = dialect; + } + @Override public GenericConnection getConnection() { ConnectionOptions options = mock(ConnectionOptions.class); when(options.getUri()).thenReturn(ConnectionImplTest.URI); com.google.cloud.spanner.connection.Connection spannerConnection = ConnectionImplTest.createConnection(options); + when(spannerConnection.getDialect()).thenReturn(dialect); when(options.getConnection()).thenReturn(spannerConnection); try { JdbcConnection connection = @@ -65,7 +81,7 @@ public GenericConnection getConnection() { @Test public void testGeneratedScript() throws Exception { - JdbcSqlScriptVerifier verifier = new JdbcSqlScriptVerifier(new TestConnectionProvider()); + JdbcSqlScriptVerifier verifier = new JdbcSqlScriptVerifier(new TestConnectionProvider(dialect)); verifier.verifyStatementsInFile( "ConnectionImplGeneratedSqlScriptTest.sql", SqlScriptVerifier.class, false); } diff --git a/src/test/java/com/google/cloud/spanner/jdbc/JdbcConnectionTest.java b/src/test/java/com/google/cloud/spanner/jdbc/JdbcConnectionTest.java index 3205cbb5e..79eafe01c 100644 --- a/src/test/java/com/google/cloud/spanner/jdbc/JdbcConnectionTest.java +++ b/src/test/java/com/google/cloud/spanner/jdbc/JdbcConnectionTest.java @@ -26,6 +26,7 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +import com.google.cloud.spanner.Dialect; import com.google.cloud.spanner.ErrorCode; import com.google.cloud.spanner.ResultSets; import com.google.cloud.spanner.SpannerExceptionFactory; @@ -53,28 +54,43 @@ import java.util.concurrent.Executor; import org.junit.Test; 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; import org.mockito.Mockito; -@RunWith(JUnit4.class) +@RunWith(Parameterized.class) public class JdbcConnectionTest { - private static final com.google.cloud.spanner.ResultSet SELECT1_RESULTSET = - ResultSets.forRows( - Type.struct(StructField.of("", Type.int64())), - Collections.singletonList(Struct.newBuilder().set("").to(1L).build())); + @Parameter public Dialect dialect; + + @Parameters(name = "dialect = {0}") + public static Object[] data() { + return Dialect.values(); + } + + private com.google.cloud.spanner.ResultSet createSelect1ResultSet() { + return ResultSets.forRows( + Type.struct(StructField.of("", Type.int64())), + Collections.singletonList(Struct.newBuilder().set("").to(1L).build())); + } private JdbcConnection createConnection(ConnectionOptions options) throws SQLException { com.google.cloud.spanner.connection.Connection spannerConnection = ConnectionImplTest.createConnection(options); + when(spannerConnection.getDialect()).thenReturn(dialect); when(options.getConnection()).thenReturn(spannerConnection); return new JdbcConnection( "jdbc:cloudspanner://localhost/projects/project/instances/instance/databases/database;credentialsUrl=url", options); } + private ConnectionOptions mockOptions() { + return mock(ConnectionOptions.class); + } + @Test public void testAutoCommit() throws SQLException { - ConnectionOptions options = mock(ConnectionOptions.class); + ConnectionOptions options = mockOptions(); when(options.isAutocommit()).thenReturn(true); try (Connection connection = createConnection(options)) { assertThat(connection.getAutoCommit()).isTrue(); @@ -90,7 +106,7 @@ public void testAutoCommit() throws SQLException { @Test public void testReadOnly() { - ConnectionOptions options = mock(ConnectionOptions.class); + ConnectionOptions options = mockOptions(); when(options.isAutocommit()).thenReturn(true); when(options.isReadOnly()).thenReturn(true); try (Connection connection = createConnection(options)) { @@ -109,7 +125,7 @@ public void testReadOnly() { @Test public void testCommit() throws SQLException { - ConnectionOptions options = mock(ConnectionOptions.class); + ConnectionOptions options = mockOptions(); try (JdbcConnection connection = createConnection(options)) { // verify that there is no transaction started assertThat(connection.getSpannerConnection().isTransactionStarted()).isFalse(); @@ -128,7 +144,7 @@ public void testCommit() throws SQLException { @Test public void testRollback() throws SQLException { - ConnectionOptions options = mock(ConnectionOptions.class); + ConnectionOptions options = mockOptions(); try (JdbcConnection connection = createConnection(options)) { // verify that there is no transaction started assertThat(connection.getSpannerConnection().isTransactionStarted()).isFalse(); @@ -302,7 +318,7 @@ private void testClosed( private void testInvokeMethodOnClosedConnection(Method method, Object... args) throws SQLException, IllegalAccessException, IllegalArgumentException { - ConnectionOptions options = mock(ConnectionOptions.class); + ConnectionOptions options = mockOptions(); JdbcConnection connection = createConnection(options); connection.close(); boolean valid = false; @@ -323,7 +339,7 @@ private void testInvokeMethodOnClosedConnection(Method method, Object... args) @Test public void testTransactionIsolation() throws SQLException { - ConnectionOptions options = mock(ConnectionOptions.class); + ConnectionOptions options = mockOptions(); try (JdbcConnection connection = createConnection(options)) { assertThat(connection.getTransactionIsolation()) .isEqualTo(Connection.TRANSACTION_SERIALIZABLE); @@ -359,7 +375,7 @@ public void testTransactionIsolation() throws SQLException { @Test public void testHoldability() throws SQLException { - ConnectionOptions options = mock(ConnectionOptions.class); + ConnectionOptions options = mockOptions(); try (JdbcConnection connection = createConnection(options)) { assertThat(connection.getHoldability()).isEqualTo(ResultSet.CLOSE_CURSORS_AT_COMMIT); // assert that setting it to this value is ok. @@ -388,7 +404,7 @@ public void testHoldability() throws SQLException { @Test public void testWarnings() throws SQLException { - ConnectionOptions options = mock(ConnectionOptions.class); + ConnectionOptions options = mockOptions(); try (JdbcConnection connection = createConnection(options)) { assertThat((Object) connection.getWarnings()).isNull(); @@ -413,7 +429,7 @@ public void testWarnings() throws SQLException { @Test public void getDefaultClientInfo() throws SQLException { - ConnectionOptions options = mock(ConnectionOptions.class); + ConnectionOptions options = mockOptions(); try (JdbcConnection connection = createConnection(options)) { Properties defaultProperties = connection.getClientInfo(); assertThat(defaultProperties.stringPropertyNames()) @@ -423,7 +439,7 @@ public void getDefaultClientInfo() throws SQLException { @Test public void testSetInvalidClientInfo() throws SQLException { - ConnectionOptions options = mock(ConnectionOptions.class); + ConnectionOptions options = mockOptions(); try (JdbcConnection connection = createConnection(options)) { assertThat((Object) connection.getWarnings()).isNull(); connection.setClientInfo("test", "foo"); @@ -445,7 +461,7 @@ public void testSetInvalidClientInfo() throws SQLException { @Test public void testSetClientInfo() throws SQLException { - ConnectionOptions options = mock(ConnectionOptions.class); + ConnectionOptions options = mockOptions(); try (JdbcConnection connection = createConnection(options)) { try (ResultSet validProperties = connection.getMetaData().getClientInfoProperties()) { while (validProperties.next()) { @@ -477,15 +493,16 @@ public void testSetClientInfo() throws SQLException { @Test public void testIsValid() throws SQLException { // Setup. - ConnectionOptions options = mock(ConnectionOptions.class); + ConnectionOptions options = mockOptions(); com.google.cloud.spanner.connection.Connection spannerConnection = mock(com.google.cloud.spanner.connection.Connection.class); + when(spannerConnection.getDialect()).thenReturn(dialect); when(options.getConnection()).thenReturn(spannerConnection); Statement statement = Statement.of(JdbcConnection.IS_VALID_QUERY); // Verify that an opened connection that returns a result set is valid. try (JdbcConnection connection = new JdbcConnection("url", options)) { - when(spannerConnection.executeQuery(statement)).thenReturn(SELECT1_RESULTSET); + when(spannerConnection.executeQuery(statement)).thenReturn(createSelect1ResultSet()); assertThat(connection.isValid(1)).isTrue(); try { // Invalid timeout value. @@ -506,14 +523,14 @@ public void testIsValid() throws SQLException { @Test public void testIsValidOnClosedConnection() throws SQLException { - Connection connection = createConnection(mock(ConnectionOptions.class)); + Connection connection = createConnection(mockOptions()); connection.close(); assertThat(connection.isValid(1)).isFalse(); } @Test public void testCreateStatement() throws SQLException { - try (JdbcConnection connection = createConnection(mock(ConnectionOptions.class))) { + try (JdbcConnection connection = createConnection(mockOptions())) { for (int resultSetType : new int[] { ResultSet.TYPE_FORWARD_ONLY, @@ -587,7 +604,7 @@ private void assertCreateStatementFails( @Test public void testPrepareStatement() throws SQLException { - try (JdbcConnection connection = createConnection(mock(ConnectionOptions.class))) { + try (JdbcConnection connection = createConnection(mockOptions())) { for (int resultSetType : new int[] { ResultSet.TYPE_FORWARD_ONLY, @@ -663,7 +680,7 @@ private void assertPrepareStatementFails( @Test public void testPrepareStatementWithAutoGeneratedKeys() throws SQLException { String sql = "INSERT INTO FOO (COL1) VALUES (?)"; - try (JdbcConnection connection = createConnection(mock(ConnectionOptions.class))) { + try (JdbcConnection connection = createConnection(mockOptions())) { PreparedStatement statement = connection.prepareStatement(sql, java.sql.Statement.NO_GENERATED_KEYS); ResultSet rs = statement.getGeneratedKeys(); @@ -679,7 +696,7 @@ public void testPrepareStatementWithAutoGeneratedKeys() throws SQLException { @Test public void testCatalog() throws SQLException { - ConnectionOptions options = mock(ConnectionOptions.class); + ConnectionOptions options = mockOptions(); when(options.getDatabaseName()).thenReturn("test"); try (JdbcConnection connection = createConnection(options)) { // The connection should always return the empty string as the current catalog, as no other @@ -699,7 +716,7 @@ public void testCatalog() throws SQLException { @Test public void testSchema() throws SQLException { - try (JdbcConnection connection = createConnection(mock(ConnectionOptions.class))) { + try (JdbcConnection connection = createConnection(mockOptions())) { assertThat(connection.getSchema()).isEqualTo(""); // This should be allowed. connection.setSchema(""); @@ -715,7 +732,7 @@ public void testSchema() throws SQLException { @Test public void testIsReturnCommitStats() throws SQLException { - try (JdbcConnection connection = createConnection(mock(ConnectionOptions.class))) { + try (JdbcConnection connection = createConnection(mockOptions())) { assertFalse(connection.isReturnCommitStats()); connection.setReturnCommitStats(true); assertTrue(connection.isReturnCommitStats()); @@ -724,7 +741,7 @@ public void testIsReturnCommitStats() throws SQLException { @Test public void testIsReturnCommitStats_throwsSqlException() { - ConnectionOptions options = mock(ConnectionOptions.class); + ConnectionOptions options = mockOptions(); com.google.cloud.spanner.connection.Connection spannerConnection = mock(com.google.cloud.spanner.connection.Connection.class); when(options.getConnection()).thenReturn(spannerConnection); @@ -746,7 +763,7 @@ public void testIsReturnCommitStats_throwsSqlException() { @Test public void testSetReturnCommitStats_throwsSqlException() { - ConnectionOptions options = mock(ConnectionOptions.class); + ConnectionOptions options = mockOptions(); com.google.cloud.spanner.connection.Connection spannerConnection = mock(com.google.cloud.spanner.connection.Connection.class); when(options.getConnection()).thenReturn(spannerConnection); @@ -769,7 +786,7 @@ public void testSetReturnCommitStats_throwsSqlException() { @Test public void testGetCommitResponse_throwsSqlException() { - ConnectionOptions options = mock(ConnectionOptions.class); + ConnectionOptions options = mockOptions(); com.google.cloud.spanner.connection.Connection spannerConnection = mock(com.google.cloud.spanner.connection.Connection.class); when(options.getConnection()).thenReturn(spannerConnection); diff --git a/src/test/java/com/google/cloud/spanner/jdbc/JdbcDatabaseMetaDataWithMockedServerTest.java b/src/test/java/com/google/cloud/spanner/jdbc/JdbcDatabaseMetaDataWithMockedServerTest.java index af7b8942b..f9011e4f2 100644 --- a/src/test/java/com/google/cloud/spanner/jdbc/JdbcDatabaseMetaDataWithMockedServerTest.java +++ b/src/test/java/com/google/cloud/spanner/jdbc/JdbcDatabaseMetaDataWithMockedServerTest.java @@ -16,12 +16,15 @@ package com.google.cloud.spanner.jdbc; +import static com.google.common.truth.Truth.assertThat; + +import com.google.cloud.spanner.Dialect; import com.google.cloud.spanner.MockSpannerServiceImpl; import com.google.cloud.spanner.MockSpannerServiceImpl.StatementResult; import com.google.cloud.spanner.Statement; +import com.google.cloud.spanner.connection.AbstractStatementParser; +import com.google.cloud.spanner.connection.AbstractStatementParser.ParametersInfo; import com.google.cloud.spanner.connection.SpannerPool; -import com.google.cloud.spanner.connection.StatementParser; -import com.google.cloud.spanner.jdbc.JdbcParameterStore.ParametersInfo; import com.google.protobuf.ListValue; import com.google.protobuf.Value; import com.google.spanner.v1.ResultSetMetadata; @@ -39,12 +42,15 @@ import java.sql.SQLException; import org.junit.After; import org.junit.AfterClass; +import org.junit.Before; import org.junit.BeforeClass; import org.junit.Test; 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; -@RunWith(JUnit4.class) +@RunWith(Parameterized.class) public class JdbcDatabaseMetaDataWithMockedServerTest { private static final ResultSetMetadata RESULTSET_METADATA = ResultSetMetadata.newBuilder() @@ -66,8 +72,23 @@ public class JdbcDatabaseMetaDataWithMockedServerTest { .setMetadata(RESULTSET_METADATA) .build(); + private static final String GSQL_STATEMENT = "/*GSQL*/"; + + /* Checks if the SQL statement starts with /*GSQL*/ + private boolean isGoogleSql(String sql) { + return sql.startsWith(GSQL_STATEMENT); + } + + @Parameter public Dialect dialect; + + @Parameters(name = "dialect = {0}") + public static Object[] data() { + return Dialect.values(); + } + private static MockSpannerServiceImpl mockSpanner; private static Server server; + private AbstractStatementParser parser; @BeforeClass public static void startStaticServer() throws IOException { @@ -84,6 +105,11 @@ public static void stopServer() throws Exception { server.awaitTermination(); } + @Before + public void setup() { + parser = AbstractStatementParser.getInstance(dialect); + } + @After public void reset() { // Close Spanner pool to prevent reusage of the same Spanner instance (and thereby the same @@ -106,9 +132,9 @@ private Connection createConnection() throws SQLException { @Test public void getTablesInDdlBatch() throws SQLException { String sql = - StatementParser.removeCommentsAndTrim( + parser.removeCommentsAndTrim( JdbcDatabaseMetaData.readSqlFromFile("DatabaseMetaData_GetTables.sql")); - ParametersInfo params = JdbcParameterStore.convertPositionalParametersToNamedParameters(sql); + ParametersInfo params = parser.convertPositionalParametersToNamedParameters('?', sql); mockSpanner.putStatementResult( StatementResult.query( Statement.newBuilder(params.sqlWithNamedParameters) @@ -140,9 +166,9 @@ public void getTablesInDdlBatch() throws SQLException { @Test public void getColumnsInDdlBatch() throws SQLException { String sql = - StatementParser.removeCommentsAndTrim( + parser.removeCommentsAndTrim( JdbcDatabaseMetaData.readSqlFromFile("DatabaseMetaData_GetColumns.sql")); - ParametersInfo params = JdbcParameterStore.convertPositionalParametersToNamedParameters(sql); + ParametersInfo params = parser.convertPositionalParametersToNamedParameters('?', sql); mockSpanner.putStatementResult( StatementResult.query( Statement.newBuilder(params.sqlWithNamedParameters) @@ -175,9 +201,8 @@ public void getKeysInDdlBatch() throws SQLException { "DatabaseMetaData_GetImportedKeys.sql", "DatabaseMetaData_GetExportedKeys.sql" }) { - String sql = - StatementParser.removeCommentsAndTrim(JdbcDatabaseMetaData.readSqlFromFile(fileName)); - ParametersInfo params = JdbcParameterStore.convertPositionalParametersToNamedParameters(sql); + String sql = parser.removeCommentsAndTrim(JdbcDatabaseMetaData.readSqlFromFile(fileName)); + ParametersInfo params = parser.convertPositionalParametersToNamedParameters('?', sql); mockSpanner.putStatementResult( StatementResult.query( Statement.newBuilder(params.sqlWithNamedParameters) @@ -212,9 +237,9 @@ public void getKeysInDdlBatch() throws SQLException { @Test public void getCrossReferencesInDdlBatch() throws SQLException { String sql = - StatementParser.removeCommentsAndTrim( + parser.removeCommentsAndTrim( JdbcDatabaseMetaData.readSqlFromFile("DatabaseMetaData_GetCrossReferences.sql")); - ParametersInfo params = JdbcParameterStore.convertPositionalParametersToNamedParameters(sql); + ParametersInfo params = parser.convertPositionalParametersToNamedParameters('?', sql); mockSpanner.putStatementResult( StatementResult.query( Statement.newBuilder(params.sqlWithNamedParameters) @@ -247,9 +272,9 @@ public void getCrossReferencesInDdlBatch() throws SQLException { @Test public void getIndexInfoInDdlBatch() throws SQLException { String sql = - StatementParser.removeCommentsAndTrim( + parser.removeCommentsAndTrim( JdbcDatabaseMetaData.readSqlFromFile("DatabaseMetaData_GetIndexInfo.sql")); - ParametersInfo params = JdbcParameterStore.convertPositionalParametersToNamedParameters(sql); + ParametersInfo params = parser.convertPositionalParametersToNamedParameters('?', sql); mockSpanner.putStatementResult( StatementResult.query( Statement.newBuilder(params.sqlWithNamedParameters) @@ -280,9 +305,9 @@ public void getIndexInfoInDdlBatch() throws SQLException { @Test public void getSchemasInDdlBatch() throws SQLException { String sql = - StatementParser.removeCommentsAndTrim( + parser.removeCommentsAndTrim( JdbcDatabaseMetaData.readSqlFromFile("DatabaseMetaData_GetSchemas.sql")); - ParametersInfo params = JdbcParameterStore.convertPositionalParametersToNamedParameters(sql); + ParametersInfo params = parser.convertPositionalParametersToNamedParameters('?', sql); mockSpanner.putStatementResult( StatementResult.query( Statement.newBuilder(params.sqlWithNamedParameters) @@ -306,4 +331,99 @@ public void getSchemasInDdlBatch() throws SQLException { connection.createStatement().execute("ABORT BATCH"); } } + + @Test + public void verifyGoogleSqlHeaderIsCorrectlyParsed() { + // Verify that the `/*GSQL*/` header is kept in the SQL statement without comments if the + // dialect is PostgreSQL, and that it is removed if the dialect is Google_Standard_Sql. + if (dialect == Dialect.POSTGRESQL) { + assertThat( + isGoogleSql( + parser.removeCommentsAndTrim( + JdbcDatabaseMetaData.readSqlFromFile( + "DatabaseMetaData_GetCrossReferences.sql")))) + .isTrue(); + assertThat( + isGoogleSql( + parser.removeCommentsAndTrim( + JdbcDatabaseMetaData.readSqlFromFile( + "DatabaseMetaData_GetImportedKeys.sql")))) + .isTrue(); + assertThat( + isGoogleSql( + parser.removeCommentsAndTrim( + JdbcDatabaseMetaData.readSqlFromFile("DatabaseMetaData_GetPrimaryKeys.sql")))) + .isTrue(); + assertThat( + isGoogleSql( + parser.removeCommentsAndTrim( + JdbcDatabaseMetaData.readSqlFromFile("DatabaseMetaData_GetTables.sql")))) + .isTrue(); + assertThat( + isGoogleSql( + parser.removeCommentsAndTrim( + JdbcDatabaseMetaData.readSqlFromFile("DatabaseMetaData_GetColumns.sql")))) + .isTrue(); + assertThat( + isGoogleSql( + parser.removeCommentsAndTrim( + JdbcDatabaseMetaData.readSqlFromFile( + "DatabaseMetaData_GetExportedKeys.sql")))) + .isTrue(); + assertThat( + isGoogleSql( + parser.removeCommentsAndTrim( + JdbcDatabaseMetaData.readSqlFromFile("DatabaseMetaData_GetIndexInfo.sql")))) + .isTrue(); + assertThat( + isGoogleSql( + parser.removeCommentsAndTrim( + JdbcDatabaseMetaData.readSqlFromFile("DatabaseMetaData_GetSchemas.sql")))) + .isTrue(); + } else { + assertThat( + isGoogleSql( + parser.removeCommentsAndTrim( + JdbcDatabaseMetaData.readSqlFromFile( + "DatabaseMetaData_GetCrossReferences.sql")))) + .isFalse(); + assertThat( + isGoogleSql( + parser.removeCommentsAndTrim( + JdbcDatabaseMetaData.readSqlFromFile( + "DatabaseMetaData_GetImportedKeys.sql")))) + .isFalse(); + assertThat( + isGoogleSql( + parser.removeCommentsAndTrim( + JdbcDatabaseMetaData.readSqlFromFile("DatabaseMetaData_GetPrimaryKeys.sql")))) + .isFalse(); + assertThat( + isGoogleSql( + parser.removeCommentsAndTrim( + JdbcDatabaseMetaData.readSqlFromFile("DatabaseMetaData_GetTables.sql")))) + .isFalse(); + assertThat( + isGoogleSql( + parser.removeCommentsAndTrim( + JdbcDatabaseMetaData.readSqlFromFile("DatabaseMetaData_GetColumns.sql")))) + .isFalse(); + assertThat( + isGoogleSql( + parser.removeCommentsAndTrim( + JdbcDatabaseMetaData.readSqlFromFile( + "DatabaseMetaData_GetExportedKeys.sql")))) + .isFalse(); + assertThat( + isGoogleSql( + parser.removeCommentsAndTrim( + JdbcDatabaseMetaData.readSqlFromFile("DatabaseMetaData_GetIndexInfo.sql")))) + .isFalse(); + assertThat( + isGoogleSql( + parser.removeCommentsAndTrim( + JdbcDatabaseMetaData.readSqlFromFile("DatabaseMetaData_GetSchemas.sql")))) + .isFalse(); + } + } } diff --git a/src/test/java/com/google/cloud/spanner/jdbc/JdbcGrpcErrorTest.java b/src/test/java/com/google/cloud/spanner/jdbc/JdbcGrpcErrorTest.java index 0668f88fa..2c1bfe820 100644 --- a/src/test/java/com/google/cloud/spanner/jdbc/JdbcGrpcErrorTest.java +++ b/src/test/java/com/google/cloud/spanner/jdbc/JdbcGrpcErrorTest.java @@ -19,9 +19,11 @@ 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.MockSpannerServiceImpl; import com.google.cloud.spanner.MockSpannerServiceImpl.SimulatedExecutionTime; import com.google.cloud.spanner.MockSpannerServiceImpl.StatementResult; +import com.google.cloud.spanner.SpannerException; import com.google.cloud.spanner.Statement; import com.google.cloud.spanner.admin.database.v1.MockDatabaseAdminImpl; import com.google.cloud.spanner.admin.instance.v1.MockInstanceAdminImpl; @@ -113,7 +115,6 @@ public static void startStaticServer() throws IOException { @AfterClass public static void stopServer() throws Exception { - SpannerPool.closeSpannerPool(); server.shutdown(); server.awaitTermination(); } @@ -122,7 +123,20 @@ public static void stopServer() throws Exception { public void reset() { // Close Spanner pool to prevent reusage of the same Spanner instance (and thereby the same // session pool). - SpannerPool.closeSpannerPool(); + try { + SpannerPool.closeSpannerPool(); + } catch (SpannerException e) { + // Ignore leaked session errors that can be caused by the internal dialect auto-detection that + // is executed at startup. This query can still be running when an error is caused by tests in + // this class, and that will be registered as a session leak as that session has not yet been + // checked in to the pool. + if (!(e.getErrorCode() == ErrorCode.FAILED_PRECONDITION + && e.getMessage() + .contains( + "There is/are 1 connection(s) still open. Close all connections before calling closeSpanner()"))) { + throw e; + } + } mockSpanner.removeAllExecutionTimes(); mockSpanner.reset(); } @@ -205,7 +219,13 @@ public void autocommitExecuteSql() { } @Test - public void autocommitPDMLExecuteSql() { + public void autocommitPDMLExecuteSql() throws SQLException { + // Make sure the dialect auto-detection has finished before we instruct the RPC to always return + // an error. + try (java.sql.Connection connection = createConnection()) { + connection.unwrap(CloudSpannerJdbcConnection.class).getDialect(); + } + mockSpanner.setExecuteStreamingSqlExecutionTime( SimulatedExecutionTime.ofException(serverException)); try (java.sql.Connection connection = createConnection()) { @@ -316,7 +336,13 @@ public void transactionalRollback() throws SQLException { } @Test - public void autocommitExecuteStreamingSql() { + public void autocommitExecuteStreamingSql() throws SQLException { + // Make sure the dialect auto-detection has finished before we instruct the RPC to always return + // an error. + try (java.sql.Connection connection = createConnection()) { + connection.unwrap(CloudSpannerJdbcConnection.class).getDialect(); + } + mockSpanner.setExecuteStreamingSqlExecutionTime( SimulatedExecutionTime.ofException(serverException)); try (java.sql.Connection connection = createConnection()) { 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 89f4ae4cf..0596d829e 100644 --- a/src/test/java/com/google/cloud/spanner/jdbc/JdbcParameterStoreTest.java +++ b/src/test/java/com/google/cloud/spanner/jdbc/JdbcParameterStoreTest.java @@ -16,18 +16,20 @@ package com.google.cloud.spanner.jdbc; -import static com.google.cloud.spanner.jdbc.JdbcParameterStore.convertPositionalParametersToNamedParameters; import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; +import static org.junit.Assume.assumeTrue; import com.google.cloud.ByteArray; +import com.google.cloud.spanner.Dialect; +import com.google.cloud.spanner.SpannerException; import com.google.cloud.spanner.Statement; import com.google.cloud.spanner.Value; +import com.google.cloud.spanner.connection.AbstractStatementParser; import com.google.cloud.spanner.jdbc.JdbcSqlExceptionFactory.JdbcSqlExceptionImpl; import com.google.common.io.CharStreams; -import com.google.common.truth.Truth; import com.google.rpc.Code; import java.io.ByteArrayInputStream; import java.io.IOException; @@ -43,20 +45,39 @@ import java.sql.Time; import java.sql.Timestamp; import java.sql.Types; +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; import java.util.Arrays; import java.util.Collections; import java.util.UUID; +import org.junit.Before; import org.junit.Test; 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; -@RunWith(JUnit4.class) +@RunWith(Parameterized.class) public class JdbcParameterStoreTest { + @Parameters(name = "dialect = {0}") + public static Object[] parameters() { + return Dialect.values(); + } + + @Parameter public Dialect dialect; + + private AbstractStatementParser parser; + + @Before + public void setUp() { + parser = AbstractStatementParser.getInstance(dialect); + } /** Tests setting a {@link Value} as a parameter value. */ @Test public void testSetValueAsParameter() throws SQLException { - JdbcParameterStore params = new JdbcParameterStore(); + JdbcParameterStore params = new JdbcParameterStore(dialect); params.setParameter(1, Value.bool(true)); verifyParameter(params, Value.bool(true)); params.setParameter(1, Value.bytes(ByteArray.copyFrom("test"))); @@ -108,7 +129,7 @@ public void testSetValueAsParameter() throws SQLException { @SuppressWarnings("deprecation") @Test public void testSetParameterWithType() throws SQLException, IOException { - JdbcParameterStore params = new JdbcParameterStore(); + JdbcParameterStore params = new JdbcParameterStore(dialect); // test the valid default combinations params.setParameter(1, true, Types.BOOLEAN); assertTrue((Boolean) params.getParameter(1)); @@ -150,6 +171,24 @@ public void testSetParameterWithType() throws SQLException, IOException { assertEquals(new Timestamp(0L), params.getParameter(1)); verifyParameter( params, Value.timestamp(com.google.cloud.Timestamp.ofTimeSecondsAndNanos(0L, 0))); + OffsetDateTime offsetDateTime = + OffsetDateTime.of(2021, 9, 24, 12, 27, 59, 42457, ZoneOffset.ofHours(2)); + params.setParameter(1, offsetDateTime, Types.TIMESTAMP_WITH_TIMEZONE); + assertEquals(offsetDateTime, params.getParameter(1)); + verifyParameter( + params, + Value.timestamp( + com.google.cloud.Timestamp.ofTimeSecondsAndNanos( + offsetDateTime.toEpochSecond(), offsetDateTime.getNano()))); + LocalDate localDate = LocalDate.of(2021, 9, 24); + params.setParameter(1, localDate, Types.DATE); + assertEquals(localDate, params.getParameter(1)); + verifyParameter( + params, + Value.date( + com.google.cloud.Date.fromYearMonthDay( + localDate.getYear(), localDate.getMonthValue(), localDate.getDayOfMonth()))); + params.setParameter(1, new byte[] {1, 2, 3}, Types.BINARY); assertArrayEquals(new byte[] {1, 2, 3}, (byte[]) params.getParameter(1)); verifyParameter(params, Value.bytes(ByteArray.copyFrom(new byte[] {1, 2, 3}))); @@ -187,7 +226,11 @@ public void testSetParameterWithType() throws SQLException, IOException { verifyParameter(params, Value.json(jsonString)); params.setParameter(1, BigDecimal.ONE, Types.DECIMAL); - verifyParameter(params, Value.numeric(BigDecimal.ONE)); + if (dialect == Dialect.POSTGRESQL) { + verifyParameter(params, Value.pgNumeric(BigDecimal.ONE.toString())); + } else { + verifyParameter(params, Value.numeric(BigDecimal.ONE)); + } // types that should lead to int64 for (int type : new int[] {Types.TINYINT, Types.SMALLINT, Types.INTEGER, Types.BIGINT}) { @@ -340,34 +383,41 @@ public void testSetParameterWithType() throws SQLException, IOException { // types that should lead to numeric for (int type : new int[] {Types.DECIMAL, Types.NUMERIC}) { + final Value expectedIntegralNumeric = + dialect == Dialect.POSTGRESQL ? Value.pgNumeric("1") : Value.numeric(BigDecimal.ONE); + final Value expectedRationalNumeric = + dialect == Dialect.POSTGRESQL + ? Value.pgNumeric("1.0") + : Value.numeric(BigDecimal.valueOf(1.0)); + params.setParameter(1, BigDecimal.ONE, type); assertEquals(BigDecimal.ONE, params.getParameter(1)); - verifyParameter(params, Value.numeric(BigDecimal.ONE)); + verifyParameter(params, expectedIntegralNumeric); params.setParameter(1, (byte) 1, type); assertEquals(1, ((Byte) params.getParameter(1)).byteValue()); - verifyParameter(params, Value.numeric(BigDecimal.ONE)); + verifyParameter(params, expectedIntegralNumeric); params.setParameter(1, (short) 1, type); assertEquals(1, ((Short) params.getParameter(1)).shortValue()); - verifyParameter(params, Value.numeric(BigDecimal.ONE)); + verifyParameter(params, expectedIntegralNumeric); params.setParameter(1, 1, type); assertEquals(1, ((Integer) params.getParameter(1)).intValue()); - verifyParameter(params, Value.numeric(BigDecimal.ONE)); + verifyParameter(params, expectedIntegralNumeric); params.setParameter(1, 1L, type); assertEquals(1, ((Long) params.getParameter(1)).longValue()); - verifyParameter(params, Value.numeric(BigDecimal.ONE)); + verifyParameter(params, expectedIntegralNumeric); params.setParameter(1, (float) 1, type); assertEquals(1.0f, (Float) params.getParameter(1), 0.0f); - verifyParameter(params, Value.numeric(BigDecimal.valueOf(1.0))); + verifyParameter(params, expectedRationalNumeric); params.setParameter(1, (double) 1, type); assertEquals(1.0d, (Double) params.getParameter(1), 0.0d); - verifyParameter(params, Value.numeric(BigDecimal.valueOf(1.0))); + verifyParameter(params, expectedRationalNumeric); } } @Test public void testSetInvalidParameterWithType() throws SQLException, IOException { - JdbcParameterStore params = new JdbcParameterStore(); + JdbcParameterStore params = new JdbcParameterStore(dialect); // types that should lead to int64, but with invalid values. for (int type : new int[] {Types.TINYINT, Types.SMALLINT, Types.INTEGER, Types.BIGINT}) { @@ -484,7 +534,7 @@ private void assertInvalidParameter(JdbcParameterStore params, Object value, int @SuppressWarnings("deprecation") @Test public void testSetParameterWithoutType() throws SQLException { - JdbcParameterStore params = new JdbcParameterStore(); + JdbcParameterStore params = new JdbcParameterStore(dialect); params.setParameter(1, (byte) 1, (Integer) null); assertEquals(1, ((Byte) params.getParameter(1)).byteValue()); verifyParameter(params, Value.int64(1)); @@ -563,7 +613,7 @@ private boolean asciiStreamsEqual(InputStream is1, InputStream is2) throws IOExc /** Tests setting array types of parameters */ @Test public void testSetArrayParameter() throws SQLException { - JdbcParameterStore params = new JdbcParameterStore(); + JdbcParameterStore params = new JdbcParameterStore(dialect); params.setParameter( 1, JdbcArray.createArray("BOOL", new Boolean[] {true, false, true}), Types.ARRAY); assertEquals( @@ -737,40 +787,47 @@ private void verifyParameterBindFails(JdbcParameterStore params) throws SQLExcep } @Test - public void testConvertPositionalParametersToNamedParameters() throws SQLException { + public void testGoogleStandardSQLDialectConvertPositionalParametersToNamedParameters() { + assumeTrue(dialect == Dialect.GOOGLE_STANDARD_SQL); assertEquals( "select * from foo where name=@p1", - convertPositionalParametersToNamedParameters("select * from foo where name=?") + parser.convertPositionalParametersToNamedParameters('?', "select * from foo where name=?") .sqlWithNamedParameters); assertEquals( "@p1'?test?\"?test?\"?'@p2", - convertPositionalParametersToNamedParameters("?'?test?\"?test?\"?'?") + parser.convertPositionalParametersToNamedParameters('?', "?'?test?\"?test?\"?'?") .sqlWithNamedParameters); assertEquals( "@p1'?it\\'?s'@p2", - convertPositionalParametersToNamedParameters("?'?it\\'?s'?").sqlWithNamedParameters); + parser.convertPositionalParametersToNamedParameters('?', "?'?it\\'?s'?") + .sqlWithNamedParameters); assertEquals( "@p1'?it\\\"?s'@p2", - convertPositionalParametersToNamedParameters("?'?it\\\"?s'?").sqlWithNamedParameters); + parser.convertPositionalParametersToNamedParameters('?', "?'?it\\\"?s'?") + .sqlWithNamedParameters); assertEquals( "@p1\"?it\\\"?s\"@p2", - convertPositionalParametersToNamedParameters("?\"?it\\\"?s\"?").sqlWithNamedParameters); + parser.convertPositionalParametersToNamedParameters('?', "?\"?it\\\"?s\"?") + .sqlWithNamedParameters); assertEquals( "@p1`?it\\`?s`@p2", - convertPositionalParametersToNamedParameters("?`?it\\`?s`?").sqlWithNamedParameters); + parser.convertPositionalParametersToNamedParameters('?', "?`?it\\`?s`?") + .sqlWithNamedParameters); assertEquals( "@p1'''?it\\'?s'''@p2", - convertPositionalParametersToNamedParameters("?'''?it\\'?s'''?").sqlWithNamedParameters); + parser.convertPositionalParametersToNamedParameters('?', "?'''?it\\'?s'''?") + .sqlWithNamedParameters); assertEquals( "@p1\"\"\"?it\\\"?s\"\"\"@p2", - convertPositionalParametersToNamedParameters("?\"\"\"?it\\\"?s\"\"\"?") + parser.convertPositionalParametersToNamedParameters('?', "?\"\"\"?it\\\"?s\"\"\"?") .sqlWithNamedParameters); assertEquals( "@p1```?it\\`?s```@p2", - convertPositionalParametersToNamedParameters("?```?it\\`?s```?").sqlWithNamedParameters); + parser.convertPositionalParametersToNamedParameters('?', "?```?it\\`?s```?") + .sqlWithNamedParameters); assertEquals( "@p1'''?it\\'?s \n ?it\\'?s'''@p2", - convertPositionalParametersToNamedParameters("?'''?it\\'?s \n ?it\\'?s'''?") + parser.convertPositionalParametersToNamedParameters('?', "?'''?it\\'?s \n ?it\\'?s'''?") .sqlWithNamedParameters); assertUnclosedLiteral("?'?it\\'?s \n ?it\\'?s'?"); @@ -779,23 +836,26 @@ public void testConvertPositionalParametersToNamedParameters() throws SQLExcepti assertEquals( "select 1, @p1, 'test?test', \"test?test\", foo.* from `foo` where col1=@p2 and col2='test' and col3=@p3 and col4='?' and col5=\"?\" and col6='?''?''?'", - convertPositionalParametersToNamedParameters( + parser.convertPositionalParametersToNamedParameters( + '?', "select 1, ?, 'test?test', \"test?test\", foo.* from `foo` where col1=? and col2='test' and col3=? and col4='?' and col5=\"?\" and col6='?''?''?'") .sqlWithNamedParameters); assertEquals( "select * " + "from foo " + "where name=@p1 " + "and col2 like @p2 " + "and col3 > @p3", - convertPositionalParametersToNamedParameters( + parser.convertPositionalParametersToNamedParameters( + '?', "select * " + "from foo " + "where name=? " + "and col2 like ? " + "and col3 > ?") .sqlWithNamedParameters); assertEquals( "select * " + "from foo " + "where id between @p1 and @p2", - convertPositionalParametersToNamedParameters( - "select * " + "from foo " + "where id between ? and ?") + parser.convertPositionalParametersToNamedParameters( + '?', "select * " + "from foo " + "where id between ? and ?") .sqlWithNamedParameters); assertEquals( "select * " + "from foo " + "limit @p1 offset @p2", - convertPositionalParametersToNamedParameters("select * " + "from foo " + "limit ? offset ?") + parser.convertPositionalParametersToNamedParameters( + '?', "select * " + "from foo " + "limit ? offset ?") .sqlWithNamedParameters); assertEquals( "select * " @@ -808,7 +868,93 @@ public void testConvertPositionalParametersToNamedParameters() throws SQLExcepti + "and col6 not in (@p6, @p7, @p8) " + "and col7 in (@p9, @p10, @p11) " + "and col8 between @p12 and @p13", - convertPositionalParametersToNamedParameters( + parser.convertPositionalParametersToNamedParameters( + '?', + "select * " + + "from foo " + + "where col1=? " + + "and col2 like ? " + + "and col3 > ? " + + "and col4 < ? " + + "and col5 != ? " + + "and col6 not in (?, ?, ?) " + + "and col7 in (?, ?, ?) " + + "and col8 between ? and ?") + .sqlWithNamedParameters); + } + + @Test + public void testPostgresDialectConvertPositionalParametersToNamedParameters() { + assumeTrue(dialect == Dialect.POSTGRESQL); + assertEquals( + "select * from foo where name=$1", + parser.convertPositionalParametersToNamedParameters('?', "select * from foo where name=?") + .sqlWithNamedParameters); + assertEquals( + "$1'?test?\"?test?\"?'$2", + parser.convertPositionalParametersToNamedParameters('?', "?'?test?\"?test?\"?'?") + .sqlWithNamedParameters); + assertEquals( + "$1'?it\\'?s'$2", + parser.convertPositionalParametersToNamedParameters('?', "?'?it\\'?s'?") + .sqlWithNamedParameters); + assertEquals( + "$1'?it\\\"?s'$2", + parser.convertPositionalParametersToNamedParameters('?', "?'?it\\\"?s'?") + .sqlWithNamedParameters); + assertEquals( + "$1\"?it\\\"?s\"$2", + parser.convertPositionalParametersToNamedParameters('?', "?\"?it\\\"?s\"?") + .sqlWithNamedParameters); + assertEquals( + "$1'''?it\\'?s'''$2", + parser.convertPositionalParametersToNamedParameters('?', "?'''?it\\'?s'''?") + .sqlWithNamedParameters); + assertEquals( + "$1\"\"\"?it\\\"?s\"\"\"$2", + parser.convertPositionalParametersToNamedParameters('?', "?\"\"\"?it\\\"?s\"\"\"?") + .sqlWithNamedParameters); + + assertUnclosedLiteral("?'?it\\'?s \n ?it\\'?s'?"); + assertUnclosedLiteral("?'?it\\'?s \n ?it\\'?s?"); + assertUnclosedLiteral("?'''?it\\'?s \n ?it\\'?s'?"); + + assertEquals( + "select 1, $1, 'test?test', \"test?test\", foo.* from `foo` where col1=$2 and col2='test' and col3=$3 and col4='?' and col5=\"?\" and col6='?''?''?'", + parser.convertPositionalParametersToNamedParameters( + '?', + "select 1, ?, 'test?test', \"test?test\", foo.* from `foo` where col1=? and col2='test' and col3=? and col4='?' and col5=\"?\" and col6='?''?''?'") + .sqlWithNamedParameters); + + assertEquals( + "select * " + "from foo " + "where name=$1 " + "and col2 like $2 " + "and col3 > $3", + parser.convertPositionalParametersToNamedParameters( + '?', + "select * " + "from foo " + "where name=? " + "and col2 like ? " + "and col3 > ?") + .sqlWithNamedParameters); + assertEquals( + "select * " + "from foo " + "where id between $1 and $2", + parser.convertPositionalParametersToNamedParameters( + '?', "select * " + "from foo " + "where id between ? and ?") + .sqlWithNamedParameters); + assertEquals( + "select * " + "from foo " + "limit $1 offset $2", + parser.convertPositionalParametersToNamedParameters( + '?', "select * " + "from foo " + "limit ? offset ?") + .sqlWithNamedParameters); + assertEquals( + "select * " + + "from foo " + + "where col1=$1 " + + "and col2 like $2 " + + "and col3 > $3 " + + "and col4 < $4 " + + "and col5 != $5 " + + "and col6 not in ($6, $7, $8) " + + "and col7 in ($9, $10, $11) " + + "and col8 between $12 and $13", + parser.convertPositionalParametersToNamedParameters( + '?', "select * " + "from foo " + "where col1=? " @@ -824,17 +970,16 @@ public void testConvertPositionalParametersToNamedParameters() throws SQLExcepti private void assertUnclosedLiteral(String sql) { try { - convertPositionalParametersToNamedParameters(sql); + parser.convertPositionalParametersToNamedParameters('?', sql); fail("missing expected exception"); - } catch (SQLException t) { - Truth.assertThat((Throwable) t).isInstanceOf(JdbcSqlException.class); - JdbcSqlException e = (JdbcSqlException) t; - Truth.assertThat(e.getCode()).isSameInstanceAs(Code.INVALID_ARGUMENT); - Truth.assertThat(e.getMessage()) - .startsWith( - Code.INVALID_ARGUMENT.name() - + ": SQL statement contains an unclosed literal: " - + sql); + } catch (SpannerException e) { + assertEquals(Code.INVALID_ARGUMENT.getNumber(), e.getCode()); + assertTrue( + e.getMessage() + .startsWith( + Code.INVALID_ARGUMENT.name() + + ": SQL statement contains an unclosed literal: " + + sql)); } } } diff --git a/src/test/java/com/google/cloud/spanner/jdbc/JdbcPreparedStatementTest.java b/src/test/java/com/google/cloud/spanner/jdbc/JdbcPreparedStatementTest.java index fdc9969cb..99f897415 100644 --- a/src/test/java/com/google/cloud/spanner/jdbc/JdbcPreparedStatementTest.java +++ b/src/test/java/com/google/cloud/spanner/jdbc/JdbcPreparedStatementTest.java @@ -18,6 +18,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; import static org.mockito.Mockito.any; @@ -26,6 +27,8 @@ import static org.mockito.Mockito.when; import com.google.cloud.ByteArray; +import com.google.cloud.spanner.Dialect; +import com.google.cloud.spanner.ErrorCode; import com.google.cloud.spanner.ReadContext.QueryAnalyzeMode; import com.google.cloud.spanner.ResultSet; import com.google.cloud.spanner.ResultSets; @@ -34,6 +37,7 @@ import com.google.cloud.spanner.Type; import com.google.cloud.spanner.Type.StructField; import com.google.cloud.spanner.Value; +import com.google.cloud.spanner.connection.AbstractStatementParser; import com.google.cloud.spanner.connection.Connection; import com.google.rpc.Code; import java.io.ByteArrayInputStream; @@ -55,10 +59,19 @@ import java.util.UUID; import org.junit.Test; 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; -@RunWith(JUnit4.class) +@RunWith(Parameterized.class) public class JdbcPreparedStatementTest { + @Parameter public Dialect dialect; + + @Parameters(name = "dialect = {0}") + public static Object[] data() { + return Dialect.values(); + } + private String generateSqlWithParameters(int numberOfParams) { StringBuilder sql = new StringBuilder("INSERT INTO FOO ("); boolean first = true; @@ -90,6 +103,8 @@ private JdbcConnection createMockConnection() throws SQLException { private JdbcConnection createMockConnection(Connection spanner) throws SQLException { JdbcConnection connection = mock(JdbcConnection.class); + when(connection.getDialect()).thenReturn(dialect); + when(connection.getParser()).thenReturn(AbstractStatementParser.getInstance(dialect)); when(connection.getSpannerConnection()).thenReturn(spanner); when(connection.createBlob()).thenCallRealMethod(); when(connection.createClob()).thenCallRealMethod(); @@ -329,7 +344,10 @@ public void testGetResultSetMetadata() throws SQLException { Type.struct( StructField.of("ID", Type.int64()), StructField.of("NAME", Type.string()), - StructField.of("AMOUNT", Type.float64())), + StructField.of("AMOUNT", Type.float64()), + dialect == Dialect.POSTGRESQL + ? StructField.of("PERCENTAGE", Type.pgNumeric()) + : StructField.of("PERCENTAGE", Type.numeric())), Collections.singletonList( Struct.newBuilder() .set("ID") @@ -338,18 +356,25 @@ public void testGetResultSetMetadata() throws SQLException { .to("foo") .set("AMOUNT") .to(Math.PI) + .set("PERCENTAGE") + .to( + dialect == Dialect.POSTGRESQL + ? Value.pgNumeric("1.23") + : Value.numeric(new BigDecimal("1.23"))) .build())); when(connection.analyzeQuery(Statement.of(sql), QueryAnalyzeMode.PLAN)).thenReturn(rs); try (JdbcPreparedStatement ps = new JdbcPreparedStatement(createMockConnection(connection), sql)) { ResultSetMetaData metadata = ps.getMetaData(); - assertEquals(3, metadata.getColumnCount()); + assertEquals(4, metadata.getColumnCount()); assertEquals("ID", metadata.getColumnLabel(1)); assertEquals("NAME", metadata.getColumnLabel(2)); assertEquals("AMOUNT", metadata.getColumnLabel(3)); + assertEquals("PERCENTAGE", metadata.getColumnLabel(4)); assertEquals(Types.BIGINT, metadata.getColumnType(1)); assertEquals(Types.NVARCHAR, metadata.getColumnType(2)); assertEquals(Types.DOUBLE, metadata.getColumnType(3)); + assertEquals(Types.NUMERIC, metadata.getColumnType(4)); } } @@ -363,4 +388,17 @@ public void testGetResultSetMetaDataForDml() throws SQLException { assertEquals(0, metadata.getColumnCount()); } } + + @Test + public void testInvalidSql() { + String sql = "SELECT * FROM Singers WHERE SingerId='"; + SQLException sqlException = + assertThrows( + SQLException.class, + () -> new JdbcPreparedStatement(createMockConnection(mock(Connection.class)), sql)); + assertTrue(sqlException instanceof JdbcSqlException); + JdbcSqlException jdbcSqlException = (JdbcSqlException) sqlException; + assertEquals( + ErrorCode.INVALID_ARGUMENT.getGrpcStatusCode().value(), jdbcSqlException.getErrorCode()); + } } diff --git a/src/test/java/com/google/cloud/spanner/jdbc/JdbcResultSetTest.java b/src/test/java/com/google/cloud/spanner/jdbc/JdbcResultSetTest.java index 7ed85ef89..024f6c243 100644 --- a/src/test/java/com/google/cloud/spanner/jdbc/JdbcResultSetTest.java +++ b/src/test/java/com/google/cloud/spanner/jdbc/JdbcResultSetTest.java @@ -49,6 +49,10 @@ import java.sql.SQLException; import java.sql.Statement; import java.sql.Time; +import java.time.Instant; +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; import java.util.Arrays; import java.util.Calendar; import java.util.Collections; @@ -1729,4 +1733,24 @@ public void testGetObjectAsValue() throws SQLException { Value.timestampArray(TIMESTAMP_ARRAY_VALUE), subject.getObject(TIMESTAMP_ARRAY_COL, Value.class)); } + + @Test + public void testGetLocalDate() throws SQLException { + LocalDate localDate = subject.getObject(DATE_COL_NOT_NULL, LocalDate.class); + assertEquals( + LocalDate.of(DATE_VALUE.getYear(), DATE_VALUE.getMonth(), DATE_VALUE.getDayOfMonth()), + localDate); + assertFalse(subject.wasNull()); + } + + @Test + public void testGetOffsetDateTime() throws SQLException { + OffsetDateTime offsetDateTime = subject.getObject(TIMESTAMP_COL_NOT_NULL, OffsetDateTime.class); + assertEquals( + OffsetDateTime.ofInstant( + Instant.ofEpochSecond(TIMESTAMP_VALUE.getSeconds(), TIMESTAMP_VALUE.getNanos()), + ZoneOffset.systemDefault()), + offsetDateTime); + assertFalse(subject.wasNull()); + } } diff --git a/src/test/java/com/google/cloud/spanner/jdbc/JdbcSqlScriptVerifier.java b/src/test/java/com/google/cloud/spanner/jdbc/JdbcSqlScriptVerifier.java index 3143d5ac1..51e121337 100644 --- a/src/test/java/com/google/cloud/spanner/jdbc/JdbcSqlScriptVerifier.java +++ b/src/test/java/com/google/cloud/spanner/jdbc/JdbcSqlScriptVerifier.java @@ -19,12 +19,12 @@ import static com.google.common.truth.Truth.assertThat; import static com.google.common.truth.Truth.assertWithMessage; +import com.google.cloud.spanner.Dialect; import com.google.cloud.spanner.connection.AbstractSqlScriptVerifier; -import com.google.cloud.spanner.connection.StatementParser; +import com.google.cloud.spanner.connection.AbstractStatementParser; import com.google.cloud.spanner.connection.StatementResult.ResultType; import com.google.rpc.Code; import java.sql.Array; -import java.sql.Connection; import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Statement; @@ -118,18 +118,18 @@ protected Object getFirstValue() throws Exception { } public static class JdbcGenericConnection extends GenericConnection { - private final Connection connection; + private final CloudSpannerJdbcConnection connection; /** * Use this to strip comments from a statement before the statement is executed. This should * only be used when the connection is used in a unit test with a mocked underlying connection. */ private boolean stripCommentsBeforeExecute; - public static JdbcGenericConnection of(Connection connection) { + public static JdbcGenericConnection of(CloudSpannerJdbcConnection connection) { return new JdbcGenericConnection(connection); } - private JdbcGenericConnection(Connection connection) { + private JdbcGenericConnection(CloudSpannerJdbcConnection connection) { this.connection = connection; } @@ -137,7 +137,7 @@ private JdbcGenericConnection(Connection connection) { protected GenericStatementResult execute(String sql) throws SQLException { Statement statement = connection.createStatement(); if (isStripCommentsBeforeExecute()) { - sql = StatementParser.removeCommentsAndTrim(sql); + sql = AbstractStatementParser.getInstance(getDialect()).removeCommentsAndTrim(sql); } boolean result = statement.execute(sql); return new JdbcGenericStatementResult(statement, result); @@ -157,6 +157,11 @@ boolean isStripCommentsBeforeExecute() { void setStripCommentsBeforeExecute(boolean stripCommentsBeforeExecute) { this.stripCommentsBeforeExecute = stripCommentsBeforeExecute; } + + @Override + public Dialect getDialect() { + return connection.getDialect(); + } } public JdbcSqlScriptVerifier() {} diff --git a/src/test/java/com/google/cloud/spanner/jdbc/JdbcStatementTest.java b/src/test/java/com/google/cloud/spanner/jdbc/JdbcStatementTest.java index e73be4ef7..3ee9edcb6 100644 --- a/src/test/java/com/google/cloud/spanner/jdbc/JdbcStatementTest.java +++ b/src/test/java/com/google/cloud/spanner/jdbc/JdbcStatementTest.java @@ -24,11 +24,12 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import com.google.cloud.spanner.Dialect; import com.google.cloud.spanner.ErrorCode; import com.google.cloud.spanner.SpannerExceptionFactory; import com.google.cloud.spanner.Type; +import com.google.cloud.spanner.connection.AbstractStatementParser; import com.google.cloud.spanner.connection.Connection; -import com.google.cloud.spanner.connection.StatementParser; import com.google.cloud.spanner.connection.StatementResult; import com.google.cloud.spanner.connection.StatementResult.ResultType; import com.google.cloud.spanner.jdbc.JdbcSqlExceptionFactory.JdbcSqlExceptionImpl; @@ -42,19 +43,29 @@ import java.util.concurrent.TimeUnit; import org.junit.Test; 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; import org.mockito.stubbing.Answer; -@RunWith(JUnit4.class) +@RunWith(Parameterized.class) public class JdbcStatementTest { private static final String SELECT = "SELECT 1"; private static final String UPDATE = "UPDATE FOO SET BAR=1 WHERE BAZ=2"; private static final String LARGE_UPDATE = "UPDATE FOO SET BAR=1 WHERE 1=1"; private static final String DDL = "CREATE INDEX FOO ON BAR(ID)"; + @Parameter public Dialect dialect; + + @Parameters(name = "dialect = {0}") + public static Object[] data() { + return Dialect.values(); + } + @SuppressWarnings("unchecked") - private JdbcStatement createStatement() { + private JdbcStatement createStatement() throws SQLException { Connection spanner = mock(Connection.class); + when(spanner.getDialect()).thenReturn(dialect); com.google.cloud.spanner.ResultSet resultSet = mock(com.google.cloud.spanner.ResultSet.class); when(resultSet.next()).thenReturn(true, false); @@ -106,7 +117,8 @@ private JdbcStatement createStatement() { List statements = (List) invocation.getArguments()[0]; if (statements.isEmpty() - || StatementParser.INSTANCE.isDdlStatement(statements.get(0).getSql())) { + || AbstractStatementParser.getInstance(dialect) + .isDdlStatement(statements.get(0).getSql())) { return new long[0]; } long[] res = @@ -118,6 +130,8 @@ private JdbcStatement createStatement() { }); JdbcConnection connection = mock(JdbcConnection.class); + when(connection.getDialect()).thenReturn(dialect); + when(connection.getParser()).thenReturn(AbstractStatementParser.getInstance(dialect)); when(connection.getSpannerConnection()).thenReturn(spanner); return new JdbcStatement(connection); } @@ -126,6 +140,7 @@ private JdbcStatement createStatement() { public void testQueryTimeout() throws SQLException { final String select = "SELECT 1"; JdbcConnection connection = mock(JdbcConnection.class); + when(connection.getDialect()).thenReturn(dialect); Connection spanner = mock(Connection.class); when(connection.getSpannerConnection()).thenReturn(spanner); StatementResult result = mock(StatementResult.class); @@ -230,8 +245,8 @@ public void testExecuteQuery() throws SQLException { @Test public void testExecuteQueryWithUpdateStatement() { - Statement statement = createStatement(); try { + Statement statement = createStatement(); statement.executeQuery(UPDATE); fail("missing expected exception"); } catch (SQLException e) { @@ -244,8 +259,8 @@ public void testExecuteQueryWithUpdateStatement() { @Test public void testExecuteQueryWithDdlStatement() { - Statement statement = createStatement(); try { + Statement statement = createStatement(); statement.executeQuery(DDL); fail("missing expected exception"); } catch (SQLException e) { @@ -271,6 +286,7 @@ public void testExecuteUpdate() throws SQLException { @Test public void testInternalExecuteUpdate() throws SQLException { JdbcConnection connection = mock(JdbcConnection.class); + when(connection.getDialect()).thenReturn(dialect); Connection spannerConnection = mock(Connection.class); when(connection.getSpannerConnection()).thenReturn(spannerConnection); com.google.cloud.spanner.Statement updateStatement = @@ -293,6 +309,7 @@ public void testInternalExecuteUpdate() throws SQLException { @Test public void testInternalExecuteLargeUpdate() throws SQLException { JdbcConnection connection = mock(JdbcConnection.class); + when(connection.getDialect()).thenReturn(dialect); Connection spannerConnection = mock(Connection.class); when(connection.getSpannerConnection()).thenReturn(spannerConnection); com.google.cloud.spanner.Statement updateStatement = @@ -329,8 +346,8 @@ public void testExecuteLargeUpdate() throws SQLException { @Test public void testExecuteUpdateWithSelectStatement() { - Statement statement = createStatement(); try { + Statement statement = createStatement(); statement.executeUpdate(SELECT); fail("missing expected exception"); } catch (SQLException e) { @@ -436,7 +453,9 @@ public void testLargeDmlBatch() throws SQLException { @Test public void testConvertUpdateCounts() { - try (JdbcStatement statement = new JdbcStatement(mock(JdbcConnection.class))) { + JdbcConnection connection = mock(JdbcConnection.class); + when(connection.getDialect()).thenReturn(dialect); + try (JdbcStatement statement = new JdbcStatement(connection)) { int[] updateCounts = statement.convertUpdateCounts(new long[] {1L, 2L, 3L}); assertThat(updateCounts).asList().containsExactly(1, 2, 3); updateCounts = statement.convertUpdateCounts(new long[] {0L, 0L, 0L}); @@ -450,8 +469,10 @@ public void testConvertUpdateCounts() { } @Test - public void testConvertUpdateCountsToSuccessNoInfo() { - try (JdbcStatement statement = new JdbcStatement(mock(JdbcConnection.class))) { + public void testConvertUpdateCountsToSuccessNoInfo() throws SQLException { + JdbcConnection connection = mock(JdbcConnection.class); + when(connection.getDialect()).thenReturn(dialect); + try (JdbcStatement statement = new JdbcStatement(connection)) { long[] updateCounts = new long[3]; statement.convertUpdateCountsToSuccessNoInfo(new long[] {1L, 2L, 3L}, updateCounts); assertThat(updateCounts) diff --git a/src/test/java/com/google/cloud/spanner/jdbc/JdbcTimeoutSqlTest.java b/src/test/java/com/google/cloud/spanner/jdbc/JdbcTimeoutSqlTest.java index 8e36a7db1..cd462b431 100644 --- a/src/test/java/com/google/cloud/spanner/jdbc/JdbcTimeoutSqlTest.java +++ b/src/test/java/com/google/cloud/spanner/jdbc/JdbcTimeoutSqlTest.java @@ -16,12 +16,15 @@ package com.google.cloud.spanner.jdbc; +import com.google.cloud.spanner.Dialect; import com.google.cloud.spanner.jdbc.JdbcConnectionGeneratedSqlScriptTest.TestConnectionProvider; import java.sql.Connection; import java.sql.Statement; import org.junit.Test; 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; /** * As JDBC connections store the statement timeout on {@link Statement} objects instead of on the @@ -30,11 +33,19 @@ * timeouts, while the underlying {@link com.google.cloud.spanner.connection.Connection}s use * milliseconds. This test script tests a number of special cases regarding this. */ -@RunWith(JUnit4.class) +@RunWith(Parameterized.class) public class JdbcTimeoutSqlTest { + + @Parameter public Dialect dialect; + + @Parameters(name = "dialect = {0}") + public static Object[] data() { + return Dialect.values(); + } + @Test public void testTimeoutScript() throws Exception { - JdbcSqlScriptVerifier verifier = new JdbcSqlScriptVerifier(new TestConnectionProvider()); + JdbcSqlScriptVerifier verifier = new JdbcSqlScriptVerifier(new TestConnectionProvider(dialect)); verifier.verifyStatementsInFile("TimeoutSqlScriptTest.sql", getClass(), false); } } diff --git a/src/test/java/com/google/cloud/spanner/jdbc/PgNumericPreparedStatementTest.java b/src/test/java/com/google/cloud/spanner/jdbc/PgNumericPreparedStatementTest.java new file mode 100644 index 000000000..fde7c42e0 --- /dev/null +++ b/src/test/java/com/google/cloud/spanner/jdbc/PgNumericPreparedStatementTest.java @@ -0,0 +1,338 @@ +/* + * 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.jdbc; + +import static org.junit.Assert.assertEquals; + +import com.google.cloud.spanner.Dialect; +import com.google.cloud.spanner.MockSpannerServiceImpl; +import com.google.cloud.spanner.MockSpannerServiceImpl.StatementResult; +import com.google.cloud.spanner.Statement; +import com.google.cloud.spanner.connection.SpannerPool; +import com.google.common.collect.ImmutableMap; +import com.google.protobuf.ListValue; +import com.google.protobuf.NullValue; +import com.google.protobuf.Struct; +import com.google.protobuf.Value; +import com.google.spanner.v1.ExecuteSqlRequest; +import com.google.spanner.v1.Type; +import com.google.spanner.v1.TypeAnnotationCode; +import com.google.spanner.v1.TypeCode; +import io.grpc.Server; +import io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder; +import java.math.BigDecimal; +import java.net.InetSocketAddress; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.sql.Types; +import java.util.Arrays; +import java.util.Map; +import org.junit.After; +import org.junit.AfterClass; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; + +public class PgNumericPreparedStatementTest { + + private static final String PROJECT = "my-project"; + private static final String INSTANCE = "my-instance"; + private static final String DATABASE = "my-database"; + private static final String QUERY = "INSERT INTO Table (col1) VALUES (?)"; + private static final String REWRITTEN_QUERY = "INSERT INTO Table (col1) VALUES ($1)"; + private static MockSpannerServiceImpl mockSpanner; + private static InetSocketAddress address; + private static Server server; + private Connection connection; + + @BeforeClass + public static void beforeClass() throws Exception { + mockSpanner = new MockSpannerServiceImpl(); + mockSpanner.setAbortProbability(0.0D); + mockSpanner.putStatementResult(StatementResult.detectDialectResult(Dialect.POSTGRESQL)); + + address = new InetSocketAddress("localhost", 0); + server = NettyServerBuilder.forAddress(address).addService(mockSpanner).build().start(); + } + + @AfterClass + public static void afterClass() throws Exception { + SpannerPool.closeSpannerPool(); + server.shutdown(); + server.awaitTermination(); + } + + @Before + public void setUp() throws Exception { + final String endpoint = address.getHostString() + ":" + server.getPort(); + final String url = + String.format( + "jdbc:cloudspanner://%s/projects/%s/instances/%s/databases/%s?usePlainText=true;dialect=POSTGRESQL", + endpoint, PROJECT, INSTANCE, DATABASE); + connection = DriverManager.getConnection(url); + mockSpanner.reset(); + } + + @After + public void tearDown() throws Exception { + connection.close(); + } + + @Test + public void testSetByteAsObject() throws SQLException { + final Byte param = 1; + + mockScalarUpdateWithParam(param.toString()); + try (PreparedStatement preparedStatement = connection.prepareStatement(QUERY)) { + preparedStatement.setObject(1, param, Types.NUMERIC); + preparedStatement.executeUpdate(); + } + assertRequestWithScalar(param.toString()); + } + + @Test + public void testSetShortAsObject() throws SQLException { + final Short param = 1; + + mockScalarUpdateWithParam(param.toString()); + try (PreparedStatement preparedStatement = connection.prepareStatement(QUERY)) { + preparedStatement.setObject(1, param, Types.NUMERIC); + preparedStatement.executeUpdate(); + } + assertRequestWithScalar(param.toString()); + } + + @Test + public void testSetIntAsObject() throws SQLException { + final Integer param = 1; + + mockScalarUpdateWithParam(param.toString()); + try (PreparedStatement preparedStatement = connection.prepareStatement(QUERY)) { + preparedStatement.setObject(1, param, Types.NUMERIC); + preparedStatement.executeUpdate(); + } + assertRequestWithScalar(param.toString()); + } + + @Test + public void testSetLongAsObject() throws SQLException { + final Long param = 1L; + + mockScalarUpdateWithParam(param.toString()); + try (PreparedStatement preparedStatement = connection.prepareStatement(QUERY)) { + preparedStatement.setObject(1, param, Types.NUMERIC); + preparedStatement.executeUpdate(); + } + assertRequestWithScalar(param.toString()); + } + + @Test + public void testSetFloatAsObject() throws SQLException { + final Float param = 1F; + + mockScalarUpdateWithParam(param.toString()); + try (PreparedStatement preparedStatement = connection.prepareStatement(QUERY)) { + preparedStatement.setObject(1, param, Types.NUMERIC); + preparedStatement.executeUpdate(); + } + assertRequestWithScalar(param.toString()); + } + + @Test + public void testSetFloatNaNAsObject() throws SQLException { + final Float param = Float.NaN; + + mockScalarUpdateWithParam(param.toString()); + try (PreparedStatement preparedStatement = connection.prepareStatement(QUERY)) { + preparedStatement.setObject(1, param, Types.NUMERIC); + preparedStatement.executeUpdate(); + } + assertRequestWithScalar(param.toString()); + } + + @Test + public void testSetDoubleAsObject() throws SQLException { + final Double param = 1D; + + mockScalarUpdateWithParam(param.toString()); + try (PreparedStatement preparedStatement = connection.prepareStatement(QUERY)) { + preparedStatement.setObject(1, param, Types.NUMERIC); + preparedStatement.executeUpdate(); + } + assertRequestWithScalar(param.toString()); + } + + @Test + public void testSetDoubleNaNAsObject() throws SQLException { + final Double param = Double.NaN; + + mockScalarUpdateWithParam(param.toString()); + try (PreparedStatement preparedStatement = connection.prepareStatement(QUERY)) { + preparedStatement.setObject(1, param, Types.NUMERIC); + preparedStatement.executeUpdate(); + } + assertRequestWithScalar(param.toString()); + } + + @Test + public void testSetBigDecimalAsObject() throws SQLException { + final BigDecimal param = new BigDecimal("1.23"); + + mockScalarUpdateWithParam(param.toString()); + try (PreparedStatement preparedStatement = connection.prepareStatement(QUERY)) { + preparedStatement.setObject(1, param, Types.NUMERIC); + preparedStatement.executeUpdate(); + } + assertRequestWithScalar(param.toString()); + } + + @Test + public void testSetBigDecimalAsObjectWithoutExplicitType() throws SQLException { + final BigDecimal param = new BigDecimal("1.23"); + + mockScalarUpdateWithParam(param.toString()); + try (PreparedStatement preparedStatement = connection.prepareStatement(QUERY)) { + preparedStatement.setObject(1, param); + preparedStatement.executeUpdate(); + } + assertRequestWithScalar(param.toString()); + } + + @Test + public void testSetBigDecimal() throws SQLException { + final BigDecimal param = new BigDecimal("1"); + + mockScalarUpdateWithParam(param.toString()); + try (PreparedStatement preparedStatement = connection.prepareStatement(QUERY)) { + preparedStatement.setBigDecimal(1, param); + preparedStatement.executeUpdate(); + } + assertRequestWithScalar(param.toString()); + } + + @Test + public void testSetNull() throws SQLException { + mockScalarUpdateWithParam(null); + try (PreparedStatement preparedStatement = connection.prepareStatement(QUERY)) { + preparedStatement.setNull(1, Types.NUMERIC); + preparedStatement.executeUpdate(); + } + assertRequestWithScalar(null); + } + + @Test + public void testSetNumericArray() throws SQLException { + final BigDecimal[] param = {BigDecimal.ONE, null, BigDecimal.TEN}; + + mockArrayUpdateWithParam(Arrays.asList("1", null, "10")); + try (PreparedStatement preparedStatement = connection.prepareStatement(QUERY)) { + preparedStatement.setArray(1, connection.createArrayOf("numeric", param)); + preparedStatement.executeUpdate(); + } + assertRequestWithArray(Arrays.asList("1", null, "10")); + } + + @Test + public void testSetNullArray() throws SQLException { + mockArrayUpdateWithParam(null); + try (PreparedStatement preparedStatement = connection.prepareStatement(QUERY)) { + preparedStatement.setArray(1, connection.createArrayOf("numeric", null)); + preparedStatement.executeUpdate(); + } + assertRequestWithArray(null); + } + + private void mockScalarUpdateWithParam(String value) { + mockSpanner.putStatementResult( + StatementResult.update( + Statement.newBuilder(REWRITTEN_QUERY) + .bind("p1") + .to(com.google.cloud.spanner.Value.pgNumeric(value)) + .build(), + 1)); + } + + private void mockArrayUpdateWithParam(Iterable value) { + mockSpanner.putStatementResult( + StatementResult.update( + Statement.newBuilder(REWRITTEN_QUERY) + .bind("p1") + .to(com.google.cloud.spanner.Value.pgNumericArray(value)) + .build(), + 1)); + } + + private void assertRequestWithScalar(String value) { + final ExecuteSqlRequest request = mockSpanner.getRequestsOfType(ExecuteSqlRequest.class).get(0); + final String actualSql = request.getSql(); + final Struct actualParams = request.getParams(); + final Map actualParamTypes = request.getParamTypesMap(); + + final Value parameterValue = protoValueFromString(value); + final Struct expectedParams = Struct.newBuilder().putFields("p1", parameterValue).build(); + final ImmutableMap expectedTypes = + ImmutableMap.of( + "p1", + Type.newBuilder() + .setCode(TypeCode.NUMERIC) + .setTypeAnnotation(TypeAnnotationCode.PG_NUMERIC) + .build()); + assertEquals(REWRITTEN_QUERY, actualSql); + assertEquals(expectedParams, actualParams); + assertEquals(expectedTypes, actualParamTypes); + } + + private void assertRequestWithArray(Iterable value) { + final ExecuteSqlRequest request = mockSpanner.getRequestsOfType(ExecuteSqlRequest.class).get(0); + final String actualSql = request.getSql(); + final Struct actualParams = request.getParams(); + final Map actualParamTypes = request.getParamTypesMap(); + + Value parameterValue; + if (value != null) { + final ListValue.Builder builder = ListValue.newBuilder(); + value.forEach(v -> builder.addValues(protoValueFromString(v))); + parameterValue = Value.newBuilder().setListValue(builder.build()).build(); + } else { + parameterValue = Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build(); + } + final Struct expectedParams = Struct.newBuilder().putFields("p1", parameterValue).build(); + final ImmutableMap expectedTypes = + ImmutableMap.of( + "p1", + Type.newBuilder() + .setCode(TypeCode.ARRAY) + .setArrayElementType( + Type.newBuilder() + .setCode(TypeCode.NUMERIC) + .setTypeAnnotation(TypeAnnotationCode.PG_NUMERIC)) + .build()); + assertEquals(REWRITTEN_QUERY, actualSql); + assertEquals(expectedParams, actualParams); + assertEquals(expectedTypes, actualParamTypes); + } + + private Value protoValueFromString(String value) { + if (value == null) { + return Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build(); + } else { + return Value.newBuilder().setStringValue(value).build(); + } + } +} diff --git a/src/test/java/com/google/cloud/spanner/jdbc/PgNumericResultSetTest.java b/src/test/java/com/google/cloud/spanner/jdbc/PgNumericResultSetTest.java new file mode 100644 index 000000000..119897af9 --- /dev/null +++ b/src/test/java/com/google/cloud/spanner/jdbc/PgNumericResultSetTest.java @@ -0,0 +1,801 @@ +/* + * Copyright 2021 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 static com.google.protobuf.NullValue.NULL_VALUE; +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import com.google.cloud.spanner.MockSpannerServiceImpl; +import com.google.cloud.spanner.MockSpannerServiceImpl.StatementResult; +import com.google.cloud.spanner.connection.SpannerPool; +import com.google.cloud.spanner.jdbc.JdbcSqlExceptionFactory.JdbcSqlExceptionImpl; +import com.google.common.io.ByteSource; +import com.google.protobuf.ListValue; +import com.google.spanner.v1.ResultSetMetadata; +import com.google.spanner.v1.StructType; +import com.google.spanner.v1.StructType.Field; +import com.google.spanner.v1.TypeAnnotationCode; +import com.google.spanner.v1.TypeCode; +import io.grpc.Server; +import io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.Reader; +import java.math.BigDecimal; +import java.net.InetSocketAddress; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.sql.Blob; +import java.sql.Clob; +import java.sql.Connection; +import java.sql.Date; +import java.sql.DriverManager; +import java.sql.NClob; +import java.sql.ResultSet; +import java.sql.Statement; +import java.sql.Time; +import java.sql.Timestamp; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import org.junit.After; +import org.junit.AfterClass; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; + +public class PgNumericResultSetTest { + + private static final String PROJECT = "my-project"; + private static final String INSTANCE = "my-instance"; + private static final String DATABASE = "my-database"; + private static final String COLUMN_NAME = "PgNumeric"; + private static final ResultSetMetadata SCALAR_METADATA = + ResultSetMetadata.newBuilder() + .setRowType( + StructType.newBuilder() + .addFields( + Field.newBuilder() + .setName(COLUMN_NAME) + .setType( + com.google.spanner.v1.Type.newBuilder() + .setCode(TypeCode.NUMERIC) + .setTypeAnnotation(TypeAnnotationCode.PG_NUMERIC)))) + .build(); + private static final ResultSetMetadata ARRAY_METADATA = + ResultSetMetadata.newBuilder() + .setRowType( + StructType.newBuilder() + .addFields( + Field.newBuilder() + .setName(COLUMN_NAME) + .setType( + com.google.spanner.v1.Type.newBuilder() + .setCode(TypeCode.ARRAY) + .setArrayElementType( + com.google.spanner.v1.Type.newBuilder() + .setCode(TypeCode.NUMERIC) + .setTypeAnnotation(TypeAnnotationCode.PG_NUMERIC))))) + .build(); + private static final String QUERY = "SELECT " + COLUMN_NAME + " FROM Table WHERE Id = 0"; + private static final int MAX_PG_NUMERIC_SCALE = 131_072; + private static final int MAX_PG_NUMERIC_PRECISION = 16_383; + + private static MockSpannerServiceImpl mockSpanner; + private static InetSocketAddress address; + private static Server server; + private Connection connection; + + @BeforeClass + public static void beforeClass() throws Exception { + mockSpanner = new MockSpannerServiceImpl(); + mockSpanner.setAbortProbability(0.0D); + + address = new InetSocketAddress("localhost", 0); + server = NettyServerBuilder.forAddress(address).addService(mockSpanner).build().start(); + } + + @AfterClass + public static void afterClass() throws Exception { + SpannerPool.closeSpannerPool(); + server.shutdown(); + server.awaitTermination(); + } + + @Before + public void setUp() throws Exception { + final String endpoint = address.getHostString() + ":" + server.getPort(); + final String url = + String.format( + "jdbc:cloudspanner://%s/projects/%s/instances/%s/databases/%s?usePlainText=true", + endpoint, PROJECT, INSTANCE, DATABASE); + connection = DriverManager.getConnection(url); + } + + @After + public void tearDown() throws Exception { + connection.close(); + } + + @Test + public void testGetString() throws Exception { + final String maxScale = String.join("", Collections.nCopies(MAX_PG_NUMERIC_SCALE, "1")); + final String maxPrecision = + "0." + String.join("", Collections.nCopies(MAX_PG_NUMERIC_PRECISION, "2")); + + mockScalarResults("0", "1", "1.23", maxScale, maxPrecision, "NaN", null); + + try (Statement statement = connection.createStatement(); + ResultSet resultSet = statement.executeQuery(QUERY)) { + + final ResultSetMatcher matcher = + resultSetMatcherFrom(resultSet, ResultSet::getString, ResultSet::getString); + + matcher.nextAndAssertEquals("0"); + matcher.nextAndAssertEquals("1"); + matcher.nextAndAssertEquals("1.23"); + matcher.nextAndAssertEquals(maxScale); + matcher.nextAndAssertEquals(maxPrecision); + matcher.nextAndAssertEquals("NaN"); + matcher.nextAndAssertEquals(null); + } + } + + @Test + public void testGetNString() throws Exception { + final String maxScale = String.join("", Collections.nCopies(MAX_PG_NUMERIC_SCALE, "1")); + final String maxPrecision = + "0." + String.join("", Collections.nCopies(MAX_PG_NUMERIC_PRECISION, "2")); + + mockScalarResults("0", "1", "1.23", maxScale, maxPrecision, "NaN", null); + + try (Statement statement = connection.createStatement(); + ResultSet resultSet = statement.executeQuery(QUERY)) { + final ResultSetMatcher matcher = + resultSetMatcherFrom(resultSet, ResultSet::getNString, ResultSet::getNString); + + matcher.nextAndAssertEquals("0"); + matcher.nextAndAssertEquals("1"); + matcher.nextAndAssertEquals("1.23"); + matcher.nextAndAssertEquals(maxScale); + matcher.nextAndAssertEquals(maxPrecision); + matcher.nextAndAssertEquals("NaN"); + matcher.nextAndAssertEquals(null); + } + } + + @Test + public void testGetBoolean() throws Exception { + mockScalarResults("0", null, "1", "NaN", "1.00"); + + try (Statement statement = connection.createStatement(); + ResultSet resultSet = statement.executeQuery(QUERY)) { + + final ResultSetMatcher matcher = + resultSetMatcherFrom(resultSet, ResultSet::getBoolean, ResultSet::getBoolean); + + // 0 == false + matcher.nextAndAssertEquals(false); + // NULL == false + matcher.nextAndAssertEquals(false); + // anything else == true + matcher.nextAndAssertEquals(true); // "1" == true + matcher.nextAndAssertEquals(true); // "Nan" == true + matcher.nextAndAssertEquals(true); // "1.00" == true + } + } + + @Test + public void testGetByte() throws Exception { + final String minValue = Byte.MIN_VALUE + ""; + final String underflow = String.valueOf((int) Byte.MIN_VALUE - 1); + final String maxValue = Byte.MAX_VALUE + ""; + final String overflow = String.valueOf((int) Short.MAX_VALUE + 1); + + mockScalarResults(minValue, maxValue, "1.23", null, "NaN", underflow, overflow); + + try (Statement statement = connection.createStatement(); + ResultSet resultSet = statement.executeQuery(QUERY)) { + + final ResultSetMatcher matcher = + resultSetMatcherFrom(resultSet, ResultSet::getByte, ResultSet::getByte); + + matcher.nextAndAssertEquals(Byte.MIN_VALUE); + matcher.nextAndAssertEquals(Byte.MAX_VALUE); + matcher.nextAndAssertEquals((byte) 1); + // NULL == 0 + matcher.nextAndAssertEquals((byte) 0); + matcher.nextAndAssertError(JdbcSqlExceptionImpl.class, "NaN is not a valid number"); + matcher.nextAndAssertError( + JdbcSqlExceptionImpl.class, "Value out of range for byte: " + underflow); + matcher.nextAndAssertError( + JdbcSqlExceptionImpl.class, "Value out of range for byte: " + overflow); + } + } + + @Test + public void testGetShort() throws Exception { + final String minValue = Short.MIN_VALUE + ""; + final String underflow = String.valueOf((int) Short.MIN_VALUE - 1); + final String maxValue = Short.MAX_VALUE + ""; + final String overflow = String.valueOf((int) Short.MAX_VALUE + 1); + + mockScalarResults(minValue, maxValue, "1.23", null, "NaN", underflow, overflow); + + try (Statement statement = connection.createStatement(); + ResultSet resultSet = statement.executeQuery(QUERY)) { + + final ResultSetMatcher matcher = + resultSetMatcherFrom(resultSet, ResultSet::getShort, ResultSet::getShort); + + matcher.nextAndAssertEquals(Short.MIN_VALUE); + matcher.nextAndAssertEquals(Short.MAX_VALUE); + matcher.nextAndAssertEquals((short) 1); + // NULL == 0 + matcher.nextAndAssertEquals((short) 0); + matcher.nextAndAssertError(JdbcSqlExceptionImpl.class, "NaN is not a valid number"); + matcher.nextAndAssertError( + JdbcSqlExceptionImpl.class, "Value out of range for short: " + underflow); + matcher.nextAndAssertError( + JdbcSqlExceptionImpl.class, "Value out of range for short: " + overflow); + } + } + + @Test + public void testGetInt() throws Exception { + final String minValue = Integer.MIN_VALUE + ""; + final String underflow = String.valueOf((long) Integer.MIN_VALUE - 1L); + final String maxValue = Integer.MAX_VALUE + ""; + final String overflow = String.valueOf((long) Integer.MAX_VALUE + 1L); + + mockScalarResults(minValue, maxValue, "1.23", null, "NaN", underflow, overflow); + + try (Statement statement = connection.createStatement(); + ResultSet resultSet = statement.executeQuery(QUERY)) { + + final ResultSetMatcher matcher = + resultSetMatcherFrom(resultSet, ResultSet::getInt, ResultSet::getInt); + + matcher.nextAndAssertEquals(Integer.MIN_VALUE); + matcher.nextAndAssertEquals(Integer.MAX_VALUE); + matcher.nextAndAssertEquals(1); + // NULL == 0 + matcher.nextAndAssertEquals(0); + matcher.nextAndAssertError(JdbcSqlExceptionImpl.class, "NaN is not a valid number"); + matcher.nextAndAssertError( + JdbcSqlExceptionImpl.class, "Value out of range for int: " + underflow); + matcher.nextAndAssertError( + JdbcSqlExceptionImpl.class, "Value out of range for int: " + overflow); + } + } + + @Test + public void testGetLong() throws Exception { + final String minValue = Long.MIN_VALUE + ""; + final String underflow = BigDecimal.valueOf(Long.MIN_VALUE).subtract(BigDecimal.ONE).toString(); + final String maxValue = Long.MAX_VALUE + ""; + final String overflow = BigDecimal.valueOf(Long.MAX_VALUE).add(BigDecimal.ONE).toString(); + + mockScalarResults(minValue, maxValue, "1.23", null, "NaN", underflow, overflow); + + try (Statement statement = connection.createStatement(); + ResultSet resultSet = statement.executeQuery(QUERY)) { + + final ResultSetMatcher matcher = + resultSetMatcherFrom(resultSet, ResultSet::getLong, ResultSet::getLong); + + matcher.nextAndAssertEquals(Long.MIN_VALUE); + matcher.nextAndAssertEquals(Long.MAX_VALUE); + matcher.nextAndAssertEquals(1L); + // NULL == 0 + matcher.nextAndAssertEquals((long) 0); + matcher.nextAndAssertError(JdbcSqlExceptionImpl.class, "NaN is not a valid number"); + matcher.nextAndAssertError( + JdbcSqlExceptionImpl.class, "Value out of range for long: " + underflow); + matcher.nextAndAssertError( + JdbcSqlExceptionImpl.class, "Value out of range for long: " + overflow); + } + } + + // TODO(thiagotnunes): Confirm that it is ok to wrap around in under / over flows (like pg) + @Test + public void testGetFloat() throws Exception { + final String minValue = Float.MIN_VALUE + ""; + final String underflow = + BigDecimal.valueOf(Float.MIN_VALUE).subtract(BigDecimal.ONE).toString(); + final String maxValue = (Float.MAX_VALUE - 1) + ""; + final String overflow = BigDecimal.valueOf(Float.MAX_VALUE).add(BigDecimal.ONE).toString(); + + mockScalarResults( + minValue, maxValue, "1.23", null, "NaN", "-Infinity", "+Infinity", underflow, overflow); + + try (Statement statement = connection.createStatement(); + ResultSet resultSet = statement.executeQuery(QUERY)) { + + final ResultSetMatcher matcher = + resultSetMatcherFrom(resultSet, ResultSet::getFloat, ResultSet::getFloat); + + matcher.nextAndAssertEquals(Float.MIN_VALUE); + matcher.nextAndAssertEquals(Float.MAX_VALUE); + matcher.nextAndAssertEquals(1.23F); + // NULL == 0 + matcher.nextAndAssertEquals(0F); + matcher.nextAndAssertEquals(Float.NaN); + matcher.nextAndAssertEquals(Float.NEGATIVE_INFINITY); + matcher.nextAndAssertEquals(Float.POSITIVE_INFINITY); + // Value rolls back to 0 + (underflow value) + matcher.nextAndAssertEquals(-1F); + // Value is capped at Float.MAX_VALUE + matcher.nextAndAssertEquals(Float.MAX_VALUE); + } + } + + @Test + public void testGetDouble() throws Exception { + final String minValue = Double.MIN_VALUE + ""; + final String underflow = + BigDecimal.valueOf(Double.MIN_VALUE).subtract(BigDecimal.ONE).toString(); + final String maxValue = (Double.MAX_VALUE - 1) + ""; + final String overflow = BigDecimal.valueOf(Double.MAX_VALUE).add(BigDecimal.ONE).toString(); + + mockScalarResults( + minValue, maxValue, "1.23", null, "NaN", "-Infinity", "+Infinity", underflow, overflow); + + try (Statement statement = connection.createStatement(); + ResultSet resultSet = statement.executeQuery(QUERY)) { + + final ResultSetMatcher matcher = + resultSetMatcherFrom(resultSet, ResultSet::getDouble, ResultSet::getDouble); + + matcher.nextAndAssertEquals(Double.MIN_VALUE); + matcher.nextAndAssertEquals(Double.MAX_VALUE); + matcher.nextAndAssertEquals(1.23D); + // NULL == 0 + matcher.nextAndAssertEquals(0D); + matcher.nextAndAssertEquals(Double.NaN); + matcher.nextAndAssertEquals(Double.NEGATIVE_INFINITY); + matcher.nextAndAssertEquals(Double.POSITIVE_INFINITY); + // Value rolls back to 0 + (underflow value) + matcher.nextAndAssertEquals(-1D); + // Value is capped at Double.MAX_VALUE + matcher.nextAndAssertEquals(Double.MAX_VALUE); + } + } + + @Test + public void testGetBigDecimal() throws Exception { + final String maxScale = String.join("", Collections.nCopies(MAX_PG_NUMERIC_SCALE, "1")); + final String maxPrecision = + "0." + String.join("", Collections.nCopies(MAX_PG_NUMERIC_PRECISION, "2")); + + mockScalarResults(maxScale, maxPrecision, "0", "1.23", null, "NaN"); + + try (Statement statement = connection.createStatement(); + ResultSet resultSet = statement.executeQuery(QUERY)) { + + final ResultSetMatcher matcher = + resultSetMatcherFrom(resultSet, ResultSet::getBigDecimal, ResultSet::getBigDecimal); + + // Default representation is BigDecimal + matcher.nextAndAssertEquals(new BigDecimal(maxScale)); + matcher.nextAndAssertEquals(new BigDecimal(maxPrecision)); + matcher.nextAndAssertEquals(BigDecimal.ZERO); + matcher.nextAndAssertEquals(new BigDecimal("1.23")); + matcher.nextAndAssertEquals(null); + matcher.nextAndAssertError(JdbcSqlExceptionImpl.class, "NaN is not a valid number"); + } + } + + @Test + public void testGetObject() throws Exception { + final String maxScale = String.join("", Collections.nCopies(MAX_PG_NUMERIC_SCALE, "1")); + final String maxPrecision = + "0." + String.join("", Collections.nCopies(MAX_PG_NUMERIC_PRECISION, "2")); + + mockScalarResults(maxScale, maxPrecision, null, "NaN", "-Infinity", "+Infinity"); + + try (Statement statement = connection.createStatement(); + ResultSet resultSet = statement.executeQuery(QUERY)) { + + final ResultSetMatcher matcher = + resultSetMatcherFrom(resultSet, ResultSet::getObject, ResultSet::getObject); + + // Default representation is BigDecimal + matcher.nextAndAssertEquals(new BigDecimal(maxScale)); + matcher.nextAndAssertEquals(new BigDecimal(maxPrecision)); + matcher.nextAndAssertEquals(null); + // Nan is represented as Double + matcher.nextAndAssertEquals(Double.NaN); + // -Infinity is represented as Double + matcher.nextAndAssertEquals(Double.NEGATIVE_INFINITY); + // +Infinity is represented as Double + matcher.nextAndAssertEquals(Double.POSITIVE_INFINITY); + } + } + + @Test + public void testGetDate() throws Exception { + mockScalarResults("1.23", null); + + try (Statement statement = connection.createStatement(); + ResultSet resultSet = statement.executeQuery(QUERY)) { + + final ResultSetMatcher matcher = + resultSetMatcherFrom(resultSet, ResultSet::getDate, ResultSet::getDate); + + matcher.nextAndAssertError(JdbcSqlExceptionImpl.class, "Invalid column type to get as date"); + matcher.nextAndAssertError(JdbcSqlExceptionImpl.class, "Invalid column type to get as date"); + } + } + + @Test + public void testGetTime() throws Exception { + mockScalarResults("1.23", null); + + try (Statement statement = connection.createStatement(); + ResultSet resultSet = statement.executeQuery(QUERY)) { + + final ResultSetMatcher