SteveDunn / Vogen

A semi-opinionated library which is a source generator and a code analyser. It Source generates Value Objects
Apache License 2.0
888 stars 46 forks source link

Specify custom `IEqualityComparer<T>` #442

Closed erri120 closed 1 year ago

erri120 commented 1 year ago

Describe the feature

When using ValueObject<string>, the various equality and compare methods are using the default equality comparer. This is fine for most types, however, strings are different since you can compare a string using Ordinal and OrdinalIgnoreCase, which drastically changes equality.

Since you can't override the generated methods, the only solution at the moment is using a custom implementation of IEqualityComparer<TValueObject> where TValueObject has the ValueObject<string> attribute:

[ValueObject<string>]
public readonly partial struct MyValueObject { }

public class MyValueObjectComparer : IEqualityComparer<MyValueObject>
{
  //...
}

Ideally, there should be an option that allows you to specify the comparer:

[ValueObject<string>(comparer: typeof(StringOrdinalIgnoreCaseComparer)]
public readonly partial struct MyValueObject { }

public class StringOrdinalIgnoreCaseComparer : StringComparer
{
  private static readonly StringComparer _comparer = StringComparer.OrdinalIgnoreCase;
  //...
}

You'd still need a custom implementation of IEqualityComparer, since StringComparer.OrdinalIgnoreCase is a static get-only property, so it can't really be used in the attribute.

SteveDunn commented 1 year ago

I've gone with adding a new parameter, e.g.: [ValueObject(stringComparison: StringComparison.OrdinalIgnoreCase)] This makes sense when applied to a class, but I'm wondering what to do if the attribute is applied to a record as that implements its own equality etc.

Any suggestions welcomed.

martinothamar commented 1 year ago

I wanted this for IComparable<T> as well. What if Vogen just doesn't generate the related methods when it's already defined in the original class/struct, does that work?

In terms of records and equality, it's still possible to override those: https://sharplab.io/#v2:D4AQTAjAsAUCDMACATgUwMYHtkBNEEEAKASwDsAXRANQEpEBvWRZxBRAN2OXIFcBDADaIARpkxCAogEd+AgM6F8AfkSZyAC1TI6AXgB8qjVqUA6Koh07qAbiYs7zNpnZbkxHKkRlKAcVTkACT45dQBhTA9CXQMqEz9A4LCI1CjbGABfWCy4JDlUQVQ8NCxcRAAhEgpqOkYYFlYkUXFEaVkFMpU1TW0LAy7jMwsrKjT6hwbVF2Q3Dy8q+KCQ8Mjo6jj/RaSVtPSgA

SteveDunn commented 1 year ago

Whilst implementing this, I went down the rabbit hole of modifying the generated code for Equals and GetHashCode to automatically use the EqualityComparer based on the provided value (e.g. OrdinalIgnoreCase).

But with hindsight, I now think Value Objects that wrap strings should just expose the equality comparer it generates and also generate overrides for Equals and GetHashCode with a StringComparison argument.

SteveDunn commented 1 year ago

This is now implemented. You can now specify a new parameter in local or global config specifying whether to generate string comparers, e.g.

[ValueObject<string>(stringComparers: StringComparersGeneration.Generate)]
public partial class MyVo
{
}

It's an enum with options Omit and Generate. It defaults to Omit.

If it's set to Generate, then it generates a bunch of comparers which can use in Equals or collections, e.g.

        var left = MyVo.From("abc");
        var right = MyVo.From("AbC");

        var comparer = MyVo.Comparers.OrdinalIgnoreCase;

        left.Equals(right, comparer).Should().BeTrue();

... and in a dictionary

        Dictionary<MyVo, int> d = new(MyVo.Comparers.OrdinalIgnoreCase);

        MyVo key1Lower = MyVo.From("abc");
        MyVo key2Mixed = MyVo.From("AbC");

        d.Add(key1Lower, 1);
        d.Should().ContainKey(key2Mixed);

Also generated is an Equals method that takes an IEqualityComparer<>:

public bool Equals(MyVo other, IEqualityComparer<MyVo> comparer)
{
    return comparer.Equals(this, other);
}

Please let me know if this works OK for you!