Repository: GoogleCloudPlatform/pgadapter
Pull request #801 (2023-05-04 13:05:19)

docs: document psycopg3 and SQLAlchemy 2.x support


Patch:
diff --git a/README.md b/README.md
index b7b6477d..f20a16e3 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@ PGAdapter can be used with the following drivers and clients:
 2. `JDBC`: Versions 42.x and higher are supported. See [JDBC support](docs/jdbc.md) for more details.
 3. `pgx`: Version 4.15 and higher are supported. See [pgx support](docs/pgx.md) for more details.
 4. `psycopg2`: Version 2.9.3 and higher are supported. See [psycopg2](docs/psycopg2.md) for more details.
-5. `psycopg3`: Version 3.1.x and higher have experimental support. See [psycopg3 support](docs/psycopg3.md) for more details.
+5. `psycopg3`: Version 3.1.x and higher are supported. See [psycopg3 support](docs/psycopg3.md) for more details.
 6. `node-postgres`: Version 8.8.0 and higher are supported. See [node-postgres support](docs/node-postgres.md) for more details.
 7. `npgsql`: Version 6.0.x and higher have experimental support. See [npgsql support](docs/npgsql.md) for more details.
 8. `hibernate`: Version 5.3.20. Final and higher are supported. See [hibernate support](samples/java/hibernate/README.md) for more details.
@@ -20,7 +20,7 @@ PGAdapter can be used with the following frameworks and tools:
    for more details. See also [this directory](samples/java/liquibase) for a sample application using `Liquibase`.
 1. `gorm`: Version 1.23.8 and higher are supported. See [gorm support](docs/gorm.md) for more details.
    See also [this directory](samples/golang/gorm) for a sample application using `gorm`.
-1. `SQLAlchemy 2.x`: Version 2.0.1 and higher has _experimental support_.
+1. `SQLAlchemy 2.x`: Version 2.0.1 and higher are supported.
    See also [this directory](samples/python/sqlalchemy2-sample) for a sample application using `SQLAlchemy 2.x`.
 1. `SQLAlchemy 1.x`: Version 1.4.45 and higher has _experimental support_. It is recommended to use `SQLAlchemy 2.x`
    instead of `SQLAlchemy 1.4.x` for the [best possible performance](docs/sqlalchemy.md#limitations). 
diff --git a/docs/psycopg3.md b/docs/psycopg3.md
index b7b120cc..90c52b9b 100644
--- a/docs/psycopg3.md
+++ b/docs/psycopg3.md
@@ -1,6 +1,6 @@
 # PGAdapter - psycopg3 Connection Options
 
-PGAdapter has experimental support for the [Python psycopg3 driver](https://www.psycopg.org/psycopg3/docs/index.html).
+PGAdapter supports the [Python psycopg3 driver](https://www.psycopg.org/psycopg3/docs/index.html).
 
 ## Sample Application
 
diff --git a/docs/sqlalchemy2.md b/docs/sqlalchemy2.md
index e6b6c74c..b2a82e3a 100644
--- a/docs/sqlalchemy2.md
+++ b/docs/sqlalchemy2.md
@@ -1,12 +1,15 @@
 # PGAdapter - SQLAlchemy 2.x Connection Options
 
+PGAdapter supports [SQLAlchemy 2.0.1](https://docs.sqlalchemy.org/en/20/) and higher.
+
 ## Limitations
-PGAdapter has experimental support for SQLAlchemy 2.x with Cloud Spanner PostgreSQL databases. It 
+PGAdapter supports SQLAlchemy 2.x with Cloud Spanner PostgreSQL databases. It 
 has been tested with SQLAlchemy 2.0.1 and psycopg 3.1.8. Developing new applications using
 SQLAlchemy 2.x is possible as long as the listed limitations are taken into account.
 Porting an existing application from PostgreSQL to Cloud Spanner is likely to require code changes.
 
-PGAdapter does not support SQLAlchemy 2.x with any other engines than psycopg 3.x.
+PGAdapter does not support SQLAlchemy 2.x with any other engines than [psycopg 3.x](https://www.psycopg.org/psycopg3/).
+Using psycopg2 with SQLAlchemy 2.x is not supported.
 
 See [Limitations](../samples/python/sqlalchemy2-sample/README.md#limitations) in the `sqlalchemy2-sample`
 directory for a full list of limitations.
diff --git a/pom.xml b/pom.xml
index a337718d..39317efa 100644
--- a/pom.xml
+++ b/pom.xml
@@ -80,7 +80,7 @@
     <dependency>
       <groupId>org.checkerframework</groupId>
       <artifactId>checker-qual</artifactId>
-      <version>3.33.0</version>
+      <version>3.34.0</version>
     </dependency>
     <dependency>
       <groupId>com.google.cloud</groupId>
diff --git a/samples/python/sqlalchemy2-sample/README.md b/samples/python/sqlalchemy2-sample/README.md
index 45528ea4..faba0a3e 100644
--- a/samples/python/sqlalchemy2-sample/README.md
+++ b/samples/python/sqlalchemy2-sample/README.md
@@ -1,6 +1,6 @@
 # PGAdapter and SQLAlchemy 2.x
 
-PGAdapter has experimental support for [SQLAlchemy 2.0](https://docs.sqlalchemy.org/en/20/)
+PGAdapter supports [SQLAlchemy 2.0](https://docs.sqlalchemy.org/en/20/)
 with the `psycopg` driver. This document shows how to use this sample application, and lists the
 limitations when working with `SQLAlchemy 2.x` with PGAdapter.
 
diff --git a/src/main/java/com/google/cloud/spanner/pgadapter/session/SessionState.java b/src/main/java/com/google/cloud/spanner/pgadapter/session/SessionState.java
index d443e974..4d8c1919 100644
--- a/src/main/java/com/google/cloud/spanner/pgadapter/session/SessionState.java
+++ b/src/main/java/com/google/cloud/spanner/pgadapter/session/SessionState.java
@@ -392,6 +392,19 @@ public class SessionState {
     return getBoolSetting("spanner", "force_autocommit", false);
   }
 
+  /**
+   * Returns whether statements with an OFFSET clause that uses a parameter should be automatically
+   * appended with a LIMIT clause. The LIMIT clause will use the literal Long.MAX_VALUE for unbound
+   * statements, and Long.MAX_VALUE - offset for bound statements.
+   *
+   * <p>This method will be removed in the future.
+   */
+  // TODO: Remove when Cloud Spanner supports parametrized OFFSET clauses without a LIMIT clause.
+  @InternalApi
+  public boolean isAutoAddLimitClause() {
+    return getBoolSetting("spanner", "auto_add_limit_clause", false);
+  }
+
   /** Returns the current setting for replacing pg_catalog tables with common table expressions. */
   public boolean isReplacePgCatalogTables() {
     PGSetting setting = internalGet(toKey("spanner", "replace_pg_catalog_tables"), false);
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 1cef6a1a..cb153959 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
@@ -295,7 +295,13 @@ public class BackendConnection {
     }
 
     Statement bindStatement(Statement statement) {
-      return statementBinder.apply(statement);
+      Statement boundStatement = statementBinder.apply(statement);
+      // Add a LIMIT clause to the statement if it contains an OFFSET clause that uses a query
+      // parameter and there is no existing LIMIT clause in the query.
+      if (sessionState.isAutoAddLimitClause()) {
+        return SimpleParser.addLimitIfParameterizedOffset(boundStatement);
+      }
+      return boundStatement;
     }
 
     StatementResult analyzeOrExecute(Statement statement) {
diff --git a/src/main/java/com/google/cloud/spanner/pgadapter/statements/SimpleParser.java b/src/main/java/com/google/cloud/spanner/pgadapter/statements/SimpleParser.java
index 1171e968..f86d4e59 100644
--- a/src/main/java/com/google/cloud/spanner/pgadapter/statements/SimpleParser.java
+++ b/src/main/java/com/google/cloud/spanner/pgadapter/statements/SimpleParser.java
@@ -24,6 +24,7 @@ import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.Locale;
 import java.util.Map;
 import java.util.Map.Entry;
 import java.util.Objects;
@@ -225,6 +226,52 @@ public class SimpleParser {
     return builder.build();
   }
 
+  /**
+   * Adds a LIMIT clause to the given statement if the statement contains a parameterized OFFSET
+   * clause and no LIMIT clause.
+   */
+  static Statement addLimitIfParameterizedOffset(Statement statement) {
+    String sqlLowerCase = statement.getSql().toLowerCase(Locale.ENGLISH);
+    // If there is no offset clause, then we know that we don't have to analyze any further.
+    if (!sqlLowerCase.contains("offset")) {
+      return statement;
+    }
+    SimpleParser parser = new SimpleParser(statement.getSql());
+    parser.parseExpressionUntilKeyword(ImmutableList.of("limit", "offset"), true, false, false);
+    if (parser.pos >= parser.getSql().length()) {
+      return statement;
+    }
+    if (parser.eatKeyword("limit")) {
+      // If the statement contains a LIMIT clause, then we're OK.
+      return statement;
+    }
+    String parameter;
+    if (parser.eatKeyword("offset") && (parameter = parser.readQueryParameter()) != null) {
+      // Check if we have a LIMIT clause after the OFFSET clause.
+      if (parser.peekKeyword("limit")) {
+        return statement;
+      }
+      // This is probably an invalid query. Do not modify it to prevent the user getting an error
+      // message for a query that they did not write.
+      if (parser.hasMoreTokens()) {
+        return statement;
+      }
+      // The statement contains an OFFSET clause using a query parameter and no LIMIT clause.
+      // Append a LIMIT clause equal to Long.MAX_VALUE / 2.
+      // We could also calculate it based on the actual OFFSET value, but that would make the
+      // query more prone to cache misses. Also, adding the LIMIT clause with a query parameter
+      // would structurally change the query, something that we also don't want.
+      long limit = Long.MAX_VALUE / 2;
+      return copyStatement(
+          statement,
+          parser.sql.substring(0, parser.pos)
+              + " limit "
+              + limit
+              + parser.sql.substring(parser.pos));
+    }
+    return statement;
+  }
+
   private String sql;
   private int pos;
 
@@ -661,6 +708,22 @@ public class SimpleParser {
     return false;
   }
 
+  String readQueryParameter() {
+    if (eat(true, false, "$")) {
+      int startPos = pos - 1;
+      if (pos == sql.length() || !Character.isDigit(sql.charAt(pos))) {
+        return null;
+      }
+      while (pos < sql.length() && Character.isDigit(sql.charAt(pos))) {
+        pos++;
+      }
+      if (Character.isDigit(sql.charAt(pos - 1))) {
+        return sql.substring(startPos, pos);
+      }
+    }
+    return null;
+  }
+
   boolean peekJoinKeyword() {
     return peekKeyword("join")
         || peekKeyword("left")


Test patch:
diff --git a/src/test/java/com/google/cloud/spanner/pgadapter/ITJdbcTest.java b/src/test/java/com/google/cloud/spanner/pgadapter/ITJdbcTest.java
index fe642942..9b640e19 100644
--- a/src/test/java/com/google/cloud/spanner/pgadapter/ITJdbcTest.java
+++ b/src/test/java/com/google/cloud/spanner/pgadapter/ITJdbcTest.java
@@ -195,6 +195,28 @@ public class ITJdbcTest implements IntegrationTest {
     }
   }
 
+  @Test
+  public void testSelectParameterizedOffsetWithoutLimit() throws SQLException {
+    try (Connection connection = DriverManager.getConnection(getConnectionUrl())) {
+      connection.createStatement().execute("set spanner.auto_add_limit_clause=true");
+      try (PreparedStatement statement =
+          connection.prepareStatement("select * from numbers offset ?")) {
+        for (long offset : new long[] {0L, 1L}) {
+          statement.setLong(1, offset);
+          try (ResultSet resultSet = statement.executeQuery()) {
+            if (offset == 0L) {
+              assertTrue(resultSet.next());
+              assertEquals(1L, resultSet.getLong(1));
+              assertFalse(resultSet.next());
+            } else {
+              assertFalse(resultSet.next());
+            }
+          }
+        }
+      }
+    }
+  }
+
   @Test
   public void testSelectCurrentSchema() throws SQLException {
     try (Connection connection = DriverManager.getConnection(getConnectionUrl())) {
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 c0a3c08e..32f94612 100644
--- a/src/test/java/com/google/cloud/spanner/pgadapter/JdbcMockServerTest.java
+++ b/src/test/java/com/google/cloud/spanner/pgadapter/JdbcMockServerTest.java
@@ -1997,6 +1997,43 @@ public class JdbcMockServerTest extends AbstractMockServerTest {
     }
   }
 
+  @Test
+  public void testParameterizedOffsetWithoutLimit() throws SQLException {
+    // Add a result for the non-limited query that contains one row.
+    mockSpanner.putStatementResult(
+        StatementResult.query(
+            Statement.newBuilder("select * from foo offset $1").bind("p1").to(0L).build(),
+            SELECT1_RESULTSET));
+    // Add a result for the limited query that is empty.
+    mockSpanner.putStatementResult(
+        StatementResult.query(
+            Statement.newBuilder("select * from foo offset $1 limit 4611686018427387903")
+                .bind("p1")
+                .to(0L)
+                .build(),
+            EMPTY_RESULTSET));
+
+    try (Connection connection = DriverManager.getConnection(createUrl())) {
+      for (boolean addLimit : new boolean[] {true, false}) {
+        connection.createStatement().execute("set spanner.auto_add_limit_clause=" + addLimit);
+        try (PreparedStatement statement =
+            connection.prepareStatement("select * from foo offset ?")) {
+          statement.setLong(1, 0);
+          try (ResultSet resultSet = statement.executeQuery()) {
+            // We should get the empty result set when the auto-limit feature is enabled.
+            if (addLimit) {
+              assertFalse(resultSet.next());
+            } else {
+              assertTrue(resultSet.next());
+              assertEquals(1L, resultSet.getLong(1));
+              assertFalse(resultSet.next());
+            }
+          }
+        }
+      }
+    }
+  }
+
   @Test
   public void testTwoQueries() throws SQLException {
     try (Connection connection = DriverManager.getConnection(createUrl())) {
diff --git a/src/test/java/com/google/cloud/spanner/pgadapter/statements/SimpleParserTest.java b/src/test/java/com/google/cloud/spanner/pgadapter/statements/SimpleParserTest.java
index e602a815..8a76f47a 100644
--- a/src/test/java/com/google/cloud/spanner/pgadapter/statements/SimpleParserTest.java
+++ b/src/test/java/com/google/cloud/spanner/pgadapter/statements/SimpleParserTest.java
@@ -15,14 +15,18 @@
 package com.google.cloud.spanner.pgadapter.statements;
 
 import static com.google.cloud.spanner.pgadapter.statements.SimpleParser.QuotedString.unescapeQuotedStringValue;
+import static com.google.cloud.spanner.pgadapter.statements.SimpleParser.addLimitIfParameterizedOffset;
 import static com.google.cloud.spanner.pgadapter.statements.SimpleParser.parseCommand;
+import static com.google.cloud.spanner.pgadapter.statements.SimpleParser.unquoteOrFoldIdentifier;
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertNotEquals;
 import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertSame;
 import static org.junit.Assert.assertThrows;
 import static org.junit.Assert.assertTrue;
 
+import com.google.cloud.spanner.Statement;
 import com.google.cloud.spanner.pgadapter.error.PGException;
 import com.google.cloud.spanner.pgadapter.statements.SimpleParser.QuotedString;
 import com.google.cloud.spanner.pgadapter.statements.SimpleParser.TableOrIndexName;
@@ -73,6 +77,7 @@ public class SimpleParserTest {
   public void testTableOrIndexName() {
     assertEquals(new TableOrIndexName(null, "foo"), new TableOrIndexName(null, "foo"));
     assertNotEquals(new TableOrIndexName(null, "foo"), new TableOrIndexName(null, "bar"));
+    assertFalse(new TableOrIndexName(null, "bar").equals(new Object()));
   }
 
   @Test
@@ -130,6 +135,16 @@ public class SimpleParserTest {
     assertFalse(new SimpleParser("").eatToken("("));
   }
 
+  @Test
+  public void testUnquoteOrFoldIdentifier() {
+    assertEquals("test", unquoteOrFoldIdentifier("test"));
+    assertEquals("test", unquoteOrFoldIdentifier("TEST"));
+    assertEquals("test", unquoteOrFoldIdentifier("Test"));
+    assertEquals("test", unquoteOrFoldIdentifier("\"test\""));
+    assertEquals("TEST", unquoteOrFoldIdentifier("\"TEST\""));
+    assertEquals("Test", unquoteOrFoldIdentifier("\"Test\""));
+  }
+
   @Test
   public void testReadTableOrIndexNamePart() {
     assertEquals("\"foo\"", new SimpleParser("\"foo\"(id)").readIdentifierPart());
@@ -426,6 +441,14 @@ public class SimpleParserTest {
 
   @Test
   public void testQuotedString() {
+    QuotedString quotedString = new QuotedString(false, '\'', "'test'");
+    assertEquals("test", quotedString.getValue());
+    assertEquals("test", quotedString.getValue());
+    assertThrows(PGException.class, () -> new QuotedString(false, '\'', "'").getValue());
+    assertThrows(PGException.class, () -> new QuotedString(false, '\'', "'foo").getValue());
+    assertThrows(PGException.class, () -> new QuotedString(true, '\'', "'").getValue());
+    assertThrows(PGException.class, () -> new QuotedString(true, '\'', "'foo").getValue());
+
     assertEquals("test", new SimpleParser("'test'").readQuotedString('\'').getValue());
     assertEquals("test", new SimpleParser("e'test'").readQuotedString('\'').getValue());
     PGException exception =
@@ -498,4 +521,195 @@ public class SimpleParserTest {
     assertEquals(
         ImmutableList.of("foo 1", "bar 2"), SimpleParser.readArrayLiteral("{foo 1, bar 2}", true));
   }
+
+  @Test
+  public void testReadQueryParameter() {
+    assertEquals("$1", new SimpleParser("$1").readQueryParameter());
+    assertEquals("$9999999", new SimpleParser("$9999999").readQueryParameter());
+    assertEquals("$1", new SimpleParser("  \n\t /*comment*/$1\n--comment\n").readQueryParameter());
+
+    assertNull(new SimpleParser("foo").readQueryParameter());
+    assertNull(new SimpleParser("   ").readQueryParameter());
+    assertNull(new SimpleParser("$foo").readQueryParameter());
+    assertNull(new SimpleParser("$").readQueryParameter());
+    assertNull(new SimpleParser("$ ").readQueryParameter());
+    assertNull(new SimpleParser("").readQueryParameter());
+  }
+
+  @Test
+  public void testAddLimitIfParameterizedOffset() {
+    Statement statement;
+
+    statement = Statement.of("select * from foo");
+    assertSame(statement, addLimitIfParameterizedOffset(statement));
+    statement = Statement.newBuilder("select * from foo where id=$1").bind("p1").to(1L).build();
+    assertSame(statement, addLimitIfParameterizedOffset(statement));
+    statement = Statement.of("select * from foo limit 1");
+    assertSame(statement, addLimitIfParameterizedOffset(statement));
+    statement = Statement.of("select * from foo limit 1 offset 1");
+    assertSame(statement, addLimitIfParameterizedOffset(statement));
+    statement = Statement.of("select * from foo offset 1");
+    assertSame(statement, addLimitIfParameterizedOffset(statement));
+    statement =
+        Statement.newBuilder("select * from (select id from bar limit 1) where id=$1")
+            .bind("p1")
+            .to(1L)
+            .build();
+    assertSame(statement, addLimitIfParameterizedOffset(statement));
+    statement =
+        Statement.newBuilder("select * from (select id from bar offset 1) where id=$1")
+            .bind("p1")
+            .to(1L)
+            .build();
+    assertSame(statement, addLimitIfParameterizedOffset(statement));
+    statement =
+        Statement.newBuilder("select * from (select id from bar limit 1 offset 1) where id=$1")
+            .bind("p1")
+            .to(1L)
+            .build();
+    assertSame(statement, addLimitIfParameterizedOffset(statement));
+    statement =
+        Statement.newBuilder("select * from (select id from bar limit $1 offset $2) where id=$3")
+            .bind("p1")
+            .to(1L)
+            .bind("p2")
+            .to(1L)
+            .bind("p3")
+            .to(1L)
+            .build();
+    assertSame(statement, addLimitIfParameterizedOffset(statement));
+    // The replacement is only done in the outer main query and not in any sub queries.
+    statement =
+        Statement.newBuilder("select * from (select id from bar offset $1) where id=$2")
+            .bind("p1")
+            .to(1L)
+            .bind("p2")
+            .to(1L)
+            .build();
+    assertSame(statement, addLimitIfParameterizedOffset(statement));
+    statement =
+        Statement.newBuilder("select * from foo where id=$1 limit $2 offset $3")
+            .bind("p1")
+            .to(1L)
+            .bind("p2")
+            .to(1L)
+            .bind("p3")
+            .to(1L)
+            .build();
+    assertSame(statement, addLimitIfParameterizedOffset(statement));
+    statement =
+        Statement.newBuilder("select * from foo where id=$1 offset $2 limit $3")
+            .bind("p1")
+            .to(1L)
+            .bind("p2")
+            .to(1L)
+            .bind("p3")
+            .to(1L)
+            .build();
+    assertSame(statement, addLimitIfParameterizedOffset(statement));
+    statement =
+        Statement.newBuilder("select * from foo where id=$1 limit $2")
+            .bind("p1")
+            .to(1L)
+            .bind("p2")
+            .to(1L)
+            .build();
+    assertSame(statement, addLimitIfParameterizedOffset(statement));
+    statement =
+        Statement.newBuilder("select * from foo where id=$1 offset $2")
+            .bind("p1")
+            .to(1L)
+            .bind("p2")
+            .to((Long) null)
+            .build();
+    assertEquals(
+        Statement.newBuilder("select * from foo where id=$1 offset $2 limit 4611686018427387903")
+            .bind("p1")
+            .to(1L)
+            .bind("p2")
+            .to((Long) null)
+            .build(),
+        addLimitIfParameterizedOffset(statement));
+
+    statement =
+        Statement.newBuilder("select * from foo where id=$1 offset $2")
+            .bind("p1")
+            .to(1L)
+            .bind("p2")
+            .to(1L)
+            .build();
+    assertEquals(
+        Statement.newBuilder("select * from foo where id=$1 offset $2 limit 4611686018427387903")
+            .bind("p1")
+            .to(1L)
+            .bind("p2")
+            .to(1L)
+            .build(),
+        addLimitIfParameterizedOffset(statement));
+    statement =
+        Statement.newBuilder("select * from foo where id=$1   offset   $2   ")
+            .bind("p1")
+            .to(1L)
+            .bind("p2")
+            .to(1L)
+            .build();
+    assertEquals(
+        Statement.newBuilder(
+                "select * from foo where id=$1   offset   $2    limit 4611686018427387903")
+            .bind("p1")
+            .to(1L)
+            .bind("p2")
+            .to(1L)
+            .build(),
+        addLimitIfParameterizedOffset(statement));
+    statement =
+        Statement.newBuilder("select * from foo where id=$1 offset $2 /* some comment */")
+            .bind("p1")
+            .to(1L)
+            .bind("p2")
+            .to(1L)
+            .build();
+    assertEquals(
+        Statement.newBuilder(
+                "select * from foo where id=$1 offset $2 /* some comment */ limit 4611686018427387903")
+            .bind("p1")
+            .to(1L)
+            .bind("p2")
+            .to(1L)
+            .build(),
+        addLimitIfParameterizedOffset(statement));
+    statement =
+        Statement.newBuilder(
+                "select * from foo where id=$1 offset -- Single line comment\n$2 /* some comment */")
+            .bind("p1")
+            .to(1L)
+            .bind("p2")
+            .to(1L)
+            .build();
+    assertEquals(
+        Statement.newBuilder(
+                "select * from foo where id=$1 offset -- Single line comment\n$2 /* some comment */ limit 4611686018427387903")
+            .bind("p1")
+            .to(1L)
+            .bind("p2")
+            .to(1L)
+            .build(),
+        addLimitIfParameterizedOffset(statement));
+    // This is an invalid query. That will be caught by the backend parser.
+    statement =
+        Statement.newBuilder("select * from foo where id=$1 offset $2 offset $3")
+            .bind("p1")
+            .to(1L)
+            .bind("p2")
+            .to(1L)
+            .bind("p3")
+            .to(1L)
+            .build();
+    assertSame(statement, addLimitIfParameterizedOffset(statement));
+
+    statement = Statement.of("select * from foo where id=$1 offset $2");
+    assertEquals(
+        Statement.of("select * from foo where id=$1 offset $2 limit 4611686018427387903"),
+        addLimitIfParameterizedOffset(statement));
+  }
 }