Java. My solution to find changes between two objects. ChangeChecker

Introduction

While working on the Jakarta validation addon, I had to write logic to check for changes in the model using my own CheckExistingByConstraintAndUnmodifiableAttributes annotation.

I looked at the resulting code for a long time, and a bright (probably) idea came to mind: why not put all this into a full-fledged customizable class?

What is this solution for?

As already mentioned, the solution is designed to search and obtain detailed information about the differences (hereinafter referred to as “delta”) between two objects.

Let's say we need to check for changes in specific fields that may not be in equals, and get information about the differences separately for each field. Let's say, just within the framework of checking for certain (not all) fields for immutability for models. And full information about the error, if there are changes.

In such cases, my solution – ChangeChecker – can be used.

Let's talk about the implementation of the idea. Two objects.

I won't go into too much implementation details (again, you can see the details in the repository) and will try to focus on the “specification”.

ChangeChecker

The implementations of this interface actually do all the work of finding the “delta” between objects. We'll talk about the implementations a bit later, but for now it looks like this.

Hidden text
/**
 * Interface for finding differences between two objects.
 * @param <T> - type of objects
 * @see ValueChangesCheckerResult
 *
 * @author Ihar Smolka
 */
public interface ChangesChecker<T> {

    /**
     * Find differences between two objects.
     * @param oldObj - old object
     * @param newObj - new object
     * @return finding result
     */
    ValueChangesCheckerResult getResult(T oldObj, T newObj);
}

It's simple: two objects of the same type come in at the input, and at the output we get a detailed comparison result for the two objects.

What does the result look like?

Hidden text
/**
 * Result for check two objects.
 * @see com.ismolka.validation.utils.change.ChangesChecker
 *
 * @param differenceMap - difference map
 * @param equalsResult - equals result
 * @author Ihar Smolka
 */
public record ValueChangesCheckerResult(
        Map<String, Difference> differenceMap,
        boolean equalsResult
) implements Difference, CheckerResult {

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        ValueChangesCheckerResult that = (ValueChangesCheckerResult) o;
        return equalsResult == that.equalsResult && Objects.equals(differenceMap, that.differenceMap);
    }

    @Override
    public int hashCode() {
        return Objects.hash(differenceMap, equalsResult);
    }

    @Override
    public <T extends Difference> T unwrap(Class<T> type) {
        if (type.isAssignableFrom(ValueChangesCheckerResult.class)) {
            return type.cast(this);
        }

        throw new ClassCastException(String.format("Cannot unwrap ValueChangesCheckerResult to %s", type));
    }

    @Override
    public CheckerResultNavigator navigator() {
        return new DefaultCheckerResultNavigator(this);
    }
}

And the associated Difference interface.

Hidden text
/**
 * Difference interface
 *
 * @author Ihar Smolka
 */
public interface Difference {

    /**
     * for unwrapping a difference
     *
     * @param type - toType
     * @return unwrapped difference
     * @param <TYPE> - type
     */
    <TYPE extends Difference> TYPE unwrap(Class<TYPE> type);
}
  • Difference is close in meaning to “marker interfaces”, because it marks all classes related to information about the “delta”. If it were not for the unwrap method, intended for a more “beautiful” casting of the Difference object to a specific implementation, it could be considered as such.

  • differenceMap – is necessary for storing detailed information on differences between two objects. Here the field name/path to the field is mapped to a specific Difference. This allows storing a complex “delta” structure with nestings of various types (both results by Map, and by Collection, etc.).

  • equalsResult – I think the meaning is clear. It tells whether objects have a “delta”.

Value Difference

It looks like this.

Hidden text
/**
 * Difference between two values.
 *
 * @param valueFieldPath - attribute path from the root class.
 * @param valueFieldRootClass - attribute root class.
 * @param valueFieldDeclaringClass - attribute declaring class.
 * @param valueClass - value class.
 * @param oldValue - old value.
 * @param newValue - new value.
 * @param <F> - value type.
 *
 * @author Ihar Smolka
 */
public record ValueDifference<F>(String valueFieldPath,
                                    Class<?> valueFieldRootClass,

                                    Class<?> valueFieldDeclaringClass,
                                    Class<F> valueClass,
                                    F oldValue,
                                    F newValue) implements Difference {

    @Override
    public <T extends Difference> T unwrap(Class<T> type) {
        if (type.isAssignableFrom(ValueDifference.class)) {
            return type.cast(this);
        }

        throw new ClassCastException(String.format("Cannot unwrap AttributeDifference to %s", type));
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        ValueDifference<?> that = (ValueDifference<?>) o;
        return Objects.equals(valueFieldPath, that.valueFieldPath) && Objects.equals(valueFieldRootClass, that.valueFieldRootClass) && Objects.equals(valueClass, that.valueClass) && Objects.equals(oldValue, that.oldValue) && Objects.equals(newValue, that.newValue);
    }

    @Override
    public int hashCode() {
        return Objects.hash(valueFieldPath, valueFieldRootClass, valueClass, oldValue, newValue);
    }
}

This is a class for storing basic information about two different objects. Here we see oldObject and newObject (the meaning is obvious), their class, and other meta-information that may be useful in comparing objects as attributes of a certain class.

ValueCheckDescriptorBuilder

The main content is as follows.

Hidden text
/**
 * Builder for {@link ValueCheckDescriptor}.
 * @see ValueCheckDescriptor
 *
 * @param <Q> - value type
 * @author Ihar Smolka
 */
public class ValueCheckDescriptorBuilder<Q> {

    Class<?> sourceClass;

    Class<Q> targetClass;

    String attribute;

    Set<String> equalsFields;

    Method equalsMethodReflection;

    BiPredicate<Q, Q> biEqualsMethod;

    ChangesChecker<Q> changesChecker;

   ...
}

Serves to describe how exactly the check of two attributes will be performed.

  • sourceClass – the class in which the attribute is defined.

  • targetClass – attribute class.

  • attribute – attribute name/path.

  • equalsFields – internal fields for comparison by equals. Can work together with installed changesChecker, but is incompatible with equalsMethodReflection and biEqualsMethod.

  • equalsMethodReflection – Method instance. Can be useful when passing some “custom equals” via reflection.

  • biEqualsMethod – BiPredicate by which objects will be compared. You can pass, for example, Objects.equals (although this is pointless, because Objects.equals will be called if other methods of comparison are not specified).

  • changesChecker – you can pass some nested ChangeChecker for checking. How this is used – you will understand in the course of the article.

And the key one.

DefaultValueChangesCheckerBuilder

It looks like this and defines the settings for checking two objects.

Hidden text
/**
 * Builder for {@link ValueCheckDescriptor}.
 * @see DefaultValueChangesChecker
 *
 * @param <T> - value type
 * @author Ihar Smolka
 */
public class DefaultValueChangesCheckerBuilder<T> {

    Class<T> targetClass;

    Set<ValueCheckDescriptor<?>> attributesCheckDescriptors;

    boolean stopOnFirstDiff;

    Method globalEqualsMethodReflection;

    BiPredicate<T, T> globalBiEqualsMethod;

    Set<String> globalEqualsFields;

   ...
}
  • targetClass – class of objects.

  • attributesCheckDescriptors – describes “complex” attribute checks using the previous class. Compatible with globalEqualsFields, incompatible with globalEqualsMethodReflection and globalBiEqualsMethod.

  • stopOnFirstDiff – whether to stop checking at the first difference.

  • globalEqualsFields – by what attributes will simple equals be applied. In essence, it is the same as equalsFields in the previous class, but it works “on” the transferred ValueCheckDescriptor.

Examples of use in the form of tests.

Hidden text
    @Test
    public void test_innerObject() {
        ChangeTestObject oldTestObj = new ChangeTestObject();
        ChangeTestObject newTestObj = new ChangeTestObject();

        oldTestObj.setInnerObject(new ChangeTestInnerObject(OLD_VAL_STR));
        newTestObj.setInnerObject(new ChangeTestInnerObject(NEW_VAL_STR));

        CheckerResult result = DefaultValueChangesCheckerBuilder.builder(ChangeTestObject.class)
                .addAttributeToCheck(
                        ValueCheckDescriptorBuilder.builder(ChangeTestObject.class, ChangeTestInnerObject.class)
                                .attribute("innerObject")
                                .addEqualsField("valueFromObject")
                                .build()
                )
                .build().getResult(oldTestObj, newTestObj);

        ValueDifference<?> valueDifference = result.navigator().getDifference("innerObject.valueFromObject").unwrap(ValueDifference.class);

        String oldValueFromCheckResult = (String) valueDifference.oldValue();
        String newValueFromCheckResult = (String) valueDifference.newValue();

        Assertions.assertEquals(oldValueFromCheckResult, oldTestObj.getInnerObject().getValueFromObject());
        Assertions.assertEquals(newValueFromCheckResult, newTestObj.getInnerObject().getValueFromObject());
    }
Hidden text
    @Test
    public void test_innerObjectWithoutValueDescriptor() {
        ChangeTestObject oldTestObj = new ChangeTestObject();
        ChangeTestObject newTestObj = new ChangeTestObject();

        oldTestObj.setInnerObject(new ChangeTestInnerObject(OLD_VAL_STR));
        newTestObj.setInnerObject(new ChangeTestInnerObject(NEW_VAL_STR));

        CheckerResult result = DefaultValueChangesCheckerBuilder.builder(ChangeTestObject.class)
                .addGlobalEqualsField("innerObject.valueFromObject")
                .build().getResult(oldTestObj, newTestObj);

        ValueDifference<?> valueDifference = result.navigator().getDifference("innerObject.valueFromObject").unwrap(ValueDifference.class);

        String oldValueFromCheckResult = (String) valueDifference.oldValue();
        String newValueFromCheckResult = (String) valueDifference.newValue();

        Assertions.assertEquals(oldValueFromCheckResult, oldTestObj.getInnerObject().getValueFromObject());
        Assertions.assertEquals(newValueFromCheckResult, newTestObj.getInnerObject().getValueFromObject());
    }

Let's continue the conversation. Two collections/arrays.

CollectionChangesChecker

To compare two collections, there is the CollectionChangesChecker interface, which extends the base ChangesChecker.

Hidden text
/**
 * Interface for check differences between two collections.
 * @see CollectionChangesCheckerResult
 *
 * @param <T> - collection value type
 *
 * @author Ihar Smolka
 */
public interface CollectionChangesChecker<T> extends ChangesChecker<T> {

    /**
     * Find difference between two collections.
     *
     * @param oldCollection - old collection
     * @param newCollection - new collection
     * @return {@link CollectionChangesCheckerResult}
     */
    CollectionChangesCheckerResult<T> getResult(Collection<T> oldCollection, Collection<T> newCollection);

    /**
     * Find difference between two arrays
     *
     * @param oldArray - old array
     * @param newArray - new array
     * @return {@link CollectionChangesCheckerResult}
     */
    CollectionChangesCheckerResult<T> getResult(T[] oldArray, T[] newArray);
}

As we can see, two more methods have appeared – getResult by collections and by arrays (in the implementation, arrays are simply wrapped in a List and pass through getResult with collections).

They return CollectionChangesCheckerResult.

CollectionChangesCheckerResult

Hidden text
/**
 * Result for check two collections.
 *
 * @param collectionClass - collection value class.
 * @param collectionDifferenceMap - collection difference.
 * @param equalsResult - equals result
 * @param <F> - type of collection values
 *
 * @author Ihar Smolka
 */
public record CollectionChangesCheckerResult<F>(
        Class<F> collectionClass,
        Map<CollectionOperation, Set<CollectionElementDifference<F>>> collectionDifferenceMap,
        boolean equalsResult) implements Difference, CheckerResult {

...
}
Hidden text
/**
 * Possible modifying operations for {@link java.util.Collection}.
 *
 * @author Ihar Smolka
 */
public enum CollectionOperation {

    /**
     * Add element
     */
    ADD,

    /**
     * Remove element
     */
    REMOVE,

    /**
     * Update element
     */
    UPDATE
}
Hidden text
/**
 * Difference between two elements of {@link java.util.Collection}.
 *
 * @param diffBetweenElementsFields - difference between elements.
 * @param elementFromOldCollection - element from old collection.
 * @param elementFromNewCollection - element from new collection.
 * @param elementFromOldCollectionIndex - index of element from old collection.
 * @param elementFromNewCollectionIndex - index of element from new collection.
 * @param <F> - type of collection elements.
 *
 * @author Ihar Smolka
 */
public record CollectionElementDifference<F>(
        Map<String, Difference> diffBetweenElementsFields,
        F elementFromOldCollection,
        F elementFromNewCollection,
        Integer elementFromOldCollectionIndex,
        Integer elementFromNewCollectionIndex
) implements Difference {

...
}

As we can see, this time the information storing the “delta” is presented in the form of a map, in which the operation of changing the collection is associated with a set of changes of this type.

Well, CollectionElementDifference contains information about which elements from which collections differ, at which indexes and what exactly the differences are between them. For the UPDATE operation, both elements must be filled. For ADD, the old element will be missing, for REMOVE, the new one, respectively.

DefaultCollectionChangesCheckerBuilder

Hidden text
**
 * Builder for {@link DefaultCollectionChangesChecker}
 *
 * @param <T> - type of collection elements.
 *
 * @author Ihar Smolka
 */
public class DefaultCollectionChangesCheckerBuilder<T> {

    Class<T> collectionGenericClass;

    Set<ValueCheckDescriptor<?>> attributesCheckDescriptors;

    boolean stopOnFirstDiff;

    Set<CollectionOperation> forOperations;

    Set<String> fieldsForMatching;

    Method globalEqualsMethodReflection;

    BiPredicate<T, T> globalBiEqualsMethod;

    Set<String> globalEqualsFields;
...
}

In principle, everything is almost the same as DefaultValueChangesCheckerBuilder, let's talk about the differences.

  • fieldsForMatching – by what fields the objects will be matched within the collections. That is, if these fields are the same for two elements in different collections, they will be compared with each other, and if there is a “delta” between them, then this is an UPDATE of the element in the collection. If this is not defined, the index in the collection will act as such a “key”.

  • forOperations – for which operations we get the “delta”. By default, for all.

  • collectionGenericClass – instances of what class the collection contains.

Example of use in the form of a test.

Hidden text
    @Test
    public void test_collection() {
        String key = "ID_IN_COLLECTION";

        ChangeTestObject oldTestObj = new ChangeTestObject();
        ChangeTestObject newTestObj = new ChangeTestObject();

        ChangeTestObjectCollection oldCollectionObj = new ChangeTestObjectCollection(key, OLD_VAL_STR);
        ChangeTestObjectCollection newCollectionObj = new ChangeTestObjectCollection(key, NEW_VAL_STR);

        oldTestObj.setCollection(List.of(oldCollectionObj));
        newTestObj.setCollection(List.of(newCollectionObj));

        CheckerResult result = DefaultValueChangesCheckerBuilder.builder(ChangeTestObject.class)
                .addAttributeToCheck(
                        ValueCheckDescriptorBuilder.builder(ChangeTestObject.class, ChangeTestObjectCollection.class)
                                .attribute("collection")
                                .changesChecker(
                                        DefaultCollectionChangesCheckerBuilder.builder(ChangeTestObjectCollection.class)
                                                .addGlobalEqualsField("valueFromCollection")
                                                .addFieldForMatching("key")
                                                .build()
                                ).build()

                ).build().getResult(oldTestObj, newTestObj);

        CollectionElementDifference<ChangeTestObjectCollection> difference = result.navigator().getDifferenceForCollection("collection", ChangeTestObjectCollection.class).stream().findFirst().orElseThrow(() -> new RuntimeException("Result for collection is not present"));

        Assertions.assertEquals(difference.elementFromOldCollection().getValueFromCollection(), oldCollectionObj.getValueFromCollection());
        Assertions.assertEquals(difference.elementFromNewCollection().getValueFromCollection(), newCollectionObj.getValueFromCollection());
    }

The conversation is coming to an end. Two maps.

MapChangesChecker

For this purpose we have the following interface.

Hidden text
/**
 * Interface for check differences between two maps.
 * @see MapChangesCheckerResult
 *
 * @param <K> - key type
 * @param <V> - value type
 *
 * @author Ihar Smolka
 */
public interface MapChangesChecker<K, V> extends ChangesChecker<V> {

    /**
     * Find difference between two maps.
     *
     * @param oldMap - old map
     * @param newMap - new map
     * @return difference result
     */
    MapChangesCheckerResult<K, V> getResult(Map<K, V> oldMap, Map<K, V> newMap);
}

K describes the key class, V – accordingly, the value class for the map.

MapChangesCheckerResult

Hidden text
/**
 * Result for check two maps.
 * @see MapElementDifference
 *
 * @param keyClass - key class
 * @param valueClass - value class
 * @param mapDifference - map difference
 * @param equalsResult - equals result
 * @param <K> - key type
 * @param <V> - value type
 *
 * @author Ihar Smolka
 */
public record MapChangesCheckerResult<K, V>(
        Class<K> keyClass,

        Class<V> valueClass,

        Map<MapOperation, Set<MapElementDifference<K, V>>> mapDifference,

        boolean equalsResult
) implements Difference, CheckerResult {

...
}
Hidden text
/**
 * Possible modifying operations for {@link java.util.Map}.
 *
 * @author Ihar Smolka
 */
public enum MapOperation {

    /**
     * Add element
     */
    PUT,

    /**
     * Remove element
     */
    REMOVE,

    /**
     * Update element
     */
    UPDATE
}
Hidden text
/**
 * Difference between two elements of {@link Map}.
 *
 * @param diffBetweenElementsFields - difference between elements
 * @param elementFromOldMap - element from the old map
 * @param elementFromNewMap - element from tht new map
 * @param key - map key with difference
 * @param <K> - key type
 * @param <V> - value type
 *
 * @author Ihar Smolka
 */
public record MapElementDifference<K, V>(
        Map<String, Difference> diffBetweenElementsFields,

        V elementFromOldMap,

        V elementFromNewMap,

        K key
) implements Difference {

...
}

In general, it looks like CollectionChangesCheckerResult, only now there are key and value classes. Well, the map with “delta” contains slightly different information – there is hardly any point in dwelling on it in detail, everything should be clear without unnecessary words.

DefaultMapChangesCheckerBuilder

Hidden text
/**
 * Builder for {@link DefaultMapChangesChecker}
 *
 * @param <K> - key type
 * @param <V> - value type
 */
public class DefaultMapChangesCheckerBuilder<K, V> {

    Class<K> keyClass;

    Class<V> valueClass;

    Set<MapOperation> forOperations;

    Set<ValueCheckDescriptor<?>> attributesCheckDescriptors;

    boolean stopOnFirstDiff;

    Method globalEqualsMethodReflection;

    BiPredicate<V, V> globalBiEqualsMethod;

    Set<String> globalEqualsFields;

...
}

Again, I think everything is clear here without further ado, as it is very similar to the previous builders.

According to tradition.

Hidden text
    @Test
    public void test_map() {
        String key = "ID_IN_MAP";

        ChangeTestObject oldTestObj = new ChangeTestObject();
        ChangeTestObject newTestObj = new ChangeTestObject();

        ChangeTestObjectMap oldMapObj = new ChangeTestObjectMap(OLD_VAL_STR);
        ChangeTestObjectMap newMapObj = new ChangeTestObjectMap(NEW_VAL_STR);

        oldTestObj.setMap(Map.of(key, oldMapObj));
        newTestObj.setMap(Map.of(key, newMapObj));

        CheckerResult result = DefaultValueChangesCheckerBuilder.builder(ChangeTestObject.class)
                .addAttributeToCheck(
                        ValueCheckDescriptorBuilder.builder(ChangeTestObject.class, ChangeTestObjectMap.class)
                                .attribute("map")
                                .changesChecker(
                                        DefaultMapChangesCheckerBuilder.builder(String.class, ChangeTestObjectMap.class)
                                                .addGlobalEqualsField("valueFromMap")
                                                .build()
                                ).build()
                ).build().getResult(oldTestObj, newTestObj);

        MapElementDifference<String, ChangeTestObjectMap> difference = result.navigator().getDifferenceForMap("map", String.class, ChangeTestObjectMap.class).stream().findFirst().orElseThrow(() -> new RuntimeException("Result for map is not present"));

        Assertions.assertEquals(difference.elementFromOldMap().getValueFromMap(), oldMapObj.getValueFromMap());
        Assertions.assertEquals(difference.elementFromNewMap().getValueFromMap(), newMapObj.getValueFromMap());
    }

The conversation is almost over. Navigating by result.

In my opinion, using these tools you can relatively easily get a “delta” for objects of any (well, almost any) structure.

The question now is how we can conveniently “navigate” through the resulting mess the resulting delta. The following interface comes to the rescue.

Hidden text
/**
 * Interface for navigation in {@link com.ismolka.validation.utils.change.CheckerResult}.
 * @see com.ismolka.validation.utils.change.CheckerResult
 *
 * @author Ihar Smolka
 */
public interface CheckerResultNavigator {

    /**
     * Get difference for {@link java.util.Map}
     *
     * @param fieldPath - attribute path with difference.
     * @param keyClass - key class.
     * @param valueClass - value class.
     * @param operations - return for {@link MapOperation}.
     * @return {@link Set} of {@link MapElementDifference} - if differences are there and 'null' - if aren't.
     * @param <K> - key type.
     * @param <V> - value type.
     */
    <K, V> Set<MapElementDifference<K, V>> getDifferenceForMap(String fieldPath, Class<K> keyClass, Class<V> valueClass, MapOperation... operations);

    /**
     * Get difference for {@link java.util.Collection}
     *
     * @param fieldPath - attribute path with difference.
     * @param forClass - class of collection values.
     * @param operations - return for {@link CollectionOperation}.
     * @return {@link Set} of {@link CollectionElementDifference} - if differences are there and 'null' - if aren't.
     * @param <T> - value type
     */
    <T> Set<CollectionElementDifference<T>> getDifferenceForCollection(String fieldPath, Class<T> forClass, CollectionOperation... operations);

    /**
     * Get difference for {@link java.util.Map}
     *
     * @param keyClass - key class.
     * @param valueClass - value class.
     * @param operations - return for {@link MapOperation}.
     * @return {@link Set} of {@link MapElementDifference} - if differences are there and 'null' - if aren't.
     * @param <K> - key type.
     * @param <V> - value type.
     */
    <K, V> Set<MapElementDifference<K, V>> getDifferenceForMap(Class<K> keyClass, Class<V> valueClass, MapOperation... operations);

    /**
     * Get difference for {@link java.util.Collection}
     *
     * @param forClass - class of collection values.
     * @param operations - return for {@link CollectionOperation}.
     * @return {@link Set} of {@link CollectionElementDifference} - if differences are there and 'null' - if aren't.
     * @param <T> - value type
     */
    <T> Set<CollectionElementDifference<T>> getDifferenceForCollection(Class<T> forClass, CollectionOperation... operations);

    /**
     * Get difference for attribute.
     *
     * @param fieldPath - attribute path with difference.
     * @return {@link Difference} - if differences are there and 'null' - if aren't.
     */
    Difference getDifference(String fieldPath);

    /**
     * Get difference.
     *
     * @return {@link Difference}
     */
    Difference getDifference();
}

And each class of the check result will give us the default implementation of this interface via the navigator() method.

Using the navigator we can wade through a multitude of attachments and get the “delta” we are interested in. Or null, if none is found.

To “unpack deltas” from collections and maps, you need to use the corresponding methods getDifferenceForMap and getDifferenceForCollection (if you are interested in a specific operation/operations, pass them at the end of the methods).

When navigating, you should take into account that if, say, somewhere in the middle of our “path” there is some collection or map, the navigator will return an error. This should only happen at the end of the path. Therefore, when we, say, need to get a “collection in a collection” – we get the “deltas” of the first collection, then we pull the navigators already at these “deltas”.

How it all looks can be seen from the examples in the tests.

End of conversation.

The decision can be found in the same repositories with validation addon, in the com.ismolka.validation.utils.change package.

The code is not yet fully brought into a divine form and, most likely, there will still be minor refactoring (at least).

I'm interested in your opinion. How necessary is this thing, how good is the solution, comments and recommendations on the code are also welcome.

Thank you all!

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *