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()); + } + } + } +}