diff --git a/google-cloud-spanner/clirr-ignored-differences.xml b/google-cloud-spanner/clirr-ignored-differences.xml index bcea799a409..b6b19d1d577 100644 --- a/google-cloud-spanner/clirr-ignored-differences.xml +++ b/google-cloud-spanner/clirr-ignored-differences.xml @@ -945,5 +945,9 @@ boolean supportsExplain() - + + 7012 + com/google/cloud/spanner/DatabaseClient + com.google.cloud.spanner.Statement$StatementFactory getStatementFactory() + diff --git a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/DatabaseClient.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/DatabaseClient.java index a33f39d47fd..e30c648c367 100644 --- a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/DatabaseClient.java +++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/DatabaseClient.java @@ -21,6 +21,7 @@ import com.google.cloud.spanner.Options.RpcPriority; import com.google.cloud.spanner.Options.TransactionOption; import com.google.cloud.spanner.Options.UpdateOption; +import com.google.cloud.spanner.Statement.StatementFactory; import com.google.spanner.v1.BatchWriteResponse; import com.google.spanner.v1.TransactionOptions.IsolationLevel; @@ -606,4 +607,24 @@ ServerStream batchWriteAtLeastOnce( * idempotent, such as deleting old rows from a very large table. */ long executePartitionedUpdate(Statement stmt, UpdateOption... options); + + /** + * Returns a {@link StatementFactory} for the given dialect. + * + *

A {@link StatementFactory} can be used to create statements with unnamed parameters. This is + * primarily intended for framework developers who want to integrate the Spanner client with + * frameworks that use unnamed parameters. Developers who just want to use the Spanner client in + * their application, should use named parameters. + * + *

Examples using {@link StatementFactory} + * + *

{@code
+   * Statement statement = databaseClient
+   *     .getStatementFactory()
+   *     .withUnnamedParameters("SELECT NAME FROM TABLE WHERE ID = ?", 10);
+   * }
+ */ + default StatementFactory getStatementFactory() { + throw new UnsupportedOperationException("method should be overwritten"); + } } diff --git a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/DatabaseClientImpl.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/DatabaseClientImpl.java index 775e38f05b9..8e0e07c457b 100644 --- a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/DatabaseClientImpl.java +++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/DatabaseClientImpl.java @@ -22,11 +22,16 @@ import com.google.cloud.spanner.Options.UpdateOption; import com.google.cloud.spanner.SessionPool.PooledSessionFuture; import com.google.cloud.spanner.SpannerImpl.ClosedException; +import com.google.cloud.spanner.Statement.StatementFactory; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Function; import com.google.common.util.concurrent.ListenableFuture; import com.google.spanner.v1.BatchWriteResponse; import io.opentelemetry.api.common.Attributes; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import javax.annotation.Nullable; class DatabaseClientImpl implements DatabaseClient { @@ -139,6 +144,30 @@ public Dialect getDialect() { return pool.getDialect(); } + private final AbstractLazyInitializer statementFactorySupplier = + new AbstractLazyInitializer() { + @Override + protected StatementFactory initialize() { + try { + Dialect dialect = getDialectAsync().get(30, TimeUnit.SECONDS); + return new StatementFactory(dialect); + } catch (ExecutionException | TimeoutException e) { + throw SpannerExceptionFactory.asSpannerException(e); + } catch (InterruptedException e) { + throw SpannerExceptionFactory.propagateInterrupt(e); + } + } + }; + + @Override + public StatementFactory getStatementFactory() { + try { + return statementFactorySupplier.get(); + } catch (Exception exception) { + throw SpannerExceptionFactory.asSpannerException(exception); + } + } + @Override @Nullable public String getDatabaseRole() { @@ -346,6 +375,14 @@ public long executePartitionedUpdate(final Statement stmt, final UpdateOption... return executePartitionedUpdateWithPooledSession(stmt, options); } + private Future getDialectAsync() { + MultiplexedSessionDatabaseClient client = getMultiplexedSessionDatabaseClient(); + if (client != null) { + return client.getDialectAsync(); + } + return pool.getDialectAsync(); + } + private long executePartitionedUpdateWithPooledSession( final Statement stmt, final UpdateOption... options) { ISpan span = tracer.spanBuilder(PARTITION_DML_TRANSACTION, commonAttributes); diff --git a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/MultiplexedSessionDatabaseClient.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/MultiplexedSessionDatabaseClient.java index 798b976c721..aa2dc857122 100644 --- a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/MultiplexedSessionDatabaseClient.java +++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/MultiplexedSessionDatabaseClient.java @@ -44,6 +44,7 @@ import java.util.Map; import java.util.concurrent.ExecutionException; import java.util.concurrent.Executors; +import java.util.concurrent.Future; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; @@ -652,6 +653,14 @@ public Dialect getDialect() { } } + Future getDialectAsync() { + try { + return MAINTAINER_SERVICE.submit(dialectSupplier::get); + } catch (Exception exception) { + throw SpannerExceptionFactory.asSpannerException(exception); + } + } + @Override public Timestamp write(Iterable mutations) throws SpannerException { return createMultiplexedSessionTransaction(/* singleUse = */ false).write(mutations); diff --git a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/SessionPool.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/SessionPool.java index c0a7fd9fa0e..37fa2c5d202 100644 --- a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/SessionPool.java +++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/SessionPool.java @@ -104,6 +104,7 @@ import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutionException; import java.util.concurrent.Executor; +import java.util.concurrent.Future; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; @@ -2548,6 +2549,10 @@ Dialect getDialect() { } } + Future getDialectAsync() { + return executor.submit(this::getDialect); + } + PooledSessionReplacementHandler getPooledSessionReplacementHandler() { return pooledSessionReplacementHandler; } diff --git a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/SpannerTypeConverter.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/SpannerTypeConverter.java new file mode 100644 index 00000000000..02c0cc213d6 --- /dev/null +++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/SpannerTypeConverter.java @@ -0,0 +1,111 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://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; + +import com.google.cloud.Date; +import com.google.protobuf.ListValue; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.time.temporal.TemporalAccessor; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +final class SpannerTypeConverter { + + private static final ZoneId UTC_ZONE = ZoneId.of("UTC"); + private static final DateTimeFormatter ISO_8601_DATE_FORMATTER = + DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSX"); + + static Value createUntypedArrayValue(Stream stream) { + List values = + stream + .map( + val -> + com.google.protobuf.Value.newBuilder() + .setStringValue(String.valueOf(val)) + .build()) + .collect(Collectors.toList()); + return Value.untyped( + com.google.protobuf.Value.newBuilder() + .setListValue(ListValue.newBuilder().addAllValues(values).build()) + .build()); + } + + static String convertToISO8601(T dateTime) { + return ISO_8601_DATE_FORMATTER.format(dateTime); + } + + static Value createUntypedStringValue(T value) { + return Value.untyped( + com.google.protobuf.Value.newBuilder().setStringValue(String.valueOf(value)).build()); + } + + static Iterable convertToTypedIterable( + Function func, T val, Iterator iterator) { + List values = new ArrayList<>(); + SpannerTypeConverter.processIterable(val, iterator, func, values::add); + return values; + } + + static Iterable convertToTypedIterable(T val, Iterator iterator) { + return convertToTypedIterable(v -> v, val, iterator); + } + + @SuppressWarnings("unchecked") + static void processIterable( + T val, Iterator iterator, Function func, Consumer consumer) { + consumer.accept(func.apply(val)); + iterator.forEachRemaining(values -> consumer.accept(func.apply((T) values))); + } + + static Date convertLocalDateToSpannerDate(LocalDate date) { + return Date.fromYearMonthDay(date.getYear(), date.getMonthValue(), date.getDayOfMonth()); + } + + static Value createUntypedIterableValue( + T value, Iterator iterator, Function func) { + ListValue.Builder listValueBuilder = ListValue.newBuilder(); + SpannerTypeConverter.processIterable( + value, + iterator, + (val) -> com.google.protobuf.Value.newBuilder().setStringValue(func.apply(val)).build(), + listValueBuilder::addValues); + return Value.untyped( + com.google.protobuf.Value.newBuilder().setListValue(listValueBuilder.build()).build()); + } + + static ZonedDateTime atUTC(LocalDateTime localDateTime) { + return atUTC(localDateTime.atZone(ZoneId.systemDefault())); + } + + static ZonedDateTime atUTC(OffsetDateTime localDateTime) { + return localDateTime.atZoneSameInstant(UTC_ZONE); + } + + static ZonedDateTime atUTC(ZonedDateTime localDateTime) { + return localDateTime.withZoneSameInstant(UTC_ZONE); + } +} diff --git a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/Statement.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/Statement.java index a9cf3e7dec4..a454fbb689b 100644 --- a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/Statement.java +++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/Statement.java @@ -20,6 +20,8 @@ import static com.google.common.base.Preconditions.checkState; import com.google.cloud.spanner.ReadContext.QueryAnalyzeMode; +import com.google.cloud.spanner.connection.AbstractStatementParser; +import com.google.cloud.spanner.connection.AbstractStatementParser.ParametersInfo; import com.google.common.base.Preconditions; import com.google.common.collect.ImmutableMap; import com.google.spanner.v1.ExecuteSqlRequest.QueryOptions; @@ -246,4 +248,103 @@ StringBuilder toString(StringBuilder b) { } return b; } + + /** + * Factory for creating {@link Statement}s with unnamed parameters. + * + *

This class is primarily intended for framework developers who want to integrate the Spanner + * client with a framework that uses unnamed parameters. Developers who want to use the Spanner + * client in their application, should use named parameters. + * + *

+ * + *

Usage Example

+ * + * Simple SQL query + * + *
{@code
+   * Statement statement = databaseClient.getStatementFactory()
+   *     .withUnnamedParameters("SELECT * FROM TABLE WHERE ID = ?", 10L)
+   * }
+ * + * SQL query with multiple parameters + * + *
{@code
+   * long id = 10L;
+   * String name = "google";
+   * List phoneNumbers = Arrays.asList("1234567890", "0987654321");
+   * Statement statement = databaseClient.getStatementFactory()
+   *      .withUnnamedParameters("INSERT INTO TABLE (ID, name, phonenumbers) VALUES(?, ?, ?)", id, name, phoneNumbers)
+   * }
+ * + * How to use arrays with the IN operator + * + *
{@code
+   * long[] ids = {10L, 12L, 1483L};
+   * Statement statement = databaseClient.getStatementFactory()
+   *     .withUnnamedParameters("SELECT * FROM TABLE WHERE ID = UNNEST(?)", ids)
+   * }
+ * + * @see DatabaseClient#getStatementFactory() + * @see StatementFactory#withUnnamedParameters(String, Object...) + */ + public static final class StatementFactory { + private final Dialect dialect; + + StatementFactory(Dialect dialect) { + this.dialect = dialect; + } + + public Statement of(String sql) { + return Statement.of(sql); + } + + /** + * This function accepts a SQL statement with unnamed parameters (?) and accepts a list of + * objects that should be used as the values for those parameters. Primitive types are + * supported. + * + *

For parameters of type DATE, the following types are supported + * + *

    + *
  • {@link java.time.LocalDate} + *
  • {@link com.google.cloud.Date} + *
+ * + *

For parameters of type TIMESTAMP, the following types are supported. Note that Spanner + * stores all timestamps in UTC. Instances of ZonedDateTime and OffsetDateTime that use other + * timezones than UTC, will be converted to the corresponding UTC values before being sent to + * Spanner. Instances of LocalDateTime will be converted to a ZonedDateTime using the system + * default timezone, and then converted to UTC before being sent to Spanner. + * + *

    + *
  • {@link java.time.LocalDateTime} + *
  • {@link java.time.OffsetDateTime} + *
  • {@link java.time.ZonedDateTime} + *
+ * + *

+ * + * @param sql SQL statement with unnamed parameters denoted as ? + * @param values positional list of values for the unnamed parameters in the SQL string + * @return Statement a statement that can be executed on Spanner + * @see DatabaseClient#getStatementFactory + */ + public Statement withUnnamedParameters(String sql, Object... values) { + Map parameters = getUnnamedParametersMap(values); + AbstractStatementParser statementParser = AbstractStatementParser.getInstance(this.dialect); + ParametersInfo parametersInfo = + statementParser.convertPositionalParametersToNamedParameters('?', sql); + return new Statement(parametersInfo.sqlWithNamedParameters, parameters, null); + } + + private Map getUnnamedParametersMap(Object[] values) { + Map parameters = new HashMap<>(); + int index = 1; + for (Object value : values) { + parameters.put("p" + (index++), Value.toValue(value)); + } + return parameters; + } + } } diff --git a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/Value.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/Value.java index 6cb68eeee4a..5befba04e57 100644 --- a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/Value.java +++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/Value.java @@ -16,6 +16,14 @@ package com.google.cloud.spanner; +import static com.google.cloud.spanner.SpannerTypeConverter.atUTC; +import static com.google.cloud.spanner.SpannerTypeConverter.convertLocalDateToSpannerDate; +import static com.google.cloud.spanner.SpannerTypeConverter.convertToISO8601; +import static com.google.cloud.spanner.SpannerTypeConverter.convertToTypedIterable; +import static com.google.cloud.spanner.SpannerTypeConverter.createUntypedArrayValue; +import static com.google.cloud.spanner.SpannerTypeConverter.createUntypedIterableValue; +import static com.google.cloud.spanner.SpannerTypeConverter.createUntypedStringValue; + import com.google.cloud.ByteArray; import com.google.cloud.Date; import com.google.cloud.Timestamp; @@ -39,16 +47,22 @@ import java.io.Serializable; import java.math.BigDecimal; import java.nio.charset.StandardCharsets; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZonedDateTime; import java.util.ArrayList; import java.util.Arrays; import java.util.Base64; import java.util.BitSet; import java.util.Collection; import java.util.Collections; +import java.util.Iterator; import java.util.List; import java.util.Objects; import java.util.function.Function; import java.util.stream.Collectors; +import java.util.stream.Stream; import javax.annotation.Nonnull; import javax.annotation.Nullable; import javax.annotation.concurrent.Immutable; @@ -824,6 +838,159 @@ public static Value structArray(Type elementType, @Nullable Iterable v) private Value() {} + static Value toValue(Object value) { + if (value == null) { + return Value.untyped(NULL_PROTO); + } + if (value instanceof Value) { + return (Value) value; + } + if (value instanceof Boolean) { + return Value.bool((Boolean) value); + } + if (value instanceof Long || value instanceof Integer) { + return createUntypedStringValue(String.valueOf(value)); + } + if (value instanceof Float) { + return Value.float32((Float) value); + } + if (value instanceof Double) { + return Value.float64((Double) value); + } + if (value instanceof BigDecimal) { + return Value.numeric((BigDecimal) value); + } + if (value instanceof ByteArray) { + return Value.bytes((ByteArray) value); + } + if (value instanceof byte[]) { + return Value.bytes(ByteArray.copyFrom((byte[]) value)); + } + if (value instanceof Date) { + return Value.date((Date) value); + } + if (value instanceof LocalDate) { + return Value.date(convertLocalDateToSpannerDate((LocalDate) value)); + } + if (value instanceof LocalDateTime) { + return createUntypedStringValue(convertToISO8601(atUTC((LocalDateTime) value))); + } + if (value instanceof OffsetDateTime) { + return createUntypedStringValue(convertToISO8601(atUTC((OffsetDateTime) value))); + } + if (value instanceof ZonedDateTime) { + return createUntypedStringValue(convertToISO8601(atUTC((ZonedDateTime) value))); + } + if (value instanceof ProtocolMessageEnum) { + return Value.protoEnum((ProtocolMessageEnum) value); + } + if (value instanceof AbstractMessage) { + return Value.protoMessage((AbstractMessage) value); + } + if (value instanceof Interval) { + return Value.interval((Interval) value); + } + if (value instanceof Struct) { + return Value.struct((Struct) value); + } + if (value instanceof Timestamp) { + return Value.timestamp((Timestamp) value); + } + if (value instanceof Iterable) { + Iterator iterator = ((Iterable) value).iterator(); + if (!iterator.hasNext()) { + return createUntypedArrayValue(Stream.empty()); + } + Object object = iterator.next(); + if (object instanceof Boolean) { + return Value.boolArray(convertToTypedIterable((Boolean) object, iterator)); + } + if (object instanceof Integer) { + return createUntypedIterableValue((Integer) object, iterator, String::valueOf); + } + if (object instanceof Long) { + return createUntypedIterableValue((Long) object, iterator, String::valueOf); + } + if (object instanceof Float) { + return Value.float32Array(convertToTypedIterable((Float) object, iterator)); + } + if (object instanceof Double) { + return Value.float64Array(convertToTypedIterable((Double) object, iterator)); + } + if (object instanceof BigDecimal) { + return Value.numericArray(convertToTypedIterable((BigDecimal) object, iterator)); + } + if (object instanceof ByteArray) { + return Value.bytesArray(convertToTypedIterable((ByteArray) object, iterator)); + } + if (object instanceof byte[]) { + return Value.bytesArray( + SpannerTypeConverter.convertToTypedIterable( + ByteArray::copyFrom, (byte[]) object, iterator)); + } + if (object instanceof Interval) { + return Value.intervalArray(convertToTypedIterable((Interval) object, iterator)); + } + if (object instanceof Timestamp) { + return Value.timestampArray(convertToTypedIterable((Timestamp) object, iterator)); + } + if (object instanceof Date) { + return Value.dateArray(convertToTypedIterable((Date) object, iterator)); + } + if (object instanceof LocalDate) { + return Value.dateArray( + SpannerTypeConverter.convertToTypedIterable( + SpannerTypeConverter::convertLocalDateToSpannerDate, (LocalDate) object, iterator)); + } + if (object instanceof LocalDateTime) { + return createUntypedIterableValue( + (LocalDateTime) object, iterator, val -> convertToISO8601(atUTC(val))); + } + if (object instanceof OffsetDateTime) { + return createUntypedIterableValue( + (OffsetDateTime) object, iterator, val -> convertToISO8601(atUTC(val))); + } + if (object instanceof ZonedDateTime) { + return createUntypedIterableValue( + (ZonedDateTime) object, iterator, val -> convertToISO8601(atUTC(val))); + } + } + + // array and primitive array + if (value instanceof Boolean[]) { + return Value.boolArray(Arrays.asList((Boolean[]) value)); + } + if (value instanceof boolean[]) { + return Value.boolArray((boolean[]) value); + } + if (value instanceof Float[]) { + return Value.float32Array(Arrays.asList((Float[]) value)); + } + if (value instanceof float[]) { + return Value.float32Array((float[]) value); + } + if (value instanceof Double[]) { + return Value.float64Array(Arrays.asList((Double[]) value)); + } + if (value instanceof double[]) { + return Value.float64Array((double[]) value); + } + if (value instanceof Long[]) { + return createUntypedArrayValue(Arrays.stream((Long[]) value)); + } + if (value instanceof long[]) { + return createUntypedArrayValue(Arrays.stream((long[]) value).boxed()); + } + if (value instanceof Integer[]) { + return createUntypedArrayValue(Arrays.stream((Integer[]) value)); + } + if (value instanceof int[]) { + return createUntypedArrayValue(Arrays.stream((int[]) value).boxed()); + } + + return createUntypedStringValue(value); + } + /** Returns the type of this value. This will return a type even if {@code isNull()} is true. */ public abstract Type getType(); diff --git a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/DatabaseClientImplTest.java b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/DatabaseClientImplTest.java index 0fb4af2e8c7..70209917f0b 100644 --- a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/DatabaseClientImplTest.java +++ b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/DatabaseClientImplTest.java @@ -4896,6 +4896,144 @@ public void testMetadataUnknownTypes() { } } + @Test + public void testStatementWithUnnamedParameters() { + DatabaseClient client = + spanner.getDatabaseClient(DatabaseId.of(TEST_PROJECT, TEST_INSTANCE, TEST_DATABASE)); + + Statement statement = + client.getStatementFactory().withUnnamedParameters("select id from test where b=?", true); + Statement generatedStatement = + Statement.newBuilder("select id from test where b=@p1").bind("p1").to(true).build(); + mockSpanner.putStatementResult(StatementResult.query(generatedStatement, SELECT1_RESULTSET)); + + try (ResultSet resultSet = client.singleUse().executeQuery(statement)) { + assertTrue(resultSet.next()); + assertEquals(1L, resultSet.getLong(0)); + assertFalse(resultSet.next()); + } + } + + @Test + public void testStatementWithUnnamedParametersAndSingleLineComment() { + DatabaseClient client = + spanner.getDatabaseClient(DatabaseId.of(TEST_PROJECT, TEST_INSTANCE, TEST_DATABASE)); + + Statement statement = + client + .getStatementFactory() + .withUnnamedParameters( + "-- comment about ? in the statement\nselect id from test where b=?", true); + Statement generatedStatement = + Statement.newBuilder("-- comment about ? in the statement\nselect id from test where b=@p1") + .bind("p1") + .to(true) + .build(); + mockSpanner.putStatementResult(StatementResult.query(generatedStatement, SELECT1_RESULTSET)); + + try (ResultSet resultSet = client.singleUse().executeQuery(statement)) { + assertTrue(resultSet.next()); + assertEquals(1L, resultSet.getLong(0)); + assertFalse(resultSet.next()); + } + } + + @Test + public void testStatementWithUnnamedParametersAndSingleLineCommentWithHash() { + DatabaseClient client = + spanner.getDatabaseClient(DatabaseId.of(TEST_PROJECT, TEST_INSTANCE, TEST_DATABASE)); + + Statement statement = + client + .getStatementFactory() + .withUnnamedParameters( + "# comment about ? in the statement\nselect id from test where b=?", true); + Statement generatedStatement = + Statement.newBuilder("# comment about ? in the statement\nselect id from test where b=@p1") + .bind("p1") + .to(true) + .build(); + mockSpanner.putStatementResult(StatementResult.query(generatedStatement, SELECT1_RESULTSET)); + + try (ResultSet resultSet = client.singleUse().executeQuery(statement)) { + assertTrue(resultSet.next()); + assertEquals(1L, resultSet.getLong(0)); + assertFalse(resultSet.next()); + } + } + + @Test + public void testStatementWithUnnamedParametersAndMultiLineComment() { + DatabaseClient client = + spanner.getDatabaseClient(DatabaseId.of(TEST_PROJECT, TEST_INSTANCE, TEST_DATABASE)); + + Statement statement = + client + .getStatementFactory() + .withUnnamedParameters( + "# comment about ? in the statement\nselect id from test\n /* This is a ? comment \n about ? */ \n where b=? # this is a inline command about ?", + true); + Statement generatedStatement = + Statement.newBuilder( + "# comment about ? in the statement\nselect id from test\n /* This is a ? comment \n about ? */ \n where b=@p1 # this is a inline command about ?") + .bind("p1") + .to(true) + .build(); + mockSpanner.putStatementResult(StatementResult.query(generatedStatement, SELECT1_RESULTSET)); + + try (ResultSet resultSet = client.singleUse().executeQuery(statement)) { + assertTrue(resultSet.next()); + assertEquals(1L, resultSet.getLong(0)); + assertFalse(resultSet.next()); + } + } + + @Test + public void testStatementWithUnnamedParametersAndStringLiteralWithQuestionMark() { + DatabaseClient client = + spanner.getDatabaseClient(DatabaseId.of(TEST_PROJECT, TEST_INSTANCE, TEST_DATABASE)); + + Statement statement = + client + .getStatementFactory() + .withUnnamedParameters("select id from test where name = \"abc?\" AND b=?", true); + Statement generatedStatement = + Statement.newBuilder("select id from test where name = \"abc?\" AND b=@p1") + .bind("p1") + .to(true) + .build(); + mockSpanner.putStatementResult(StatementResult.query(generatedStatement, SELECT1_RESULTSET)); + + try (ResultSet resultSet = client.singleUse().executeQuery(statement)) { + assertTrue(resultSet.next()); + assertEquals(1L, resultSet.getLong(0)); + assertFalse(resultSet.next()); + } + } + + @Test + public void testStatementWithUnnamedParametersAndHint() { + DatabaseClient client = + spanner.getDatabaseClient(DatabaseId.of(TEST_PROJECT, TEST_INSTANCE, TEST_DATABASE)); + + Statement statement = + client + .getStatementFactory() + .withUnnamedParameters("@{FORCE_INDEX=ABCDEF} select id from test where b=?", true); + Statement generatedStatement = + Statement.newBuilder("@{FORCE_INDEX=ABCDEF} select id from test where b=@p1") + .bind("p1") + .to(true) + .build(); + mockSpanner.putStatementResult(StatementResult.query(generatedStatement, SELECT1_RESULTSET)); + + try (ResultSet resultSet = client.singleUse().executeQuery(statement)) { + assertTrue(resultSet.next()); + assertEquals(1L, resultSet.getLong(0)); + assertFalse(resultSet.next()); + } + } + @Test public void testStatementWithBytesArrayParameter() { Statement statement = diff --git a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/ValueTest.java b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/ValueTest.java index 3c96a482340..f88d7683967 100644 --- a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/ValueTest.java +++ b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/ValueTest.java @@ -42,16 +42,28 @@ import com.google.common.testing.EqualsTester; import com.google.protobuf.ListValue; import com.google.protobuf.NullValue; +import com.google.protobuf.ProtocolMessageEnum; +import com.google.spanner.v1.PartialResultSet; +import com.google.spanner.v1.TransactionOptions.IsolationLevel; import java.io.Serializable; import java.math.BigDecimal; import java.math.BigInteger; import java.nio.charset.StandardCharsets; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; import java.util.ArrayList; import java.util.Arrays; import java.util.Base64; import java.util.Collections; +import java.util.HashSet; import java.util.List; import java.util.Random; +import java.util.Set; +import java.util.TimeZone; import java.util.function.Supplier; import java.util.stream.Collectors; import org.junit.Test; @@ -2496,6 +2508,306 @@ public void verifyBrokenSerialization() { reserializeAndAssert(BrokenSerializationList.of(1, 2, 3)); } + @Test + public void testToValue() { + Value value = Value.toValue(null); + assertNull(value.getType()); + assertEquals("NULL", value.getAsString()); + + int i = 10; + value = Value.toValue(i); + assertNull(value.getType()); + assertEquals("10", value.getAsString()); + + Integer j = 10; + value = Value.toValue(j); + assertNull(value.getType()); + assertEquals("10", value.getAsString()); + + long k = 10L; + value = Value.toValue(k); + assertNull(value.getType()); + assertEquals("10", value.getAsString()); + + Long l = 10L; + value = Value.toValue(i); + assertNull(value.getType()); + assertEquals("10", value.getAsString()); + + boolean m = true; + value = Value.toValue(m); + assertEquals(Type.bool(), value.getType()); + assertTrue(value.getBool()); + + Boolean n = true; + value = Value.toValue(n); + assertEquals(Type.bool(), value.getType()); + assertTrue(value.getBool()); + + Float o = 0.3f; + value = Value.toValue(o); + assertEquals(Type.float32(), value.getType()); + assertEquals(0.3f, value.getFloat32(), 0); + + float p = 0.3f; + value = Value.toValue(p); + assertEquals(Type.float32(), value.getType()); + assertEquals(0.3f, value.getFloat32(), 0); + + Double q = 0.4d; + value = Value.toValue(q); + assertEquals(Type.float64(), value.getType()); + assertEquals(0.4d, value.getFloat64(), 0); + + double s = 0.5d; + value = Value.toValue(s); + assertEquals(Type.float64(), value.getType()); + assertEquals(0.5d, value.getFloat64(), 0); + + BigDecimal t = BigDecimal.valueOf(0.6d); + value = Value.toValue(t); + assertEquals(Type.numeric(), value.getType()); + assertEquals(t, value.getNumeric()); + + ByteArray bytes = ByteArray.copyFrom("hello"); + value = Value.toValue(bytes); + assertEquals(Type.bytes(), value.getType()); + assertEquals(bytes, value.getBytes()); + + byte[] byteArray = "hello".getBytes(); + value = Value.toValue(byteArray); + assertEquals(Type.bytes(), value.getType()); + assertEquals(bytes, value.getBytes()); + + Date date = Date.fromYearMonthDay(2018, 2, 26); + value = Value.toValue(date); + assertEquals(Type.date(), value.getType()); + assertEquals(date, value.getDate()); + + LocalDate localDate = LocalDate.of(2018, 2, 26); + value = Value.toValue(localDate); + assertEquals(Type.date(), value.getType()); + assertEquals(date, value.getDate()); + + TimeZone defaultTimezone = TimeZone.getDefault(); + TimeZone.setDefault(TimeZone.getTimeZone("Europe/Paris")); + LocalDateTime localDateTime = LocalDateTime.of(2018, 2, 26, 11, 30, 10); + value = Value.toValue(localDateTime); + assertNull(value.getType()); + assertEquals("2018-02-26T10:30:10.000Z", value.getAsString()); + TimeZone.setDefault(defaultTimezone); + + OffsetDateTime offsetDateTime = OffsetDateTime.of(localDateTime, ZoneOffset.ofHours(10)); + value = Value.toValue(offsetDateTime); + assertNull(value.getType()); + assertEquals("2018-02-26T01:30:10.000Z", value.getAsString()); + + ZonedDateTime zonedDateTime = ZonedDateTime.of(localDateTime, ZoneId.of("Asia/Kolkata")); + value = Value.toValue(zonedDateTime); + assertNull(value.getType()); + assertEquals("2018-02-26T06:00:10.000Z", value.getAsString()); + + ProtocolMessageEnum protocolMessageEnum = IsolationLevel.SERIALIZABLE; + value = Value.toValue(protocolMessageEnum); + assertEquals( + Type.protoEnum("google.spanner.v1.TransactionOptions.IsolationLevel"), value.getType()); + assertEquals( + protocolMessageEnum, + value.getProtoEnum( + (val -> { + switch (val) { + case 1: + return IsolationLevel.SERIALIZABLE; + case 2: + return IsolationLevel.REPEATABLE_READ; + default: + return IsolationLevel.ISOLATION_LEVEL_UNSPECIFIED; + } + }))); + + PartialResultSet partialResultSet = + PartialResultSet.newBuilder() + .addValues(com.google.protobuf.Value.newBuilder().setStringValue("hello").build()) + .build(); + value = Value.toValue(partialResultSet); + assertEquals(Type.proto("google.spanner.v1.PartialResultSet"), value.getType()); + assertEquals(partialResultSet, value.getProtoMessage(PartialResultSet.getDefaultInstance())); + + Interval interval = Interval.ofDays(10); + value = Value.toValue(interval); + assertEquals(Type.interval(), value.getType()); + assertEquals(interval, value.getInterval()); + + Struct struct = Struct.newBuilder().set("name").to(10L).build(); + value = Value.toValue(struct); + assertEquals(Type.struct(StructField.of("name", Type.int64())), value.getType()); + assertEquals(struct, value.getStruct()); + + Timestamp timestamp = Timestamp.now(); + value = Value.toValue(timestamp); + assertEquals(Type.timestamp(), value.getType()); + assertEquals(timestamp, value.getTimestamp()); + + List expectedBoolArray = Arrays.asList(true, false); + boolean[] bools1 = {true, false}; + value = Value.toValue(bools1); + assertEquals(Type.array(Type.bool()), value.getType()); + assertEquals(expectedBoolArray, value.getBoolArray()); + + Boolean[] bools2 = {true, false}; + value = Value.toValue(bools2); + assertEquals(Type.array(Type.bool()), value.getType()); + assertEquals(expectedBoolArray, value.getBoolArray()); + + List expectedFloatArray = Arrays.asList(0.1f, 0.2f, 0.3f); + Float[] floats1 = {0.1f, 0.2f, 0.3f}; + value = Value.toValue(floats1); + assertEquals(Type.array(Type.float32()), value.getType()); + assertEquals(expectedFloatArray, value.getFloat32Array()); + + float[] floats2 = {0.1f, 0.2f, 0.3f}; + value = Value.toValue(floats2); + assertEquals(Type.array(Type.float32()), value.getType()); + assertEquals(expectedFloatArray, value.getFloat32Array()); + + List expectedDoubleArray = Arrays.asList(0.1d, 0.2d, 0.3d, 0.4d); + Double[] doubles1 = {0.1d, 0.2d, 0.3d, 0.4d}; + value = Value.toValue(doubles1); + assertEquals(Type.array(Type.float64()), value.getType()); + assertEquals(expectedDoubleArray, value.getFloat64Array()); + + double[] doubles2 = {0.1d, 0.2d, 0.3d, 0.4d}; + value = Value.toValue(doubles2); + assertEquals(Type.array(Type.float64()), value.getType()); + assertEquals(expectedDoubleArray, value.getFloat64Array()); + + List expectedIntLongArray = Arrays.asList("1", "2", "3"); + int[] ints1 = {1, 2, 3}; + value = Value.toValue(ints1); + assertNull(value.getType()); + assertEquals(expectedIntLongArray, value.getAsStringList()); + + Integer[] ints2 = {1, 2, 3}; + value = Value.toValue(ints2); + assertNull(value.getType()); + assertEquals(expectedIntLongArray, value.getAsStringList()); + + Long[] longs1 = {1L, 2L, 3L}; + value = Value.toValue(longs1); + assertNull(value.getType()); + assertEquals(expectedIntLongArray, value.getAsStringList()); + + long[] longs2 = {1L, 2L, 3L}; + value = Value.toValue(longs2); + assertNull(value.getType()); + assertEquals(expectedIntLongArray, value.getAsStringList()); + + String string = "hello"; + value = Value.toValue(string); + assertNull(value.getType()); + assertEquals("hello", value.getAsString()); + } + + @Test + public void testToValueIterable() { + List booleans = Arrays.asList(true, false); + Value value = Value.toValue(booleans); + assertEquals(Type.array(Type.bool()), value.getType()); + assertEquals(booleans, value.getBoolArray()); + + List ints = Arrays.asList(1, 2, 3); + value = Value.toValue(ints); + assertNull(value.getType()); + assertEquals(Arrays.asList("1", "2", "3"), value.getAsStringList()); + + List longs = Arrays.asList(1L, 2L, 3L); + value = Value.toValue(longs); + assertNull(value.getType()); + assertEquals(Arrays.asList("1", "2", "3"), value.getAsStringList()); + + Set floats = new HashSet<>(Arrays.asList(0.1f, 0.2f, 0.3f)); + value = Value.toValue(floats); + assertEquals(Type.array(Type.float32()), value.getType()); + assertEquals(Arrays.asList(0.1f, 0.2f, 0.3f), value.getFloat32Array()); + + List doubles = Arrays.asList(0.1d, 0.2d, 0.3d, 0.4d); + value = Value.toValue(doubles); + assertEquals(Type.array(Type.float64()), value.getType()); + assertEquals(doubles, value.getFloat64Array()); + + List bigDecimals = + Arrays.asList(BigDecimal.valueOf(0.1d), BigDecimal.valueOf(0.2d)); + value = Value.toValue(bigDecimals); + assertEquals(Type.array(Type.numeric()), value.getType()); + assertEquals(bigDecimals, value.getNumericArray()); + + List byteArrays = + Arrays.asList(ByteArray.copyFrom("hello"), ByteArray.copyFrom("world")); + value = Value.toValue(byteArrays); + assertEquals(Type.array(Type.bytes()), value.getType()); + assertEquals(byteArrays, value.getBytesArray()); + + List bytes = Arrays.asList("hello".getBytes(), "world".getBytes()); + value = Value.toValue(bytes); + assertEquals(Type.array(Type.bytes()), value.getType()); + assertEquals(byteArrays, value.getBytesArray()); + + List intervals = Arrays.asList(Interval.ofDays(10), Interval.ofDays(20)); + value = Value.toValue(intervals); + assertEquals(Type.array(Type.interval()), value.getType()); + assertEquals(intervals, value.getIntervalArray()); + + List timestamps = Arrays.asList(Timestamp.now(), Timestamp.now()); + value = Value.toValue(timestamps); + assertEquals(Type.array(Type.timestamp()), value.getType()); + assertEquals(timestamps, value.getTimestampArray()); + + List dates = + Arrays.asList(Date.fromYearMonthDay(2024, 8, 23), Date.fromYearMonthDay(2024, 12, 27)); + value = Value.toValue(dates); + assertEquals(Type.array(Type.date()), value.getType()); + assertEquals(dates, value.getDateArray()); + + List localDates = + Arrays.asList(LocalDate.of(2024, 8, 23), LocalDate.of(2024, 12, 27)); + value = Value.toValue(localDates); + assertEquals(Type.array(Type.date()), value.getType()); + assertEquals(dates, value.getDateArray()); + + TimeZone defaultTimezone = TimeZone.getDefault(); + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Kolkata")); + List localDateTimes = + Arrays.asList( + LocalDateTime.of(2024, 8, 23, 1, 49, 52, 10), + LocalDateTime.of(2024, 12, 27, 1, 49, 52, 10)); + value = Value.toValue(localDateTimes); + assertNull(value.getType()); + assertEquals( + Arrays.asList("2024-08-22T20:19:52.000Z", "2024-12-26T20:19:52.000Z"), + value.getAsStringList()); + TimeZone.setDefault(defaultTimezone); + + List offsetDateTimes = + Arrays.asList( + LocalDateTime.of(2024, 8, 23, 1, 49, 52, 10).atOffset(ZoneOffset.ofHours(1)), + LocalDateTime.of(2024, 12, 27, 1, 49, 52, 10).atOffset(ZoneOffset.ofHours(1))); + value = Value.toValue(offsetDateTimes); + assertNull(value.getType()); + assertEquals( + Arrays.asList("2024-08-23T00:49:52.000Z", "2024-12-27T00:49:52.000Z"), + value.getAsStringList()); + + List zonedDateTimes = + Arrays.asList( + LocalDateTime.of(2024, 8, 23, 1, 49, 52, 10).atZone(ZoneId.of("UTC")), + LocalDateTime.of(2024, 12, 27, 1, 49, 52, 10).atZone(ZoneId.of("UTC"))); + value = Value.toValue(zonedDateTimes); + assertNull(value.getType()); + assertEquals( + Arrays.asList("2024-08-23T01:49:52.000Z", "2024-12-27T01:49:52.000Z"), + value.getAsStringList()); + } + private static class BrokenSerializationList extends ForwardingList implements Serializable { private static final long serialVersionUID = 1L;