Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/changes/changes.xml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
</action>
<action dev="jeffjensen" type="update" issue="732" due-to="jeffjensen">Update maven dependency org.gaul:modernizer-maven-plugin from 2.7.0 to 3.5.0.</action>
<action dev="jeffjensen" type="add" issue="921" system="github" due-to="jeffjensen">
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.
</action>
</release>
<release version="3.4.0" date="Jul 28, 2026" description="Test-suite hardening (un-skip and strengthen dozens of disabled/no-op tests); add CachingConnectionProvider and reduce DefaultPrepAndExpectedTestCase's per-test connection churn; pin identifier case-folding to Locale.ENGLISH for Turkish-locale correctness; and a broad set of correctness fixes across export formats (XML, YAML, CSV, XLS, Ant), TimestampDataType timezone handling, InsertOperation/TransactionOperation, and resource-leak cleanups">
<action dev="jeffjensen" type="fix" issue="797" system="github" due-to="jeffjensen">
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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.
*
* <p>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 <code>true</code> 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";
}
}
12 changes: 12 additions & 0 deletions src/site/asciidoc/datacomparisons/valuecomparer.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading