diff --git a/src/test/java/com/databricks/jdbc/comparator/COMPARATOR_README.md b/src/test/java/com/databricks/jdbc/comparator/COMPARATOR_README.md
index 4a172f690..323b86ba3 100644
--- a/src/test/java/com/databricks/jdbc/comparator/COMPARATOR_README.md
+++ b/src/test/java/com/databricks/jdbc/comparator/COMPARATOR_README.md
@@ -263,6 +263,9 @@ If both are present for a method, **runOnly takes precedence**: argument combina
| `NEGATIVE_CONNECTION` | connect failures — bad token, unknown host, bad warehouse, bad ConnCatalog/ConnSchema (own broken connections) |
| `NEGATIVE_CANCEL_TIMEOUT` | cancel() mid/after/double + setQueryTimeout(1) on a slow query (own fresh connections) |
| `NEGATIVE_VOLUME` | GET/PUT/REMOVE failures + op-after-close (runs under the `VOLUME_OPERATIONS` config) |
+| `NEGATIVE_STATEMENT_SELECT_TRUNCATED` | Invalid setMaxRows/setLargeMaxRows values (negative row limits) |
+| `NEGATIVE_RESULTSET` | ResultSet misuse — next()/getObject() after close, out-of-range column (CloudFetch link-expiry not deterministically reproducible; see provider javadoc) |
+| `NEGATIVE_ASYNC` | Databricks async extension — getExecutionResult before/after execute, executeAsync on invalid SQL |
Negative suites compare each endpoint's **error behavior** (exception class, SQLState, vendor code,
message) via the `ERROR_COMPARISON_MODE` gate (default `shadow`). See
diff --git a/src/test/java/com/databricks/jdbc/comparator/config/TestSuite.java b/src/test/java/com/databricks/jdbc/comparator/config/TestSuite.java
index 2dad8bd46..f5cd29422 100644
--- a/src/test/java/com/databricks/jdbc/comparator/config/TestSuite.java
+++ b/src/test/java/com/databricks/jdbc/comparator/config/TestSuite.java
@@ -46,7 +46,12 @@ public enum TestSuite {
// VOLUME_OPERATIONS suite.
NEGATIVE_CONNECTION(new NegativeConnectionProvider()),
NEGATIVE_CANCEL_TIMEOUT(new NegativeCancelTimeoutProvider()),
- NEGATIVE_VOLUME(new NegativeVolumeProvider());
+ NEGATIVE_VOLUME(new NegativeVolumeProvider()),
+
+ // Specialized negative suites — row-limit rejection, ResultSet misuse, async extension API.
+ NEGATIVE_STATEMENT_SELECT_TRUNCATED(new NegativeStatementSelectTruncatedProvider()),
+ NEGATIVE_RESULTSET(new NegativeResultSetProvider()),
+ NEGATIVE_ASYNC(new NegativeAsyncProvider());
private final SuiteProvider provider;
diff --git a/src/test/java/com/databricks/jdbc/comparator/suite/NegativeAsyncProvider.java b/src/test/java/com/databricks/jdbc/comparator/suite/NegativeAsyncProvider.java
new file mode 100644
index 000000000..2e8db62dd
--- /dev/null
+++ b/src/test/java/com/databricks/jdbc/comparator/suite/NegativeAsyncProvider.java
@@ -0,0 +1,92 @@
+package com.databricks.jdbc.comparator.suite;
+
+import com.databricks.jdbc.api.IDatabricksStatement;
+import com.databricks.jdbc.comparator.ComparisonResult;
+import com.databricks.jdbc.comparator.error.CapturedOutcome;
+import com.databricks.jdbc.comparator.error.Captures;
+import com.databricks.jdbc.comparator.error.ErrorDiffs;
+import java.sql.Connection;
+import java.sql.ResultSet;
+import java.sql.Statement;
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * Negative async-execution cases for the Databricks extension API ({@link IDatabricksStatement}):
+ * {@code getExecutionResult()} before any async execution, {@code executeAsync} on invalid SQL, and
+ * {@code getExecutionResult()} after the async statement was closed. Compares how the two endpoints
+ * surface each failure via {@link ErrorDiffs}.
+ *
+ *
Obtains the extension via {@code stmt.unwrap(IDatabricksStatement.class)}. Read-only; safe on
+ * the shared connections. The statement is closed in a {@code finally}.
+ */
+public class NegativeAsyncProvider implements SuiteProvider {
+
+ private static final String TABLE = "comparator_tests.oss_jdbc_tests.test_result_set_types";
+
+ private static final String RESULT_BEFORE_EXEC =
+ "getExecutionResult() before any async execution";
+ private static final String ASYNC_INVALID_SQL = "executeAsync() on invalid SQL";
+ private static final String RESULT_AFTER_CLOSE =
+ "getExecutionResult() after the statement is closed";
+
+ @Override
+ public List getTestCases() {
+ return Arrays.asList(
+ new TestCase(RESULT_BEFORE_EXEC, RESULT_BEFORE_EXEC),
+ new TestCase(ASYNC_INVALID_SQL, ASYNC_INVALID_SQL),
+ new TestCase(RESULT_AFTER_CLOSE, RESULT_AFTER_CLOSE));
+ }
+
+ @Override
+ public ComparisonResult execute(
+ Connection conn1, Connection conn2, TestCase testCase, String label) throws Exception {
+ String id = testCase.getIdentifier();
+ CapturedOutcome left = Captures.capture(() -> runCase(conn1, id));
+ CapturedOutcome right = Captures.capture(() -> runCase(conn2, id));
+ ComparisonResult result = new ComparisonResult(label, id, testCase.getArgs());
+ ErrorDiffs.foldInto(result, left, right, "result ", "");
+ return result;
+ }
+
+ private Object runCase(Connection conn, String id) throws Exception {
+ Statement stmt = conn.createStatement();
+ IDatabricksStatement async = stmt.unwrap(IDatabricksStatement.class);
+ try {
+ switch (id) {
+ case RESULT_BEFORE_EXEC:
+ return async.getExecutionResult(); // no async execution has been submitted
+ case ASYNC_INVALID_SQL:
+ {
+ // executeAsync only SUBMITS; the syntax error surfaces on result retrieval. Poll
+ // getExecutionResult and drain it so the async failure is actually captured (not the
+ // successful submit).
+ async.executeAsync("SELET 1 FROM " + TABLE); // syntax error
+ for (int i = 0; i < 60; i++) {
+ ResultSet rs = async.getExecutionResult(); // throws if the async query failed
+ if (rs.next()) {
+ return "returned a row"; // unexpected — no error
+ }
+ // No row yet; if the query is still running, wait and re-poll.
+ Thread.sleep(500);
+ }
+ return "no error after polling";
+ }
+ case RESULT_AFTER_CLOSE:
+ {
+ async.executeAsync("SELECT id FROM " + TABLE + " LIMIT 1");
+ stmt.close();
+ return async.getExecutionResult(); // statement closed
+ }
+ default:
+ throw new IllegalArgumentException("Unknown case: " + id);
+ }
+ } finally {
+ try {
+ stmt.close();
+ } catch (Exception ignored) {
+ // best-effort; some cases close it deliberately above
+ }
+ }
+ }
+}
diff --git a/src/test/java/com/databricks/jdbc/comparator/suite/NegativeResultSetProvider.java b/src/test/java/com/databricks/jdbc/comparator/suite/NegativeResultSetProvider.java
new file mode 100644
index 000000000..8aacf926a
--- /dev/null
+++ b/src/test/java/com/databricks/jdbc/comparator/suite/NegativeResultSetProvider.java
@@ -0,0 +1,100 @@
+package com.databricks.jdbc.comparator.suite;
+
+import com.databricks.jdbc.comparator.ComparisonResult;
+import com.databricks.jdbc.comparator.error.CapturedOutcome;
+import com.databricks.jdbc.comparator.error.Captures;
+import com.databricks.jdbc.comparator.error.ErrorDiffs;
+import java.sql.Connection;
+import java.sql.ResultSet;
+import java.sql.Statement;
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * Negative ResultSet-iteration cases — mis-using a ResultSet after it (or its statement) is closed,
+ * and reading a column out of range. Compares how the two endpoints surface each failure via {@link
+ * ErrorDiffs}.
+ *
+ * Best-effort note: the originally-intended case — {@code next()} failing mid-iteration due to
+ * CloudFetch presigned-link expiry or a chunk-download failure — cannot be provoked
+ * deterministically from a client test (links are valid for the query's lifetime and expiry is
+ * time/infra dependent), so it is intentionally NOT included here. The cases below are the
+ * reliably-reproducible ResultSet misuse errors; CloudFetch-expiry comparison, if ever needed,
+ * would require server-side fault injection out of scope for this harness.
+ *
+ *
Read-only, safe on the shared connections.
+ */
+public class NegativeResultSetProvider implements SuiteProvider {
+
+ private static final String TABLE = "comparator_tests.oss_jdbc_tests.test_result_set_types";
+
+ private static final String NEXT_AFTER_CLOSE = "next() after the ResultSet is closed";
+ private static final String GET_AFTER_CLOSE = "getObject() after the ResultSet is closed";
+ private static final String GET_AFTER_STMT_CLOSE = "next() after the Statement is closed";
+ private static final String COLUMN_OUT_OF_RANGE = "getObject() on an out-of-range column index";
+
+ @Override
+ public List getTestCases() {
+ return Arrays.asList(
+ new TestCase(NEXT_AFTER_CLOSE, NEXT_AFTER_CLOSE),
+ new TestCase(GET_AFTER_CLOSE, GET_AFTER_CLOSE),
+ new TestCase(GET_AFTER_STMT_CLOSE, GET_AFTER_STMT_CLOSE),
+ new TestCase(COLUMN_OUT_OF_RANGE, COLUMN_OUT_OF_RANGE));
+ }
+
+ @Override
+ public ComparisonResult execute(
+ Connection conn1, Connection conn2, TestCase testCase, String label) throws Exception {
+ String id = testCase.getIdentifier();
+ CapturedOutcome left = Captures.capture(() -> runCase(conn1, id));
+ CapturedOutcome right = Captures.capture(() -> runCase(conn2, id));
+ ComparisonResult result = new ComparisonResult(label, id, testCase.getArgs());
+ ErrorDiffs.foldInto(result, left, right, "result ", "");
+ return result;
+ }
+
+ private Object runCase(Connection conn, String id) throws Exception {
+ switch (id) {
+ case NEXT_AFTER_CLOSE:
+ {
+ Statement s = conn.createStatement();
+ ResultSet rs = s.executeQuery("SELECT id FROM " + TABLE + " LIMIT 1");
+ rs.close();
+ try {
+ return rs.next(); // ResultSet closed
+ } finally {
+ s.close();
+ }
+ }
+ case GET_AFTER_CLOSE:
+ {
+ Statement s = conn.createStatement();
+ ResultSet rs = s.executeQuery("SELECT id FROM " + TABLE + " LIMIT 1");
+ rs.next();
+ rs.close();
+ try {
+ return rs.getObject(1); // ResultSet closed
+ } finally {
+ s.close();
+ }
+ }
+ case GET_AFTER_STMT_CLOSE:
+ {
+ Statement s = conn.createStatement();
+ ResultSet rs = s.executeQuery("SELECT id FROM " + TABLE + " LIMIT 1");
+ s.close(); // closing the statement closes the ResultSet
+ return rs.next();
+ }
+ case COLUMN_OUT_OF_RANGE:
+ {
+ try (Statement s = conn.createStatement();
+ ResultSet rs = s.executeQuery("SELECT id FROM " + TABLE + " LIMIT 1")) {
+ rs.next();
+ return rs.getObject(999); // out-of-range column index
+ }
+ }
+ default:
+ throw new IllegalArgumentException("Unknown case: " + id);
+ }
+ }
+}
diff --git a/src/test/java/com/databricks/jdbc/comparator/suite/NegativeStatementSelectTruncatedProvider.java b/src/test/java/com/databricks/jdbc/comparator/suite/NegativeStatementSelectTruncatedProvider.java
new file mode 100644
index 000000000..bc4443f53
--- /dev/null
+++ b/src/test/java/com/databricks/jdbc/comparator/suite/NegativeStatementSelectTruncatedProvider.java
@@ -0,0 +1,94 @@
+package com.databricks.jdbc.comparator.suite;
+
+import com.databricks.jdbc.comparator.ComparisonResult;
+import com.databricks.jdbc.comparator.error.CapturedOutcome;
+import com.databricks.jdbc.comparator.error.Captures;
+import com.databricks.jdbc.comparator.error.ErrorDiffs;
+import java.sql.Connection;
+import java.sql.Statement;
+import java.util.Arrays;
+import java.util.List;
+import java.util.stream.Collectors;
+
+/**
+ * Negative row-limit cases — invalid {@code setMaxRows}/{@code setLargeMaxRows} values that should
+ * be rejected, per JDBC ({@code setMaxRows} requires a non-negative limit). Compares how the two
+ * endpoints surface the rejection (at the setter or at execute) via {@link ErrorDiffs}.
+ *
+ * Read-only: sets a bad limit then runs a SELECT; safe on the shared connections. The whole
+ * set-then-execute sequence is captured per side since the failure may originate at either point.
+ */
+public class NegativeStatementSelectTruncatedProvider implements SuiteProvider {
+
+ private static final String TABLE = "comparator_tests.oss_jdbc_tests.test_result_set_types";
+ private static final String QUERY = "SELECT * FROM " + TABLE + " ORDER BY id LIMIT 100";
+
+ /** Applies a bad row-limit to a statement then runs the query; returns a ResultSet or throws. */
+ @FunctionalInterface
+ private interface LimitAction {
+ Object run(Statement stmt) throws Exception;
+ }
+
+ private static final class Case {
+ final String description;
+ final LimitAction action;
+
+ Case(String description, LimitAction action) {
+ this.description = description;
+ this.action = action;
+ }
+ }
+
+ private static final List CASES =
+ Arrays.asList(
+ new Case(
+ "setMaxRows(-1) — negative row limit",
+ stmt -> {
+ stmt.setMaxRows(-1);
+ return stmt.executeQuery(QUERY);
+ }),
+ new Case(
+ "setLargeMaxRows(-1) — negative large row limit",
+ stmt -> {
+ stmt.setLargeMaxRows(-1L);
+ return stmt.executeQuery(QUERY);
+ }),
+ new Case(
+ "setMaxRows(MIN_VALUE) — extreme negative row limit",
+ stmt -> {
+ stmt.setMaxRows(Integer.MIN_VALUE);
+ return stmt.executeQuery(QUERY);
+ }));
+
+ @Override
+ public List getTestCases() {
+ return CASES.stream()
+ .map(c -> new TestCase(c.description, c.description))
+ .collect(Collectors.toList());
+ }
+
+ @Override
+ public ComparisonResult execute(
+ Connection conn1, Connection conn2, TestCase testCase, String label) throws Exception {
+ Case c =
+ CASES.stream()
+ .filter(x -> x.description.equals(testCase.getIdentifier()))
+ .findFirst()
+ .orElseThrow(
+ () -> new IllegalArgumentException("Unknown case: " + testCase.getIdentifier()));
+
+ try (Statement stmt1 = conn1.createStatement();
+ Statement stmt2 = conn2.createStatement()) {
+ CapturedOutcome left = Captures.capture(() -> c.action.run(stmt1));
+ CapturedOutcome right = Captures.capture(() -> c.action.run(stmt2));
+ try {
+ ComparisonResult result = new ComparisonResult(label, c.description, testCase.getArgs());
+ ErrorDiffs.foldInto(result, left, right, "result ", "");
+ return result;
+ } finally {
+ Captures.closeIfResultSet(left.value());
+ Captures.closeIfResultSet(right.value());
+ }
+ }
+ }
+}