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

Issue:
feat: support converting arrays to ResultSet
`java.sql.Array` has an optional method `getResultSet()`. This returns the array as a `ResultSet` and allows a client application to consume the array in the same way as consuming a normal result set. This method is currently not supported by the Cloud Spanner JDBC driver, which causes some generic applications to return error messages or not displaying (all types of) arrays correctly.

Supporting the `java.sql.Array#getResultSet()` method enhances the user experience in applications that try to consume arrays as `ResultSet`s.

Pull request description:
feat: Support Array conversion to ResultSet
JDBC arrays have an optional method that allow them to be converted to `ResultSet`s. This method was not implemented for the Cloud Spanner JDBC driver. This would cause DBeaver to not be able to fetch and view the data of `NUMERIC` columns, as DBeaver would use this method for specifically those columns (other array columns would work). This PR fixes that specific problem and allows other users to use this convenience method for all array types.

Fixes #332 

![Screenshot from 2021-01-18 12-44-51](https://user-images.githubusercontent.com/1196707/104911616-713bc200-598b-11eb-9acd-9de89fc693b6.png)


Patch:
diff --git a/.github/readme/synth.metadata/synth.metadata b/.github/readme/synth.metadata/synth.metadata
index 6230cb5..69ebebb 100644
--- a/.github/readme/synth.metadata/synth.metadata
+++ b/.github/readme/synth.metadata/synth.metadata
@@ -4,14 +4,14 @@
       "git": {
         "name": ".",
         "remote": "https://github.com/googleapis/java-spanner-jdbc.git",
-        "sha": "5115ed8f45b4fe1c0424d25190cdfc8681ef388f"
+        "sha": "8b4e50d10a9121334be3d8b5ed0d8fc9ff63c182"
       }
     },
     {
       "git": {
         "name": "synthtool",
         "remote": "https://github.com/googleapis/synthtool.git",
-        "sha": "a3e990f3545dc8ccd384a75d20ce9cb185ca6a28"
+        "sha": "d1bb9173100f62c0cfc8f3138b62241e7f47ca6a"
       }
     }
   ]
diff --git a/.github/workflows/auto-release.yaml b/.github/workflows/auto-release.yaml
index 2b6cdbc..76e6404 100644
--- a/.github/workflows/auto-release.yaml
+++ b/.github/workflows/auto-release.yaml
@@ -17,7 +17,7 @@ jobs:
           }
 
           // only approve PRs like "chore: release <release version>"
-          if ( !context.payload.pull_request.title.startsWith("chore: release") ) {
+          if ( !context.payload.pull_request.title.startsWith("chore(master): release") ) {
             return;
           }
 
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index 6830220..98a472e 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -54,7 +54,18 @@ jobs:
       with:
         java-version: 8
     - run: java -version
-    - run: .kokoro/linkage-monitor.sh
+    - name: Install artifacts to local Maven repository
+      run: |
+        source .kokoro/common.sh
+        retry_with_backoff 3 10 \
+            mvn install -B -V \
+                -Dmaven.test.skip -DskipTests=true \
+                -Dclirr.skip=true \
+                -Denforcer.skip=true \
+                -Dmaven.javadoc.skip=true \
+                -Dgcloud.download.skip=true
+      shell: bash
+    - uses: GoogleCloudPlatform/cloud-opensource-java/linkage-monitor@v1-linkagemonitor
   lint:
     runs-on: ubuntu-latest
     steps:
diff --git a/.kokoro/build.sh b/.kokoro/build.sh
index b4005cc..27d9f2a 100755
--- a/.kokoro/build.sh
+++ b/.kokoro/build.sh
@@ -110,8 +110,8 @@ bash .kokoro/coerce_logs.sh
 
 if [[ "${ENABLE_BUILD_COP}" == "true" ]]
 then
-    chmod +x ${KOKORO_GFILE_DIR}/linux_amd64/buildcop
-    ${KOKORO_GFILE_DIR}/linux_amd64/buildcop -repo=googleapis/java-spanner-jdbc
+    chmod +x ${KOKORO_GFILE_DIR}/linux_amd64/flakybot
+    ${KOKORO_GFILE_DIR}/linux_amd64/flakybot -repo=googleapis/java-spanner-jdbc
 fi
 
 echo "exiting with ${RETURN_CODE}"
diff --git a/.kokoro/linkage-monitor.sh b/.kokoro/linkage-monitor.sh
deleted file mode 100755
index 759ab4e..0000000
--- a/.kokoro/linkage-monitor.sh
+++ /dev/null
@@ -1,46 +0,0 @@
-#!/bin/bash
-# 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.
-
-set -eo pipefail
-# Display commands being run.
-set -x
-
-## Get the directory of the build script
-scriptDir=$(realpath $(dirname "${BASH_SOURCE[0]}"))
-## cd to the parent directory, i.e. the root of the git repo
-cd ${scriptDir}/..
-
-# include common functions
-source ${scriptDir}/common.sh
-
-# Print out Java version
-java -version
-echo ${JOB_TYPE}
-
-# attempt to install 3 times with exponential backoff (starting with 10 seconds)
-retry_with_backoff 3 10 \
-  mvn install -B -V \
-    -DskipTests=true \
-    -Dclirr.skip=true \
-    -Denforcer.skip=true \
-    -Dmaven.javadoc.skip=true \
-    -Dgcloud.download.skip=true
-
-# Kokoro job cloud-opensource-java/ubuntu/linkage-monitor-gcs creates this JAR
-JAR=linkage-monitor-latest-all-deps.jar
-curl -v -O "https://storage.googleapis.com/cloud-opensource-java-linkage-monitor/${JAR}"
-
-# Fails if there's new linkage errors compared with baseline
-java -jar ${JAR} com.google.cloud:libraries-bom
diff --git a/.kokoro/release/publish_javadoc.cfg b/.kokoro/release/publish_javadoc.cfg
index 33cb6e7..448a04e 100644
--- a/.kokoro/release/publish_javadoc.cfg
+++ b/.kokoro/release/publish_javadoc.cfg
@@ -26,4 +26,4 @@ before_action {
       keyname: "docuploader_service_account"
     }
   }
-}
+}
\ No newline at end of file
diff --git a/.kokoro/release/publish_javadoc11.cfg b/.kokoro/release/publish_javadoc11.cfg
new file mode 100644
index 0000000..28074bc
--- /dev/null
+++ b/.kokoro/release/publish_javadoc11.cfg
@@ -0,0 +1,30 @@
+# Format: //devtools/kokoro/config/proto/build.proto
+
+env_vars: {
+  key: "STAGING_BUCKET_V2"
+  value: "docs-staging-v2"
+  # Production will be at: docs-staging-v2
+}
+
+# Configure the docker image for kokoro-trampoline
+env_vars: {
+  key: "TRAMPOLINE_IMAGE"
+  value: "gcr.io/cloud-devrel-kokoro-resources/java11"
+}
+
+env_vars: {
+  key: "TRAMPOLINE_BUILD_FILE"
+  value: "github/java-spanner-jdbc/.kokoro/release/publish_javadoc11.sh"
+}
+
+before_action {
+  fetch_keystore {
+    keystore_resource {
+      keystore_config_id: 73713
+      keyname: "docuploader_service_account"
+    }
+  }
+}
+
+# Downloads docfx doclet resource. This will be in ${KOKORO_GFILE_DIR}/<doclet name>
+gfile_resources: "/bigstore/cloud-devrel-kokoro-resources/docfx"
diff --git a/.kokoro/release/publish_javadoc11.sh b/.kokoro/release/publish_javadoc11.sh
new file mode 100755
index 0000000..dc26611
--- /dev/null
+++ b/.kokoro/release/publish_javadoc11.sh
@@ -0,0 +1,55 @@
+#!/bin/bash
+# Copyright 2021 Google Inc.
+#
+# 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.
+
+set -eo pipefail
+
+if [[ -z "${CREDENTIALS}" ]]; then
+  CREDENTIALS=${KOKORO_KEYSTORE_DIR}/73713_docuploader_service_account
+fi
+
+if [[ -z "${STAGING_BUCKET_V2}" ]]; then
+  echo "Need to set STAGING_BUCKET_V2 environment variable"
+  exit 1
+fi
+
+# work from the git root directory
+pushd $(dirname "$0")/../../
+
+# install docuploader package
+python3 -m pip install gcp-docuploader
+
+# compile all packages
+mvn clean install -B -q -DskipTests=true
+
+export NAME=google-cloud-spanner-jdbc
+export VERSION=$(grep ${NAME}: versions.txt | cut -d: -f3)
+
+# V3 generates docfx yml from javadoc
+# generate yml
+mvn clean site -B -q -P docFX
+
+pushd target/docfx-yml
+
+# create metadata
+python3 -m docuploader create-metadata \
+ --name ${NAME} \
+ --version ${VERSION} \
+ --language java
+
+# upload yml
+python3 -m docuploader upload . \
+ --credentials ${CREDENTIALS} \
+ --staging-bucket ${STAGING_BUCKET_V2} \
+ --destination-prefix docfx-
diff --git a/.readme-partials.yaml b/.readme-partials.yaml
index 47e65e1..c407ffc 100644
--- a/.readme-partials.yaml
+++ b/.readme-partials.yaml
@@ -23,3 +23,14 @@ custom_content: |
      }
    }
    ```
+   
+   ### Creating a Shaded Jar
+   
+   A jar with all dependencies included is automatically generated when you execute `mvn package`.
+   The dependencies in this jar are not shaded. To create a jar with shaded dependencies you must
+   activate the `shade` profile like this:
+   
+    ```
+    mvn package -Pshade
+    ```
+   
\ No newline at end of file
diff --git a/LICENSE b/LICENSE
index d645695..261eeb9 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,4 +1,3 @@
-
                                  Apache License
                            Version 2.0, January 2004
                         http://www.apache.org/licenses/
diff --git a/README.md b/README.md
index ae46cf8..bcdfb37 100644
--- a/README.md
+++ b/README.md
@@ -84,6 +84,16 @@ try (Connection connection =
 }
 ```
 
+### Creating a Shaded Jar
+
+A jar with all dependencies included is automatically generated when you execute `mvn package`.
+The dependencies in this jar are not shaded. To create a jar with shaded dependencies you must
+activate the `shade` profile like this:
+
+ ```
+ mvn package -Pshade
+ ```
+
 
 
 
diff --git a/pom.xml b/pom.xml
index 9797798..b971e4e 100644
--- a/pom.xml
+++ b/pom.xml
@@ -14,7 +14,7 @@
   <parent>
     <groupId>com.google.cloud</groupId>
     <artifactId>google-cloud-shared-config</artifactId>
-    <version>0.9.4</version>
+    <version>0.10.0</version>
   </parent>
   <developers>
     <developer>
@@ -71,7 +71,7 @@
       <dependency>
         <groupId>com.google.cloud</groupId>
         <artifactId>google-cloud-spanner-bom</artifactId>
-        <version>3.3.1</version>
+        <version>3.3.2</version>
         <type>pom</type>
         <scope>import</scope>
       </dependency>
@@ -309,6 +309,50 @@
       </plugins>
     </pluginManagement>
   </build>
+  
+  <profiles>
+    <profile>
+      <id>shade</id>
+      <build>
+        <plugins>
+          <plugin>
+            <groupId>org.apache.maven.plugins</groupId>
+            <artifactId>maven-shade-plugin</artifactId>
+            <configuration>
+              <relocations>
+                <relocation>
+                  <pattern>com</pattern>
+                  <shadedPattern>com.google.cloud.spanner.jdbc.shaded.com</shadedPattern>
+                  <excludes>
+                    <exclude>com.google.cloud.spanner.**</exclude>
+                  </excludes>
+                </relocation>
+                <relocation>
+                  <pattern>android</pattern>
+                  <shadedPattern>com.google.cloud.spanner.jdbc.shaded.android</shadedPattern>
+                </relocation>
+                <relocation>
+                  <pattern>io</pattern>
+                  <shadedPattern>com.google.cloud.spanner.jdbc.shaded.io</shadedPattern>
+                  <excludes>
+                    <exclude>io.grpc.netty.shaded.**</exclude>
+                  </excludes>
+                </relocation>
+                <relocation>
+                  <pattern>org</pattern>
+                  <shadedPattern>com.google.cloud.spanner.jdbc.shaded.org</shadedPattern>
+                  <excludes>
+                    <exclude>org.conscrypt.**</exclude>
+                  </excludes>
+                </relocation>
+              </relocations>
+            </configuration>
+          </plugin>
+        </plugins>
+      </build>
+    </profile>
+  </profiles>
+  
   <reporting>
     <plugins>
       <plugin>
diff --git a/samples/install-without-bom/pom.xml b/samples/install-without-bom/pom.xml
index 5df6d9a..029ad86 100644
--- a/samples/install-without-bom/pom.xml
+++ b/samples/install-without-bom/pom.xml
@@ -42,7 +42,7 @@
     <dependency>
       <groupId>com.google.truth</groupId>
       <artifactId>truth</artifactId>
-      <version>1.1</version>
+      <version>1.1.2</version>
       <scope>test</scope>
     </dependency>
   </dependencies>
diff --git a/samples/snapshot/pom.xml b/samples/snapshot/pom.xml
index 50cb9e5..36049d4 100644
--- a/samples/snapshot/pom.xml
+++ b/samples/snapshot/pom.xml
@@ -41,7 +41,7 @@
     <dependency>
       <groupId>com.google.truth</groupId>
       <artifactId>truth</artifactId>
-      <version>1.1</version>
+      <version>1.1.2</version>
       <scope>test</scope>
     </dependency>
   </dependencies>
diff --git a/samples/snippets/pom.xml b/samples/snippets/pom.xml
index 69cbd88..be53fa1 100644
--- a/samples/snippets/pom.xml
+++ b/samples/snippets/pom.xml
@@ -53,7 +53,7 @@
     <dependency>
       <groupId>com.google.truth</groupId>
       <artifactId>truth</artifactId>
-      <version>1.1</version>
+      <version>1.1.2</version>
       <scope>test</scope>
     </dependency>
   </dependencies>
diff --git a/src/main/java/com/google/cloud/spanner/jdbc/JdbcArray.java b/src/main/java/com/google/cloud/spanner/jdbc/JdbcArray.java
index 67d8e0c..2a9d9e7 100644
--- a/src/main/java/com/google/cloud/spanner/jdbc/JdbcArray.java
+++ b/src/main/java/com/google/cloud/spanner/jdbc/JdbcArray.java
@@ -16,11 +16,21 @@
 
 package com.google.cloud.spanner.jdbc;
 
+import com.google.cloud.ByteArray;
+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.ValueBinder;
+import com.google.common.collect.ImmutableList;
 import com.google.rpc.Code;
+import java.math.BigDecimal;
 import java.sql.Array;
+import java.sql.Date;
 import java.sql.ResultSet;
 import java.sql.SQLException;
 import java.sql.SQLFeatureNotSupportedException;
+import java.sql.Timestamp;
 import java.util.Arrays;
 import java.util.List;
 import java.util.Map;
@@ -137,28 +147,89 @@ class JdbcArray implements Array {
     return null;
   }
 
-  private static final String RESULTSET_NOT_SUPPORTED =
-      "Getting a ResultSet from an array is not supported";
+  private static final String RESULTSET_WITH_TYPE_MAPPING_NOT_SUPPORTED =
+      "Getting a ResultSet with a custom type mapping from an array is not supported";
 
   @Override
   public ResultSet getResultSet() throws SQLException {
-    throw new SQLFeatureNotSupportedException(RESULTSET_NOT_SUPPORTED);
+    return getResultSet(1L, Integer.MAX_VALUE);
   }
 
   @Override
   public ResultSet getResultSet(Map<String, Class<?>> map) throws SQLException {
-    throw new SQLFeatureNotSupportedException(RESULTSET_NOT_SUPPORTED);
+    throw new SQLFeatureNotSupportedException(RESULTSET_WITH_TYPE_MAPPING_NOT_SUPPORTED);
   }
 
   @Override
-  public ResultSet getResultSet(long index, int count) throws SQLException {
-    throw new SQLFeatureNotSupportedException(RESULTSET_NOT_SUPPORTED);
+  public ResultSet getResultSet(long startIndex, int count) throws SQLException {
+    JdbcPreconditions.checkArgument(
+        startIndex + count - 1L <= Integer.MAX_VALUE,
+        String.format("End index cannot exceed %d", Integer.MAX_VALUE));
+    JdbcPreconditions.checkArgument(startIndex >= 1L, "Start index must be >= 1");
+    JdbcPreconditions.checkArgument(count >= 0, "Count must be >= 0");
+    checkFree();
+    ImmutableList.Builder<Struct> rows = ImmutableList.builder();
+    int added = 0;
+    if (data != null) {
+      // Note that array index in JDBC is base-one.
+      for (int index = (int) startIndex;
+          added < count && index <= ((Object[]) data).length;
+          index++) {
+        Object value = ((Object[]) data)[index - 1];
+        ValueBinder<Struct.Builder> binder =
+            Struct.newBuilder().set("INDEX").to(index).set("VALUE");
+        Struct.Builder builder = null;
+        switch (type.getCode()) {
+          case BOOL:
+            builder = binder.to((Boolean) value);
+            break;
+          case BYTES:
+            builder = binder.to(ByteArray.copyFrom((byte[]) value));
+            break;
+          case DATE:
+            builder = binder.to(JdbcTypeConverter.toGoogleDate((Date) value));
+            break;
+          case FLOAT64:
+            builder = binder.to((Double) value);
+            break;
+          case INT64:
+            builder = binder.to((Long) value);
+            break;
+          case NUMERIC:
+            builder = binder.to((BigDecimal) value);
+            break;
+          case STRING:
+            builder = binder.to((String) value);
+            break;
+          case TIMESTAMP:
+            builder = binder.to(JdbcTypeConverter.toGoogleTimestamp((Timestamp) value));
+            break;
+          case ARRAY:
+          case STRUCT:
+          default:
+            throw new SQLFeatureNotSupportedException(
+                String.format(
+                    "Array of type %s cannot be converted to a ResultSet", type.getCode().name()));
+        }
+        rows.add(builder.build());
+        added++;
+        if (added == count) {
+          break;
+        }
+      }
+    }
+    return JdbcResultSet.of(
+        ResultSets.forRows(
+            Type.struct(
+                StructField.of("INDEX", Type.int64()),
+                StructField.of("VALUE", type.getSpannerType())),
+            rows.build()));
   }
 
   @Override
   public ResultSet getResultSet(long index, int count, Map<String, Class<?>> map)
       throws SQLException {
-    throw new SQLFeatureNotSupportedException(RESULTSET_NOT_SUPPORTED);
+    throw new SQLFeatureNotSupportedException(RESULTSET_WITH_TYPE_MAPPING_NOT_SUPPORTED);
   }
 
   @Override
diff --git a/synth.metadata b/synth.metadata
index f442a9f..6c9056a 100644
--- a/synth.metadata
+++ b/synth.metadata
@@ -4,14 +4,14 @@
       "git": {
         "name": ".",
         "remote": "https://github.com/googleapis/java-spanner-jdbc.git",
-        "sha": "0e557ef7657cae04d263daa6717ee34290338b7a"
+        "sha": "81a844413be775520dcf15ba74938a6c1cf36442"
       }
     },
     {
       "git": {
         "name": "synthtool",
         "remote": "https://github.com/googleapis/synthtool.git",
-        "sha": "6133907dbb3ddab204a17a15d5c53ec0aae9b033"
+        "sha": "692715c0f23a7bb3bfbbaa300f7620ddfa8c47e5"
       }
     }
   ],
@@ -40,7 +40,6 @@
     ".kokoro/continuous/java8.cfg",
     ".kokoro/continuous/readme.cfg",
     ".kokoro/dependencies.sh",
-    ".kokoro/linkage-monitor.sh",
     ".kokoro/nightly/common.cfg",
     ".kokoro/nightly/integration.cfg",
     ".kokoro/nightly/java11.cfg",
@@ -72,6 +71,8 @@
     ".kokoro/release/promote.sh",
     ".kokoro/release/publish_javadoc.cfg",
     ".kokoro/release/publish_javadoc.sh",
+    ".kokoro/release/publish_javadoc11.cfg",
+    ".kokoro/release/publish_javadoc11.sh",
     ".kokoro/release/snapshot.cfg",
     ".kokoro/release/snapshot.sh",
     ".kokoro/release/stage.cfg",


Test patch:
diff --git a/src/test/java/com/google/cloud/spanner/jdbc/JdbcArrayTest.java b/src/test/java/com/google/cloud/spanner/jdbc/JdbcArrayTest.java
index 01184c2..24e3e57 100644
--- a/src/test/java/com/google/cloud/spanner/jdbc/JdbcArrayTest.java
+++ b/src/test/java/com/google/cloud/spanner/jdbc/JdbcArrayTest.java
@@ -16,10 +16,16 @@
 
 package com.google.cloud.spanner.jdbc;
 
-import static org.junit.Assert.assertEquals;
+import static com.google.cloud.spanner.jdbc.JdbcTypeConverter.toSqlDate;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.fail;
 
+import com.google.cloud.spanner.ErrorCode;
+import com.google.cloud.spanner.jdbc.JdbcSqlExceptionFactory.JdbcSqlExceptionImpl;
 import java.math.BigDecimal;
 import java.sql.Date;
+import java.sql.ResultSet;
+import java.sql.ResultSetMetaData;
 import java.sql.SQLException;
 import java.sql.Timestamp;
 import java.sql.Types;
@@ -35,42 +41,208 @@ public class JdbcArrayTest {
     // Note that JDBC array indices start at 1.
     JdbcArray array;
     array = JdbcArray.createArray("BOOL", new Boolean[] {true, false, true});
-    assertEquals(array.getBaseType(), Types.BOOLEAN);
-    assertEquals(((Boolean[]) array.getArray(1, 1))[0], Boolean.TRUE);
+    assertThat(array.getBaseType()).isEqualTo(Types.BOOLEAN);
+    assertThat(((Boolean[]) array.getArray(1, 1))[0]).isEqualTo(Boolean.TRUE);
+    try (ResultSet rs = array.getResultSet()) {
+      assertThat(rs.next()).isTrue();
+      assertThat(rs.getBoolean(2)).isEqualTo(true);
+      assertThat(rs.next()).isTrue();
+      assertThat(rs.getBoolean(2)).isEqualTo(false);
+      assertThat(rs.next()).isTrue();
+      assertThat(rs.getBoolean(2)).isEqualTo(true);
+      assertThat(rs.next()).isFalse();
+    }
 
     array = JdbcArray.createArray("BYTES", new byte[][] {new byte[] {1, 2}, new byte[] {3, 4}});
-    assertEquals(array.getBaseType(), Types.BINARY);
-    assertEquals(((byte[][]) array.getArray(1, 1))[0][1], (byte) 2);
+    assertThat(array.getBaseType()).isEqualTo(Types.BINARY);
+    assertThat(((byte[][]) array.getArray(1, 1))[0][1]).isEqualTo((byte) 2);
+    try (ResultSet rs = array.getResultSet()) {
+      assertThat(rs.next()).isTrue();
+      assertThat(rs.getBytes(2)).isEqualTo(new byte[] {1, 2});
+      assertThat(rs.next()).isTrue();
+      assertThat(rs.getBytes(2)).isEqualTo(new byte[] {3, 4});
+      assertThat(rs.next()).isFalse();
+    }
 
     array =
-        JdbcArray.createArray("DATE", new Date[] {new Date(1L), new Date(100L), new Date(1000L)});
-    assertEquals(array.getBaseType(), Types.DATE);
-    assertEquals(((Date[]) array.getArray(1, 1))[0], new Date(1L));
+        JdbcArray.createArray(
+            "DATE",
+            new Date[] {
+              toSqlDate(com.google.cloud.Date.fromYearMonthDay(2021, 1, 18)),
+              toSqlDate(com.google.cloud.Date.fromYearMonthDay(2000, 2, 29)),
+              toSqlDate(com.google.cloud.Date.fromYearMonthDay(2019, 8, 31))
+            });
+    assertThat(array.getBaseType()).isEqualTo(Types.DATE);
+    assertThat(((Date[]) array.getArray(1, 1))[0])
+        .isEqualTo(toSqlDate(com.google.cloud.Date.fromYearMonthDay(2021, 1, 18)));
+    try (ResultSet rs = array.getResultSet()) {
+      assertThat(rs.next()).isTrue();
+      assertThat(rs.getDate(2))
+          .isEqualTo(toSqlDate(com.google.cloud.Date.fromYearMonthDay(2021, 1, 18)));
+      assertThat(rs.next()).isTrue();
+      assertThat(rs.getDate(2))
+          .isEqualTo(toSqlDate(com.google.cloud.Date.fromYearMonthDay(2000, 2, 29)));
+      assertThat(rs.next()).isTrue();
+      assertThat(rs.getDate(2))
+          .isEqualTo(toSqlDate(com.google.cloud.Date.fromYearMonthDay(2019, 8, 31)));
+      assertThat(rs.next()).isFalse();
+    }
 
     array = JdbcArray.createArray("FLOAT64", new Double[] {1.1D, 2.2D, Math.PI});
-    assertEquals(array.getBaseType(), Types.DOUBLE);
-    assertEquals(((Double[]) array.getArray(1, 3))[2], Double.valueOf(Math.PI));
+    assertThat(array.getBaseType()).isEqualTo(Types.DOUBLE);
+    assertThat(((Double[]) array.getArray(1, 3))[2]).isEqualTo(Double.valueOf(Math.PI));
+    try (ResultSet rs = array.getResultSet()) {
+      assertThat(rs.next()).isTrue();
+      assertThat(rs.getDouble(2)).isEqualTo(1.1D);
+      assertThat(rs.next()).isTrue();
+      assertThat(rs.getDouble(2)).isEqualTo(2.2D);
+      assertThat(rs.next()).isTrue();
+      assertThat(rs.getDouble(2)).isEqualTo(Math.PI);
+      assertThat(rs.next()).isFalse();
+    }
 
     array = JdbcArray.createArray("INT64", new Long[] {1L, 2L, 3L});
-    assertEquals(array.getBaseType(), Types.BIGINT);
-    assertEquals(((Long[]) array.getArray(1, 1))[0], Long.valueOf(1L));
+    assertThat(array.getBaseType()).isEqualTo(Types.BIGINT);
+    assertThat(((Long[]) array.getArray(1, 1))[0]).isEqualTo(Long.valueOf(1L));
+    try (ResultSet rs = array.getResultSet()) {
+      assertThat(rs.next()).isTrue();
+      assertThat(rs.getLong(2)).isEqualTo(1L);
+      assertThat(rs.next()).isTrue();
+      assertThat(rs.getLong(2)).isEqualTo(2L);
+      assertThat(rs.next()).isTrue();
+      assertThat(rs.getLong(2)).isEqualTo(3L);
+      assertThat(rs.next()).isFalse();
+    }
 
     array =
         JdbcArray.createArray("NUMERIC", new BigDecimal[] {BigDecimal.ONE, null, BigDecimal.TEN});
-    assertEquals(array.getBaseType(), Types.NUMERIC);
-    assertEquals(((BigDecimal[]) array.getArray(1, 1))[0], BigDecimal.ONE);
-    assertEquals(((BigDecimal[]) array.getArray(2, 1))[0], null);
-    assertEquals(((BigDecimal[]) array.getArray(3, 1))[0], BigDecimal.TEN);
+    assertThat(array.getBaseType()).isEqualTo(Types.NUMERIC);
+    assertThat(((BigDecimal[]) array.getArray(1, 1))[0]).isEqualTo(BigDecimal.ONE);
+    assertThat(((BigDecimal[]) array.getArray(2, 1))[0]).isNull();
+    assertThat(((BigDecimal[]) array.getArray(3, 1))[0]).isEqualTo(BigDecimal.TEN);
+    try (ResultSet rs = array.getResultSet()) {
+      assertThat(rs.next()).isTrue();
+      assertThat(rs.getBigDecimal(2)).isEqualTo(BigDecimal.ONE);
+      assertThat(rs.next()).isTrue();
+      assertThat(rs.getBigDecimal(2)).isNull();
+      assertThat(rs.next()).isTrue();
+      assertThat(rs.getBigDecimal(2)).isEqualTo(BigDecimal.TEN);
+      assertThat(rs.next()).isFalse();
+    }
 
     array = JdbcArray.createArray("STRING", new String[] {"foo", "bar", "baz"});
-    assertEquals(array.getBaseType(), Types.NVARCHAR);
-    assertEquals(((String[]) array.getArray(1, 1))[0], "foo");
+    assertThat(array.getBaseType()).isEqualTo(Types.NVARCHAR);
+    assertThat(((String[]) array.getArray(1, 1))[0]).isEqualTo("foo");
+    try (ResultSet rs = array.getResultSet()) {
+      assertThat(rs.next()).isTrue();
+      assertThat(rs.getString(2)).isEqualTo("foo");
+      assertThat(rs.next()).isTrue();
+      assertThat(rs.getString(2)).isEqualTo("bar");
+      assertThat(rs.next()).isTrue();
+      assertThat(rs.getString(2)).isEqualTo("baz");
+      assertThat(rs.next()).isFalse();
+    }
 
     array =
         JdbcArray.createArray(
             "TIMESTAMP",
             new Timestamp[] {new Timestamp(1L), new Timestamp(100L), new Timestamp(1000L)});
-    assertEquals(array.getBaseType(), Types.TIMESTAMP);
-    assertEquals(((Timestamp[]) array.getArray(1, 1))[0], new Timestamp(1L));
+    assertThat(array.getBaseType()).isEqualTo(Types.TIMESTAMP);
+    assertThat(((Timestamp[]) array.getArray(1, 1))[0]).isEqualTo(new Timestamp(1L));
+    try (ResultSet rs = array.getResultSet()) {
+      assertThat(rs.next()).isTrue();
+      assertThat(rs.getTimestamp(2)).isEqualTo(new Timestamp(1L));
+      assertThat(rs.next()).isTrue();
+      assertThat(rs.getTimestamp(2)).isEqualTo(new Timestamp(100L));
+      assertThat(rs.next()).isTrue();
+      assertThat(rs.getTimestamp(2)).isEqualTo(new Timestamp(1000L));
+      assertThat(rs.next()).isFalse();
+    }
+  }
+
+  @Test
+  public void testCreateArrayOfArray() {
+    try {
+      JdbcArray.createArray("ARRAY<STRING>", new String[][] {{}});
+      fail("missing expected exception");
+    } catch (SQLException e) {
+      assertThat((Exception) e).isInstanceOf(JdbcSqlException.class);
+      JdbcSqlException jse = (JdbcSqlException) e;
+      assertThat(jse.getErrorCode())
+          .isEqualTo(ErrorCode.INVALID_ARGUMENT.getGrpcStatusCode().value());
+    }
+  }
+
+  @Test
+  public void testCreateArrayOfStruct() {
+    try {
+      JdbcArray.createArray("STRUCT", new Object[] {});
+      fail("missing expected exception");
+    } catch (SQLException e) {
+      assertThat((Exception) e).isInstanceOf(JdbcSqlException.class);
+      JdbcSqlException jse = (JdbcSqlException) e;
+      assertThat(jse.getErrorCode())
+          .isEqualTo(ErrorCode.INVALID_ARGUMENT.getGrpcStatusCode().value());
+    }
+  }
+
+  @Test
+  public void testGetResultSetMetadata() throws SQLException {
+    JdbcArray array = JdbcArray.createArray("STRING", new String[] {"foo", "bar", "baz"});
+    try (ResultSet rs = array.getResultSet()) {
+      ResultSetMetaData metadata = rs.getMetaData();
+      assertThat(metadata.getColumnCount()).isEqualTo(2);
+      assertThat(metadata.getColumnType(1)).isEqualTo(Types.BIGINT);
+      assertThat(metadata.getColumnType(2)).isEqualTo(Types.NVARCHAR);
+      assertThat(metadata.getColumnName(1)).isEqualTo("INDEX");
+      assertThat(metadata.getColumnName(2)).isEqualTo("VALUE");
+    }
+  }
+
+  @Test
+  public void testGetResultSetWithIndex() throws SQLException {
+    JdbcArray array = JdbcArray.createArray("STRING", new String[] {"foo", "bar", "baz"});
+    try (ResultSet rs = array.getResultSet(2L, 1)) {
+      assertThat(rs.next()).isTrue();
+      assertThat(rs.getLong("INDEX")).isEqualTo(2L);
+      assertThat(rs.getString("VALUE")).isEqualTo("bar");
+      assertThat(rs.next()).isFalse();
+    }
+
+    try (ResultSet rs = array.getResultSet(1L, 5)) {
+      assertThat(rs.next()).isTrue();
+      assertThat(rs.getString(2)).isEqualTo("foo");
+      assertThat(rs.next()).isTrue();
+      assertThat(rs.getString(2)).isEqualTo("bar");
+      assertThat(rs.next()).isTrue();
+      assertThat(rs.getString(2)).isEqualTo("baz");
+      assertThat(rs.next()).isFalse();
+    }
+
+    try (ResultSet rs = array.getResultSet(1L, 0)) {
+      assertThat(rs.next()).isFalse();
+    }
+  }
+
+  @Test
+  public void testGetResultSetWithInvalidIndex() throws SQLException {
+    JdbcArray array = JdbcArray.createArray("STRING", new String[] {"foo", "bar", "baz"});
+    try (ResultSet rs = array.getResultSet(0L, 1)) {
+      fail("missing expected exception");
+    } catch (JdbcSqlExceptionImpl e) {
+      assertThat(e.getErrorCode())
+          .isEqualTo(ErrorCode.INVALID_ARGUMENT.getGrpcStatusCode().value());
+    }
+  }
+
+  @Test
+  public void testGetResultSetWithInvalidCount() throws SQLException {
+    JdbcArray array = JdbcArray.createArray("STRING", new String[] {"foo", "bar", "baz"});
+    try (ResultSet rs = array.getResultSet(1L, -1)) {
+      fail("missing expected exception");
+    } catch (JdbcSqlExceptionImpl e) {
+      assertThat(e.getErrorCode())
+          .isEqualTo(ErrorCode.INVALID_ARGUMENT.getGrpcStatusCode().value());
+    }
   }
 }