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