From da25f1061cf848c0423dac29182aa28b2880cafc Mon Sep 17 00:00:00 2001 From: Jeff Jensen Date: Sat, 8 Aug 2026 20:00:12 -0500 Subject: [PATCH] feat(assertion): Add IsActualEqualToExpectedJsonValueComparer for JSON columns Reviewed a Stack Overflow report of DbUnit failing to compare a MySQL JSON column (https://stackoverflow.com/a/55839637/2848514). MySQL Connector/J already reports native JSON columns as Types.LONGVARCHAR, which DbUnit's existing StringDataType handles for reads/writes with no DataTypeFactory change needed - but MySQL (like PostgreSQL jsonb and H2 JSON) reformats the text on storage, sorting object keys and stripping insignificant whitespace, so a literal string comparison against an expected dataset value spuriously fails even when the JSON is semantically identical. The SO thread's own suggested fix (a custom DataTypeFactory binding a driver-specific object such as PGobject) does not apply here and targets a different, write-path problem specific to PostgreSQL's stricter parameter binding. * Add IsActualEqualToExpectedJsonValueComparer, parsing both sides with Jackson and comparing the resulting document trees: object member order is ignored while array element order stays significant, matching JSON's own equality semantics. Database-agnostic - applies to any column that round-trips as text, not only MySQL. * Deliberately not exposed as a ValueComparers constant, since that class eagerly instantiates every constant it declares and would force the optional jackson-databind dependency onto every consumer, not only those comparing JSON columns. * Add unit coverage: null handling, identical text, whitespace-only and object-key-order differences, equivalent nested objects/arrays, array-order sensitivity, differing values, and malformed-JSON failures on both sides. * Document the new comparer in valuecomparer.adoc, noting why it is absent from ValueComparers and pointing readers there directly. Refs: 921 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_016E3LGxvPZgjZjK1GHrcZpJ --- src/changes/changes.xml | 3 + ...ctualEqualToExpectedJsonValueComparer.java | 139 ++++++++++ .../datacomparisons/valuecomparer.adoc | 12 + ...lEqualToExpectedJsonValueComparerTest.java | 260 ++++++++++++++++++ 4 files changed, 414 insertions(+) create mode 100644 src/main/java/org/dbunit/assertion/comparer/value/IsActualEqualToExpectedJsonValueComparer.java create mode 100644 src/test/java/org/dbunit/assertion/comparer/value/IsActualEqualToExpectedJsonValueComparerTest.java diff --git a/src/changes/changes.xml b/src/changes/changes.xml index 28ea270e0..dd87d49bd 100644 --- a/src/changes/changes.xml +++ b/src/changes/changes.xml @@ -249,6 +249,9 @@ Add DatabaseConfig.FEATURE_SKIP_CYCLE_CHECK, an opt-in escape hatch letting DatabaseSequenceFilter proceed on a schema with a foreign-key dependency cycle instead of unconditionally rejecting it with CyclicTablesDependencyException (issues 501 and 517: dbUnit could not order, and therefore could not CLEAN_INSERT/DELETE_ALL, tables bound together by circular FK references). This is the configurable cycle-breaking escape hatch issue 411 originally proposed rather than a full topological resolution of the cycle itself: DatabaseSequenceFilter.sortTableNames now collapses each cycle into a single strongly-connected-component unit for ordering purposes and logs a warning per cycle instead of throwing, so a table outside the cycle is still correctly ordered relative to it (e.g. a table with its own FK to a cyclic table still sorts after the whole cycle, not merely after whichever cyclic member happened to be placed) and every requested table is still returned exactly once; only the relative order of the tables making up the cycle itself is unresolved and falls back to their original input order, leaving the caller responsible for making the cycle insertable another way (e.g. nullable FK columns populated in a later operation, or database-side deferred constraint checking). Off by default, preserving the existing fail-fast behavior for callers who never touch it. Update maven dependency org.gaul:modernizer-maven-plugin from 2.7.0 to 3.5.0. + + Add IsActualEqualToExpectedJsonValueComparer, a ValueComparer that parses expected and actual column values as JSON and compares the resulting document trees instead of their raw text: object member order is ignored while array element order stays significant, matching JSON's own equality semantics. Prompted by reviewing a Stack Overflow report of DbUnit failing on a MySQL JSON column; MySQL Connector/J already reports native JSON columns as Types.LONGVARCHAR, which DbUnit's existing StringDataType handles for reads/writes with no DataTypeFactory change needed, but MySQL (like PostgreSQL jsonb and H2 JSON) reformats the text on storage - sorting object keys and stripping insignificant whitespace - so a literal string comparison against an expected dataset value spuriously fails even when the JSON is semantically identical. Not exposed as a ValueComparers constant, since that class eagerly instantiates every constant it declares and would force the optional jackson-databind dependency onto every consumer, not only those comparing JSON columns. + diff --git a/src/main/java/org/dbunit/assertion/comparer/value/IsActualEqualToExpectedJsonValueComparer.java b/src/main/java/org/dbunit/assertion/comparer/value/IsActualEqualToExpectedJsonValueComparer.java new file mode 100644 index 000000000..f0932a0a2 --- /dev/null +++ b/src/main/java/org/dbunit/assertion/comparer/value/IsActualEqualToExpectedJsonValueComparer.java @@ -0,0 +1,139 @@ +package org.dbunit.assertion.comparer.value; + +import java.io.IOException; + +import org.dbunit.DatabaseUnitException; +import org.dbunit.dataset.ITable; +import org.dbunit.dataset.datatype.DataType; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +/** + * {@link ValueComparer} implementation that verifies the actual value is + * semantically equal to the expected value by parsing both as JSON and + * comparing the resulting document trees, instead of comparing their raw + * text. + * + *

Databases that store native JSON (for example MySQL {@code JSON}, + * PostgreSQL {@code json}/{@code jsonb}, or H2 {@code JSON}) commonly + * reformat the text on storage: insignificant whitespace is stripped and + * object keys may be reordered. A plain string or + * {@link DataType#compare(Object, Object)} comparison then fails even when + * the expected and actual documents are equivalent. This comparer instead + * treats JSON object member order as insignificant while still treating JSON + * array element order as significant, matching JSON's own equality + * semantics. Special case: if both values are null, they match. + * + *

Requires the optional {@code jackson-databind} dependency (the same one + * used by {@link org.dbunit.dataset.json.JsonDataSet}) on the classpath. + * Deliberately not exposed as a constant on {@link ValueComparers}, because + * that class eagerly instantiates every constant it declares; doing so here + * would force the optional dependency onto every consumer of + * {@link ValueComparers}, not only those comparing JSON columns. Construct + * this comparer directly instead. + * + * @author Jeff Jensen + * @since 3.4.1 + */ +public class IsActualEqualToExpectedJsonValueComparer + extends ValueComparerTemplateBase +{ + private final Logger log = LoggerFactory.getLogger(getClass()); + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + @Override + protected boolean isExpected(final ITable expectedTable, + final ITable actualTable, final int rowNum, final String columnName, + final DataType dataType, final Object expectedValue, + final Object actualValue) throws DatabaseUnitException + { + final boolean isExpected; + + // handle nulls: prevent NPE and isExpected=true when both null + if (expectedValue == null && actualValue == null) + { + // both are null, so match + isExpected = true; + } else if (expectedValue == null || actualValue == null) + { + // both aren't null, one is null, so no match + isExpected = false; + } else + { + // neither are null, so compare + isExpected = isJsonEqual(rowNum, columnName, expectedValue, + actualValue); + } + + return isExpected; + } + + /** + * Returns whether the expected and actual values parse as structurally + * equal JSON documents. + * + * @param rowNum the current row number comparing, used only to identify a parse failure. + * @param columnName the name of the current column comparing, used only to identify a parse failure. + * @param expectedValue the expected value. + * @param actualValue the actual value. + * @return true if both values parse as JSON and their document trees are equal. + * @throws DatabaseUnitException if either value cannot be converted to a string or parsed as JSON. + */ + protected boolean isJsonEqual(final int rowNum, final String columnName, + final Object expectedValue, final Object actualValue) + throws DatabaseUnitException + { + final JsonNode expectedNode = + parseJson(rowNum, columnName, "expected", expectedValue); + final JsonNode actualNode = + parseJson(rowNum, columnName, "actual", actualValue); + log.debug("isJsonEqual: expectedNode={}, actualNode={}", expectedNode, + actualNode); + + return actualNode.equals(expectedNode); + } + + private JsonNode parseJson(final int rowNum, final String columnName, + final String label, final Object value) throws DatabaseUnitException + { + final String json = DataType.asString(value); + + final JsonNode node; + try + { + node = OBJECT_MAPPER.readTree(json); + } catch (final IOException e) + { + throw new DatabaseUnitException( + parseFailureMessage(rowNum, columnName, label, json), e); + } + + if (node == null || node.isMissingNode()) + { + // Jackson returns a MissingNode (not an exception, and not a + // Java null either) for empty or whitespace-only input + throw new DatabaseUnitException( + parseFailureMessage(rowNum, columnName, label, json)); + } + + return node; + } + + private String parseFailureMessage(final int rowNum, + final String columnName, final String label, final String json) + { + return String.format( + "Unable to parse %s value as JSON for column '%s', row %d: %s", + label, columnName, rowNum, json); + } + + @Override + protected String getFailPhrase() + { + return "not JSON-equal to"; + } +} diff --git a/src/site/asciidoc/datacomparisons/valuecomparer.adoc b/src/site/asciidoc/datacomparisons/valuecomparer.adoc index d6c8024f9..7a6cea525 100644 --- a/src/site/asciidoc/datacomparisons/valuecomparer.adoc +++ b/src/site/asciidoc/datacomparisons/valuecomparer.adoc @@ -46,6 +46,18 @@ plus pre-configured variances (e.g. link:/dbunit/apidocs/org/dbunit/assertion/comparer/value/ValueComparers.html#isActualWithinOneMinuteNewerOfExpectedTimestamp[isActualWithinOneMinuteNewerOfExpectedTimestamp]). Start with these as they provide for most comparison needs. +Some implementations are deliberately left off +link:/dbunit/apidocs/org/dbunit/assertion/comparer/value/ValueComparers.html[ValueComparers] +because it eagerly instantiates every instance it declares, and doing so there would force +an optional dependency onto every user of that class instead of only those who need it; +construct these directly instead. For example, +link:/dbunit/apidocs/org/dbunit/assertion/comparer/value/IsActualEqualToExpectedJsonValueComparer.html[IsActualEqualToExpectedJsonValueComparer] +compares JSON/JSONB column values (e.g. MySQL `JSON`, PostgreSQL `json`/`jsonb`, H2 `JSON`) +by their parsed document structure instead of raw text - ignoring object member order and +insignificant whitespace, both of which a database may rewrite when it stores a JSON value - +and needs the optional `jackson-databind` dependency also used by +link:/dbunit/apidocs/org/dbunit/dataset/json/JsonDataSet.html[JsonDataSet]. + It is easy to add your own implementations of the link:/dbunit/apidocs/org/dbunit/assertion/comparer/value/ValueComparer.html[ValueComparer] interface, diff --git a/src/test/java/org/dbunit/assertion/comparer/value/IsActualEqualToExpectedJsonValueComparerTest.java b/src/test/java/org/dbunit/assertion/comparer/value/IsActualEqualToExpectedJsonValueComparerTest.java new file mode 100644 index 000000000..a254e806c --- /dev/null +++ b/src/test/java/org/dbunit/assertion/comparer/value/IsActualEqualToExpectedJsonValueComparerTest.java @@ -0,0 +1,260 @@ +package org.dbunit.assertion.comparer.value; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +import org.dbunit.DatabaseUnitException; +import org.dbunit.dataset.ITable; +import org.dbunit.dataset.datatype.DataType; +import org.junit.jupiter.api.Test; + +class IsActualEqualToExpectedJsonValueComparerTest +{ + final IsActualEqualToExpectedJsonValueComparer sut = + new IsActualEqualToExpectedJsonValueComparer(); + + private final ITable expectedTable = null; + private final ITable actualTable = null; + private final int rowNum = 3; + private final String columnName = "MY_JSON_COLUMN"; + private final DataType dataType = DataType.LONGVARCHAR; + + @Test + void testIsExpected_AllNull_True() throws DatabaseUnitException + { + final boolean actual = sut.isExpected(expectedTable, actualTable, + rowNum, columnName, dataType, null, null); + + assertThat(actual).as("All null should have been equal.").isTrue(); + } + + @Test + void testIsExpected_ActualNullExpectedNotNull_False() + throws DatabaseUnitException + { + final boolean actual = sut.isExpected(expectedTable, actualTable, + rowNum, columnName, dataType, "{\"a\":1}", null); + + assertThat(actual).as( + "Actual null, expected not null should not have been equal.") + .isFalse(); + } + + @Test + void testIsExpected_ActualNotNullExpectedNull_False() + throws DatabaseUnitException + { + final boolean actual = sut.isExpected(expectedTable, actualTable, + rowNum, columnName, dataType, null, "{\"a\":1}"); + + assertThat(actual).as( + "Actual not null, expected null, should not have been equal.") + .isFalse(); + } + + @Test + void testIsExpected_IdenticalText_True() throws DatabaseUnitException + { + final Object expectedValue = "{\"a\":1,\"b\":2}"; + final Object actualValue = "{\"a\":1,\"b\":2}"; + + final boolean actual = sut.isExpected(expectedTable, actualTable, + rowNum, columnName, dataType, expectedValue, actualValue); + + assertThat(actual).as("Identical JSON text should have been equal.") + .isTrue(); + } + + @Test + void testIsExpected_DifferentInsignificantWhitespace_True() + throws DatabaseUnitException + { + final Object expectedValue = "{\"a\":1,\"b\":2}"; + final Object actualValue = "{ \"a\" : 1, \"b\" : 2 }"; + + final boolean actual = sut.isExpected(expectedTable, actualTable, + rowNum, columnName, dataType, expectedValue, actualValue); + + assertThat(actual).as( + "JSON differing only in insignificant whitespace should have been equal, " + + "matching how MySQL/PostgreSQL/H2 reformat stored JSON.") + .isTrue(); + } + + @Test + void testIsExpected_DifferentObjectKeyOrder_True() + throws DatabaseUnitException + { + final Object expectedValue = "{\"a\":1,\"b\":2}"; + final Object actualValue = "{\"b\":2,\"a\":1}"; + + final boolean actual = sut.isExpected(expectedTable, actualTable, + rowNum, columnName, dataType, expectedValue, actualValue); + + assertThat(actual).as( + "JSON objects differing only in member order should have been equal, " + + "matching how MySQL sorts object keys on storage.") + .isTrue(); + } + + @Test + void testIsExpected_EquivalentNestedObjectsAndArrays_True() + throws DatabaseUnitException + { + final Object expectedValue = + "{\"name\":\"a\",\"tags\":[\"x\",\"y\"],\"meta\":{\"n\":1,\"m\":2}}"; + final Object actualValue = + "{\"meta\":{\"m\":2,\"n\":1},\"tags\":[\"x\",\"y\"],\"name\":\"a\"}"; + + final boolean actual = sut.isExpected(expectedTable, actualTable, + rowNum, columnName, dataType, expectedValue, actualValue); + + assertThat(actual).as( + "Nested JSON objects/arrays equivalent apart from object member order " + + "should have been equal.").isTrue(); + } + + @Test + void testIsExpected_DifferentArrayElementOrder_False() + throws DatabaseUnitException + { + final Object expectedValue = "[1,2,3]"; + final Object actualValue = "[3,2,1]"; + + final boolean actual = sut.isExpected(expectedTable, actualTable, + rowNum, columnName, dataType, expectedValue, actualValue); + + assertThat(actual).as( + "JSON arrays differing in element order should not have been equal: " + + "array order is significant.").isFalse(); + } + + @Test + void testIsExpected_DifferentValues_False() throws DatabaseUnitException + { + final Object expectedValue = "{\"a\":1}"; + final Object actualValue = "{\"a\":2}"; + + final boolean actual = sut.isExpected(expectedTable, actualTable, + rowNum, columnName, dataType, expectedValue, actualValue); + + assertThat(actual) + .as("JSON with a different value for the same key should not have been equal.") + .isFalse(); + } + + @Test + void testIsExpected_MissingKey_False() throws DatabaseUnitException + { + final Object expectedValue = "{\"a\":1,\"b\":2}"; + final Object actualValue = "{\"a\":1}"; + + final boolean actual = sut.isExpected(expectedTable, actualTable, + rowNum, columnName, dataType, expectedValue, actualValue); + + assertThat(actual) + .as("JSON missing a key present in the other should not have been equal.") + .isFalse(); + } + + @Test + void testIsExpected_ExpectedNotValidJson_ThrowsDatabaseUnitException() + { + final Object expectedValue = "not json"; + final Object actualValue = "{\"a\":1}"; + + assertThatExceptionOfType(DatabaseUnitException.class) + .as("Unparseable expected value should have thrown.") + .isThrownBy(() -> sut.isExpected(expectedTable, actualTable, + rowNum, columnName, dataType, expectedValue, + actualValue)) + .withMessageContaining(columnName) + .withMessageContaining(String.valueOf(rowNum)); + } + + @Test + void testIsExpected_ActualNotValidJson_ThrowsDatabaseUnitException() + { + final Object expectedValue = "{\"a\":1}"; + final Object actualValue = "not json"; + + assertThatExceptionOfType(DatabaseUnitException.class) + .as("Unparseable actual value should have thrown.") + .isThrownBy(() -> sut.isExpected(expectedTable, actualTable, + rowNum, columnName, dataType, expectedValue, + actualValue)) + .withMessageContaining(columnName) + .withMessageContaining(String.valueOf(rowNum)); + } + + @Test + void testIsExpected_ExpectedEmptyString_ThrowsDatabaseUnitException() + { + final Object expectedValue = ""; + final Object actualValue = "{\"a\":1}"; + + assertThatExceptionOfType(DatabaseUnitException.class) + .as("Empty-string expected value should have thrown " + + "DatabaseUnitException instead of NullPointerException, " + + "since Jackson returns null (not an exception) for empty input.") + .isThrownBy(() -> sut.isExpected(expectedTable, actualTable, + rowNum, columnName, dataType, expectedValue, + actualValue)); + } + + @Test + void testIsExpected_ActualEmptyString_ThrowsDatabaseUnitException() + { + final Object expectedValue = "{\"a\":1}"; + final Object actualValue = ""; + + assertThatExceptionOfType(DatabaseUnitException.class) + .as("Empty-string actual value should have thrown " + + "DatabaseUnitException instead of NullPointerException, " + + "since Jackson returns null (not an exception) for empty input.") + .isThrownBy(() -> sut.isExpected(expectedTable, actualTable, + rowNum, columnName, dataType, expectedValue, + actualValue)); + } + + @Test + void testIsExpected_ExpectedWhitespaceOnly_ThrowsDatabaseUnitException() + { + final Object expectedValue = " "; + final Object actualValue = "{\"a\":1}"; + + assertThatExceptionOfType(DatabaseUnitException.class) + .as("Whitespace-only expected value should have thrown " + + "DatabaseUnitException instead of NullPointerException, " + + "since Jackson returns null (not an exception) for " + + "whitespace-only input.") + .isThrownBy(() -> sut.isExpected(expectedTable, actualTable, + rowNum, columnName, dataType, expectedValue, + actualValue)); + } + + @Test + void testIsExpected_ActualWhitespaceOnly_ThrowsDatabaseUnitException() + { + final Object expectedValue = "{\"a\":1}"; + final Object actualValue = " "; + + assertThatExceptionOfType(DatabaseUnitException.class) + .as("Whitespace-only actual value should have thrown " + + "DatabaseUnitException instead of NullPointerException, " + + "since Jackson returns null (not an exception) for " + + "whitespace-only input.") + .isThrownBy(() -> sut.isExpected(expectedTable, actualTable, + rowNum, columnName, dataType, expectedValue, + actualValue)); + } + + @Test + void testGetFailPhrase_DefaultComparer_ReturnsNonNullPhrase() + throws Exception + { + final String actual = sut.getFailPhrase(); + + assertThat(actual).as("Should have fail phrase.").isNotNull(); + } +}