Repository: googleapis/java-spanner-jdbc (java)

Issue:
feat!: Upgrade to Java 8
Upgrade to Java 8 and JDBC 4.2. Drop Java 7 support.

See https://docs.oracle.com/javase/8/docs/technotes/guides/jdbc/jdbc_42.html for an overview of the changes in JDBC between Java 7 and Java 8.

Pull request:
feat!: upgrade to Java 8 and JDBC 4.2
Upgrades the JDBC driver to Java 8 and implements new methods added in JDBC 4.2. JDBC version 4.2 is linked with Java 8.

Drops support for Java 7.

Fixes #396


Patch:
diff --git a/.github/sync-repo-settings.yaml b/.github/sync-repo-settings.yaml
index 6bddd18..a0c0cd4 100644
--- a/.github/sync-repo-settings.yaml
+++ b/.github/sync-repo-settings.yaml
@@ -34,7 +34,6 @@ branchProtectionRules:
     - "linkage-monitor"
     - "lint"
     - "clirr"
-    - "units (7)"
     - "units (8)"
     - "units (11)"
     - "Kokoro - Test: Integration"
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index def8b3a..29d933b 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -9,7 +9,7 @@ jobs:
     runs-on: ubuntu-latest
     strategy:
       matrix:
-        java: [7, 8, 11]
+        java: [8, 11]
     steps:
     - uses: actions/checkout@v2
     - uses: actions/setup-java@v1
diff --git a/.kokoro/nightly/java7.cfg b/.kokoro/nightly/java7.cfg
deleted file mode 100644
index cb24f44..0000000
--- a/.kokoro/nightly/java7.cfg
+++ /dev/null
@@ -1,7 +0,0 @@
-# Format: //devtools/kokoro/config/proto/build.proto
-
-# Configure the docker image for kokoro-trampoline.
-env_vars: {
-  key: "TRAMPOLINE_IMAGE"
-  value: "gcr.io/cloud-devrel-kokoro-resources/java7"
-}
diff --git a/.kokoro/presubmit/java7.cfg b/.kokoro/presubmit/java7.cfg
deleted file mode 100644
index cb24f44..0000000
--- a/.kokoro/presubmit/java7.cfg
+++ /dev/null
@@ -1,7 +0,0 @@
-# Format: //devtools/kokoro/config/proto/build.proto
-
-# Configure the docker image for kokoro-trampoline.
-env_vars: {
-  key: "TRAMPOLINE_IMAGE"
-  value: "gcr.io/cloud-devrel-kokoro-resources/java7"
-}
diff --git a/pom.xml b/pom.xml
index 3900685..1fcd435 100644
--- a/pom.xml
+++ b/pom.xml
@@ -71,7 +71,7 @@
       <dependency>
         <groupId>com.google.cloud</groupId>
         <artifactId>google-cloud-spanner-bom</artifactId>
-        <version>5.1.0</version>
+        <version>5.2.0</version>
         <type>pom</type>
         <scope>import</scope>
       </dependency>
@@ -197,6 +197,18 @@
   </dependencies>
   <build>
     <plugins>
+      <plugin>
+        <groupId>org.apache.maven.plugins</groupId>
+        <artifactId>maven-compiler-plugin</artifactId>
+        <configuration>
+          <source>1.8</source>
+          <target>1.8</target>
+          <encoding>UTF-8</encoding>
+          <compilerArgument>-Xlint:unchecked</compilerArgument>
+          <compilerArgument>-Xlint:deprecation</compilerArgument>
+          <showDeprecation>true</showDeprecation>
+        </configuration>
+      </plugin>
       <plugin>
         <groupId>org.apache.maven.plugins</groupId>
         <artifactId>maven-surefire-plugin</artifactId>
diff --git a/samples/snippets/pom.xml b/samples/snippets/pom.xml
index 6890bf9..4ae551a 100644
--- a/samples/snippets/pom.xml
+++ b/samples/snippets/pom.xml
@@ -30,7 +30,7 @@
       <dependency>
         <groupId>com.google.cloud</groupId>
         <artifactId>libraries-bom</artifactId>
-        <version>19.2.0</version>
+        <version>19.2.1</version>
         <type>pom</type>
         <scope>import</scope>
       </dependency>
diff --git a/src/main/java/com/google/cloud/spanner/jdbc/AbstractJdbcPreparedStatement.java b/src/main/java/com/google/cloud/spanner/jdbc/AbstractJdbcPreparedStatement.java
index 0940997..0507d5d 100644
--- a/src/main/java/com/google/cloud/spanner/jdbc/AbstractJdbcPreparedStatement.java
+++ b/src/main/java/com/google/cloud/spanner/jdbc/AbstractJdbcPreparedStatement.java
@@ -31,6 +31,7 @@ import java.sql.Ref;
 import java.sql.ResultSet;
 import java.sql.RowId;
 import java.sql.SQLException;
+import java.sql.SQLType;
 import java.sql.SQLXML;
 import java.sql.Time;
 import java.sql.Timestamp;
@@ -321,6 +322,21 @@ abstract class AbstractJdbcPreparedStatement extends JdbcStatement implements Pr
     parameters.setParameter(parameterIndex, value, targetSqlType, scaleOrLength);
   }
 
+  @Override
+  public void setObject(int parameterIndex, Object value, SQLType targetSqlType)
+      throws SQLException {
+    checkClosed();
+    parameters.setParameter(parameterIndex, value, targetSqlType.getVendorTypeNumber());
+  }
+
+  @Override
+  public void setObject(int parameterIndex, Object value, SQLType targetSqlType, int scaleOrLength)
+      throws SQLException {
+    checkClosed();
+    parameters.setParameter(
+        parameterIndex, value, targetSqlType.getVendorTypeNumber(), scaleOrLength);
+  }
+
   @Override
   public void setAsciiStream(int parameterIndex, InputStream value, long length)
       throws SQLException {
diff --git a/src/main/java/com/google/cloud/spanner/jdbc/AbstractJdbcResultSet.java b/src/main/java/com/google/cloud/spanner/jdbc/AbstractJdbcResultSet.java
index 6016bd2..74d5ccd 100644
--- a/src/main/java/com/google/cloud/spanner/jdbc/AbstractJdbcResultSet.java
+++ b/src/main/java/com/google/cloud/spanner/jdbc/AbstractJdbcResultSet.java
@@ -29,6 +29,7 @@ import java.sql.ResultSet;
 import java.sql.RowId;
 import java.sql.SQLException;
 import java.sql.SQLFeatureNotSupportedException;
+import java.sql.SQLType;
 import java.sql.SQLWarning;
 import java.sql.SQLXML;
 import java.sql.Time;
@@ -233,11 +234,22 @@ abstract class AbstractJdbcResultSet extends AbstractJdbcWrapper implements Resu
     throw new SQLFeatureNotSupportedException();
   }
 
+  @Override
+  public void updateObject(int columnIndex, Object x, SQLType type, int scaleOrLength)
+      throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
   @Override
   public void updateObject(int columnIndex, Object x) throws SQLException {
     throw new SQLFeatureNotSupportedException();
   }
 
+  @Override
+  public void updateObject(int columnIndex, Object x, SQLType type) throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
   @Override
   public void updateNull(String columnLabel) throws SQLException {
     throw new SQLFeatureNotSupportedException();
@@ -335,6 +347,17 @@ abstract class AbstractJdbcResultSet extends AbstractJdbcWrapper implements Resu
     throw new SQLFeatureNotSupportedException();
   }
 
+  @Override
+  public void updateObject(String columnLabel, Object x, SQLType type) throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void updateObject(String columnLabel, Object x, SQLType type, int scaleOrLength)
+      throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
   @Override
   public void insertRow() throws SQLException {
     throw new SQLFeatureNotSupportedException();
diff --git a/src/main/java/com/google/cloud/spanner/jdbc/AbstractJdbcStatement.java b/src/main/java/com/google/cloud/spanner/jdbc/AbstractJdbcStatement.java
index 5217396..9863f47 100644
--- a/src/main/java/com/google/cloud/spanner/jdbc/AbstractJdbcStatement.java
+++ b/src/main/java/com/google/cloud/spanner/jdbc/AbstractJdbcStatement.java
@@ -201,20 +201,32 @@ abstract class AbstractJdbcStatement extends AbstractJdbcWrapper implements Stat
    * Executes a SQL statement on the connection of this {@link Statement} as an update (DML)
    * statement.
    *
-   * @param statement The SQL statement to execute.
-   * @return the number of rows that was inserted/updated/deleted.
+   * @param statement The SQL statement to execute
+   * @return the number of rows that was inserted/updated/deleted
    * @throws SQLException if a database error occurs, or if the number of rows affected is larger
-   *     than {@link Integer#MAX_VALUE}.
+   *     than {@link Integer#MAX_VALUE}
    */
   int executeUpdate(com.google.cloud.spanner.Statement statement) throws SQLException {
+    long count = executeLargeUpdate(statement);
+    if (count > Integer.MAX_VALUE) {
+      throw JdbcSqlExceptionFactory.of(
+          "update count too large for executeUpdate: " + count, Code.OUT_OF_RANGE);
+    }
+    return (int) count;
+  }
+
+  /**
+   * Executes a SQL statement on the connection of this {@link Statement} as an update (DML)
+   * statement.
+   *
+   * @param statement The SQL statement to execute
+   * @return the number of rows that was inserted/updated/deleted
+   * @throws SQLException if a database error occurs
+   */
+  long executeLargeUpdate(com.google.cloud.spanner.Statement statement) throws SQLException {
     StatementTimeout originalTimeout = setTemporaryStatementTimeout();
     try {
-      long count = connection.getSpannerConnection().executeUpdate(statement);
-      if (count > Integer.MAX_VALUE) {
-        throw JdbcSqlExceptionFactory.of(
-            "update count too large for executeUpdate: " + count, Code.OUT_OF_RANGE);
-      }
-      return (int) count;
+      return connection.getSpannerConnection().executeUpdate(statement);
     } catch (SpannerException e) {
       throw JdbcSqlExceptionFactory.of(e);
     } finally {
@@ -357,11 +369,22 @@ abstract class AbstractJdbcStatement extends AbstractJdbcWrapper implements Stat
     return 0;
   }
 
+  @Override
+  public long getLargeMaxRows() throws SQLException {
+    checkClosed();
+    return 0L;
+  }
+
   @Override
   public void setMaxRows(int max) throws SQLException {
     checkClosed();
   }
 
+  @Override
+  public void setLargeMaxRows(long max) throws SQLException {
+    checkClosed();
+  }
+
   @Override
   public void setEscapeProcessing(boolean enable) throws SQLException {
     checkClosed();
diff --git a/src/main/java/com/google/cloud/spanner/jdbc/AbstractJdbcWrapper.java b/src/main/java/com/google/cloud/spanner/jdbc/AbstractJdbcWrapper.java
index 6555668..9f97524 100644
--- a/src/main/java/com/google/cloud/spanner/jdbc/AbstractJdbcWrapper.java
+++ b/src/main/java/com/google/cloud/spanner/jdbc/AbstractJdbcWrapper.java
@@ -66,7 +66,8 @@ abstract class AbstractJdbcWrapper implements Wrapper {
     if (sqlType == Types.NUMERIC || sqlType == Types.DECIMAL)
       return Type.numeric().getCode().name();
     if (sqlType == Types.NVARCHAR) return Type.string().getCode().name();
-    if (sqlType == Types.TIMESTAMP) return Type.timestamp().getCode().name();
+    if (sqlType == Types.TIMESTAMP || sqlType == Types.TIMESTAMP_WITH_TIMEZONE)
+      return Type.timestamp().getCode().name();
     if (sqlType == Types.ARRAY) return Code.ARRAY.name();
 
     return OTHER_NAME;
@@ -84,7 +85,8 @@ abstract class AbstractJdbcWrapper implements Wrapper {
         || sqlType == Types.TINYINT) return Long.class.getName();
     if (sqlType == Types.NUMERIC || sqlType == Types.DECIMAL) return BigDecimal.class.getName();
     if (sqlType == Types.NVARCHAR) return String.class.getName();
-    if (sqlType == Types.TIMESTAMP) return Timestamp.class.getName();
+    if (sqlType == Types.TIMESTAMP || sqlType == Types.TIMESTAMP_WITH_TIMEZONE)
+      return Timestamp.class.getName();
     if (sqlType == Types.ARRAY) return Object.class.getName();
 
     return null;
diff --git a/src/main/java/com/google/cloud/spanner/jdbc/JdbcDatabaseMetaData.java b/src/main/java/com/google/cloud/spanner/jdbc/JdbcDatabaseMetaData.java
index a5d79b1..191d991 100644
--- a/src/main/java/com/google/cloud/spanner/jdbc/JdbcDatabaseMetaData.java
+++ b/src/main/java/com/google/cloud/spanner/jdbc/JdbcDatabaseMetaData.java
@@ -1630,4 +1630,15 @@ class JdbcDatabaseMetaData extends AbstractJdbcWrapper implements DatabaseMetaDa
   public boolean generatedKeyAlwaysReturned() throws SQLException {
     return false;
   }
+
+  @Override
+  public long getMaxLogicalLobSize() throws SQLException {
+    // BYTES(MAX)
+    return 10485760L;
+  }
+
+  @Override
+  public boolean supportsRefCursors() throws SQLException {
+    return false;
+  }
 }
diff --git a/src/main/java/com/google/cloud/spanner/jdbc/JdbcDriver.java b/src/main/java/com/google/cloud/spanner/jdbc/JdbcDriver.java
index 3f4a433..dbb2df5 100644
--- a/src/main/java/com/google/cloud/spanner/jdbc/JdbcDriver.java
+++ b/src/main/java/com/google/cloud/spanner/jdbc/JdbcDriver.java
@@ -106,7 +106,8 @@ import java.util.regex.Pattern;
  */
 public class JdbcDriver implements Driver {
   private static final String JDBC_API_CLIENT_LIB_TOKEN = "sp-jdbc";
-  static final int MAJOR_VERSION = 1;
+  // Updated to version 2 when upgraded to Java 8 (JDBC 4.2)
+  static final int MAJOR_VERSION = 2;
   static final int MINOR_VERSION = 0;
   private static final String JDBC_URL_FORMAT =
       "jdbc:" + ConnectionOptions.Builder.SPANNER_URI_FORMAT;
@@ -245,12 +246,12 @@ public class JdbcDriver implements Driver {
 
   @Override
   public int getMajorVersion() {
-    return 1;
+    return MAJOR_VERSION;
   }
 
   @Override
   public int getMinorVersion() {
-    return 0;
+    return MINOR_VERSION;
   }
 
   @Override
diff --git a/src/main/java/com/google/cloud/spanner/jdbc/JdbcParameterStore.java b/src/main/java/com/google/cloud/spanner/jdbc/JdbcParameterStore.java
index 10de0f8..2c529c4 100644
--- a/src/main/java/com/google/cloud/spanner/jdbc/JdbcParameterStore.java
+++ b/src/main/java/com/google/cloud/spanner/jdbc/JdbcParameterStore.java
@@ -204,7 +204,9 @@ class JdbcParameterStore {
       case Types.LONGNVARCHAR:
       case Types.DATE:
       case Types.TIME:
+      case Types.TIME_WITH_TIMEZONE:
       case Types.TIMESTAMP:
+      case Types.TIMESTAMP_WITH_TIMEZONE:
       case Types.BINARY:
       case Types.VARBINARY:
       case Types.LONGVARBINARY:
@@ -249,7 +251,9 @@ class JdbcParameterStore {
             || value instanceof URL;
       case Types.DATE:
       case Types.TIME:
+      case Types.TIME_WITH_TIMEZONE:
       case Types.TIMESTAMP:
+      case Types.TIMESTAMP_WITH_TIMEZONE:
         return value instanceof Date || value instanceof Time || value instanceof Timestamp;
       case Types.BINARY:
       case Types.VARBINARY:
@@ -522,7 +526,9 @@ class JdbcParameterStore {
         }
         throw JdbcSqlExceptionFactory.of(value + " is not a valid date", Code.INVALID_ARGUMENT);
       case Types.TIME:
+      case Types.TIME_WITH_TIMEZONE:
       case Types.TIMESTAMP:
+      case Types.TIMESTAMP_WITH_TIMEZONE:
         if (value instanceof Date) {
           return binder.to(JdbcTypeConverter.toGoogleTimestamp((Date) value));
         } else if (value instanceof Time) {
@@ -715,7 +721,9 @@ class JdbcParameterStore {
         case Types.DATE:
           return binder.toDateArray((Iterable<com.google.cloud.Date>) null);
         case Types.TIME:
+        case Types.TIME_WITH_TIMEZONE:
         case Types.TIMESTAMP:
+        case Types.TIMESTAMP_WITH_TIMEZONE:
           return binder.toTimestampArray((Iterable<com.google.cloud.Timestamp>) null);
         case Types.BINARY:
         case Types.VARBINARY:
@@ -843,8 +851,9 @@ class JdbcParameterStore {
       case Types.SQLXML:
         return binder.to((String) null);
       case Types.TIME:
-        return binder.to((com.google.cloud.Timestamp) null);
+      case Types.TIME_WITH_TIMEZONE:
       case Types.TIMESTAMP:
+      case Types.TIMESTAMP_WITH_TIMEZONE:
         return binder.to((com.google.cloud.Timestamp) null);
       case Types.TINYINT:
         return binder.to((Long) null);
diff --git a/src/main/java/com/google/cloud/spanner/jdbc/JdbcPreparedStatement.java b/src/main/java/com/google/cloud/spanner/jdbc/JdbcPreparedStatement.java
index d8eeb62..b0032e4 100644
--- a/src/main/java/com/google/cloud/spanner/jdbc/JdbcPreparedStatement.java
+++ b/src/main/java/com/google/cloud/spanner/jdbc/JdbcPreparedStatement.java
@@ -74,6 +74,11 @@ class JdbcPreparedStatement extends AbstractJdbcPreparedStatement {
     return executeUpdate(createStatement());
   }
 
+  public long executeLargeUpdate() throws SQLException {
+    checkClosed();
+    return executeLargeUpdate(createStatement());
+  }
+
   @Override
   public boolean execute() throws SQLException {
     checkClosed();
diff --git a/src/main/java/com/google/cloud/spanner/jdbc/JdbcSqlExceptionFactory.java b/src/main/java/com/google/cloud/spanner/jdbc/JdbcSqlExceptionFactory.java
index b7d1130..33d3bb4 100644
--- a/src/main/java/com/google/cloud/spanner/jdbc/JdbcSqlExceptionFactory.java
+++ b/src/main/java/com/google/cloud/spanner/jdbc/JdbcSqlExceptionFactory.java
@@ -127,6 +127,16 @@ public final class JdbcSqlExceptionFactory {
       this.code = Code.forNumber(cause.getCode());
     }
 
+    private JdbcSqlBatchUpdateException(long[] updateCounts, SpannerBatchUpdateException cause) {
+      super(
+          cause.getMessage(),
+          cause.getErrorCode().toString(),
+          cause.getCode(),
+          updateCounts,
+          cause);
+      this.code = Code.forNumber(cause.getCode());
+    }
+
     @Override
     public Code getCode() {
       return code;
@@ -315,4 +325,10 @@ public final class JdbcSqlExceptionFactory {
       int[] updateCounts, SpannerBatchUpdateException cause) {
     return new JdbcSqlBatchUpdateException(updateCounts, cause);
   }
+
+  /** Creates a {@link JdbcSqlException} for large batch update exceptions. */
+  static BatchUpdateException batchException(
+      long[] updateCounts, SpannerBatchUpdateException cause) {
+    return new JdbcSqlBatchUpdateException(updateCounts, cause);
+  }
 }
diff --git a/src/main/java/com/google/cloud/spanner/jdbc/JdbcStatement.java b/src/main/java/com/google/cloud/spanner/jdbc/JdbcStatement.java
index b4eaa0d..5a8f691 100644
--- a/src/main/java/com/google/cloud/spanner/jdbc/JdbcStatement.java
+++ b/src/main/java/com/google/cloud/spanner/jdbc/JdbcStatement.java
@@ -68,6 +68,21 @@ class JdbcStatement extends AbstractJdbcStatement {
    */
   @Override
   public int executeUpdate(String sql) throws SQLException {
+    long result = executeLargeUpdate(sql);
+    if (result > Integer.MAX_VALUE) {
+      throw JdbcSqlExceptionFactory.of("update count too large: " + result, Code.OUT_OF_RANGE);
+    }
+    return (int) result;
+  }
+
+  /**
+   * @see java.sql.Statement#executeLargeUpdate(String)
+   *     <p>This method allows both DML and DDL statements to be executed. It assumes that the user
+   *     knows what kind of statement is being executed, and the method will therefore return 0 for
+   *     both DML statements that changed 0 rows as well as for all DDL statements.
+   */
+  @Override
+  public long executeLargeUpdate(String sql) throws SQLException {
     checkClosed();
     Statement statement = Statement.of(sql);
     StatementResult result = execute(statement);
@@ -76,13 +91,9 @@ class JdbcStatement extends AbstractJdbcStatement {
         throw JdbcSqlExceptionFactory.of(
             "The statement is not an update or DDL statement", Code.INVALID_ARGUMENT);
       case UPDATE_COUNT:
-        if (result.getUpdateCount() > Integer.MAX_VALUE) {
-          throw JdbcSqlExceptionFactory.of(
-              "update count too large: " + result.getUpdateCount(), Code.OUT_OF_RANGE);
-        }
-        return result.getUpdateCount().intValue();
+        return result.getUpdateCount();
       case NO_RESULT:
-        return 0;
+        return 0L;
       default:
         throw JdbcSqlExceptionFactory.of(
             "unknown result: " + result.getResultType(), Code.FAILED_PRECONDITION);
@@ -138,6 +149,18 @@ class JdbcStatement extends AbstractJdbcStatement {
     return (int) currentUpdateCount;
   }
 
+  /**
+   * Returns the update count of the last update statement as a {@link Long}. Will return {@link
+   * JdbcConstants#STATEMENT_RESULT_SET} if the last statement returned a {@link ResultSet} and will
+   * return {@link JdbcConstants#STATEMENT_NO_RESULT} if the last statement did not have any return
+   * value, such as for example DDL statements.
+   */
+  @Override
+  public long getLargeUpdateCount() throws SQLException {
+    checkClosed();
+    return currentUpdateCount;
+  }
+
   @Override
   public boolean getMoreResults() throws SQLException {
     checkClosed();
@@ -234,19 +257,28 @@ class JdbcStatement extends AbstractJdbcStatement {
 
   @Override
   public int[] executeBatch() throws SQLException {
+    return convertUpdateCounts(executeBatch(false));
+  }
+
+  public long[] executeLargeBatch() throws SQLException {
+    return executeBatch(true);
+  }
+
+  private long[] executeBatch(boolean large) throws SQLException {
     checkClosed();
     checkConnectionHasNoActiveBatch();
     try {
       switch (this.currentBatchType) {
         case DML:
           try {
-            long[] updateCounts =
-                getConnection().getSpannerConnection().executeBatchUpdate(batchedStatements);
-            int[] res = convertUpdateCounts(updateCounts);
-            return res;
+            return getConnection().getSpannerConnection().executeBatchUpdate(batchedStatements);
           } catch (SpannerBatchUpdateException e) {
-            int[] updateCounts = convertUpdateCounts(e.getUpdateCounts());
-            throw JdbcSqlExceptionFactory.batchException(updateCounts, e);
+            if (large) {
+              throw JdbcSqlExceptionFactory.batchException(e.getUpdateCounts(), e);
+            } else {
+              throw JdbcSqlExceptionFactory.batchException(
+                  convertUpdateCounts(e.getUpdateCounts()), e);
+            }
           } catch (SpannerException e) {
             throw JdbcSqlExceptionFactory.of(e);
           }
@@ -257,20 +289,24 @@ class JdbcStatement extends AbstractJdbcStatement {
               execute(statement);
             }
             getConnection().getSpannerConnection().runBatch();
-            int[] res = new int[batchedStatements.size()];
+            long[] res = new long[batchedStatements.size()];
             Arrays.fill(res, java.sql.Statement.SUCCESS_NO_INFO);
             return res;
           } catch (SpannerBatchUpdateException e) {
-            int[] res = new int[batchedStatements.size()];
+            long[] res = new long[batchedStatements.size()];
             Arrays.fill(res, java.sql.Statement.EXECUTE_FAILED);
             convertUpdateCountsToSuccessNoInfo(e.getUpdateCounts(), res);
-            throw JdbcSqlExceptionFactory.batchException(res, e);
+            if (large) {
+              throw JdbcSqlExceptionFactory.batchException(res, e);
+            } else {
+              throw JdbcSqlExceptionFactory.batchException(convertUpdateCounts(res), e);
+            }
           } catch (SpannerException e) {
             throw JdbcSqlExceptionFactory.of(e);
           }
         case NONE:
           // There is no batch on this statement, this is a no-op.
-          return new int[0];
+          return new long[0];
         default:
           throw JdbcSqlExceptionFactory.unsupported(
               String.format("Unknown batch type: %s", this.currentBatchType.name()));
@@ -296,16 +332,11 @@ class JdbcStatement extends AbstractJdbcStatement {
   }
 
   @VisibleForTesting
-  void convertUpdateCountsToSuccessNoInfo(long[] updateCounts, int[] res) throws SQLException {
+  void convertUpdateCountsToSuccessNoInfo(long[] updateCounts, long[] res) throws SQLException {
     Preconditions.checkNotNull(updateCounts);
     Preconditions.checkNotNull(res);
     Preconditions.checkArgument(res.length >= updateCounts.length);
     for (int index = 0; index < updateCounts.length; index++) {
-      if (updateCounts[index] > Integer.MAX_VALUE) {
-        throw JdbcSqlExceptionFactory.of(
-            String.format("Update count too large for int: %d", updateCounts[index]),
-            Code.OUT_OF_RANGE);
-      }
       if (updateCounts[index] > 0L) {
         res[index] = java.sql.Statement.SUCCESS_NO_INFO;
       } else {
@@ -349,6 +380,27 @@ class JdbcStatement extends AbstractJdbcStatement {
     return executeUpdate(sql);
   }
 
+  @Override
+  public long executeLargeUpdate(String sql, int autoGeneratedKeys) throws SQLException {
+    checkClosed();
+    JdbcPreconditions.checkSqlFeatureSupported(
+        autoGeneratedKeys == java.sql.Statement.NO_GENERATED_KEYS,
+        JdbcConnection.ONLY_NO_GENERATED_KEYS);
+    return executeLargeUpdate(sql);
+  }
+
+  @Override
+  public long executeLargeUpdate(String sql, int[] columnIndexes) throws SQLException {
+    checkClosed();
+    return executeLargeUpdate(sql);
+  }
+
+  @Override
+  public long executeLargeUpdate(String sql, String[] columnNames) throws SQLException {
+    checkClosed();
+    return executeLargeUpdate(sql);
+  }
+
   @Override
   public boolean execute(String sql, int autoGeneratedKeys) throws SQLException {
     checkClosed();


Test patch:
diff --git a/src/test/java/com/google/cloud/spanner/jdbc/AbstractJdbcResultSetTest.java b/src/test/java/com/google/cloud/spanner/jdbc/AbstractJdbcResultSetTest.java
index 960287c..2be79bf 100644
--- a/src/test/java/com/google/cloud/spanner/jdbc/AbstractJdbcResultSetTest.java
+++ b/src/test/java/com/google/cloud/spanner/jdbc/AbstractJdbcResultSetTest.java
@@ -40,6 +40,7 @@ import java.sql.ResultSet;
 import java.sql.RowId;
 import java.sql.SQLException;
 import java.sql.SQLFeatureNotSupportedException;
+import java.sql.SQLType;
 import java.sql.SQLXML;
 import java.sql.Statement;
 import java.sql.Time;
@@ -508,14 +509,28 @@ public class AbstractJdbcResultSetTest {
         new SqlRunnable() {
           @Override
           public void run() throws SQLException {
-            rs.updateObject("test", new Object());
+            rs.updateObject(1, new Object(), 1);
           }
         });
     assertUnsupported(
         new SqlRunnable() {
           @Override
           public void run() throws SQLException {
-            rs.updateObject(1, new Object(), 1);
+            rs.updateObject(1, new Object(), mock(SQLType.class));
+          }
+        });
+    assertUnsupported(
+        new SqlRunnable() {
+          @Override
+          public void run() throws SQLException {
+            rs.updateObject(1, new Object(), mock(SQLType.class), 0);
+          }
+        });
+    assertUnsupported(
+        new SqlRunnable() {
+          @Override
+          public void run() throws SQLException {
+            rs.updateObject("test", new Object());
           }
         });
     assertUnsupported(
@@ -525,6 +540,20 @@ public class AbstractJdbcResultSetTest {
             rs.updateObject("test", new Object(), 1);
           }
         });
+    assertUnsupported(
+        new SqlRunnable() {
+          @Override
+          public void run() throws SQLException {
+            rs.updateObject("test", new Object(), mock(SQLType.class));
+          }
+        });
+    assertUnsupported(
+        new SqlRunnable() {
+          @Override
+          public void run() throws SQLException {
+            rs.updateObject("test", new Object(), mock(SQLType.class), 1);
+          }
+        });
     assertUnsupported(
         new SqlRunnable() {
           @Override
diff --git a/src/test/java/com/google/cloud/spanner/jdbc/JdbcDatabaseMetaDataTest.java b/src/test/java/com/google/cloud/spanner/jdbc/JdbcDatabaseMetaDataTest.java
index ac8ab7b..e7e634a 100644
--- a/src/test/java/com/google/cloud/spanner/jdbc/JdbcDatabaseMetaDataTest.java
+++ b/src/test/java/com/google/cloud/spanner/jdbc/JdbcDatabaseMetaDataTest.java
@@ -20,6 +20,8 @@ import static org.hamcrest.CoreMatchers.equalTo;
 import static org.hamcrest.CoreMatchers.is;
 import static org.hamcrest.CoreMatchers.notNullValue;
 import static org.hamcrest.MatcherAssert.assertThat;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.when;
 
@@ -267,6 +269,8 @@ public class JdbcDatabaseMetaDataTest {
         }) {
       assertThat(meta.supportsTransactionIsolationLevel(level), is(false));
     }
+    assertEquals(10485760L, meta.getMaxLogicalLobSize());
+    assertFalse(meta.supportsRefCursors());
 
     // trivial tests that guarantee that the function works, but the return value doesn't matter
     assertThat(meta.getNumericFunctions(), is(notNullValue()));
diff --git a/src/test/java/com/google/cloud/spanner/jdbc/JdbcDriverTest.java b/src/test/java/com/google/cloud/spanner/jdbc/JdbcDriverTest.java
index 014d9bd..c6709b3 100644
--- a/src/test/java/com/google/cloud/spanner/jdbc/JdbcDriverTest.java
+++ b/src/test/java/com/google/cloud/spanner/jdbc/JdbcDriverTest.java
@@ -17,6 +17,7 @@
 package com.google.cloud.spanner.jdbc;
 
 import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.fail;
 
 import com.google.cloud.spanner.MockSpannerServiceImpl;
@@ -82,6 +83,12 @@ public class JdbcDriverTest {
     assertThat(JdbcDriver.getClientLibToken()).isEqualTo("sp-jdbc");
   }
 
+  @Test
+  public void testVersion() throws SQLException {
+    assertEquals(JdbcDriver.MAJOR_VERSION, JdbcDriver.getRegisteredDriver().getMajorVersion());
+    assertEquals(JdbcDriver.MINOR_VERSION, JdbcDriver.getRegisteredDriver().getMinorVersion());
+  }
+
   @Test
   public void testRegister() throws SQLException {
     assertThat(JdbcDriver.isRegistered()).isTrue();
diff --git a/src/test/java/com/google/cloud/spanner/jdbc/JdbcParameterStoreTest.java b/src/test/java/com/google/cloud/spanner/jdbc/JdbcParameterStoreTest.java
index 502ef54..7f68601 100644
--- a/src/test/java/com/google/cloud/spanner/jdbc/JdbcParameterStoreTest.java
+++ b/src/test/java/com/google/cloud/spanner/jdbc/JdbcParameterStoreTest.java
@@ -84,10 +84,18 @@ public class JdbcParameterStoreTest {
     verifyParameter(params, Value.date(com.google.cloud.Date.fromYearMonthDay(1970, 1, 1)));
     params.setParameter(1, new Time(0L), Types.TIME);
     assertEquals(new Time(0L), params.getParameter(1));
+    verifyParameter(
+        params, Value.timestamp(com.google.cloud.Timestamp.ofTimeSecondsAndNanos(0L, 0)));
+    params.setParameter(1, new Time(0L), Types.TIME_WITH_TIMEZONE);
+    assertEquals(new Time(0L), params.getParameter(1));
     verifyParameter(
         params, Value.timestamp(com.google.cloud.Timestamp.ofTimeSecondsAndNanos(0L, 0)));
     params.setParameter(1, new Timestamp(0L), Types.TIMESTAMP);
     assertEquals(new Timestamp(0L), params.getParameter(1));
+    verifyParameter(
+        params, Value.timestamp(com.google.cloud.Timestamp.ofTimeSecondsAndNanos(0L, 0)));
+    params.setParameter(1, new Timestamp(0L), Types.TIMESTAMP_WITH_TIMEZONE);
+    assertEquals(new Timestamp(0L), params.getParameter(1));
     verifyParameter(
         params, Value.timestamp(com.google.cloud.Timestamp.ofTimeSecondsAndNanos(0L, 0)));
     params.setParameter(1, new byte[] {1, 2, 3}, Types.BINARY);
@@ -184,7 +192,10 @@ public class JdbcParameterStoreTest {
     }
 
     // types that should lead to timestamp
-    for (int type : new int[] {Types.TIME, Types.TIMESTAMP}) {
+    for (int type :
+        new int[] {
+          Types.TIME, Types.TIME_WITH_TIMEZONE, Types.TIMESTAMP, Types.TIMESTAMP_WITH_TIMEZONE
+        }) {
       params.setParameter(1, new Date(0L), type);
       assertEquals(new Date(0L), params.getParameter(1));
       verifyParameter(
@@ -321,7 +332,10 @@ public class JdbcParameterStoreTest {
     }
 
     // types that should lead to timestamp
-    for (int type : new int[] {Types.TIME, Types.TIMESTAMP}) {
+    for (int type :
+        new int[] {
+          Types.TIME, Types.TIME_WITH_TIMEZONE, Types.TIMESTAMP, Types.TIMESTAMP_WITH_TIMEZONE
+        }) {
       assertInvalidParameter(params, "1", type);
       assertInvalidParameter(params, new Object(), type);
       assertInvalidParameter(params, Boolean.TRUE, type);
diff --git a/src/test/java/com/google/cloud/spanner/jdbc/JdbcPreparedStatementTest.java b/src/test/java/com/google/cloud/spanner/jdbc/JdbcPreparedStatementTest.java
index 5a1235f..b19aa54 100644
--- a/src/test/java/com/google/cloud/spanner/jdbc/JdbcPreparedStatementTest.java
+++ b/src/test/java/com/google/cloud/spanner/jdbc/JdbcPreparedStatementTest.java
@@ -39,6 +39,7 @@ import java.io.StringReader;
 import java.net.MalformedURLException;
 import java.net.URL;
 import java.sql.Date;
+import java.sql.JDBCType;
 import java.sql.PreparedStatement;
 import java.sql.Ref;
 import java.sql.ResultSetMetaData;
@@ -101,7 +102,7 @@ public class JdbcPreparedStatementTest {
   @SuppressWarnings("deprecation")
   @Test
   public void testParameters() throws SQLException, MalformedURLException {
-    final int numberOfParams = 48;
+    final int numberOfParams = 51;
     String sql = generateSqlWithParameters(numberOfParams);
 
     JdbcConnection connection = createMockConnection();
@@ -151,6 +152,8 @@ public class JdbcPreparedStatementTest {
       ps.setUnicodeStream(47, new ByteArrayInputStream("TEST".getBytes()), 4);
       ps.setURL(48, new URL("https://spanner.google.com"));
       ps.setObject(49, UUID.fromString("83b988cf-1f4e-428a-be3d-cc712621942e"));
+      ps.setObject(50, "TEST", JDBCType.NVARCHAR);
+      ps.setObject(51, "TEST", JDBCType.NVARCHAR, 20);
 
       testSetUnsupportedTypes(ps);
 
@@ -204,6 +207,8 @@ public class JdbcPreparedStatementTest {
       assertEquals(ByteArrayInputStream.class.getName(), pmd.getParameterClassName(47));
       assertEquals(URL.class.getName(), pmd.getParameterClassName(48));
       assertEquals(UUID.class.getName(), pmd.getParameterClassName(49));
+      assertEquals(String.class.getName(), pmd.getParameterClassName(50));
+      assertEquals(String.class.getName(), pmd.getParameterClassName(51));
 
       ps.clearParameters();
       pmd = ps.getParameterMetaData();
@@ -237,41 +242,46 @@ public class JdbcPreparedStatementTest {
 
   @Test
   public void testSetNullValues() throws SQLException {
-    String sql = generateSqlWithParameters(27);
+    final int numberOfParameters = 27;
+    String sql = generateSqlWithParameters(numberOfParameters);
     try (JdbcPreparedStatement ps = new JdbcPreparedStatement(createMockConnection(), sql)) {
-      ps.setNull(1, Types.BLOB);
-      ps.setNull(2, Types.NVARCHAR);
-      ps.setNull(4, Types.BINARY);
-      ps.setNull(5, Types.BOOLEAN);
-      ps.setNull(6, Types.TINYINT);
-      ps.setNull(7, Types.DATE);
-      ps.setNull(8, Types.DOUBLE);
-      ps.setNull(9, Types.FLOAT);
-      ps.setNull(10, Types.INTEGER);
-      ps.setNull(11, Types.BIGINT);
-      ps.setNull(12, Types.SMALLINT);
-      ps.setNull(13, Types.TIME);
-      ps.setNull(14, Types.TIMESTAMP);
-      ps.setNull(15, Types.CHAR);
-      ps.setNull(16, Types.CLOB);
-      ps.setNull(17, Types.LONGNVARCHAR);
-      ps.setNull(18, Types.LONGVARBINARY);
-      ps.setNull(19, Types.LONGVARCHAR);
-      ps.setNull(20, Types.NCHAR);
-      ps.setNull(21, Types.NCLOB);
-      ps.setNull(23, Types.NVARCHAR);
-      ps.setNull(24, Types.REAL);
-      ps.setNull(25, Types.BIT);
-      ps.setNull(26, Types.VARBINARY);
-      ps.setNull(27, Types.VARCHAR);
+      int index = 0;
+      ps.setNull(++index, Types.BLOB);
+      ps.setNull(++index, Types.NVARCHAR);
+      ps.setNull(++index, Types.BINARY);
+      ps.setNull(++index, Types.BOOLEAN);
+      ps.setNull(++index, Types.TINYINT);
+      ps.setNull(++index, Types.DATE);
+      ps.setNull(++index, Types.DOUBLE);
+      ps.setNull(++index, Types.FLOAT);
+      ps.setNull(++index, Types.INTEGER);
+      ps.setNull(++index, Types.BIGINT);
+      ps.setNull(++index, Types.SMALLINT);
+      ps.setNull(++index, Types.TIME);
+      ps.setNull(++index, Types.TIME_WITH_TIMEZONE);
+      ps.setNull(++index, Types.TIMESTAMP);
+      ps.setNull(++index, Types.TIMESTAMP_WITH_TIMEZONE);
+      ps.setNull(++index, Types.CHAR);
+      ps.setNull(++index, Types.CLOB);
+      ps.setNull(++index, Types.LONGNVARCHAR);
+      ps.setNull(++index, Types.LONGVARBINARY);
+      ps.setNull(++index, Types.LONGVARCHAR);
+      ps.setNull(++index, Types.NCHAR);
+      ps.setNull(++index, Types.NCLOB);
+      ps.setNull(++index, Types.NVARCHAR);
+      ps.setNull(++index, Types.REAL);
+      ps.setNull(++index, Types.BIT);
+      ps.setNull(++index, Types.VARBINARY);
+      ps.setNull(++index, Types.VARCHAR);
+      assertEquals(numberOfParameters, index);
 
       JdbcParameterMetaData pmd = ps.getParameterMetaData();
-      assertEquals(27, pmd.getParameterCount());
-      assertEquals(Timestamp.class.getName(), pmd.getParameterClassName(14));
+      assertEquals(numberOfParameters, pmd.getParameterCount());
+      assertEquals(Timestamp.class.getName(), pmd.getParameterClassName(15));
 
       ps.clearParameters();
       pmd = ps.getParameterMetaData();
-      assertEquals(27, pmd.getParameterCount());
+      assertEquals(numberOfParameters, pmd.getParameterCount());
     }
   }
 
diff --git a/src/test/java/com/google/cloud/spanner/jdbc/JdbcPreparedStatementWithMockedServerTest.java b/src/test/java/com/google/cloud/spanner/jdbc/JdbcPreparedStatementWithMockedServerTest.java
new file mode 100644
index 0000000..3f36f00
--- /dev/null
+++ b/src/test/java/com/google/cloud/spanner/jdbc/JdbcPreparedStatementWithMockedServerTest.java
@@ -0,0 +1,197 @@
+/*
+ * Copyright 2021 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.cloud.spanner.jdbc;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import com.google.cloud.spanner.ErrorCode;
+import com.google.cloud.spanner.MockSpannerServiceImpl;
+import com.google.cloud.spanner.MockSpannerServiceImpl.StatementResult;
+import com.google.cloud.spanner.Statement;
+import com.google.cloud.spanner.connection.SpannerPool;
+import com.google.cloud.spanner.jdbc.JdbcSqlExceptionFactory.JdbcSqlBatchUpdateException;
+import io.grpc.Server;
+import io.grpc.Status;
+import io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder;
+import java.io.IOException;
+import java.net.InetSocketAddress;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.PreparedStatement;
+import java.sql.SQLException;
+import java.util.Arrays;
+import java.util.Collection;
+import org.junit.After;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+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 JdbcPreparedStatementWithMockedServerTest {
+  private static MockSpannerServiceImpl mockSpanner;
+  private static Server server;
+  private static InetSocketAddress address;
+
+  @Parameter public boolean executeLarge;
+
+  @Parameters(name = "executeLarge = {0}")
+  public static Collection<Object[]> data() {
+    return Arrays.asList(new Object[][] {{false}, {true}});
+  }
+
+  @BeforeClass
+  public static void startStaticServer() throws IOException {
+    mockSpanner = new MockSpannerServiceImpl();
+    mockSpanner.setAbortProbability(0.0D);
+    address = new InetSocketAddress("localhost", 0);
+    server = NettyServerBuilder.forAddress(address).addService(mockSpanner).build().start();
+  }
+
+  @AfterClass
+  public static void stopServer() throws Exception {
+    server.shutdown();
+    server.awaitTermination();
+  }
+
+  @After
+  public void reset() {
+    SpannerPool.closeSpannerPool();
+    mockSpanner.removeAllExecutionTimes();
+    mockSpanner.reset();
+  }
+
+  private String createUrl() {
+    return String.format(
+        "jdbc:cloudspanner://localhost:%d/projects/%s/instances/%s/databases/%s?usePlainText=true",
+        server.getPort(), "proj", "inst", "db");
+  }
+
+  private Connection createConnection() throws SQLException {
+    return DriverManager.getConnection(createUrl());
+  }
+
+  @Test
+  public void testExecuteBatch() throws SQLException {
+    Statement.Builder insertBuilder =
+        Statement.newBuilder("INSERT INTO Test (Col1, Col2) VALUES (@p1, @p2)");
+    mockSpanner.putStatementResult(
+        StatementResult.update(
+            insertBuilder.bind("p1").to(1L).bind("p2").to("test 1").build(), 1L));
+    mockSpanner.putStatementResult(
+        StatementResult.update(
+            insertBuilder.bind("p1").to(2L).bind("p2").to("test 2").build(), 1L));
+    try (Connection connection = createConnection()) {
+      try (PreparedStatement statement =
+          connection.prepareStatement("INSERT INTO Test (Col1, Col2) VALUES (?, ?)")) {
+        statement.setLong(1, 1L);
+        statement.setString(2, "test 1");
+        statement.addBatch();
+        statement.setLong(1, 2L);
+        statement.setString(2, "test 2");
+        statement.addBatch();
+        if (executeLarge) {
+          assertThat(statement.executeLargeBatch()).asList().containsExactly(1L, 1L);
+        } else {
+          assertThat(statement.executeBatch()).asList().containsExactly(1, 1);
+        }
+      }
+    }
+  }
+
+  @Test
+  public void testExecuteBatch_withOverflow() throws SQLException {
+    Statement.Builder insertBuilder =
+        Statement.newBuilder("INSERT INTO Test (Col1, Col2) VALUES (@p1, @p2)");
+    mockSpanner.putStatementResult(
+        StatementResult.update(
+            insertBuilder.bind("p1").to(1L).bind("p2").to("test 1").build(), 1L));
+    mockSpanner.putStatementResult(
+        StatementResult.update(
+            insertBuilder.bind("p1").to(2L).bind("p2").to("test 2").build(),
+            Integer.MAX_VALUE + 1L));
+    try (Connection connection = createConnection()) {
+      try (PreparedStatement statement =
+          connection.prepareStatement("INSERT INTO Test (Col1, Col2) VALUES (?, ?)")) {
+        statement.setLong(1, 1L);
+        statement.setString(2, "test 1");
+        statement.addBatch();
+        statement.setLong(1, 2L);
+        statement.setString(2, "test 2");
+        statement.addBatch();
+        if (executeLarge) {
+          assertThat(statement.executeLargeBatch())
+              .asList()
+              .containsExactly(1L, Integer.MAX_VALUE + 1L);
+        } else {
+          try {
+            statement.executeBatch();
+            fail("missing expected OutOfRange exception");
+          } catch (SQLException e) {
+            assertTrue(e instanceof JdbcSqlException);
+            JdbcSqlException sqlException = (JdbcSqlException) e;
+            assertEquals(
+                ErrorCode.OUT_OF_RANGE.getGrpcStatusCode().value(), sqlException.getErrorCode());
+          }
+        }
+      }
+    }
+  }
+
+  @Test
+  public void testExecuteBatch_withException() throws SQLException {
+    Statement.Builder insertBuilder =
+        Statement.newBuilder("INSERT INTO Test (Col1, Col2) VALUES (@p1, @p2)");
+    mockSpanner.putStatementResult(
+        StatementResult.update(
+            insertBuilder.bind("p1").to(1L).bind("p2").to("test 1").build(), 1L));
+    mockSpanner.putStatementResult(
+        StatementResult.exception(
+            insertBuilder.bind("p1").to(2L).bind("p2").to("test 2").build(),
+            Status.ALREADY_EXISTS.asRuntimeException()));
+    try (Connection connection = createConnection()) {
+      try (PreparedStatement statement =
+          connection.prepareStatement("INSERT INTO Test (Col1, Col2) VALUES (?, ?)")) {
+        statement.setLong(1, 1L);
+        statement.setString(2, "test 1");
+        statement.addBatch();
+        statement.setLong(1, 2L);
+        statement.setString(2, "test 2");
+        statement.addBatch();
+        try {
+          if (executeLarge) {
+            statement.executeLargeBatch();
+          } else {
+            statement.executeBatch();
+          }
+        } catch (JdbcSqlBatchUpdateException e) {
+          if (executeLarge) {
+            assertThat(e.getLargeUpdateCounts()).asList().containsExactly(1L);
+          } else {
+            assertThat(e.getUpdateCounts()).asList().containsExactly(1);
+          }
+        }
+      }
+    }
+  }
+}
diff --git a/src/test/java/com/google/cloud/spanner/jdbc/JdbcStatementTest.java b/src/test/java/com/google/cloud/spanner/jdbc/JdbcStatementTest.java
index 7349bfc..ee21dda 100644
--- a/src/test/java/com/google/cloud/spanner/jdbc/JdbcStatementTest.java
+++ b/src/test/java/com/google/cloud/spanner/jdbc/JdbcStatementTest.java
@@ -187,14 +187,15 @@ public class JdbcStatementTest {
     assertThat(res).isFalse();
     assertThat(statement.getResultSet()).isNull();
     assertThat(statement.getUpdateCount()).isEqualTo(1);
+    assertThat(statement.execute(LARGE_UPDATE)).isFalse();
+    assertThat(statement.getResultSet()).isNull();
     try {
-      assertThat(statement.execute(LARGE_UPDATE)).isFalse();
-      assertThat(statement.getResultSet()).isNull();
       statement.getUpdateCount();
       fail("missing expected exception");
     } catch (JdbcSqlExceptionImpl e) {
       assertThat(e.getCode()).isEqualTo(Code.OUT_OF_RANGE);
     }
+    assertThat(statement.getLargeUpdateCount()).isEqualTo(Integer.MAX_VALUE + 1L);
   }
 
   @Test
@@ -270,6 +271,65 @@ public class JdbcStatementTest {
     }
   }
 
+  @Test
+  public void testInternalExecuteUpdate() throws SQLException {
+    JdbcConnection connection = mock(JdbcConnection.class);
+    Connection spannerConnection = mock(Connection.class);
+    when(connection.getSpannerConnection()).thenReturn(spannerConnection);
+    com.google.cloud.spanner.Statement updateStatement =
+        com.google.cloud.spanner.Statement.of(UPDATE);
+    com.google.cloud.spanner.Statement largeUpdateStatement =
+        com.google.cloud.spanner.Statement.of(LARGE_UPDATE);
+    when(spannerConnection.executeUpdate(updateStatement)).thenReturn(1L);
+    when(spannerConnection.executeUpdate(largeUpdateStatement)).thenReturn(Integer.MAX_VALUE + 1L);
+    try (JdbcStatement statement = new JdbcStatement(connection)) {
+      assertThat(statement.executeUpdate(updateStatement)).isEqualTo(1);
+      try {
+        statement.executeUpdate(largeUpdateStatement);
+        fail("missing expected exception");
+      } catch (JdbcSqlExceptionImpl e) {
+        assertThat(e.getCode()).isEqualTo(Code.OUT_OF_RANGE);
+      }
+    }
+  }
+
+  @Test
+  public void testInternalExecuteLargeUpdate() throws SQLException {
+    JdbcConnection connection = mock(JdbcConnection.class);
+    Connection spannerConnection = mock(Connection.class);
+    when(connection.getSpannerConnection()).thenReturn(spannerConnection);
+    com.google.cloud.spanner.Statement updateStatement =
+        com.google.cloud.spanner.Statement.of(UPDATE);
+    com.google.cloud.spanner.Statement largeUpdateStatement =
+        com.google.cloud.spanner.Statement.of(LARGE_UPDATE);
+    when(spannerConnection.executeUpdate(updateStatement)).thenReturn(1L);
+    when(spannerConnection.executeUpdate(largeUpdateStatement)).thenReturn(Integer.MAX_VALUE + 1L);
+    try (JdbcStatement statement = new JdbcStatement(connection)) {
+      assertThat(statement.executeLargeUpdate(updateStatement)).isEqualTo(1);
+      assertThat(statement.executeLargeUpdate(largeUpdateStatement))
+          .isEqualTo(Integer.MAX_VALUE + 1L);
+    }
+  }
+
+  @Test
+  public void testExecuteLargeUpdate() throws SQLException {
+    Statement statement = createStatement();
+    assertThat(statement.executeLargeUpdate(UPDATE)).isEqualTo(1L);
+    assertThat(statement.executeLargeUpdate(LARGE_UPDATE)).isEqualTo(Integer.MAX_VALUE + 1L);
+
+    assertThat(statement.executeLargeUpdate(UPDATE, Statement.NO_GENERATED_KEYS)).isEqualTo(1L);
+    assertThat(statement.executeLargeUpdate(LARGE_UPDATE, Statement.NO_GENERATED_KEYS))
+        .isEqualTo(Integer.MAX_VALUE + 1L);
+
+    assertThat(statement.executeLargeUpdate(UPDATE, new int[0])).isEqualTo(1L);
+    assertThat(statement.executeLargeUpdate(LARGE_UPDATE, new int[0]))
+        .isEqualTo(Integer.MAX_VALUE + 1L);
+
+    assertThat(statement.executeLargeUpdate(UPDATE, new String[0])).isEqualTo(1L);
+    assertThat(statement.executeLargeUpdate(LARGE_UPDATE, new String[0]))
+        .isEqualTo(Integer.MAX_VALUE + 1L);
+  }
+
   @Test
   public void testExecuteUpdateWithSelectStatement() throws SQLException {
     Statement statement = createStatement();
@@ -364,6 +424,19 @@ public class JdbcStatementTest {
     }
   }
 
+  @Test
+  public void testLargeDmlBatch() throws SQLException {
+    try (Statement statement = createStatement()) {
+      // Verify that multiple batches can be executed on the same statement.
+      for (int i = 0; i < 2; i++) {
+        statement.addBatch("INSERT INTO FOO (ID, NAME) VALUES (1, 'TEST')");
+        statement.addBatch("INSERT INTO FOO (ID, NAME) VALUES (2, 'TEST')");
+        statement.addBatch("INSERT INTO FOO (ID, NAME) VALUES (3, 'TEST')");
+        assertThat(statement.executeLargeBatch()).asList().containsExactly(1L, 1L, 1L);
+      }
+    }
+  }
+
   @Test
   public void testConvertUpdateCounts() throws SQLException {
     try (JdbcStatement statement = new JdbcStatement(mock(JdbcConnection.class))) {
@@ -382,30 +455,39 @@ public class JdbcStatementTest {
   @Test
   public void testConvertUpdateCountsToSuccessNoInfo() throws SQLException {
     try (JdbcStatement statement = new JdbcStatement(mock(JdbcConnection.class))) {
-      int[] updateCounts = new int[3];
+      long[] updateCounts = new long[3];
       statement.convertUpdateCountsToSuccessNoInfo(new long[] {1L, 2L, 3L}, updateCounts);
       assertThat(updateCounts)
           .asList()
           .containsExactly(
-              Statement.SUCCESS_NO_INFO, Statement.SUCCESS_NO_INFO, Statement.SUCCESS_NO_INFO);
+              Long.valueOf(Statement.SUCCESS_NO_INFO),
+              Long.valueOf(Statement.SUCCESS_NO_INFO),
+              Long.valueOf(Statement.SUCCESS_NO_INFO));
 
       statement.convertUpdateCountsToSuccessNoInfo(new long[] {0L, 0L, 0L}, updateCounts);
       assertThat(updateCounts)
           .asList()
           .containsExactly(
-              Statement.EXECUTE_FAILED, Statement.EXECUTE_FAILED, Statement.EXECUTE_FAILED);
+              Long.valueOf(Statement.EXECUTE_FAILED),
+              Long.valueOf(Statement.EXECUTE_FAILED),
+              Long.valueOf(Statement.EXECUTE_FAILED));
 
       statement.convertUpdateCountsToSuccessNoInfo(new long[] {1L, 0L, 2L}, updateCounts);
       assertThat(updateCounts)
           .asList()
           .containsExactly(
-              Statement.SUCCESS_NO_INFO, Statement.EXECUTE_FAILED, Statement.SUCCESS_NO_INFO);
+              Long.valueOf(Statement.SUCCESS_NO_INFO),
+              Long.valueOf(Statement.EXECUTE_FAILED),
+              Long.valueOf(Statement.SUCCESS_NO_INFO));
 
       statement.convertUpdateCountsToSuccessNoInfo(
-          new long[] {1L, Integer.MAX_VALUE + 1L}, updateCounts);
-      fail("missing expected exception");
-    } catch (SQLException e) {
-      assertThat(JdbcExceptionMatcher.matchCode(Code.OUT_OF_RANGE).matches(e)).isTrue();
+          new long[] {1L, Integer.MAX_VALUE + 1L, 2L}, updateCounts);
+      assertThat(updateCounts)
+          .asList()
+          .containsExactly(
+              Long.valueOf(Statement.SUCCESS_NO_INFO),
+              Long.valueOf(Statement.SUCCESS_NO_INFO),
+              Long.valueOf(Statement.SUCCESS_NO_INFO));
     }
   }
 }