Repository: GoogleCloudPlatform/pgadapter
Pull request #660 (2023-02-15 13:46:44)

perf: do not create a transaction for describe+execute
We should not start an implicit transaction when all that is being executed is an (auto-)describe message + execute message for the same statement. Instead, these two statements can safely be executed in separate single-use read-only transactions.

codecov[bot]: # [Codecov](https://codecov.io/gh/GoogleCloudPlatform/pgadapter/pull/660?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleCloudPlatform) Report
> Merging [#660](https://codecov.io/gh/GoogleCloudPlatform/pgadapter/pull/660?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleCloudPlatform) (38eb66d) into [postgresql-dialect](https://codecov.io/gh/GoogleCloudPlatform/pgadapter/commit/8a19695373ed5371e9baea4a5980b10c50736403?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleCloudPlatform) (8a19695) will **decrease** coverage by `0.01%`.
> The diff coverage is `55.55%`.

```diff
@@                   Coverage Diff                    @@
##             postgresql-dialect     #660      +/-   ##
========================================================
- Coverage                 89.03%   89.03%   -0.01%     
- Complexity                 2191     2194       +3     
========================================================
  Files                       126      126              
  Lines                      7311     7320       +9     
  Branches                   1042     1048       +6     
========================================================
+ Hits                       6509     6517       +8     
+ Misses                      561      559       -2     
- Partials                    241      244       +3     
```

| Flag | Coverage Δ | |
|---|---|---|
| all_tests | `89.03% <55.55%> (-0.01%)` | :arrow_down: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleCloudPlatform#carryforward-flags-in-the-pull-request-comment) to find out more.

| [Impacted Files](https://codecov.io/gh/GoogleCloudPlatform/pgadapter/pull/660?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleCloudPlatform) | Coverage Δ | |
|---|---|---|
| [...panner/pgadapter/statements/BackendConnection.java](https://codecov.io/gh/GoogleCloudPlatform/pgadapter/pull/660?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleCloudPlatform#diff-c3JjL21haW4vamF2YS9jb20vZ29vZ2xlL2Nsb3VkL3NwYW5uZXIvcGdhZGFwdGVyL3N0YXRlbWVudHMvQmFja2VuZENvbm5lY3Rpb24uamF2YQ==) | `93.02% <55.55%> (-0.62%)` | :arrow_down: |
| [...gle/cloud/spanner/pgadapter/ConnectionHandler.java](https://codecov.io/gh/GoogleCloudPlatform/pgadapter/pull/660?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleCloudPlatform#diff-c3JjL21haW4vamF2YS9jb20vZ29vZ2xlL2Nsb3VkL3NwYW5uZXIvcGdhZGFwdGVyL0Nvbm5lY3Rpb25IYW5kbGVyLmphdmE=) | `88.08% <0.00%> (+0.77%)` | :arrow_up: |

:mega: We’re building smart automated test selection to slash your CI/CD build times. [Learn more](https://about.codecov.io/iterative-testing/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleCloudPlatform)


Patch:
diff --git a/src/main/java/com/google/cloud/spanner/pgadapter/parsers/TimestampParser.java b/src/main/java/com/google/cloud/spanner/pgadapter/parsers/TimestampParser.java
index 54682366..2945b1f5 100644
--- a/src/main/java/com/google/cloud/spanner/pgadapter/parsers/TimestampParser.java
+++ b/src/main/java/com/google/cloud/spanner/pgadapter/parsers/TimestampParser.java
@@ -124,7 +124,8 @@ public class TimestampParser extends Parser<Timestamp> {
    * Converts the given string value to a {@link Timestamp}. The given timezone is used to determine
    * the actual point in time if the timestamp string itself does not contain a timezone offset.
    */
-  public static Timestamp toTimestamp(String value, ZoneId timezone) {
+  public static Timestamp toTimestamp(@Nonnull String value, @Nonnull ZoneId timezone) {
+    value = stripBracketsAndQuotes(value);
     try {
       String stringValue = toPGString(value);
       TemporalAccessor temporalAccessor = TIMESTAMPTZ_INPUT_FORMATTER.parse(stringValue);
@@ -158,6 +159,28 @@ public class TimestampParser extends Parser<Timestamp> {
     }
   }
 
+  static String stripBracketsAndQuotes(@Nonnull String value) {
+    while (value.length() > 1
+        && (value.charAt(0) == '('
+            || value.charAt(0) == '"'
+            || value.charAt(0) == '\''
+            || Character.isWhitespace(value.charAt(0))
+            || Character.isWhitespace(value.charAt(value.length() - 1)))) {
+      if (value.charAt(value.length() - 1) == value.charAt(0)) {
+        value = value.substring(1, value.length() - 1);
+      } else if (value.charAt(0) == '(' && value.charAt(value.length() - 1) == ')') {
+        value = value.substring(1, value.length() - 1);
+      } else if (Character.isWhitespace(value.charAt(0))) {
+        value = value.substring(1);
+      } else if (Character.isWhitespace(value.charAt(value.length() - 1))) {
+        value = value.substring(0, value.length() - 1);
+      } else {
+        break;
+      }
+    }
+    return value;
+  }
+
   @Override
   public String stringParse() {
     return this.item == null ? null : toPGString(this.item, sessionState.getTimezone());
diff --git a/src/main/java/com/google/cloud/spanner/pgadapter/statements/BackendConnection.java b/src/main/java/com/google/cloud/spanner/pgadapter/statements/BackendConnection.java
index ef1d09f7..eca718b7 100644
--- a/src/main/java/com/google/cloud/spanner/pgadapter/statements/BackendConnection.java
+++ b/src/main/java/com/google/cloud/spanner/pgadapter/statements/BackendConnection.java
@@ -15,8 +15,6 @@
 package com.google.cloud.spanner.pgadapter.statements;
 
 import static com.google.cloud.spanner.pgadapter.error.PGExceptionFactory.toPGException;
-import static com.google.cloud.spanner.pgadapter.statements.SimpleParser.isCommand;
-import static com.google.cloud.spanner.pgadapter.wireprotocol.QueryMessage.COPY;
 
 import com.google.api.core.InternalApi;
 import com.google.cloud.ByteArray;
@@ -162,6 +160,8 @@ public class BackendConnection {
       return false;
     }
 
+    abstract boolean isUpdate();
+
     abstract void execute();
 
     void checkConnectionState() {
@@ -199,6 +199,11 @@ public class BackendConnection {
       return !analyze;
     }
 
+    @Override
+    boolean isUpdate() {
+      return this.parsedStatement.isUpdate();
+    }
+
     @Override
     void execute() {
       Statement updatedStatement = statement;
@@ -384,6 +389,11 @@ public class BackendConnection {
       super(parsedStatement, statement);
     }
 
+    @Override
+    boolean isUpdate() {
+      return false;
+    }
+
     @Override
     void execute() {
       checkConnectionState();
@@ -454,6 +464,11 @@ public class BackendConnection {
       this.executor = MoreExecutors.listeningDecorator(executor);
     }
 
+    @Override
+    boolean isUpdate() {
+      return true;
+    }
+
     @Override
     void execute() {
       try {
@@ -493,6 +508,11 @@ public class BackendConnection {
       this.vacuumStatement = vacuumStatement;
     }
 
+    @Override
+    boolean isUpdate() {
+      return false;
+    }
+
     @Override
     void execute() {
       try {
@@ -534,6 +554,11 @@ public class BackendConnection {
       this.truncateStatement = truncateStatement;
     }
 
+    @Override
+    boolean isUpdate() {
+      return true;
+    }
+
     @Override
     void execute() {
       try {
@@ -851,11 +876,26 @@ public class BackendConnection {
     if (spannerConnection.isDdlBatchActive() || spannerConnection.isDmlBatchActive()) {
       return;
     }
+    // Do not start an implicit transaction if all that is in the buffer is a DESCRIBE and an
+    // EXECUTE message for the same statement.
+    if (isSync
+        && bufferedStatements.size() == 2
+        && bufferedStatements.get(0) instanceof Execute
+        && bufferedStatements.get(1) instanceof Execute
+        && ((Execute) bufferedStatements.get(0)).analyze
+        && !((Execute) bufferedStatements.get(1)).analyze
+        && bufferedStatements
+            .get(0)
+            .statement
+            .getSql()
+            .equals(bufferedStatements.get(1).statement.getSql())) {
+      return;
+    }
 
     // We need to start an implicit transaction.
     // Check if a read-only transaction suffices.
     spannerConnection.beginTransaction();
-    if (isSync && !hasDmlOrCopyStatementsAfter(index)) {
+    if (isSync && !hasUpdateStatementsAfter(index)) {
       spannerConnection.setTransactionMode(
           com.google.cloud.spanner.connection.TransactionMode.READ_ONLY_TRANSACTION);
     }
@@ -1028,13 +1068,9 @@ public class BackendConnection {
   }
 
   @VisibleForTesting
-  boolean hasDmlOrCopyStatementsAfter(int index) {
+  boolean hasUpdateStatementsAfter(int index) {
     return bufferedStatements.subList(index, bufferedStatements.size()).stream()
-        .anyMatch(
-            statement ->
-                statement.parsedStatement.getType() == StatementType.UPDATE
-                    || statement.parsedStatement.getType() == StatementType.UNKNOWN
-                        && isCommand(COPY, statement.parsedStatement.getSqlWithoutComments()));
+        .anyMatch(BufferedStatement::isUpdate);
   }
 
   private int getStatementCount() {


Test patch:
diff --git a/src/test/java/com/google/cloud/spanner/pgadapter/JdbcMockServerTest.java b/src/test/java/com/google/cloud/spanner/pgadapter/JdbcMockServerTest.java
index 01fa0ee8..30c1a332 100644
--- a/src/test/java/com/google/cloud/spanner/pgadapter/JdbcMockServerTest.java
+++ b/src/test/java/com/google/cloud/spanner/pgadapter/JdbcMockServerTest.java
@@ -857,6 +857,15 @@ public class JdbcMockServerTest extends AbstractMockServerTest {
         List<ExecuteSqlRequest> requests = mockSpanner.getRequestsOfType(ExecuteSqlRequest.class);
         if (attempt == 1) {
           assertEquals(2, requests.size());
+          ExecuteSqlRequest describeRequest = requests.get(0);
+          assertEquals(QueryMode.PLAN, describeRequest.getQueryMode());
+          assertEquals(pgSql, describeRequest.getSql());
+          // Even though we are sending two requests to Cloud Spanner, we should not start a
+          // transaction for these two statements, as the first statement is only used to describe
+          // the parameters, and not to get any actual data.
+          assertTrue(describeRequest.hasTransaction());
+          assertTrue(describeRequest.getTransaction().hasSingleUse());
+          assertTrue(describeRequest.getTransaction().getSingleUse().hasReadOnly());
         } else {
           assertEquals(1, requests.size());
         }
@@ -864,6 +873,9 @@ public class JdbcMockServerTest extends AbstractMockServerTest {
         ExecuteSqlRequest executeRequest = requests.get(requests.size() - 1);
         assertEquals(QueryMode.NORMAL, executeRequest.getQueryMode());
         assertEquals(pgSql, executeRequest.getSql());
+        assertTrue(executeRequest.hasTransaction());
+        assertTrue(executeRequest.getTransaction().hasSingleUse());
+        assertTrue(executeRequest.getTransaction().getSingleUse().hasReadOnly());
 
         Map<String, Value> params = executeRequest.getParams().getFieldsMap();
         Map<String, Type> types = executeRequest.getParamTypesMap();
@@ -871,6 +883,9 @@ public class JdbcMockServerTest extends AbstractMockServerTest {
         assertEquals(TypeCode.DATE, types.get("p1").getCode());
         assertEquals("2022-03-29", params.get("p1").getStringValue());
 
+        assertEquals(0, mockSpanner.countRequestsOfType(CommitRequest.class));
+        assertEquals(0, mockSpanner.countRequestsOfType(RollbackRequest.class));
+
         mockSpanner.clearRequests();
       }
     }
@@ -4018,6 +4033,27 @@ public class JdbcMockServerTest extends AbstractMockServerTest {
     assertEquals(sql, request.getStatements(0).getSql());
   }
 
+  @Test
+  public void testDescribeAndExecute() throws SQLException {
+    String sql = "delete from foo";
+    mockSpanner.putStatementResult(StatementResult.update(Statement.of(sql), 10L));
+
+    try (Connection connection = DriverManager.getConnection(createUrl())) {
+      connection.unwrap(PGConnection.class).setPrepareThreshold(-1);
+      try (PreparedStatement preparedStatement = connection.prepareStatement("truncate foo")) {
+        assertEquals(0, preparedStatement.executeUpdate());
+      }
+    }
+
+    assertEquals(1, mockSpanner.countRequestsOfType(CommitRequest.class));
+    assertEquals(1, mockSpanner.countRequestsOfType(ExecuteBatchDmlRequest.class));
+    ExecuteBatchDmlRequest request =
+        mockSpanner.getRequestsOfType(ExecuteBatchDmlRequest.class).get(0);
+    assertTrue(request.getTransaction().hasBegin());
+    assertEquals(1, request.getStatementsCount());
+    assertEquals(sql, request.getStatements(0).getSql());
+  }
+
   @Ignore("Only used for manual performance testing")
   @Test
   public void testBasePerformance() throws SQLException {
diff --git a/src/test/java/com/google/cloud/spanner/pgadapter/nodejs/TypeORMMockServerTest.java b/src/test/java/com/google/cloud/spanner/pgadapter/nodejs/TypeORMMockServerTest.java
index 5062ba03..9d3638ee 100644
--- a/src/test/java/com/google/cloud/spanner/pgadapter/nodejs/TypeORMMockServerTest.java
+++ b/src/test/java/com/google/cloud/spanner/pgadapter/nodejs/TypeORMMockServerTest.java
@@ -132,15 +132,9 @@ public class TypeORMMockServerTest extends AbstractMockServerTest {
     ExecuteSqlRequest describeRequest = executeSqlRequests.get(0);
     ExecuteSqlRequest executeRequest = executeSqlRequests.get(1);
     assertEquals(0, mockSpanner.countRequestsOfType(CommitRequest.class));
-    assertEquals(1, mockSpanner.countRequestsOfType(BeginTransactionRequest.class));
-    assertTrue(
-        mockSpanner
-            .getRequestsOfType(BeginTransactionRequest.class)
-            .get(0)
-            .getOptions()
-            .hasReadOnly());
-    assertTrue(describeRequest.getTransaction().hasId());
-    assertTrue(executeRequest.getTransaction().hasId());
+    assertEquals(0, mockSpanner.countRequestsOfType(BeginTransactionRequest.class));
+    assertTrue(describeRequest.getTransaction().hasSingleUse());
+    assertTrue(executeRequest.getTransaction().hasSingleUse());
   }
 
   @Test
diff --git a/src/test/java/com/google/cloud/spanner/pgadapter/parsers/TimestampParserTest.java b/src/test/java/com/google/cloud/spanner/pgadapter/parsers/TimestampParserTest.java
index 229afcbd..be462464 100644
--- a/src/test/java/com/google/cloud/spanner/pgadapter/parsers/TimestampParserTest.java
+++ b/src/test/java/com/google/cloud/spanner/pgadapter/parsers/TimestampParserTest.java
@@ -134,5 +134,49 @@ public class TimestampParserTest {
     assertEquals(
         Timestamp.parseTimestamp("2022-12-28T07:30Z"),
         TimestampParser.toTimestamp("2022-12-28 10:00+02:30", ZoneId.of("CET")));
+    assertEquals(
+        Timestamp.parseTimestamp("2011-11-04T00:05:23.123456Z"),
+        TimestampParser.toTimestamp("(\"2011-11-04 00:05:23.123456+00:00\")", ZoneId.of("UTC")));
+    assertEquals(
+        Timestamp.parseTimestamp("2011-11-04T00:05:23.123456Z"),
+        TimestampParser.toTimestamp("('2011-11-04 00:05:23.123456+00:00')", ZoneId.of("UTC")));
+    assertEquals(
+        Timestamp.parseTimestamp("2011-11-04T00:05:23.123456Z"),
+        TimestampParser.toTimestamp("'2011-11-04 00:05:23.123456+00:00'", ZoneId.of("UTC")));
+    assertThrows(PGException.class, () -> TimestampParser.toTimestamp("", ZoneId.of("UTC")));
+    assertThrows(PGException.class, () -> TimestampParser.toTimestamp("(", ZoneId.of("UTC")));
+    assertThrows(PGException.class, () -> TimestampParser.toTimestamp(")", ZoneId.of("UTC")));
+    assertThrows(
+        PGException.class,
+        () -> TimestampParser.toTimestamp("'2011-11-04 00:05:23.123456+00:00')", ZoneId.of("UTC")));
+    assertThrows(
+        PGException.class,
+        () -> TimestampParser.toTimestamp("('2011-11-04 00:05:23.123456+00:00'", ZoneId.of("UTC")));
+    assertThrows(PGException.class, () -> TimestampParser.toTimestamp("()", ZoneId.of("UTC")));
+    assertThrows(PGException.class, () -> TimestampParser.toTimestamp("''", ZoneId.of("UTC")));
+    assertThrows(PGException.class, () -> TimestampParser.toTimestamp("'2000'", ZoneId.of("UTC")));
+    assertEquals(
+        Timestamp.parseTimestamp("2000-01-01T00:00:00Z"),
+        TimestampParser.toTimestamp("'2000-01-01'", ZoneId.of("UTC")));
+    assertEquals(
+        Timestamp.parseTimestamp("2011-11-04T00:05:23.123456Z"),
+        TimestampParser.toTimestamp(" (\"2011-11-04 00:05:23.123456+00:00\")", ZoneId.of("UTC")));
+    assertEquals(
+        Timestamp.parseTimestamp("2011-11-04T00:05:23.123456Z"),
+        TimestampParser.toTimestamp("(\"2011-11-04 00:05:23.123456+00:00\") ", ZoneId.of("UTC")));
+    assertEquals(
+        Timestamp.parseTimestamp("2011-11-04T00:05:23.123456Z"),
+        TimestampParser.toTimestamp("( \"2011-11-04 00:05:23.123456+00:00\" )", ZoneId.of("UTC")));
+    assertEquals(
+        Timestamp.parseTimestamp("2011-11-04T00:05:23.123456Z"),
+        TimestampParser.toTimestamp("(\" 2011-11-04 00:05:23.123456+00:00\")", ZoneId.of("UTC")));
+    assertEquals(
+        Timestamp.parseTimestamp("2011-11-04T00:05:23.123456Z"),
+        TimestampParser.toTimestamp(
+            "\n(  \"2011-11-04 00:05:23.123456+00:00  \" )", ZoneId.of("UTC")));
+    assertEquals(
+        Timestamp.parseTimestamp("2011-11-04T00:05:23.123456Z"),
+        TimestampParser.toTimestamp(
+            "\t\n( \"  2011-11-04 00:05:23.123456+00:00  \n\t\" )", ZoneId.of("UTC")));
   }
 }
diff --git a/src/test/java/com/google/cloud/spanner/pgadapter/statements/BackendConnectionTest.java b/src/test/java/com/google/cloud/spanner/pgadapter/statements/BackendConnectionTest.java
index d460a321..23757835 100644
--- a/src/test/java/com/google/cloud/spanner/pgadapter/statements/BackendConnectionTest.java
+++ b/src/test/java/com/google/cloud/spanner/pgadapter/statements/BackendConnectionTest.java
@@ -26,12 +26,14 @@ import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.doThrow;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
 import com.google.cloud.spanner.DatabaseId;
 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.SpannerBatchUpdateException;
 import com.google.cloud.spanner.SpannerException;
@@ -43,10 +45,13 @@ import com.google.cloud.spanner.connection.AbstractStatementParser.StatementType
 import com.google.cloud.spanner.connection.Connection;
 import com.google.cloud.spanner.connection.StatementResult;
 import com.google.cloud.spanner.connection.StatementResult.ResultType;
+import com.google.cloud.spanner.connection.TransactionMode;
 import com.google.cloud.spanner.pgadapter.AbstractMockServerTest;
+import com.google.cloud.spanner.pgadapter.ConnectionHandler;
 import com.google.cloud.spanner.pgadapter.error.PGException;
 import com.google.cloud.spanner.pgadapter.error.PGExceptionFactory;
 import com.google.cloud.spanner.pgadapter.error.SQLState;
+import com.google.cloud.spanner.pgadapter.metadata.ConnectionMetadata;
 import com.google.cloud.spanner.pgadapter.metadata.OptionsMetadata;
 import com.google.cloud.spanner.pgadapter.statements.BackendConnection.NoResult;
 import com.google.cloud.spanner.pgadapter.statements.BackendConnection.QueryResult;
@@ -214,8 +219,8 @@ public class BackendConnectionTest {
             ImmutableList::of);
     onlyDmlStatements.execute(parsedUpdateStatement, updateStatement, Function.identity());
     onlyDmlStatements.execute(parsedUpdateStatement, updateStatement, Function.identity());
-    assertTrue(onlyDmlStatements.hasDmlOrCopyStatementsAfter(0));
-    assertTrue(onlyDmlStatements.hasDmlOrCopyStatementsAfter(1));
+    assertTrue(onlyDmlStatements.hasUpdateStatementsAfter(0));
+    assertTrue(onlyDmlStatements.hasUpdateStatementsAfter(1));
 
     BackendConnection onlyCopyStatements =
         new BackendConnection(
@@ -226,8 +231,8 @@ public class BackendConnectionTest {
             ImmutableList::of);
     onlyCopyStatements.executeCopy(parsedCopyStatement, copyStatement, receiver, writer, executor);
     onlyCopyStatements.executeCopy(parsedCopyStatement, copyStatement, receiver, writer, executor);
-    assertTrue(onlyCopyStatements.hasDmlOrCopyStatementsAfter(0));
-    assertTrue(onlyCopyStatements.hasDmlOrCopyStatementsAfter(1));
+    assertTrue(onlyCopyStatements.hasUpdateStatementsAfter(0));
+    assertTrue(onlyCopyStatements.hasUpdateStatementsAfter(1));
 
     BackendConnection dmlAndCopyStatements =
         new BackendConnection(
@@ -239,8 +244,8 @@ public class BackendConnectionTest {
     dmlAndCopyStatements.execute(parsedUpdateStatement, updateStatement, Function.identity());
     dmlAndCopyStatements.executeCopy(
         parsedCopyStatement, copyStatement, receiver, writer, executor);
-    assertTrue(dmlAndCopyStatements.hasDmlOrCopyStatementsAfter(0));
-    assertTrue(dmlAndCopyStatements.hasDmlOrCopyStatementsAfter(1));
+    assertTrue(dmlAndCopyStatements.hasUpdateStatementsAfter(0));
+    assertTrue(dmlAndCopyStatements.hasUpdateStatementsAfter(1));
 
     BackendConnection onlySelectStatements =
         new BackendConnection(
@@ -251,8 +256,8 @@ public class BackendConnectionTest {
             ImmutableList::of);
     onlySelectStatements.execute(parsedSelectStatement, selectStatement, Function.identity());
     onlySelectStatements.execute(parsedSelectStatement, selectStatement, Function.identity());
-    assertFalse(onlySelectStatements.hasDmlOrCopyStatementsAfter(0));
-    assertFalse(onlySelectStatements.hasDmlOrCopyStatementsAfter(1));
+    assertFalse(onlySelectStatements.hasUpdateStatementsAfter(0));
+    assertFalse(onlySelectStatements.hasUpdateStatementsAfter(1));
 
     BackendConnection onlyClientSideStatements =
         new BackendConnection(
@@ -265,8 +270,8 @@ public class BackendConnectionTest {
         parsedClientSideStatement, clientSideStatement, Function.identity());
     onlyClientSideStatements.execute(
         parsedClientSideStatement, clientSideStatement, Function.identity());
-    assertFalse(onlyClientSideStatements.hasDmlOrCopyStatementsAfter(0));
-    assertFalse(onlyClientSideStatements.hasDmlOrCopyStatementsAfter(1));
+    assertFalse(onlyClientSideStatements.hasUpdateStatementsAfter(0));
+    assertFalse(onlyClientSideStatements.hasUpdateStatementsAfter(1));
 
     BackendConnection onlyUnknownStatements =
         new BackendConnection(
@@ -277,8 +282,8 @@ public class BackendConnectionTest {
             ImmutableList::of);
     onlyUnknownStatements.execute(parsedUnknownStatement, unknownStatement, Function.identity());
     onlyUnknownStatements.execute(parsedUnknownStatement, unknownStatement, Function.identity());
-    assertFalse(onlyUnknownStatements.hasDmlOrCopyStatementsAfter(0));
-    assertFalse(onlyUnknownStatements.hasDmlOrCopyStatementsAfter(1));
+    assertFalse(onlyUnknownStatements.hasUpdateStatementsAfter(0));
+    assertFalse(onlyUnknownStatements.hasUpdateStatementsAfter(1));
 
     BackendConnection dmlAndSelectStatements =
         new BackendConnection(
@@ -289,8 +294,8 @@ public class BackendConnectionTest {
             ImmutableList::of);
     dmlAndSelectStatements.execute(parsedUpdateStatement, updateStatement, Function.identity());
     dmlAndSelectStatements.execute(parsedSelectStatement, selectStatement, Function.identity());
-    assertTrue(dmlAndSelectStatements.hasDmlOrCopyStatementsAfter(0));
-    assertFalse(dmlAndSelectStatements.hasDmlOrCopyStatementsAfter(1));
+    assertTrue(dmlAndSelectStatements.hasUpdateStatementsAfter(0));
+    assertFalse(dmlAndSelectStatements.hasUpdateStatementsAfter(1));
 
     BackendConnection copyAndSelectStatements =
         new BackendConnection(
@@ -302,8 +307,8 @@ public class BackendConnectionTest {
     copyAndSelectStatements.executeCopy(
         parsedCopyStatement, copyStatement, receiver, writer, executor);
     copyAndSelectStatements.execute(parsedSelectStatement, selectStatement, Function.identity());
-    assertTrue(copyAndSelectStatements.hasDmlOrCopyStatementsAfter(0));
-    assertFalse(copyAndSelectStatements.hasDmlOrCopyStatementsAfter(1));
+    assertTrue(copyAndSelectStatements.hasUpdateStatementsAfter(0));
+    assertFalse(copyAndSelectStatements.hasUpdateStatementsAfter(1));
 
     BackendConnection copyAndUnknownStatements =
         new BackendConnection(
@@ -315,8 +320,8 @@ public class BackendConnectionTest {
     copyAndUnknownStatements.executeCopy(
         parsedCopyStatement, copyStatement, receiver, writer, executor);
     copyAndUnknownStatements.execute(parsedUnknownStatement, unknownStatement, Function.identity());
-    assertTrue(copyAndUnknownStatements.hasDmlOrCopyStatementsAfter(0));
-    assertFalse(copyAndUnknownStatements.hasDmlOrCopyStatementsAfter(1));
+    assertTrue(copyAndUnknownStatements.hasUpdateStatementsAfter(0));
+    assertFalse(copyAndUnknownStatements.hasUpdateStatementsAfter(1));
   }
 
   @Test
@@ -538,9 +543,37 @@ public class BackendConnectionTest {
 
   @Test
   public void testDoNotStartTransactionInBatch() {
+    for (boolean ddl : new boolean[] {true, false}) {
+      Connection connection = mock(Connection.class);
+      if (ddl) {
+        when(connection.isDdlBatchActive()).thenReturn(true);
+      } else {
+        when(connection.isDmlBatchActive()).thenReturn(true);
+      }
+      Statement statement = Statement.of("insert into foo values (1)");
+      ParsedStatement parsedStatement =
+          AbstractStatementParser.getInstance(Dialect.POSTGRESQL).parse(statement);
+
+      BackendConnection backendConnection =
+          new BackendConnection(
+              DatabaseId.of("p", "i", "d"),
+              connection,
+              () -> WellKnownClient.UNSPECIFIED,
+              mock(OptionsMetadata.class),
+              () -> EMPTY_LOCAL_STATEMENTS);
+
+      backendConnection.execute(parsedStatement, statement, Function.identity());
+      backendConnection.flush();
+
+      verify(connection).execute(statement);
+      verify(connection, never()).beginTransaction();
+    }
+  }
+
+  @Test
+  public void testDescribeAndExecuteSameStatement_doesNotStartTransaction() {
     Connection connection = mock(Connection.class);
-    when(connection.isDmlBatchActive()).thenReturn(true);
-    Statement statement = Statement.of("insert into foo values (1)");
+    Statement statement = Statement.of("select * from foo where id=$1");
     ParsedStatement parsedStatement =
         AbstractStatementParser.getInstance(Dialect.POSTGRESQL).parse(statement);
 
@@ -552,10 +585,165 @@ public class BackendConnectionTest {
             mock(OptionsMetadata.class),
             () -> EMPTY_LOCAL_STATEMENTS);
 
+    // Describing and executing the same statement in one pipelined operation should not start an
+    // implicit transaction.
+    backendConnection.analyze(parsedStatement, statement);
     backendConnection.execute(parsedStatement, statement, Function.identity());
-    backendConnection.flush();
+    backendConnection.sync();
 
+    verify(connection).analyzeQuery(statement, QueryAnalyzeMode.PLAN);
     verify(connection).execute(statement);
     verify(connection, never()).beginTransaction();
   }
+
+  @Test
+  public void testDescribeAndExecuteDifferentStatements_startsTransaction() {
+    Connection connection = mock(Connection.class);
+    Statement statement1 = Statement.of("select * from foo where id=$1");
+    ParsedStatement parsedStatement1 =
+        AbstractStatementParser.getInstance(Dialect.POSTGRESQL).parse(statement1);
+    Statement statement2 = Statement.of("select * from bar where id=$1");
+    ParsedStatement parsedStatement2 =
+        AbstractStatementParser.getInstance(Dialect.POSTGRESQL).parse(statement1);
+
+    BackendConnection backendConnection =
+        new BackendConnection(
+            DatabaseId.of("p", "i", "d"),
+            connection,
+            () -> WellKnownClient.UNSPECIFIED,
+            mock(OptionsMetadata.class),
+            () -> EMPTY_LOCAL_STATEMENTS);
+
+    // Describing and executing different queries in one pipelined operation should start an
+    // implicit read-only transaction.
+    backendConnection.analyze(parsedStatement1, statement1);
+    backendConnection.execute(parsedStatement2, statement2, Function.identity());
+    backendConnection.sync();
+
+    verify(connection).analyzeQuery(statement1, QueryAnalyzeMode.PLAN);
+    verify(connection).execute(statement2);
+    verify(connection).setTransactionMode(TransactionMode.READ_ONLY_TRANSACTION);
+    verify(connection).beginTransaction();
+  }
+
+  @Test
+  public void testExecuteSameStatementTwice_startsTransaction() {
+    Connection connection = mock(Connection.class);
+    Statement statement = Statement.of("select * from foo where id=$1");
+    ParsedStatement parsedStatement =
+        AbstractStatementParser.getInstance(Dialect.POSTGRESQL).parse(statement);
+
+    BackendConnection backendConnection =
+        new BackendConnection(
+            DatabaseId.of("p", "i", "d"),
+            connection,
+            () -> WellKnownClient.UNSPECIFIED,
+            mock(OptionsMetadata.class),
+            () -> EMPTY_LOCAL_STATEMENTS);
+
+    // Executing the same query twice in one pipelined operation should start an
+    // implicit read-only transaction.
+    backendConnection.execute(parsedStatement, statement, Function.identity());
+    backendConnection.execute(parsedStatement, statement, Function.identity());
+    backendConnection.sync();
+
+    verify(connection, times(2)).execute(statement);
+    verify(connection).setTransactionMode(TransactionMode.READ_ONLY_TRANSACTION);
+    verify(connection).beginTransaction();
+  }
+
+  @Test
+  public void testExecuteAndCopy_startsTransaction() {
+    Connection connection = mock(Connection.class);
+    Statement statement = Statement.of("select * from foo where id=$1");
+    ParsedStatement parsedStatement =
+        AbstractStatementParser.getInstance(Dialect.POSTGRESQL).parse(statement);
+
+    BackendConnection backendConnection =
+        new BackendConnection(
+            DatabaseId.of("p", "i", "d"),
+            connection,
+            () -> WellKnownClient.UNSPECIFIED,
+            mock(OptionsMetadata.class),
+            () -> EMPTY_LOCAL_STATEMENTS);
+
+    backendConnection.execute(parsedStatement, statement, Function.identity());
+    backendConnection.executeCopyOut(parsedStatement, statement);
+    backendConnection.sync();
+
+    verify(connection).execute(statement);
+    verify(connection).setTransactionMode(TransactionMode.READ_ONLY_TRANSACTION);
+    verify(connection).beginTransaction();
+  }
+
+  @Test
+  public void testTruncateAndExecute_startsTransaction() {
+    Connection connection = mock(Connection.class);
+    Statement statement = Statement.of("select * from foo where id=$1");
+    ParsedStatement parsedStatement =
+        AbstractStatementParser.getInstance(Dialect.POSTGRESQL).parse(statement);
+    ConnectionHandler connectionHandler = mock(ConnectionHandler.class);
+    ConnectionMetadata connectionMetadata = mock(ConnectionMetadata.class);
+    when(connectionHandler.getConnectionMetadata()).thenReturn(connectionMetadata);
+    String truncateSql = "truncate foo";
+    TruncateStatement truncateStatement =
+        new TruncateStatement(
+            connectionHandler,
+            mock(OptionsMetadata.class),
+            "",
+            AbstractStatementParser.getInstance(Dialect.POSTGRESQL)
+                .parse(Statement.of(truncateSql)),
+            Statement.of(truncateSql));
+
+    BackendConnection backendConnection =
+        new BackendConnection(
+            DatabaseId.of("p", "i", "d"),
+            connection,
+            () -> WellKnownClient.UNSPECIFIED,
+            mock(OptionsMetadata.class),
+            () -> EMPTY_LOCAL_STATEMENTS);
+
+    backendConnection.execute(truncateStatement);
+    backendConnection.execute(parsedStatement, statement, Function.identity());
+    backendConnection.sync();
+
+    verify(connection).execute(statement);
+    verify(connection, never()).setTransactionMode(TransactionMode.READ_ONLY_TRANSACTION);
+    verify(connection).beginTransaction();
+  }
+
+  @Test
+  public void testExecuteAndVacuum_startsTransaction() {
+    Connection connection = mock(Connection.class);
+    Statement statement = Statement.of("select * from foo where id=$1");
+    ParsedStatement parsedStatement =
+        AbstractStatementParser.getInstance(Dialect.POSTGRESQL).parse(statement);
+    ConnectionHandler connectionHandler = mock(ConnectionHandler.class);
+    ConnectionMetadata connectionMetadata = mock(ConnectionMetadata.class);
+    when(connectionHandler.getConnectionMetadata()).thenReturn(connectionMetadata);
+    String vacuumSql = "vacuum foo";
+    VacuumStatement vacuumStatement =
+        new VacuumStatement(
+            connectionHandler,
+            mock(OptionsMetadata.class),
+            "",
+            AbstractStatementParser.getInstance(Dialect.POSTGRESQL).parse(Statement.of(vacuumSql)),
+            Statement.of(vacuumSql));
+
+    BackendConnection backendConnection =
+        new BackendConnection(
+            DatabaseId.of("p", "i", "d"),
+            connection,
+            () -> WellKnownClient.UNSPECIFIED,
+            mock(OptionsMetadata.class),
+            () -> EMPTY_LOCAL_STATEMENTS);
+
+    backendConnection.execute(parsedStatement, statement, Function.identity());
+    backendConnection.execute(vacuumStatement);
+    backendConnection.sync();
+
+    verify(connection).execute(statement);
+    verify(connection).setTransactionMode(TransactionMode.READ_ONLY_TRANSACTION);
+    verify(connection).beginTransaction();
+  }
 }