ValeraT1982 / ObjectsComparer

C# Framework provides mechanism to compare complex objects, allows to override comparison rules for specific properties and types.
MIT License
352 stars 86 forks source link

Comparison context, difference tree, list and another improvements #47

Open reponemec opened 2 years ago

reponemec commented 2 years ago

Please review my ideas for improving the library. All new features are unit tested and passed as well as all existing tests. New features brings no breaking changes, the original behavior of the library has been preserved by default. Existing "overrides" still take precedence over new features.

Comparison context and the difference tree

With the comparison context, you can configure each part of the comparison process separately. For example, you can compare one list by the index, while another by the key, one by the default key, and other by the key you provide. For one property, you can only compare lists of equal size, while for the other, unequal. Even the same property can be configured differently depending on its current context.
Using new CalculateDifferenceTree extension method, you get the tree of differences instead of a flat list. You can traverse the difference tree and examine differences at particular members.
All that is needed is to implement the IDifferenceTreeBuilder interface in a similar way as it was already implemented into all built-in comparers. Implementation into new or existing third-party comparers is easy and optional.

Calculates a difference tree

[Test]
public void CalculateCompletedDifferenceTree()
{
    var student1 = new Student
    {
        Person = new Person
        {
            FirstName = "Daniel",

            ListOfAddress1 = new List<Address>
            {
                new Address { City = "Prague", Country = "Czech republic" },
                new Address { City = "Pilsen", Country = "Czech republic" }
            }
        }
    };

    var student2 = new Student
    {                
        Person = new Person
        {
            FirstName = "Dan",

            ListOfAddress1 = new List<Address>
            {
                new Address { City = "Olomouc", Country = "Czechia" },
                new Address { City = "Pilsen", Country = "Czech republic" }
            }
        }
    };

    var comparer = new Comparer<Student>();

    var rootNode = comparer.CalculateDifferenceTree(student1, student2);

    Assert.AreEqual(3, rootNode.GetDifferences(recursive: true).Count());

    var stringBuilder = new StringBuilder();
    WriteDifferenceTree(rootNode, 0, stringBuilder);
    var differenceTreeStr = stringBuilder.ToString();

    /*
     * differenceTreeStr:
    ?
      Person
        FirstName
            Difference: DifferenceType=ValueMismatch, MemberPath='Person.FirstName', Value1='Daniel', Value2='Dan'.
        LastName
        Birthdate
        PhoneNumber
        ListOfAddress1
          [0]
            Id
            City
              Difference: DifferenceType=ValueMismatch, MemberPath='Person.ListOfAddress1[0].City', Value1='Prague', Value2='Olomouc'.
            Country
              Difference: DifferenceType=ValueMismatch, MemberPath='Person.ListOfAddress1[0].Country', Value1='Czech republic', Value2='Czechia'.
            State
            PostalCode
            Street
          [1]
            Id
            City
            Country
            State
            PostalCode
            Street
        ListOfAddress2
     */

    using (var sr = new StringReader(differenceTreeStr)) 
    {
        var expectedLine = sr.ReadLine();
        Assert.AreEqual(expectedLine.Trim(), "?");
        expectedLine = sr.ReadLine();
        Assert.AreEqual(expectedLine.Trim(), "Person");
        expectedLine = sr.ReadLine();
        Assert.AreEqual(expectedLine.Trim(), "FirstName");
        expectedLine = sr.ReadLine();
        Assert.AreEqual(expectedLine.Trim(), "Difference: DifferenceType=ValueMismatch, MemberPath='Person.FirstName', Value1='Daniel', Value2='Dan'.");
        expectedLine = sr.ReadLine();
        Assert.AreEqual(expectedLine.Trim(), "LastName");
        expectedLine = sr.ReadLine();
        Assert.AreEqual(expectedLine.Trim(), "Birthdate");
        expectedLine = sr.ReadLine();
        Assert.AreEqual(expectedLine.Trim(), "PhoneNumber");
        expectedLine = sr.ReadLine();
        Assert.AreEqual(expectedLine.Trim(), "ListOfAddress1");
        expectedLine = sr.ReadLine();
        Assert.AreEqual(expectedLine.Trim(), "[0]");
        expectedLine = sr.ReadLine();
        Assert.AreEqual(expectedLine.Trim(), "Id");
        expectedLine = sr.ReadLine();
        Assert.AreEqual(expectedLine.Trim(), "City");
        expectedLine = sr.ReadLine();
        Assert.AreEqual(expectedLine.Trim(), "Difference: DifferenceType=ValueMismatch, MemberPath='Person.ListOfAddress1[0].City', Value1='Prague', Value2='Olomouc'.");
        expectedLine = sr.ReadLine();
        Assert.AreEqual(expectedLine.Trim(), "Country");
        expectedLine = sr.ReadLine();
        Assert.AreEqual(expectedLine.Trim(), "Difference: DifferenceType=ValueMismatch, MemberPath='Person.ListOfAddress1[0].Country', Value1='Czech republic', Value2='Czechia'.");
    }

    rootNode.Shrink();

    stringBuilder = new StringBuilder();
    WriteDifferenceTree(rootNode, 0, stringBuilder);
    differenceTreeStr = stringBuilder.ToString();

    /* differenceTreeStr (shrinked):
     ?
      Person
          FirstName
            Difference: DifferenceType=ValueMismatch, MemberPath='Person.FirstName', Value1='Daniel', Value2='Dan'.
        ListOfAddress1
          [0]
            City
              Difference: DifferenceType=ValueMismatch, MemberPath='Person.ListOfAddress1[0].City', Value1='Prague', Value2='Olomouc'.
            Country
              Difference: DifferenceType=ValueMismatch, MemberPath='Person.ListOfAddress1[0].Country', Value1='Czech republic', Value2='Czechia'.
     */

    using (var sr = new StringReader(differenceTreeStr))
    {
        var expectedLine = sr.ReadLine();
        Assert.AreEqual(expectedLine.Trim(), "?");
        expectedLine = sr.ReadLine();
        Assert.AreEqual(expectedLine.Trim(), "Person");
        expectedLine = sr.ReadLine();
        Assert.AreEqual(expectedLine.Trim(), "FirstName");
        expectedLine = sr.ReadLine();
        Assert.AreEqual(expectedLine.Trim(), "Difference: DifferenceType=ValueMismatch, MemberPath='Person.FirstName', Value1='Daniel', Value2='Dan'.");
        expectedLine = sr.ReadLine();
        Assert.AreEqual(expectedLine.Trim(), "ListOfAddress1");
        expectedLine = sr.ReadLine();
        Assert.AreEqual(expectedLine.Trim(), "[0]");
        expectedLine = sr.ReadLine();
        Assert.AreEqual(expectedLine.Trim(), "City");
        expectedLine = sr.ReadLine();
        Assert.AreEqual(expectedLine.Trim(), "Difference: DifferenceType=ValueMismatch, MemberPath='Person.ListOfAddress1[0].City', Value1='Prague', Value2='Olomouc'.");
        expectedLine = sr.ReadLine();
        Assert.AreEqual(expectedLine.Trim(), "Country");
        expectedLine = sr.ReadLine();
        Assert.AreEqual(expectedLine.Trim(), "Difference: DifferenceType=ValueMismatch, MemberPath='Person.ListOfAddress1[0].Country', Value1='Czech republic', Value2='Czechia'.");
    }
}
[Test]
public void CalculateUncompletedDifferenceTree()
{
    var student1 = new Student
    {
        Person = new Person
        {
            FirstName = "Daniel",

            ListOfAddress1 = new List<Address>
            {
                new Address { City = "Prague", Country = "Czech republic" },
                new Address { City = "Pilsen", Country = "Czech republic" }
            }
        }
    };

    var student2 = new Student
    {
        Person = new Person
        {
            FirstName = "Dan",

            ListOfAddress1 = new List<Address>
            {
                new Address { City = "Olomouc", Country = "Czechia" },
                new Address { City = "Pilsen", Country = "Czech republic" }
            }
        }
    };

    var comparer = new Comparer<Student>();

    var rootNode = comparer.CalculateDifferenceTree(
        student1,
        student2,
        currentContext => currentContext.RootNode.GetDifferences(recursive: true).Any() == false);

    Assert.AreEqual(1, rootNode.GetDifferences(recursive: true).Count());

    rootNode.Shrink();

    var stringBuilder = new StringBuilder();
    WriteDifferenceTree(rootNode, 0, stringBuilder);
    var differenceTreeStr = stringBuilder.ToString();

    /* differenceTreeStr (shrinked):
     ?
      Person
        FirstName
                Difference: DifferenceType=ValueMismatch, MemberPath='Person.FirstName', Value1='Daniel', Value2='Dan'.
     */

    using (var sr = new StringReader(differenceTreeStr))
    {
        var expectedLine = sr.ReadLine();
        Assert.AreEqual(expectedLine.Trim(), "?");
        expectedLine = sr.ReadLine();
        Assert.AreEqual(expectedLine.Trim(), "Person");
        expectedLine = sr.ReadLine();
        Assert.AreEqual(expectedLine.Trim(), "FirstName");
        expectedLine = sr.ReadLine();
        Assert.AreEqual(expectedLine.Trim(), "Difference: DifferenceType=ValueMismatch, MemberPath='Person.FirstName', Value1='Daniel', Value2='Dan'.");
        expectedLine = sr.ReadLine();
        Assert.AreEqual(expectedLine, null);
    }
}

Difference improvements

Translates difference members

string TranslateToCzech(MemberInfo member)
{
    if (member == null)
    {
        return null;
    }

    var descriptionAttr = member.GetCustomAttribute<DescriptionAttribute>();

    if (descriptionAttr != null)
    {
        return descriptionAttr.Description;
    }

    return TranslateToCzech(member.Name);
}

string TranslateToCzech(string original)
{
    var translated = original;

    switch (original)
    {
        case "Person":
            translated = "Osoba";
            break;
        case "FirstName":
            translated = "Křestní jméno";
            break;
        case "LastName":
            translated = "Příjmení";
            break;
        case "Birthdate":
            translated = "Datum narození";
            break;
        case "PhoneNumber":
            translated = "Číslo telefonu";
            break;
        case "ListOfAddress1":
            translated = "Seznam adres 1";
            break;
        case "City":
            translated = "Město";
            break;
        case "Country":
            translated = "Stát";
            break;
        default:
            break;
    }

    return translated;
}

[Test]
public void CalculateDifferencesTranslateMembers()
{
    var student1 = new Student
    {
        Person = new Person
        {
            ListOfAddress1 = new List<Address>
            {
                new Address { City = "Prague", Country = "Czech republic" },
                new Address { City = "Pilsen", Country = "Czech republic" }
            }
        }
    };

    var student2 = new Student
    {
        Person = new Person
        {
            ListOfAddress1 = new List<Address>
            {
                new Address { City = "Olomouc", Country = "Czechia" },
                new Address { City = "Pilsen", Country = "Czech republic" }
            }
        }
    };

    var settings = new ComparisonSettings();
    settings.ConfigureDifferences(defaultMember => TranslateToCzech(defaultMember?.Name));
    var comparer = new Comparer<Student>(settings);
    var differences = comparer.CalculateDifferences(student1, student2).ToArray();

    Assert.AreEqual(2, differences.Count());

    Assert.IsTrue(differences.Any(d => 
        d.DifferenceType == DifferenceTypes.ValueMismatch && 
        d.MemberPath == "Osoba.Seznam adres 1[0].Město" && 
        d.Value1 == "Prague" && d.Value2 == "Olomouc"));

    Assert.IsTrue(differences.Any(d => 
        d.DifferenceType == DifferenceTypes.ValueMismatch && 
        d.MemberPath == "Osoba.Seznam adres 1[0].Stát" && 
        d.Value1 == "Czech republic" && d.Value2 == "Czechia"));
}
[Test]
public void CalculateDifferenceTreeTranslateMembersUsingAttributes()
{
    var student1 = new Student
    {
        Person = new Person
        {
            ListOfAddress1 = new List<Address>
            {
                new Address { City = "Prague", Country = "Czech republic" },
                new Address { City = "Pilsen", Country = "Czech republic" }
            }
        }
    };

    var student2 = new Student
    {
        Person = new Person
        {
            ListOfAddress1 = new List<Address>
            {
                new Address { City = "Olomouc", Country = "Czechia" },
                new Address { City = "Pilsen", Country = "Czech republic" }
            }
        }
    };

    var settings = new ComparisonSettings();
    settings.ConfigureDifferences(defaultMember => TranslateToCzech(defaultMember));
    var comparer = new Comparer<Student>(settings);
    var rootNode = comparer.CalculateDifferenceTree(student1, student2);
    rootNode.Shrink();

    Assert.AreEqual(2, rootNode.GetDifferences(recursive: true).Count());

    var stringBuilder = new StringBuilder();
    WriteDifferenceTree(rootNode, 0, stringBuilder);
    var differenceTreeStr = stringBuilder.ToString();

    /* differenceTreeStr (shrinked):
     ?
      Člověk
        Kolekce adres
          [0]
            Aglomerace (město)
              Difference: DifferenceType=ValueMismatch, MemberPath='Člověk.Kolekce adres[0].Aglomerace (město)', Value1='Prague', Value2='Olomouc'.
            Země (stát)
              Difference: DifferenceType=ValueMismatch, MemberPath='Člověk.Kolekce adres[0].Země (stát)', Value1='Czech republic', Value2='Czechia'.
     */

    using (var sr = new StringReader(differenceTreeStr))
    {
        var expectedLine = sr.ReadLine();
        Assert.AreEqual(expectedLine.Trim(), "?");
        expectedLine = sr.ReadLine();
        Assert.AreEqual(expectedLine.Trim(), "Člověk");
        expectedLine = sr.ReadLine();
        Assert.AreEqual(expectedLine.Trim(), "Kolekce adres");
        expectedLine = sr.ReadLine();
        Assert.AreEqual(expectedLine.Trim(), "[0]");
        expectedLine = sr.ReadLine();
        Assert.AreEqual(expectedLine.Trim(), "Aglomerace (město)");
        expectedLine = sr.ReadLine();
        Assert.AreEqual(expectedLine.Trim(), "Difference: DifferenceType=ValueMismatch, MemberPath='Člověk.Kolekce adres[0].Aglomerace (město)', Value1='Prague', Value2='Olomouc'.");
        expectedLine = sr.ReadLine();
        Assert.AreEqual(expectedLine.Trim(), "Země (stát)");
        expectedLine = sr.ReadLine();
        Assert.AreEqual(expectedLine.Trim(), "Difference: DifferenceType=ValueMismatch, MemberPath='Člověk.Kolekce adres[0].Země (stát)', Value1='Czech republic', Value2='Czechia'.");
    }
}

Includes raw values

[Test]
public void PreserveRawValues()
{
    var a1 = new A() { IntProperty = 10 };
    var a2 = new A() { IntProperty = 11 };

    var settings = new ComparisonSettings();
    settings.ConfigureDifference(includeRawValues: true);

    var comparer = new Comparer<A>(settings);

    var differences = comparer.CalculateDifferences(a1, a2).ToArray();

    Assert.IsTrue((int)differences[0].RawValue1 == 10);
    Assert.IsTrue((int)differences[0].RawValue2 == 11);
}
[Test]
public void PreserveRawValuesConditionally()
{
    var a1 = new A() { IntProperty = 10, TestProperty1 = "TestProperty1value" };
    var a2 = new A() { IntProperty = 11, TestProperty1 = "TestProperty2value" };

    var settings = new ComparisonSettings();

    settings.ConfigureDifference((currentProperty, options) => 
    {
        options.IncludeRawValues(currentProperty.Member.Name == "IntProperty");
    });

    var comparer = new Comparer<A>(settings);

    var differences = comparer.CalculateDifferences(a1, a2).ToArray();

    Assert.IsTrue(differences[0].MemberPath == "IntProperty");
    Assert.IsTrue((int)differences[0].RawValue1 == 10);
    Assert.IsTrue((int)differences[0].RawValue2 == 11);

    Assert.IsTrue(differences[1].MemberPath == "TestProperty1");
    Assert.IsTrue(differences[1].RawValue1 == null);
    Assert.IsTrue(differences[1].RawValue2 == null);
}

List comparison improvements

Compares unequal lists

[Test]
public void CompareIntArrayUnequalListEnabled()
{
    var a1 = new int[] { 3, 2, 1 };
    var a2 = new int[] { 1, 2, 3, 4 };

    var settings = new ComparisonSettings();
    settings.ConfigureListComparison(compareUnequalLists: true);
    var comparer = new Comparer(settings);
    var differences = comparer.CalculateDifferences(a1, a2).ToArray();

    Assert.IsTrue(differences.Count() == 4);
    Assert.IsTrue(differences.Any(d => d.DifferenceType == DifferenceTypes.ValueMismatch && d.MemberPath == "[0]" && d.Value1 == "3" && d.Value2 == "1"));
    Assert.IsTrue(differences.Any(d => d.DifferenceType == DifferenceTypes.ValueMismatch && d.MemberPath == "[2]" && d.Value1 == "1" && d.Value2 == "3"));
    Assert.IsTrue(differences.Any(d => d.DifferenceType == DifferenceTypes.MissedElementInFirstObject && d.MemberPath == "[3]" && d.Value1 == "" && d.Value2 == "4"));
    Assert.IsTrue(differences.Any(d => d.DifferenceType == DifferenceTypes.ValueMismatch && d.MemberPath == "Length" && d.Value1 == "3" && d.Value2 == "4"));
}

Compares lists by key

Note that MemberPath contains key, not an index, by default.

[Test]
public void CompareIntArrayByKey_UnequalListEnabled()
{
    var a1 = new int[] { 3, 2, 1 };
    var a2 = new int[] { 1, 2, 3, 4 };

    var settings = new ComparisonSettings();
    settings.ConfigureListComparison(compareElementsByKey: true, compareUnequalLists: true);
    var comparer = new Comparer(settings);
    var differences = comparer.CalculateDifferences(a1, a2).ToArray();

    Assert.IsTrue(differences.Count() == 2);
    Assert.IsTrue(differences.Any(d => d.DifferenceType == DifferenceTypes.MissedElementInFirstObject && d.MemberPath == "[4]" && d.Value1 == "" && d.Value2 == "4"));
    Assert.IsTrue(differences.Any(d => d.DifferenceType == DifferenceTypes.ValueMismatch && d.MemberPath == "Length" && d.Value1 == "3" && d.Value2 == "4"));
}

Supports null elements

[Test]
[TestCase(true, 0)]
[TestCase(false, 4)]
public void ShortcutConfigureListComparison(bool compareElementsByKey, int expectedDiffsCount)
{
    var a1 = new int?[] { 1, 2, 3, null };
    var a2 = new int?[] { null, 3, 2, 1 };

    var settings = new ComparisonSettings();
    settings.ConfigureListComparison(compareElementsByKey);

    var comparer = new Comparer<int?[]>(settings);

    var differences = comparer.CalculateDifferences(a1, a2).ToList();

    Assert.IsTrue(differences.Count == expectedDiffsCount);
}

Compares lists of objects by key

[Test]
public void CompareObjectListByKey()
{
    var a1 = new A { ListOfB = new List<B> { new B { Id = 1, Property1 = "Value 1" }, new B { Id = 2, Property1 = "Value 2" } } };
    var a2 = new A { ListOfB = new List<B> { new B { Id = 2, Property1 = "Value two" }, new B { Id = 1, Property1 = "Value one" } } };

    var settings = new ComparisonSettings();
    settings.ConfigureListComparison(compareElementsByKey: true);
    var comparer = new Comparer(settings);
    var differences = comparer.CalculateDifferences(a1, a2).ToArray();

    Assert.IsTrue(differences.Count() == 2);
    Assert.IsTrue(differences.Any(d => d.DifferenceType == DifferenceTypes.ValueMismatch && d.MemberPath == "ListOfB[1].Property1" && d.Value1 == "Value 1" && d.Value2 == "Value one"));
    Assert.IsTrue(differences.Any(d => d.DifferenceType == DifferenceTypes.ValueMismatch && d.MemberPath == "ListOfB[2].Property1" && d.Value1 == "Value 2" && d.Value2 == "Value two"));
}

By default, no custom comparer is needed.

[Test]
public void List_Of_Different_Sizes_But_Is_Inequality()
{
    var formula1 = new Formula
    {
        Id = 1,
        Name = "Formula 1",
        Items = new List<FormulaItem>
        {
            new FormulaItem
            {
                Id = 1,
                Delay = 60,
                Name = "Item 1",
                Instruction = "Instruction 1"
            }
        }
    };

    var formula2 = new Formula
    {
        Id = 1,
        Name = "Formula 1",
        Items = new List<FormulaItem>
        {
            new FormulaItem
            {
                Id = 1,
                Delay = 80,
                Name = "Item One",
                Instruction = "Instruction One"
            },
            new FormulaItem
            {
                Id = 2,
                Delay = 30,
                Name = "Item Two",
                Instruction = "Instruction Two"
            }
        }
    };

    var settings = new ComparisonSettings();
    settings.ConfigureListComparison(compareElementsByKey: true, compareUnequalLists: true);

    var comparer = new Comparer<Formula>(settings);

    var isEqual = comparer.Compare(formula1, formula2, out var differences);

    ResultToOutput(isEqual, differences);

    Assert.IsFalse(isEqual);
    Assert.AreEqual(5, differences.Count());
    Assert.IsTrue(differences.Any(d => d.MemberPath == "Items" && d.Value1 == "1" && d.Value2 == "2" && d.DifferenceType == DifferenceTypes.NumberOfElementsMismatch));
    Assert.IsTrue(differences.Any(d => d.MemberPath == "Items[1].Delay" && d.Value1 == "60" && d.Value2 == "80"));
    Assert.IsTrue(differences.Any(d => d.MemberPath == "Items[1].Name" && d.Value1 == "Item 1" && d.Value2 == "Item One"));
    Assert.IsTrue(differences.Any(d => d.MemberPath == "Items[1].Instruction" && d.Value1 == "Instruction 1" && d.Value2 == "Instruction One"));
    Assert.IsTrue(differences.Any(d => d.MemberPath == "Items[2]" && d.DifferenceType ==  DifferenceTypes.MissedElementInFirstObject && d.Value1 == "" && d.Value2 == "ObjectsComparer.Examples.Example4.FormulaItem"));
}

Formats element key

[Test]
public void ClassArrayInequalityProperty_CompareByKey_FormatKey()
{
    var a1 = new A { ArrayOfB = new[] { new B { Property1 = "Str2", Id = 2 }, new B { Property1 = "Str1", Id = 1 } } };
    var a2 = new A { ArrayOfB = new[] { new B { Property1 = "Str1", Id = 1 }, new B { Property1 = "Str3", Id = 2 } } };

    var settings = new ComparisonSettings();

    settings.ConfigureListComparison(listOptions => 
        listOptions
            .CompareElementsByKey(keyOptions => keyOptions.FormatElementKey(args => $"Id={args.ElementKey}")));

    var comparer = new Comparer<A>(settings);

    var differences = comparer.CalculateDifferences(a1, a2).ToList();

    Assert.AreEqual(1, differences.Count);
    Assert.AreEqual("ArrayOfB[Id=2].Property1", differences.First().MemberPath);
    Assert.AreEqual("Str2", differences.First().Value1);
    Assert.AreEqual("Str3", differences.First().Value2);
}

Compares lists by key but displays the index

[Test]
public void CompareIntArrayByKeyDisplayIndex()
{
    var a1 = new int[] { 3, 2, 1 };
    var a2 = new int[] { 1, 2, 3, 4 };

    var settings = new ComparisonSettings();

    settings.ConfigureListComparison(listOptions =>
    {
        listOptions
            .CompareUnequalLists(true)
            .CompareElementsByKey(keyOptions => 
            {
                keyOptions.FormatElementKey(args => args.ElementIndex.ToString());
            });
    });

    var comparer = new Comparer(settings);
    var differences = comparer.CalculateDifferences(a1, a2).ToArray();

    Assert.IsTrue(differences.Count() == 2);
    Assert.IsTrue(differences.Any(d => d.DifferenceType == DifferenceTypes.MissedElementInFirstObject && d.MemberPath == "[3]" && d.Value1 == "" && d.Value2 == "4"));
    Assert.IsTrue(differences.Any(d => d.DifferenceType == DifferenceTypes.ValueMismatch && d.MemberPath == "Length" && d.Value1 == "3" && d.Value2 == "4"));
}

Formats null element identifier

[Test]
public void FluentTest_CompareUnequalLists_CompareElementsByKey_FormatKey_FormatNullElementIdentifier()
{
    var a1 = new int?[] { 3, 2, 1 };
    var a2 = new int?[] { 1, 2, 3, 4, null };

    var settings = new ComparisonSettings();

    settings.ConfigureListComparison(listOptions => listOptions
        .CompareUnequalLists(true)
        .CompareElementsByKey(keyOptions => keyOptions
            .FormatElementKey(formatArgs => $"Key={formatArgs.ElementKey}")
            .FormatNullElementIdentifier(formatArgs => $"Null at {formatArgs.ElementIndex}")));

    var comparer = new Comparer(settings);

    var differences = comparer.CalculateDifferences(a1, a2).ToList();

    Assert.AreEqual(3, differences.Count);

    Assert.AreEqual(DifferenceTypes.MissedElementInFirstObject, differences[0].DifferenceType);
    Assert.AreEqual("[Key=4]", differences[0].MemberPath);
    Assert.AreEqual("", differences[0].Value1);
    Assert.AreEqual("4", differences[0].Value2);

    Assert.AreEqual(DifferenceTypes.MissedElementInFirstObject, differences[1].DifferenceType);
    Assert.AreEqual("[Null at 4]", differences[1].MemberPath);
    Assert.AreEqual("", differences[1].Value1);
    Assert.AreEqual("", differences[1].Value2);

    Assert.AreEqual(DifferenceTypes.ValueMismatch, differences[2].DifferenceType);
    Assert.AreEqual("Length", differences[2].MemberPath);
    Assert.AreEqual("3", differences[2].Value1);
    Assert.AreEqual("5", differences[2].Value2);
}

Compares first list by the key but second list by the index

[Test]
public void CompareIntArrayFirstByIndexSecondByKey()
{
    var a1 = new A { IntArray = new int[] { 3, 2, 1 }, IntArray2 = new int[] { 3, 2, 1 } };
    var a2 = new A { IntArray = new int[] { 1, 2, 3, 4 }, IntArray2 = new int[] { 1, 2, 3, 4 } };

    var settings = new ComparisonSettings();

    settings.ConfigureListComparison((currentProperty, listOptions) =>
    {
        listOptions.CompareUnequalLists(true);

        if (currentProperty.Member.Name == nameof(A.IntArray2))
        {
            listOptions.CompareElementsByKey();
        }
    });

    var comparer = new Comparer(settings);
    var differences = comparer.CalculateDifferences(a1, a2).ToArray();

    Assert.IsTrue(differences.Count() == 6);
    Assert.IsTrue(differences.Any(d => d.DifferenceType == DifferenceTypes.ValueMismatch && d.MemberPath == "IntArray[0]" && d.Value1 == "3" && d.Value2 == "1"));
    Assert.IsTrue(differences.Any(d => d.DifferenceType == DifferenceTypes.ValueMismatch && d.MemberPath == "IntArray[2]" && d.Value1 == "1" && d.Value2 == "3"));
    Assert.IsTrue(differences.Any(d => d.DifferenceType == DifferenceTypes.MissedElementInFirstObject && d.MemberPath == "IntArray[3]" && d.Value1 == "" && d.Value2 == "4"));
    Assert.IsTrue(differences.Any(d => d.DifferenceType == DifferenceTypes.ValueMismatch && d.MemberPath == "IntArray.Length" && d.Value1 == "3" && d.Value2 == "4"));
    Assert.IsTrue(differences.Any(d => d.DifferenceType == DifferenceTypes.MissedElementInFirstObject && d.MemberPath == "IntArray2[4]" && d.Value1 == "" && d.Value2 == "4"));
    Assert.IsTrue(differences.Any(d => d.DifferenceType == DifferenceTypes.ValueMismatch && d.MemberPath == "IntArray2.Length" && d.Value1 == "3" && d.Value2 == "4"));
}

Compares first list by the default key but second list by the custom key

[Test]
public void CompareObjectListFirstByDefaultKeySecondByCustomKey()
{
    var a1 = new A 
    {
        ListOfB = new List<B> { new B { Id = 1, Property1 = "Value 1" }, new B { Id = 2, Property1 = "Value 2" } },
        ListOfC = new List<C> { new C { Key = "Key1", Property1 = "Value 3" }, new C { Key = "Key2", Property1 = "Value 4" } }
    };

    var a2 = new A 
    {
        ListOfB = new List<B> { new B { Id = 2, Property1 = "Value two" }, new B { Id = 1, Property1 = "Value one" } } ,
        ListOfC = new List<C> { new C { Key = "Key2", Property1 = "Value four" }, new C { Key = "Key1", Property1 = "Value three" } }
    };

    var settings = new ComparisonSettings();

    settings.ConfigureListComparison((currentProperty, listOptions) =>
    {
        listOptions.CompareElementsByKey();

        if (currentProperty.Member.Name == nameof(A.ListOfC))
        {
            listOptions.CompareElementsByKey(keyOptions => keyOptions.UseKey(args => ((C)args.Element).Key));
        }
    });

    var comparer = new Comparer(settings);
    var differences = comparer.CalculateDifferences(a1, a2).ToArray();

    Assert.IsTrue(differences.Count() == 4);
    Assert.IsTrue(differences.Any(d => d.DifferenceType == DifferenceTypes.ValueMismatch && d.MemberPath == "ListOfB[1].Property1" && d.Value1 == "Value 1" && d.Value2 == "Value one"));
    Assert.IsTrue(differences.Any(d => d.DifferenceType == DifferenceTypes.ValueMismatch && d.MemberPath == "ListOfB[2].Property1" && d.Value1 == "Value 2" && d.Value2 == "Value two"));
    Assert.IsTrue(differences.Any(d => d.DifferenceType == DifferenceTypes.ValueMismatch && d.MemberPath == "ListOfC[Key1].Property1" && d.Value1 == "Value 3" && d.Value2 == "Value three"));
    Assert.IsTrue(differences.Any(d => d.DifferenceType == DifferenceTypes.ValueMismatch && d.MemberPath == "ListOfC[Key2].Property1" && d.Value1 == "Value 4" && d.Value2 == "Value four"));
}

Compares first list by the default key but second list by the custom key, formats custom key

[Test]
public void CompareObjectListFirstByDefaultKeySecondByCustomKeyFormatCustomKey()
{
    var a1 = new A
    {
        ListOfB = new List<B> { new B { Id = 1, Property1 = "Value 1" }, new B { Id = 2, Property1 = "Value 2" } },
        ListOfC = new List<C> { new C { Key = "Key1", Property1 = "Value 3" }, new C { Key = "Key2", Property1 = "Value 4" } }
    };

    var a2 = new A
    {
        ListOfB = new List<B> { new B { Id = 2, Property1 = "Value two" }, new B { Id = 1, Property1 = "Value one" } },
        ListOfC = new List<C> { new C { Key = "Key2", Property1 = "Value four" }, new C { Key = "Key1", Property1 = "Value three" } }
    };

    var settings = new ComparisonSettings();

    settings.ConfigureListComparison((currentProperty, listOptions) =>
    {
        listOptions.CompareElementsByKey();

        if (currentProperty.Member.Name == nameof(A.ListOfC))
        {
            listOptions.CompareElementsByKey(keyOptions =>
                keyOptions
                    .UseKey(args => new { ((C)args.Element).Key })
                    .FormatElementKey(args => $"Key={((C)args.Element).Key}"));
        }
    });

    var comparer = new Comparer(settings);
    var differences = comparer.CalculateDifferences(a1, a2).ToArray();

    Assert.IsTrue(differences.Count() == 4);
    Assert.IsTrue(differences.Any(d => d.DifferenceType == DifferenceTypes.ValueMismatch && d.MemberPath == "ListOfB[1].Property1" && d.Value1 == "Value 1" && d.Value2 == "Value one"));
    Assert.IsTrue(differences.Any(d => d.DifferenceType == DifferenceTypes.ValueMismatch && d.MemberPath == "ListOfB[2].Property1" && d.Value1 == "Value 2" && d.Value2 == "Value two"));
    Assert.IsTrue(differences.Any(d => d.DifferenceType == DifferenceTypes.ValueMismatch && d.MemberPath == "ListOfC[Key=Key1].Property1" && d.Value1 == "Value 3" && d.Value2 == "Value three"));
    Assert.IsTrue(differences.Any(d => d.DifferenceType == DifferenceTypes.ValueMismatch && d.MemberPath == "ListOfC[Key=Key2].Property1" && d.Value1 == "Value 4" && d.Value2 == "Value four"));
}

Compares lists by complex key


[Test]
public void CompareObjectListsByComplexKey()
{
    var a1 = new A
    {
        ListOfC = new List<C> 
        {
            new C { Property1 = "Key1a", Property2 ="Key1b", Property3 = "Value 1" }, 
            new C { Property1 = "Key2a", Property2 = "Key2b", Property3 = "Value 2" } 
        }
    };

    var a2 = new A
    {
        ListOfC = new List<C>
        {                    
            new C { Property1 = "Key2a", Property2 = "Key2b", Property3 = "Value two" },
            new C { Property1 = "Key1a", Property2 ="Key1b", Property3 = "Value 1" },
        }
    };

    var settings = new ComparisonSettings();

    settings.ConfigureListComparison(listOptions =>
    {
        listOptions.CompareElementsByKey(keyOptions => keyOptions.UseKey(args => new 
        { 
            ((C)args.Element).Property1, 
            ((C)args.Element).Property2 
        }));
    });

    var comparer = new Comparer(settings);
    var differences = comparer.CalculateDifferences(a1, a2).ToArray();

    Assert.IsTrue(differences.Count() == 1);

    Assert.IsTrue(differences.Any(d => 
        d.DifferenceType == DifferenceTypes.ValueMismatch 
        && d.MemberPath == "ListOfC[{ Property1 = Key2a, Property2 = Key2b }].Property3" 
        && d.Value1 == "Value 2" 
        && d.Value2 == "Value two"));
}
ValeraT1982 commented 2 years ago

Hi. Thanks a lot for so many improvements!!! I definitely like some of them. It's difficult to review so many changes in one pull request.

  1. I definitely like the idea of comparison tree. Can you make it another pull request please? Can method just return string with no need to create string builder?
  2. Can you please move all changes related to list comparison to another pull request? Or probably after moving the smaller changes into another pull request this request will be the pull request for list comparison related changes.
  3. RowValue - love it. Please move to another pull request.
  4. Translation - some concerns about naming. Please make it another pull request.
reponemec commented 2 years ago

I know it's generally not a good idea to have multiple responsibilities in one PR. On the other hand, the main strength of the new features lies in the use of the comparison context and the difference tree. I needed to test these things against each other, so I'm sending the PR as is. But don't be afraid of many commits. Many of these are only typo commits, code comments commits and just technical commits, as I've often bounced between multiple machines with their own repositories over time. I believe that the code is sufficiently documented and can serve as an inspiration for merging it.

  1. It's all about implementing the IDifferenceTreeBuilder interface. This interface is now implemented by all built-in comparers (and can be implemented by third party programmers, of course). Comparers still do what they have been doing up until now, except that they pass recursively an instance of the IDifferenceTreeNode interface. All types related to this feature are in the DifferenceTree folder. About conversion to string: Have a look at ToJson extension method (https://github.com/reponemec/ObjectsComparer/blob/35cd3fc60898ff44639146efe4efb9a108a5a5f7/ObjectsComparer/ObjectsComparer.Tests/Utils/DifferenceTreeNodeSerializationExtensions.cs#L21), so far only as part of the test project. I can imagine a similar extension (override) of ToString(), both builded into the core.
  2. EnumerablesComparerBase is the only class that implements the function of comparing lists, that is, key and unequal comparison of lists.
  3. Raw values in the Difference class represent just two new properties and the interface that provides access to them.
  4. All member paths as well as the entire difference tree are translatable without need of parsing generated string. Square brackets in the Member path are preserved by default.

Let me know if you need further assistance in this.

ValeraT1982 commented 2 years ago

I'm about to go on vacation so I'll have a closer look when I'll be back. Having features separated gives a flexibility to revert specific feature if needed and understand how feature was implemented looking at the change with only one specific feature. Would you mind if I manually create separate commits based on this pull request?

jerome-huboo commented 6 months ago

@reponemec This looks like a really useful PR that you put a lot of work into. Disappointing that nothing has happened with it :/

ValeraT1982 commented 6 months ago

I'd be more then happy to merge some parts of this PR and release new version if PR will be separated into parts.

jerome-huboo commented 6 months ago

Oh yes, I see, that's a lot of commits and not easy to untangle :)