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 6d52e2876..f9e5fa31c 100644 --- a/src/test/java/com/databricks/jdbc/comparator/COMPARATOR_README.md +++ b/src/test/java/com/databricks/jdbc/comparator/COMPARATOR_README.md @@ -250,4 +250,13 @@ If both are present for a method, **runOnly takes precedence**: argument combina | `NULL_HANDLING` | wasNull() verification | | `VOLUME_OPERATIONS` | UC Volume PUT / GET / DELETE | | `DATABASE_METADATA` | All DatabaseMetaData methods (~13,500 argument combinations) | +| `NEGATIVE_STATEMENT_SELECT` | Error-provoking SELECTs (missing table/column, syntax, cast, wrong method) | +| `NEGATIVE_STATEMENT_OTHER` | Error-provoking SHOW / DESCRIBE / EXPLAIN / SET + JDBC-API misuse | +| `NEGATIVE_PARAM_BINDING` | Bad PreparedStatement bindings (index, count, type, precision) | +| `NEGATIVE_PREPARED_METADATA` | clearParameters + unbound execute; getMetaData on invalid SQL | +| `NEGATIVE_TYPE_CONVERSION` | Incompatible ResultSet.getX() conversions (overflow, wrong target) | + +Negative suites compare each endpoint's **error behavior** (exception class, SQLState, vendor code, +message) via the `ERROR_COMPARISON_MODE` gate (default `shadow`). See +[`error/`](error/) for the comparison engine. diff --git a/src/test/java/com/databricks/jdbc/comparator/config/Endpoint.java b/src/test/java/com/databricks/jdbc/comparator/config/Endpoint.java index c22f9cbe5..5c076c4e2 100644 --- a/src/test/java/com/databricks/jdbc/comparator/config/Endpoint.java +++ b/src/test/java/com/databricks/jdbc/comparator/config/Endpoint.java @@ -9,8 +9,10 @@ * *
    *
  1. {@code _HTTP_PATH} — full path, escape hatch. - *
  2. {@code _CLUSTER} — {@code orgId:clusterId} → {@code /sql/protocolv1/o//}. - *
  3. {@code _WAREHOUSE} — {@code } → {@code /sql/1.0/warehouses/}. + *
  4. {@code _CLUSTER} — {@code orgId:clusterId} → {@code + * /sql/protocolv1/o//}. + *
  5. {@code _WAREHOUSE} — {@code } → {@code + * /sql/1.0/warehouses/}. *
* *

If none of the above is set on either side, callers should fall back to the legacy @@ -95,7 +97,10 @@ private static String resolvePath(String prefix) { throw new IllegalArgumentException( prefix + "CLUSTER must be of the form orgId:clusterId, got: " + cluster); } - return "/sql/protocolv1/o/" + cluster.substring(0, colon) + "/" + cluster.substring(colon + 1); + return "/sql/protocolv1/o/" + + cluster.substring(0, colon) + + "/" + + cluster.substring(colon + 1); } String warehouse = System.getProperty(prefix + "WAREHOUSE"); @@ -114,8 +119,7 @@ private static String normalizeTransport(String transport) { if (transport == null) return SEA; String lower = transport.toLowerCase(); if (!SEA.equals(lower) && !THRIFT.equals(lower)) { - throw new IllegalArgumentException( - "transport must be 'sea' or 'thrift', got: " + transport); + throw new IllegalArgumentException("transport must be 'sea' or 'thrift', got: " + transport); } return lower; } 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 5b95794ea..8c8b8b3c4 100644 --- a/src/test/java/com/databricks/jdbc/comparator/config/TestSuite.java +++ b/src/test/java/com/databricks/jdbc/comparator/config/TestSuite.java @@ -20,7 +20,14 @@ public enum TestSuite { GEOSPATIAL(new GeospatialProvider()), NULL_HANDLING(new NullHandlingProvider()), VOLUME_OPERATIONS(new VolumeOperationsProvider()), - DATABASE_METADATA(new DatabaseMetaDataProvider()); + DATABASE_METADATA(new DatabaseMetaDataProvider()), + + // Negative (error-provoking) suites — read-only, run on the shared connections. + NEGATIVE_STATEMENT_SELECT(new NegativeStatementSelectProvider()), + NEGATIVE_STATEMENT_OTHER(new NegativeStatementOtherProvider()), + NEGATIVE_PARAM_BINDING(new NegativeParamBindingProvider()), + NEGATIVE_PREPARED_METADATA(new NegativePreparedMetadataProvider()), + NEGATIVE_TYPE_CONVERSION(new NegativeTypeConversionProvider()); private final SuiteProvider provider; diff --git a/src/test/java/com/databricks/jdbc/comparator/suite/NegativeParamBindingProvider.java b/src/test/java/com/databricks/jdbc/comparator/suite/NegativeParamBindingProvider.java new file mode 100644 index 000000000..6f001c98b --- /dev/null +++ b/src/test/java/com/databricks/jdbc/comparator/suite/NegativeParamBindingProvider.java @@ -0,0 +1,103 @@ +package com.databricks.jdbc.comparator.suite; + +import com.databricks.jdbc.comparator.ComparisonResult; +import com.databricks.jdbc.comparator.ResultSetComparator; +import com.databricks.jdbc.comparator.error.Captures; +import java.math.BigDecimal; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.util.Arrays; +import java.util.List; + +/** + * Negative parameter-binding cases — bad {@code setXxx} usage or missing bindings that should fail + * at bind or execute time. The failure can originate at the setter or at {@code executeQuery}, so + * the whole prepare → set → execute sequence is captured per side as one unit and the endpoints' + * error behavior is compared. + * + *

Read-only: each case only reads (or fails before reading), so it is safe on the shared + * connections. + */ +public class NegativeParamBindingProvider implements SuiteProvider { + + private static final String TABLE = "comparator_tests.oss_jdbc_tests.test_result_set_types"; + + /** A binding action that may throw at set or execute time; returns a ResultSet on success. */ + @FunctionalInterface + private interface BindAndRun { + Object run(PreparedStatement ps) throws Exception; + } + + private static final class Case { + final String description; + final String sql; + final BindAndRun action; + + Case(String description, String sql, BindAndRun action) { + this.description = description; + this.sql = sql; + this.action = action; + } + } + + private static final List CASES = + Arrays.asList( + new Case( + "setInt at out-of-range parameter index (99)", + "SELECT integer_column FROM " + TABLE + " WHERE integer_column = ?", + ps -> { + ps.setInt(99, 42); + return ps.executeQuery(); + }), + new Case( + "Fewer parameters bound than placeholders", + "SELECT * FROM " + TABLE + " WHERE integer_column = ? AND bigint_column = ?", + ps -> { + ps.setInt(1, 42); // second placeholder left unbound + return ps.executeQuery(); + }), + new Case( + "Type mismatch: setString into a numeric column", + "SELECT integer_column FROM " + TABLE + " WHERE integer_column = ?", + ps -> { + ps.setString(1, "not_a_number"); + return ps.executeQuery(); + }), + new Case( + "setBigDecimal exceeding column precision/scale", + "SELECT decimal_column FROM " + TABLE + " WHERE decimal_column = ?", + ps -> { + ps.setBigDecimal(1, new BigDecimal("123456789012345.678901234567890")); + return ps.executeQuery(); + })); + + @Override + public List getTestCases() { + return CASES.stream() + .map(c -> new TestCase(c.description, c.description)) + .collect(java.util.stream.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 (PreparedStatement ps1 = conn1.prepareStatement(c.sql); + PreparedStatement ps2 = conn2.prepareStatement(c.sql)) { + Object r1 = Captures.resultOrThrowable(() -> c.action.run(ps1)); + Object r2 = Captures.resultOrThrowable(() -> c.action.run(ps2)); + try { + return ResultSetComparator.compare(label, c.description, testCase.getArgs(), r1, r2); + } finally { + Captures.closeIfResultSet(r1); + Captures.closeIfResultSet(r2); + } + } + } +} diff --git a/src/test/java/com/databricks/jdbc/comparator/suite/NegativePreparedMetadataProvider.java b/src/test/java/com/databricks/jdbc/comparator/suite/NegativePreparedMetadataProvider.java new file mode 100644 index 000000000..de961ae44 --- /dev/null +++ b/src/test/java/com/databricks/jdbc/comparator/suite/NegativePreparedMetadataProvider.java @@ -0,0 +1,91 @@ +package com.databricks.jdbc.comparator.suite; + +import com.databricks.jdbc.comparator.ComparisonResult; +import com.databricks.jdbc.comparator.ResultSetComparator; +import com.databricks.jdbc.comparator.error.Captures; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.util.Arrays; +import java.util.List; + +/** + * Negative prepared-statement metadata cases — {@code clearParameters()} then execute with a + * placeholder unbound, and {@code getMetaData()} before execute on invalid SQL / a missing table / + * a missing column. Each side's outcome (a metadata/result value or a thrown error) is compared. + * + *

Read-only and safe on the shared connections. + */ +public class NegativePreparedMetadataProvider implements SuiteProvider { + + private static final String TABLE = "comparator_tests.oss_jdbc_tests.test_result_set_types"; + + @FunctionalInterface + private interface PsAction { + Object run(PreparedStatement ps) throws Exception; + } + + private static final class Case { + final String description; + final String sql; + final PsAction action; + + Case(String description, String sql, PsAction action) { + this.description = description; + this.sql = sql; + this.action = action; + } + } + + private static final List CASES = + Arrays.asList( + new Case( + "clearParameters() then execute with placeholder unbound", + "SELECT integer_column FROM " + TABLE + " WHERE integer_column = ?", + ps -> { + ps.setInt(1, 42); + ps.clearParameters(); + return ps.executeQuery(); + }), + new Case( + "getMetaData() before execute on invalid SQL", + "SELET integer_column FROM " + TABLE, + PreparedStatement::getMetaData), + new Case( + "getMetaData() before execute on a missing table", + "SELECT * FROM comparator_tests.oss_jdbc_tests.__no_such_table__", + PreparedStatement::getMetaData), + new Case( + "getMetaData() before execute on a missing column", + "SELECT __no_such_column__ FROM " + TABLE, + PreparedStatement::getMetaData)); + + @Override + public List getTestCases() { + return CASES.stream() + .map(c -> new TestCase(c.description, c.description)) + .collect(java.util.stream.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 (PreparedStatement ps1 = conn1.prepareStatement(c.sql); + PreparedStatement ps2 = conn2.prepareStatement(c.sql)) { + Object r1 = Captures.resultOrThrowable(() -> c.action.run(ps1)); + Object r2 = Captures.resultOrThrowable(() -> c.action.run(ps2)); + try { + return ResultSetComparator.compare(label, c.description, testCase.getArgs(), r1, r2); + } finally { + Captures.closeIfResultSet(r1); + Captures.closeIfResultSet(r2); + } + } + } +} diff --git a/src/test/java/com/databricks/jdbc/comparator/suite/NegativeStatementOtherProvider.java b/src/test/java/com/databricks/jdbc/comparator/suite/NegativeStatementOtherProvider.java new file mode 100644 index 000000000..3d1556c0d --- /dev/null +++ b/src/test/java/com/databricks/jdbc/comparator/suite/NegativeStatementOtherProvider.java @@ -0,0 +1,112 @@ +package com.databricks.jdbc.comparator.suite; + +import com.databricks.jdbc.comparator.ComparisonResult; +import com.databricks.jdbc.comparator.ResultSetComparator; +import com.databricks.jdbc.comparator.error.Captures; +import java.sql.Connection; +import java.sql.Statement; +import java.util.Arrays; +import java.util.List; + +/** + * Negative utility-command cases — SHOW / DESCRIBE / EXPLAIN / SET against bad targets, plus a + * couple of JDBC-API misuse cases ({@code getMoreResults} after results are consumed, {@code + * getUpdateCount} before {@code execute}). Each side's outcome (value or thrown error) is compared. + * + *

Read-only: provokes errors without mutating server or session state, so it is safe on the + * shared connections. {@code USE CATALOG/SCHEMA} is intentionally excluded (session-state mutation) + * and belongs to the connection-state suite. + */ +public class NegativeStatementOtherProvider implements SuiteProvider { + + private static final String TABLE = "comparator_tests.oss_jdbc_tests.test_result_set_types"; + private static final String GET_MORE_RESULTS = "GET_MORE_RESULTS_AFTER_CONSUMED"; + private static final String GET_UPDATE_COUNT = "GET_UPDATE_COUNT_BEFORE_EXECUTE"; + + @Override + public List getTestCases() { + return Arrays.asList( + new TestCase( + "SHOW TABLES IN comparator_tests.__no_such_schema__", + "SHOW TABLES in a non-existent schema"), + new TestCase( + "SHOW SCHEMAS IN __no_such_catalog__", "SHOW SCHEMAS in a non-existent catalog"), + new TestCase( + "DESCRIBE TABLE comparator_tests.oss_jdbc_tests.__no_such_table__", + "DESCRIBE a non-existent table"), + new TestCase("SET = 'x'", "SET with an invalid (empty) parameter name"), + new TestCase( + "EXPLAIN SELECT __no_such_column__ FROM " + TABLE, "EXPLAIN of an invalid query"), + new TestCase(GET_MORE_RESULTS, "getMoreResults() after all results are consumed"), + new TestCase(GET_UPDATE_COUNT, "getUpdateCount() before execute()")); + } + + @Override + public ComparisonResult execute( + Connection conn1, Connection conn2, TestCase testCase, String label) throws Exception { + String id = testCase.getIdentifier(); + if (GET_MORE_RESULTS.equals(id)) { + return compareApi( + conn1, conn2, label, id, testCase, NegativeStatementOtherProvider::moreResults); + } + if (GET_UPDATE_COUNT.equals(id)) { + return compareApi( + conn1, + conn2, + label, + id, + testCase, + NegativeStatementOtherProvider::updateCountBeforeExecute); + } + // SQL command cases: capture execute() per side and compare. + try (Statement s1 = conn1.createStatement(); + Statement s2 = conn2.createStatement()) { + Object r1 = Captures.resultOrThrowable(() -> s1.execute(id)); + Object r2 = Captures.resultOrThrowable(() -> s2.execute(id)); + return ResultSetComparator.compare(label, id, testCase.getArgs(), r1, r2); + } + } + + /** Runs a per-connection API action on both sides and compares the captured outcomes. */ + private ComparisonResult compareApi( + Connection conn1, + Connection conn2, + String label, + String id, + TestCase testCase, + ApiAction action) { + Object r1 = Captures.resultOrThrowable(() -> action.run(conn1)); + Object r2 = Captures.resultOrThrowable(() -> action.run(conn2)); + try { + return ResultSetComparator.compare(label, id, testCase.getArgs(), r1, r2); + } catch (Exception e) { + // ResultSetComparator only throws SQLException while comparing ResultSets; these API actions + // return a Boolean/Integer or a Throwable, so this is unreachable in practice. + throw new RuntimeException(e); + } finally { + Captures.closeIfResultSet(r1); + Captures.closeIfResultSet(r2); + } + } + + @FunctionalInterface + private interface ApiAction { + Object run(Connection conn) throws Exception; + } + + /** Consumes a small ResultSet fully, then calls getMoreResults() and returns its Boolean. */ + private static Object moreResults(Connection conn) throws Exception { + try (Statement s = conn.createStatement()) { + s.executeQuery("SELECT 1"); + // Drain is unnecessary for getMoreResults semantics; call it after the single result set. + return s.getMoreResults(); + } + } + + /** Calls getUpdateCount() on a fresh statement before any execute(). */ + private static Object updateCountBeforeExecute(Connection conn) throws Exception { + try (Statement s = conn.createStatement()) { + return s.getUpdateCount(); + } + } +} diff --git a/src/test/java/com/databricks/jdbc/comparator/suite/NegativeStatementSelectProvider.java b/src/test/java/com/databricks/jdbc/comparator/suite/NegativeStatementSelectProvider.java new file mode 100644 index 000000000..f3c82ba29 --- /dev/null +++ b/src/test/java/com/databricks/jdbc/comparator/suite/NegativeStatementSelectProvider.java @@ -0,0 +1,55 @@ +package com.databricks.jdbc.comparator.suite; + +import com.databricks.jdbc.comparator.ComparisonResult; +import com.databricks.jdbc.comparator.ResultSetComparator; +import com.databricks.jdbc.comparator.error.Captures; +import java.sql.Connection; +import java.sql.Statement; +import java.util.Arrays; +import java.util.List; + +/** + * Negative SELECT cases — inputs that should make {@code executeQuery} fail, so the two endpoints' + * error behavior (class, SQLState, code, message) is compared instead of their success output. + * + *

Read-only: every case runs on the shared connections and provokes an error without mutating + * server state, so it cannot poison later suites. + */ +public class NegativeStatementSelectProvider implements SuiteProvider { + + private static final String TABLE = "comparator_tests.oss_jdbc_tests.test_result_set_types"; + + @Override + public List getTestCases() { + return Arrays.asList( + new TestCase( + "SELECT * FROM comparator_tests.oss_jdbc_tests.__no_such_table__", + "SELECT from a non-existent table"), + new TestCase("SELET 1", "Syntax error (SELET typo)"), + new TestCase("SELECT 1/0", "Division by zero"), + new TestCase("SELECT CAST('x' AS INT)", "Runtime cast failure (non-numeric to INT)"), + new TestCase( + "SELECT __no_such_column__ FROM " + TABLE, "Reference to a non-existent column"), + // Wrong method: executeQuery on a statement that does not produce a ResultSet. + new TestCase( + "INSERT INTO " + TABLE + " (id) VALUES (1)", + "Wrong method: executeQuery on an INSERT")); + } + + @Override + public ComparisonResult execute( + Connection conn1, Connection conn2, TestCase testCase, String label) throws Exception { + String query = testCase.getIdentifier(); + try (Statement stmt1 = conn1.createStatement(); + Statement stmt2 = conn2.createStatement()) { + Object r1 = Captures.resultOrThrowable(() -> stmt1.executeQuery(query)); + Object r2 = Captures.resultOrThrowable(() -> stmt2.executeQuery(query)); + try { + return ResultSetComparator.compare(label, query, testCase.getArgs(), r1, r2); + } finally { + Captures.closeIfResultSet(r1); + Captures.closeIfResultSet(r2); + } + } + } +} diff --git a/src/test/java/com/databricks/jdbc/comparator/suite/NegativeTypeConversionProvider.java b/src/test/java/com/databricks/jdbc/comparator/suite/NegativeTypeConversionProvider.java new file mode 100644 index 000000000..525251341 --- /dev/null +++ b/src/test/java/com/databricks/jdbc/comparator/suite/NegativeTypeConversionProvider.java @@ -0,0 +1,114 @@ +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 java.sql.Connection; +import java.sql.ResultSet; +import java.sql.Statement; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +/** + * Negative type-conversion cases — both endpoints return a row successfully, but a {@code + * ResultSet.getX()} call converts a cell to an incompatible target and may fail. Compares how each + * endpoint surfaces the conversion (a value, or a thrown error compared by class/SQLState/code). + * + *

Read-only: only SELECTs and getters, safe on the shared connections. The query selects a + * single known row so both sides read identical cell values before the conversion is attempted. + */ +public class NegativeTypeConversionProvider implements SuiteProvider { + + private static final String TABLE = "comparator_tests.oss_jdbc_tests.test_result_set_types"; + + /** Reads column 1 of the first row via a getter that may fail to convert. */ + @FunctionalInterface + private interface Getter { + Object get(ResultSet rs) throws Exception; + } + + private static final class Case { + final String description; + final String query; + final Getter getter; + + Case(String description, String query, Getter getter) { + this.description = description; + this.query = query; + this.getter = getter; + } + } + + private static final List CASES = + Arrays.asList( + new Case( + "getInt() on an out-of-int-range BIGINT", + "SELECT bigint_column FROM " + TABLE + " WHERE bigint_column > 3000000000 LIMIT 1", + rs -> rs.getInt(1)), + new Case( + "getByte() on an out-of-byte-range INTEGER", + "SELECT integer_column FROM " + TABLE + " WHERE integer_column > 1000 LIMIT 1", + rs -> rs.getByte(1)), + new Case( + "getInt() on a non-numeric VARCHAR", + "SELECT varchar_column FROM " + + TABLE + + " WHERE varchar_column IS NOT NULL AND varchar_column NOT RLIKE '^[0-9]+$'" + + " LIMIT 1", + rs -> rs.getInt(1)), + new Case( + "getObject(column, Integer.class) on a VARCHAR", + "SELECT varchar_column FROM " + TABLE + " WHERE varchar_column IS NOT NULL LIMIT 1", + rs -> rs.getObject(1, Integer.class)), + new Case( + "getInt() on an ARRAY column", + "SELECT array_column FROM " + TABLE + " WHERE array_column IS NOT NULL LIMIT 1", + rs -> rs.getInt(1)), + new Case( + "getBigDecimal() on a STRUCT column", + "SELECT struct_column FROM " + TABLE + " WHERE struct_column IS NOT NULL LIMIT 1", + rs -> rs.getBigDecimal(1))); + + @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 s1 = conn1.createStatement(); + Statement s2 = conn2.createStatement(); + ResultSet rs1 = s1.executeQuery(c.query); + ResultSet rs2 = s2.executeQuery(c.query)) { + // Both queries must return the same shape; if a side has no row, the getter can't run — that + // is a harness/data problem, so let it propagate (fail loudly) rather than compare noise. + boolean has1 = rs1.next(); + boolean has2 = rs2.next(); + if (!has1 || !has2) { + throw new IllegalStateException( + "Type-conversion case '" + + c.description + + "' expected a row on both sides but got " + + "left=" + + has1 + + " right=" + + has2); + } + CapturedOutcome left = Captures.capture(() -> c.getter.get(rs1)); + CapturedOutcome right = Captures.capture(() -> c.getter.get(rs2)); + return Captures.compareCall( + label, c.description, testCase.getArgs(), left, right, v -> "value " + v); + } + } +}