Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
72 commits
Select commit Hold shift + click to select a range
c60d71b
feat: IReadOnlyDictionary support, allocation-free hash, and adapter …
May 11, 2026
68d93d7
feat: multi-dim array comparer, adapter base class support, and neste…
May 11, 2026
4c8c29c
refactor: split adapter generators into separate packages
May 11, 2026
ebc5bcb
feat: add EQ0020/EQ0021 analyzers for missing DataContract/MessagePac…
May 11, 2026
6a20cfe
refactor: extract IsPublicInstanceProperty helper to eliminate adapte…
May 11, 2026
4eeb879
fix: use EqualityComparer<T>.Default for value types without == operator
May 12, 2026
cd892ca
feat: add ordered: bool parameter to [DictionaryEquality]
May 12, 2026
1a29496
fix: sync dictionary/hashset Equals and GetHashCode, add sequential m…
May 12, 2026
390b187
docs: add comments explaining equality contract fix in dictionary and…
May 12, 2026
287d732
fix: use sentinel value 1 for empty collection hash codes to distingu…
May 13, 2026
1418950
fix: use sentinel value 1 for empty collection hash codes in equality…
May 13, 2026
14555a8
test: rename property tests to encode invariants and fix bidirectiona…
May 13, 2026
1bf9f1c
feat: add InferCollectionComparer and IsEquatableGeneratorAttribute t…
May 13, 2026
cf696eb
test: add nested collection entity fixtures for DataContract and Mess…
May 13, 2026
190aaa2
test: expand comparer unit tests — GetHashCode contract, nested colle…
May 13, 2026
e928996
test: split adapter analyzer tests and expand ISet/array analyzer cov…
May 13, 2026
b49792e
test: add adapter generator snapshot tests and expand generator/write…
May 13, 2026
929f0c5
test: add integration tests for nested collection equality on generat…
May 13, 2026
6379a64
Mirror MessagePack generator tests to match DataContract entity shapes
May 13, 2026
eacbf15
fix: propagate sequential dict kind into nested dictionary comparers
May 13, 2026
5db8f93
feat: support [HashSetEquality] on List<T>/T[] and [SequenceEquality]…
May 13, 2026
b4be828
feat: propagate explicit enumKind/dictKind annotations into all neste…
May 13, 2026
65e9d2c
Add symmetry property tests for nested collection equality
May 13, 2026
fb782e1
Update README with new abilities: adapters, comparer propagation, dir…
May 13, 2026
355e4d9
README: document collection defaults and remove redundant attribute e…
May 13, 2026
41dbe47
README: replace domain-specific names with generic examples
May 13, 2026
90b2841
README: add nested collections section with propagation rules and exp…
May 13, 2026
7a4da01
README: document multi-dimensional array support
May 13, 2026
04baf85
README: document supported types for each collection attribute
May 13, 2026
c7b822f
README: clarify PrivateAssets is optional, add Equatable.Comparers to…
May 13, 2026
3541e77
README: explain how explicit overrides propagate into nested collections
May 13, 2026
4124d06
README: split propagation rules into one table per outer annotation
May 13, 2026
bf3b080
Merge branch 'main' into feature/readonly-collections-and-adapters
kasyanovandrii May 13, 2026
40d852f
ci: point GitHub Packages publish to fork owner
May 13, 2026
5bd1254
ci: use github.repository_owner for GitHub Packages feed URL
May 13, 2026
b53d24f
ci: revert GitHub Packages feed URL to original
May 13, 2026
5465ef9
build: add adapter projects to solution so they are packed by dotnet …
May 14, 2026
d750ba7
README: replace propagation tables with concrete code examples per at…
May 14, 2026
d6b21b7
README: add inline comments and rank/transpose examples for multi-dim…
May 14, 2026
e5b283b
README: multi-dimensional arrays default like T[] — no attribute needed
May 14, 2026
ee11f93
README: document multi-dimensional array override rules and contrast …
May 14, 2026
522e4ce
fix: correct README — [EqualityComparer] on T[,] bypasses multi-dim c…
May 14, 2026
95cf6cd
test: prove [EqualityComparer] on T[,] bypasses MultiDimensionalArray…
May 14, 2026
0152c83
feat: EQ0014 — warn when any attribute is applied to a multi-dimensio…
May 14, 2026
ec7a207
feat: EQ0015 — warn when [SequenceEquality] or [HashSetEquality] is u…
May 14, 2026
1ba29ec
docs: clarify row-major order in multi-dimensional array equality exa…
May 14, 2026
7cb634b
docs: emphasize row-major order in multi-dimensional array equality b…
May 14, 2026
0467be9
docs: document EQ0020 and EQ0021 in build-time diagnostics section
May 14, 2026
2a3ec83
docs: note that PrivateAssets="all" applies to adapter generator pack…
May 14, 2026
097b8be
feat: EQ0022/EQ0023 — warn on unannotated properties on adapter-equat…
May 14, 2026
293663f
docs: expand adapter generators section — property selection, compare…
May 14, 2026
b1adb80
docs: split attributes table by package — Equatable.Generator, DataCo…
May 14, 2026
944de05
docs: mention skip/ignore attributes in packages table summary
May 14, 2026
33b6ab8
docs: clarify adapter packages table — include/exclude/silently-skip …
May 14, 2026
593c040
docs: add What's new section covering all new packages, features, fix…
May 14, 2026
c03cf42
docs: expand What it does — explain manual IEquatable pain points and…
May 14, 2026
89efa8c
docs: show Order param in DataMember in packages table for consistenc…
May 14, 2026
840c052
docs: add hash contract drift as a manual IEquatable pitfall
May 14, 2026
470a016
docs: ground pitfall list in practical observation — not theoretical
May 14, 2026
40c7dd9
docs: explain record equality limitations — reference types and colle…
May 14, 2026
bab3dc1
docs: add edge case note — string works in records because it impleme…
May 14, 2026
dd17427
docs: reword string edge case note — cleaner phrasing for public libr…
May 14, 2026
3471cc5
docs: professional rewrite of all prose sections throughout README
May 14, 2026
9e18db9
feat: honour [IgnoreEquality] on [DataMember]/[Key(n)] properties in …
May 14, 2026
b8f78f5
fix: correct OrderedDictionaryHashCode inline helper to sort before h…
May 14, 2026
8eb00d6
refactor: remove dead OrderedDictionary inline helpers from Equatable…
May 14, 2026
59fe9ae
docs: clarify DictionaryEquality vs sequential — identical semantics,…
May 14, 2026
4ab42a0
docs+test: demonstrate custom IEqualityComparer+IComparer with sequen…
May 14, 2026
cee3fc0
docs: generalise custom-comparer note — applies to any domain rule, n…
May 14, 2026
3c801a7
fix: DictionaryEqualityComparer.Equals must use KeyComparer, not dict…
May 14, 2026
a14351c
docs: update sequential note — both comparers now handle custom keyCo…
May 14, 2026
d0de340
refactor: remove [DictionaryEquality(sequential: true)] — never shipp…
May 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@
<PackageVersion Include="Microsoft.Bcl.HashCode" Version="6.0.0" />
<PackageVersion Include="MinVer" Version="7.0.0" />
<PackageVersion Include="System.Text.Json" Version="10.0.8" />
<PackageVersion Include="FsCheck" Version="3.3.3" />
<PackageVersion Include="FsCheck.Xunit.v3" Version="3.3.3" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageVersion Include="MessagePack.Annotations" Version="2.5.108" />

<PackageVersion Include="Verify.XunitV3" Version="31.16.3" />
<PackageVersion Include="xunit.v3.mtp-v2" Version="3.2.2" />
</ItemGroup>
Expand Down
4 changes: 4 additions & 0 deletions Equatable.Generator.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,9 @@
</Folder>
<Project Path="src/Equatable.Comparers/Equatable.Comparers.csproj" />
<Project Path="src/Equatable.Generator/Equatable.Generator.csproj" />
<Project Path="src/Equatable.Generator.DataContract/Equatable.Generator.DataContract.csproj" />
<Project Path="src/Equatable.Generator.MessagePack/Equatable.Generator.MessagePack.csproj" />
<Project Path="src/Equatable.SourceGenerator/Equatable.SourceGenerator.csproj" />
<Project Path="src/Equatable.SourceGenerator.DataContract/Equatable.SourceGenerator.DataContract.csproj" />
<Project Path="src/Equatable.SourceGenerator.MessagePack/Equatable.SourceGenerator.MessagePack.csproj" />
</Solution>
631 changes: 573 additions & 58 deletions README.md

Large diffs are not rendered by default.

29 changes: 20 additions & 9 deletions src/Equatable.Comparers/DictionaryEqualityComparer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,14 @@ public bool Equals(IDictionary<TKey, TValue>? x, IDictionary<TKey, TValue>? y)
if (x.Count != y.Count)
return false;

// y.TryGetValue uses y's own internal comparer, not this.KeyComparer.
// Build a lookup keyed by KeyComparer so the same comparer governs both
// Equals and GetHashCode — required for the hash contract to hold.
var yLookup = new Dictionary<TKey, TValue>(y, KeyComparer);

foreach (var pair in x)
{
if (!y.TryGetValue(pair.Key, out var value))
if (!yLookup.TryGetValue(pair.Key, out var value))
return false;

if (!ValueComparer.Equals(pair.Value, value))
Expand All @@ -71,15 +76,21 @@ public int GetHashCode(IDictionary<TKey, TValue> obj)
if (obj == null)
return 0;

var hash = new HashCode();
// Start at 1, not 0: an empty dictionary must not hash the same as null.
// Equals correctly returns false for (null, empty), so GetHashCode must also
// differ — otherwise a hash table would bucket them together and force an Equals
// call that returns false, producing unnecessary collisions. null returns 0 above;
// 1 here ensures an empty collection is always distinguishable.
int hashCode = 1;

// sort by key to ensure dictionary with different order are the same
foreach (var pair in obj.OrderBy(d => d.Key))
{
hash.Add(pair.Key, KeyComparer);
hash.Add(pair.Value, ValueComparer);
}
// Commutative SUM ensures hash is insertion-order independent, consistent with
// Equals which uses TryGetValue (also order-independent). Previously GetHashCode
// used OrderBy + sequential HashCode.Add, which was order-dependent and violated
// the contract: two equal dictionaries (same keys/values, different insertion order)
// would produce different hash codes.
foreach (var pair in obj)
hashCode += HashCode.Combine(KeyComparer.GetHashCode(pair.Key!), ValueComparer.GetHashCode(pair.Value!));

return hash.ToHashCode();
return hashCode;
}
}
19 changes: 14 additions & 5 deletions src/Equatable.Comparers/HashSetEqualityComparer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,21 @@ public int GetHashCode(IEnumerable<TValue> obj)
if (obj == null)
return 0;

var hashCode = new HashCode();
// Start at 1, not 0: an empty set must not hash the same as null.
// Equals correctly returns false for (null, empty), so GetHashCode must also
// differ — otherwise a hash table would bucket them together and force an Equals
// call that returns false, producing unnecessary collisions. null returns 0 above;
// 1 here ensures an empty collection is always distinguishable.
int hashCode = 1;

// sort to ensure set with different order are the same
foreach (var item in obj.OrderBy(s => s))
hashCode.Add(item, Comparer);
// Commutative SUM ensures hash is iteration-order independent, consistent with
// Equals which uses SetEquals (also order-independent). Previously GetHashCode
// used OrderBy + sequential HashCode.Add, which was order-dependent and violated
// the contract: two equal sets (same elements, different insertion order) could
// produce different hash codes.
foreach (var item in obj)
hashCode += Comparer.GetHashCode(item!);

return hashCode.ToHashCode();
return hashCode;
}
}
78 changes: 78 additions & 0 deletions src/Equatable.Comparers/MultiDimensionalArrayEqualityComparer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
namespace Equatable.Comparers;

/// <summary>
/// Structural equality comparer for multi-dimensional arrays (T[,], T[,,], etc.).
/// Compares element-by-element in row-major order without LINQ or intermediate allocations.
/// </summary>
/// <typeparam name="TValue">The element type of the array.</typeparam>
public class MultiDimensionalArrayEqualityComparer<TValue> : IEqualityComparer<Array?>
{
/// <summary>
/// Gets the default equality comparer for the specified element type.
/// </summary>
public static MultiDimensionalArrayEqualityComparer<TValue> Default { get; } = new();

/// <summary>
/// Initializes a new instance using the default element comparer.
/// </summary>
public MultiDimensionalArrayEqualityComparer() : this(EqualityComparer<TValue>.Default)
{
}

/// <summary>
/// Initializes a new instance using the specified element comparer.
/// </summary>
public MultiDimensionalArrayEqualityComparer(IEqualityComparer<TValue> comparer)
{
Comparer = comparer ?? throw new ArgumentNullException(nameof(comparer));
}

/// <summary>
/// Gets the element comparer.
/// </summary>
public IEqualityComparer<TValue> Comparer { get; }

/// <inheritdoc />
public bool Equals(Array? x, Array? y)
{
if (ReferenceEquals(x, y))
return true;

if (x is null || y is null)
return false;

if (x.Rank != y.Rank)
return false;

for (int dim = 0; dim < x.Rank; dim++)
{
if (x.GetLength(dim) != y.GetLength(dim))
return false;
}

var ex = x.GetEnumerator();
var ey = y.GetEnumerator();
while (ex.MoveNext())
{
ey.MoveNext();
if (!Comparer.Equals((TValue)ex.Current!, (TValue)ey.Current!))
return false;
}

return true;
}

/// <inheritdoc />
public int GetHashCode(Array? obj)
{
if (obj is null)
return 0;

var hashCode = new HashCode();
var e = obj.GetEnumerator();
while (e.MoveNext())
hashCode.Add((TValue)e.Current!, Comparer);

return hashCode.ToHashCode();
}
}
91 changes: 91 additions & 0 deletions src/Equatable.Comparers/ReadOnlyDictionaryEqualityComparer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
namespace Equatable.Comparers;

/// <summary>
/// <see cref="IReadOnlyDictionary{TKey, TValue}"/> equality comparer instance
/// </summary>
/// <typeparam name="TKey">The type of the keys in the dictionary.</typeparam>
/// <typeparam name="TValue">The type of the values in the dictionary.</typeparam>
public class ReadOnlyDictionaryEqualityComparer<TKey, TValue> : IEqualityComparer<IReadOnlyDictionary<TKey, TValue>>
{
/// <summary>
/// Gets the default equality comparer for specified generic argument.
/// </summary>
public static ReadOnlyDictionaryEqualityComparer<TKey, TValue> Default { get; } = new();

/// <summary>
/// Initializes a new instance of the <see cref="ReadOnlyDictionaryEqualityComparer{TKey, TValue}" /> class.
/// </summary>
public ReadOnlyDictionaryEqualityComparer() : this(EqualityComparer<TKey>.Default, EqualityComparer<TValue>.Default)
{
}

/// <summary>
/// Initializes a new instance of the <see cref="ReadOnlyDictionaryEqualityComparer{TKey, TValue}" /> class.
/// </summary>
/// <param name="keyComparer">The <see cref="IEqualityComparer{TKey}"/> that is used to determine equality of keys in a dictionary</param>
/// <param name="valueComparer">The <see cref="IEqualityComparer{TValue}"/> that is used to determine equality of values in a dictionary</param>
/// <exception cref="ArgumentNullException"><paramref name="keyComparer"/> or <paramref name="valueComparer"/> is null</exception>
public ReadOnlyDictionaryEqualityComparer(IEqualityComparer<TKey> keyComparer, IEqualityComparer<TValue> valueComparer)
{
KeyComparer = keyComparer ?? throw new ArgumentNullException(nameof(keyComparer));
ValueComparer = valueComparer ?? throw new ArgumentNullException(nameof(valueComparer));
}

/// <summary>
/// Gets the <see cref="IEqualityComparer{TKey}"/> that is used to determine equality of keys in a dictionary
/// </summary>
public IEqualityComparer<TKey> KeyComparer { get; }

/// <summary>
/// Gets the <see cref="IEqualityComparer{TValue}"/> that is used to determine equality of values in a dictionary
/// </summary>
public IEqualityComparer<TValue> ValueComparer { get; }

/// <inheritdoc />
public bool Equals(IReadOnlyDictionary<TKey, TValue>? x, IReadOnlyDictionary<TKey, TValue>? y)
{
if (ReferenceEquals(x, y))
return true;

if (x is null || y is null)
return false;

if (x.Count != y.Count)
return false;

// y.TryGetValue uses y's own internal comparer, not this.KeyComparer.
// Build a lookup keyed by KeyComparer so the same comparer governs both
// Equals and GetHashCode — required for the hash contract to hold.
var yLookup = new Dictionary<TKey, TValue>(y, KeyComparer);

foreach (var pair in x)
{
if (!yLookup.TryGetValue(pair.Key, out var value))
return false;

if (!ValueComparer.Equals(pair.Value, value))
return false;
}

return true;
}

/// <inheritdoc />
public int GetHashCode(IReadOnlyDictionary<TKey, TValue> obj)
{
if (obj == null)
return 0;

// Start at 1, not 0: an empty dictionary must not hash the same as null.
// Equals correctly returns false for (null, empty), so GetHashCode must also
// differ — otherwise a hash table would bucket them together and force an Equals
// call that returns false, producing unnecessary collisions. null returns 0 above;
// 1 here ensures an empty collection is always distinguishable.
int hashCode = 1;

foreach (var pair in obj)
hashCode += HashCode.Combine(KeyComparer.GetHashCode(pair.Key!), ValueComparer.GetHashCode(pair.Value!));

return hashCode;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
using System.Diagnostics;

namespace Equatable.Attributes.DataContract;

/// <summary>
/// Marks the class to source generate overrides for Equals and GetHashCode,
/// including only properties decorated with <see cref="System.Runtime.Serialization.DataMemberAttribute"/>
/// and excluding properties decorated with <see cref="System.Runtime.Serialization.IgnoreDataMemberAttribute"/>.
/// </summary>
[Conditional("EQUATABLE_GENERATOR")]
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)]
public class DataContractEquatableAttribute : Attribute;
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFrameworks>netstandard2.0;net8.0;net9.0;net10.0</TargetFrameworks>
<RootNamespace>Equatable</RootNamespace>
<Description>Source generator for Equals and GetHashCode for types using System.Runtime.Serialization DataMember attributes</Description>
</PropertyGroup>

<ItemGroup>
<None Include="..\Equatable.SourceGenerator\bin\$(Configuration)\netstandard2.0\Equatable.SourceGenerator.dll" PackagePath="analyzers/dotnet/roslyn4.8/cs" Pack="true" Visible="false" />
<None Include="..\Equatable.SourceGenerator.DataContract\bin\$(Configuration)\netstandard2.0\Equatable.SourceGenerator.DataContract.dll" PackagePath="analyzers/dotnet/roslyn4.8/cs" Pack="true" Visible="false" />
<None Include="..\Equatable.SourceGenerator\Equatable.Generator.targets" PackagePath="build" Pack="true" Visible="false" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
using System.Diagnostics;

namespace Equatable.Attributes.MessagePack;

/// <summary>
/// Marks the class to source generate overrides for Equals and GetHashCode,
/// including only properties decorated with <c>MessagePack.KeyAttribute</c>
/// and excluding properties decorated with <c>MessagePack.IgnoreMemberAttribute</c>.
/// </summary>
[Conditional("EQUATABLE_GENERATOR")]
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)]
public class MessagePackEquatableAttribute : Attribute;
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFrameworks>netstandard2.0;net8.0;net9.0;net10.0</TargetFrameworks>
<RootNamespace>Equatable</RootNamespace>
<Description>Source generator for Equals and GetHashCode for types using MessagePack Key attributes</Description>
</PropertyGroup>

<ItemGroup>
<None Include="..\Equatable.SourceGenerator\bin\$(Configuration)\netstandard2.0\Equatable.SourceGenerator.dll" PackagePath="analyzers/dotnet/roslyn4.8/cs" Pack="true" Visible="false" />
<None Include="..\Equatable.SourceGenerator.MessagePack\bin\$(Configuration)\netstandard2.0\Equatable.SourceGenerator.MessagePack.dll" PackagePath="analyzers/dotnet/roslyn4.8/cs" Pack="true" Visible="false" />
<None Include="..\Equatable.SourceGenerator\Equatable.Generator.targets" PackagePath="build" Pack="true" Visible="false" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@
namespace Equatable.Attributes;

/// <summary>
/// Use a dictionary based comparer to determine if dictionaries are equal
/// Use a dictionary based comparer to determine if dictionaries are equal.
/// Two dictionaries are equal when they contain the same key/value pairs, regardless of insertion order.
/// </summary>
[Conditional("EQUATABLE_GENERATOR")]
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)]
public class DictionaryEqualityAttribute : Attribute;
public class DictionaryEqualityAttribute : Attribute
{
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
namespace Equatable.Attributes;

/// <summary>
/// Use <see cref="ISet{T}.SetEquals(IEnumerable{T})"/> in Equals and GetHashCode implementations
/// Use <see cref="ISet{T}.SetEquals(IEnumerable{T})"/> in Equals and GetHashCode implementations.
/// Comparison is order-independent — use <see cref="SequenceEqualityAttribute"/> for order-sensitive comparison.
/// </summary>
[Conditional("EQUATABLE_GENERATOR")]
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
; Shipped analyzer releases
; https://github.com/dotnet/roslyn-analyzers/blob/master/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
; Unshipped analyzer release
; https://github.com/dotnet/roslyn-analyzers/blob/master/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md

### New Rules

Rule ID | Category | Severity | Notes
--------|----------|----------|------------------
EQ0020 | Usage | Warning | DataContractEquatableAnalyzer
Loading