diff --git a/src/changes/changes.xml b/src/changes/changes.xml index a74092afc..92c9e4a3e 100644 --- a/src/changes/changes.xml +++ b/src/changes/changes.xml @@ -45,6 +45,9 @@ Add org.dbunit.PrepAndExpectedTestData, an immutable value bundling the VerifyTableDefinition[], prep String[], and expected String[] that PrepAndExpectedTestCase.configureTest(), preTest(), and runTest() otherwise take as three separate arguments; it copies each array in and out, normalizes a null array to empty, and provides a NONE constant and a prepOnly(String...) factory. Add default configureTest(PrepAndExpectedTestData), preTest(PrepAndExpectedTestData), and runTest(PrepAndExpectedTestData, PrepAndExpectedTestCaseSteps) overloads that unpack the bundle and delegate to the existing array methods - purely additive, no existing signature changes. Aimed at data-driven tests, where the triple otherwise threads through every @ParameterizedTest signature and @MethodSource row. + + Add RegularExpressionValueComparer to org.dbunit.assertion.comparer.value: a ValueComparer that reads the expected dataset value as a regular expression and passes when it matches the actual value in its entirety (Matcher.matches(), the same whole-value rule as String.matches()), for verifying columns whose format a test controls but whose exact content it does not, such as database-generated ids, UUID columns, or timestamps rendered into a text column. A partial match is opt-in by making the pattern permissive at both ends, and an invalid pattern raises DatabaseUnitException naming the row and column. Also exposed as the ValueComparers.regularExpressionValueComparer constant, since it adds no dependency beyond java.util.regex. + diff --git a/src/main/java/org/dbunit/assertion/comparer/value/RegularExpressionValueComparer.java b/src/main/java/org/dbunit/assertion/comparer/value/RegularExpressionValueComparer.java new file mode 100644 index 000000000..0afb65977 --- /dev/null +++ b/src/main/java/org/dbunit/assertion/comparer/value/RegularExpressionValueComparer.java @@ -0,0 +1,144 @@ +package org.dbunit.assertion.comparer.value; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; + +import org.dbunit.DatabaseUnitException; +import org.dbunit.dataset.ITable; +import org.dbunit.dataset.datatype.DataType; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * {@link ValueComparer} implementation that verifies the actual value matches + * the regular expression supplied as the expected value. + * + *

+ * The expected value, converted to a {@link String}, is the + * {@link java.util.regex.Pattern regular expression}; the actual value, also + * converted to a {@link String}, is the input tested against it. This mirrors + * how {@link IsActualContainingExpectedStringValueComparer} treats the expected + * value as the substring to look for. + * + *

+ * The comparison succeeds only when the pattern matches the entire + * actual value, using {@link Matcher#matches()}, consistent with + * {@link String#matches(String)}. To match only part of the actual value, make + * the pattern permissive at both ends, for example .*[0-9]{4}.*. + * A . does not match a line terminator unless the + * {@link Pattern#DOTALL DOTALL} flag is set, so prefix such a pattern with + * (?s) when the actual value may contain a newline, for example + * (?s).*[0-9]{4}.*. + * + *

+ * Useful for columns whose exact content a test does not control but whose + * format it does, such as database-generated identifiers, UUID columns, or + * timestamps rendered into a text column. + * + *

+ * Special case: if both values are null, they match; if exactly one is null, + * they do not. + * + *

+ * This comparer adds no dependency beyond {@code java.util.regex}, so it is also + * available as {@link ValueComparers#regularExpressionValueComparer}. + * + * @author Jeff Jensen + * @since 3.6.0 + */ +public class RegularExpressionValueComparer extends ValueComparerTemplateBase +{ + private final Logger log = LoggerFactory.getLogger(getClass()); + + @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 = isMatching(rowNum, columnName, expectedValue, + actualValue); + } + + return isExpected; + } + + /** + * Returns whether the regular expression held in the expected value matches + * the whole actual value, both converted to strings. + * + * @param rowNum + * The current row number comparing, used only to identify an + * invalid pattern. + * @param columnName + * The name of the current column comparing, used only to identify + * an invalid pattern. + * @param expectedValue + * The expected value, holding the regular expression. + * @param actualValue + * The actual value tested against the regular expression. + * @return true if the regular expression matches the entire + * actual value string. + * @throws DatabaseUnitException + * If either value cannot be converted to a string, or the + * expected value is not a valid regular expression. + */ + protected boolean isMatching(final int rowNum, final String columnName, + final Object expectedValue, final Object actualValue) + throws DatabaseUnitException + { + final String regex = DataType.asString(expectedValue); + final String actualValueString = DataType.asString(actualValue); + final Pattern pattern = compilePattern(rowNum, columnName, regex); + final Matcher matcher = pattern.matcher(actualValueString); + final boolean isMatching = matcher.matches(); + log.debug("isMatching: regex={}, actualValueString={}, isMatching={}", + regex, actualValueString, isMatching); + + return isMatching; + } + + /** + * Compiles the expected value into a {@link Pattern}, turning an invalid + * expression into a {@link DatabaseUnitException} identifying the row and + * column, consistent with how + * {@link IsActualEqualToExpectedJsonValueComparer} reports an unparseable + * expected value. + */ + private Pattern compilePattern(final int rowNum, final String columnName, + final String regex) throws DatabaseUnitException + { + try + { + return Pattern.compile(regex); + } catch (final PatternSyntaxException e) + { + final String message = String.format( + "Unable to compile expected value as a regular expression" + + " for column '%s', row %d: %s", + columnName, rowNum, regex); + throw new DatabaseUnitException(message, e); + } + } + + @Override + protected String getFailPhrase() + { + return "not matching the regular expression"; + } +} diff --git a/src/main/java/org/dbunit/assertion/comparer/value/ValueComparers.java b/src/main/java/org/dbunit/assertion/comparer/value/ValueComparers.java index a498b7ead..69e9522a2 100644 --- a/src/main/java/org/dbunit/assertion/comparer/value/ValueComparers.java +++ b/src/main/java/org/dbunit/assertion/comparer/value/ValueComparers.java @@ -148,6 +148,16 @@ protected ValueComparers() public static final ValueComparer isActualContainingExpectedStringValueComparer = new IsActualContainingExpectedStringValueComparer(); + /** + * Checks whether the actual value matches, in its entirety, the regular + * expression given as the expected value. + * + * @see RegularExpressionValueComparer + * @since 3.6.0 + */ + public static final ValueComparer regularExpressionValueComparer = + new RegularExpressionValueComparer(); + /** * Verifies nothing and never fails. * diff --git a/src/site/asciidoc/datacomparisons/valuecomparer.adoc b/src/site/asciidoc/datacomparisons/valuecomparer.adoc index 7a6cea525..a8e047e38 100644 --- a/src/site/asciidoc/datacomparisons/valuecomparer.adoc +++ b/src/site/asciidoc/datacomparisons/valuecomparer.adoc @@ -17,6 +17,7 @@ It enables comparisons such as: * greater-than * less-than * contains +* regular expression match * complex multi-column-based * dynamically choosing the ValueComparer based on criteria @@ -46,6 +47,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. +For a column whose exact content a test does not control but whose format it +does - a database-generated id, a UUID, a formatted identifier, a timestamp +rendered into a text column - +link:/dbunit/apidocs/org/dbunit/assertion/comparer/value/RegularExpressionValueComparer.html[RegularExpressionValueComparer] +(also +link:/dbunit/apidocs/org/dbunit/assertion/comparer/value/ValueComparers.html#regularExpressionValueComparer[ValueComparers.regularExpressionValueComparer]) +takes the expected dataset value as a regular expression and passes when it +matches the actual value in its entirety, the same rule as +`java.lang.String.matches`. To match only part of the value, wrap the pattern in +`.*` on both ends, e.g. `.*[0-9]{4}.*`; a `.` does not cross a line terminator, +so prefix the pattern with `(?s)` when the actual value may contain a newline. + 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 diff --git a/src/test/java/org/dbunit/assertion/DbUnitValueComparerAssertIT.java b/src/test/java/org/dbunit/assertion/DbUnitValueComparerAssertIT.java index 2d7cbe6f1..e6efb223e 100644 --- a/src/test/java/org/dbunit/assertion/DbUnitValueComparerAssertIT.java +++ b/src/test/java/org/dbunit/assertion/DbUnitValueComparerAssertIT.java @@ -102,6 +102,42 @@ void testAssertWithValueComparer_isActualGreaterThan_failsWhenActualIsSmaller() .isInstanceOf(DbComparisonFailure.class); } + @Test + void testAssertWithValueComparer_regularExpression_passesWhenActualMatchesPattern() + throws Exception + { + final Column[] columns = + new Column[]{new Column("ID", DataType.VARCHAR)}; + + final DefaultTable expected = new DefaultTable("T", columns); + expected.addRow(new Object[]{"[0-9]+"}); + + final DefaultTable actual = new DefaultTable("T", columns); + actual.addRow(new Object[]{"4071"}); + + assertDoesNotThrow(() -> sut.assertWithValueComparer(expected, actual, + ValueComparers.regularExpressionValueComparer)); + } + + @Test + void testAssertWithValueComparer_regularExpression_failsWhenActualDoesNotMatchPattern() + throws Exception + { + final Column[] columns = + new Column[]{new Column("ID", DataType.VARCHAR)}; + + final DefaultTable expected = new DefaultTable("T", columns); + expected.addRow(new Object[]{"[0-9]+"}); + + final DefaultTable actual = new DefaultTable("T", columns); + actual.addRow(new Object[]{"4071-A"}); + + assertThatThrownBy(() -> sut.assertWithValueComparer(expected, actual, + ValueComparers.regularExpressionValueComparer)) + .as("A value not matching the pattern should fail the comparison.") + .isInstanceOf(DbComparisonFailure.class); + } + @Test void testAssertWithValueComparer_isActualNull_passesWhenActualIsNull() throws Exception diff --git a/src/test/java/org/dbunit/assertion/comparer/value/RegularExpressionValueComparerTest.java b/src/test/java/org/dbunit/assertion/comparer/value/RegularExpressionValueComparerTest.java new file mode 100644 index 000000000..7703359b2 --- /dev/null +++ b/src/test/java/org/dbunit/assertion/comparer/value/RegularExpressionValueComparerTest.java @@ -0,0 +1,400 @@ +package org.dbunit.assertion.comparer.value; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +import java.util.regex.PatternSyntaxException; + +import org.dbunit.DatabaseUnitException; +import org.dbunit.dataset.ITable; +import org.dbunit.dataset.datatype.DataType; +import org.junit.jupiter.api.Test; + +class RegularExpressionValueComparerTest +{ + private final RegularExpressionValueComparer sut = + new RegularExpressionValueComparer(); + + private final ITable expectedTable = null; + private final ITable actualTable = null; + private final int rowNum = 5; + private final String columnName = "MY_COLUMN"; + private final DataType dataType = DataType.VARCHAR; + + private boolean isExpected(final Object expectedValue, + final Object actualValue) throws DatabaseUnitException + { + return sut.isExpected(expectedTable, actualTable, rowNum, columnName, + dataType, expectedValue, actualValue); + } + + @Test + void testIsExpected_AllNull_True() throws DatabaseUnitException + { + final boolean actual = isExpected(null, null); + + assertThat(actual).as("Both values null should have matched.").isTrue(); + } + + @Test + void testIsExpected_ActualNullExpectedNotNull_False() + throws DatabaseUnitException + { + final boolean actual = isExpected("\\d+", null); + + assertThat(actual) + .as("Null actual value should not match a non-null pattern.") + .isFalse(); + } + + @Test + void testIsExpected_ActualNotNullExpectedNull_False() + throws DatabaseUnitException + { + final boolean actual = isExpected(null, "123"); + + assertThat(actual) + .as("Non-null actual value should not match a null pattern.") + .isFalse(); + } + + @Test + void testIsExpected_LiteralPatternEqualsWholeActualValue_True() + throws DatabaseUnitException + { + final boolean actual = isExpected("abc", "abc"); + + assertThat(actual) + .as("Literal pattern equal to the whole actual value should have matched.") + .isTrue(); + } + + @Test + void testIsExpected_DigitPatternMatchesAllDigitActualValue_True() + throws DatabaseUnitException + { + final boolean actual = isExpected("\\d+", "12345"); + + assertThat(actual) + .as("Digit pattern should have matched an all-digit actual value.") + .isTrue(); + } + + @Test + void testIsExpected_DigitPatternWithNonDigitInActualValue_False() + throws DatabaseUnitException + { + final boolean actual = isExpected("\\d+", "12a45"); + + assertThat(actual).as( + "Digit pattern should not have matched an actual value containing a letter.") + .isFalse(); + } + + @Test + void testIsExpected_DigitPatternMatchesOnlyLeadingDigitsOfActualValue_False() + throws DatabaseUnitException + { + final boolean actual = isExpected("\\d+", "123abc"); + + assertThat(actual).as( + "Match is anchored to the whole value, so leading-only digits should not have matched.") + .isFalse(); + } + + @Test + void testIsExpected_DigitPatternMatchesOnlyMiddleOfActualValue_False() + throws DatabaseUnitException + { + final boolean actual = isExpected("\\d+", "abc123def"); + + assertThat(actual).as( + "Match is anchored to the whole value, so a matching substring should not have matched.") + .isFalse(); + } + + @Test + void testIsExpected_WildcardWrappedPatternMatchesSubstringWithinWholeValue_True() + throws DatabaseUnitException + { + final boolean actual = isExpected(".*\\d{4}.*", "order-2026-xyz"); + + assertThat(actual).as( + "Wrapping the pattern in .* should let it match a substring within the whole value.") + .isTrue(); + } + + @Test + void testIsExpected_CharacterClassAndQuantifierFormatPattern_True() + throws DatabaseUnitException + { + final boolean actual = isExpected("[A-Z]{3}-\\d{4}", "ABC-1234"); + + assertThat(actual) + .as("Format pattern should have matched a correctly shaped actual value.") + .isTrue(); + } + + @Test + void testIsExpected_FormatPatternWithWrongShapeActualValue_False() + throws DatabaseUnitException + { + final boolean actual = isExpected("[A-Z]{3}-\\d{4}", "AB-1234"); + + assertThat(actual) + .as("Format pattern should not have matched a wrongly shaped actual value.") + .isFalse(); + } + + @Test + void testIsExpected_UuidPatternMatchesUuidActualValue_True() + throws DatabaseUnitException + { + final String uuidPattern = + "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"; + + final boolean actual = + isExpected(uuidPattern, "3f2504e0-4f89-41d3-9a0c-0305e82c3301"); + + assertThat(actual) + .as("UUID pattern should have matched a UUID-shaped actual value.") + .isTrue(); + } + + @Test + void testIsExpected_AlternationPatternMatchesOneAlternative_True() + throws DatabaseUnitException + { + final boolean actual = isExpected("cat|dog", "dog"); + + assertThat(actual) + .as("Alternation pattern should have matched one of its alternatives.") + .isTrue(); + } + + @Test + void testIsExpected_AlternationPatternMatchesNoAlternative_False() + throws DatabaseUnitException + { + final boolean actual = isExpected("cat|dog", "bird"); + + assertThat(actual).as( + "Alternation pattern should not have matched a value that is none of its alternatives.") + .isFalse(); + } + + @Test + void testIsExpected_PatternIsCaseSensitiveByDefault_False() + throws DatabaseUnitException + { + final boolean actual = isExpected("abc", "ABC"); + + assertThat(actual) + .as("Matching should be case-sensitive unless the pattern opts out.") + .isFalse(); + } + + @Test + void testIsExpected_PatternWithInlineCaseInsensitiveFlag_True() + throws DatabaseUnitException + { + final boolean actual = isExpected("(?i)abc", "ABC"); + + assertThat(actual) + .as("The inline (?i) flag should make matching case-insensitive.") + .isTrue(); + } + + @Test + void testIsExpected_PatternWithInlineDotallFlagMatchesNewline_True() + throws DatabaseUnitException + { + final boolean actual = isExpected("(?s)a.b", "a\nb"); + + assertThat(actual) + .as("The inline (?s) flag should let a dot match a newline.") + .isTrue(); + } + + @Test + void testIsExpected_DotWithoutDotallFlagDoesNotMatchNewline_False() + throws DatabaseUnitException + { + final boolean actual = isExpected("a.b", "a\nb"); + + assertThat(actual) + .as("Without the (?s) flag a dot should not match a newline.") + .isFalse(); + } + + @Test + void testIsExpected_WildcardWrappedPatternDoesNotCrossNewlineInActualValue_False() + throws DatabaseUnitException + { + final boolean actual = isExpected(".*4071.*", "top-line\n4071"); + + assertThat(actual).as( + "The documented .* wrapping should not reach across a newline without the (?s) flag.") + .isFalse(); + } + + @Test + void testIsExpected_DotallWildcardWrappedPatternCrossesNewlineInActualValue_True() + throws DatabaseUnitException + { + final boolean actual = isExpected("(?s).*4071.*", "top-line\n4071"); + + assertThat(actual).as( + "Prefixing the .* wrapping with (?s) should reach across a newline, as documented.") + .isTrue(); + } + + @Test + void testIsExpected_EscapedDotMatchesLiteralDot_True() + throws DatabaseUnitException + { + final boolean actual = isExpected("a\\.b", "a.b"); + + assertThat(actual) + .as("An escaped dot in the pattern should match a literal dot.") + .isTrue(); + } + + @Test + void testIsExpected_EscapedDotDoesNotMatchOtherCharacter_False() + throws DatabaseUnitException + { + final boolean actual = isExpected("a\\.b", "axb"); + + assertThat(actual).as( + "An escaped dot in the pattern should not match a non-dot character.") + .isFalse(); + } + + @Test + void testIsExpected_ExplicitlyAnchoredPattern_True() + throws DatabaseUnitException + { + final boolean actual = isExpected("^\\d+$", "123"); + + assertThat(actual).as( + "Redundant explicit anchors should not prevent a whole-value match.") + .isTrue(); + } + + @Test + void testIsExpected_EmptyPatternMatchesEmptyActualValue_True() + throws DatabaseUnitException + { + final boolean actual = isExpected("", ""); + + assertThat(actual) + .as("An empty pattern should match an empty actual value.") + .isTrue(); + } + + @Test + void testIsExpected_EmptyPatternWithNonEmptyActualValue_False() + throws DatabaseUnitException + { + final boolean actual = isExpected("", "x"); + + assertThat(actual) + .as("An empty pattern should not match a non-empty actual value.") + .isFalse(); + } + + @Test + void testIsExpected_StarQuantifierPatternMatchesEmptyActualValue_True() + throws DatabaseUnitException + { + final boolean actual = isExpected("a*", ""); + + assertThat(actual).as( + "A pattern that can match zero characters should match an empty actual value.") + .isTrue(); + } + + @Test + void testIsExpected_NumericActualValueMatchesDigitPattern_True() + throws DatabaseUnitException + { + final boolean actual = isExpected("\\d+", Integer.valueOf(123)); + + assertThat(actual).as( + "A non-string actual value should be converted to a string before matching.") + .isTrue(); + } + + @Test + void testIsExpected_NumericActualValueDoesNotMatchLetterPattern_False() + throws DatabaseUnitException + { + final boolean actual = isExpected("[A-Za-z]+", Integer.valueOf(123)); + + assertThat(actual).as( + "A numeric actual value converted to a string should not match a letters-only pattern.") + .isFalse(); + } + + @Test + void testIsExpected_ExpectedRegexInvalid_ThrowsDatabaseUnitExceptionWithRowAndColumn() + { + assertThatExceptionOfType(DatabaseUnitException.class) + .as("An invalid regular expression should have thrown DatabaseUnitException.") + .isThrownBy(() -> isExpected("[", "abc")) + .withMessageContaining(columnName) + .withMessageContaining(String.valueOf(rowNum)); + } + + @Test + void testIsExpected_ExpectedRegexInvalid_ExceptionCauseIsPatternSyntaxException() + { + assertThatExceptionOfType(DatabaseUnitException.class) + .as("The thrown exception should wrap the underlying PatternSyntaxException.") + .isThrownBy(() -> isExpected("a(", "abc")) + .withCauseInstanceOf(PatternSyntaxException.class); + } + + @Test + void testCompare_ActualValueMatchesPattern_ReturnsNull() + throws DatabaseUnitException + { + final String actual = sut.compare(expectedTable, actualTable, rowNum, + columnName, dataType, "\\d+", "42"); + + assertThat(actual).as("A matching value should produce no fail message.") + .isNull(); + } + + @Test + void testCompare_ActualValueDoesNotMatchPattern_ReturnsFailMessageWithValuesAndPhrase() + throws DatabaseUnitException + { + final String actual = sut.compare(expectedTable, actualTable, rowNum, + columnName, dataType, "\\d+", "x"); + + assertThat(actual) + .as("A non-matching value should produce a fail message naming both values and the fail phrase.") + .contains("x").contains("\\d+") + .contains("not matching the regular expression"); + } + + @Test + void testCompare_BothNull_ReturnsNull() throws DatabaseUnitException + { + final String actual = sut.compare(expectedTable, actualTable, rowNum, + columnName, dataType, null, null); + + assertThat(actual).as("Both values null should produce no fail message.") + .isNull(); + } + + @Test + void testGetFailPhrase_ReturnsNonNullPhrase() + { + final String actual = sut.getFailPhrase(); + + assertThat(actual).as("Should have a fail phrase.").isNotNull(); + } +}