diff --git a/src/main/java/io/github/ktestify/utils/serdes/AvroUtils.java b/src/main/java/io/github/ktestify/utils/serdes/AvroUtils.java index 258869c..d89de15 100644 --- a/src/main/java/io/github/ktestify/utils/serdes/AvroUtils.java +++ b/src/main/java/io/github/ktestify/utils/serdes/AvroUtils.java @@ -227,9 +227,14 @@ public static boolean doesAvroRecordsSmartMatches(@NonNull String expectedRecord *

This method is particularly useful when you want to compare records while ignoring certain fields like * timestamps, IDs, or other fields that may vary between records but don't affect the semantic equality. * + *

Each entry in {@code excludedKeys} can be a simple field name (excludes that field at any nesting depth) or a + * dot-notation path such as {@code "RootField.NestedField"} to exclude only a specific nested field. See + * {@link #deepEquals(Map, Map, List)} for details. + * * @param expectedRecord the expected Avro record in JSON format * @param actualRecord the actual Avro record in JSON format - * @param excludedKeys a list of JSON keys that will be excluded from comparison + * @param excludedKeys a list of JSON keys (simple names or dot-notation paths) that will be excluded from + * comparison * @return true if the records match (excluding specified keys), false otherwise * @throws JsonSyntaxException if either record is not valid JSON * @throws IllegalArgumentException if any parameter is null @@ -267,7 +272,7 @@ public static boolean deepEquals( var expectedConverted = convertDatesToTimestamps(new HashMap<>(expectedValueMap)); var actualConverted = convertDatesToTimestamps(new HashMap<>(actualValueMap)); - return performDeepEqualsComparison(expectedConverted, actualConverted, Collections.emptyList()); + return performDeepEqualsComparison(expectedConverted, actualConverted, Collections.emptyList(), ""); } /** @@ -277,9 +282,21 @@ public static boolean deepEquals( * comparison. This is useful for ignoring volatile fields like timestamps, UUIDs, or other fields that may change * between records. * + *

Keys in {@code excludedKeys} can be either: + * + *

+ * * @param expectedValueMap the map representing the expected Avro record * @param actualValueMap the map representing the actual Avro record - * @param excludedKeys a list of keys to exclude from comparison + * @param excludedKeys a list of keys (simple names or dot-notation paths) to exclude from comparison * @return true if the maps are deeply equal (excluding specified keys), false otherwise * @throws IllegalArgumentException if any parameter is null */ @@ -297,7 +314,7 @@ public static boolean deepEquals( var expectedConverted = convertDatesToTimestamps(new HashMap<>(expectedValueMap)); var actualConverted = convertDatesToTimestamps(new HashMap<>(actualValueMap)); - return performDeepEqualsComparison(expectedConverted, actualConverted, excludedKeys); + return performDeepEqualsComparison(expectedConverted, actualConverted, excludedKeys, ""); } // =========================================== @@ -358,7 +375,8 @@ public static void assertAvroRecordsSmartMatch(@NonNull String expectedRecord, @ * * @param expectedRecord the expected Avro record in JSON format * @param actualRecord the actual Avro record in JSON format - * @param excludedKeys a list of JSON keys that will be excluded from comparison + * @param excludedKeys a list of JSON keys (simple names or dot-notation paths, see {@link #deepEquals(Map, Map, + * List)}) that will be excluded from comparison * @throws ComparisonException if the records do not match (excluding specified keys) * @throws JsonSyntaxException if either record is not valid JSON * @throws IllegalArgumentException if any parameter is null @@ -512,18 +530,29 @@ private static Object getNestedValue(Map map, String key) { *

This method handles the core comparison logic including nested objects, lists, and null value handling. It * provides detailed logging for debugging and troubleshooting purposes. * + *

{@code excludedKeys} entries are matched either as a simple field name (matching at any nesting depth) or as a + * dot-notation path qualified from the root of the record being compared (e.g. {@code "RootField.NestedField"}), + * built up across recursive calls via {@code parentPath}. + * * @param expectedValueMap the processed expected map * @param actualValueMap the processed actual map - * @param excludedKeys list of keys to exclude from comparison + * @param excludedKeys list of keys (simple names or dot-notation paths) to exclude from comparison + * @param parentPath the dot-notation path of the object currently being compared, relative to the record root + * (empty string at the root) * @return true if maps are equal, false otherwise */ private static boolean performDeepEqualsComparison( - Map expectedValueMap, Map actualValueMap, List excludedKeys) { - - long expectedExcludedCount = - excludedKeys.stream().filter(expectedValueMap::containsKey).count(); - long actualExcludedCount = - excludedKeys.stream().filter(actualValueMap::containsKey).count(); + Map expectedValueMap, + Map actualValueMap, + List excludedKeys, + String parentPath) { + + long expectedExcludedCount = expectedValueMap.keySet().stream() + .filter(k -> isKeyExcluded(k, buildQualifiedKey(parentPath, k), excludedKeys)) + .count(); + long actualExcludedCount = actualValueMap.keySet().stream() + .filter(k -> isKeyExcluded(k, buildQualifiedKey(parentPath, k), excludedKeys)) + .count(); long effectiveExpectedSize = expectedValueMap.size() - expectedExcludedCount; long effectiveActualSize = actualValueMap.size() - actualExcludedCount; @@ -549,8 +578,9 @@ private static boolean performDeepEqualsComparison( for (var entry : expectedValueMap.entrySet()) { var key = entry.getKey(); - if (excludedKeys.contains(key)) { - LOGGER.debug("The key {} will not be matched due to it being in the excluded list", key); + var qualifiedKey = buildQualifiedKey(parentPath, key); + if (isKeyExcluded(key, qualifiedKey, excludedKeys)) { + LOGGER.debug("The key {} will not be matched due to it being in the excluded list", qualifiedKey); continue; } @@ -562,7 +592,7 @@ private static boolean performDeepEqualsComparison( var expectedValue = entry.getValue(); var actualValue = actualValueMap.get(key); - if (!compareValues(expectedValue, actualValue, excludedKeys, key)) { + if (!compareValues(expectedValue, actualValue, excludedKeys, key, qualifiedKey)) { return false; } } @@ -570,6 +600,31 @@ private static boolean performDeepEqualsComparison( return true; } + /** + * Builds the fully-qualified dot-notation path of a key, relative to the record root. + * + * @param parentPath the path of the enclosing object (empty string at the root) + * @param key the local field name + * @return {@code key} if {@code parentPath} is empty, otherwise {@code parentPath + "." + key} + */ + private static String buildQualifiedKey(String parentPath, String key) { + return (parentPath == null || parentPath.isEmpty()) ? key : parentPath + "." + key; + } + + /** + * Determines whether a field should be excluded from comparison, supporting both the historical simple-name + * matching (exclude at any nesting depth) and dot-notation path matching (exclude only at a specific nested + * location). + * + * @param key the local field name + * @param qualifiedKey the fully-qualified dot-notation path of the field, relative to the record root + * @param excludedKeys the configured list of excluded keys/paths + * @return true if the field must be excluded from comparison + */ + private static boolean isKeyExcluded(String key, String qualifiedKey, List excludedKeys) { + return excludedKeys.contains(key) || excludedKeys.contains(qualifiedKey); + } + /** * Compares two values, handling different types including nested objects and lists. * @@ -588,15 +643,20 @@ private static boolean performDeepEqualsComparison( * @param actualValue the actual value * @param excludedKeys list of keys to exclude from nested comparisons * @param key the current key being compared (for logging) + * @param qualifiedKey the dot-notation path of the current key, relative to the record root (used to thread nested + * exclusion paths down into recursive comparisons) * @return true if values are equal, false otherwise */ private static boolean compareValues( - Object expectedValue, Object actualValue, List excludedKeys, String key) { + Object expectedValue, Object actualValue, List excludedKeys, String key, String qualifiedKey) { if (expectedValue instanceof Map && actualValue instanceof Map) { LOGGER.debug( AVRO_UTILS_NESTED_OBJECT_FOUND, expectedValue.getClass().getSimpleName()); if (!performDeepEqualsComparison( - (Map) expectedValue, (Map) actualValue, excludedKeys)) { + (Map) expectedValue, + (Map) actualValue, + excludedKeys, + qualifiedKey)) { LOGGER.debug(AVRO_UTILS_NESTED_OBJECT_NOT_MATCH); return false; } @@ -605,7 +665,7 @@ private static boolean compareValues( AVRO_UTILS_NESTED_OBJECT_FOUND + " key : {}", expectedValue.getClass().getSimpleName(), key); - if (!deepEqualsList((List) expectedValue, (List) actualValue, excludedKeys)) { + if (!deepEqualsList((List) expectedValue, (List) actualValue, excludedKeys, qualifiedKey)) { LOGGER.error("Value mismatch for key {} values does not match for a list", key); return false; } @@ -747,10 +807,12 @@ private static LocalDate parseExpectedLocalDate(String expectedValue) { * @param expectedList the expected list * @param actualList the actual list * @param excludedKeys list of keys to exclude from nested object comparisons + * @param parentPath the dot-notation path of the array field, relative to the record root (used so that exclusion + * paths like {@code "items.subField"} apply to every element of the array) * @return true if lists are equal, false otherwise */ private static boolean deepEqualsList( - List expectedList, List actualList, List excludedKeys) { + List expectedList, List actualList, List excludedKeys, String parentPath) { if (expectedList.size() != actualList.size()) { LOGGER.error("List size mismatch. Expected: {}, Actual: {}", expectedList.size(), actualList.size()); return false; @@ -759,11 +821,11 @@ private static boolean deepEqualsList( // For primitive arrays or mixed types, fall back to ordered comparison if (expectedList.isEmpty() || !isObjectArray(expectedList)) { - return deepEqualsListOrdered(expectedList, actualList, excludedKeys); + return deepEqualsListOrdered(expectedList, actualList, excludedKeys, parentPath); } // Smart matching for object arrays - match regardless of order - return deepEqualsListUnordered(expectedList, actualList, excludedKeys); + return deepEqualsListUnordered(expectedList, actualList, excludedKeys, parentPath); } /** @@ -782,10 +844,11 @@ private static boolean isObjectArray(List list) { * @param expectedList the expected list * @param actualList the actual list * @param excludedKeys list of keys to exclude from nested object comparisons + * @param parentPath the dot-notation path of the array field, relative to the record root * @return true if lists are equal in order, false otherwise */ private static boolean deepEqualsListOrdered( - List expectedList, List actualList, List excludedKeys) { + List expectedList, List actualList, List excludedKeys, String parentPath) { LOGGER.debug("Performing ordered array comparison for {} expected objects", expectedList.size()); for (int i = 0; i < expectedList.size(); i++) { var expected = expectedList.get(i); @@ -793,7 +856,7 @@ private static boolean deepEqualsListOrdered( if (expected instanceof Map && actual instanceof Map) { if (!performDeepEqualsComparison( - (Map) expected, (Map) actual, excludedKeys)) { + (Map) expected, (Map) actual, excludedKeys, parentPath)) { return false; } } else if (!Objects.equals(expected, actual)) { @@ -812,10 +875,11 @@ private static boolean deepEqualsListOrdered( * @param expectedList the expected list of objects * @param actualList the actual list of objects * @param excludedKeys list of keys to exclude from nested object comparisons + * @param parentPath the dot-notation path of the array field, relative to the record root * @return true if all expected objects find matches in actual list, false otherwise */ private static boolean deepEqualsListUnordered( - List expectedList, List actualList, List excludedKeys) { + List expectedList, List actualList, List excludedKeys, String parentPath) { LOGGER.debug("Performing smart unordered array comparison for {} expected objects", expectedList.size()); // Keep track of which actual objects have been matched @@ -834,7 +898,7 @@ private static boolean deepEqualsListUnordered( var actualObject = (Map) actualList.get(actualIndex); - if (performDeepEqualsComparison(expectedObject, actualObject, excludedKeys)) { + if (performDeepEqualsComparison(expectedObject, actualObject, excludedKeys, parentPath)) { LOGGER.debug( "Found match for expected object at index {} with actual object at index {}", expectedIndex, @@ -1306,9 +1370,9 @@ private static GenericData.Array convertJsonArrayToArray(JsonArray jsonA * href="https://github.com/ktestify/ktestify-core/issues/49">issue #49): guessing whether a plain string "looks * like" a date is fundamentally unreliable, since it cannot distinguish a genuine Avro {@code string} field whose * content happens to look like a date from an actual logical-type date/timestamp field. Date/timestamp comparison - * is now performed in a type-driven way directly in {@link #compareValues(Object, Object, List, String)}, based on - * the actual side's real Java type ({@link Instant}, {@link LocalDateTime}, {@link LocalDate}) as resolved by - * {@link AvroDeserializer}. + * is now performed in a type-driven way directly in {@link #compareValues(Object, Object, List, String, String)}, + * based on the actual side's real Java type ({@link Instant}, {@link LocalDateTime}, {@link LocalDate}) as resolved + * by {@link AvroDeserializer}. * *

This method still recurses into nested maps and lists so that the returned map is a structurally-independent * (deep) copy, but performs no value conversion. diff --git a/src/test/java/io/github/ktestify/utils/serdes/AvroUtilsTest.java b/src/test/java/io/github/ktestify/utils/serdes/AvroUtilsTest.java index bfe1e58..e7bcd0c 100644 --- a/src/test/java/io/github/ktestify/utils/serdes/AvroUtilsTest.java +++ b/src/test/java/io/github/ktestify/utils/serdes/AvroUtilsTest.java @@ -2738,4 +2738,157 @@ void shouldMatch_NestedListWithDateField() { assertTrue(AvroUtils.deepEquals(expected, actual)); } } + + @Nested + @DisplayName("Dot-notation excludedKeys (nested field exclusion)") + class DotNotationExcludedKeysTests { + + @Test + @DisplayName( + "Should exclude only the nested field when a dot-notation path is used, keeping sibling fields compared") + void shouldExcludeOnlyNestedField_WhenDotNotationPathIsUsed() { + // Mirrors the reported schema: RootField is a nested record with NestedField1 / NestedField2. + Map expectedNested = new HashMap<>(); + expectedNested.put("NestedField1", "same-value"); + expectedNested.put("NestedField2", "expected-value"); + + Map actualNested = new HashMap<>(); + actualNested.put("NestedField1", "same-value"); + actualNested.put("NestedField2", "actual-value"); // differs, but excluded via dot-notation + + Map expected = new HashMap<>(); + expected.put("RootField", expectedNested); + + Map actual = new HashMap<>(); + actual.put("RootField", actualNested); + + assertTrue( + AvroUtils.deepEquals(expected, actual, List.of("RootField.NestedField2")), + "RootField.NestedField2 should be excluded, leaving NestedField1 (which matches) to make the" + + " records equal"); + } + + @Test + @DisplayName("Should still fail when a sibling (non-excluded) nested field differs") + void shouldStillFail_WhenNonExcludedNestedFieldDiffers() { + Map expectedNested = new HashMap<>(); + expectedNested.put("NestedField1", "expected-value"); + expectedNested.put("NestedField2", "same-value"); + + Map actualNested = new HashMap<>(); + actualNested.put("NestedField1", "actual-value"); // differs and is NOT excluded + actualNested.put("NestedField2", "same-value"); + + Map expected = new HashMap<>(); + expected.put("RootField", expectedNested); + + Map actual = new HashMap<>(); + actual.put("RootField", actualNested); + + assertFalse( + AvroUtils.deepEquals(expected, actual, List.of("RootField.NestedField2")), + "NestedField1 differs and is not excluded, so the records should not match"); + } + + @Test + @DisplayName("Should support multi-level dot-notation paths") + void shouldSupportMultiLevelDotNotationPaths() { + Map expectedLevel3 = new HashMap<>(); + expectedLevel3.put("value", "expected"); + + Map actualLevel3 = new HashMap<>(); + actualLevel3.put("value", "actual"); // differs, excluded via "a.b.c.value" + + Map expectedLevel2 = new HashMap<>(); + expectedLevel2.put("c", expectedLevel3); + Map actualLevel2 = new HashMap<>(); + actualLevel2.put("c", actualLevel3); + + Map expectedLevel1 = new HashMap<>(); + expectedLevel1.put("b", expectedLevel2); + Map actualLevel1 = new HashMap<>(); + actualLevel1.put("b", actualLevel2); + + Map expected = new HashMap<>(); + expected.put("a", expectedLevel1); + Map actual = new HashMap<>(); + actual.put("a", actualLevel1); + + assertTrue(AvroUtils.deepEquals(expected, actual, List.of("a.b.c.value"))); + } + + @Test + @DisplayName("Should apply dot-notation exclusion to every element of an array field") + void shouldApplyDotNotationExclusionToArrayElements() { + Map expectedItem1 = new HashMap<>(); + expectedItem1.put("id", 1); + expectedItem1.put("volatileField", "expected-1"); + + Map expectedItem2 = new HashMap<>(); + expectedItem2.put("id", 2); + expectedItem2.put("volatileField", "expected-2"); + + Map actualItem1 = new HashMap<>(); + actualItem1.put("id", 1); + actualItem1.put("volatileField", "actual-1"); + + Map actualItem2 = new HashMap<>(); + actualItem2.put("id", 2); + actualItem2.put("volatileField", "actual-2"); + + Map expected = new HashMap<>(); + expected.put("items", List.of(expectedItem1, expectedItem2)); + + Map actual = new HashMap<>(); + actual.put("items", List.of(actualItem1, actualItem2)); + + assertTrue(AvroUtils.deepEquals(expected, actual, List.of("items.volatileField"))); + } + + @Test + @DisplayName("Should keep excluding a field at every depth when a simple (non-dotted) name is used") + void shouldStillSupportSimpleNameExclusion_ForBackwardCompatibility() { + Map expectedNested = new HashMap<>(); + expectedNested.put("NestedField1", "same-value"); + expectedNested.put("NestedField2", "expected-value"); + + Map actualNested = new HashMap<>(); + actualNested.put("NestedField1", "same-value"); + actualNested.put("NestedField2", "actual-value"); + + Map expected = new HashMap<>(); + expected.put("RootField", expectedNested); + + Map actual = new HashMap<>(); + actual.put("RootField", actualNested); + + // Simple (non-dotted) exclusion still works at any nesting depth, as before. + assertTrue(AvroUtils.deepEquals(expected, actual, List.of("NestedField2"))); + } + + @Test + @DisplayName("Should correctly account for effective size when a nested field is excluded via dot-notation") + void shouldAccountForEffectiveSize_WhenNestedFieldExcludedViaDotNotation() { + // Even though "RootField.NestedField2" is excluded, RootField itself must remain counted at the + // top level (only NestedField2 is skipped at the nested level), so this must not be treated as a + // record-size mismatch. + Map expectedNested = new HashMap<>(); + expectedNested.put("NestedField1", "value1"); + expectedNested.put("NestedField2", "expected-only"); + + Map actualNested = new HashMap<>(); + actualNested.put("NestedField1", "value1"); + actualNested.put("NestedField2", "actual-only"); + + Map expected = new HashMap<>(); + expected.put("RootField", expectedNested); + expected.put("otherField", "same"); + + Map actual = new HashMap<>(); + actual.put("RootField", actualNested); + actual.put("otherField", "same"); + + assertTrue(AvroUtils.deepEquals(expected, actual, List.of("RootField.NestedField2"))); + } + } }