diff --git a/.github/.OwlBot.lock.yaml b/.github/.OwlBot.lock.yaml
index 35478afe3..aadf54f64 100644
--- a/.github/.OwlBot.lock.yaml
+++ b/.github/.OwlBot.lock.yaml
@@ -13,4 +13,4 @@
# limitations under the License.
docker:
image: gcr.io/cloud-devrel-public-resources/owlbot-java:latest
- digest: sha256:8dd8395defb6a5069b0b10c435058bf13980606ba1967e2b3925ed50fc3cb22f
+ digest: sha256:ad9cabee4c022f1aab04a71332369e0c23841062124818a4490f73337f790337
diff --git a/.github/release-trigger.yml b/.github/release-trigger.yml
index d4ca94189..5e6624896 100644
--- a/.github/release-trigger.yml
+++ b/.github/release-trigger.yml
@@ -1 +1,2 @@
enabled: true
+multiScmName: java-spanner-jdbc
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 63e2bbf63..57a583fc9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,18 @@
# Changelog
+## [2.10.0](https://github.com/googleapis/java-spanner-jdbc/compare/v2.9.16...v2.10.0) (2023-05-30)
+
+
+### Features
+
+* Support Savepoint ([#1212](https://github.com/googleapis/java-spanner-jdbc/issues/1212)) ([6833696](https://github.com/googleapis/java-spanner-jdbc/commit/683369633627367342b3a40e3abba4fa81069724))
+
+
+### Dependencies
+
+* Update dependency com.google.cloud:google-cloud-shared-dependencies to v3.10.1 ([#1239](https://github.com/googleapis/java-spanner-jdbc/issues/1239)) ([8f7e7a7](https://github.com/googleapis/java-spanner-jdbc/commit/8f7e7a79be6d7326d7e6bdd6018bb76a695cb1b8))
+* Update dependency com.google.cloud:google-cloud-spanner-bom to v6.42.2 ([#1237](https://github.com/googleapis/java-spanner-jdbc/issues/1237)) ([97961b2](https://github.com/googleapis/java-spanner-jdbc/commit/97961b2c501d428575e283485386e04f4673d968))
+
## [2.9.16](https://github.com/googleapis/java-spanner-jdbc/compare/v2.9.15...v2.9.16) (2023-05-15)
diff --git a/clirr-ignored-differences.xml b/clirr-ignored-differences.xml
index 235cb9074..a92ad5305 100644
--- a/clirr-ignored-differences.xml
+++ b/clirr-ignored-differences.xml
@@ -6,4 +6,14 @@
com/google/cloud/spanner/jdbc/CloudSpannerJdbcConnection
com.google.cloud.spanner.Dialect getDialect()
+
+ 7012
+ com/google/cloud/spanner/jdbc/CloudSpannerJdbcConnection
+ com.google.cloud.spanner.connection.SavepointSupport getSavepointSupport()
+
+
+ 7012
+ com/google/cloud/spanner/jdbc/CloudSpannerJdbcConnection
+ void setSavepointSupport(com.google.cloud.spanner.connection.SavepointSupport)
+
diff --git a/pom.xml b/pom.xml
index 22180dc3b..116956d90 100644
--- a/pom.xml
+++ b/pom.xml
@@ -4,7 +4,7 @@
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
4.0.0
google-cloud-spanner-jdbc
- 2.9.16
+ 2.10.0
jar
Google Cloud Spanner JDBC
https://github.com/googleapis/java-spanner-jdbc
@@ -51,7 +51,7 @@
google-cloud-spanner-jdbc
4.13.2
3.0.2
- 1.1.3
+ 1.1.4
4.11.0
2.2
0.31.1
@@ -62,14 +62,14 @@
com.google.cloud
google-cloud-spanner-bom
- 6.42.0
+ 6.42.2
pom
import
com.google.cloud
google-cloud-shared-dependencies
- 3.9.0
+ 3.10.1
pom
import
@@ -393,7 +393,7 @@
org.apache.maven.plugins
maven-project-info-reports-plugin
- 3.4.3
+ 3.4.4
diff --git a/samples/install-without-bom/pom.xml b/samples/install-without-bom/pom.xml
index 88f873d13..7ac31aeb4 100644
--- a/samples/install-without-bom/pom.xml
+++ b/samples/install-without-bom/pom.xml
@@ -29,7 +29,7 @@
com.google.cloud
google-cloud-spanner-jdbc
- 2.9.14
+ 2.9.16
@@ -42,7 +42,7 @@
com.google.truth
truth
- 1.1.3
+ 1.1.4
test
diff --git a/samples/snapshot/pom.xml b/samples/snapshot/pom.xml
index 55fd3483a..6cd1705d3 100644
--- a/samples/snapshot/pom.xml
+++ b/samples/snapshot/pom.xml
@@ -28,7 +28,7 @@
com.google.cloud
google-cloud-spanner-jdbc
- 2.9.16
+ 2.10.0
@@ -41,7 +41,7 @@
com.google.truth
truth
- 1.1.3
+ 1.1.4
test
diff --git a/samples/snippets/pom.xml b/samples/snippets/pom.xml
index fcbbc8e01..cf10eb51d 100644
--- a/samples/snippets/pom.xml
+++ b/samples/snippets/pom.xml
@@ -30,7 +30,7 @@
com.google.cloud
libraries-bom
- 26.14.0
+ 26.15.0
pom
import
@@ -53,7 +53,7 @@
com.google.truth
truth
- 1.1.3
+ 1.1.4
test
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 1a6cfe2ed..33cf3bc57 100644
--- a/src/main/java/com/google/cloud/spanner/jdbc/AbstractJdbcConnection.java
+++ b/src/main/java/com/google/cloud/spanner/jdbc/AbstractJdbcConnection.java
@@ -28,7 +28,6 @@
import java.sql.SQLException;
import java.sql.SQLWarning;
import java.sql.SQLXML;
-import java.sql.Savepoint;
import java.sql.Struct;
import java.util.Properties;
import java.util.concurrent.Executor;
@@ -42,7 +41,6 @@ abstract class AbstractJdbcConnection extends AbstractJdbcWrapper
"Only isolation level TRANSACTION_SERIALIZABLE is supported";
private static final String ONLY_CLOSE_ALLOWED =
"Only holdability CLOSE_CURSORS_AT_COMMIT is supported";
- private static final String SAVEPOINTS_UNSUPPORTED = "Savepoints are not supported";
private static final String SQLXML_UNSUPPORTED = "SQLXML is not supported";
private static final String STRUCTS_UNSUPPORTED = "Structs are not supported";
private static final String ABORT_UNSUPPORTED = "Abort is not supported";
@@ -163,26 +161,6 @@ public void clearWarnings() throws SQLException {
lastWarning = null;
}
- @Override
- public Savepoint setSavepoint() throws SQLException {
- return checkClosedAndThrowUnsupported(SAVEPOINTS_UNSUPPORTED);
- }
-
- @Override
- public Savepoint setSavepoint(String name) throws SQLException {
- return checkClosedAndThrowUnsupported(SAVEPOINTS_UNSUPPORTED);
- }
-
- @Override
- public void rollback(Savepoint savepoint) throws SQLException {
- checkClosedAndThrowUnsupported(SAVEPOINTS_UNSUPPORTED);
- }
-
- @Override
- public void releaseSavepoint(Savepoint savepoint) throws SQLException {
- checkClosedAndThrowUnsupported(SAVEPOINTS_UNSUPPORTED);
- }
-
@Override
public SQLXML createSQLXML() throws SQLException {
return checkClosedAndThrowUnsupported(SQLXML_UNSUPPORTED);
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 679fa8716..588e8e768 100644
--- a/src/main/java/com/google/cloud/spanner/jdbc/CloudSpannerJdbcConnection.java
+++ b/src/main/java/com/google/cloud/spanner/jdbc/CloudSpannerJdbcConnection.java
@@ -25,6 +25,7 @@
import com.google.cloud.spanner.ResultSet;
import com.google.cloud.spanner.TimestampBound;
import com.google.cloud.spanner.connection.AutocommitDmlMode;
+import com.google.cloud.spanner.connection.SavepointSupport;
import com.google.cloud.spanner.connection.TransactionMode;
import java.sql.Connection;
import java.sql.SQLException;
@@ -256,6 +257,12 @@ default String getStatementTag() throws SQLException {
*/
void setRetryAbortsInternally(boolean retryAbortsInternally) throws SQLException;
+ /** Returns the current savepoint support for this connection. */
+ SavepointSupport getSavepointSupport() throws SQLException;
+
+ /** Sets how savepoints should be supported on this connection. */
+ void setSavepointSupport(SavepointSupport savepointSupport) throws SQLException;
+
/**
* Writes the specified mutation directly to the database and commits the change. The value is
* readable after the successful completion of this method. Writing multiple mutations to a
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 867695fa8..a710ac80b 100644
--- a/src/main/java/com/google/cloud/spanner/jdbc/JdbcConnection.java
+++ b/src/main/java/com/google/cloud/spanner/jdbc/JdbcConnection.java
@@ -23,6 +23,7 @@
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.SavepointSupport;
import com.google.cloud.spanner.connection.TransactionMode;
import com.google.common.collect.Iterators;
import java.sql.Array;
@@ -33,6 +34,7 @@
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
+import java.sql.Savepoint;
import java.sql.Statement;
import java.sql.Timestamp;
import java.util.HashMap;
@@ -402,6 +404,70 @@ public String getSchema() throws SQLException {
return "";
}
+ @Override
+ public SavepointSupport getSavepointSupport() throws SQLException {
+ checkClosed();
+ return getSpannerConnection().getSavepointSupport();
+ }
+
+ @Override
+ public void setSavepointSupport(SavepointSupport savepointSupport) throws SQLException {
+ checkClosed();
+ try {
+ getSpannerConnection().setSavepointSupport(savepointSupport);
+ } catch (SpannerException e) {
+ throw JdbcSqlExceptionFactory.of(e);
+ }
+ }
+
+ @Override
+ public Savepoint setSavepoint() throws SQLException {
+ checkClosed();
+ try {
+ JdbcSavepoint savepoint = JdbcSavepoint.unnamed();
+ getSpannerConnection().savepoint(savepoint.internalGetSavepointName());
+ return savepoint;
+ } catch (SpannerException e) {
+ throw JdbcSqlExceptionFactory.of(e);
+ }
+ }
+
+ @Override
+ public Savepoint setSavepoint(String name) throws SQLException {
+ checkClosed();
+ try {
+ JdbcSavepoint savepoint = JdbcSavepoint.named(name);
+ getSpannerConnection().savepoint(savepoint.internalGetSavepointName());
+ return savepoint;
+ } catch (SpannerException e) {
+ throw JdbcSqlExceptionFactory.of(e);
+ }
+ }
+
+ @Override
+ public void rollback(Savepoint savepoint) throws SQLException {
+ checkClosed();
+ JdbcPreconditions.checkArgument(savepoint instanceof JdbcSavepoint, savepoint);
+ JdbcSavepoint jdbcSavepoint = (JdbcSavepoint) savepoint;
+ try {
+ getSpannerConnection().rollbackToSavepoint(jdbcSavepoint.internalGetSavepointName());
+ } catch (SpannerException e) {
+ throw JdbcSqlExceptionFactory.of(e);
+ }
+ }
+
+ @Override
+ public void releaseSavepoint(Savepoint savepoint) throws SQLException {
+ checkClosed();
+ JdbcPreconditions.checkArgument(savepoint instanceof JdbcSavepoint, savepoint);
+ JdbcSavepoint jdbcSavepoint = (JdbcSavepoint) savepoint;
+ try {
+ getSpannerConnection().releaseSavepoint(jdbcSavepoint.internalGetSavepointName());
+ } catch (SpannerException e) {
+ throw JdbcSqlExceptionFactory.of(e);
+ }
+ }
+
@Override
public Timestamp getCommitTimestamp() throws SQLException {
checkClosed();
diff --git a/src/main/java/com/google/cloud/spanner/jdbc/JdbcSavepoint.java b/src/main/java/com/google/cloud/spanner/jdbc/JdbcSavepoint.java
new file mode 100644
index 000000000..3cd4c4137
--- /dev/null
+++ b/src/main/java/com/google/cloud/spanner/jdbc/JdbcSavepoint.java
@@ -0,0 +1,58 @@
+/*
+ * Copyright 2023 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 java.sql.SQLException;
+import java.sql.Savepoint;
+import java.util.concurrent.atomic.AtomicInteger;
+
+class JdbcSavepoint implements Savepoint {
+ private static final AtomicInteger COUNTER = new AtomicInteger();
+
+ static JdbcSavepoint named(String name) {
+ return new JdbcSavepoint(-1, name);
+ }
+
+ static JdbcSavepoint unnamed() {
+ int id = COUNTER.incrementAndGet();
+ return new JdbcSavepoint(id, String.format("s_%d", id));
+ }
+
+ private final int id;
+ private final String name;
+
+ private JdbcSavepoint(int id, String name) {
+ this.id = id;
+ this.name = name;
+ }
+
+ @Override
+ public int getSavepointId() throws SQLException {
+ JdbcPreconditions.checkState(this.id >= 0, "This is a named savepoint");
+ return id;
+ }
+
+ @Override
+ public String getSavepointName() throws SQLException {
+ JdbcPreconditions.checkState(this.id < 0, "This is an unnamed savepoint");
+ return name;
+ }
+
+ String internalGetSavepointName() {
+ return name;
+ }
+}
diff --git a/src/test/java/com/google/cloud/spanner/jdbc/JdbcSavepointTest.java b/src/test/java/com/google/cloud/spanner/jdbc/JdbcSavepointTest.java
new file mode 100644
index 000000000..50b000b7d
--- /dev/null
+++ b/src/test/java/com/google/cloud/spanner/jdbc/JdbcSavepointTest.java
@@ -0,0 +1,47 @@
+/*
+ * Copyright 2023 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 static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
+
+import java.sql.SQLException;
+import java.sql.Savepoint;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public class JdbcSavepointTest {
+
+ @Test
+ public void testNamed() throws SQLException {
+ Savepoint savepoint = JdbcSavepoint.named("test");
+ assertEquals("test", savepoint.getSavepointName());
+ assertThrows(SQLException.class, savepoint::getSavepointId);
+ }
+
+ @Test
+ public void testUnnamed() throws SQLException {
+ Savepoint savepoint = JdbcSavepoint.unnamed();
+ assertTrue(
+ String.format("Savepoint id: %d", savepoint.getSavepointId()),
+ savepoint.getSavepointId() > 0);
+ assertThrows(SQLException.class, savepoint::getSavepointName);
+ }
+}
diff --git a/src/test/java/com/google/cloud/spanner/jdbc/SavepointMockServerTest.java b/src/test/java/com/google/cloud/spanner/jdbc/SavepointMockServerTest.java
new file mode 100644
index 000000000..2c5d5185d
--- /dev/null
+++ b/src/test/java/com/google/cloud/spanner/jdbc/SavepointMockServerTest.java
@@ -0,0 +1,697 @@
+/*
+ * Copyright 2023 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 static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
+
+import com.google.cloud.spanner.Dialect;
+import com.google.cloud.spanner.ErrorCode;
+import com.google.cloud.spanner.MockSpannerServiceImpl.StatementResult;
+import com.google.cloud.spanner.Mutation;
+import com.google.cloud.spanner.Statement;
+import com.google.cloud.spanner.connection.AbstractMockServerTest;
+import com.google.cloud.spanner.connection.RandomResultSetGenerator;
+import com.google.cloud.spanner.connection.SavepointSupport;
+import com.google.cloud.spanner.connection.SpannerPool;
+import com.google.cloud.spanner.jdbc.JdbcSqlExceptionFactory.JdbcAbortedDueToConcurrentModificationException;
+import com.google.common.base.Strings;
+import com.google.protobuf.AbstractMessage;
+import com.google.spanner.v1.BeginTransactionRequest;
+import com.google.spanner.v1.CommitRequest;
+import com.google.spanner.v1.ExecuteBatchDmlRequest;
+import com.google.spanner.v1.ExecuteSqlRequest;
+import com.google.spanner.v1.RollbackRequest;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Savepoint;
+import java.util.List;
+import java.util.stream.Collectors;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+import org.junit.runners.Parameterized.Parameter;
+import org.junit.runners.Parameterized.Parameters;
+
+@RunWith(Parameterized.class)
+public class SavepointMockServerTest extends AbstractMockServerTest {
+ @Parameter public Dialect dialect;
+
+ @Parameters(name = "dialect = {0}")
+ public static Object[] data() {
+ return Dialect.values();
+ }
+
+ @Before
+ public void setupDialect() {
+ mockSpanner.putStatementResult(StatementResult.detectDialectResult(this.dialect));
+ }
+
+ @After
+ public void clearRequests() {
+ mockSpanner.clearRequests();
+ SpannerPool.closeSpannerPool();
+ }
+
+ private String createUrl() {
+ return String.format(
+ "jdbc:cloudspanner://localhost:%d/projects/%s/instances/%s/databases/%s?usePlainText=true;autoCommit=false",
+ getPort(), "proj", "inst", "db");
+ }
+
+ private Connection createConnection() throws SQLException {
+ return DriverManager.getConnection(createUrl());
+ }
+
+ @Test
+ public void testCreateSavepoint() throws SQLException {
+ try (Connection connection = createConnection()) {
+ connection.setSavepoint("s1");
+
+ if (dialect == Dialect.POSTGRESQL) {
+ // PostgreSQL allows multiple savepoints with the same name.
+ connection.setSavepoint("s1");
+ } else {
+ assertThrows(SQLException.class, () -> connection.setSavepoint("s1"));
+ }
+
+ // Test invalid identifiers.
+ assertThrows(SQLException.class, () -> connection.setSavepoint(null));
+ assertThrows(SQLException.class, () -> connection.setSavepoint(""));
+ assertThrows(SQLException.class, () -> connection.setSavepoint("1"));
+ assertThrows(SQLException.class, () -> connection.setSavepoint("-foo"));
+ assertThrows(SQLException.class, () -> connection.setSavepoint(Strings.repeat("t", 129)));
+ }
+ }
+
+ @Test
+ public void testCreateSavepointWhenDisabled() throws SQLException {
+ try (Connection connection = createConnection()) {
+ connection
+ .unwrap(CloudSpannerJdbcConnection.class)
+ .setSavepointSupport(SavepointSupport.DISABLED);
+ assertThrows(SQLException.class, () -> connection.setSavepoint("s1"));
+ }
+ }
+
+ @Test
+ public void testReleaseSavepoint() throws SQLException {
+ try (Connection connection = createConnection()) {
+ Savepoint s1 = connection.setSavepoint("s1");
+ connection.releaseSavepoint(s1);
+ assertThrows(SQLException.class, () -> connection.releaseSavepoint(s1));
+
+ Savepoint s1_2 = connection.setSavepoint("s1");
+ Savepoint s2 = connection.setSavepoint("s2");
+ connection.releaseSavepoint(s1_2);
+ // Releasing a savepoint also removes all savepoints after it.
+ assertThrows(SQLException.class, () -> connection.releaseSavepoint(s2));
+
+ if (dialect == Dialect.POSTGRESQL) {
+ // PostgreSQL allows multiple savepoints with the same name.
+ Savepoint savepoint1 = connection.setSavepoint("s1");
+ Savepoint savepoint2 = connection.setSavepoint("s2");
+ Savepoint savepoint1_2 = connection.setSavepoint("s1");
+ connection.releaseSavepoint(savepoint1_2);
+ connection.releaseSavepoint(savepoint2);
+ connection.releaseSavepoint(savepoint1);
+ assertThrows(SQLException.class, () -> connection.releaseSavepoint(savepoint1));
+ }
+ }
+ }
+
+ @Test
+ public void testRollbackToSavepoint() throws SQLException {
+ for (SavepointSupport savepointSupport :
+ new SavepointSupport[] {SavepointSupport.ENABLED, SavepointSupport.FAIL_AFTER_ROLLBACK}) {
+ try (Connection connection = createConnection()) {
+ connection.unwrap(CloudSpannerJdbcConnection.class).setSavepointSupport(savepointSupport);
+
+ Savepoint s1 = connection.setSavepoint("s1");
+ connection.rollback(s1);
+ // Rolling back to a savepoint does not remove it, so we can roll back multiple times to the
+ // same savepoint.
+ connection.rollback(s1);
+
+ Savepoint s2 = connection.setSavepoint("s2");
+ connection.rollback(s1);
+ // Rolling back to a savepoint removes all savepoints after it.
+ assertThrows(SQLException.class, () -> connection.rollback(s2));
+
+ if (dialect == Dialect.POSTGRESQL) {
+ // PostgreSQL allows multiple savepoints with the same name.
+ Savepoint savepoint2 = connection.setSavepoint("s2");
+ Savepoint savepoint1 = connection.setSavepoint("s1");
+ connection.rollback(savepoint1);
+ connection.rollback(savepoint2);
+ connection.rollback(savepoint1);
+ connection.rollback(savepoint1);
+ connection.releaseSavepoint(savepoint1);
+ assertThrows(SQLException.class, () -> connection.rollback(savepoint1));
+ }
+ }
+ }
+ }
+
+ @Test
+ public void testSavepointInAutoCommit() throws SQLException {
+ try (Connection connection = createConnection()) {
+ connection.setAutoCommit(true);
+ assertThrows(SQLException.class, () -> connection.setSavepoint("s1"));
+
+ // Starting a 'manual' transaction in autocommit mode should enable savepoints.
+ connection.createStatement().execute("begin transaction");
+ Savepoint s1 = connection.setSavepoint("s1");
+ connection.releaseSavepoint(s1);
+ }
+ }
+
+ @Test
+ public void testRollbackToSavepointInReadOnlyTransaction() throws SQLException {
+ for (SavepointSupport savepointSupport :
+ new SavepointSupport[] {SavepointSupport.ENABLED, SavepointSupport.FAIL_AFTER_ROLLBACK}) {
+ try (Connection connection = createConnection()) {
+ connection.unwrap(CloudSpannerJdbcConnection.class).setSavepointSupport(savepointSupport);
+ connection.setReadOnly(true);
+
+ // Read-only transactions also support savepoints, but they do not do anything. This feature
+ // is here purely for compatibility.
+ Savepoint s1 = connection.setSavepoint("s1");
+ try (ResultSet resultSet =
+ connection.createStatement().executeQuery(SELECT_RANDOM_STATEMENT.getSql())) {
+ int count = 0;
+ while (resultSet.next()) {
+ count++;
+ }
+ assertEquals(RANDOM_RESULT_SET_ROW_COUNT, count);
+ }
+
+ connection.rollback(s1);
+ try (ResultSet resultSet =
+ connection.createStatement().executeQuery(SELECT_RANDOM_STATEMENT.getSql())) {
+ int count = 0;
+ while (resultSet.next()) {
+ count++;
+ }
+ assertEquals(RANDOM_RESULT_SET_ROW_COUNT, count);
+ }
+ // Committing a read-only transaction is necessary to mark the end of the transaction.
+ // It is a no-op on Cloud Spanner.
+ connection.commit();
+
+ assertEquals(1, mockSpanner.countRequestsOfType(BeginTransactionRequest.class));
+ BeginTransactionRequest beginRequest =
+ mockSpanner.getRequestsOfType(BeginTransactionRequest.class).get(0);
+ assertTrue(beginRequest.getOptions().hasReadOnly());
+ assertEquals(0, mockSpanner.countRequestsOfType(CommitRequest.class));
+ }
+ mockSpanner.clearRequests();
+ }
+ }
+
+ @Test
+ public void testRollbackToSavepointInReadWriteTransaction() throws SQLException {
+ try (Connection connection = createConnection()) {
+ connection
+ .unwrap(CloudSpannerJdbcConnection.class)
+ .setSavepointSupport(SavepointSupport.ENABLED);
+
+ Savepoint s1 = connection.setSavepoint("s1");
+ try (ResultSet resultSet =
+ connection.createStatement().executeQuery(SELECT_RANDOM_STATEMENT.getSql())) {
+ int count = 0;
+ while (resultSet.next()) {
+ count++;
+ }
+ assertEquals(RANDOM_RESULT_SET_ROW_COUNT, count);
+ }
+
+ connection.rollback(s1);
+ try (ResultSet resultSet =
+ connection.createStatement().executeQuery(SELECT_RANDOM_STATEMENT.getSql())) {
+ int count = 0;
+ while (resultSet.next()) {
+ count++;
+ }
+ assertEquals(RANDOM_RESULT_SET_ROW_COUNT, count);
+ }
+ connection.commit();
+
+ // Read/write transactions are started with inlined Begin transaction options.
+ assertEquals(0, mockSpanner.countRequestsOfType(BeginTransactionRequest.class));
+ assertEquals(1, mockSpanner.countRequestsOfType(RollbackRequest.class));
+ assertEquals(1, mockSpanner.countRequestsOfType(CommitRequest.class));
+ assertEquals(2, mockSpanner.countRequestsOfType(ExecuteSqlRequest.class));
+
+ List requests =
+ mockSpanner.getRequests().stream()
+ .filter(
+ request ->
+ request instanceof ExecuteSqlRequest
+ || request instanceof RollbackRequest
+ || request instanceof CommitRequest)
+ .collect(Collectors.toList());
+ assertEquals(4, requests.size());
+ int index = 0;
+ assertEquals(ExecuteSqlRequest.class, requests.get(index++).getClass());
+ assertEquals(RollbackRequest.class, requests.get(index++).getClass());
+ assertEquals(ExecuteSqlRequest.class, requests.get(index++).getClass());
+ assertEquals(CommitRequest.class, requests.get(index++).getClass());
+ }
+ }
+
+ @Test
+ public void testRollbackToSavepointWithDmlStatements() throws SQLException {
+ try (Connection connection = createConnection()) {
+ connection
+ .unwrap(CloudSpannerJdbcConnection.class)
+ .setSavepointSupport(SavepointSupport.ENABLED);
+
+ // First do a query that is included in the transaction.
+ try (ResultSet resultSet =
+ connection.createStatement().executeQuery(SELECT_RANDOM_STATEMENT.getSql())) {
+ int count = 0;
+ while (resultSet.next()) {
+ count++;
+ }
+ assertEquals(RANDOM_RESULT_SET_ROW_COUNT, count);
+ }
+ // Set a savepoint and execute a couple of DML statements.
+ Savepoint s1 = connection.setSavepoint("s1");
+ connection.createStatement().executeUpdate(INSERT_STATEMENT.getSql());
+ Savepoint s2 = connection.setSavepoint("s2");
+ connection.createStatement().executeUpdate(INSERT_STATEMENT.getSql());
+ // Rollback the last DML statement and commit.
+ connection.rollback(s2);
+
+ connection.commit();
+
+ // Read/write transactions are started with inlined Begin transaction options.
+ assertEquals(0, mockSpanner.countRequestsOfType(BeginTransactionRequest.class));
+ assertEquals(1, mockSpanner.countRequestsOfType(RollbackRequest.class));
+ assertEquals(1, mockSpanner.countRequestsOfType(CommitRequest.class));
+ assertEquals(5, mockSpanner.countRequestsOfType(ExecuteSqlRequest.class));
+
+ List requests =
+ mockSpanner.getRequests().stream()
+ .filter(
+ request ->
+ request instanceof ExecuteSqlRequest
+ || request instanceof RollbackRequest
+ || request instanceof CommitRequest)
+ .collect(Collectors.toList());
+ assertEquals(7, requests.size());
+ int index = 0;
+ assertEquals(ExecuteSqlRequest.class, requests.get(index++).getClass());
+ assertEquals(ExecuteSqlRequest.class, requests.get(index++).getClass());
+ assertEquals(ExecuteSqlRequest.class, requests.get(index++).getClass());
+ assertEquals(RollbackRequest.class, requests.get(index++).getClass());
+ assertEquals(ExecuteSqlRequest.class, requests.get(index++).getClass());
+ assertEquals(ExecuteSqlRequest.class, requests.get(index++).getClass());
+ assertEquals(CommitRequest.class, requests.get(index++).getClass());
+ }
+ }
+
+ @Test
+ public void testRollbackToSavepointFails() throws SQLException {
+ Statement statement = Statement.of("select * from foo where bar=true");
+ int numRows = 10;
+ RandomResultSetGenerator generator = new RandomResultSetGenerator(numRows);
+ mockSpanner.putStatementResult(StatementResult.query(statement, generator.generate()));
+ try (Connection connection = createConnection()) {
+ connection
+ .unwrap(CloudSpannerJdbcConnection.class)
+ .setSavepointSupport(SavepointSupport.ENABLED);
+
+ try (ResultSet resultSet = connection.createStatement().executeQuery(statement.getSql())) {
+ int count = 0;
+ while (resultSet.next()) {
+ count++;
+ }
+ assertEquals(numRows, count);
+ }
+ // Set a savepoint and execute a couple of DML statements.
+ Savepoint s1 = connection.setSavepoint("s1");
+ connection.createStatement().executeUpdate(INSERT_STATEMENT.getSql());
+ connection.createStatement().executeUpdate(INSERT_STATEMENT.getSql());
+ // Change the result of the initial query.
+ mockSpanner.putStatementResult(StatementResult.query(statement, generator.generate()));
+ // Rollback to before the DML statements.
+ // This will succeed as long as we don't execute any further statements.
+ connection.rollback(s1);
+
+ // Trying to commit the transaction or execute any other statements on the transaction will
+ // fail.
+ assertThrows(JdbcAbortedDueToConcurrentModificationException.class, connection::commit);
+
+ // Read/write transactions are started with inlined Begin transaction options.
+ assertEquals(0, mockSpanner.countRequestsOfType(BeginTransactionRequest.class));
+ assertEquals(2, mockSpanner.countRequestsOfType(RollbackRequest.class));
+ assertEquals(0, mockSpanner.countRequestsOfType(CommitRequest.class));
+ assertEquals(4, mockSpanner.countRequestsOfType(ExecuteSqlRequest.class));
+
+ List requests =
+ mockSpanner.getRequests().stream()
+ .filter(
+ request ->
+ request instanceof ExecuteSqlRequest
+ || request instanceof RollbackRequest
+ || request instanceof CommitRequest)
+ .collect(Collectors.toList());
+ assertEquals(6, requests.size());
+ int index = 0;
+ assertEquals(ExecuteSqlRequest.class, requests.get(index++).getClass());
+ assertEquals(ExecuteSqlRequest.class, requests.get(index++).getClass());
+ assertEquals(ExecuteSqlRequest.class, requests.get(index++).getClass());
+ assertEquals(RollbackRequest.class, requests.get(index++).getClass());
+ assertEquals(ExecuteSqlRequest.class, requests.get(index++).getClass());
+ assertEquals(RollbackRequest.class, requests.get(index++).getClass());
+ }
+ }
+
+ @Test
+ public void testRollbackToSavepointWithFailAfterRollback() throws SQLException {
+ Statement statement = Statement.of("select * from foo where bar=true");
+ int numRows = 10;
+ RandomResultSetGenerator generator = new RandomResultSetGenerator(numRows);
+ mockSpanner.putStatementResult(StatementResult.query(statement, generator.generate()));
+ try (Connection connection = createConnection()) {
+ connection
+ .unwrap(CloudSpannerJdbcConnection.class)
+ .setSavepointSupport(SavepointSupport.FAIL_AFTER_ROLLBACK);
+
+ try (ResultSet resultSet = connection.createStatement().executeQuery(statement.getSql())) {
+ int count = 0;
+ while (resultSet.next()) {
+ count++;
+ }
+ assertEquals(numRows, count);
+ }
+ // Set a savepoint and execute a couple of DML statements.
+ Savepoint s1 = connection.setSavepoint("s1");
+ connection.createStatement().executeUpdate(INSERT_STATEMENT.getSql());
+ connection.createStatement().executeUpdate(INSERT_STATEMENT.getSql());
+ // Rollback to before the DML statements.
+ // This will succeed as long as we don't execute any further statements.
+ connection.rollback(s1);
+
+ // Trying to commit the transaction or execute any other statements on the transaction will
+ // fail with an FAILED_PRECONDITION error, as using a transaction after a rollback to
+ // savepoint has been disabled.
+ SQLException exception = assertThrows(SQLException.class, connection::commit);
+ assertEquals(
+ ErrorCode.FAILED_PRECONDITION.getGrpcStatusCode().value(), exception.getErrorCode());
+ assertEquals(
+ "FAILED_PRECONDITION: Using a read/write transaction after rolling back to a "
+ + "savepoint is not supported with SavepointSupport=FAIL_AFTER_ROLLBACK",
+ exception.getMessage());
+ }
+ }
+
+ @Test
+ public void testRollbackToSavepointSucceedsWithRollback() throws SQLException {
+ for (SavepointSupport savepointSupport :
+ new SavepointSupport[] {SavepointSupport.ENABLED, SavepointSupport.FAIL_AFTER_ROLLBACK}) {
+ Statement statement = Statement.of("select * from foo where bar=true");
+ int numRows = 10;
+ RandomResultSetGenerator generator = new RandomResultSetGenerator(numRows);
+ mockSpanner.putStatementResult(StatementResult.query(statement, generator.generate()));
+ try (Connection connection = createConnection()) {
+ connection.unwrap(CloudSpannerJdbcConnection.class).setSavepointSupport(savepointSupport);
+
+ try (ResultSet resultSet = connection.createStatement().executeQuery(statement.getSql())) {
+ int count = 0;
+ while (resultSet.next()) {
+ count++;
+ }
+ assertEquals(numRows, count);
+ }
+ // Change the result of the initial query and set a savepoint.
+ Savepoint s1 = connection.setSavepoint("s1");
+ mockSpanner.putStatementResult(StatementResult.query(statement, generator.generate()));
+ // This will succeed as long as we don't execute any further statements.
+ connection.rollback(s1);
+
+ // Rolling back the transaction should now be a no-op, as it has already been rolled back.
+ connection.rollback();
+
+ // Read/write transactions are started with inlined Begin transaction options.
+ assertEquals(0, mockSpanner.countRequestsOfType(BeginTransactionRequest.class));
+ assertEquals(1, mockSpanner.countRequestsOfType(RollbackRequest.class));
+ assertEquals(0, mockSpanner.countRequestsOfType(CommitRequest.class));
+ assertEquals(1, mockSpanner.countRequestsOfType(ExecuteSqlRequest.class));
+ }
+ mockSpanner.clearRequests();
+ }
+ }
+
+ @Test
+ public void testMultipleRollbacksWithChangedResults() throws SQLException {
+ Statement statement = Statement.of("select * from foo where bar=true");
+ int numRows = 10;
+ RandomResultSetGenerator generator = new RandomResultSetGenerator(numRows);
+ mockSpanner.putStatementResult(StatementResult.query(statement, generator.generate()));
+ try (Connection connection = createConnection()) {
+ try (ResultSet resultSet = connection.createStatement().executeQuery(statement.getSql())) {
+ int count = 0;
+ while (resultSet.next()) {
+ count++;
+ }
+ assertEquals(numRows, count);
+ }
+ Savepoint s1 = connection.setSavepoint("s1");
+ connection.createStatement().executeUpdate(INSERT_STATEMENT.getSql());
+ Savepoint s2 = connection.setSavepoint("s2");
+ connection.createStatement().executeUpdate(INSERT_STATEMENT.getSql());
+
+ // Change the result of the initial query to make sure that any retry will fail.
+ mockSpanner.putStatementResult(StatementResult.query(statement, generator.generate()));
+ // This will succeed as long as we don't execute any further statements.
+ connection.rollback(s2);
+ // Rolling back one further should also work.
+ connection.rollback(s1);
+
+ // Rolling back the transaction should now be a no-op, as it has already been rolled back.
+ connection.rollback();
+
+ assertEquals(1, mockSpanner.countRequestsOfType(RollbackRequest.class));
+ assertEquals(0, mockSpanner.countRequestsOfType(CommitRequest.class));
+ assertEquals(3, mockSpanner.countRequestsOfType(ExecuteSqlRequest.class));
+ }
+ }
+
+ @Test
+ public void testMultipleRollbacks() throws SQLException {
+ Statement statement = Statement.of("select * from foo where bar=true");
+ int numRows = 10;
+ RandomResultSetGenerator generator = new RandomResultSetGenerator(numRows);
+ mockSpanner.putStatementResult(StatementResult.query(statement, generator.generate()));
+ try (Connection connection = createConnection()) {
+ connection
+ .unwrap(CloudSpannerJdbcConnection.class)
+ .setSavepointSupport(SavepointSupport.ENABLED);
+
+ try (ResultSet resultSet = connection.createStatement().executeQuery(statement.getSql())) {
+ int count = 0;
+ while (resultSet.next()) {
+ count++;
+ }
+ assertEquals(numRows, count);
+ }
+ Savepoint s1 = connection.setSavepoint("s1");
+ connection.createStatement().executeUpdate(INSERT_STATEMENT.getSql());
+ Savepoint s2 = connection.setSavepoint("s2");
+ connection.createStatement().executeUpdate(INSERT_STATEMENT.getSql());
+
+ // First roll back one step and then one more.
+ connection.rollback(s2);
+ connection.rollback(s1);
+
+ // This will only commit the SELECT query.
+ connection.commit();
+
+ assertEquals(1, mockSpanner.countRequestsOfType(RollbackRequest.class));
+ assertEquals(1, mockSpanner.countRequestsOfType(CommitRequest.class));
+ assertEquals(4, mockSpanner.countRequestsOfType(ExecuteSqlRequest.class));
+
+ List requests =
+ mockSpanner.getRequests().stream()
+ .filter(
+ request ->
+ request instanceof ExecuteSqlRequest
+ || request instanceof RollbackRequest
+ || request instanceof CommitRequest)
+ .collect(Collectors.toList());
+ assertEquals(6, requests.size());
+ int index = 0;
+ assertEquals(ExecuteSqlRequest.class, requests.get(index++).getClass());
+ assertEquals(ExecuteSqlRequest.class, requests.get(index++).getClass());
+ assertEquals(ExecuteSqlRequest.class, requests.get(index++).getClass());
+ assertEquals(RollbackRequest.class, requests.get(index++).getClass());
+ assertEquals(ExecuteSqlRequest.class, requests.get(index++).getClass());
+ assertEquals(CommitRequest.class, requests.get(index++).getClass());
+ }
+ }
+
+ @Test
+ public void testRollbackMutations() throws SQLException {
+ try (Connection con = createConnection()) {
+ CloudSpannerJdbcConnection connection = con.unwrap(CloudSpannerJdbcConnection.class);
+ connection.setSavepointSupport(SavepointSupport.ENABLED);
+
+ connection.bufferedWrite(Mutation.newInsertBuilder("foo1").build());
+ Savepoint s1 = connection.setSavepoint("s1");
+ connection.createStatement().executeUpdate(INSERT_STATEMENT.getSql());
+ connection.bufferedWrite(Mutation.newInsertBuilder("foo2").build());
+ connection.setSavepoint("s2");
+ connection.createStatement().executeUpdate(INSERT_STATEMENT.getSql());
+ connection.bufferedWrite(Mutation.newInsertBuilder("foo3").build());
+
+ connection.rollback(s1);
+
+ // This will only commit the first mutation.
+ connection.commit();
+
+ assertEquals(1, mockSpanner.countRequestsOfType(RollbackRequest.class));
+ assertEquals(1, mockSpanner.countRequestsOfType(CommitRequest.class));
+ assertEquals(2, mockSpanner.countRequestsOfType(ExecuteSqlRequest.class));
+ CommitRequest commitRequest = mockSpanner.getRequestsOfType(CommitRequest.class).get(0);
+ assertEquals(1, commitRequest.getMutationsCount());
+ assertEquals("foo1", commitRequest.getMutations(0).getInsert().getTable());
+ }
+ }
+
+ @Test
+ public void testRollbackBatchDml() throws SQLException {
+ try (Connection connection = createConnection()) {
+ connection
+ .unwrap(CloudSpannerJdbcConnection.class)
+ .setSavepointSupport(SavepointSupport.ENABLED);
+
+ connection.createStatement().executeUpdate(INSERT_STATEMENT.getSql());
+ Savepoint s1 = connection.setSavepoint("s1");
+ try (java.sql.Statement statement = connection.createStatement()) {
+ statement.addBatch(INSERT_STATEMENT.getSql());
+ statement.addBatch(INSERT_STATEMENT.getSql());
+ statement.executeBatch();
+ }
+ Savepoint s2 = connection.setSavepoint("s2");
+
+ connection.createStatement().executeUpdate(INSERT_STATEMENT.getSql());
+ Savepoint s3 = connection.setSavepoint("s3");
+ try (java.sql.Statement statement = connection.createStatement()) {
+ statement.addBatch(INSERT_STATEMENT.getSql());
+ statement.addBatch(INSERT_STATEMENT.getSql());
+ statement.executeBatch();
+ }
+ connection.setSavepoint("s4");
+
+ connection.rollback(s2);
+
+ connection.commit();
+
+ assertEquals(1, mockSpanner.countRequestsOfType(RollbackRequest.class));
+ assertEquals(1, mockSpanner.countRequestsOfType(CommitRequest.class));
+ assertEquals(3, mockSpanner.countRequestsOfType(ExecuteSqlRequest.class));
+ assertEquals(3, mockSpanner.countRequestsOfType(ExecuteBatchDmlRequest.class));
+
+ List requests =
+ mockSpanner.getRequests().stream()
+ .filter(
+ request ->
+ request instanceof ExecuteSqlRequest
+ || request instanceof RollbackRequest
+ || request instanceof CommitRequest
+ || request instanceof ExecuteBatchDmlRequest)
+ .collect(Collectors.toList());
+ assertEquals(8, requests.size());
+ int index = 0;
+ assertEquals(ExecuteSqlRequest.class, requests.get(index++).getClass());
+ assertEquals(ExecuteBatchDmlRequest.class, requests.get(index++).getClass());
+ assertEquals(ExecuteSqlRequest.class, requests.get(index++).getClass());
+ assertEquals(ExecuteBatchDmlRequest.class, requests.get(index++).getClass());
+ assertEquals(RollbackRequest.class, requests.get(index++).getClass());
+ assertEquals(ExecuteSqlRequest.class, requests.get(index++).getClass());
+ assertEquals(ExecuteBatchDmlRequest.class, requests.get(index++).getClass());
+ assertEquals(CommitRequest.class, requests.get(index++).getClass());
+ }
+ }
+
+ @Test
+ public void testRollbackToSavepointWithoutInternalRetries() throws SQLException {
+ Statement statement = Statement.of("select * from foo where bar=true");
+ int numRows = 10;
+ RandomResultSetGenerator generator = new RandomResultSetGenerator(numRows);
+ mockSpanner.putStatementResult(StatementResult.query(statement, generator.generate()));
+ try (Connection connection = createConnection()) {
+ connection.unwrap(CloudSpannerJdbcConnection.class).setRetryAbortsInternally(false);
+
+ Savepoint s1 = connection.setSavepoint("s1");
+ try (ResultSet resultSet = connection.createStatement().executeQuery(statement.getSql())) {
+ int count = 0;
+ while (resultSet.next()) {
+ count++;
+ }
+ assertEquals(numRows, count);
+ }
+ // This should work.
+ connection.rollback(s1);
+ // Resuming after a rollback is not supported without internal retries enabled.
+ assertThrows(
+ SQLException.class,
+ () -> connection.createStatement().executeUpdate(INSERT_STATEMENT.getSql()));
+ }
+ }
+
+ @Test
+ public void testRollbackToSavepointWithoutInternalRetriesInReadOnlyTransaction()
+ throws SQLException {
+ Statement statement = Statement.of("select * from foo where bar=true");
+ int numRows = 10;
+ RandomResultSetGenerator generator = new RandomResultSetGenerator(numRows);
+ mockSpanner.putStatementResult(StatementResult.query(statement, generator.generate()));
+ try (Connection connection = createConnection()) {
+ connection.unwrap(CloudSpannerJdbcConnection.class).setRetryAbortsInternally(false);
+ connection.setReadOnly(true);
+
+ Savepoint s1 = connection.setSavepoint("s1");
+ try (ResultSet resultSet = connection.createStatement().executeQuery(statement.getSql())) {
+ int count = 0;
+ while (resultSet.next()) {
+ count++;
+ }
+ assertEquals(numRows, count);
+ }
+
+ // Both rolling back and resuming after a rollback are supported in a read-only transaction,
+ // even if internal retries have been disabled.
+ connection.rollback(s1);
+ try (ResultSet resultSet = connection.createStatement().executeQuery(statement.getSql())) {
+ int count = 0;
+ while (resultSet.next()) {
+ count++;
+ }
+ assertEquals(numRows, count);
+ }
+ }
+ }
+}
diff --git a/versions.txt b/versions.txt
index 990caee80..363915b72 100644
--- a/versions.txt
+++ b/versions.txt
@@ -1,4 +1,4 @@
# Format:
# module:released-version:current-version
-google-cloud-spanner-jdbc:2.9.16:2.9.16
+google-cloud-spanner-jdbc:2.10.0:2.10.0