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:
+ *
+ *
+ *
A simple field name (e.g. {@code "timestamp"}), which excludes any field with that exact name at any
+ * nesting depth (top-level object, nested object, or inside array elements) — this is the historical
+ * behavior.
+ *
A dot-notation path (e.g. {@code "RootField.NestedField"}), which excludes only the field found at that
+ * exact nested location, leaving sibling fields (and the parent object itself) subject to normal comparison.
+ * Paths can be nested arbitrarily deep (e.g. {@code "a.b.c"}), and also apply within array elements (e.g.
+ * {@code "items.subField"} excludes {@code subField} on every element of the {@code items} array).
+ *
+ *
* @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