diff --git a/google-cloud-clients/google-cloud-contrib/google-cloud-spanner-jdbc/src/main/java/com/google/cloud/spanner/jdbc/AbstractJdbcResultSet.java b/google-cloud-clients/google-cloud-contrib/google-cloud-spanner-jdbc/src/main/java/com/google/cloud/spanner/jdbc/AbstractJdbcResultSet.java
new file mode 100644
index 000000000000..9015943ed279
--- /dev/null
+++ b/google-cloud-clients/google-cloud-contrib/google-cloud-spanner-jdbc/src/main/java/com/google/cloud/spanner/jdbc/AbstractJdbcResultSet.java
@@ -0,0 +1,627 @@
+/*
+ * Copyright 2019 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.cloud.spanner.jdbc;
+
+import java.io.InputStream;
+import java.io.Reader;
+import java.math.BigDecimal;
+import java.sql.Array;
+import java.sql.Blob;
+import java.sql.Clob;
+import java.sql.Date;
+import java.sql.NClob;
+import java.sql.Ref;
+import java.sql.ResultSet;
+import java.sql.RowId;
+import java.sql.SQLException;
+import java.sql.SQLFeatureNotSupportedException;
+import java.sql.SQLWarning;
+import java.sql.SQLXML;
+import java.sql.Time;
+import java.sql.Timestamp;
+
+/** Base class for Cloud Spanner {@link ResultSet}s. */
+abstract class AbstractJdbcResultSet extends AbstractJdbcWrapper implements ResultSet {
+  /** The underlying Cloud Spanner {@link com.google.cloud.spanner.ResultSet}. */
+  final com.google.cloud.spanner.ResultSet spanner;
+
+  AbstractJdbcResultSet(com.google.cloud.spanner.ResultSet spanner) {
+    this.spanner = spanner;
+  }
+
+  @Override
+  public SQLWarning getWarnings() throws SQLException {
+    return null;
+  }
+
+  @Override
+  public void clearWarnings() throws SQLException {}
+
+  @Override
+  public String getCursorName() throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public boolean isLast() throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void beforeFirst() throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void afterLast() throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public boolean first() throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public boolean last() throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public boolean absolute(int row) throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public boolean relative(int rows) throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public boolean previous() throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void setFetchDirection(int direction) throws SQLException {
+    JdbcPreconditions.checkArgument(direction == FETCH_FORWARD, direction);
+  }
+
+  @Override
+  public int getFetchDirection() throws SQLException {
+    return FETCH_FORWARD;
+  }
+
+  @Override
+  public void setFetchSize(int rows) throws SQLException {
+    // no-op
+  }
+
+  @Override
+  public int getFetchSize() throws SQLException {
+    return 0;
+  }
+
+  @Override
+  public int getType() throws SQLException {
+    return TYPE_FORWARD_ONLY;
+  }
+
+  @Override
+  public int getConcurrency() throws SQLException {
+    return CONCUR_READ_ONLY;
+  }
+
+  @Override
+  public boolean rowUpdated() throws SQLException {
+    return false;
+  }
+
+  @Override
+  public boolean rowInserted() throws SQLException {
+    return false;
+  }
+
+  @Override
+  public boolean rowDeleted() throws SQLException {
+    return false;
+  }
+
+  @Override
+  public void updateNull(int columnIndex) throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void updateBoolean(int columnIndex, boolean x) throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void updateByte(int columnIndex, byte x) throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void updateShort(int columnIndex, short x) throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void updateInt(int columnIndex, int x) throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void updateLong(int columnIndex, long x) throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void updateFloat(int columnIndex, float x) throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void updateDouble(int columnIndex, double x) throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void updateBigDecimal(int columnIndex, BigDecimal x) throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void updateString(int columnIndex, String x) throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void updateBytes(int columnIndex, byte[] x) throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void updateDate(int columnIndex, Date x) throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void updateTime(int columnIndex, Time x) throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void updateTimestamp(int columnIndex, Timestamp x) throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void updateAsciiStream(int columnIndex, InputStream x, int length) throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void updateBinaryStream(int columnIndex, InputStream x, int length) throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void updateCharacterStream(int columnIndex, Reader x, int length) throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void updateObject(int columnIndex, Object x, int scaleOrLength) throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void updateObject(int columnIndex, Object x) throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void updateNull(String columnLabel) throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void updateBoolean(String columnLabel, boolean x) throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void updateByte(String columnLabel, byte x) throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void updateShort(String columnLabel, short x) throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void updateInt(String columnLabel, int x) throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void updateLong(String columnLabel, long x) throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void updateFloat(String columnLabel, float x) throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void updateDouble(String columnLabel, double x) throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void updateBigDecimal(String columnLabel, BigDecimal x) throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void updateString(String columnLabel, String x) throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void updateBytes(String columnLabel, byte[] x) throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void updateDate(String columnLabel, Date x) throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void updateTime(String columnLabel, Time x) throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void updateTimestamp(String columnLabel, Timestamp x) throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void updateAsciiStream(String columnLabel, InputStream x, int length) throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void updateBinaryStream(String columnLabel, InputStream x, int length)
+      throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void updateCharacterStream(String columnLabel, Reader reader, int length)
+      throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void updateObject(String columnLabel, Object x, int scaleOrLength) throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void updateObject(String columnLabel, Object x) throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void insertRow() throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void updateRow() throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void deleteRow() throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void refreshRow() throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void cancelRowUpdates() throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void moveToInsertRow() throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void moveToCurrentRow() throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public Ref getRef(int columnIndex) throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public Ref getRef(String columnLabel) throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void updateRef(int columnIndex, Ref x) throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void updateRef(String columnLabel, Ref x) throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void updateBlob(int columnIndex, Blob x) throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void updateBlob(String columnLabel, Blob x) throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void updateClob(int columnIndex, Clob x) throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void updateClob(String columnLabel, Clob x) throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void updateArray(int columnIndex, Array x) throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void updateArray(String columnLabel, Array x) throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public RowId getRowId(int columnIndex) throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public RowId getRowId(String columnLabel) throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void updateRowId(int columnIndex, RowId x) throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void updateRowId(String columnLabel, RowId x) throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void updateNString(int columnIndex, String nString) throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void updateNString(String columnLabel, String nString) throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void updateNClob(int columnIndex, NClob nClob) throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void updateNClob(String columnLabel, NClob nClob) throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public SQLXML getSQLXML(int columnIndex) throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public SQLXML getSQLXML(String columnLabel) throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void updateSQLXML(int columnIndex, SQLXML xmlObject) throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void updateSQLXML(String columnLabel, SQLXML xmlObject) throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void updateNCharacterStream(int columnIndex, Reader x, long length) throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void updateNCharacterStream(String columnLabel, Reader reader, long length)
+      throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void updateAsciiStream(int columnIndex, InputStream x, long length) throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void updateBinaryStream(int columnIndex, InputStream x, long length) throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void updateCharacterStream(int columnIndex, Reader x, long length) throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void updateAsciiStream(String columnLabel, InputStream x, long length)
+      throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void updateBinaryStream(String columnLabel, InputStream x, long length)
+      throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void updateCharacterStream(String columnLabel, Reader reader, long length)
+      throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void updateBlob(int columnIndex, InputStream inputStream, long length)
+      throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void updateBlob(String columnLabel, InputStream inputStream, long length)
+      throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void updateClob(int columnIndex, Reader reader, long length) throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void updateClob(String columnLabel, Reader reader, long length) throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void updateNClob(int columnIndex, Reader reader, long length) throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void updateNClob(String columnLabel, Reader reader, long length) throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void updateNCharacterStream(int columnIndex, Reader x) throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void updateNCharacterStream(String columnLabel, Reader reader) throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void updateAsciiStream(int columnIndex, InputStream x) throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void updateBinaryStream(int columnIndex, InputStream x) throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void updateCharacterStream(int columnIndex, Reader x) throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void updateAsciiStream(String columnLabel, InputStream x) throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void updateBinaryStream(String columnLabel, InputStream x) throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void updateCharacterStream(String columnLabel, Reader reader) throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void updateBlob(int columnIndex, InputStream inputStream) throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void updateBlob(String columnLabel, InputStream inputStream) throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void updateClob(int columnIndex, Reader reader) throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void updateClob(String columnLabel, Reader reader) throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void updateNClob(int columnIndex, Reader reader) throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+  @Override
+  public void updateNClob(String columnLabel, Reader reader) throws SQLException {
+    throw new SQLFeatureNotSupportedException();
+  }
+
+}
diff --git a/google-cloud-clients/google-cloud-contrib/google-cloud-spanner-jdbc/src/main/java/com/google/cloud/spanner/jdbc/AbstractJdbcWrapper.java b/google-cloud-clients/google-cloud-contrib/google-cloud-spanner-jdbc/src/main/java/com/google/cloud/spanner/jdbc/AbstractJdbcWrapper.java
new file mode 100644
index 000000000000..abfa0f58d8ad
--- /dev/null
+++ b/google-cloud-clients/google-cloud-contrib/google-cloud-spanner-jdbc/src/main/java/com/google/cloud/spanner/jdbc/AbstractJdbcWrapper.java
@@ -0,0 +1,184 @@
+/*
+ * Copyright 2019 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 com.google.cloud.spanner.Type;
+import com.google.cloud.spanner.Type.Code;
+import com.google.common.base.Preconditions;
+import java.sql.Date;
+import java.sql.SQLException;
+import java.sql.SQLFeatureNotSupportedException;
+import java.sql.Timestamp;
+import java.sql.Types;
+import java.sql.Wrapper;
+
+/** Base class for all Cloud Spanner JDBC classes that implement the {@link Wrapper} interface. */
+abstract class AbstractJdbcWrapper implements Wrapper {
+  static final String OTHER_NAME = "OTHER";
+
+  /**
+   * Extract {@link java.sql.Types} code from Spanner {@link Type}.
+   *
+   * @param type The Cloud Spanner type to convert. May not be <code>null</code>.
+   */
+  static int extractColumnType(Type type) {
+    Preconditions.checkNotNull(type);
+    if (type.equals(Type.bool())) return Types.BOOLEAN;
+    if (type.equals(Type.bytes())) return Types.BINARY;
+    if (type.equals(Type.date())) return Types.DATE;
+    if (type.equals(Type.float64())) return Types.DOUBLE;
+    if (type.equals(Type.int64())) return Types.BIGINT;
+    if (type.equals(Type.string())) return Types.NVARCHAR;
+    if (type.equals(Type.timestamp())) return Types.TIMESTAMP;
+    if (type.getCode() == Code.ARRAY) return Types.ARRAY;
+    return Types.OTHER;
+  }
+
+  /** Extract Spanner type name from {@link java.sql.Types} code. */
+  static String getSpannerTypeName(int sqlType) {
+    if (sqlType == Types.BOOLEAN) return Type.bool().getCode().name();
+    if (sqlType == Types.BINARY) return Type.bytes().getCode().name();
+    if (sqlType == Types.DATE) return Type.date().getCode().name();
+    if (sqlType == Types.DOUBLE || sqlType == Types.FLOAT) return Type.float64().getCode().name();
+    if (sqlType == Types.BIGINT
+        || sqlType == Types.INTEGER
+        || sqlType == Types.SMALLINT
+        || sqlType == Types.TINYINT) return Type.int64().getCode().name();
+    if (sqlType == Types.NVARCHAR) return Type.string().getCode().name();
+    if (sqlType == Types.TIMESTAMP) return Type.timestamp().getCode().name();
+    if (sqlType == Types.ARRAY) return Code.ARRAY.name();
+
+    return OTHER_NAME;
+  }
+
+  /** Get corresponding Java class name from {@link java.sql.Types} code. */
+  static String getClassName(int sqlType) {
+    if (sqlType == Types.BOOLEAN) return Boolean.class.getName();
+    if (sqlType == Types.BINARY) return Byte[].class.getName();
+    if (sqlType == Types.DATE) return Date.class.getName();
+    if (sqlType == Types.DOUBLE || sqlType == Types.FLOAT) return Double.class.getName();
+    if (sqlType == Types.BIGINT
+        || sqlType == Types.INTEGER
+        || sqlType == Types.SMALLINT
+        || sqlType == Types.TINYINT) return Long.class.getName();
+    if (sqlType == Types.NVARCHAR) return String.class.getName();
+    if (sqlType == Types.TIMESTAMP) return Timestamp.class.getName();
+    if (sqlType == Types.ARRAY) return Object.class.getName();
+
+    return null;
+  }
+
+  /**
+   * Get corresponding Java class name from Spanner {@link Type}.
+   *
+   * @param type The Cloud Spanner type to convert. May not be <code>null</code>.
+   */
+  static String getClassName(Type type) {
+    Preconditions.checkNotNull(type);
+    if (type == Type.bool()) return Boolean.class.getName();
+    if (type == Type.bytes()) return byte[].class.getName();
+    if (type == Type.date()) return Date.class.getName();
+    if (type == Type.float64()) return Double.class.getName();
+    if (type == Type.int64()) return Long.class.getName();
+    if (type == Type.string()) return String.class.getName();
+    if (type == Type.timestamp()) return Timestamp.class.getName();
+    if (type.getCode() == Code.ARRAY) {
+      if (type.getArrayElementType() == Type.bool()) return Boolean[].class.getName();
+      if (type.getArrayElementType() == Type.bytes()) return byte[][].class.getName();
+      if (type.getArrayElementType() == Type.date()) return Date[].class.getName();
+      if (type.getArrayElementType() == Type.float64()) return Double[].class.getName();
+      if (type.getArrayElementType() == Type.int64()) return Long[].class.getName();
+      if (type.getArrayElementType() == Type.string()) return String[].class.getName();
+      if (type.getArrayElementType() == Type.timestamp()) return Timestamp[].class.getName();
+    }
+    return null;
+  }
+
+  /** Standard error message for out-of-range values. */
+  private static final String OUT_OF_RANGE_MSG = "Value out of range for %s: %s";
+
+  /** Cast value and throw {@link SQLException} if out-of-range. */
+  static byte checkedCastToByte(long val) throws SQLException {
+    if (val > Byte.MAX_VALUE || val < Byte.MIN_VALUE) {
+      throw JdbcSqlExceptionFactory.of(
+          String.format(OUT_OF_RANGE_MSG, "byte", val), com.google.rpc.Code.OUT_OF_RANGE);
+    }
+    return (byte) val;
+  }
+
+  /** Cast value and throw {@link SQLException} if out-of-range. */
+  static short checkedCastToShort(long val) throws SQLException {
+    if (val > Short.MAX_VALUE || val < Short.MIN_VALUE) {
+      throw JdbcSqlExceptionFactory.of(
+          String.format(OUT_OF_RANGE_MSG, "short", val), com.google.rpc.Code.OUT_OF_RANGE);
+    }
+    return (short) val;
+  }
+
+  /** Cast value and throw {@link SQLException} if out-of-range. */
+  static int checkedCastToInt(long val) throws SQLException {
+    if (val > Integer.MAX_VALUE || val < Integer.MIN_VALUE) {
+      throw JdbcSqlExceptionFactory.of(
+          String.format(OUT_OF_RANGE_MSG, "int", val), com.google.rpc.Code.OUT_OF_RANGE);
+    }
+    return (int) val;
+  }
+
+  /** Cast value and throw {@link SQLException} if out-of-range. */
+  static float checkedCastToFloat(double val) throws SQLException {
+    if (val > Float.MAX_VALUE || val < -Float.MAX_VALUE) {
+      throw JdbcSqlExceptionFactory.of(
+          String.format(OUT_OF_RANGE_MSG, "float", val), com.google.rpc.Code.OUT_OF_RANGE);
+    }
+    return (float) val;
+  }
+
+  /** Should return true if this object has been closed */
+  public abstract boolean isClosed() throws SQLException;
+
+  /** Throws a {@link SQLException} if this object is closed */
+  void checkClosed() throws SQLException {
+    if (isClosed()) {
+      throw JdbcSqlExceptionFactory.of(
+          "This " + getClass().getName() + " has been closed",
+          com.google.rpc.Code.FAILED_PRECONDITION);
+    }
+  }
+
+  /**
+   * Throws a {@link SQLException} if this object is closed and otherwise a {@link
+   * SQLFeatureNotSupportedException} with the given message
+   */
+  <T> T checkClosedAndThrowUnsupported(String message) throws SQLException {
+    checkClosed();
+    throw JdbcSqlExceptionFactory.unsupported(message);
+  }
+
+  @Override
+  public boolean isWrapperFor(Class<?> iface) throws SQLException {
+    return iface != null && iface.isAssignableFrom(getClass());
+  }
+
+  @Override
+  public <T> T unwrap(Class<T> iface) throws SQLException {
+    if (isWrapperFor(iface)) {
+      return iface.cast(this);
+    }
+    throw JdbcSqlExceptionFactory.of(
+        "Cannot unwrap to " + iface.getName(), com.google.rpc.Code.INVALID_ARGUMENT);
+  }
+}
diff --git a/google-cloud-clients/google-cloud-contrib/google-cloud-spanner-jdbc/src/main/java/com/google/cloud/spanner/jdbc/JdbcArray.java b/google-cloud-clients/google-cloud-contrib/google-cloud-spanner-jdbc/src/main/java/com/google/cloud/spanner/jdbc/JdbcArray.java
index f4066e36821d..67d8e0c15724 100644
--- a/google-cloud-clients/google-cloud-contrib/google-cloud-spanner-jdbc/src/main/java/com/google/cloud/spanner/jdbc/JdbcArray.java
+++ b/google-cloud-clients/google-cloud-contrib/google-cloud-spanner-jdbc/src/main/java/com/google/cloud/spanner/jdbc/JdbcArray.java
@@ -138,7 +138,7 @@ public Object getArray(long index, int count, Map<String, Class<?>> map) throws
   }
 
   private static final String RESULTSET_NOT_SUPPORTED =
-      "Getting a resultset from an array is not supported";
+      "Getting a ResultSet from an array is not supported";
 
   @Override
   public ResultSet getResultSet() throws SQLException {
diff --git a/google-cloud-clients/google-cloud-contrib/google-cloud-spanner-jdbc/src/main/java/com/google/cloud/spanner/jdbc/JdbcClob.java b/google-cloud-clients/google-cloud-contrib/google-cloud-spanner-jdbc/src/main/java/com/google/cloud/spanner/jdbc/JdbcClob.java
index 51040bd8426f..40e26733a562 100644
--- a/google-cloud-clients/google-cloud-contrib/google-cloud-spanner-jdbc/src/main/java/com/google/cloud/spanner/jdbc/JdbcClob.java
+++ b/google-cloud-clients/google-cloud-contrib/google-cloud-spanner-jdbc/src/main/java/com/google/cloud/spanner/jdbc/JdbcClob.java
@@ -102,11 +102,11 @@ public InputStream getAsciiStream() throws SQLException {
   }
 
   @Override
-  public long position(String searchstr, long start) throws SQLException {
+  public long position(String searchStr, long start) throws SQLException {
     JdbcPreconditions.checkArgument(start >= 1, "Start position must be >= 1");
-    JdbcPreconditions.checkArgument(searchstr != null, "searchstr may not be null");
+    JdbcPreconditions.checkArgument(searchStr != null, "searchStr may not be null");
     checkPosition(start);
-    int res = value.indexOf(searchstr, (int) start - 1);
+    int res = value.indexOf(searchStr, (int) start - 1);
     if (res == -1) {
       return res;
     }
@@ -114,12 +114,12 @@ public long position(String searchstr, long start) throws SQLException {
   }
 
   @Override
-  public long position(Clob searchstr, long start) throws SQLException {
+  public long position(Clob searchStr, long start) throws SQLException {
     JdbcPreconditions.checkArgument(start >= 1, "Start position must be >= 1");
-    JdbcPreconditions.checkArgument(searchstr != null, "searchstr may not be null");
+    JdbcPreconditions.checkArgument(searchStr != null, "searchStr may not be null");
     checkPosition(start);
-    checkPositionPlusLength(start, searchstr.length());
-    int res = value.indexOf(searchstr.getSubString(1L, (int) searchstr.length()), (int) start - 1);
+    checkPositionPlusLength(start, searchStr.length());
+    int res = value.indexOf(searchStr.getSubString(1L, (int) searchStr.length()), (int) start - 1);
     if (res == -1) {
       return res;
     }
diff --git a/google-cloud-clients/google-cloud-contrib/google-cloud-spanner-jdbc/src/main/java/com/google/cloud/spanner/jdbc/JdbcResultSet.java b/google-cloud-clients/google-cloud-contrib/google-cloud-spanner-jdbc/src/main/java/com/google/cloud/spanner/jdbc/JdbcResultSet.java
new file mode 100644
index 000000000000..0fd9914f9bf0
--- /dev/null
+++ b/google-cloud-clients/google-cloud-contrib/google-cloud-spanner-jdbc/src/main/java/com/google/cloud/spanner/jdbc/JdbcResultSet.java
@@ -0,0 +1,679 @@
+/*
+ * Copyright 2019 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.cloud.spanner.jdbc;
+
+import java.io.ByteArrayInputStream;
+import java.io.InputStream;
+import java.io.Reader;
+import java.io.StringReader;
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+import java.sql.Array;
+import java.sql.Blob;
+import java.sql.Clob;
+import java.sql.Date;
+import java.sql.NClob;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.sql.Time;
+import java.sql.Timestamp;
+import java.util.Calendar;
+import java.util.List;
+import java.util.Map;
+import com.google.cloud.spanner.Type;
+import com.google.cloud.spanner.Type.Code;
+import com.google.common.base.Preconditions;
+
+/** Implementation of {@link ResultSet} for Cloud Spanner */
+class JdbcResultSet extends AbstractJdbcResultSet {
+
+  static JdbcResultSet of(com.google.cloud.spanner.ResultSet resultSet) {
+    Preconditions.checkNotNull(resultSet);
+    return new JdbcResultSet(null, resultSet);
+  }
+
+  static JdbcResultSet of(Statement statement, com.google.cloud.spanner.ResultSet resultSet) {
+    Preconditions.checkNotNull(statement);
+    Preconditions.checkNotNull(resultSet);
+    return new JdbcResultSet(statement, resultSet);
+  }
+
+  private boolean closed = false;
+  private final Statement statement;
+  private boolean wasNull = false;
+  private boolean nextReturnedFalse = false;
+  private boolean nextCalledForMetaData = false;
+  private boolean nextCalledForMetaDataResult = false;
+  private long currentRow = 0L;
+
+  private JdbcResultSet(Statement statement, com.google.cloud.spanner.ResultSet spanner) {
+    super(spanner);
+    this.statement = statement;
+  }
+
+  void checkClosedAndValidRow() throws SQLException {
+    checkClosed();
+    if (currentRow == 0L) {
+      throw JdbcSqlExceptionFactory.of("ResultSet is before first row. Call next() first.",
+          com.google.rpc.Code.FAILED_PRECONDITION);
+    }
+    if (nextReturnedFalse) {
+      throw JdbcSqlExceptionFactory.of(
+          "ResultSet is after last row. There is no more data available.",
+          com.google.rpc.Code.FAILED_PRECONDITION);
+    }
+  }
+
+  @Override
+  public boolean next() throws SQLException {
+    checkClosed();
+    currentRow++;
+    if (nextCalledForMetaData) {
+      nextReturnedFalse = !nextCalledForMetaDataResult;
+      nextCalledForMetaData = false;
+    } else {
+      nextReturnedFalse = !spanner.next();
+    }
+
+    return !nextReturnedFalse;
+  }
+
+  @Override
+  public void close() throws SQLException {
+    spanner.close();
+    this.closed = true;
+  }
+
+  @Override
+  public boolean wasNull() throws SQLException {
+    checkClosedAndValidRow();
+    return wasNull;
+  }
+
+  private boolean isNull(int columnIndex) {
+    wasNull = spanner.isNull(columnIndex - 1);
+    return wasNull;
+  }
+
+  private boolean isNull(String columnName) {
+    wasNull = spanner.isNull(columnName);
+    return wasNull;
+  }
+
+  @Override
+  public String getString(int columnIndex) throws SQLException {
+    checkClosedAndValidRow();
+    return isNull(columnIndex) ? null : spanner.getString(columnIndex - 1);
+  }
+
+  @Override
+  public boolean getBoolean(int columnIndex) throws SQLException {
+    checkClosedAndValidRow();
+    return isNull(columnIndex) ? false : spanner.getBoolean(columnIndex - 1);
+  }
+
+  @Override
+  public byte getByte(int columnIndex) throws SQLException {
+    checkClosedAndValidRow();
+    long val = isNull(columnIndex) ? 0L : spanner.getLong(columnIndex - 1);
+    return checkedCastToByte(val);
+  }
+
+  @Override
+  public short getShort(int columnIndex) throws SQLException {
+    checkClosedAndValidRow();
+    long val = isNull(columnIndex) ? 0L : spanner.getLong(columnIndex - 1);
+    return checkedCastToShort(val);
+  }
+
+  @Override
+  public int getInt(int columnIndex) throws SQLException {
+    checkClosedAndValidRow();
+    long val = isNull(columnIndex) ? 0L : spanner.getLong(columnIndex - 1);
+    return checkedCastToInt(val);
+  }
+
+  @Override
+  public long getLong(int columnIndex) throws SQLException {
+    checkClosedAndValidRow();
+    return isNull(columnIndex) ? 0L : spanner.getLong(columnIndex - 1);
+  }
+
+  @Override
+  public float getFloat(int columnIndex) throws SQLException {
+    checkClosedAndValidRow();
+    double val = isNull(columnIndex) ? 0D : spanner.getDouble(columnIndex - 1);
+    return checkedCastToFloat(val);
+  }
+
+  @Override
+  public double getDouble(int columnIndex) throws SQLException {
+    checkClosedAndValidRow();
+    return isNull(columnIndex) ? 0D : spanner.getDouble(columnIndex - 1);
+  }
+
+  @Override
+  public byte[] getBytes(int columnIndex) throws SQLException {
+    checkClosedAndValidRow();
+    return isNull(columnIndex) ? null : spanner.getBytes(columnIndex - 1).toByteArray();
+  }
+
+  @Override
+  public Date getDate(int columnIndex) throws SQLException {
+    checkClosedAndValidRow();
+    return isNull(columnIndex) ? null
+        : JdbcTypeConverter.toSqlDate(spanner.getDate(columnIndex - 1));
+  }
+
+  @Override
+  public Time getTime(int columnIndex) throws SQLException {
+    checkClosedAndValidRow();
+    return isNull(columnIndex) ? null
+        : JdbcTypeConverter.toSqlTime(spanner.getTimestamp(columnIndex - 1));
+  }
+
+  @Override
+  public Timestamp getTimestamp(int columnIndex) throws SQLException {
+    checkClosedAndValidRow();
+    return isNull(columnIndex) ? null
+        : JdbcTypeConverter.toSqlTimestamp(spanner.getTimestamp(columnIndex - 1));
+  }
+
+  private InputStream getInputStream(String val, Charset charset) {
+    if (val == null)
+      return null;
+    byte[] b = val.getBytes(charset);
+    return new ByteArrayInputStream(b);
+  }
+
+  @Override
+  public InputStream getAsciiStream(int columnIndex) throws SQLException {
+    checkClosedAndValidRow();
+    return getInputStream(getString(columnIndex), StandardCharsets.US_ASCII);
+  }
+
+  @Override
+  public InputStream getUnicodeStream(int columnIndex) throws SQLException {
+    checkClosedAndValidRow();
+    return getInputStream(getString(columnIndex), StandardCharsets.UTF_16LE);
+  }
+
+  @Override
+  public InputStream getBinaryStream(int columnIndex) throws SQLException {
+    checkClosedAndValidRow();
+    byte[] val = getBytes(columnIndex);
+    return val == null ? null : new ByteArrayInputStream(val);
+  }
+
+  @Override
+  public String getString(String columnLabel) throws SQLException {
+    checkClosedAndValidRow();
+    return isNull(columnLabel) ? null : spanner.getString(columnLabel);
+  }
+
+  @Override
+  public boolean getBoolean(String columnLabel) throws SQLException {
+    checkClosedAndValidRow();
+    return isNull(columnLabel) ? false : spanner.getBoolean(columnLabel);
+  }
+
+  @Override
+  public byte getByte(String columnLabel) throws SQLException {
+    checkClosedAndValidRow();
+    long val = isNull(columnLabel) ? 0L : spanner.getLong(columnLabel);
+    return checkedCastToByte(val);
+  }
+
+  @Override
+  public short getShort(String columnLabel) throws SQLException {
+    checkClosedAndValidRow();
+    long val = isNull(columnLabel) ? 0L : spanner.getLong(columnLabel);
+    return checkedCastToShort(val);
+  }
+
+  @Override
+  public int getInt(String columnLabel) throws SQLException {
+    checkClosedAndValidRow();
+    long val = isNull(columnLabel) ? 0L : spanner.getLong(columnLabel);
+    return checkedCastToInt(val);
+  }
+
+  @Override
+  public long getLong(String columnLabel) throws SQLException {
+    checkClosedAndValidRow();
+    return isNull(columnLabel) ? 0L : spanner.getLong(columnLabel);
+  }
+
+  @Override
+  public float getFloat(String columnLabel) throws SQLException {
+    checkClosedAndValidRow();
+    double val = isNull(columnLabel) ? 0D : spanner.getDouble(columnLabel);
+    return checkedCastToFloat(val);
+  }
+
+  @Override
+  public double getDouble(String columnLabel) throws SQLException {
+    checkClosedAndValidRow();
+    return isNull(columnLabel) ? 0D : spanner.getDouble(columnLabel);
+  }
+
+  @Override
+  public byte[] getBytes(String columnLabel) throws SQLException {
+    checkClosedAndValidRow();
+    return isNull(columnLabel) ? null : spanner.getBytes(columnLabel).toByteArray();
+  }
+
+  @Override
+  public Date getDate(String columnLabel) throws SQLException {
+    checkClosedAndValidRow();
+    return isNull(columnLabel) ? null : JdbcTypeConverter.toSqlDate(spanner.getDate(columnLabel));
+  }
+
+  @Override
+  public Time getTime(String columnLabel) throws SQLException {
+    checkClosedAndValidRow();
+    return isNull(columnLabel) ? null
+        : JdbcTypeConverter.toSqlTime(spanner.getTimestamp(columnLabel));
+  }
+
+  @Override
+  public Timestamp getTimestamp(String columnLabel) throws SQLException {
+    checkClosedAndValidRow();
+    return isNull(columnLabel) ? null
+        : JdbcTypeConverter.toSqlTimestamp(spanner.getTimestamp(columnLabel));
+  }
+
+  @Override
+  public InputStream getAsciiStream(String columnLabel) throws SQLException {
+    checkClosedAndValidRow();
+    return getInputStream(getString(columnLabel), StandardCharsets.US_ASCII);
+  }
+
+  @Override
+  public InputStream getUnicodeStream(String columnLabel) throws SQLException {
+    checkClosedAndValidRow();
+    return getInputStream(getString(columnLabel), StandardCharsets.UTF_16LE);
+  }
+
+  @Override
+  public InputStream getBinaryStream(String columnLabel) throws SQLException {
+    checkClosedAndValidRow();
+    byte[] val = getBytes(columnLabel);
+    return val == null ? null : new ByteArrayInputStream(val);
+  }
+
+  @Override
+  public JdbcResultSetMetaData getMetaData() throws SQLException {
+    checkClosed();
+    if (isBeforeFirst()) {
+      // do a call to next() on the underlying resultset to initialize metadata
+      nextCalledForMetaData = true;
+      nextCalledForMetaDataResult = spanner.next();
+    }
+    return new JdbcResultSetMetaData(this, statement);
+  }
+
+  @Override
+  public Object getObject(String columnLabel) throws SQLException {
+    checkClosedAndValidRow();
+    Type type = spanner.getColumnType(columnLabel);
+    return isNull(columnLabel) ? null : getObject(type, columnLabel);
+  }
+
+  @Override
+  public Object getObject(int columnIndex) throws SQLException {
+    checkClosedAndValidRow();
+    Type type = spanner.getColumnType(columnIndex - 1);
+    return isNull(columnIndex) ? null : getObject(type, columnIndex);
+  }
+
+  private Object getObject(Type type, String columnLabel) throws SQLException {
+    return getObject(type, spanner.getColumnIndex(columnLabel) + 1);
+  }
+
+  private Object getObject(Type type, int columnIndex) throws SQLException {
+    if (type == Type.bool())
+      return getBoolean(columnIndex);
+    if (type == Type.bytes())
+      return getBytes(columnIndex);
+    if (type == Type.date())
+      return getDate(columnIndex);
+    if (type == Type.float64())
+      return getDouble(columnIndex);
+    if (type == Type.int64())
+      return getLong(columnIndex);
+    if (type == Type.string())
+      return getString(columnIndex);
+    if (type == Type.timestamp())
+      return getTimestamp(columnIndex);
+    if (type.getCode() == Code.ARRAY)
+      return getArray(columnIndex);
+    throw JdbcSqlExceptionFactory.of("Unknown type: " + type.toString(),
+        com.google.rpc.Code.INVALID_ARGUMENT);
+  }
+
+  @Override
+  public int findColumn(String columnLabel) throws SQLException {
+    checkClosed();
+    try {
+      return spanner.getColumnIndex(columnLabel) + 1;
+    } catch (IllegalArgumentException e) {
+      throw JdbcSqlExceptionFactory.of("no column with label " + columnLabel + " found",
+          com.google.rpc.Code.INVALID_ARGUMENT);
+    }
+  }
+
+  @Override
+  public Reader getCharacterStream(int columnIndex) throws SQLException {
+    checkClosedAndValidRow();
+    String val = getString(columnIndex);
+    return val == null ? null : new StringReader(val);
+  }
+
+  @Override
+  public Reader getCharacterStream(String columnLabel) throws SQLException {
+    checkClosedAndValidRow();
+    String val = getString(columnLabel);
+    return val == null ? null : new StringReader(val);
+  }
+
+  @Override
+  public BigDecimal getBigDecimal(int columnIndex) throws SQLException {
+    checkClosedAndValidRow();
+    return getBigDecimal(columnIndex, false, 0);
+  }
+
+  @Override
+  public BigDecimal getBigDecimal(String columnLabel) throws SQLException {
+    checkClosedAndValidRow();
+    return getBigDecimal(spanner.getColumnIndex(columnLabel) + 1, false, 0);
+  }
+
+  @Override
+  public BigDecimal getBigDecimal(int columnIndex, int scale) throws SQLException {
+    checkClosedAndValidRow();
+    return getBigDecimal(columnIndex, true, scale);
+  }
+
+  @Override
+  public BigDecimal getBigDecimal(String columnLabel, int scale) throws SQLException {
+    checkClosedAndValidRow();
+    return getBigDecimal(spanner.getColumnIndex(columnLabel) + 1, true, scale);
+  }
+
+  private BigDecimal getBigDecimal(int columnIndex, boolean fixedScale, int scale)
+      throws SQLException {
+    Type type = spanner.getColumnType(columnIndex - 1);
+    BigDecimal res;
+    if (isNull(columnIndex)) {
+      res = null;
+    } else {
+      if (type.getCode() == Code.STRING) {
+        try {
+          res = new BigDecimal(spanner.getString(columnIndex - 1));
+        } catch (NumberFormatException e) {
+          throw JdbcSqlExceptionFactory.of("The column does not contain a valid BigDecimal",
+              com.google.rpc.Code.INVALID_ARGUMENT, e);
+        }
+      } else if (type.getCode() == Code.INT64) {
+        res = BigDecimal.valueOf(spanner.getLong(columnIndex - 1));
+      } else if (type.getCode() == Code.FLOAT64) {
+        res = BigDecimal.valueOf(spanner.getDouble(columnIndex - 1));
+      } else {
+        throw JdbcSqlExceptionFactory.of("The column does not contain a valid BigDecimal",
+            com.google.rpc.Code.INVALID_ARGUMENT);
+      }
+      if (fixedScale) {
+        res = res.setScale(scale, RoundingMode.HALF_UP);
+      }
+    }
+    return res;
+  }
+
+  @Override
+  public boolean isBeforeFirst() throws SQLException {
+    checkClosed();
+    return currentRow == 0L;
+  }
+
+  @Override
+  public boolean isAfterLast() throws SQLException {
+    checkClosed();
+    return nextReturnedFalse;
+  }
+
+  @Override
+  public boolean isFirst() throws SQLException {
+    checkClosed();
+    return currentRow == 1L;
+  }
+
+  @Override
+  public int getRow() throws SQLException {
+    checkClosed();
+    return checkedCastToInt(currentRow);
+  }
+
+  @Override
+  public Statement getStatement() throws SQLException {
+    checkClosed();
+    return statement;
+  }
+
+  @Override
+  public Array getArray(String columnLabel) throws SQLException {
+    checkClosedAndValidRow();
+    return getArray(findColumn(columnLabel));
+  }
+
+  @Override
+  public Array getArray(int columnIndex) throws SQLException {
+    checkClosedAndValidRow();
+    if (isNull(columnIndex))
+      return null;
+    Type type = spanner.getColumnType(columnIndex - 1);
+    if (type.getCode() != Code.ARRAY)
+      throw JdbcSqlExceptionFactory.of(
+          "Column with index " + columnIndex + " does not contain an array",
+          com.google.rpc.Code.INVALID_ARGUMENT);
+    JdbcDataType dataType = JdbcDataType.getType(type.getArrayElementType().getCode());
+    List<? extends Object> elements = dataType.getArrayElements(spanner, columnIndex - 1);
+
+    return JdbcArray.createArray(dataType, elements);
+  }
+
+  @Override
+  public Date getDate(int columnIndex, Calendar cal) throws SQLException {
+    checkClosedAndValidRow();
+    return isNull(columnIndex) ? null
+        : JdbcTypeConverter.toSqlDate(spanner.getDate(columnIndex - 1), cal);
+  }
+
+  @Override
+  public Date getDate(String columnLabel, Calendar cal) throws SQLException {
+    checkClosedAndValidRow();
+    return isNull(columnLabel) ? null
+        : JdbcTypeConverter.toSqlDate(spanner.getDate(columnLabel), cal);
+  }
+
+  @Override
+  public Time getTime(int columnIndex, Calendar cal) throws SQLException {
+    checkClosedAndValidRow();
+    return isNull(columnIndex) ? null
+        : JdbcTypeConverter.toSqlTime(spanner.getTimestamp(columnIndex - 1), cal);
+  }
+
+  @Override
+  public Time getTime(String columnLabel, Calendar cal) throws SQLException {
+    checkClosedAndValidRow();
+    return isNull(columnLabel) ? null
+        : JdbcTypeConverter.toSqlTime(spanner.getTimestamp(columnLabel), cal);
+  }
+
+  @Override
+  public Timestamp getTimestamp(int columnIndex, Calendar cal) throws SQLException {
+    checkClosedAndValidRow();
+    return isNull(columnIndex) ? null
+        : JdbcTypeConverter.getAsSqlTimestamp(spanner.getTimestamp(columnIndex - 1), cal);
+  }
+
+  @Override
+  public Timestamp getTimestamp(String columnLabel, Calendar cal) throws SQLException {
+    checkClosedAndValidRow();
+    return isNull(columnLabel) ? null
+        : JdbcTypeConverter.getAsSqlTimestamp(spanner.getTimestamp(columnLabel), cal);
+  }
+
+  @Override
+  public URL getURL(int columnIndex) throws SQLException {
+    checkClosedAndValidRow();
+    try {
+      return isNull(columnIndex) ? null : new URL(spanner.getString(columnIndex - 1));
+    } catch (MalformedURLException e) {
+      throw JdbcSqlExceptionFactory.of("Invalid URL: " + spanner.getString(columnIndex - 1),
+          com.google.rpc.Code.INVALID_ARGUMENT);
+    }
+  }
+
+  @Override
+  public URL getURL(String columnLabel) throws SQLException {
+    checkClosedAndValidRow();
+    return getURL(findColumn(columnLabel));
+  }
+
+  @Override
+  public int getHoldability() throws SQLException {
+    checkClosed();
+    return CLOSE_CURSORS_AT_COMMIT;
+  }
+
+  @Override
+  public boolean isClosed() throws SQLException {
+    return closed;
+  }
+
+  @Override
+  public String getNString(int columnIndex) throws SQLException {
+    checkClosedAndValidRow();
+    return getString(columnIndex);
+  }
+
+  @Override
+  public String getNString(String columnLabel) throws SQLException {
+    checkClosedAndValidRow();
+    return getString(columnLabel);
+  }
+
+  @Override
+  public Reader getNCharacterStream(int columnIndex) throws SQLException {
+    checkClosedAndValidRow();
+    return getCharacterStream(columnIndex);
+  }
+
+  @Override
+  public Reader getNCharacterStream(String columnLabel) throws SQLException {
+    checkClosedAndValidRow();
+    return getCharacterStream(columnLabel);
+  }
+
+  @Override
+  public <T> T getObject(int columnIndex, Class<T> type) throws SQLException {
+    checkClosedAndValidRow();
+    return convertObject(getObject(columnIndex), type, spanner.getColumnType(columnIndex - 1));
+  }
+
+  @Override
+  public <T> T getObject(String columnLabel, Class<T> type) throws SQLException {
+    checkClosedAndValidRow();
+    return convertObject(getObject(columnLabel), type, spanner.getColumnType(columnLabel));
+  }
+
+  @Override
+  public Object getObject(int columnIndex, Map<String, Class<?>> map) throws SQLException {
+    checkClosedAndValidRow();
+    return convertObject(getObject(columnIndex), map, spanner.getColumnType(columnIndex - 1));
+  }
+
+  @Override
+  public Object getObject(String columnLabel, Map<String, Class<?>> map) throws SQLException {
+    checkClosedAndValidRow();
+    return convertObject(getObject(columnLabel), map, spanner.getColumnType(columnLabel));
+  }
+
+  @Override
+  public Blob getBlob(int columnIndex) throws SQLException {
+    checkClosedAndValidRow();
+    byte[] val = getBytes(columnIndex);
+    return val == null ? null : new JdbcBlob(val);
+  }
+
+  @Override
+  public Blob getBlob(String columnLabel) throws SQLException {
+    checkClosedAndValidRow();
+    byte[] val = getBytes(columnLabel);
+    return val == null ? null : new JdbcBlob(val);
+  }
+
+  @Override
+  public Clob getClob(int columnIndex) throws SQLException {
+    checkClosedAndValidRow();
+    String val = getString(columnIndex);
+    return val == null ? null : new JdbcClob(val);
+  }
+
+  @Override
+  public Clob getClob(String columnLabel) throws SQLException {
+    checkClosedAndValidRow();
+    String val = getString(columnLabel);
+    return val == null ? null : new JdbcClob(val);
+  }
+
+  @Override
+  public NClob getNClob(int columnIndex) throws SQLException {
+    checkClosedAndValidRow();
+    String val = getString(columnIndex);
+    return val == null ? null : new JdbcClob(val);
+  }
+
+  @Override
+  public NClob getNClob(String columnLabel) throws SQLException {
+    checkClosedAndValidRow();
+    String val = getString(columnLabel);
+    return val == null ? null : new JdbcClob(val);
+  }
+
+  @SuppressWarnings("unchecked")
+  private <T> T convertObject(Object o, Class<T> javaType, Type type) throws SQLException {
+    return (T) JdbcTypeConverter.convert(o, type, javaType);
+  }
+
+  private Object convertObject(Object o, Map<String, Class<?>> map, Type type) throws SQLException {
+    if (map == null)
+      throw JdbcSqlExceptionFactory.of("Map may not be null", com.google.rpc.Code.INVALID_ARGUMENT);
+    if (o == null)
+      return null;
+    Class<?> javaType = map.get(type.getCode().name());
+    if (javaType == null)
+      return o;
+    return JdbcTypeConverter.convert(o, type, javaType);
+  }
+}
diff --git a/google-cloud-clients/google-cloud-contrib/google-cloud-spanner-jdbc/src/main/java/com/google/cloud/spanner/jdbc/JdbcResultSetMetaData.java b/google-cloud-clients/google-cloud-contrib/google-cloud-spanner-jdbc/src/main/java/com/google/cloud/spanner/jdbc/JdbcResultSetMetaData.java
new file mode 100644
index 000000000000..a3eb15970e91
--- /dev/null
+++ b/google-cloud-clients/google-cloud-contrib/google-cloud-spanner-jdbc/src/main/java/com/google/cloud/spanner/jdbc/JdbcResultSetMetaData.java
@@ -0,0 +1,212 @@
+/*
+ * Copyright 2019 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.cloud.spanner.jdbc;
+
+import java.sql.ResultSetMetaData;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.sql.Types;
+import com.google.cloud.spanner.ResultSet;
+import com.google.common.base.Preconditions;
+
+/** Implementation of {@link ResultSetMetaData} for Cloud Spanner */
+class JdbcResultSetMetaData extends AbstractJdbcWrapper implements ResultSetMetaData {
+  /**
+   * The default column display size for columns with a data type of variable size that is used
+   * when the actual column size is not known.
+   */
+  private static final int DEFAULT_COL_DISPLAY_SIZE_FOR_VARIABLE_LENGTH_COLS = 50;
+  private final ResultSet spannerResultSet;
+  private final Statement statement;
+
+  JdbcResultSetMetaData(JdbcResultSet jdbcResultSet, Statement statement) {
+    Preconditions.checkNotNull(jdbcResultSet);
+    this.spannerResultSet = jdbcResultSet.spanner;
+    this.statement = statement;
+  }
+
+  @Override
+  public boolean isClosed() {
+    return false;
+  }
+
+  @Override
+  public int getColumnCount() throws SQLException {
+    return spannerResultSet.getColumnCount();
+  }
+
+  @Override
+  public boolean isAutoIncrement(int column) throws SQLException {
+    return false;
+  }
+
+  @Override
+  public boolean isCaseSensitive(int column) throws SQLException {
+    int type = getColumnType(column);
+    return type == Types.NVARCHAR || type == Types.BINARY;
+  }
+
+  @Override
+  public boolean isSearchable(int column) throws SQLException {
+    return true;
+  }
+
+  @Override
+  public boolean isCurrency(int column) throws SQLException {
+    return false;
+  }
+
+  @Override
+  public int isNullable(int column) throws SQLException {
+    return columnNullableUnknown;
+  }
+
+  @Override
+  public boolean isSigned(int column) throws SQLException {
+    int type = getColumnType(column);
+    return type == Types.DOUBLE || type == Types.BIGINT;
+  }
+
+  @Override
+  public int getColumnDisplaySize(int column) throws SQLException {
+    int colType = getColumnType(column);
+    switch (colType) {
+      case Types.ARRAY:
+        return DEFAULT_COL_DISPLAY_SIZE_FOR_VARIABLE_LENGTH_COLS;
+      case Types.BOOLEAN:
+        return 5;
+      case Types.BINARY:
+        int binaryLength = getPrecision(column);
+        return binaryLength == 0 ? DEFAULT_COL_DISPLAY_SIZE_FOR_VARIABLE_LENGTH_COLS : binaryLength;
+      case Types.DATE:
+        return 10;
+      case Types.DOUBLE:
+        return 14;
+      case Types.BIGINT:
+        return 10;
+      case Types.NVARCHAR:
+        int length = getPrecision(column);
+        return length == 0 ? DEFAULT_COL_DISPLAY_SIZE_FOR_VARIABLE_LENGTH_COLS : length;
+      case Types.TIMESTAMP:
+        return 16;
+      default:
+        return 10;
+    }
+  }
+
+  @Override
+  public String getColumnLabel(int column) throws SQLException {
+    return spannerResultSet.getType().getStructFields().get(column - 1).getName();
+  }
+
+  @Override
+  public String getColumnName(int column) throws SQLException {
+    return spannerResultSet.getType().getStructFields().get(column - 1).getName();
+  }
+
+  @Override
+  public String getSchemaName(int column) throws SQLException {
+    return statement.getConnection().getSchema();
+  }
+
+  @Override
+  public int getPrecision(int column) throws SQLException {
+    int colType = getColumnType(column);
+    switch (colType) {
+      case Types.BOOLEAN:
+        return 1;
+      case Types.DATE:
+        return 10;
+      case Types.DOUBLE:
+        return 14;
+      case Types.BIGINT:
+        return 10;
+      case Types.TIMESTAMP:
+        return 24;
+      default:
+        // For column types with variable size, such as text columns, we should return the length
+        // in characters. We could try to fetch it from INFORMATION_SCHEMA, but that would mean
+        // parsing the SQL statement client side in order to figure out which column it actually
+        // is. For now we just return the default column display size.
+        return DEFAULT_COL_DISPLAY_SIZE_FOR_VARIABLE_LENGTH_COLS;
+    }
+  }
+
+  @Override
+  public int getScale(int column) throws SQLException {
+    int colType = getColumnType(column);
+    if (colType == Types.DOUBLE)
+      return 15;
+    return 0;
+  }
+
+  @Override
+  public String getTableName(int column) throws SQLException {
+    return "";
+  }
+
+  @Override
+  public String getCatalogName(int column) throws SQLException {
+    return statement.getConnection().getCatalog();
+  }
+
+  @Override
+  public int getColumnType(int column) throws SQLException {
+    return extractColumnType(spannerResultSet.getColumnType(column - 1));
+  }
+
+  @Override
+  public String getColumnTypeName(int column) throws SQLException {
+    return spannerResultSet.getColumnType(column - 1).getCode().name();
+  }
+
+  @Override
+  public boolean isReadOnly(int column) throws SQLException {
+    return false;
+  }
+
+  @Override
+  public boolean isWritable(int column) throws SQLException {
+    return !isReadOnly(column);
+  }
+
+  @Override
+  public boolean isDefinitelyWritable(int column) throws SQLException {
+    return false;
+  }
+
+  @Override
+  public String getColumnClassName(int column) throws SQLException {
+    return getClassName(spannerResultSet.getColumnType(column - 1));
+  }
+
+  @Override
+  public String toString() {
+    StringBuilder res = new StringBuilder();
+    try {
+      for (int col = 1; col <= getColumnCount(); col++) {
+        res.append("Col ").append(col).append(": ");
+        res.append(getColumnName(col)).append(" ").append(getColumnTypeName(col));
+        res.append("\n");
+      }
+    } catch (SQLException e) {
+      return "An error occurred while generating string: " + e.getMessage();
+    }
+    return res.toString();
+  }
+
+}
diff --git a/google-cloud-clients/google-cloud-contrib/google-cloud-spanner-jdbc/src/test/java/com/google/cloud/spanner/jdbc/AbstractJdbcWrapperTest.java b/google-cloud-clients/google-cloud-contrib/google-cloud-spanner-jdbc/src/test/java/com/google/cloud/spanner/jdbc/AbstractJdbcWrapperTest.java
new file mode 100644
index 000000000000..1a391dfb24f5
--- /dev/null
+++ b/google-cloud-clients/google-cloud-contrib/google-cloud-spanner-jdbc/src/test/java/com/google/cloud/spanner/jdbc/AbstractJdbcWrapperTest.java
@@ -0,0 +1,179 @@
+/*
+ * Copyright 2019 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 java.sql.SQLException;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public class AbstractJdbcWrapperTest {
+  /** Create a concrete sub class to use for testing. */
+  private static class TestWrapper extends AbstractJdbcWrapper {
+    @Override
+    public boolean isClosed() throws SQLException {
+      return false;
+    }
+  }
+
+  /** Add a sub class for the test class for testing wrapping. */
+  private static class SubTestWrapper extends TestWrapper {}
+
+  @Test
+  public void testIsWrapperFor() throws SQLException {
+    TestWrapper subject = new TestWrapper();
+    assertThat(subject.isWrapperFor(TestWrapper.class)).isTrue();
+    assertThat(subject.isWrapperFor(SubTestWrapper.class)).isFalse();
+    assertThat(subject.isWrapperFor(Object.class)).isTrue();
+    assertThat(subject.isWrapperFor(getClass())).isFalse();
+
+    subject = new SubTestWrapper();
+    assertThat(subject.isWrapperFor(TestWrapper.class)).isTrue();
+    assertThat(subject.isWrapperFor(SubTestWrapper.class)).isTrue();
+    assertThat(subject.isWrapperFor(Object.class)).isTrue();
+    assertThat(subject.isWrapperFor(getClass())).isFalse();
+  }
+
+  @Test
+  public void testUnwrap() throws SQLException {
+    TestWrapper subject = new TestWrapper();
+    assertThat(unwrapSucceeds(subject, TestWrapper.class)).isTrue();
+    assertThat(unwrapSucceeds(subject, SubTestWrapper.class)).isFalse();
+    assertThat(unwrapSucceeds(subject, Object.class)).isTrue();
+    assertThat(unwrapSucceeds(subject, getClass())).isFalse();
+  }
+
+  private static interface CheckedCastChecker<V> {
+    boolean cast(V val);
+  }
+
+  private static final class CheckedCastToByteChecker implements CheckedCastChecker<Long> {
+    @Override
+    public boolean cast(Long val) {
+      try {
+        AbstractJdbcWrapper.checkedCastToByte(val);
+        return true;
+      } catch (SQLException e) {
+        return false;
+      }
+    }
+  }
+
+  @Test
+  public void testCheckedCastToByte() throws SQLException {
+    CheckedCastToByteChecker checker = new CheckedCastToByteChecker();
+    assertThat(checker.cast(0L)).isTrue();
+    assertThat(checker.cast(1L)).isTrue();
+    assertThat(checker.cast(Long.valueOf(Byte.MAX_VALUE))).isTrue();
+    assertThat(checker.cast(Long.valueOf(Byte.MAX_VALUE) + 1L)).isFalse();
+    assertThat(checker.cast(Long.MAX_VALUE)).isFalse();
+    assertThat(checker.cast(-1L)).isTrue();
+    assertThat(checker.cast(Long.valueOf(Byte.MIN_VALUE))).isTrue();
+    assertThat(checker.cast(Long.valueOf(Byte.MIN_VALUE) - 1L)).isFalse();
+    assertThat(checker.cast(Long.MIN_VALUE)).isFalse();
+  }
+
+  private static final class CheckedCastToShortChecker implements CheckedCastChecker<Long> {
+    @Override
+    public boolean cast(Long val) {
+      try {
+        AbstractJdbcWrapper.checkedCastToShort(val);
+        return true;
+      } catch (SQLException e) {
+        return false;
+      }
+    }
+  }
+
+  @Test
+  public void testCheckedCastToShort() throws SQLException {
+    CheckedCastToShortChecker checker = new CheckedCastToShortChecker();
+    assertThat(checker.cast(0L)).isTrue();
+    assertThat(checker.cast(1L)).isTrue();
+    assertThat(checker.cast(Long.valueOf(Short.MAX_VALUE))).isTrue();
+    assertThat(checker.cast(Long.valueOf(Short.MAX_VALUE) + 1L)).isFalse();
+    assertThat(checker.cast(Long.MAX_VALUE)).isFalse();
+    assertThat(checker.cast(-1L)).isTrue();
+    assertThat(checker.cast(Long.valueOf(Short.MIN_VALUE))).isTrue();
+    assertThat(checker.cast(Long.valueOf(Short.MIN_VALUE) - 1L)).isFalse();
+    assertThat(checker.cast(Long.MIN_VALUE)).isFalse();
+  }
+
+  private static final class CheckedCastToIntChecker implements CheckedCastChecker<Long> {
+    @Override
+    public boolean cast(Long val) {
+      try {
+        AbstractJdbcWrapper.checkedCastToInt(val);
+        return true;
+      } catch (SQLException e) {
+        return false;
+      }
+    }
+  }
+
+  @Test
+  public void testCheckedCastToInt() throws SQLException {
+    CheckedCastToIntChecker checker = new CheckedCastToIntChecker();
+    assertThat(checker.cast(0L)).isTrue();
+    assertThat(checker.cast(1L)).isTrue();
+    assertThat(checker.cast(Long.valueOf(Integer.MAX_VALUE))).isTrue();
+    assertThat(checker.cast(Long.valueOf(Integer.MAX_VALUE) + 1L)).isFalse();
+    assertThat(checker.cast(Long.MAX_VALUE)).isFalse();
+    assertThat(checker.cast(-1L)).isTrue();
+    assertThat(checker.cast(Long.valueOf(Integer.MIN_VALUE))).isTrue();
+    assertThat(checker.cast(Long.valueOf(Integer.MIN_VALUE) - 1L)).isFalse();
+    assertThat(checker.cast(Long.MIN_VALUE)).isFalse();
+  }
+
+  private static final class CheckedCastToFloatChecker implements CheckedCastChecker<Double> {
+    @Override
+    public boolean cast(Double val) {
+      try {
+        AbstractJdbcWrapper.checkedCastToFloat(val);
+        return true;
+      } catch (SQLException e) {
+        return false;
+      }
+    }
+  }
+
+  @Test
+  public void testCheckedCastToFloat() throws SQLException {
+    CheckedCastToFloatChecker checker = new CheckedCastToFloatChecker();
+    assertThat(checker.cast(0D)).isTrue();
+    assertThat(checker.cast(1D)).isTrue();
+    assertThat(checker.cast(Double.valueOf(Float.MAX_VALUE))).isTrue();
+    assertThat(checker.cast(Double.valueOf(Float.MAX_VALUE) * 2.0D)).isFalse();
+    assertThat(checker.cast(Double.MAX_VALUE)).isFalse();
+    assertThat(checker.cast(-1D)).isTrue();
+    assertThat(checker.cast(Double.valueOf(Float.MIN_VALUE))).isTrue();
+    assertThat(checker.cast(Double.valueOf(-Float.MAX_VALUE * 2))).isFalse();
+    assertThat(checker.cast(-Double.MAX_VALUE)).isFalse();
+  }
+
+  private boolean unwrapSucceeds(AbstractJdbcWrapper subject, Class<?> iface) {
+    try {
+      subject.unwrap(iface);
+      return true;
+    } catch (SQLException e) {
+      return false;
+    }
+  }
+}
diff --git a/google-cloud-clients/google-cloud-contrib/google-cloud-spanner-jdbc/src/test/java/com/google/cloud/spanner/jdbc/JdbcResultSetMetaDataTest.java b/google-cloud-clients/google-cloud-contrib/google-cloud-spanner-jdbc/src/test/java/com/google/cloud/spanner/jdbc/JdbcResultSetMetaDataTest.java
new file mode 100644
index 000000000000..e97382d0a0b4
--- /dev/null
+++ b/google-cloud-clients/google-cloud-contrib/google-cloud-spanner-jdbc/src/test/java/com/google/cloud/spanner/jdbc/JdbcResultSetMetaDataTest.java
@@ -0,0 +1,483 @@
+/*
+ * Copyright 2019 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.cloud.spanner.jdbc;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import com.google.cloud.ByteArray;
+import com.google.cloud.spanner.ResultSet;
+import com.google.cloud.spanner.ResultSets;
+import com.google.cloud.spanner.Struct;
+import com.google.cloud.spanner.Type;
+import com.google.cloud.spanner.Type.Code;
+import com.google.cloud.spanner.Type.StructField;
+import com.google.cloud.spanner.Value;
+import com.google.common.base.Preconditions;
+import java.sql.Date;
+import java.sql.ResultSetMetaData;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.sql.Timestamp;
+import java.sql.Types;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+import org.mockito.internal.stubbing.answers.Returns;
+
+@RunWith(JUnit4.class)
+public class JdbcResultSetMetaDataTest {
+  private JdbcResultSetMetaData subject;
+  private java.sql.Connection connection;
+
+  private static class TestColumn {
+    private final Type type;
+    private final String name;
+    private final int defaultSize;
+    private final boolean calculated;
+
+    private TestColumn(Type type, String name, Integer nulls, int size, boolean calculated) {
+      Preconditions.checkNotNull(type);
+      Preconditions.checkNotNull(name);
+      Preconditions.checkNotNull(nulls);
+      this.type = type;
+      this.name = name;
+      this.defaultSize = getDefaultSize(type);
+      this.calculated = calculated;
+    }
+
+    private static int getDefaultSize(Type type) {
+      if (type == Type.bool()) return 1;
+      if (type == Type.date()) return 10;
+      if (type == Type.float64()) return 14;
+      if (type == Type.int64()) return 10;
+      if (type == Type.timestamp()) return 24;
+      if (type == Type.string()) return 50;
+      if (type == Type.bytes()) return 50;
+      return 50;
+    }
+
+    private boolean isTableColumn() {
+      return !calculated;
+    }
+
+    private static class Builder {
+      private Type type;
+      private String name;
+      private Integer nulls;
+      private int size = 0;
+      private boolean calculated = false;
+
+      public static Builder getBuilder() {
+        return new Builder();
+      }
+
+      private TestColumn build() {
+        return new TestColumn(type, name, nulls, size, calculated);
+      }
+
+      private Builder withType(Type type) {
+        this.type = type;
+        return this;
+      }
+
+      private Builder withName(String name) {
+        this.name = name;
+        return this;
+      }
+
+      private Builder withNotNull() {
+        this.nulls = ResultSetMetaData.columnNoNulls;
+        return this;
+      }
+
+      private Builder withNullable() {
+        this.nulls = ResultSetMetaData.columnNullable;
+        return this;
+      }
+
+      private Builder withNullableUnknown() {
+        this.nulls = ResultSetMetaData.columnNullableUnknown;
+        return this;
+      }
+
+      private Builder withSize(int size) {
+        this.size = size;
+        return this;
+      }
+
+      private Builder withCalculated(boolean calculated) {
+        this.calculated = calculated;
+        return this;
+      }
+    }
+  }
+
+  private static final List<TestColumn> TEST_COLUMNS = createTestColumns();
+
+  @Before
+  public void setup() throws SQLException {
+    connection = mock(java.sql.Connection.class);
+    Statement statement = mock(Statement.class);
+    JdbcResultSet resultSet = getFooTestResultSet(statement);
+    when(connection.getSchema()).thenReturn("");
+    when(connection.getCatalog()).thenReturn("test-database");
+    when(statement.getConnection()).then(new Returns(connection));
+
+    subject = resultSet.getMetaData();
+  }
+
+  private static List<TestColumn> createTestColumns() {
+    List<TestColumn> res = new ArrayList<>();
+    int index = 1;
+    for (Type type : getAllTypes()) {
+      TestColumn.Builder builder = TestColumn.Builder.getBuilder();
+      builder.withName("COL" + index).withType(type).withSize(getDefaultSize(type));
+      if (index % 2 == 1) builder.withNotNull();
+      else builder.withNullable();
+      res.add(builder.build());
+      index++;
+    }
+    TestColumn.Builder builder = TestColumn.Builder.getBuilder();
+    builder
+        .withName("CALCULATED")
+        .withType(Type.int64())
+        .withNullableUnknown()
+        .withCalculated(true);
+    res.add(builder.build());
+    return res;
+  }
+
+  private static int getDefaultSize(Type type) {
+    if (type == Type.string()) return 100;
+    return 0;
+  }
+
+  private static List<Type> getAllTypes() {
+    List<Type> types = new ArrayList<>();
+    types.add(Type.bool());
+    types.add(Type.bytes());
+    types.add(Type.date());
+    types.add(Type.float64());
+    types.add(Type.int64());
+    types.add(Type.string());
+    types.add(Type.timestamp());
+    List<Type> arrayTypes = new ArrayList<>();
+    for (Type type : types) {
+      arrayTypes.add(Type.array(type));
+    }
+    types.addAll(arrayTypes);
+
+    return types;
+  }
+
+  private JdbcResultSet getFooTestResultSet(Statement statement) throws SQLException {
+    List<Struct> rows = new ArrayList<>(4);
+    for (int row = 1; row <= 4; row++) {
+      Struct.Builder builder = Struct.newBuilder();
+      for (TestColumn col : TEST_COLUMNS) {
+        builder.set(col.name).to(getDefaultValue(col.type, row));
+      }
+      rows.add(builder.build());
+    }
+    StructField[] fields = new StructField[TEST_COLUMNS.size()];
+    int index = 0;
+    for (TestColumn col : TEST_COLUMNS) {
+      fields[index] = StructField.of(col.name, col.type);
+      index++;
+    }
+
+    ResultSet rs = ResultSets.forRows(Type.struct(fields), rows);
+    return JdbcResultSet.of(statement, rs);
+  }
+
+  private Value getDefaultValue(Type type, int row) {
+    if (type == Type.bool()) return Value.bool(Boolean.TRUE);
+    if (type == Type.bytes()) return Value.bytes(ByteArray.copyFrom("test byte array " + row));
+    if (type == Type.date()) return Value.date(com.google.cloud.Date.fromYearMonthDay(2018, 4, 1));
+    if (type == Type.float64()) return Value.float64(123.45D);
+    if (type == Type.int64()) return Value.int64(12345L);
+    if (type == Type.string()) return Value.string("test value " + row);
+    if (type == Type.timestamp()) return Value.timestamp(com.google.cloud.Timestamp.now());
+
+    if (type.getCode() == Code.ARRAY) {
+      if (type.getArrayElementType() == Type.bool())
+        return Value.boolArray(Arrays.asList(Boolean.TRUE, Boolean.FALSE));
+      if (type.getArrayElementType() == Type.bytes())
+        return Value.bytesArray(
+            Arrays.asList(
+                ByteArray.copyFrom("test byte array " + row),
+                ByteArray.copyFrom("test byte array " + row)));
+      if (type.getArrayElementType() == Type.date())
+        return Value.dateArray(
+            Arrays.asList(
+                com.google.cloud.Date.fromYearMonthDay(2018, 4, 1),
+                com.google.cloud.Date.fromYearMonthDay(2018, 4, 2)));
+      if (type.getArrayElementType() == Type.float64())
+        return Value.float64Array(Arrays.asList(123.45D, 543.21D));
+      if (type.getArrayElementType() == Type.int64())
+        return Value.int64Array(Arrays.asList(12345L, 54321L));
+      if (type.getArrayElementType() == Type.string())
+        return Value.stringArray(Arrays.asList("test value " + row, "test value " + row));
+      if (type.getArrayElementType() == Type.timestamp())
+        return Value.timestampArray(
+            Arrays.asList(com.google.cloud.Timestamp.now(), com.google.cloud.Timestamp.now()));
+    }
+    return null;
+  }
+
+  @Test
+  public void testGetColumnCount() throws SQLException {
+    assertEquals(TEST_COLUMNS.size(), subject.getColumnCount());
+  }
+
+  @Test
+  public void testIsAutoIncrement() throws SQLException {
+    for (int i = 1; i <= TEST_COLUMNS.size(); i++) {
+      assertEquals(false, subject.isAutoIncrement(i));
+    }
+  }
+
+  @Test
+  public void testIsCaseSensitive() throws SQLException {
+    for (int i = 1; i <= TEST_COLUMNS.size(); i++) {
+      Type type = TEST_COLUMNS.get(i - 1).type;
+      assertEquals(type == Type.string() || type == Type.bytes(), subject.isCaseSensitive(i));
+    }
+  }
+
+  @Test
+  public void testIsSearchable() throws SQLException {
+    for (int i = 1; i <= TEST_COLUMNS.size(); i++) {
+      assertEquals(true, subject.isSearchable(i));
+    }
+  }
+
+  @Test
+  public void testIsCurrency() throws SQLException {
+    for (int i = 1; i <= TEST_COLUMNS.size(); i++) {
+      assertEquals(false, subject.isCurrency(i));
+    }
+  }
+
+  @Test
+  public void testIsNullable() throws SQLException {
+    for (int i = 1; i <= TEST_COLUMNS.size(); i++) {
+      assertEquals(ResultSetMetaData.columnNullableUnknown, subject.isNullable(i));
+    }
+  }
+
+  @Test
+  public void testIsSigned() throws SQLException {
+    for (int i = 1; i <= TEST_COLUMNS.size(); i++) {
+      Type type = TEST_COLUMNS.get(i - 1).type;
+      if (type == Type.int64() || type == Type.float64()) {
+        assertTrue(subject.isSigned(i));
+      } else {
+        assertFalse(subject.isSigned(i));
+      }
+    }
+  }
+
+  @Test
+  public void testGetColumnDisplaySize() throws SQLException {
+    for (int i = 1; i <= TEST_COLUMNS.size(); i++) {
+      assertEquals(
+          getDefaultDisplaySize(TEST_COLUMNS.get(i - 1).type, i), subject.getColumnDisplaySize(i));
+    }
+  }
+
+  private int getDefaultDisplaySize(Type type, int column) throws SQLException {
+    if (type.getCode() == Code.ARRAY) return 50;
+    if (type == Type.bool()) return 5;
+    if (type == Type.bytes()) return 50;
+    if (type == Type.date()) return 10;
+    if (type == Type.float64()) return 14;
+    if (type == Type.int64()) return 10;
+    if (type == Type.string()) {
+      int length = subject.getPrecision(column);
+      return length == 0 ? 50 : length;
+    }
+    if (type == Type.timestamp()) return 16;
+    return 10;
+  }
+
+  @Test
+  public void testGetColumnLabel() throws SQLException {
+    for (int i = 1; i <= TEST_COLUMNS.size(); i++) {
+      assertEquals(TEST_COLUMNS.get(i - 1).name, subject.getColumnLabel(i));
+    }
+  }
+
+  @Test
+  public void testGetColumnName() throws SQLException {
+    for (int i = 1; i <= TEST_COLUMNS.size(); i++) {
+      assertEquals(TEST_COLUMNS.get(i - 1).name, subject.getColumnName(i));
+    }
+  }
+
+  @Test
+  public void testGetSchemaName() throws SQLException {
+    assertEquals("", subject.getSchemaName(1));
+  }
+
+  @Test
+  public void testGetPrecision() throws SQLException {
+    for (int i = 1; i <= TEST_COLUMNS.size(); i++) {
+      assertEquals(getPrecision(TEST_COLUMNS.get(i - 1)), subject.getPrecision(i));
+    }
+  }
+
+  private int getPrecision(TestColumn col) {
+    if (col.type == Type.bool()) return 1;
+    if (col.type == Type.date()) return 10;
+    if (col.type == Type.float64()) return 14;
+    if (col.type == Type.int64()) return 10;
+    if (col.type == Type.timestamp()) return 24;
+    if (col.isTableColumn()) return col.defaultSize;
+    return 50;
+  }
+
+  @Test
+  public void testGetScale() throws SQLException {
+    for (int i = 1; i <= TEST_COLUMNS.size(); i++) {
+      assertEquals(getScale(TEST_COLUMNS.get(i - 1)), subject.getScale(i));
+    }
+  }
+
+  private int getScale(TestColumn col) {
+    if (col.type == Type.float64()) return 15;
+    return 0;
+  }
+
+  @Test
+  public void testGetTableName() throws SQLException {
+    for (int i = 1; i <= TEST_COLUMNS.size(); i++) {
+      assertEquals("", subject.getTableName(i));
+    }
+  }
+
+  @Test
+  public void testGetCatalogName() throws SQLException {
+    assertEquals("test-database", subject.getCatalogName(1));
+  }
+
+  @Test
+  public void testGetColumnType() throws SQLException {
+    for (int i = 1; i <= TEST_COLUMNS.size(); i++) {
+      assertEquals(getSqlType(TEST_COLUMNS.get(i - 1).type), subject.getColumnType(i));
+    }
+  }
+
+  private int getSqlType(Type type) {
+    if (type == Type.bool()) return Types.BOOLEAN;
+    if (type == Type.bytes()) return Types.BINARY;
+    if (type == Type.date()) return Types.DATE;
+    if (type == Type.float64()) return Types.DOUBLE;
+    if (type == Type.int64()) return Types.BIGINT;
+    if (type == Type.string()) return Types.NVARCHAR;
+    if (type == Type.timestamp()) return Types.TIMESTAMP;
+    if (type.getCode() == Code.ARRAY) return Types.ARRAY;
+    return Types.OTHER;
+  }
+
+  @Test
+  public void getColumnTypeName() throws SQLException {
+    int index = 1;
+    for (TestColumn col : TEST_COLUMNS) {
+      assertEquals(col.type.getCode().name(), subject.getColumnTypeName(index));
+      index++;
+    }
+  }
+
+  @Test
+  public void testIsReadOnly() throws SQLException {
+    for (int i = 0; i < TEST_COLUMNS.size(); i++) {
+      assertFalse(subject.isReadOnly(i));
+    }
+  }
+
+  @Test
+  public void testIsWritable() throws SQLException {
+    for (int i = 0; i < TEST_COLUMNS.size(); i++) {
+      assertTrue(subject.isWritable(i));
+    }
+  }
+
+  @Test
+  public void testIsDefinitelyWritable() throws SQLException {
+    for (int i = 0; i < TEST_COLUMNS.size(); i++) {
+      assertFalse(subject.isDefinitelyWritable(i));
+    }
+  }
+
+  @Test
+  public void testGetColumnClassName() throws SQLException {
+    for (int i = 1; i <= TEST_COLUMNS.size(); i++) {
+      assertEquals(getTypeClassName(TEST_COLUMNS.get(i - 1).type), subject.getColumnClassName(i));
+    }
+  }
+
+  private String getTypeClassName(Type type) {
+    if (type == Type.bool()) return Boolean.class.getName();
+    if (type == Type.bytes()) return byte[].class.getName();
+    if (type == Type.date()) return Date.class.getName();
+    if (type == Type.float64()) return Double.class.getName();
+    if (type == Type.int64()) return Long.class.getName();
+    if (type == Type.string()) return String.class.getName();
+    if (type == Type.timestamp()) return Timestamp.class.getName();
+    if (type.getCode() == Code.ARRAY) {
+      if (type.getArrayElementType() == Type.bool()) return Boolean[].class.getName();
+      if (type.getArrayElementType() == Type.bytes()) return byte[][].class.getName();
+      if (type.getArrayElementType() == Type.date()) return Date[].class.getName();
+      if (type.getArrayElementType() == Type.float64()) return Double[].class.getName();
+      if (type.getArrayElementType() == Type.int64()) return Long[].class.getName();
+      if (type.getArrayElementType() == Type.string()) return String[].class.getName();
+      if (type.getArrayElementType() == Type.timestamp()) return Timestamp[].class.getName();
+    }
+    return null;
+  }
+
+  private static final String EXPECTED_TO_STRING =
+      "Col 1: COL1 BOOL\n"
+          + "Col 2: COL2 BYTES\n"
+          + "Col 3: COL3 DATE\n"
+          + "Col 4: COL4 FLOAT64\n"
+          + "Col 5: COL5 INT64\n"
+          + "Col 6: COL6 STRING\n"
+          + "Col 7: COL7 TIMESTAMP\n"
+          + "Col 8: COL8 ARRAY\n"
+          + "Col 9: COL9 ARRAY\n"
+          + "Col 10: COL10 ARRAY\n"
+          + "Col 11: COL11 ARRAY\n"
+          + "Col 12: COL12 ARRAY\n"
+          + "Col 13: COL13 ARRAY\n"
+          + "Col 14: COL14 ARRAY\n"
+          + "Col 15: CALCULATED INT64\n";
+
+  @Test
+  public void testToString() {
+    assertEquals(subject.toString(), EXPECTED_TO_STRING);
+  }
+}
diff --git a/google-cloud-clients/google-cloud-contrib/google-cloud-spanner-jdbc/src/test/java/com/google/cloud/spanner/jdbc/JdbcResultSetTest.java b/google-cloud-clients/google-cloud-contrib/google-cloud-spanner-jdbc/src/test/java/com/google/cloud/spanner/jdbc/JdbcResultSetTest.java
new file mode 100644
index 000000000000..6e808c8b254b
--- /dev/null
+++ b/google-cloud-clients/google-cloud-contrib/google-cloud-spanner-jdbc/src/test/java/com/google/cloud/spanner/jdbc/JdbcResultSetTest.java
@@ -0,0 +1,1099 @@
+/*
+ * Copyright 2019 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.cloud.spanner.jdbc;
+
+import static org.junit.Assert.assertArrayEquals;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.Reader;
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.sql.Time;
+import java.util.Arrays;
+import java.util.Calendar;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.TimeZone;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+import org.mockito.internal.stubbing.answers.Returns;
+import com.google.cloud.ByteArray;
+import com.google.cloud.Date;
+import com.google.cloud.Timestamp;
+import com.google.cloud.spanner.ResultSet;
+import com.google.cloud.spanner.ResultSets;
+import com.google.cloud.spanner.Struct;
+import com.google.cloud.spanner.Type;
+import com.google.cloud.spanner.Type.StructField;
+import com.google.cloud.spanner.jdbc.JdbcSqlExceptionFactory.JdbcSqlExceptionImpl;
+
+@RunWith(JUnit4.class)
+public class JdbcResultSetTest {
+  static final String UNKNOWN_COLUMN = "UNKNOWN_COLUMN";
+  static final String STRING_COL_NULL = "STRING_COL_NULL";
+  static final String STRING_COL_NOT_NULL = "STRING_COL_NOT_NULL";
+  static final String STRING_VALUE = "FOO";
+  static final int STRING_COLINDEX_NULL = 1;
+  static final int STRING_COLINDEX_NOTNULL = 2;
+  static final String BOOLEAN_COL_NULL = "BOOLEAN_COL_NULL";
+  static final String BOOLEAN_COL_NOT_NULL = "BOOLEAN_COL_NOT_NULL";
+  static final boolean BOOLEAN_VALUE = true;
+  static final int BOOLEAN_COLINDEX_NULL = 3;
+  static final int BOOLEAN_COLINDEX_NOTNULL = 4;
+  static final String DOUBLE_COL_NULL = "DOUBLE_COL_NULL";
+  static final String DOUBLE_COL_NOT_NULL = "DOUBLE_COL_NOT_NULL";
+  static final double DOUBLE_VALUE = 3.14159265359D;
+  static final int DOUBLE_COLINDEX_NULL = 5;
+  static final int DOUBLE_COLINDEX_NOTNULL = 6;
+  static final String BYTES_COL_NULL = "BYTES_COL_NULL";
+  static final String BYTES_COL_NOT_NULL = "BYTES_COL_NOT_NULL";
+  static final ByteArray BYTES_VALUE = ByteArray.copyFrom("FOO");
+  static final int BYTES_COLINDEX_NULL = 7;
+  static final int BYTES_COLINDEX_NOTNULL = 8;
+  static final String LONG_COL_NULL = "LONG_COL_NULL";
+  static final String LONG_COL_NOT_NULL = "LONG_COL_NOT_NULL";
+  static final long LONG_VALUE = 1L;
+  static final int LONG_COLINDEX_NULL = 9;
+  static final int LONG_COLINDEX_NOTNULL = 10;
+  static final String DATE_COL_NULL = "DATE_COL_NULL";
+  static final String DATE_COL_NOT_NULL = "DATE_COL_NOT_NULL";
+  static final Date DATE_VALUE = Date.fromYearMonthDay(2019, 1, 18);
+  static final int DATE_COLINDEX_NULL = 11;
+  static final int DATE_COLINDEX_NOTNULL = 12;
+  static final String TIMESTAMP_COL_NULL = "TIMESTAMP_COL_NULL";
+  static final String TIMESTAMP_COL_NOT_NULL = "TIMESTAMP_COL_NOT_NULL";
+  static final Timestamp TIMESTAMP_VALUE = Timestamp.parseTimestamp("2019-01-18T10:00:01.1213Z");
+  static final int TIMESTAMP_COLINDEX_NULL = 13;
+  static final int TIMESTAMP_COLINDEX_NOTNULL = 14;
+  static final String TIME_COL_NULL = "TIME_COL_NULL";
+  static final String TIME_COL_NOT_NULL = "TIME_COL_NOT_NULL";
+  static final Timestamp TIME_VALUE = Timestamp.parseTimestamp("1970-01-01T10:01:02.995Z");
+  static final int TIME_COLINDEX_NULL = 15;
+  static final int TIME_COLINDEX_NOTNULL = 16;
+  static final String ARRAY_COL_NULL = "ARRAY_COL_NULL";
+  static final String ARRAY_COL_NOT_NULL = "ARRAY_COL_NOT_NULL";
+  static final long[] ARRAY_VALUE = new long[] {1L, 2L, 3L};
+  static final int ARRAY_COLINDEX_NULL = 17;
+  static final int ARRAY_COLINDEX_NOTNULL = 18;
+  static final String URL_COL_NULL = "URL_COL_NULL";
+  static final String URL_COL_NOT_NULL = "URL_COL_NOT_NULL";
+  static final String URL_VALUE = "https://cloud.google.com/spanner/docs/apis";
+  static final int URL_COLINDEX_NULL = 19;
+  static final int URL_COLINDEX_NOTNULL = 20;
+
+  @Rule
+  public ExpectedException thrown = ExpectedException.none();
+
+  private JdbcResultSet subject;
+
+  static ResultSet getMockResultSet() {
+    ResultSet res = ResultSets.forRows(
+      Type.struct(
+        StructField.of(STRING_COL_NULL, Type.string()),
+        StructField.of(STRING_COL_NOT_NULL, Type.string()),
+        StructField.of(BOOLEAN_COL_NULL, Type.bool()),
+        StructField.of(BOOLEAN_COL_NOT_NULL, Type.bool()),
+        StructField.of(DOUBLE_COL_NULL, Type.float64()),
+        StructField.of(DOUBLE_COL_NOT_NULL, Type.float64()),
+        StructField.of(BYTES_COL_NULL, Type.bytes()),
+        StructField.of(BYTES_COL_NOT_NULL, Type.bytes()),
+        StructField.of(LONG_COL_NULL, Type.int64()),
+        StructField.of(LONG_COL_NOT_NULL, Type.int64()),
+        StructField.of(DATE_COL_NULL, Type.date()),
+        StructField.of(DATE_COL_NOT_NULL, Type.date()),
+        StructField.of(TIMESTAMP_COL_NULL, Type.timestamp()),
+        StructField.of(TIMESTAMP_COL_NOT_NULL, Type.timestamp()),
+        StructField.of(TIME_COL_NULL, Type.timestamp()),
+        StructField.of(TIME_COL_NOT_NULL, Type.timestamp()),
+        StructField.of(ARRAY_COL_NULL, Type.array(Type.int64())),
+        StructField.of(ARRAY_COL_NOT_NULL, Type.array(Type.int64())),
+        StructField.of(URL_COL_NULL, Type.string()),
+        StructField.of(URL_COL_NOT_NULL, Type.string())
+      ),
+      Arrays.asList(Struct.newBuilder()
+          .set(STRING_COL_NULL).to((String) null)
+          .set(STRING_COL_NOT_NULL).to(STRING_VALUE)
+          .set(BOOLEAN_COL_NULL).to((Boolean) null)
+          .set(BOOLEAN_COL_NOT_NULL).to(BOOLEAN_VALUE)
+          .set(DOUBLE_COL_NULL).to((Double) null)
+          .set(DOUBLE_COL_NOT_NULL).to(DOUBLE_VALUE)
+          .set(BYTES_COL_NULL).to((ByteArray) null)
+          .set(BYTES_COL_NOT_NULL).to(BYTES_VALUE)
+          .set(LONG_COL_NULL).to((Long) null)
+          .set(LONG_COL_NOT_NULL).to(LONG_VALUE)
+          .set(DATE_COL_NULL).to((Date) null)
+          .set(DATE_COL_NOT_NULL).to(DATE_VALUE)
+          .set(TIMESTAMP_COL_NULL).to((Timestamp) null)
+          .set(TIMESTAMP_COL_NOT_NULL).to(TIMESTAMP_VALUE)
+          .set(TIME_COL_NULL).to((Timestamp) null)
+          .set(TIME_COL_NOT_NULL).to(TIME_VALUE)
+          .set(ARRAY_COL_NULL).toInt64Array((long[]) null)
+          .set(ARRAY_COL_NOT_NULL).toInt64Array(ARRAY_VALUE)
+          .set(URL_COL_NULL).to((String) null)
+          .set(URL_COL_NOT_NULL).to(URL_VALUE)
+        .build()
+      ));
+
+    return res;
+  }
+
+  static ResultSet getMockResultSet_old() {
+    ResultSet res = mock(ResultSet.class);
+    when(res.getString(STRING_COL_NULL)).thenReturn(null);
+    when(res.isNull(STRING_COL_NULL)).thenReturn(true);
+    when(res.getString(STRING_COL_NOT_NULL)).thenReturn("FOO");
+    when(res.isNull(STRING_COL_NOT_NULL)).thenReturn(false);
+    when(res.getString(STRING_COLINDEX_NULL - 1)).thenReturn(null);
+    when(res.isNull(STRING_COLINDEX_NULL - 1)).thenReturn(true);
+    when(res.getString(STRING_COLINDEX_NOTNULL - 1)).thenReturn("BAR");
+    when(res.isNull(STRING_COLINDEX_NOTNULL - 1)).thenReturn(false);
+    when(res.getColumnType(STRING_COL_NULL)).thenReturn(Type.string());
+    when(res.getColumnType(STRING_COL_NOT_NULL)).thenReturn(Type.string());
+    when(res.getColumnType(STRING_COLINDEX_NULL - 1)).thenReturn(Type.string());
+    when(res.getColumnType(STRING_COLINDEX_NOTNULL - 1)).thenReturn(Type.string());
+
+    when(res.getString(URL_COL_NULL)).thenReturn(null);
+    when(res.isNull(URL_COL_NULL)).thenReturn(true);
+    when(res.getString(URL_COL_NOT_NULL)).thenReturn("https://github.com/olavloite/spanner-jdbc");
+    when(res.isNull(URL_COL_NOT_NULL)).thenReturn(false);
+    when(res.getString(URL_COLINDEX_NULL - 1)).thenReturn(null);
+    when(res.isNull(URL_COLINDEX_NULL - 1)).thenReturn(true);
+    when(res.getString(URL_COLINDEX_NOTNULL - 1)).thenReturn("https://github.com/olavloite");
+    when(res.isNull(URL_COLINDEX_NOTNULL - 1)).thenReturn(false);
+    when(res.getColumnType(URL_COL_NULL)).thenReturn(Type.string());
+    when(res.getColumnType(URL_COL_NOT_NULL)).thenReturn(Type.string());
+    when(res.getColumnType(URL_COLINDEX_NULL - 1)).thenReturn(Type.string());
+    when(res.getColumnType(URL_COLINDEX_NOTNULL - 1)).thenReturn(Type.string());
+
+    when(res.getBoolean(BOOLEAN_COL_NULL)).thenReturn(false);
+    when(res.isNull(BOOLEAN_COL_NULL)).thenReturn(true);
+    when(res.getBoolean(BOOLEAN_COL_NOT_NULL)).thenReturn(true);
+    when(res.isNull(BOOLEAN_COL_NOT_NULL)).thenReturn(false);
+    when(res.getBoolean(BOOLEAN_COLINDEX_NULL - 1)).thenReturn(false);
+    when(res.isNull(BOOLEAN_COLINDEX_NULL - 1)).thenReturn(true);
+    when(res.getBoolean(BOOLEAN_COLINDEX_NOTNULL - 1)).thenReturn(false);
+    when(res.isNull(BOOLEAN_COLINDEX_NOTNULL - 1)).thenReturn(false);
+    when(res.getColumnType(BOOLEAN_COL_NULL)).thenReturn(Type.bool());
+    when(res.getColumnType(BOOLEAN_COL_NOT_NULL)).thenReturn(Type.bool());
+    when(res.getColumnType(BOOLEAN_COLINDEX_NULL - 1)).thenReturn(Type.bool());
+    when(res.getColumnType(BOOLEAN_COLINDEX_NOTNULL - 1)).thenReturn(Type.bool());
+
+    when(res.getDouble(DOUBLE_COL_NULL)).thenReturn(0d);
+    when(res.isNull(DOUBLE_COL_NULL)).thenReturn(true);
+    when(res.getDouble(DOUBLE_COL_NOT_NULL)).thenReturn(1.123456789d);
+    when(res.isNull(DOUBLE_COL_NOT_NULL)).thenReturn(false);
+    when(res.getDouble(DOUBLE_COLINDEX_NULL - 1)).thenReturn(0d);
+    when(res.isNull(DOUBLE_COLINDEX_NULL - 1)).thenReturn(true);
+    when(res.getDouble(DOUBLE_COLINDEX_NOTNULL - 1)).thenReturn(2.123456789d);
+    when(res.isNull(DOUBLE_COLINDEX_NOTNULL - 1)).thenReturn(false);
+    when(res.getColumnType(DOUBLE_COL_NULL)).thenReturn(Type.float64());
+    when(res.getColumnType(DOUBLE_COL_NOT_NULL)).thenReturn(Type.float64());
+    when(res.getColumnType(DOUBLE_COLINDEX_NULL - 1)).thenReturn(Type.float64());
+    when(res.getColumnType(DOUBLE_COLINDEX_NOTNULL - 1)).thenReturn(Type.float64());
+
+    when(res.getString(BYTES_COL_NULL)).thenReturn(null);
+    when(res.isNull(BYTES_COL_NULL)).thenReturn(true);
+    when(res.getBytes(BYTES_COL_NOT_NULL)).thenReturn(ByteArray.copyFrom("FOO"));
+    when(res.isNull(BYTES_COL_NOT_NULL)).thenReturn(false);
+    when(res.getBytes(BYTES_COLINDEX_NULL - 1)).thenReturn(null);
+    when(res.isNull(BYTES_COLINDEX_NULL - 1)).thenReturn(true);
+    when(res.getBytes(BYTES_COLINDEX_NOTNULL - 1)).thenReturn(ByteArray.copyFrom("BAR"));
+    when(res.isNull(BYTES_COLINDEX_NOTNULL - 1)).thenReturn(false);
+    when(res.getColumnType(BYTES_COL_NULL)).thenReturn(Type.bytes());
+    when(res.getColumnType(BYTES_COL_NOT_NULL)).thenReturn(Type.bytes());
+    when(res.getColumnType(BYTES_COLINDEX_NULL - 1)).thenReturn(Type.bytes());
+    when(res.getColumnType(BYTES_COLINDEX_NOTNULL - 1)).thenReturn(Type.bytes());
+
+    when(res.getLong(LONG_COL_NULL)).thenReturn(0l);
+    when(res.isNull(LONG_COL_NULL)).thenReturn(true);
+    when(res.getLong(LONG_COL_NOT_NULL)).thenReturn(1l);
+    when(res.isNull(LONG_COL_NOT_NULL)).thenReturn(false);
+    when(res.getLong(LONG_COLINDEX_NULL - 1)).thenReturn(0l);
+    when(res.isNull(LONG_COLINDEX_NULL - 1)).thenReturn(true);
+    when(res.getLong(LONG_COLINDEX_NOTNULL - 1)).thenReturn(2l);
+    when(res.isNull(LONG_COLINDEX_NOTNULL - 1)).thenReturn(false);
+    when(res.getColumnType(LONG_COL_NULL)).thenReturn(Type.int64());
+    when(res.getColumnType(LONG_COL_NOT_NULL)).thenReturn(Type.int64());
+    when(res.getColumnType(LONG_COLINDEX_NULL - 1)).thenReturn(Type.int64());
+    when(res.getColumnType(LONG_COLINDEX_NOTNULL - 1)).thenReturn(Type.int64());
+
+    when(res.getDate(DATE_COL_NULL)).thenAnswer(new Returns(null));
+    when(res.isNull(DATE_COL_NULL)).thenAnswer(new Returns(true));
+    when(res.getDate(DATE_COL_NOT_NULL))
+        .thenAnswer(new Returns(Date.fromYearMonthDay(2017, 9, 10)));
+    when(res.isNull(DATE_COL_NOT_NULL)).thenAnswer(new Returns(false));
+    when(res.getDate(DATE_COLINDEX_NULL - 1)).thenAnswer(new Returns(null));
+    when(res.isNull(DATE_COLINDEX_NULL - 1)).thenAnswer(new Returns(true));
+    when(res.getDate(DATE_COLINDEX_NOTNULL - 1))
+        .thenAnswer(new Returns(Date.fromYearMonthDay(2017, 9, 10)));
+    when(res.isNull(DATE_COLINDEX_NOTNULL - 1)).thenAnswer(new Returns(false));
+    when(res.getColumnType(DATE_COL_NULL)).thenAnswer(new Returns(Type.date()));
+    when(res.getColumnType(DATE_COL_NOT_NULL)).thenAnswer(new Returns(Type.date()));
+    when(res.getColumnType(DATE_COLINDEX_NULL - 1)).thenAnswer(new Returns(Type.date()));
+    when(res.getColumnType(DATE_COLINDEX_NOTNULL - 1)).thenAnswer(new Returns(Type.date()));
+
+    Calendar cal1 = Calendar.getInstance(TimeZone.getTimeZone("GMT"));
+    cal1.clear();
+    cal1.set(2017, 8, 10, 8, 15, 59);
+    Calendar cal2 = Calendar.getInstance(TimeZone.getTimeZone("GMT"));
+    cal2.clear();
+    cal2.set(2017, 8, 11, 8, 15, 59);
+    when(res.getTimestamp(TIMESTAMP_COL_NULL)).thenReturn(null);
+    when(res.isNull(TIMESTAMP_COL_NULL)).thenReturn(true);
+    when(res.getTimestamp(TIMESTAMP_COL_NOT_NULL)).thenReturn(Timestamp.of(cal1.getTime()));
+    when(res.isNull(TIMESTAMP_COL_NOT_NULL)).thenReturn(false);
+    when(res.getTimestamp(TIMESTAMP_COLINDEX_NULL - 1)).thenReturn(null);
+    when(res.isNull(TIMESTAMP_COLINDEX_NULL - 1)).thenReturn(true);
+    when(res.getTimestamp(TIMESTAMP_COLINDEX_NOTNULL - 1)).thenReturn(Timestamp.of(cal2.getTime()));
+    when(res.isNull(TIMESTAMP_COLINDEX_NOTNULL - 1)).thenReturn(false);
+    when(res.getColumnType(TIMESTAMP_COL_NULL)).thenReturn(Type.timestamp());
+    when(res.getColumnType(TIMESTAMP_COL_NOT_NULL)).thenReturn(Type.timestamp());
+    when(res.getColumnType(TIMESTAMP_COLINDEX_NULL - 1)).thenReturn(Type.timestamp());
+    when(res.getColumnType(TIMESTAMP_COLINDEX_NOTNULL - 1)).thenReturn(Type.timestamp());
+
+    Calendar cal3 = Calendar.getInstance(TimeZone.getTimeZone("GMT"));
+    cal3.clear();
+    cal3.set(1970, 0, 1, 14, 6, 15);
+    Calendar cal4 = Calendar.getInstance(TimeZone.getTimeZone("GMT"));
+    cal4.clear();
+    cal4.set(1970, 0, 1, 14, 6, 15);
+    when(res.getTimestamp(TIME_COL_NULL)).thenReturn(null);
+    when(res.isNull(TIME_COL_NULL)).thenReturn(true);
+    when(res.getTimestamp(TIME_COL_NOT_NULL)).thenReturn(Timestamp.of(cal3.getTime()));
+    when(res.isNull(TIME_COL_NOT_NULL)).thenReturn(false);
+    when(res.getTimestamp(TIME_COLINDEX_NULL - 1)).thenReturn(null);
+    when(res.isNull(TIME_COLINDEX_NULL - 1)).thenReturn(true);
+    when(res.getTimestamp(TIME_COLINDEX_NOTNULL - 1)).thenReturn(Timestamp.of(cal4.getTime()));
+    when(res.isNull(TIME_COLINDEX_NOTNULL - 1)).thenReturn(false);
+    when(res.getColumnType(TIME_COL_NULL)).thenReturn(Type.timestamp());
+    when(res.getColumnType(TIME_COL_NOT_NULL)).thenReturn(Type.timestamp());
+    when(res.getColumnType(TIME_COLINDEX_NULL - 1)).thenReturn(Type.timestamp());
+    when(res.getColumnType(TIME_COLINDEX_NOTNULL - 1)).thenReturn(Type.timestamp());
+
+    when(res.getLongList(ARRAY_COL_NULL)).thenAnswer(new Returns(null));
+    when(res.isNull(ARRAY_COL_NULL)).thenAnswer(new Returns(true));
+    when(res.getLongList(ARRAY_COL_NOT_NULL)).thenAnswer(new Returns(Arrays.asList(1L, 2L, 3L)));
+    when(res.isNull(ARRAY_COL_NOT_NULL)).thenAnswer(new Returns(false));
+    when(res.getLongList(ARRAY_COLINDEX_NULL - 1)).thenAnswer(new Returns(null));
+    when(res.isNull(ARRAY_COLINDEX_NULL - 1)).thenAnswer(new Returns(true));
+    when(res.getLongList(ARRAY_COLINDEX_NOTNULL - 1))
+        .thenAnswer(new Returns(Arrays.asList(1L, 2L, 3L)));
+    when(res.isNull(ARRAY_COLINDEX_NOTNULL - 1)).thenAnswer(new Returns(false));
+    when(res.getColumnType(ARRAY_COL_NULL)).thenAnswer(new Returns(Type.array(Type.int64())));
+    when(res.getColumnType(ARRAY_COL_NOT_NULL)).thenAnswer(new Returns(Type.array(Type.int64())));
+    when(res.getColumnType(ARRAY_COLINDEX_NULL - 1))
+        .thenAnswer(new Returns(Type.array(Type.int64())));
+    when(res.getColumnType(ARRAY_COLINDEX_NOTNULL - 1))
+        .thenAnswer(new Returns(Type.array(Type.int64())));
+
+    when(res.getColumnIndex(STRING_COL_NOT_NULL)).thenAnswer(new Returns(1));
+    when(res.getColumnIndex(UNKNOWN_COLUMN)).thenThrow(new IllegalArgumentException());
+    when(res.getColumnIndex(DATE_COL_NOT_NULL)).thenAnswer(new Returns(DATE_COLINDEX_NOTNULL - 1));
+    when(res.getColumnIndex(ARRAY_COL_NOT_NULL))
+        .thenAnswer(new Returns(ARRAY_COLINDEX_NOTNULL - 1));
+    when(res.getColumnIndex(ARRAY_COL_NULL)).thenAnswer(new Returns(ARRAY_COLINDEX_NULL - 1));
+
+    when(res.getType()).thenReturn(Type.struct(StructField.of(STRING_COL_NULL, Type.string()),
+        StructField.of(STRING_COL_NOT_NULL, Type.string()),
+        StructField.of(BOOLEAN_COL_NULL, Type.bool()),
+        StructField.of(BOOLEAN_COL_NOT_NULL, Type.bool()),
+        StructField.of(DOUBLE_COL_NULL, Type.float64()),
+        StructField.of(DOUBLE_COL_NOT_NULL, Type.float64()),
+        StructField.of(BYTES_COL_NULL, Type.bytes()),
+        StructField.of(BYTES_COL_NOT_NULL, Type.bytes()),
+        StructField.of(LONG_COL_NULL, Type.int64()),
+        StructField.of(LONG_COL_NOT_NULL, Type.int64()), StructField.of(DATE_COL_NULL, Type.date()),
+        StructField.of(DATE_COL_NOT_NULL, Type.date()),
+        StructField.of(TIMESTAMP_COL_NULL, Type.timestamp()),
+        StructField.of(TIMESTAMP_COL_NOT_NULL, Type.timestamp()),
+        StructField.of(TIME_COL_NULL, Type.timestamp()),
+        StructField.of(TIME_COL_NOT_NULL, Type.timestamp()),
+        StructField.of(URL_COL_NULL, Type.string()),
+        StructField.of(URL_COL_NOT_NULL, Type.string())));
+
+    // Next behaviour.
+    when(res.next()).thenReturn(true, true, true, true, false);
+
+    return res;
+  }
+
+  public JdbcResultSetTest() throws SQLException {
+    subject = JdbcResultSet.of(mock(Statement.class), getMockResultSet());
+    subject.next();
+  }
+
+  @Test
+  public void testWasNull() throws SQLException {
+    String value = subject.getString(STRING_COL_NULL);
+    boolean wasNull = subject.wasNull();
+    assertTrue(wasNull);
+    assertNull(value);
+    String valueNotNull = subject.getString(STRING_COL_NOT_NULL);
+    boolean wasNotNull = subject.wasNull();
+    assertEquals(false, wasNotNull);
+    assertNotNull(valueNotNull);
+  }
+
+  @Test
+  public void testNext() throws SQLException {
+    try (JdbcResultSet rs = JdbcResultSet.of(mock(Statement.class), getMockResultSet())) {
+      assertTrue(rs.isBeforeFirst());
+      assertEquals(false, rs.isAfterLast());
+      int num = 0;
+      while (rs.next()) {
+        num++;
+      }
+      assertTrue(num > 0);
+      assertEquals(false, rs.isBeforeFirst());
+      assertTrue(rs.isAfterLast());
+    }
+  }
+
+  @Test
+  public void testClose() throws SQLException {
+    try (JdbcResultSet rs = JdbcResultSet.of(mock(Statement.class), getMockResultSet())) {
+      assertEquals(false, rs.isClosed());
+      rs.next();
+      assertNotNull(rs.getString(STRING_COL_NOT_NULL));
+      rs.close();
+      assertTrue(rs.isClosed());
+      boolean failed = false;
+      try {
+        // Should fail
+        rs.getString(STRING_COL_NOT_NULL);
+      } catch (SQLException e) {
+        failed = true;
+      }
+      assertTrue(failed);
+    }
+  }
+
+  @Test
+  public void testGetStringIndex() throws SQLException {
+    assertNotNull(subject.getString(STRING_COLINDEX_NOTNULL));
+    assertEquals(STRING_VALUE, subject.getString(STRING_COLINDEX_NOTNULL));
+    assertEquals(false, subject.wasNull());
+    assertNull(subject.getString(STRING_COLINDEX_NULL));
+    assertTrue(subject.wasNull());
+  }
+
+  @Test
+  public void testGetNStringIndex() throws SQLException {
+    assertNotNull(subject.getNString(STRING_COLINDEX_NOTNULL));
+    assertEquals(STRING_VALUE, subject.getNString(STRING_COLINDEX_NOTNULL));
+    assertEquals(false, subject.wasNull());
+    assertNull(subject.getNString(STRING_COLINDEX_NULL));
+    assertTrue(subject.wasNull());
+  }
+
+  @Test
+  public void testGetURLIndex() throws SQLException, MalformedURLException {
+    assertNotNull(subject.getURL(URL_COLINDEX_NOTNULL));
+    assertEquals(new URL(URL_VALUE), subject.getURL(URL_COLINDEX_NOTNULL));
+    assertEquals(false, subject.wasNull());
+    assertNull(subject.getURL(URL_COLINDEX_NULL));
+    assertTrue(subject.wasNull());
+  }
+
+  @Test
+  public void testGetURLIndexInvalid() throws SQLException, MalformedURLException {
+    thrown.expect(JdbcSqlExceptionImpl.class);
+    thrown.expectMessage("Invalid URL");
+    assertNotNull(subject.getURL(STRING_COLINDEX_NOTNULL));
+  }
+
+  @Test
+  public void testGetBooleanIndex() throws SQLException {
+    assertNotNull(subject.getBoolean(BOOLEAN_COLINDEX_NOTNULL));
+    assertEquals(false, subject.wasNull());
+    assertEquals(false, subject.getBoolean(BOOLEAN_COLINDEX_NULL));
+    assertTrue(subject.wasNull());
+  }
+
+  @Test
+  public void testGetLongIndex() throws SQLException {
+    assertNotNull(subject.getLong(LONG_COLINDEX_NOTNULL));
+    assertEquals(LONG_VALUE, subject.getLong(LONG_COLINDEX_NOTNULL));
+    assertEquals(false, subject.wasNull());
+    assertEquals(0l, subject.getLong(LONG_COLINDEX_NULL));
+    assertTrue(subject.wasNull());
+  }
+
+  @Test
+  public void testGetDoubleIndex() throws SQLException {
+    assertNotNull(subject.getDouble(DOUBLE_COLINDEX_NOTNULL));
+    assertEquals(DOUBLE_VALUE, subject.getDouble(DOUBLE_COLINDEX_NOTNULL), 0d);
+    assertEquals(false, subject.wasNull());
+    assertEquals(0d, subject.getDouble(DOUBLE_COLINDEX_NULL), 0d);
+    assertTrue(subject.wasNull());
+  }
+
+  @Test
+  public void testGetBigDecimalIndexAndScale() throws SQLException {
+    assertNotNull(subject.getBigDecimal(DOUBLE_COLINDEX_NOTNULL, 2));
+    assertEquals(BigDecimal.valueOf(DOUBLE_VALUE).setScale(2, RoundingMode.HALF_UP),
+        subject.getBigDecimal(DOUBLE_COLINDEX_NOTNULL, 2));
+    assertEquals(false, subject.wasNull());
+    assertNull(subject.getBigDecimal(DOUBLE_COLINDEX_NULL, 2));
+    assertTrue(subject.wasNull());
+  }
+
+  @Test
+  public void testGetBytesIndex() throws SQLException {
+    assertNotNull(subject.getBytes(BYTES_COLINDEX_NOTNULL));
+    assertArrayEquals(BYTES_VALUE.toByteArray(), subject.getBytes(BYTES_COLINDEX_NOTNULL));
+    assertEquals(false, subject.wasNull());
+    assertNull(subject.getBytes(BYTES_COLINDEX_NULL));
+    assertTrue(subject.wasNull());
+  }
+
+  @SuppressWarnings("deprecation")
+  @Test
+  public void testGetDateIndex() throws SQLException {
+    assertNotNull(subject.getDate(DATE_COLINDEX_NOTNULL));
+    assertEquals(new java.sql.Date(DATE_VALUE.getYear() - 1900, DATE_VALUE.getMonth() - 1,
+        DATE_VALUE.getDayOfMonth()), subject.getDate(DATE_COLINDEX_NOTNULL));
+    assertEquals(false, subject.wasNull());
+    assertNull(subject.getDate(DATE_COLINDEX_NULL));
+    assertTrue(subject.wasNull());
+  }
+
+  @Test
+  public void testGetTimeIndex() throws SQLException {
+    assertNotNull(subject.getTime(TIME_COLINDEX_NOTNULL));
+    assertEquals(new Time(TIME_VALUE.toSqlTimestamp().getTime()),
+        subject.getTime(TIME_COLINDEX_NOTNULL));
+    assertEquals(false, subject.wasNull());
+    assertNull(subject.getTime(TIME_COLINDEX_NULL));
+    assertTrue(subject.wasNull());
+  }
+
+  @Test
+  public void testGetTimestampIndex() throws SQLException {
+    assertNotNull(subject.getTimestamp(TIMESTAMP_COLINDEX_NOTNULL));
+    assertEquals(TIMESTAMP_VALUE.toSqlTimestamp(),
+        subject.getTimestamp(TIMESTAMP_COLINDEX_NOTNULL));
+    assertEquals(false, subject.wasNull());
+    assertNull(subject.getTimestamp(TIMESTAMP_COLINDEX_NULL));
+    assertTrue(subject.wasNull());
+  }
+
+  @Test
+  public void testGetStringLabel() throws SQLException {
+    assertNotNull(subject.getString(STRING_COL_NOT_NULL));
+    assertEquals("FOO", subject.getString(STRING_COL_NOT_NULL));
+    assertEquals(false, subject.wasNull());
+    assertNull(subject.getString(STRING_COL_NULL));
+    assertTrue(subject.wasNull());
+  }
+
+  @Test
+  public void testGetNStringLabel() throws SQLException {
+    assertNotNull(subject.getNString(STRING_COL_NOT_NULL));
+    assertEquals("FOO", subject.getNString(STRING_COL_NOT_NULL));
+    assertEquals(false, subject.wasNull());
+    assertNull(subject.getNString(STRING_COL_NULL));
+    assertTrue(subject.wasNull());
+  }
+
+  @Test
+  public void testGetURLLabel() throws SQLException {
+    assertNotNull(subject.getString(URL_COL_NOT_NULL));
+    assertEquals(URL_VALUE, subject.getString(URL_COL_NOT_NULL));
+    assertEquals(false, subject.wasNull());
+    assertNull(subject.getString(URL_COL_NULL));
+    assertTrue(subject.wasNull());
+  }
+
+  @Test
+  public void testGetURLLabelInvalid() throws SQLException {
+    thrown.expect(JdbcSqlExceptionImpl.class);
+    thrown.expectMessage("Invalid URL");
+    assertNotNull(subject.getURL(STRING_COL_NOT_NULL));
+  }
+
+  @Test
+  public void testGetBooleanLabel() throws SQLException {
+    assertNotNull(subject.getBoolean(BOOLEAN_COL_NOT_NULL));
+    assertEquals(false, subject.wasNull());
+    assertEquals(false, subject.getBoolean(BOOLEAN_COL_NULL));
+    assertTrue(subject.wasNull());
+  }
+
+  @Test
+  public void testGetLongLabel() throws SQLException {
+    assertNotNull(subject.getLong(LONG_COL_NOT_NULL));
+    assertEquals(1l, subject.getLong(LONG_COL_NOT_NULL));
+    assertEquals(false, subject.wasNull());
+    assertEquals(0l, subject.getLong(LONG_COL_NULL));
+    assertTrue(subject.wasNull());
+  }
+
+  @Test
+  public void testGetDoubleLabel() throws SQLException {
+    assertNotNull(subject.getDouble(DOUBLE_COL_NOT_NULL));
+    assertEquals(DOUBLE_VALUE, subject.getDouble(DOUBLE_COL_NOT_NULL), 0d);
+    assertEquals(false, subject.wasNull());
+    assertEquals(0d, subject.getDouble(DOUBLE_COL_NULL), 0d);
+    assertTrue(subject.wasNull());
+  }
+
+  @Test
+  public void testGetBigDecimalLabelAndScale() throws SQLException {
+    assertNotNull(subject.getBigDecimal(DOUBLE_COL_NOT_NULL, 2));
+    assertEquals(BigDecimal.valueOf(3.14d), subject.getBigDecimal(DOUBLE_COL_NOT_NULL, 2));
+    assertEquals(false, subject.wasNull());
+    assertNull(subject.getBigDecimal(DOUBLE_COL_NULL, 2));
+    assertTrue(subject.wasNull());
+  }
+
+  @Test
+  public void testGetBytesLabel() throws SQLException {
+    assertNotNull(subject.getBytes(BYTES_COL_NOT_NULL));
+    assertArrayEquals(ByteArray.copyFrom("FOO").toByteArray(),
+        subject.getBytes(BYTES_COL_NOT_NULL));
+    assertEquals(false, subject.wasNull());
+    assertNull(subject.getBytes(BYTES_COL_NULL));
+    assertTrue(subject.wasNull());
+  }
+
+  @SuppressWarnings("deprecation")
+  @Test
+  public void testGetDateLabel() throws SQLException {
+    assertNotNull(subject.getDate(DATE_COL_NOT_NULL));
+    assertEquals(new java.sql.Date(DATE_VALUE.getYear() - 1900, DATE_VALUE.getMonth() - 1,
+        DATE_VALUE.getDayOfMonth()), subject.getDate(DATE_COL_NOT_NULL));
+    assertEquals(false, subject.wasNull());
+    assertNull(subject.getDate(DATE_COL_NULL));
+    assertTrue(subject.wasNull());
+  }
+
+  @Test
+  public void testGetTimeLabel() throws SQLException {
+    assertNotNull(subject.getTime(TIME_COL_NOT_NULL));
+    assertEquals(new Time(TIME_VALUE.toSqlTimestamp().getTime()),
+        subject.getTime(TIME_COL_NOT_NULL));
+    assertEquals(false, subject.wasNull());
+    assertNull(subject.getTime(TIME_COL_NULL));
+    assertTrue(subject.wasNull());
+  }
+
+  @Test
+  public void testGetTimestampLabel() throws SQLException {
+    assertNotNull(subject.getTime(TIMESTAMP_COL_NOT_NULL));
+    assertEquals(TIMESTAMP_VALUE.toSqlTimestamp(), subject.getTimestamp(TIMESTAMP_COL_NOT_NULL));
+    assertEquals(false, subject.wasNull());
+    assertNull(subject.getTimestamp(TIMESTAMP_COL_NULL));
+    assertTrue(subject.wasNull());
+  }
+
+  @Test
+  public void testGetMetaData() throws SQLException {
+    JdbcResultSetMetaData metadata = subject.getMetaData();
+    assertNotNull(metadata);
+  }
+
+  @Test
+  public void testFindColumn() throws SQLException {
+    assertEquals(2, subject.findColumn(STRING_COL_NOT_NULL));
+  }
+
+  @Test
+  public void testGetBigDecimalIndex() throws SQLException {
+    assertNotNull(subject.getBigDecimal(DOUBLE_COLINDEX_NOTNULL));
+    assertEquals(BigDecimal.valueOf(DOUBLE_VALUE), subject.getBigDecimal(DOUBLE_COLINDEX_NOTNULL));
+    assertEquals(false, subject.wasNull());
+    assertNull(subject.getBigDecimal(DOUBLE_COLINDEX_NULL));
+    assertTrue(subject.wasNull());
+  }
+
+  @Test
+  public void testGetBigDecimalLabel() throws SQLException {
+    assertNotNull(subject.getBigDecimal(DOUBLE_COL_NOT_NULL));
+    assertEquals(BigDecimal.valueOf(DOUBLE_VALUE), subject.getBigDecimal(DOUBLE_COL_NOT_NULL));
+    assertEquals(false, subject.wasNull());
+    assertNull(subject.getBigDecimal(DOUBLE_COL_NULL));
+    assertTrue(subject.wasNull());
+  }
+
+  @Test
+  public void testGetStatement() throws SQLException {
+    assertNotNull(subject.getStatement());
+  }
+
+  @SuppressWarnings("deprecation")
+  @Test
+  public void testGetDateIndexCalendar() throws SQLException {
+    Calendar cal = Calendar.getInstance();
+    assertNotNull(subject.getDate(DATE_COLINDEX_NOTNULL, cal));
+    assertEquals(new java.sql.Date(DATE_VALUE.getYear() - 1900, DATE_VALUE.getMonth() - 1,
+        DATE_VALUE.getDayOfMonth()), subject.getDate(DATE_COLINDEX_NOTNULL, cal));
+    assertEquals(false, subject.wasNull());
+    assertNull(subject.getDate(DATE_COLINDEX_NULL, cal));
+    assertTrue(subject.wasNull());
+
+    Calendar calGMT = Calendar.getInstance(TimeZone.getTimeZone("GMT"));
+    Calendar expectedCal = Calendar.getInstance(TimeZone.getTimeZone("GMT"));
+    expectedCal.clear();
+    expectedCal.set(DATE_VALUE.getYear(), DATE_VALUE.getMonth() - 1, DATE_VALUE.getDayOfMonth());
+    java.sql.Date expected = new java.sql.Date(expectedCal.getTimeInMillis());
+    assertEquals(expected, subject.getDate(DATE_COLINDEX_NOTNULL, calGMT));
+  }
+
+  @SuppressWarnings("deprecation")
+  @Test
+  public void testGetDateLabelCalendar() throws SQLException {
+    Calendar cal = Calendar.getInstance();
+    assertNotNull(subject.getDate(DATE_COL_NOT_NULL, cal));
+    assertEquals(new java.sql.Date(DATE_VALUE.getYear() - 1900, DATE_VALUE.getMonth() - 1,
+        DATE_VALUE.getDayOfMonth()), subject.getDate(DATE_COL_NOT_NULL, cal));
+    assertEquals(false, subject.wasNull());
+    assertNull(subject.getDate(DATE_COL_NULL, cal));
+    assertTrue(subject.wasNull());
+
+    Calendar calGMT = Calendar.getInstance(TimeZone.getTimeZone("GMT"));
+    Calendar expected = Calendar.getInstance(TimeZone.getTimeZone("GMT"));
+    expected.set(DATE_VALUE.getYear(), DATE_VALUE.getMonth() - 1, DATE_VALUE.getDayOfMonth(), 0, 0,
+        0);
+    expected.clear(Calendar.MILLISECOND);
+    assertEquals(new java.sql.Date(expected.getTimeInMillis()),
+        subject.getDate(DATE_COL_NOT_NULL, calGMT));
+  }
+
+  @Test
+  public void testGetTimeIndexCalendar() throws SQLException {
+    Calendar cal = Calendar.getInstance(TimeZone.getTimeZone("GMT"));
+
+    assertNotNull(subject.getTime(TIME_COLINDEX_NOTNULL, cal));
+    assertEquals(new Time(TIME_VALUE.toSqlTimestamp().getTime()),
+        subject.getTime(TIME_COLINDEX_NOTNULL, cal));
+    assertEquals(false, subject.wasNull());
+    assertNull(subject.getTime(TIME_COLINDEX_NULL, cal));
+    assertTrue(subject.wasNull());
+  }
+
+  @Test
+  public void testGetTimeLabelCalendar() throws SQLException {
+    Calendar cal = Calendar.getInstance(TimeZone.getTimeZone("GMT"));
+
+    assertNotNull(subject.getTime(TIME_COL_NOT_NULL, cal));
+    assertEquals(new Time(TIME_VALUE.toSqlTimestamp().getTime()),
+        subject.getTime(TIME_COL_NOT_NULL, cal));
+    assertEquals(false, subject.wasNull());
+    assertNull(subject.getTime(TIME_COL_NULL, cal));
+    assertTrue(subject.wasNull());
+  }
+
+  @Test
+  public void testGetTimestampIndexCalendar() throws SQLException {
+    Calendar cal = Calendar.getInstance(TimeZone.getTimeZone("GMT"));
+
+    assertNotNull(subject.getTimestamp(TIMESTAMP_COLINDEX_NOTNULL, cal));
+    assertEquals(TIMESTAMP_VALUE.toSqlTimestamp(),
+        subject.getTimestamp(TIMESTAMP_COLINDEX_NOTNULL, cal));
+    assertEquals(false, subject.wasNull());
+    assertNull(subject.getTimestamp(TIMESTAMP_COLINDEX_NULL, cal));
+    assertTrue(subject.wasNull());
+  }
+
+  @Test
+  public void testGetTimestampLabelCalendar() throws SQLException {
+    Calendar cal = Calendar.getInstance(TimeZone.getTimeZone("GMT"));
+
+    assertNotNull(subject.getTimestamp(TIMESTAMP_COL_NOT_NULL, cal));
+    assertEquals(TIMESTAMP_VALUE.toSqlTimestamp(),
+        subject.getTimestamp(TIMESTAMP_COL_NOT_NULL, cal));
+    assertEquals(false, subject.wasNull());
+    assertNull(subject.getTimestamp(TIMESTAMP_COL_NULL, cal));
+    assertTrue(subject.wasNull());
+  }
+
+  @Test
+  public void testIsClosed() throws SQLException {
+    try (JdbcResultSet rs = JdbcResultSet.of(mock(Statement.class), getMockResultSet())) {
+      assertFalse(rs.isClosed());
+      rs.close();
+      assertTrue(rs.isClosed());
+    }
+  }
+
+  @Test
+  public void testGetByteIndex() throws SQLException {
+    assertNotNull(subject.getByte(LONG_COLINDEX_NOTNULL));
+    assertEquals(LONG_VALUE, subject.getByte(LONG_COLINDEX_NOTNULL));
+    assertEquals(false, subject.wasNull());
+    assertEquals(0, subject.getByte(LONG_COLINDEX_NULL));
+    assertTrue(subject.wasNull());
+  }
+
+  @Test
+  public void testGetShortIndex() throws SQLException {
+    assertNotNull(subject.getShort(LONG_COLINDEX_NOTNULL));
+    assertEquals(LONG_VALUE, subject.getShort(LONG_COLINDEX_NOTNULL));
+    assertEquals(false, subject.wasNull());
+    assertEquals(0, subject.getShort(LONG_COLINDEX_NULL));
+    assertTrue(subject.wasNull());
+  }
+
+  @Test
+  public void testGetIntIndex() throws SQLException {
+    assertNotNull(subject.getInt(LONG_COLINDEX_NOTNULL));
+    int expected = (int) LONG_VALUE;
+    assertEquals(expected, subject.getInt(LONG_COLINDEX_NOTNULL));
+    assertEquals(false, subject.wasNull());
+    assertEquals(0, subject.getInt(LONG_COLINDEX_NULL));
+    assertTrue(subject.wasNull());
+  }
+
+  @Test
+  public void testGetFloatIndex() throws SQLException {
+    assertNotNull(subject.getFloat(DOUBLE_COLINDEX_NOTNULL));
+    float expected = (float) DOUBLE_VALUE;
+    assertEquals(expected, subject.getFloat(DOUBLE_COLINDEX_NOTNULL), 0f);
+    assertEquals(false, subject.wasNull());
+    assertEquals(0d, subject.getFloat(DOUBLE_COLINDEX_NULL), 0f);
+    assertTrue(subject.wasNull());
+  }
+
+  @Test
+  public void testGetByteLabel() throws SQLException {
+    assertNotNull(subject.getByte(LONG_COL_NOT_NULL));
+    assertEquals(1, subject.getByte(LONG_COL_NOT_NULL));
+    assertEquals(false, subject.wasNull());
+    assertEquals(0, subject.getByte(LONG_COL_NULL));
+    assertTrue(subject.wasNull());
+  }
+
+  @Test
+  public void testGetShortLabel() throws SQLException {
+    assertNotNull(subject.getShort(LONG_COL_NOT_NULL));
+    assertEquals(1, subject.getShort(LONG_COL_NOT_NULL));
+    assertEquals(false, subject.wasNull());
+    assertEquals(0, subject.getShort(LONG_COL_NULL));
+    assertTrue(subject.wasNull());
+  }
+
+  @Test
+  public void testGetIntLabel() throws SQLException {
+    assertNotNull(subject.getInt(LONG_COL_NOT_NULL));
+    assertEquals(1, subject.getInt(LONG_COL_NOT_NULL));
+    assertEquals(false, subject.wasNull());
+    assertEquals(0, subject.getInt(LONG_COL_NULL));
+    assertTrue(subject.wasNull());
+  }
+
+  @Test
+  public void testGetFloatLabel() throws SQLException {
+    assertNotNull(subject.getFloat(DOUBLE_COL_NOT_NULL));
+    float expected = (float) DOUBLE_VALUE;
+    assertEquals(expected, subject.getFloat(DOUBLE_COL_NOT_NULL), 0f);
+    assertEquals(false, subject.wasNull());
+    assertEquals(0f, subject.getFloat(DOUBLE_COL_NULL), 0f);
+    assertTrue(subject.wasNull());
+  }
+
+  @SuppressWarnings("deprecation")
+  @Test
+  public void testGetObjectLabel() throws SQLException {
+    assertNotNull(subject.getObject(DATE_COL_NOT_NULL));
+    assertEquals(new java.sql.Date(DATE_VALUE.getYear() - 1900, DATE_VALUE.getMonth() - 1,
+        DATE_VALUE.getDayOfMonth()), subject.getObject(DATE_COL_NOT_NULL));
+    assertEquals(false, subject.wasNull());
+    assertNull(subject.getObject(DATE_COL_NULL));
+    assertTrue(subject.wasNull());
+  }
+
+  @SuppressWarnings("deprecation")
+  @Test
+  public void testGetObjectIndex() throws SQLException {
+    assertNotNull(subject.getObject(DATE_COLINDEX_NOTNULL));
+    assertEquals(new java.sql.Date(DATE_VALUE.getYear() - 1900, DATE_VALUE.getMonth() - 1,
+        DATE_VALUE.getDayOfMonth()), subject.getObject(DATE_COLINDEX_NOTNULL));
+    assertEquals(false, subject.wasNull());
+    assertNull(subject.getObject(DATE_COLINDEX_NULL));
+    assertTrue(subject.wasNull());
+  }
+
+  @SuppressWarnings("deprecation")
+  @Test
+  public void testGetObjectLabelMap() throws SQLException {
+    Map<String, Class<?>> map = new HashMap<>();
+    assertNotNull(subject.getObject(DATE_COL_NOT_NULL, map));
+    assertEquals(new java.sql.Date(DATE_VALUE.getYear() - 1900, DATE_VALUE.getMonth() - 1,
+        DATE_VALUE.getDayOfMonth()), subject.getObject(DATE_COL_NOT_NULL, map));
+    assertEquals(false, subject.wasNull());
+    assertNull(subject.getObject(DATE_COL_NULL, map));
+    assertTrue(subject.wasNull());
+  }
+
+  @SuppressWarnings("deprecation")
+  @Test
+  public void testGetObjectIndexMap() throws SQLException {
+    Map<String, Class<?>> map = Collections.emptyMap();
+    assertNotNull(subject.getObject(DATE_COLINDEX_NOTNULL, map));
+    assertEquals(new java.sql.Date(DATE_VALUE.getYear() - 1900, DATE_VALUE.getMonth() - 1,
+        DATE_VALUE.getDayOfMonth()), subject.getObject(DATE_COLINDEX_NOTNULL, map));
+    assertEquals(false, subject.wasNull());
+    assertNull(subject.getObject(DATE_COLINDEX_NULL, map));
+    assertTrue(subject.wasNull());
+  }
+
+  @Test
+  public void testGetArrayLabel() throws SQLException {
+    assertNotNull(subject.getArray(ARRAY_COL_NOT_NULL));
+    assertEquals(JdbcArray.createArray(JdbcDataType.INT64, Arrays.asList(1L, 2L, 3L)),
+        subject.getArray(ARRAY_COL_NOT_NULL));
+    assertEquals(false, subject.wasNull());
+    assertNull(subject.getArray(ARRAY_COL_NULL));
+    assertTrue(subject.wasNull());
+  }
+
+  @Test
+  public void testGetArrayIndex() throws SQLException {
+    assertNotNull(subject.getArray(ARRAY_COLINDEX_NOTNULL));
+    assertEquals(JdbcArray.createArray(JdbcDataType.INT64, Arrays.asList(1L, 2L, 3L)),
+        subject.getArray(ARRAY_COLINDEX_NOTNULL));
+    assertEquals(false, subject.wasNull());
+    assertNull(subject.getArray(ARRAY_COLINDEX_NULL));
+    assertTrue(subject.wasNull());
+  }
+
+  @Test
+  public void testGetWarnings() throws SQLException {
+    assertNull(subject.getWarnings());
+  }
+
+  @Test
+  public void testClearWarnings() throws SQLException {
+    subject.clearWarnings();
+  }
+
+  @Test
+  public void testIsBeforeFirst() throws SQLException {
+    try (JdbcResultSet rs = JdbcResultSet.of(mock(Statement.class), getMockResultSet())) {
+      assertTrue(rs.isBeforeFirst());
+      rs.next();
+      assertFalse(rs.isBeforeFirst());
+    }
+  }
+
+  @Test
+  public void testIsAfterLast() throws SQLException {
+    try (JdbcResultSet rs = JdbcResultSet.of(mock(Statement.class), getMockResultSet())) {
+      assertFalse(rs.isAfterLast());
+      while (rs.next()) {
+        // do nothing
+      }
+      assertTrue(rs.isAfterLast());
+    }
+  }
+
+  @Test
+  public void testGetCharacterStreamIndex() throws SQLException, IOException {
+    assertNotNull(subject.getCharacterStream(STRING_COLINDEX_NOTNULL));
+    Reader actual = subject.getCharacterStream(STRING_COLINDEX_NOTNULL);
+    char[] cbuf = new char[10];
+    int len = actual.read(cbuf, 0, cbuf.length);
+    assertEquals(STRING_VALUE, new String(cbuf, 0, len));
+    assertEquals(3, len);
+    assertEquals(false, subject.wasNull());
+    assertNull(subject.getCharacterStream(STRING_COLINDEX_NULL));
+    assertTrue(subject.wasNull());
+  }
+
+  @Test
+  public void testGetCharacterStreamLabel() throws SQLException, IOException {
+    assertNotNull(subject.getCharacterStream(STRING_COL_NOT_NULL));
+    Reader actual = subject.getCharacterStream(STRING_COL_NOT_NULL);
+    char[] cbuf = new char[10];
+    int len = actual.read(cbuf, 0, cbuf.length);
+    assertEquals("FOO", new String(cbuf, 0, len));
+    assertEquals(3, len);
+    assertEquals(false, subject.wasNull());
+    assertNull(subject.getCharacterStream(STRING_COL_NULL));
+    assertTrue(subject.wasNull());
+  }
+
+  @Test
+  public void testGetNCharacterStreamIndex() throws SQLException, IOException {
+    assertNotNull(subject.getNCharacterStream(STRING_COLINDEX_NOTNULL));
+    Reader actual = subject.getNCharacterStream(STRING_COLINDEX_NOTNULL);
+    char[] cbuf = new char[10];
+    int len = actual.read(cbuf, 0, cbuf.length);
+    assertEquals(STRING_VALUE, new String(cbuf, 0, len));
+    assertEquals(3, len);
+    assertEquals(false, subject.wasNull());
+    assertNull(subject.getNCharacterStream(STRING_COLINDEX_NULL));
+    assertTrue(subject.wasNull());
+  }
+
+  @Test
+  public void testGetNCharacterStreamLabel() throws SQLException, IOException {
+    assertNotNull(subject.getNCharacterStream(STRING_COL_NOT_NULL));
+    Reader actual = subject.getNCharacterStream(STRING_COL_NOT_NULL);
+    char[] cbuf = new char[10];
+    int len = actual.read(cbuf, 0, cbuf.length);
+    assertEquals("FOO", new String(cbuf, 0, len));
+    assertEquals(3, len);
+    assertEquals(false, subject.wasNull());
+    assertNull(subject.getNCharacterStream(STRING_COL_NULL));
+    assertTrue(subject.wasNull());
+  }
+
+  @Test
+  public void testGetAsciiStreamIndex() throws SQLException, IOException {
+    assertNotNull(subject.getAsciiStream(STRING_COLINDEX_NOTNULL));
+    InputStream actual = subject.getAsciiStream(STRING_COLINDEX_NOTNULL);
+    byte[] cbuf = new byte[10];
+    int len = actual.read(cbuf, 0, cbuf.length);
+    assertEquals(STRING_VALUE, new String(cbuf, 0, len, StandardCharsets.US_ASCII));
+    assertEquals(3, len);
+    assertEquals(false, subject.wasNull());
+    assertNull(subject.getAsciiStream(STRING_COLINDEX_NULL));
+    assertTrue(subject.wasNull());
+  }
+
+  @Test
+  public void testGetUnicodeStreamIndex() throws SQLException, IOException {
+    assertNotNull(subject.getUnicodeStream(STRING_COLINDEX_NOTNULL));
+    InputStream actual = subject.getUnicodeStream(STRING_COLINDEX_NOTNULL);
+    byte[] cbuf = new byte[10];
+    int len = actual.read(cbuf, 0, cbuf.length);
+    assertEquals(STRING_VALUE, new String(cbuf, 0, len, StandardCharsets.UTF_16LE));
+    assertEquals(6, len);
+    assertEquals(false, subject.wasNull());
+    assertNull(subject.getUnicodeStream(STRING_COLINDEX_NULL));
+    assertTrue(subject.wasNull());
+  }
+
+  @Test
+  public void testGetBinaryStreamIndex() throws SQLException, IOException {
+    assertNotNull(subject.getBinaryStream(BYTES_COLINDEX_NOTNULL));
+    InputStream actual = subject.getBinaryStream(BYTES_COLINDEX_NOTNULL);
+    byte[] cbuf = new byte[3];
+    int len = actual.read(cbuf, 0, cbuf.length);
+    assertArrayEquals(BYTES_VALUE.toByteArray(), cbuf);
+    assertEquals(3, len);
+    assertEquals(false, subject.wasNull());
+    assertNull(subject.getUnicodeStream(BYTES_COLINDEX_NULL));
+    assertTrue(subject.wasNull());
+  }
+
+  @Test
+  public void testGetAsciiStreamLabel() throws SQLException, IOException {
+    assertNotNull(subject.getAsciiStream(STRING_COL_NOT_NULL));
+    InputStream actual = subject.getAsciiStream(STRING_COL_NOT_NULL);
+    byte[] cbuf = new byte[10];
+    int len = actual.read(cbuf, 0, cbuf.length);
+    assertEquals("FOO", new String(cbuf, 0, len, StandardCharsets.US_ASCII));
+    assertEquals(3, len);
+    assertEquals(false, subject.wasNull());
+    assertNull(subject.getAsciiStream(STRING_COL_NULL));
+    assertTrue(subject.wasNull());
+  }
+
+  @Test
+  public void testGetUnicodeStreamLabel() throws SQLException, IOException {
+    assertNotNull(subject.getUnicodeStream(STRING_COL_NOT_NULL));
+    InputStream actual = subject.getUnicodeStream(STRING_COL_NOT_NULL);
+    byte[] cbuf = new byte[10];
+    int len = actual.read(cbuf, 0, cbuf.length);
+    assertEquals("FOO", new String(cbuf, 0, len, StandardCharsets.UTF_16LE));
+    assertEquals(6, len);
+    assertEquals(false, subject.wasNull());
+    assertNull(subject.getUnicodeStream(STRING_COL_NULL));
+    assertTrue(subject.wasNull());
+  }
+
+  @Test
+  public void testGetBinaryStreamLabel() throws SQLException, IOException {
+    assertNotNull(subject.getBinaryStream(BYTES_COL_NOT_NULL));
+    InputStream actual = subject.getBinaryStream(BYTES_COL_NOT_NULL);
+    byte[] cbuf = new byte[3];
+    int len = actual.read(cbuf, 0, cbuf.length);
+    assertArrayEquals(ByteArray.copyFrom("FOO").toByteArray(), cbuf);
+    assertEquals(3, len);
+    assertEquals(false, subject.wasNull());
+    assertNull(subject.getUnicodeStream(BYTES_COL_NULL));
+    assertTrue(subject.wasNull());
+  }
+
+  @Test
+  public void testGetBeforeNext() throws SQLException {
+    try (JdbcResultSet rs = JdbcResultSet.of(mock(Statement.class), getMockResultSet())) {
+      thrown.expect(SQLException.class);
+      thrown
+          .expectMessage("FAILED_PRECONDITION: ResultSet is before first row. Call next() first.");
+      rs.getBigDecimal(LONG_COLINDEX_NOTNULL);
+    }
+  }
+
+  @Test
+  public void testGetAfterLast() throws SQLException {
+    try (JdbcResultSet rs = JdbcResultSet.of(mock(Statement.class), getMockResultSet())) {
+      while (rs.next()) {
+        // do nothing
+      }
+      thrown.expect(SQLException.class);
+      thrown.expectMessage(
+          "FAILED_PRECONDITION: ResultSet is after last row. There is no more data available.");
+      rs.getBigDecimal(LONG_COLINDEX_NOTNULL);
+    }
+  }
+
+  @Test
+  public void testFindIllegalColumnName() throws SQLException {
+    thrown.expect(SQLException.class);
+    thrown.expectMessage("INVALID_ARGUMENT: no column with label " + UNKNOWN_COLUMN + " found");
+    int index = subject.findColumn(UNKNOWN_COLUMN);
+    assertEquals(0, index);
+  }
+
+  @Test
+  public void testGetRowAndIsFirst() throws SQLException {
+    try (JdbcResultSet rs = JdbcResultSet.of(mock(Statement.class), getMockResultSet())) {
+      int row = 0;
+      while (rs.next()) {
+        row++;
+        assertEquals(row, rs.getRow());
+        assertEquals(row == 1, rs.isFirst());
+      }
+    }
+  }
+
+  @Test
+  public void testGetHoldability() throws SQLException {
+    assertEquals(java.sql.ResultSet.CLOSE_CURSORS_AT_COMMIT, subject.getHoldability());
+  }
+
+}
