From c60d71be34ed37ea019a6b0ec909f084d92097a0 Mon Sep 17 00:00:00 2001 From: "a.kasyanov" Date: Mon, 11 May 2026 21:51:39 +0300 Subject: [PATCH 01/71] feat: IReadOnlyDictionary support, allocation-free hash, and adapter attributes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Support IReadOnlyDictionary in [DictionaryEquality] — generator now recognises both IDictionary and IReadOnlyDictionary via AllInterfaces check; generated DictionaryEquals helper uses IEnumerable> as the common base and pattern-matches to IReadOnlyDictionary/IDictionary for O(1) TryGetValue lookup without casting - Add ReadOnlyDictionaryEqualityComparer to Equatable.Comparers for use with [EqualityComparer] on nested IReadOnlyDictionary properties - Replace OrderBy in DictionaryHashCode and HashSetHashCode with commutative sum-of-HashCode.Combine approach — order-independent and allocation-free; same fix applied to DictionaryEqualityComparer and HashSetEqualityComparer - Add [DataContractEquatable] adapter attribute — reacts to [DataMember]/ [IgnoreDataMember] instead of [IgnoreEquality]; feeds into the same EquatableWriter pipeline - Add [MessagePackEquatable] adapter attribute — reacts to MessagePack [Key]/[IgnoreMember] attributes - Add entity and generator snapshot tests for all new scenarios Co-Authored-By: Claude Sonnet 4.5 --- .../DictionaryEqualityComparer.cs | 13 +-- .../HashSetEqualityComparer.cs | 10 +- .../ReadOnlyDictionaryEqualityComparer.cs | 82 ++++++++++++++ .../DataContractEquatableAttribute.cs | 12 +++ .../MessagePackEquatableAttribute.cs | 12 +++ .../DiagnosticDescriptors.cs | 4 +- .../EquatableGenerator.cs | 76 ++++++++++--- .../EquatableWriter.cs | 78 ++++++++------ test/Equatable.Entities/MarketInputs.cs | 16 +++ test/Equatable.Entities/OrderDataContract.cs | 21 ++++ .../Entities/MarketInputsTest.cs | 101 ++++++++++++++++++ .../Entities/OrderDataContractTest.cs | 34 ++++++ .../EquatableGeneratorTest.cs | 97 +++++++++++++++++ 13 files changed, 494 insertions(+), 62 deletions(-) create mode 100644 src/Equatable.Comparers/ReadOnlyDictionaryEqualityComparer.cs create mode 100644 src/Equatable.Generator/Attributes/DataContractEquatableAttribute.cs create mode 100644 src/Equatable.Generator/Attributes/MessagePackEquatableAttribute.cs create mode 100644 test/Equatable.Entities/MarketInputs.cs create mode 100644 test/Equatable.Entities/OrderDataContract.cs create mode 100644 test/Equatable.Generator.Tests/Entities/MarketInputsTest.cs create mode 100644 test/Equatable.Generator.Tests/Entities/OrderDataContractTest.cs diff --git a/src/Equatable.Comparers/DictionaryEqualityComparer.cs b/src/Equatable.Comparers/DictionaryEqualityComparer.cs index 61ee694..c2afabb 100644 --- a/src/Equatable.Comparers/DictionaryEqualityComparer.cs +++ b/src/Equatable.Comparers/DictionaryEqualityComparer.cs @@ -71,15 +71,12 @@ public int GetHashCode(IDictionary obj) if (obj == null) return 0; - var hash = new HashCode(); + int hashCode = 0; - // 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); - } + // sum of per-pair hashes is order-independent without sorting allocations + foreach (var pair in obj) + hashCode += HashCode.Combine(KeyComparer.GetHashCode(pair.Key!), ValueComparer.GetHashCode(pair.Value!)); - return hash.ToHashCode(); + return hashCode; } } diff --git a/src/Equatable.Comparers/HashSetEqualityComparer.cs b/src/Equatable.Comparers/HashSetEqualityComparer.cs index cb02689..ea2ac04 100644 --- a/src/Equatable.Comparers/HashSetEqualityComparer.cs +++ b/src/Equatable.Comparers/HashSetEqualityComparer.cs @@ -56,12 +56,12 @@ public int GetHashCode(IEnumerable obj) if (obj == null) return 0; - var hashCode = new HashCode(); + int hashCode = 0; - // sort to ensure set with different order are the same - foreach (var item in obj.OrderBy(s => s)) - hashCode.Add(item, Comparer); + // sum of individual hashes is order-independent without sorting allocations + foreach (var item in obj) + hashCode += Comparer.GetHashCode(item!); - return hashCode.ToHashCode(); + return hashCode; } } diff --git a/src/Equatable.Comparers/ReadOnlyDictionaryEqualityComparer.cs b/src/Equatable.Comparers/ReadOnlyDictionaryEqualityComparer.cs new file mode 100644 index 0000000..01303f3 --- /dev/null +++ b/src/Equatable.Comparers/ReadOnlyDictionaryEqualityComparer.cs @@ -0,0 +1,82 @@ +namespace Equatable.Comparers; + +/// +/// equality comparer instance +/// +/// The type of the keys in the dictionary. +/// The type of the values in the dictionary. +public class ReadOnlyDictionaryEqualityComparer : IEqualityComparer> +{ + /// + /// Gets the default equality comparer for specified generic argument. + /// + public static ReadOnlyDictionaryEqualityComparer Default { get; } = new(); + + /// + /// Initializes a new instance of the class. + /// + public ReadOnlyDictionaryEqualityComparer() : this(EqualityComparer.Default, EqualityComparer.Default) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The that is used to determine equality of keys in a dictionary + /// The that is used to determine equality of values in a dictionary + /// or is null + public ReadOnlyDictionaryEqualityComparer(IEqualityComparer keyComparer, IEqualityComparer valueComparer) + { + KeyComparer = keyComparer ?? throw new ArgumentNullException(nameof(keyComparer)); + ValueComparer = valueComparer ?? throw new ArgumentNullException(nameof(valueComparer)); + } + + /// + /// Gets the that is used to determine equality of keys in a dictionary + /// + public IEqualityComparer KeyComparer { get; } + + /// + /// Gets the that is used to determine equality of values in a dictionary + /// + public IEqualityComparer ValueComparer { get; } + + /// + public bool Equals(IReadOnlyDictionary? x, IReadOnlyDictionary? y) + { + if (ReferenceEquals(x, y)) + return true; + + if (x is null || y is null) + return false; + + if (x.Count != y.Count) + return false; + + foreach (var pair in x) + { + if (!y.TryGetValue(pair.Key, out var value)) + return false; + + if (!ValueComparer.Equals(pair.Value, value)) + return false; + } + + return true; + } + + /// + public int GetHashCode(IReadOnlyDictionary obj) + { + if (obj == null) + return 0; + + int hashCode = 0; + + // sum of per-pair hashes is order-independent without sorting allocations + foreach (var pair in obj) + hashCode += HashCode.Combine(KeyComparer.GetHashCode(pair.Key!), ValueComparer.GetHashCode(pair.Value!)); + + return hashCode; + } +} diff --git a/src/Equatable.Generator/Attributes/DataContractEquatableAttribute.cs b/src/Equatable.Generator/Attributes/DataContractEquatableAttribute.cs new file mode 100644 index 0000000..b6251fa --- /dev/null +++ b/src/Equatable.Generator/Attributes/DataContractEquatableAttribute.cs @@ -0,0 +1,12 @@ +using System.Diagnostics; + +namespace Equatable.Attributes; + +/// +/// Marks the class to source generate overrides for Equals and GetHashCode, +/// including only properties decorated with +/// and excluding properties decorated with . +/// +[Conditional("EQUATABLE_GENERATOR")] +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)] +public class DataContractEquatableAttribute : Attribute; diff --git a/src/Equatable.Generator/Attributes/MessagePackEquatableAttribute.cs b/src/Equatable.Generator/Attributes/MessagePackEquatableAttribute.cs new file mode 100644 index 0000000..d874b21 --- /dev/null +++ b/src/Equatable.Generator/Attributes/MessagePackEquatableAttribute.cs @@ -0,0 +1,12 @@ +using System.Diagnostics; + +namespace Equatable.Attributes; + +/// +/// Marks the class to source generate overrides for Equals and GetHashCode, +/// including only properties decorated with MessagePack.KeyAttribute +/// and excluding properties decorated with MessagePack.IgnoreMemberAttribute. +/// +[Conditional("EQUATABLE_GENERATOR")] +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)] +public class MessagePackEquatableAttribute : Attribute; diff --git a/src/Equatable.SourceGenerator/DiagnosticDescriptors.cs b/src/Equatable.SourceGenerator/DiagnosticDescriptors.cs index 21757d7..e04eb8f 100644 --- a/src/Equatable.SourceGenerator/DiagnosticDescriptors.cs +++ b/src/Equatable.SourceGenerator/DiagnosticDescriptors.cs @@ -7,7 +7,7 @@ internal static class DiagnosticDescriptors public static readonly DiagnosticDescriptor MissingDictionaryEqualityAttribute = new( id: "EQ0001", title: "Missing DictionaryEquality Attribute", - messageFormat: "Property '{0}' type implements IDictionary but does not have the [DictionaryEquality] attribute", + messageFormat: "Property '{0}' type implements IDictionary or IReadOnlyDictionary but does not have the [DictionaryEquality] attribute", category: "Usage", defaultSeverity: DiagnosticSeverity.Warning, isEnabledByDefault: true @@ -34,7 +34,7 @@ internal static class DiagnosticDescriptors public static readonly DiagnosticDescriptor InvalidDictionaryEqualityAttributeUsage = new( id: "EQ0011", title: "Invalid DictionaryEquality Attribute Usage", - messageFormat: "Invalid DictionaryEquality attribute usage for property '{0}'. Property type does not implement IDictionary.", + messageFormat: "Invalid DictionaryEquality attribute usage for property '{0}'. Property type does not implement IDictionary or IReadOnlyDictionary.", category: "Usage", defaultSeverity: DiagnosticSeverity.Warning, isEnabledByDefault: true diff --git a/src/Equatable.SourceGenerator/EquatableGenerator.cs b/src/Equatable.SourceGenerator/EquatableGenerator.cs index ff54a97..53b5cd0 100644 --- a/src/Equatable.SourceGenerator/EquatableGenerator.cs +++ b/src/Equatable.SourceGenerator/EquatableGenerator.cs @@ -13,21 +13,39 @@ public class EquatableGenerator : IIncrementalGenerator private static readonly SymbolDisplayFormat NameAndNamespaces = new(SymbolDisplayGlobalNamespaceStyle.Omitted, SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces, SymbolDisplayGenericsOptions.None); public void Initialize(IncrementalGeneratorInitializationContext context) + { + RegisterProvider(context, + fullyQualifiedMetadataName: "Equatable.Attributes.EquatableAttribute", + trackingName: "EquatableAttribute", + propertyFilter: IsIncluded); + + RegisterProvider(context, + fullyQualifiedMetadataName: "Equatable.Attributes.DataContractEquatableAttribute", + trackingName: "DataContractEquatableAttribute", + propertyFilter: IsIncludedDataContract); + + RegisterProvider(context, + fullyQualifiedMetadataName: "Equatable.Attributes.MessagePackEquatableAttribute", + trackingName: "MessagePackEquatableAttribute", + propertyFilter: IsIncludedMessagePack); + } + + private static void RegisterProvider( + IncrementalGeneratorInitializationContext context, + string fullyQualifiedMetadataName, + string trackingName, + Func propertyFilter) { var provider = context.SyntaxProvider .ForAttributeWithMetadataName( - fullyQualifiedMetadataName: "Equatable.Attributes.EquatableAttribute", + fullyQualifiedMetadataName: fullyQualifiedMetadataName, predicate: SyntacticPredicate, - transform: SemanticTransform + transform: (ctx, ct) => SemanticTransform(ctx, ct, propertyFilter) ) - .Where(static context => context is not null) - .WithTrackingName("EquatableAttribute"); - - // output code - var entityClasses = provider - .Where(static item => item is not null); + .Where(static item => item is not null) + .WithTrackingName(trackingName); - context.RegisterSourceOutput(entityClasses, Execute); + context.RegisterSourceOutput(provider, Execute); } @@ -49,7 +67,7 @@ private static bool SyntacticPredicate(SyntaxNode syntaxNode, CancellationToken || (syntaxNode is StructDeclarationSyntax structDeclaration && !structDeclaration.Modifiers.Any(SyntaxKind.StaticKeyword)); } - private static EquatableClass? SemanticTransform(GeneratorAttributeSyntaxContext context, CancellationToken cancellationToken) + private static EquatableClass? SemanticTransform(GeneratorAttributeSyntaxContext context, CancellationToken cancellationToken, Func propertyFilter) { if (context.TargetSymbol is not INamedTypeSymbol targetSymbol) return null; @@ -67,7 +85,7 @@ private static bool SyntacticPredicate(SyntaxNode syntaxNode, CancellationToken var baseEquals = GetBaseEqualsMethod(targetSymbol); var baseEquatable = GetBaseEquatableType(targetSymbol); - var propertySymbols = GetProperties(targetSymbol, baseHashCode == null && baseEquatable == null); + var propertySymbols = GetProperties(targetSymbol, baseHashCode == null && baseEquatable == null, propertyFilter); var propertyArray = propertySymbols .Select(CreateProperty) @@ -103,7 +121,7 @@ private static bool SyntacticPredicate(SyntaxNode syntaxNode, CancellationToken } - private static IEnumerable GetProperties(INamedTypeSymbol targetSymbol, bool includeBaseProperties = true) + private static IEnumerable GetProperties(INamedTypeSymbol targetSymbol, bool includeBaseProperties, Func propertyFilter) { var properties = new Dictionary(); @@ -116,7 +134,7 @@ private static IEnumerable GetProperties(INamedTypeSymbol targe .GetMembers() .Where(m => m.Kind == SymbolKind.Property) .OfType() - .Where(IsIncluded) + .Where(propertyFilter) .Where(p => !properties.ContainsKey(p.Name)); foreach (var propertySymbol in propertySymbols) @@ -282,6 +300,36 @@ private static bool IsIncluded(IPropertySymbol propertySymbol) return !propertySymbol.IsIndexer && propertySymbol.DeclaredAccessibility == Accessibility.Public; } + private static bool IsIncludedDataContract(IPropertySymbol propertySymbol) + { + if (propertySymbol.IsIndexer || propertySymbol.DeclaredAccessibility != Accessibility.Public) + return false; + + var attributes = propertySymbol.GetAttributes(); + if (attributes.Length == 0) + return false; + + if (attributes.Any(a => a.AttributeClass is { Name: "IgnoreDataMemberAttribute", ContainingNamespace.Name: "Serialization" })) + return false; + + return attributes.Any(a => a.AttributeClass is { Name: "DataMemberAttribute", ContainingNamespace.Name: "Serialization" }); + } + + private static bool IsIncludedMessagePack(IPropertySymbol propertySymbol) + { + if (propertySymbol.IsIndexer || propertySymbol.DeclaredAccessibility != Accessibility.Public) + return false; + + var attributes = propertySymbol.GetAttributes(); + if (attributes.Length == 0) + return false; + + if (attributes.Any(a => a.AttributeClass is { Name: "IgnoreMemberAttribute", ContainingNamespace.Name: "MessagePack" })) + return false; + + return attributes.Any(a => a.AttributeClass is { Name: "KeyAttribute", ContainingNamespace.Name: "MessagePack" }); + } + private static bool IsKnownAttribute(AttributeData? attribute) { if (attribute == null) @@ -334,7 +382,7 @@ private static bool IsDictionary(INamedTypeSymbol targetSymbol) { return targetSymbol is { - Name: "IDictionary", + Name: "IDictionary" or "IReadOnlyDictionary", IsGenericType: true, TypeArguments.Length: 2, TypeParameters.Length: 2, diff --git a/src/Equatable.SourceGenerator/EquatableWriter.cs b/src/Equatable.SourceGenerator/EquatableWriter.cs index 8179dc5..2c55d05 100644 --- a/src/Equatable.SourceGenerator/EquatableWriter.cs +++ b/src/Equatable.SourceGenerator/EquatableWriter.cs @@ -232,7 +232,7 @@ private static void GenerateEquatableFunctions(IndentedStringBuilder codeBuilder if (entityClass.Properties.Any(p => p.ComparerType == ComparerTypes.Dictionary)) { codeBuilder - .AppendLine("static bool DictionaryEquals(global::System.Collections.Generic.IDictionary? left, global::System.Collections.Generic.IDictionary? right)") + .AppendLine("static bool DictionaryEquals(global::System.Collections.Generic.IEnumerable>? left, global::System.Collections.Generic.IEnumerable>? right)") .AppendLine("{") .IncrementIndent() .AppendLine("if (global::System.Object.ReferenceEquals(left, right))") @@ -241,28 +241,51 @@ private static void GenerateEquatableFunctions(IndentedStringBuilder codeBuilder .AppendLine("if (left is null || right is null)") .AppendLine(" return false;") .AppendLine() - .AppendLine("if (left.Count != right.Count)") + .AppendLine("if (left is global::System.Collections.Generic.IReadOnlyCollection> leftCollection &&") + .AppendLine(" right is global::System.Collections.Generic.IReadOnlyCollection> rightCollection &&") + .AppendLine(" leftCollection.Count != rightCollection.Count)") .AppendLine(" return false;") - .AppendLine(); - - codeBuilder + .AppendLine() + .AppendLine("if (right is global::System.Collections.Generic.IReadOnlyDictionary rightReadOnly)") + .AppendLine("{") + .IncrementIndent() .AppendLine("foreach (var pair in left)") .AppendLine("{") .IncrementIndent() - .AppendLine("if (!right.TryGetValue(pair.Key, out var value))") + .AppendLine("if (!rightReadOnly.TryGetValue(pair.Key, out var value))") .AppendLine(" return false;") .AppendLine() .AppendLine("if (!global::System.Collections.Generic.EqualityComparer.Default.Equals(pair.Value, value))") .AppendLine(" return false;") - .AppendLine() .DecrementIndent() - .AppendLine("}"); // foreach - - codeBuilder + .AppendLine("}") + .AppendLine("return true;") + .DecrementIndent() + .AppendLine("}") .AppendLine() + .AppendLine("if (right is global::System.Collections.Generic.IDictionary rightDictionary)") + .AppendLine("{") + .IncrementIndent() + .AppendLine("foreach (var pair in left)") + .AppendLine("{") + .IncrementIndent() + .AppendLine("if (!rightDictionary.TryGetValue(pair.Key, out var value))") + .AppendLine(" return false;") + .AppendLine() + .AppendLine("if (!global::System.Collections.Generic.EqualityComparer.Default.Equals(pair.Value, value))") + .AppendLine(" return false;") + .DecrementIndent() + .AppendLine("}") .AppendLine("return true;") .DecrementIndent() .AppendLine("}") + .AppendLine() + .AppendLine("return global::System.Linq.Enumerable.SequenceEqual(") + .AppendLine(" global::System.Linq.Enumerable.OrderBy(left, p => p.Key),") + .AppendLine(" global::System.Linq.Enumerable.OrderBy(right, p => p.Key),") + .AppendLine(" global::System.Collections.Generic.EqualityComparer>.Default);") + .DecrementIndent() + .AppendLine("}") .AppendLine(); } @@ -503,28 +526,19 @@ private static void GenerateHashCodeFunctions(IndentedStringBuilder codeBuilder, if (entityClass.Properties.Any(p => p.ComparerType == ComparerTypes.Dictionary)) { codeBuilder - .AppendLine("static int DictionaryHashCode(global::System.Collections.Generic.IDictionary? items)") + .AppendLine("static int DictionaryHashCode(global::System.Collections.Generic.IEnumerable>? items)") .AppendLine("{") .IncrementIndent() .AppendLine("if (items is null)") .AppendLine(" return 0;") - .AppendLine(); - - codeBuilder - .Append("int hashCode = ") - .Append(entityClass.SeedHash) - .AppendLine(";") .AppendLine() - .AppendLine("// sort by key to ensure dictionary with different order are the same") - .AppendLine("foreach (var item in global::System.Linq.Enumerable.OrderBy(items, d => d.Key))") - .AppendLine("{") - .IncrementIndent() - .AppendLine("hashCode = (hashCode * -1521134295) + global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(item.Key!);") - .AppendLine("hashCode = (hashCode * -1521134295) + global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(item.Value!);") - .DecrementIndent() - .AppendLine("}"); // foreach - - codeBuilder + .AppendLine("int hashCode = 0;") + .AppendLine() + .AppendLine("// sum of per-pair hashes is order-independent without sorting allocations") + .AppendLine("foreach (var item in items)") + .AppendLine(" hashCode += global::System.HashCode.Combine(") + .AppendLine(" global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(item.Key!),") + .AppendLine(" global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(item.Value!));") .AppendLine() .AppendLine("return hashCode;") .DecrementIndent() @@ -541,13 +555,11 @@ private static void GenerateHashCodeFunctions(IndentedStringBuilder codeBuilder, .AppendLine("if (items is null)") .AppendLine(" return 0;") .AppendLine() - .Append("int hashCode = ") - .Append(entityClass.SeedHash) - .AppendLine(";") + .AppendLine("int hashCode = 0;") .AppendLine() - .AppendLine("// sort to ensure set with different order are the same") - .AppendLine("foreach (var item in global::System.Linq.Enumerable.OrderBy(items, d => d))") - .AppendLine(" hashCode = (hashCode * -1521134295) + global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(item!);") + .AppendLine("// sum of individual hashes is order-independent without sorting allocations") + .AppendLine("foreach (var item in items)") + .AppendLine(" hashCode += global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(item!);") .AppendLine() .AppendLine("return hashCode;") .DecrementIndent() diff --git a/test/Equatable.Entities/MarketInputs.cs b/test/Equatable.Entities/MarketInputs.cs new file mode 100644 index 0000000..c2a54b9 --- /dev/null +++ b/test/Equatable.Entities/MarketInputs.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; +using System.Runtime.Serialization; +using Equatable.Attributes; +using Equatable.Comparers; + +namespace Equatable.Entities; + +[Equatable] +public partial class MarketInputs +{ + [DictionaryEquality] + public IReadOnlyDictionary? FlatInputs { get; set; } + + [EqualityComparer(typeof(ReadOnlyDictionaryEqualityComparer>))] + public IReadOnlyDictionary>? NestedInputs { get; set; } +} diff --git a/test/Equatable.Entities/OrderDataContract.cs b/test/Equatable.Entities/OrderDataContract.cs new file mode 100644 index 0000000..1e34a92 --- /dev/null +++ b/test/Equatable.Entities/OrderDataContract.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; +using System.Runtime.Serialization; +using Equatable.Attributes; + +namespace Equatable.Entities; + +[DataContractEquatable] +public partial class OrderDataContract +{ + [DataMember(Order = 0)] + public int Id { get; set; } + + [DataMember(Order = 1)] + public string? Name { get; set; } + + // not included — no [DataMember] + public string? InternalNote { get; set; } + + [IgnoreDataMember] + public string? IgnoredField { get; set; } +} diff --git a/test/Equatable.Generator.Tests/Entities/MarketInputsTest.cs b/test/Equatable.Generator.Tests/Entities/MarketInputsTest.cs new file mode 100644 index 0000000..de41f28 --- /dev/null +++ b/test/Equatable.Generator.Tests/Entities/MarketInputsTest.cs @@ -0,0 +1,101 @@ +using System.Collections.Generic; +using Equatable.Entities; + +namespace Equatable.Generator.Tests.Entities; + +public class MarketInputsTest +{ + [Fact] + public void EqualsWithReadOnlyDictionary() + { + var left = new MarketInputs + { + FlatInputs = new Dictionary { ["a"] = 1.0, ["b"] = 2.0 } + }; + + var right = new MarketInputs + { + FlatInputs = new Dictionary { ["b"] = 2.0, ["a"] = 1.0 } + }; + + Assert.True(left.Equals(right)); + } + + [Fact] + public void NotEqualsWithReadOnlyDictionary() + { + var left = new MarketInputs + { + FlatInputs = new Dictionary { ["a"] = 1.0, ["b"] = 2.0 } + }; + + var right = new MarketInputs + { + FlatInputs = new Dictionary { ["a"] = 1.0, ["b"] = 3.0 } + }; + + Assert.False(left.Equals(right)); + } + + [Fact] + public void EqualsWithNestedReadOnlyDictionary() + { + var left = new MarketInputs + { + NestedInputs = new Dictionary> + { + ["market1"] = new Dictionary { ["x"] = 0.5, ["y"] = 0.5 }, + ["market2"] = new Dictionary { ["x"] = 0.3, ["y"] = 0.7 } + } + }; + + var right = new MarketInputs + { + NestedInputs = new Dictionary> + { + ["market2"] = new Dictionary { ["y"] = 0.7, ["x"] = 0.3 }, + ["market1"] = new Dictionary { ["y"] = 0.5, ["x"] = 0.5 } + } + }; + + Assert.True(left.Equals(right)); + } + + [Fact] + public void NotEqualsWithNestedReadOnlyDictionary() + { + var left = new MarketInputs + { + NestedInputs = new Dictionary> + { + ["market1"] = new Dictionary { ["x"] = 0.5, ["y"] = 0.5 } + } + }; + + var right = new MarketInputs + { + NestedInputs = new Dictionary> + { + ["market1"] = new Dictionary { ["x"] = 0.5, ["y"] = 0.6 } + } + }; + + Assert.False(left.Equals(right)); + } + + [Fact] + public void HashCodeEqualsWithReadOnlyDictionary() + { + var left = new MarketInputs + { + FlatInputs = new Dictionary { ["a"] = 1.0, ["b"] = 2.0 } + }; + + var right = new MarketInputs + { + FlatInputs = new Dictionary { ["b"] = 2.0, ["a"] = 1.0 } + }; + + Assert.Equal(left.GetHashCode(), right.GetHashCode()); + } +} diff --git a/test/Equatable.Generator.Tests/Entities/OrderDataContractTest.cs b/test/Equatable.Generator.Tests/Entities/OrderDataContractTest.cs new file mode 100644 index 0000000..af9ba67 --- /dev/null +++ b/test/Equatable.Generator.Tests/Entities/OrderDataContractTest.cs @@ -0,0 +1,34 @@ +using Equatable.Entities; + +namespace Equatable.Generator.Tests.Entities; + +public class OrderDataContractTest +{ + [Fact] + public void EqualsOnDataMembers() + { + var left = new OrderDataContract { Id = 1, Name = "Test", InternalNote = "note-a", IgnoredField = "ignored-a" }; + var right = new OrderDataContract { Id = 1, Name = "Test", InternalNote = "note-b", IgnoredField = "ignored-b" }; + + // InternalNote and IgnoredField are excluded — only Id and Name matter + Assert.True(left.Equals(right)); + } + + [Fact] + public void NotEqualsOnDataMembers() + { + var left = new OrderDataContract { Id = 1, Name = "Test" }; + var right = new OrderDataContract { Id = 2, Name = "Test" }; + + Assert.False(left.Equals(right)); + } + + [Fact] + public void HashCodeEqualsOnDataMembers() + { + var left = new OrderDataContract { Id = 1, Name = "Test", InternalNote = "note-a" }; + var right = new OrderDataContract { Id = 1, Name = "Test", InternalNote = "note-b" }; + + Assert.Equal(left.GetHashCode(), right.GetHashCode()); + } +} diff --git a/test/Equatable.Generator.Tests/EquatableGeneratorTest.cs b/test/Equatable.Generator.Tests/EquatableGeneratorTest.cs index 281a2c8..fbb75a2 100644 --- a/test/Equatable.Generator.Tests/EquatableGeneratorTest.cs +++ b/test/Equatable.Generator.Tests/EquatableGeneratorTest.cs @@ -540,6 +540,103 @@ public partial class UserImport .ScrubLinesContaining("GeneratedCodeAttribute"); } + [Fact] + public Task GenerateReadOnlyDictionary() + { + var source = @" +using System.Collections.Generic; +using Equatable.Attributes; + +namespace Equatable.Entities; + +[Equatable] +public partial class MarketInputs +{ + [DictionaryEquality] + public IReadOnlyDictionary? FlatInputs { get; set; } +} +"; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + + return Verifier + .Verify(output) + .UseDirectory("Snapshots") + .ScrubLinesContaining("GeneratedCodeAttribute"); + } + + [Fact] + public Task GenerateDataContractEquatable() + { + var source = @" +using System.Runtime.Serialization; +using Equatable.Attributes; + +namespace Equatable.Entities; + +[DataContractEquatable] +public partial class OrderDataContract +{ + [DataMember(Order = 0)] + public int Id { get; set; } + + [DataMember(Order = 1)] + public string? Name { get; set; } + + public string? InternalNote { get; set; } + + [IgnoreDataMember] + public string? IgnoredField { get; set; } +} +"; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + + return Verifier + .Verify(output) + .UseDirectory("Snapshots") + .ScrubLinesContaining("GeneratedCodeAttribute"); + } + + [Fact] + public Task GenerateMessagePackEquatable() + { + var source = @" +using MessagePack; +using Equatable.Attributes; + +namespace Equatable.Entities; + +[MessagePackEquatable] +public partial class PricingContract +{ + [Key(0)] + public int MarketId { get; set; } + + [Key(1)] + public double Probability { get; set; } + + [IgnoreMember] + public string? DebugInfo { get; set; } + + public string? NotIncluded { get; set; } +} +"; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + + return Verifier + .Verify(output) + .UseDirectory("Snapshots") + .ScrubLinesContaining("GeneratedCodeAttribute"); + } + private static (ImmutableArray Diagnostics, string Output) GetGeneratedOutput(string source) where T : IIncrementalGenerator, new() { From 68d93d76a727367e1d4ac5763326fb548483e943 Mon Sep 17 00:00:00 2001 From: "a.kasyanov" Date: Tue, 12 May 2026 00:46:18 +0300 Subject: [PATCH 02/71] feat: multi-dim array comparer, adapter base class support, and nested collection composition - Add MultiDimensionalArrayEqualityComparer for int[,]/T[,] with zero-allocation Array.GetEnumerator() iteration - Extend GetBaseEquatableType and HasEquatableAttribute to recognise DataContractEquatable and MessagePackEquatable bases so derived classes emit base.Equals()/base.GetHashCode() - Fix IsIncludedDataContract namespace check to require full System.Runtime.Serialization chain - Auto-compose nested collection comparers (SequenceEqualityComparer, DictionaryEqualityComparer, HashSetEqualityComparer) recursively from a single top-level attribute; explicit [EqualityComparer] overrides at any level - Add ComparerTypes.Expression path in writer for fully-composed comparer instance expressions - Extend analyzer to cover DataContractEquatable/MessagePackEquatable types and accept arrays for [SequenceEquality] - Add pinned MetadataReferences for DataMemberAttribute and KeyAttribute assemblies to fix load-order fragility in tests - Add property-based tests (FsCheck) and snapshot tests for all new scenarios Co-Authored-By: Claude Sonnet 4.5 --- Directory.Packages.props | 5 + .../MultiDimensionalArrayEqualityComparer.cs | 78 ++ .../EquatableAnalyzer.cs | 10 +- .../EquatableGenerator.cs | 257 ++++++- .../EquatableWriter.cs | 19 + .../Models/ComparerTypes.cs | 4 +- .../Models/EquatableProperty.cs | 4 +- .../Equatable.Entities.csproj | 2 + test/Equatable.Entities/LookupTable.cs | 16 + test/Equatable.Entities/MarketInputs.cs | 16 - test/Equatable.Entities/NestedCollections.cs | 94 +++ test/Equatable.Entities/SerializedRecord.cs | 21 + ...quatable.Generator.Properties.Tests.csproj | 38 + .../DictionaryComparerProperties.cs | 47 ++ .../Properties/HashSetComparerProperties.cs | 57 ++ .../Properties/LookupTableProperties.cs | 165 ++++ .../Properties/NestedCollectionsProperties.cs | 724 ++++++++++++++++++ .../Properties/OrderDataContractProperties.cs | 72 ++ .../ReadOnlyDictionaryComparerProperties.cs | 68 ++ .../Properties/SerializedRecordProperties.cs | 76 ++ .../global.json | 5 + .../DictionaryHashCodeTest.cs | 72 ++ .../Entities/LookupTableTest.cs | 102 +++ .../Entities/MarketInputsTest.cs | 101 --- .../Equatable.Generator.Tests.csproj | 4 + .../EquatableGeneratorTest.cs | 482 +++++++++++- .../DictionaryComparerProperties.cs | 47 ++ .../Properties/HashSetComparerProperties.cs | 52 ++ .../Properties/NestedCollectionsProperties.cs | 694 +++++++++++++++++ .../Properties/OrderDataContractProperties.cs | 72 ++ .../ReadOnlyDictionaryComparerProperties.cs | 67 ++ ...GenerateDataContractEquatable.verified.txt | 45 ++ ...erivedIncludesUnannotatedBase.verified.txt | 47 ++ ...FromDataContractEquatableBase.verified.txt | 45 ++ ...dFromMessagePackEquatableBase.verified.txt | 45 ++ ...ratorTest.GenerateDictOfLists.verified.txt | 43 ++ ...t.GenerateIDictionaryEquality.verified.txt | 104 +++ ...teIEnumerableSequenceEquality.verified.txt | 67 ++ ...GenerateIListSequenceEquality.verified.txt | 67 ++ ...nlyCollectionSequenceEquality.verified.txt | 67 ++ ...IReadOnlyListSequenceEquality.verified.txt | 67 ++ ...t.GenerateISetHashSetEquality.verified.txt | 75 ++ ...ratorTest.GenerateListOfDicts.verified.txt | 43 ++ ....GenerateMessagePackEquatable.verified.txt | 45 ++ ...erivedIncludesUnannotatedBase.verified.txt | 47 ++ ...Test.GenerateNestedDictionary.verified.txt | 43 ++ ...ateNestedSequenceInDictionary.verified.txt | 43 ++ ...st.GenerateReadOnlyDictionary.verified.txt | 104 +++ ...EqualityMultiDimensionalArray.verified.txt | 45 ++ ...enerateSequenceOfDictionaries.verified.txt | 43 ++ ...Test.GenerateThreeLevelNested.verified.txt | 43 ++ ...eratorTest.GenerateUserImport.verified.txt | 62 +- ...teUserImportHashSetDictionary.verified.txt | 62 +- 53 files changed, 4513 insertions(+), 210 deletions(-) create mode 100644 src/Equatable.Comparers/MultiDimensionalArrayEqualityComparer.cs create mode 100644 test/Equatable.Entities/LookupTable.cs delete mode 100644 test/Equatable.Entities/MarketInputs.cs create mode 100644 test/Equatable.Entities/NestedCollections.cs create mode 100644 test/Equatable.Entities/SerializedRecord.cs create mode 100644 test/Equatable.Generator.Properties.Tests/Equatable.Generator.Properties.Tests.csproj create mode 100644 test/Equatable.Generator.Properties.Tests/Properties/DictionaryComparerProperties.cs create mode 100644 test/Equatable.Generator.Properties.Tests/Properties/HashSetComparerProperties.cs create mode 100644 test/Equatable.Generator.Properties.Tests/Properties/LookupTableProperties.cs create mode 100644 test/Equatable.Generator.Properties.Tests/Properties/NestedCollectionsProperties.cs create mode 100644 test/Equatable.Generator.Properties.Tests/Properties/OrderDataContractProperties.cs create mode 100644 test/Equatable.Generator.Properties.Tests/Properties/ReadOnlyDictionaryComparerProperties.cs create mode 100644 test/Equatable.Generator.Properties.Tests/Properties/SerializedRecordProperties.cs create mode 100644 test/Equatable.Generator.Properties.Tests/global.json create mode 100644 test/Equatable.Generator.Tests/DictionaryHashCodeTest.cs create mode 100644 test/Equatable.Generator.Tests/Entities/LookupTableTest.cs delete mode 100644 test/Equatable.Generator.Tests/Entities/MarketInputsTest.cs create mode 100644 test/Equatable.Generator.Tests/Properties/DictionaryComparerProperties.cs create mode 100644 test/Equatable.Generator.Tests/Properties/HashSetComparerProperties.cs create mode 100644 test/Equatable.Generator.Tests/Properties/NestedCollectionsProperties.cs create mode 100644 test/Equatable.Generator.Tests/Properties/OrderDataContractProperties.cs create mode 100644 test/Equatable.Generator.Tests/Properties/ReadOnlyDictionaryComparerProperties.cs create mode 100644 test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateDataContractEquatable.verified.txt create mode 100644 test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateDataContractEquatableDerivedIncludesUnannotatedBase.verified.txt create mode 100644 test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateDerivedFromDataContractEquatableBase.verified.txt create mode 100644 test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateDerivedFromMessagePackEquatableBase.verified.txt create mode 100644 test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateDictOfLists.verified.txt create mode 100644 test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateIDictionaryEquality.verified.txt create mode 100644 test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateIEnumerableSequenceEquality.verified.txt create mode 100644 test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateIListSequenceEquality.verified.txt create mode 100644 test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateIReadOnlyCollectionSequenceEquality.verified.txt create mode 100644 test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateIReadOnlyListSequenceEquality.verified.txt create mode 100644 test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateISetHashSetEquality.verified.txt create mode 100644 test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateListOfDicts.verified.txt create mode 100644 test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateMessagePackEquatable.verified.txt create mode 100644 test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateMessagePackEquatableDerivedIncludesUnannotatedBase.verified.txt create mode 100644 test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateNestedDictionary.verified.txt create mode 100644 test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateNestedSequenceInDictionary.verified.txt create mode 100644 test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateReadOnlyDictionary.verified.txt create mode 100644 test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateSequenceEqualityMultiDimensionalArray.verified.txt create mode 100644 test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateSequenceOfDictionaries.verified.txt create mode 100644 test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateThreeLevelNested.verified.txt diff --git a/Directory.Packages.props b/Directory.Packages.props index 9c229b7..e56f1fb 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -10,6 +10,11 @@ + + + + + diff --git a/src/Equatable.Comparers/MultiDimensionalArrayEqualityComparer.cs b/src/Equatable.Comparers/MultiDimensionalArrayEqualityComparer.cs new file mode 100644 index 0000000..8ef3afa --- /dev/null +++ b/src/Equatable.Comparers/MultiDimensionalArrayEqualityComparer.cs @@ -0,0 +1,78 @@ +namespace Equatable.Comparers; + +/// +/// Structural equality comparer for multi-dimensional arrays (T[,], T[,,], etc.). +/// Compares element-by-element in row-major order without LINQ or intermediate allocations. +/// +/// The element type of the array. +public class MultiDimensionalArrayEqualityComparer : IEqualityComparer +{ + /// + /// Gets the default equality comparer for the specified element type. + /// + public static MultiDimensionalArrayEqualityComparer Default { get; } = new(); + + /// + /// Initializes a new instance using the default element comparer. + /// + public MultiDimensionalArrayEqualityComparer() : this(EqualityComparer.Default) + { + } + + /// + /// Initializes a new instance using the specified element comparer. + /// + public MultiDimensionalArrayEqualityComparer(IEqualityComparer comparer) + { + Comparer = comparer ?? throw new ArgumentNullException(nameof(comparer)); + } + + /// + /// Gets the element comparer. + /// + public IEqualityComparer Comparer { get; } + + /// + 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; + } + + /// + 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(); + } +} diff --git a/src/Equatable.SourceGenerator/EquatableAnalyzer.cs b/src/Equatable.SourceGenerator/EquatableAnalyzer.cs index 86c2d04..c148a05 100644 --- a/src/Equatable.SourceGenerator/EquatableAnalyzer.cs +++ b/src/Equatable.SourceGenerator/EquatableAnalyzer.cs @@ -51,7 +51,7 @@ private static IEnumerable GetAnalyzableProperties(INamedTypeSy if (IsSystemBaseType(currentSymbol)) break; - // If a base type (not the target itself) has [Equatable], stop: it will be analyzed separately + // If a base type (not the target itself) has any generator attribute, stop: it will be analyzed separately if (!SymbolEqualityComparer.Default.Equals(currentSymbol, typeSymbol) && HasEquatableAttribute(currentSymbol)) break; @@ -143,7 +143,8 @@ private static void AnalyzeProperty(SymbolAnalysisContext context, IPropertySymb private static bool HasEquatableAttribute(INamedTypeSymbol typeSymbol) { return typeSymbol.GetAttributes().Any( - a => IsKnownAttribute(a) && a.AttributeClass?.Name == "EquatableAttribute"); + a => IsKnownAttribute(a) && a.AttributeClass?.Name is + "EquatableAttribute" or "DataContractEquatableAttribute" or "MessagePackEquatableAttribute"); } private static bool IsIgnored(IPropertySymbol propertySymbol) @@ -193,6 +194,11 @@ private static bool ImplementsDictionary(ITypeSymbol type) private static bool ImplementsEnumerable(ITypeSymbol type) { + // Arrays (including multi-dimensional) are always valid for [SequenceEquality]: + // single-dim arrays implement IEnumerable; multi-dim use MultiDimensionalArrayEqualityComparer. + if (type is IArrayTypeSymbol) + return true; + return (type is INamedTypeSymbol named && IsEnumerable(named)) || type.AllInterfaces.Any(IsEnumerable); } diff --git a/src/Equatable.SourceGenerator/EquatableGenerator.cs b/src/Equatable.SourceGenerator/EquatableGenerator.cs index 53b5cd0..58feed7 100644 --- a/src/Equatable.SourceGenerator/EquatableGenerator.cs +++ b/src/Equatable.SourceGenerator/EquatableGenerator.cs @@ -156,17 +156,8 @@ private static EquatableProperty CreateProperty(IPropertySymbol propertySymbol) var isValueType = propertySymbol.Type.IsValueType; var defaultComparer = isValueType ? ComparerTypes.ValueType : ComparerTypes.Default; - // look for custom equality + // look for an explicit equality attribute var attributes = propertySymbol.GetAttributes(); - if (attributes.Length == 0) - { - return new EquatableProperty( - propertyName, - propertyType, - defaultComparer); - } - - // search for known attribute foreach (var attribute in attributes) { (var comparerType, var comparerName, var comparerInstance) = GetComparer(attribute); @@ -176,44 +167,229 @@ private static EquatableProperty CreateProperty(IPropertySymbol propertySymbol) var isValid = ValidateComparer(propertySymbol, comparerType); if (!isValid) + return new EquatableProperty(propertyName, propertyType, defaultComparer); + + // for collection attributes, check if the element type itself contains nested collections + // that need a composed comparer rather than EqualityComparer.Default. + // BuildArrayComparerExpression always returns a non-null expression for arrays + // (including multi-dimensional, which use MultiDimensionalArrayEqualityComparer). + if (comparerType is ComparerTypes.Dictionary or ComparerTypes.HashSet or ComparerTypes.Sequence) { - return new EquatableProperty( - propertyName, - propertyType, - defaultComparer); + string? expression = propertySymbol.Type switch + { + INamedTypeSymbol namedType => BuildCollectionComparerExpression(namedType, comparerType.Value), + IArrayTypeSymbol arrayType => BuildArrayComparerExpression(arrayType), + _ => null + }; + if (expression != null) + return new EquatableProperty(propertyName, propertyType, ComparerTypes.Expression, ComparerExpression: expression); } - return new EquatableProperty( - propertyName, - propertyType, - comparerType.Value, - comparerName, - comparerInstance); + return new EquatableProperty(propertyName, propertyType, comparerType.Value, comparerName, comparerInstance); + } + + return new EquatableProperty(propertyName, propertyType, defaultComparer); + } + + // Returns a fully-qualified IEqualityComparer instance expression for a collection type + // when its element type is itself a collection (requires composition). + // Returns null when EqualityComparer.Default is sufficient (element is a plain type). + // Depth is unbounded — recursion terminates naturally when an element type is not a + // recognised collection interface (BuildElementComparerExpression returns null). + private static string? BuildCollectionComparerExpression(INamedTypeSymbol collectionType, ComparerTypes kind) + { + // unwrap nullable wrapper + var unwrapped = collectionType.IsGenericType + && collectionType.OriginalDefinition.SpecialType == SpecialType.None + && collectionType.Name == "Nullable" + ? collectionType.TypeArguments[0] as INamedTypeSymbol ?? collectionType + : collectionType; + + // find the collection interface and extract element type(s) + INamedTypeSymbol? dictInterface = IsDictionary(unwrapped) ? unwrapped + : unwrapped.AllInterfaces.FirstOrDefault(IsDictionary); + + INamedTypeSymbol? enumInterface = IsEnumerable(unwrapped) ? unwrapped + : unwrapped.AllInterfaces.FirstOrDefault(IsEnumerable); + + if (kind == ComparerTypes.Dictionary && dictInterface != null) + { + var keyType = dictInterface.TypeArguments[0]; + var valueType = dictInterface.TypeArguments[1]; + + var keyExpr = BuildElementComparerExpression(keyType); + var valueExpr = BuildElementComparerExpression(valueType); + + // only compose if at least one argument needs a non-default comparer + if (keyExpr == null && valueExpr == null) + return null; + + var keyTypeFq = keyType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + var valueTypeFq = valueType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + keyExpr ??= $"global::System.Collections.Generic.EqualityComparer<{keyTypeFq}>.Default"; + valueExpr ??= $"global::System.Collections.Generic.EqualityComparer<{valueTypeFq}>.Default"; + + var isReadOnly = IsReadOnlyDictionary(unwrapped) || unwrapped.AllInterfaces.Any(IsReadOnlyDictionary); + var comparerClass = isReadOnly + ? "global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer" + : "global::Equatable.Comparers.DictionaryEqualityComparer"; + + return $"new {comparerClass}<{keyTypeFq}, {valueTypeFq}>({keyExpr}, {valueExpr})"; + } + + if ((kind == ComparerTypes.HashSet || kind == ComparerTypes.Sequence) && enumInterface != null) + { + var elementType = enumInterface.TypeArguments[0]; + var elementExpr = BuildElementComparerExpression(elementType); + + if (elementExpr == null) + return null; + + var elementTypeFq = elementType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + var comparerClass = kind == ComparerTypes.HashSet + ? "global::Equatable.Comparers.HashSetEqualityComparer" + : "global::Equatable.Comparers.SequenceEqualityComparer"; + + return $"new {comparerClass}<{elementTypeFq}>({elementExpr})"; } - return new EquatableProperty( - propertyName, - propertyType, - defaultComparer); + return null; + } + + // Returns a comparer expression for a single element type, or null if EqualityComparer.Default suffices. + // Terminates naturally when elementType is not a recognised collection interface. + // visited guards against self-referential types (e.g. class A : IEnumerable). + private static string? BuildElementComparerExpression(ITypeSymbol elementType, HashSet? visited = null) + { + if (elementType is IArrayTypeSymbol arrayType) + return BuildArrayComparerExpression(arrayType); + + if (elementType is not INamedTypeSymbol named) + return null; + + // string implements IEnumerable but must use default equality, not SequenceEqualityComparer + if (IsString(named)) + return null; + + visited ??= new HashSet(SymbolEqualityComparer.Default); + if (!visited.Add(elementType)) + return null; // cycle detected + + // always scan AllInterfaces — covers concrete types (List, HashSet, Dictionary, etc.) + // dictionary check takes priority over enumerable + var asDictInterface = IsDictionary(named) ? named + : named.AllInterfaces.FirstOrDefault(IsDictionary); + + if (asDictInterface != null) + { + var isReadOnly = IsReadOnlyDictionary(named) + || named.AllInterfaces.Any(IsReadOnlyDictionary); + return BuildDictComparerExpression(asDictInterface, isReadOnly, visited); + } + + var asEnumInterface = IsEnumerable(named) ? named + : named.AllInterfaces.FirstOrDefault(IsEnumerable); + + if (asEnumInterface != null) + { + var innerType = asEnumInterface.TypeArguments[0]; + var innerExpr = BuildElementComparerExpression(innerType, visited); + var innerTypeFq = innerType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + + var isSet = named.AllInterfaces.Any(i => i is { Name: "ISet" or "IReadOnlySet", IsGenericType: true }) + || named is { Name: "ISet" or "IReadOnlySet", IsGenericType: true }; + var comparerClass = isSet + ? "global::Equatable.Comparers.HashSetEqualityComparer" + : "global::Equatable.Comparers.SequenceEqualityComparer"; + + if (innerExpr != null) + return $"new {comparerClass}<{innerTypeFq}>({innerExpr})"; + + return $"{comparerClass}<{innerTypeFq}>.Default"; + } + + return null; + } + + private static string BuildDictComparerExpression(INamedTypeSymbol dictInterface, bool isReadOnly, HashSet? visited = null) + { + var keyType = dictInterface.TypeArguments[0]; + var valueType = dictInterface.TypeArguments[1]; + var keyTypeFq = keyType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + var valueTypeFq = valueType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + + var keyExpr = BuildElementComparerExpression(keyType, visited) + ?? $"global::System.Collections.Generic.EqualityComparer<{keyTypeFq}>.Default"; + var valueExpr = BuildElementComparerExpression(valueType, visited) + ?? $"global::System.Collections.Generic.EqualityComparer<{valueTypeFq}>.Default"; + + var comparerClass = isReadOnly + ? "global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer" + : "global::Equatable.Comparers.DictionaryEqualityComparer"; + + return $"new {comparerClass}<{keyTypeFq}, {valueTypeFq}>({keyExpr}, {valueExpr})"; + } + + private static string BuildArrayComparerExpression(IArrayTypeSymbol arrayType) + { + var elementType = arrayType.ElementType; + var elementTypeFq = elementType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + var innerExpr = BuildElementComparerExpression(elementType); + + if (arrayType.Rank > 1) + { + // Multi-dimensional arrays don't implement IEnumerable; use the dedicated comparer + // that iterates via Array.GetEnumerator() with no LINQ or intermediate allocations. + if (innerExpr != null) + return $"new global::Equatable.Comparers.MultiDimensionalArrayEqualityComparer<{elementTypeFq}>({innerExpr})"; + return $"global::Equatable.Comparers.MultiDimensionalArrayEqualityComparer<{elementTypeFq}>.Default"; + } + + if (innerExpr != null) + return $"new global::Equatable.Comparers.SequenceEqualityComparer<{elementTypeFq}>({innerExpr})"; + return $"global::Equatable.Comparers.SequenceEqualityComparer<{elementTypeFq}>.Default"; + } + + private static bool IsReadOnlyDictionary(INamedTypeSymbol targetSymbol) + { + return targetSymbol is + { + Name: "IReadOnlyDictionary", + IsGenericType: true, + TypeArguments.Length: 2, + ContainingNamespace: + { + Name: "Generic", + ContainingNamespace: + { + Name: "Collections", + ContainingNamespace.Name: "System" + } + } + }; } private static bool ValidateComparer(IPropertySymbol propertySymbol, ComparerTypes? comparerType) { // don't need to validate these types - if (comparerType is null or ComparerTypes.Default or ComparerTypes.Reference or ComparerTypes.Custom) + if (comparerType is null or ComparerTypes.Default or ComparerTypes.Reference or ComparerTypes.Custom or ComparerTypes.Expression) return true; if (comparerType == ComparerTypes.String) return IsString(propertySymbol.Type); if (comparerType == ComparerTypes.Dictionary) - return propertySymbol.Type.AllInterfaces.Any(IsDictionary); + return (propertySymbol.Type is INamedTypeSymbol nt && IsDictionary(nt)) + || propertySymbol.Type.AllInterfaces.Any(IsDictionary); if (comparerType == ComparerTypes.HashSet) - return propertySymbol.Type.AllInterfaces.Any(IsEnumerable); + return (propertySymbol.Type is INamedTypeSymbol ntHs && IsEnumerable(ntHs)) + || propertySymbol.Type.AllInterfaces.Any(IsEnumerable); if (comparerType == ComparerTypes.Sequence) - return propertySymbol.Type.AllInterfaces.Any(IsEnumerable); + return propertySymbol.Type is IArrayTypeSymbol + || (propertySymbol.Type is INamedTypeSymbol ntSeq && IsEnumerable(ntSeq)) + || propertySymbol.Type.AllInterfaces.Any(IsEnumerable); return true; } @@ -309,10 +485,18 @@ private static bool IsIncludedDataContract(IPropertySymbol propertySymbol) if (attributes.Length == 0) return false; - if (attributes.Any(a => a.AttributeClass is { Name: "IgnoreDataMemberAttribute", ContainingNamespace.Name: "Serialization" })) + if (attributes.Any(a => a.AttributeClass is + { + Name: "IgnoreDataMemberAttribute", + ContainingNamespace: { Name: "Serialization", ContainingNamespace: { Name: "Runtime", ContainingNamespace.Name: "System" } } + })) return false; - return attributes.Any(a => a.AttributeClass is { Name: "DataMemberAttribute", ContainingNamespace.Name: "Serialization" }); + return attributes.Any(a => a.AttributeClass is + { + Name: "DataMemberAttribute", + ContainingNamespace: { Name: "Serialization", ContainingNamespace: { Name: "Runtime", ContainingNamespace.Name: "System" } } + }); } private static bool IsIncludedMessagePack(IPropertySymbol propertySymbol) @@ -369,10 +553,7 @@ private static bool IsEnumerable(INamedTypeSymbol targetSymbol) ContainingNamespace: { Name: "Collections", - ContainingNamespace: - { - Name: "System" - } + ContainingNamespace.Name: "System" } } }; @@ -392,10 +573,7 @@ private static bool IsDictionary(INamedTypeSymbol targetSymbol) ContainingNamespace: { Name: "Collections", - ContainingNamespace: - { - Name: "System" - } + ContainingNamespace.Name: "System" } } }; @@ -523,7 +701,8 @@ private static EquatableArray GetContainingTypes(INamedTypeSymb return null; var attributes = currentSymbol.GetAttributes(); - if (attributes.Length > 0 && attributes.Any(a => IsKnownAttribute(a) && a.AttributeClass?.Name == "EquatableAttribute")) + if (attributes.Length > 0 && attributes.Any(a => IsKnownAttribute(a) && a.AttributeClass?.Name is + "EquatableAttribute" or "DataContractEquatableAttribute" or "MessagePackEquatableAttribute")) { return currentSymbol; } diff --git a/src/Equatable.SourceGenerator/EquatableWriter.cs b/src/Equatable.SourceGenerator/EquatableWriter.cs index 2c55d05..9507445 100644 --- a/src/Equatable.SourceGenerator/EquatableWriter.cs +++ b/src/Equatable.SourceGenerator/EquatableWriter.cs @@ -187,6 +187,17 @@ private static void GenerateEquatable(IndentedStringBuilder codeBuilder, Equatab .Append(entityProperty.PropertyName) .Append(")"); + break; + case ComparerTypes.Expression: + codeBuilder + .Append(" (") + .Append(entityProperty.ComparerExpression) + .Append(").Equals(") + .Append(entityProperty.PropertyName) + .Append(", other.") + .Append(entityProperty.PropertyName) + .Append(")"); + break; case ComparerTypes.ValueType: codeBuilder @@ -488,6 +499,14 @@ private static void GenerateHashCode(IndentedStringBuilder codeBuilder, Equatabl .Append(entityProperty.PropertyName) .AppendLine("!);"); break; + case ComparerTypes.Expression: + codeBuilder + .Append("hashCode = (hashCode * -1521134295) + (") + .Append(entityProperty.ComparerExpression) + .Append(").GetHashCode(") + .Append(entityProperty.PropertyName) + .AppendLine("!);"); + break; case ComparerTypes.ValueType: codeBuilder .Append("hashCode = (hashCode * -1521134295) + ") diff --git a/src/Equatable.SourceGenerator/Models/ComparerTypes.cs b/src/Equatable.SourceGenerator/Models/ComparerTypes.cs index c27caa5..8839db2 100644 --- a/src/Equatable.SourceGenerator/Models/ComparerTypes.cs +++ b/src/Equatable.SourceGenerator/Models/ComparerTypes.cs @@ -9,5 +9,7 @@ public enum ComparerTypes Sequence, String, ValueType, - Custom + Custom, + // a fully-composed IEqualityComparer expression built by the generator for nested collections + Expression } diff --git a/src/Equatable.SourceGenerator/Models/EquatableProperty.cs b/src/Equatable.SourceGenerator/Models/EquatableProperty.cs index 652a353..7c7a753 100644 --- a/src/Equatable.SourceGenerator/Models/EquatableProperty.cs +++ b/src/Equatable.SourceGenerator/Models/EquatableProperty.cs @@ -5,4 +5,6 @@ public record EquatableProperty( string PropertyType, ComparerTypes ComparerType = ComparerTypes.Default, string? ComparerName = null, - string? ComparerInstance = null); + string? ComparerInstance = null, + // fully-composed comparer instance expression for ComparerTypes.Expression + string? ComparerExpression = null); diff --git a/test/Equatable.Entities/Equatable.Entities.csproj b/test/Equatable.Entities/Equatable.Entities.csproj index 3ecf986..41354ae 100644 --- a/test/Equatable.Entities/Equatable.Entities.csproj +++ b/test/Equatable.Entities/Equatable.Entities.csproj @@ -12,10 +12,12 @@ + + Analyzer diff --git a/test/Equatable.Entities/LookupTable.cs b/test/Equatable.Entities/LookupTable.cs new file mode 100644 index 0000000..eabb54e --- /dev/null +++ b/test/Equatable.Entities/LookupTable.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; + +using Equatable.Attributes; + +namespace Equatable.Entities; + +[Equatable] +public partial class LookupTable +{ + [DictionaryEquality] + public IReadOnlyDictionary? FlatEntries { get; set; } + + // nested: generator auto-composes ReadOnlyDictionaryEqualityComparer for the value type + [DictionaryEquality] + public IReadOnlyDictionary>? NestedEntries { get; set; } +} diff --git a/test/Equatable.Entities/MarketInputs.cs b/test/Equatable.Entities/MarketInputs.cs deleted file mode 100644 index c2a54b9..0000000 --- a/test/Equatable.Entities/MarketInputs.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System.Collections.Generic; -using System.Runtime.Serialization; -using Equatable.Attributes; -using Equatable.Comparers; - -namespace Equatable.Entities; - -[Equatable] -public partial class MarketInputs -{ - [DictionaryEquality] - public IReadOnlyDictionary? FlatInputs { get; set; } - - [EqualityComparer(typeof(ReadOnlyDictionaryEqualityComparer>))] - public IReadOnlyDictionary>? NestedInputs { get; set; } -} diff --git a/test/Equatable.Entities/NestedCollections.cs b/test/Equatable.Entities/NestedCollections.cs new file mode 100644 index 0000000..6df2523 --- /dev/null +++ b/test/Equatable.Entities/NestedCollections.cs @@ -0,0 +1,94 @@ +using System.Collections.Generic; +using Equatable.Attributes; + +namespace Equatable.Entities; + +/// +/// Covers the cross-product of outer × inner collection shapes for auto-composed comparers. +/// All combinations up to 3 levels of nesting. +/// +[Equatable] +public partial class NestedCollections +{ + // ── 2-level: Dict outer ──────────────────────────────────────────────── + + // Dict> + [DictionaryEquality] + public Dictionary>? DictOfLists { get; set; } + + // Dict> + [DictionaryEquality] + public Dictionary>? DictOfSets { get; set; } + + // Dict> + [DictionaryEquality] + public Dictionary>? DictOfDicts { get; set; } + + // ── 2-level: List outer ──────────────────────────────────────────────── + + // List> + [SequenceEquality] + public List>? ListOfDicts { get; set; } + + // List> + [SequenceEquality] + public List>? ListOfSets { get; set; } + + // List> + [SequenceEquality] + public List>? ListOfLists { get; set; } + + // ── 2-level: HashSet outer ──────────────────────────────────────────── + + // HashSet> — set of sequences (unusual but valid) + [HashSetEquality] + public HashSet>? SetOfLists { get; set; } + + // HashSet> + [HashSetEquality] + public HashSet>? SetOfDicts { get; set; } + + // ── 3-level ──────────────────────────────────────────────────────────── + + // Dict>> + [DictionaryEquality] + public Dictionary>>? ThreeLevelNested { get; set; } + + // Dict>> + [DictionaryEquality] + public Dictionary>>? DictOfListOfSets { get; set; } + + // Dict>> + [DictionaryEquality] + public Dictionary>>? DictOfListOfDicts { get; set; } + + // List>> + [SequenceEquality] + public List>>? ListOfDictOfLists { get; set; } + + // List>> + [SequenceEquality] + public List>>? ListOfDictOfSets { get; set; } + + // List>> + [SequenceEquality] + public List>>? ListOfListOfDicts { get; set; } + + // ── Arrays ───────────────────────────────────────────────────────────── + + // int[] — plain array + [SequenceEquality] + public int[]? FlatArray { get; set; } + + // int[][] — array of arrays + [SequenceEquality] + public int[][]? ArrayOfArrays { get; set; } + + // Dictionary[] — array of dicts + [SequenceEquality] + public Dictionary[]? ArrayOfDicts { get; set; } + + // int[][] as a value in a dict + [DictionaryEquality] + public Dictionary? DictOfArrays { get; set; } +} diff --git a/test/Equatable.Entities/SerializedRecord.cs b/test/Equatable.Entities/SerializedRecord.cs new file mode 100644 index 0000000..dd16d1e --- /dev/null +++ b/test/Equatable.Entities/SerializedRecord.cs @@ -0,0 +1,21 @@ +using Equatable.Attributes; +using MessagePack; + +namespace Equatable.Entities; + +[MessagePackEquatable] +[MessagePackObject] +public partial class SerializedRecord +{ + [Key(0)] + public int Id { get; set; } + + [Key(1)] + public double Score { get; set; } + + [IgnoreMember] + public string? Metadata { get; set; } + + // not included — no [Key] + public string? Extra { get; set; } +} diff --git a/test/Equatable.Generator.Properties.Tests/Equatable.Generator.Properties.Tests.csproj b/test/Equatable.Generator.Properties.Tests/Equatable.Generator.Properties.Tests.csproj new file mode 100644 index 0000000..00cba3a --- /dev/null +++ b/test/Equatable.Generator.Properties.Tests/Equatable.Generator.Properties.Tests.csproj @@ -0,0 +1,38 @@ + + + + net10.0 + enable + enable + latest + false + true + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + Analyzer + true + + + + + + + + + + + + diff --git a/test/Equatable.Generator.Properties.Tests/Properties/DictionaryComparerProperties.cs b/test/Equatable.Generator.Properties.Tests/Properties/DictionaryComparerProperties.cs new file mode 100644 index 0000000..5d14a6c --- /dev/null +++ b/test/Equatable.Generator.Properties.Tests/Properties/DictionaryComparerProperties.cs @@ -0,0 +1,47 @@ +using Equatable.Comparers; + +namespace Equatable.Generator.Tests.Properties; + +public class DictionaryComparerProperties +{ + private static readonly DictionaryEqualityComparer Comparer = DictionaryEqualityComparer.Default; + + [Property] + public Property Reflexivity(Dictionary dict) + { + return Comparer.Equals(dict, dict).ToProperty(); + } + + [Property] + public Property Symmetry(Dictionary x, Dictionary y) + { + return (Comparer.Equals(x, y) == Comparer.Equals(y, x)).ToProperty(); + } + + [Property] + public Property HashIsInsertionOrderIndependent(Dictionary dict) + { + var reversed = new Dictionary(dict.Reverse()); + return (Comparer.GetHashCode(dict) == Comparer.GetHashCode(reversed)).ToProperty(); + } + + [Property] + public Property EqualDictionariesHaveSameHashCode(Dictionary dict) + { + var copy = new Dictionary(dict); + return (Comparer.Equals(dict, copy) && Comparer.GetHashCode(dict) == Comparer.GetHashCode(copy)).ToProperty(); + } + + [Property] + public Property DifferentValueProducesDifferentHash(Dictionary dict, string key, int v1, int v2) + { + if (key == null || v1 == v2) + return true.ToProperty().When(true); + + var a = new Dictionary(dict) { [key] = v1 }; + var b = new Dictionary(dict) { [key] = v2 }; + + // different values must NOT be equal + return (!Comparer.Equals(a, b)).ToProperty(); + } +} diff --git a/test/Equatable.Generator.Properties.Tests/Properties/HashSetComparerProperties.cs b/test/Equatable.Generator.Properties.Tests/Properties/HashSetComparerProperties.cs new file mode 100644 index 0000000..ec04141 --- /dev/null +++ b/test/Equatable.Generator.Properties.Tests/Properties/HashSetComparerProperties.cs @@ -0,0 +1,57 @@ +using Equatable.Comparers; + +namespace Equatable.Generator.Tests.Properties; + +public class HashSetComparerProperties +{ + private static readonly HashSetEqualityComparer Comparer = HashSetEqualityComparer.Default; + + // FsCheck cannot auto-generate HashSet; use string[] and convert. + + [Property] + public Property Reflexivity(string[] items) + { + var set = new HashSet(items); + return Comparer.Equals(set, set).ToProperty(); + } + + [Property] + public Property Symmetry(string[] xs, string[] ys) + { + var x = new HashSet(xs); + var y = new HashSet(ys); + return (Comparer.Equals(x, y) == Comparer.Equals(y, x)).ToProperty(); + } + + [Property] + public Property HashIsInsertionOrderIndependent(string[] items) + { + var set = new HashSet(items); + var reversed = new HashSet(items.Reverse()); + return (Comparer.GetHashCode(set) == Comparer.GetHashCode(reversed)).ToProperty(); + } + + [Property] + public Property EqualSetsHaveSameHashCode(string[] items) + { + var set = new HashSet(items); + var copy = new HashSet(items); + return (Comparer.Equals(set, copy) && Comparer.GetHashCode(set) == Comparer.GetHashCode(copy)).ToProperty(); + } + + [Property] + public Property ExtraElementMakesNotEqual(string[] items, string extra) + { + if (extra == null) return true.ToProperty().When(true); + var set = new HashSet(items); + if (set.Contains(extra)) return true.ToProperty().When(true); + var bigger = new HashSet(set) { extra }; + return (!Comparer.Equals(set, bigger)).ToProperty(); + } + + [Property] + public Property NullEqualsNull() + { + return Comparer.Equals(null, null).ToProperty(); + } +} diff --git a/test/Equatable.Generator.Properties.Tests/Properties/LookupTableProperties.cs b/test/Equatable.Generator.Properties.Tests/Properties/LookupTableProperties.cs new file mode 100644 index 0000000..33adf7d --- /dev/null +++ b/test/Equatable.Generator.Properties.Tests/Properties/LookupTableProperties.cs @@ -0,0 +1,165 @@ +using Equatable.Entities; + +namespace Equatable.Generator.Tests.Properties; + +/// +/// Property-based tests for [DictionaryEquality] on IReadOnlyDictionary, +/// including auto-composed nested collection comparers. +/// +public class LookupTableProperties +{ + // --- FlatEntries: IReadOnlyDictionary --- + + [Property] + public Property FlatEntries_Reflexivity(Dictionary dict) + { + var t = new LookupTable { FlatEntries = dict }; + return t.Equals(t).ToProperty(); + } + + [Property] + public Property FlatEntries_Symmetry(Dictionary x, Dictionary y) + { + var a = new LookupTable { FlatEntries = x }; + var b = new LookupTable { FlatEntries = y }; + return (a.Equals(b) == b.Equals(a)).ToProperty(); + } + + [Property] + public Property FlatEntries_InsertionOrderDoesNotMatter(Dictionary dict) + { + var a = new LookupTable { FlatEntries = dict }; + var b = new LookupTable { FlatEntries = new Dictionary(dict.Reverse()) }; + return a.Equals(b).ToProperty(); + } + + [Property] + public Property FlatEntries_HashIsInsertionOrderIndependent(Dictionary dict) + { + var a = new LookupTable { FlatEntries = dict }; + var b = new LookupTable { FlatEntries = new Dictionary(dict.Reverse()) }; + return (a.GetHashCode() == b.GetHashCode()).ToProperty(); + } + + [Property] + public Property FlatEntries_EqualImpliesSameHashCode(Dictionary dict) + { + var a = new LookupTable { FlatEntries = dict }; + var b = new LookupTable { FlatEntries = new Dictionary(dict) }; + return (a.Equals(b) && a.GetHashCode() == b.GetHashCode()).ToProperty(); + } + + [Property] + public Property FlatEntries_NullBothSidesEqual() + { + var a = new LookupTable { FlatEntries = null }; + var b = new LookupTable { FlatEntries = null }; + return a.Equals(b).ToProperty(); + } + + [Property] + public Property FlatEntries_NullOneNotEqual(Dictionary dict) + { + if (dict.Count == 0) + return true.ToProperty().When(true); + + var a = new LookupTable { FlatEntries = dict }; + var b = new LookupTable { FlatEntries = null }; + return (!a.Equals(b) && !b.Equals(a)).ToProperty(); + } + + [Property] + public Property FlatEntries_DifferentValueNotEqual(string key, double v1, double v2) + { + if (key == null || Math.Abs(v1 - v2) < double.Epsilon || double.IsNaN(v1) || double.IsNaN(v2)) + return true.ToProperty().When(true); + + var a = new LookupTable { FlatEntries = new Dictionary { [key] = v1 } }; + var b = new LookupTable { FlatEntries = new Dictionary { [key] = v2 } }; + return (!a.Equals(b)).ToProperty(); + } + + // --- NestedEntries: IReadOnlyDictionary> + // auto-composed comparer: no manual [EqualityComparer] needed --- + + [Property] + public Property NestedEntries_Reflexivity(Dictionary> raw) + { + var t = new LookupTable { NestedEntries = ToNested(raw) }; + return t.Equals(t).ToProperty(); + } + + [Property] + public Property NestedEntries_Symmetry( + Dictionary> x, + Dictionary> y) + { + var a = new LookupTable { NestedEntries = ToNested(x) }; + var b = new LookupTable { NestedEntries = ToNested(y) }; + return (a.Equals(b) == b.Equals(a)).ToProperty(); + } + + [Property] + public Property NestedEntries_OuterInsertionOrderDoesNotMatter(Dictionary> raw) + { + var a = new LookupTable { NestedEntries = ToNested(raw) }; + var b = new LookupTable { NestedEntries = ToNested(raw.Reverse().ToDictionary(kv => kv.Key, kv => kv.Value)) }; + return a.Equals(b).ToProperty(); + } + + [Property] + public Property NestedEntries_InnerInsertionOrderDoesNotMatter(Dictionary> raw) + { + // reverse the inner dictionary for each entry + var reversed = raw.ToDictionary( + kv => kv.Key, + kv => kv.Value.Reverse().ToDictionary(p => p.Key, p => p.Value)); + + var a = new LookupTable { NestedEntries = ToNested(raw) }; + var b = new LookupTable { NestedEntries = ToNested(reversed) }; + return a.Equals(b).ToProperty(); + } + + [Property] + public Property NestedEntries_EqualImpliesSameHashCode(Dictionary> raw) + { + var a = new LookupTable { NestedEntries = ToNested(raw) }; + var b = new LookupTable { NestedEntries = ToNested(raw.ToDictionary(kv => kv.Key, kv => kv.Value)) }; + return (a.Equals(b) && a.GetHashCode() == b.GetHashCode()).ToProperty(); + } + + [Property] + public Property NestedEntries_HashIsOuterInsertionOrderIndependent(Dictionary> raw) + { + var a = new LookupTable { NestedEntries = ToNested(raw) }; + var b = new LookupTable { NestedEntries = ToNested(raw.Reverse().ToDictionary(kv => kv.Key, kv => kv.Value)) }; + return (a.GetHashCode() == b.GetHashCode()).ToProperty(); + } + + [Property] + public Property NestedEntries_DifferentInnerValueNotEqual(string outerKey, string innerKey, double v1, double v2) + { + if (outerKey == null || innerKey == null || Math.Abs(v1 - v2) < double.Epsilon || double.IsNaN(v1) || double.IsNaN(v2)) + return true.ToProperty().When(true); + + var a = new LookupTable + { + NestedEntries = new Dictionary> + { + [outerKey] = new Dictionary { [innerKey] = v1 } + } + }; + var b = new LookupTable + { + NestedEntries = new Dictionary> + { + [outerKey] = new Dictionary { [innerKey] = v2 } + } + }; + return (!a.Equals(b)).ToProperty(); + } + + private static IReadOnlyDictionary> ToNested( + Dictionary> raw) + => raw.ToDictionary(kv => kv.Key, kv => (IReadOnlyDictionary)kv.Value); +} diff --git a/test/Equatable.Generator.Properties.Tests/Properties/NestedCollectionsProperties.cs b/test/Equatable.Generator.Properties.Tests/Properties/NestedCollectionsProperties.cs new file mode 100644 index 0000000..e854f39 --- /dev/null +++ b/test/Equatable.Generator.Properties.Tests/Properties/NestedCollectionsProperties.cs @@ -0,0 +1,724 @@ +using Equatable.Entities; + +namespace Equatable.Generator.Tests.Properties; + +/// +/// Property-based tests for auto-composed nested collection comparers. +/// Covers all meaningful 2-level and 3-level combinations of Dict / List / HashSet. +/// Convention per shape: +/// - Dict outer → insertion order must not matter +/// - List outer → insertion order MUST matter +/// - HashSet outer → element order must not matter +/// - List/Sequence inner → element order matters +/// - Dict/HashSet inner → element order does not matter +/// +public class NestedCollectionsProperties +{ + // ══════════════════════════════════════════════════════════════════════ + // 2-level: Dict> + // ══════════════════════════════════════════════════════════════════════ + + [Property] + public Property DictOfLists_EqualWhenSameContent(Dictionary> raw) + { + var a = new NestedCollections { DictOfLists = raw }; + var b = new NestedCollections { DictOfLists = raw.ToDictionary(kv => kv.Key, kv => new List(kv.Value)) }; + return a.Equals(b).ToProperty(); + } + + [Property] + public Property DictOfLists_OuterInsertionOrderDoesNotMatter(Dictionary> raw) + { + var a = new NestedCollections { DictOfLists = raw }; + var b = new NestedCollections { DictOfLists = raw.Reverse().ToDictionary(kv => kv.Key, kv => kv.Value) }; + return a.Equals(b).ToProperty(); + } + + [Property] + public Property DictOfLists_HashIsInsertionOrderIndependent(Dictionary> raw) + { + var a = new NestedCollections { DictOfLists = raw }; + var b = new NestedCollections { DictOfLists = raw.Reverse().ToDictionary(kv => kv.Key, kv => kv.Value) }; + return (a.GetHashCode() == b.GetHashCode()).ToProperty(); + } + + [Property] + public Property DictOfLists_InnerOrderMatters(string key, int v1, int v2) + { + if (key == null || v1 == v2) return true.ToProperty().When(true); + var a = new NestedCollections { DictOfLists = new Dictionary> { [key] = [v1, v2] } }; + var b = new NestedCollections { DictOfLists = new Dictionary> { [key] = [v2, v1] } }; + return (!a.Equals(b)).ToProperty(); + } + + [Property] + public Property DictOfLists_EqualImpliesSameHash(Dictionary> raw) + { + var a = new NestedCollections { DictOfLists = raw }; + var b = new NestedCollections { DictOfLists = raw.ToDictionary(kv => kv.Key, kv => new List(kv.Value)) }; + return (a.Equals(b) && a.GetHashCode() == b.GetHashCode()).ToProperty(); + } + + // ══════════════════════════════════════════════════════════════════════ + // 2-level: Dict> + // ══════════════════════════════════════════════════════════════════════ + + // FsCheck cannot generate HashSet; use Dictionary and convert values. + + [Property] + public Property DictOfSets_EqualWhenSameContent(Dictionary raw) + { + var a = new NestedCollections { DictOfSets = raw.ToDictionary(kv => kv.Key, kv => new HashSet(kv.Value)) }; + var b = new NestedCollections { DictOfSets = raw.ToDictionary(kv => kv.Key, kv => new HashSet(kv.Value)) }; + return a.Equals(b).ToProperty(); + } + + [Property] + public Property DictOfSets_OuterInsertionOrderDoesNotMatter(Dictionary raw) + { + var sets = raw.ToDictionary(kv => kv.Key, kv => new HashSet(kv.Value)); + var a = new NestedCollections { DictOfSets = sets }; + var b = new NestedCollections { DictOfSets = sets.Reverse().ToDictionary(kv => kv.Key, kv => kv.Value) }; + return a.Equals(b).ToProperty(); + } + + [Property] + public Property DictOfSets_InnerOrderDoesNotMatter(string key, int v1, int v2) + { + if (key == null) return true.ToProperty().When(true); + // HashSet — insertion order must not matter (even if values differ) + var s1 = new HashSet { v1, v2 }; + var s2 = new HashSet { v2, v1 }; + var a = new NestedCollections { DictOfSets = new Dictionary> { [key] = s1 } }; + var b = new NestedCollections { DictOfSets = new Dictionary> { [key] = s2 } }; + return a.Equals(b).ToProperty(); + } + + [Property] + public Property DictOfSets_HashIsInsertionOrderIndependent(Dictionary raw) + { + var sets = raw.ToDictionary(kv => kv.Key, kv => new HashSet(kv.Value)); + var a = new NestedCollections { DictOfSets = sets }; + var b = new NestedCollections { DictOfSets = sets.Reverse().ToDictionary(kv => kv.Key, kv => kv.Value) }; + return (a.GetHashCode() == b.GetHashCode()).ToProperty(); + } + + // ══════════════════════════════════════════════════════════════════════ + // 2-level: Dict> + // ══════════════════════════════════════════════════════════════════════ + + [Property] + public Property DictOfDicts_EqualWhenSameContent(Dictionary> raw) + { + var a = new NestedCollections { DictOfDicts = raw }; + var b = new NestedCollections { DictOfDicts = raw.ToDictionary(kv => kv.Key, kv => new Dictionary(kv.Value)) }; + return a.Equals(b).ToProperty(); + } + + [Property] + public Property DictOfDicts_OuterInsertionOrderDoesNotMatter(Dictionary> raw) + { + var a = new NestedCollections { DictOfDicts = raw }; + var b = new NestedCollections { DictOfDicts = raw.Reverse().ToDictionary(kv => kv.Key, kv => kv.Value) }; + return a.Equals(b).ToProperty(); + } + + [Property] + public Property DictOfDicts_InnerInsertionOrderDoesNotMatter(Dictionary> raw) + { + var reversed = raw.ToDictionary(kv => kv.Key, kv => kv.Value.Reverse().ToDictionary(p => p.Key, p => p.Value)); + var a = new NestedCollections { DictOfDicts = raw }; + var b = new NestedCollections { DictOfDicts = reversed }; + return a.Equals(b).ToProperty(); + } + + [Property] + public Property DictOfDicts_EqualImpliesSameHash(Dictionary> raw) + { + var a = new NestedCollections { DictOfDicts = raw }; + var b = new NestedCollections { DictOfDicts = raw.ToDictionary(kv => kv.Key, kv => new Dictionary(kv.Value)) }; + return (a.Equals(b) && a.GetHashCode() == b.GetHashCode()).ToProperty(); + } + + // ══════════════════════════════════════════════════════════════════════ + // 2-level: List> + // ══════════════════════════════════════════════════════════════════════ + + [Property] + public Property ListOfDicts_EqualWhenSameContent(List> items) + { + var a = new NestedCollections { ListOfDicts = items }; + var b = new NestedCollections { ListOfDicts = items.Select(d => new Dictionary(d)).ToList() }; + return a.Equals(b).ToProperty(); + } + + [Property] + public Property ListOfDicts_InnerInsertionOrderDoesNotMatter(List> items) + { + var reversed = items.Select(d => d.Reverse().ToDictionary(kv => kv.Key, kv => kv.Value)).ToList(); + var a = new NestedCollections { ListOfDicts = items }; + var b = new NestedCollections { ListOfDicts = reversed }; + return a.Equals(b).ToProperty(); + } + + [Property] + public Property ListOfDicts_OuterOrderMatters(Dictionary d1, Dictionary d2) + { + if (d1.SequenceEqual(d2)) return true.ToProperty().When(true); + var a = new NestedCollections { ListOfDicts = [d1, d2] }; + var b = new NestedCollections { ListOfDicts = [d2, d1] }; + return (!a.Equals(b)).ToProperty(); + } + + [Property] + public Property ListOfDicts_EqualImpliesSameHash(List> items) + { + var a = new NestedCollections { ListOfDicts = items }; + var b = new NestedCollections { ListOfDicts = items.Select(d => new Dictionary(d)).ToList() }; + return (a.Equals(b) && a.GetHashCode() == b.GetHashCode()).ToProperty(); + } + + // ══════════════════════════════════════════════════════════════════════ + // 2-level: List> + // ══════════════════════════════════════════════════════════════════════ + + // FsCheck cannot generate HashSet; use int[][] and convert each inner array to HashSet. + + [Property] + public Property ListOfSets_EqualWhenSameContent(int[][] raw) + { + var a = new NestedCollections { ListOfSets = raw.Select(x => new HashSet(x)).ToList() }; + var b = new NestedCollections { ListOfSets = raw.Select(x => new HashSet(x)).ToList() }; + return a.Equals(b).ToProperty(); + } + + [Property] + public Property ListOfSets_InnerOrderDoesNotMatter(int[][] raw) + { + var a = new NestedCollections { ListOfSets = raw.Select(x => new HashSet(x)).ToList() }; + var b = new NestedCollections { ListOfSets = raw.Select(x => new HashSet(x.Reverse())).ToList() }; + return a.Equals(b).ToProperty(); + } + + [Property] + public Property ListOfSets_OuterOrderMatters(int[] xs, int[] ys) + { + var s1 = new HashSet(xs); + var s2 = new HashSet(ys); + // two distinct non-equal sets — swapping them must break equality + if (s1.SetEquals(s2)) return true.ToProperty().When(true); + var a = new NestedCollections { ListOfSets = [s1, s2] }; + var b = new NestedCollections { ListOfSets = [s2, s1] }; + return (!a.Equals(b)).ToProperty(); + } + + // ══════════════════════════════════════════════════════════════════════ + // 2-level: List> + // ══════════════════════════════════════════════════════════════════════ + + [Property] + public Property ListOfLists_EqualWhenSameContent(List> items) + { + var a = new NestedCollections { ListOfLists = items }; + var b = new NestedCollections { ListOfLists = items.Select(l => new List(l)).ToList() }; + return a.Equals(b).ToProperty(); + } + + [Property] + public Property ListOfLists_OuterOrderMatters(List l1, List l2) + { + if (l1.SequenceEqual(l2)) return true.ToProperty().When(true); + var a = new NestedCollections { ListOfLists = [l1, l2] }; + var b = new NestedCollections { ListOfLists = [l2, l1] }; + return (!a.Equals(b)).ToProperty(); + } + + [Property] + public Property ListOfLists_InnerOrderMatters(string outerTag, int v1, int v2) + { + // inner lists are order-sensitive + if (v1 == v2) return true.ToProperty().When(true); + var a = new NestedCollections { ListOfLists = [[v1, v2]] }; + var b = new NestedCollections { ListOfLists = [[v2, v1]] }; + return (!a.Equals(b)).ToProperty(); + } + + [Property] + public Property ListOfLists_EqualImpliesSameHash(List> items) + { + var a = new NestedCollections { ListOfLists = items }; + var b = new NestedCollections { ListOfLists = items.Select(l => new List(l)).ToList() }; + return (a.Equals(b) && a.GetHashCode() == b.GetHashCode()).ToProperty(); + } + + // ══════════════════════════════════════════════════════════════════════ + // 2-level: HashSet> + // ══════════════════════════════════════════════════════════════════════ + + // Note: HashSet> uses reference equality for List elements (List does not implement + // IEquatable). Two distinct List instances with the same content are NOT equal in a HashSet. + // The [HashSetEquality] property correctly reflects this: same-reference sets are equal. + + [Property] + public Property SetOfLists_SameReferenceSetIsEqual(List l1, List l2) + { + var items = new List> { l1, l2 }; + var set = new HashSet>(items); + var a = new NestedCollections { SetOfLists = set }; + var b = new NestedCollections { SetOfLists = set }; // same reference + return a.Equals(b).ToProperty(); + } + + // ══════════════════════════════════════════════════════════════════════ + // 2-level: HashSet> + // ══════════════════════════════════════════════════════════════════════ + + // Same caveat: Dictionary uses reference equality inside a HashSet. + + [Property] + public Property SetOfDicts_SameReferenceSetIsEqual(Dictionary d1, Dictionary d2) + { + var set = new HashSet> { d1, d2 }; + var a = new NestedCollections { SetOfDicts = set }; + var b = new NestedCollections { SetOfDicts = set }; // same reference + return a.Equals(b).ToProperty(); + } + + // ══════════════════════════════════════════════════════════════════════ + // 3-level: Dict>> + // ══════════════════════════════════════════════════════════════════════ + + [Property] + public Property ThreeLevelNested_EqualWhenSameContent(Dictionary>> raw) + { + var copy = raw.ToDictionary(o => o.Key, o => o.Value.ToDictionary(i => i.Key, i => new List(i.Value))); + var a = new NestedCollections { ThreeLevelNested = raw }; + var b = new NestedCollections { ThreeLevelNested = copy }; + return a.Equals(b).ToProperty(); + } + + [Property] + public Property ThreeLevelNested_OuterInsertionOrderDoesNotMatter(Dictionary>> raw) + { + var a = new NestedCollections { ThreeLevelNested = raw }; + var b = new NestedCollections { ThreeLevelNested = raw.Reverse().ToDictionary(kv => kv.Key, kv => kv.Value) }; + return a.Equals(b).ToProperty(); + } + + [Property] + public Property ThreeLevelNested_MiddleInsertionOrderDoesNotMatter(Dictionary>> raw) + { + var reversed = raw.ToDictionary(o => o.Key, o => o.Value.Reverse().ToDictionary(kv => kv.Key, kv => kv.Value)); + var a = new NestedCollections { ThreeLevelNested = raw }; + var b = new NestedCollections { ThreeLevelNested = reversed }; + return a.Equals(b).ToProperty(); + } + + [Property] + public Property ThreeLevelNested_InnermostOrderMatters(string outerKey, string innerKey, int v1, int v2) + { + if (outerKey == null || innerKey == null || v1 == v2) return true.ToProperty().When(true); + var a = new NestedCollections + { + ThreeLevelNested = new Dictionary>> + { + [outerKey] = new Dictionary> { [innerKey] = [v1, v2] } + } + }; + var b = new NestedCollections + { + ThreeLevelNested = new Dictionary>> + { + [outerKey] = new Dictionary> { [innerKey] = [v2, v1] } + } + }; + return (!a.Equals(b)).ToProperty(); + } + + [Property] + public Property ThreeLevelNested_EqualImpliesSameHash(Dictionary>> raw) + { + var copy = raw.ToDictionary(o => o.Key, o => o.Value.ToDictionary(i => i.Key, i => new List(i.Value))); + var a = new NestedCollections { ThreeLevelNested = raw }; + var b = new NestedCollections { ThreeLevelNested = copy }; + return (a.Equals(b) && a.GetHashCode() == b.GetHashCode()).ToProperty(); + } + + // ══════════════════════════════════════════════════════════════════════ + // 3-level: Dict>> + // ══════════════════════════════════════════════════════════════════════ + + // FsCheck cannot generate HashSet; use Dictionary and convert. + + [Property] + public Property DictOfListOfSets_EqualWhenSameContent(Dictionary raw) + { + var a = new NestedCollections { DictOfListOfSets = raw.ToDictionary(kv => kv.Key, kv => kv.Value.Select(s => new HashSet(s)).ToList()) }; + var b = new NestedCollections { DictOfListOfSets = raw.ToDictionary(kv => kv.Key, kv => kv.Value.Select(s => new HashSet(s)).ToList()) }; + return a.Equals(b).ToProperty(); + } + + [Property] + public Property DictOfListOfSets_OuterInsertionOrderDoesNotMatter(Dictionary raw) + { + var sets = raw.ToDictionary(kv => kv.Key, kv => kv.Value.Select(s => new HashSet(s)).ToList()); + var a = new NestedCollections { DictOfListOfSets = sets }; + var b = new NestedCollections { DictOfListOfSets = sets.Reverse().ToDictionary(kv => kv.Key, kv => kv.Value) }; + return a.Equals(b).ToProperty(); + } + + [Property] + public Property DictOfListOfSets_MiddleOrderMatters(string key, int[] xs, int[] ys) + { + if (key == null) return true.ToProperty().When(true); + var s1 = new HashSet(xs); + var s2 = new HashSet(ys); + // middle is List — position matters + if (s1.SetEquals(s2)) return true.ToProperty().When(true); + var a = new NestedCollections { DictOfListOfSets = new Dictionary>> { [key] = [s1, s2] } }; + var b = new NestedCollections { DictOfListOfSets = new Dictionary>> { [key] = [s2, s1] } }; + return (!a.Equals(b)).ToProperty(); + } + + [Property] + public Property DictOfListOfSets_InnermostOrderDoesNotMatter(string key, int v1, int v2) + { + if (key == null) return true.ToProperty().When(true); + // innermost is HashSet — order must not matter + var a = new NestedCollections + { + DictOfListOfSets = new Dictionary>> + { [key] = [new HashSet { v1, v2 }] } + }; + var b = new NestedCollections + { + DictOfListOfSets = new Dictionary>> + { [key] = [new HashSet { v2, v1 }] } + }; + return a.Equals(b).ToProperty(); + } + + // ══════════════════════════════════════════════════════════════════════ + // 3-level: Dict>> + // ══════════════════════════════════════════════════════════════════════ + + [Property] + public Property DictOfListOfDicts_EqualWhenSameContent(Dictionary>> raw) + { + var copy = raw.ToDictionary( + kv => kv.Key, + kv => kv.Value.Select(d => new Dictionary(d)).ToList()); + var a = new NestedCollections { DictOfListOfDicts = raw }; + var b = new NestedCollections { DictOfListOfDicts = copy }; + return a.Equals(b).ToProperty(); + } + + [Property] + public Property DictOfListOfDicts_OuterInsertionOrderDoesNotMatter(Dictionary>> raw) + { + var a = new NestedCollections { DictOfListOfDicts = raw }; + var b = new NestedCollections { DictOfListOfDicts = raw.Reverse().ToDictionary(kv => kv.Key, kv => kv.Value) }; + return a.Equals(b).ToProperty(); + } + + [Property] + public Property DictOfListOfDicts_MiddleOrderMatters(string key, Dictionary d1, Dictionary d2) + { + if (key == null) return true.ToProperty().When(true); + // middle is List — position matters + if (d1.SequenceEqual(d2)) return true.ToProperty().When(true); + var a = new NestedCollections { DictOfListOfDicts = new Dictionary>> { [key] = [d1, d2] } }; + var b = new NestedCollections { DictOfListOfDicts = new Dictionary>> { [key] = [d2, d1] } }; + return (!a.Equals(b)).ToProperty(); + } + + [Property] + public Property DictOfListOfDicts_InnermostInsertionOrderDoesNotMatter(Dictionary>> raw) + { + var copy = raw.ToDictionary( + kv => kv.Key, + kv => kv.Value.Select(d => d.Reverse().ToDictionary(p => p.Key, p => p.Value)).ToList()); + var a = new NestedCollections { DictOfListOfDicts = raw }; + var b = new NestedCollections { DictOfListOfDicts = copy }; + return a.Equals(b).ToProperty(); + } + + // ══════════════════════════════════════════════════════════════════════ + // 3-level: List>> + // ══════════════════════════════════════════════════════════════════════ + + [Property] + public Property ListOfDictOfLists_EqualWhenSameContent(List>> items) + { + var copy = items.Select(d => d.ToDictionary(kv => kv.Key, kv => new List(kv.Value))).ToList(); + var a = new NestedCollections { ListOfDictOfLists = items }; + var b = new NestedCollections { ListOfDictOfLists = copy }; + return a.Equals(b).ToProperty(); + } + + [Property] + public Property ListOfDictOfLists_OuterOrderMatters(Dictionary> d1, Dictionary> d2) + { + // outer is List — position matters + Func>, Dictionary>, bool> sameContent = + (x, y) => x.Count == y.Count && x.All(kv => y.TryGetValue(kv.Key, out var v) && kv.Value.SequenceEqual(v)); + + if (sameContent(d1, d2)) return true.ToProperty().When(true); + var a = new NestedCollections { ListOfDictOfLists = [d1, d2] }; + var b = new NestedCollections { ListOfDictOfLists = [d2, d1] }; + return (!a.Equals(b)).ToProperty(); + } + + [Property] + public Property ListOfDictOfLists_MiddleInsertionOrderDoesNotMatter(List>> items) + { + var reversed = items.Select(d => d.Reverse().ToDictionary(kv => kv.Key, kv => kv.Value)).ToList(); + var a = new NestedCollections { ListOfDictOfLists = items }; + var b = new NestedCollections { ListOfDictOfLists = reversed }; + return a.Equals(b).ToProperty(); + } + + [Property] + public Property ListOfDictOfLists_InnermostOrderMatters(string key, int v1, int v2) + { + if (key == null || v1 == v2) return true.ToProperty().When(true); + var a = new NestedCollections { ListOfDictOfLists = [new Dictionary> { [key] = [v1, v2] }] }; + var b = new NestedCollections { ListOfDictOfLists = [new Dictionary> { [key] = [v2, v1] }] }; + return (!a.Equals(b)).ToProperty(); + } + + // ══════════════════════════════════════════════════════════════════════ + // 3-level: List>> + // ══════════════════════════════════════════════════════════════════════ + + // FsCheck cannot generate HashSet; use Dictionary and convert values to HashSet + + [Property] + public Property ListOfDictOfSets_EqualWhenSameContent(List> raw) + { + var items = raw.Select(d => d.ToDictionary(kv => kv.Key, kv => new HashSet(kv.Value))).ToList(); + var copy = raw.Select(d => d.ToDictionary(kv => kv.Key, kv => new HashSet(kv.Value))).ToList(); + var a = new NestedCollections { ListOfDictOfSets = items }; + var b = new NestedCollections { ListOfDictOfSets = copy }; + return a.Equals(b).ToProperty(); + } + + [Property] + public Property ListOfDictOfSets_OuterOrderMatters(Dictionary raw1, Dictionary raw2) + { + var d1 = raw1.ToDictionary(kv => kv.Key, kv => new HashSet(kv.Value)); + var d2 = raw2.ToDictionary(kv => kv.Key, kv => new HashSet(kv.Value)); + Func>, Dictionary>, bool> sameContent = + (x, y) => x.Count == y.Count && x.All(kv => y.TryGetValue(kv.Key, out var v) && kv.Value.SetEquals(v)); + + if (sameContent(d1, d2)) return true.ToProperty().When(true); + var a = new NestedCollections { ListOfDictOfSets = [d1, d2] }; + var b = new NestedCollections { ListOfDictOfSets = [d2, d1] }; + return (!a.Equals(b)).ToProperty(); + } + + [Property] + public Property ListOfDictOfSets_MiddleInsertionOrderDoesNotMatter(List> raw) + { + var items = raw.Select(d => d.ToDictionary(kv => kv.Key, kv => new HashSet(kv.Value))).ToList(); + var reversed = raw.Select(d => d.Reverse().ToDictionary(kv => kv.Key, kv => new HashSet(kv.Value))).ToList(); + var a = new NestedCollections { ListOfDictOfSets = items }; + var b = new NestedCollections { ListOfDictOfSets = reversed }; + return a.Equals(b).ToProperty(); + } + + [Property] + public Property ListOfDictOfSets_InnermostOrderDoesNotMatter(string key, int v1, int v2) + { + if (key == null) return true.ToProperty().When(true); + // innermost is HashSet — insertion order must not matter + var a = new NestedCollections + { + ListOfDictOfSets = [new Dictionary> { [key] = new HashSet { v1, v2 } }] + }; + var b = new NestedCollections + { + ListOfDictOfSets = [new Dictionary> { [key] = new HashSet { v2, v1 } }] + }; + return a.Equals(b).ToProperty(); + } + + // ══════════════════════════════════════════════════════════════════════ + // 3-level: List>> + // ══════════════════════════════════════════════════════════════════════ + + [Property] + public Property ListOfListOfDicts_EqualWhenSameContent(List>> items) + { + var copy = items.Select(l => l.Select(d => new Dictionary(d)).ToList()).ToList(); + var a = new NestedCollections { ListOfListOfDicts = items }; + var b = new NestedCollections { ListOfListOfDicts = copy }; + return a.Equals(b).ToProperty(); + } + + [Property] + public Property ListOfListOfDicts_OuterOrderMatters(List> l1, List> l2) + { + Func>, List>, bool> sameContent = + (x, y) => x.Count == y.Count && + x.Zip(y).All(pair => pair.First.SequenceEqual(pair.Second)); + + if (sameContent(l1, l2)) return true.ToProperty().When(true); + var a = new NestedCollections { ListOfListOfDicts = [l1, l2] }; + var b = new NestedCollections { ListOfListOfDicts = [l2, l1] }; + return (!a.Equals(b)).ToProperty(); + } + + [Property] + public Property ListOfListOfDicts_MiddleOrderMatters(Dictionary d1, Dictionary d2) + { + // middle is also List — position matters + if (d1.SequenceEqual(d2)) return true.ToProperty().When(true); + var a = new NestedCollections { ListOfListOfDicts = [[d1, d2]] }; + var b = new NestedCollections { ListOfListOfDicts = [[d2, d1]] }; + return (!a.Equals(b)).ToProperty(); + } + + [Property] + public Property ListOfListOfDicts_InnermostInsertionOrderDoesNotMatter(List>> items) + { + var copy = items.Select(l => l.Select(d => d.Reverse().ToDictionary(kv => kv.Key, kv => kv.Value)).ToList()).ToList(); + var a = new NestedCollections { ListOfListOfDicts = items }; + var b = new NestedCollections { ListOfListOfDicts = copy }; + return a.Equals(b).ToProperty(); + } + + [Property] + public Property ListOfListOfDicts_EqualImpliesSameHash(List>> items) + { + var copy = items.Select(l => l.Select(d => new Dictionary(d)).ToList()).ToList(); + var a = new NestedCollections { ListOfListOfDicts = items }; + var b = new NestedCollections { ListOfListOfDicts = copy }; + return (a.Equals(b) && a.GetHashCode() == b.GetHashCode()).ToProperty(); + } + + // ══════════════════════════════════════════════════════════════════════ + // Arrays: int[] + // ══════════════════════════════════════════════════════════════════════ + + [Property] + public Property FlatArray_EqualWhenSameContent(int[] arr) + { + var a = new NestedCollections { FlatArray = arr }; + var b = new NestedCollections { FlatArray = (int[])arr.Clone() }; + return a.Equals(b).ToProperty(); + } + + [Property] + public Property FlatArray_OrderMatters(int v1, int v2) + { + if (v1 == v2) return true.ToProperty().When(true); + var a = new NestedCollections { FlatArray = [v1, v2] }; + var b = new NestedCollections { FlatArray = [v2, v1] }; + return (!a.Equals(b)).ToProperty(); + } + + [Property] + public Property FlatArray_EqualImpliesSameHash(int[] arr) + { + var a = new NestedCollections { FlatArray = arr }; + var b = new NestedCollections { FlatArray = (int[])arr.Clone() }; + return (a.Equals(b) && a.GetHashCode() == b.GetHashCode()).ToProperty(); + } + + // ══════════════════════════════════════════════════════════════════════ + // Arrays: int[][] (array of arrays) + // ══════════════════════════════════════════════════════════════════════ + + [Property] + public Property ArrayOfArrays_EqualWhenSameContent(int[][] arr) + { + var a = new NestedCollections { ArrayOfArrays = arr }; + var b = new NestedCollections { ArrayOfArrays = arr.Select(inner => (int[])inner.Clone()).ToArray() }; + return a.Equals(b).ToProperty(); + } + + [Property] + public Property ArrayOfArrays_OuterOrderMatters(int[] inner1, int[] inner2) + { + if (inner1.SequenceEqual(inner2)) return true.ToProperty().When(true); + var a = new NestedCollections { ArrayOfArrays = [inner1, inner2] }; + var b = new NestedCollections { ArrayOfArrays = [inner2, inner1] }; + return (!a.Equals(b)).ToProperty(); + } + + [Property] + public Property ArrayOfArrays_InnerOrderMatters(int v1, int v2) + { + if (v1 == v2) return true.ToProperty().When(true); + var a = new NestedCollections { ArrayOfArrays = [[v1, v2]] }; + var b = new NestedCollections { ArrayOfArrays = [[v2, v1]] }; + return (!a.Equals(b)).ToProperty(); + } + + // ══════════════════════════════════════════════════════════════════════ + // Arrays: Dictionary[] (array of dicts) + // ══════════════════════════════════════════════════════════════════════ + + [Property] + public Property ArrayOfDicts_EqualWhenSameContent(Dictionary[] arr) + { + var a = new NestedCollections { ArrayOfDicts = arr }; + var b = new NestedCollections { ArrayOfDicts = arr.Select(d => new Dictionary(d)).ToArray() }; + return a.Equals(b).ToProperty(); + } + + [Property] + public Property ArrayOfDicts_OuterOrderMatters(Dictionary d1, Dictionary d2) + { + if (d1.SequenceEqual(d2)) return true.ToProperty().When(true); + var a = new NestedCollections { ArrayOfDicts = [d1, d2] }; + var b = new NestedCollections { ArrayOfDicts = [d2, d1] }; + return (!a.Equals(b)).ToProperty(); + } + + [Property] + public Property ArrayOfDicts_InnerInsertionOrderDoesNotMatter(Dictionary[] arr) + { + var a = new NestedCollections { ArrayOfDicts = arr }; + var b = new NestedCollections { ArrayOfDicts = arr.Select(d => d.Reverse().ToDictionary(kv => kv.Key, kv => kv.Value)).ToArray() }; + return a.Equals(b).ToProperty(); + } + + // ══════════════════════════════════════════════════════════════════════ + // Arrays: Dictionary (dict of arrays) + // ══════════════════════════════════════════════════════════════════════ + + [Property] + public Property DictOfArrays_EqualWhenSameContent(Dictionary raw) + { + var a = new NestedCollections { DictOfArrays = raw }; + var b = new NestedCollections { DictOfArrays = raw.ToDictionary(kv => kv.Key, kv => (int[])kv.Value.Clone()) }; + return a.Equals(b).ToProperty(); + } + + [Property] + public Property DictOfArrays_OuterInsertionOrderDoesNotMatter(Dictionary raw) + { + var a = new NestedCollections { DictOfArrays = raw }; + var b = new NestedCollections { DictOfArrays = raw.Reverse().ToDictionary(kv => kv.Key, kv => kv.Value) }; + return a.Equals(b).ToProperty(); + } + + [Property] + public Property DictOfArrays_InnerOrderMatters(string key, int v1, int v2) + { + if (key == null || v1 == v2) return true.ToProperty().When(true); + var a = new NestedCollections { DictOfArrays = new Dictionary { [key] = [v1, v2] } }; + var b = new NestedCollections { DictOfArrays = new Dictionary { [key] = [v2, v1] } }; + return (!a.Equals(b)).ToProperty(); + } + + [Property] + public Property DictOfArrays_EqualImpliesSameHash(Dictionary raw) + { + var a = new NestedCollections { DictOfArrays = raw }; + var b = new NestedCollections { DictOfArrays = raw.ToDictionary(kv => kv.Key, kv => (int[])kv.Value.Clone()) }; + return (a.Equals(b) && a.GetHashCode() == b.GetHashCode()).ToProperty(); + } +} diff --git a/test/Equatable.Generator.Properties.Tests/Properties/OrderDataContractProperties.cs b/test/Equatable.Generator.Properties.Tests/Properties/OrderDataContractProperties.cs new file mode 100644 index 0000000..2a84c80 --- /dev/null +++ b/test/Equatable.Generator.Properties.Tests/Properties/OrderDataContractProperties.cs @@ -0,0 +1,72 @@ +using Equatable.Entities; + +namespace Equatable.Generator.Tests.Properties; + +/// +/// Property-based tests for [DataContractEquatable]: +/// only [DataMember] properties participate in equality. +/// +public class OrderDataContractProperties +{ + [Property] + public Property Reflexivity(int id, string? name) + { + var o = new OrderDataContract { Id = id, Name = name }; + return o.Equals(o).ToProperty(); + } + + [Property] + public Property Symmetry(int id1, string? name1, int id2, string? name2) + { + var a = new OrderDataContract { Id = id1, Name = name1 }; + var b = new OrderDataContract { Id = id2, Name = name2 }; + return (a.Equals(b) == b.Equals(a)).ToProperty(); + } + + [Property] + public Property EqualImpliesSameHashCode(int id, string? name) + { + var a = new OrderDataContract { Id = id, Name = name }; + var b = new OrderDataContract { Id = id, Name = name }; + return (a.Equals(b) && a.GetHashCode() == b.GetHashCode()).ToProperty(); + } + + [Property] + public Property NonDataMemberFieldsIgnored(int id, string? name, string? note1, string? note2, string? ignored1, string? ignored2) + { + // InternalNote (no [DataMember]) and IgnoredField ([IgnoreDataMember]) must not affect equality + var a = new OrderDataContract { Id = id, Name = name, InternalNote = note1, IgnoredField = ignored1 }; + var b = new OrderDataContract { Id = id, Name = name, InternalNote = note2, IgnoredField = ignored2 }; + return a.Equals(b).ToProperty(); + } + + [Property] + public Property NonDataMemberFieldsIgnoredInHashCode(int id, string? name, string? note1, string? note2) + { + var a = new OrderDataContract { Id = id, Name = name, InternalNote = note1 }; + var b = new OrderDataContract { Id = id, Name = name, InternalNote = note2 }; + return (a.GetHashCode() == b.GetHashCode()).ToProperty(); + } + + [Property] + public Property DifferentIdNotEqual(string? name, int id1, int id2) + { + if (id1 == id2) + return true.ToProperty().When(true); + + var a = new OrderDataContract { Id = id1, Name = name }; + var b = new OrderDataContract { Id = id2, Name = name }; + return (!a.Equals(b)).ToProperty(); + } + + [Property] + public Property DifferentNameNotEqual(int id, string name1, string name2) + { + if (name1 == name2) + return true.ToProperty().When(true); + + var a = new OrderDataContract { Id = id, Name = name1 }; + var b = new OrderDataContract { Id = id, Name = name2 }; + return (!a.Equals(b)).ToProperty(); + } +} diff --git a/test/Equatable.Generator.Properties.Tests/Properties/ReadOnlyDictionaryComparerProperties.cs b/test/Equatable.Generator.Properties.Tests/Properties/ReadOnlyDictionaryComparerProperties.cs new file mode 100644 index 0000000..3839de6 --- /dev/null +++ b/test/Equatable.Generator.Properties.Tests/Properties/ReadOnlyDictionaryComparerProperties.cs @@ -0,0 +1,68 @@ +using Equatable.Comparers; + +namespace Equatable.Generator.Tests.Properties; + +public class ReadOnlyDictionaryComparerProperties +{ + private static readonly ReadOnlyDictionaryEqualityComparer Comparer = ReadOnlyDictionaryEqualityComparer.Default; + + [Property] + public Property Reflexivity(Dictionary dict) + { + IReadOnlyDictionary d = dict; + return Comparer.Equals(d, d).ToProperty(); + } + + [Property] + public Property Symmetry(Dictionary x, Dictionary y) + { + IReadOnlyDictionary a = x; + IReadOnlyDictionary b = y; + return (Comparer.Equals(a, b) == Comparer.Equals(b, a)).ToProperty(); + } + + [Property] + public Property EqualImpliesSameHashCode(Dictionary dict) + { + // two dictionaries with same entries in different insertion order must have equal hash + IReadOnlyDictionary a = dict; + IReadOnlyDictionary b = new Dictionary(dict.Reverse()); + return (Comparer.Equals(a, b) == (Comparer.GetHashCode(a) == Comparer.GetHashCode(b))).ToProperty(); + } + + [Property] + public Property HashIsInsertionOrderIndependent(Dictionary dict) + { + IReadOnlyDictionary a = dict; + IReadOnlyDictionary b = new Dictionary(dict.Reverse()); + return (Comparer.GetHashCode(a) == Comparer.GetHashCode(b)).ToProperty(); + } + + [Property] + public Property NullEqualsNull() + { + return Comparer.Equals(null, null).ToProperty(); + } + + [Property] + public Property NullNotEqualsNonNull(Dictionary dict) + { + IReadOnlyDictionary d = dict; + return (!Comparer.Equals(null, d) && !Comparer.Equals(d, null)).ToProperty(); + } + + [Property] + public Property ExtraKeyMakesNotEqual(Dictionary dict, string key, int value) + { + if (key == null) return true.ToProperty().When(true); + // guard: key must not already be in dict + if (dict.ContainsKey(key)) + return true.ToProperty().When(true); // vacuously true — skip this input + + IReadOnlyDictionary a = dict; + var bigger = new Dictionary(dict) { [key] = value }; + IReadOnlyDictionary b = bigger; + + return (!Comparer.Equals(a, b)).ToProperty(); + } +} diff --git a/test/Equatable.Generator.Properties.Tests/Properties/SerializedRecordProperties.cs b/test/Equatable.Generator.Properties.Tests/Properties/SerializedRecordProperties.cs new file mode 100644 index 0000000..6a5f748 --- /dev/null +++ b/test/Equatable.Generator.Properties.Tests/Properties/SerializedRecordProperties.cs @@ -0,0 +1,76 @@ +using Equatable.Entities; + +namespace Equatable.Generator.Tests.Properties; + +/// +/// Property-based tests for [MessagePackEquatable]: +/// only [Key] properties participate in equality; [IgnoreMember] and unannotated properties are excluded. +/// +public class SerializedRecordProperties +{ + [Property] + public Property Reflexivity(int id, double score) + { + // NaN != NaN in IEEE 754 — skip to avoid false failures in value equality + if (double.IsNaN(score)) return true.ToProperty().When(true); + var r = new SerializedRecord { Id = id, Score = score }; + return r.Equals(r).ToProperty(); + } + + [Property] + public Property Symmetry(int id1, double s1, int id2, double s2) + { + var a = new SerializedRecord { Id = id1, Score = s1 }; + var b = new SerializedRecord { Id = id2, Score = s2 }; + return (a.Equals(b) == b.Equals(a)).ToProperty(); + } + + [Property] + public Property EqualImpliesSameHashCode(int id, double score) + { + if (double.IsNaN(score)) return true.ToProperty().When(true); + var a = new SerializedRecord { Id = id, Score = score }; + var b = new SerializedRecord { Id = id, Score = score }; + return (a.Equals(b) && a.GetHashCode() == b.GetHashCode()).ToProperty(); + } + + [Property] + public Property IgnoredAndUnannotatedFieldsExcluded(int id, double score, string? meta1, string? meta2, string? extra1, string? extra2) + { + if (double.IsNaN(score)) return true.ToProperty().When(true); + // Metadata ([IgnoreMember]) and Extra (no attribute) must not affect equality + var a = new SerializedRecord { Id = id, Score = score, Metadata = meta1, Extra = extra1 }; + var b = new SerializedRecord { Id = id, Score = score, Metadata = meta2, Extra = extra2 }; + return a.Equals(b).ToProperty(); + } + + [Property] + public Property IgnoredFieldsExcludedFromHashCode(int id, double score, string? meta1, string? meta2) + { + var a = new SerializedRecord { Id = id, Score = score, Metadata = meta1 }; + var b = new SerializedRecord { Id = id, Score = score, Metadata = meta2 }; + return (a.GetHashCode() == b.GetHashCode()).ToProperty(); + } + + [Property] + public Property DifferentIdNotEqual(double score, int id1, int id2) + { + if (id1 == id2) + return true.ToProperty().When(true); + + var a = new SerializedRecord { Id = id1, Score = score }; + var b = new SerializedRecord { Id = id2, Score = score }; + return (!a.Equals(b)).ToProperty(); + } + + [Property] + public Property DifferentScoreNotEqual(int id, double s1, double s2) + { + if (Math.Abs(s1 - s2) < double.Epsilon) + return true.ToProperty().When(true); + + var a = new SerializedRecord { Id = id, Score = s1 }; + var b = new SerializedRecord { Id = id, Score = s2 }; + return (!a.Equals(b)).ToProperty(); + } +} diff --git a/test/Equatable.Generator.Properties.Tests/global.json b/test/Equatable.Generator.Properties.Tests/global.json new file mode 100644 index 0000000..3bcd41a --- /dev/null +++ b/test/Equatable.Generator.Properties.Tests/global.json @@ -0,0 +1,5 @@ +{ + "test": { + "runner": "VSTest" + } +} diff --git a/test/Equatable.Generator.Tests/DictionaryHashCodeTest.cs b/test/Equatable.Generator.Tests/DictionaryHashCodeTest.cs new file mode 100644 index 0000000..2d36c05 --- /dev/null +++ b/test/Equatable.Generator.Tests/DictionaryHashCodeTest.cs @@ -0,0 +1,72 @@ +using Equatable.Comparers; + +namespace Equatable.Generator.Tests; + +/// +/// Demonstrates and verifies the order-independent hash code algorithm used by +/// DictionaryEqualityComparer and ReadOnlyDictionaryEqualityComparer. +/// +/// The implementation sums HashCode.Combine(key, value) over every entry. +/// Addition is commutative, so the total is the same regardless of insertion order: +/// +/// hash({a→1, b→2}) = Combine("a",1) + Combine("b",2) +/// = Combine("b",2) + Combine("a",1) +/// = hash({b→2, a→1}) +/// +public class DictionaryHashCodeTest +{ + private static readonly DictionaryEqualityComparer DictComparer + = DictionaryEqualityComparer.Default; + + private static readonly ReadOnlyDictionaryEqualityComparer ReadOnlyComparer + = ReadOnlyDictionaryEqualityComparer.Default; + + [Fact] + public void HashCode_IsInsertionOrderIndependent() + { + // same key-value pairs, different insertion order + var first = new Dictionary { ["a"] = 1, ["b"] = 2, ["c"] = 3 }; + var second = new Dictionary { ["c"] = 3, ["a"] = 1, ["b"] = 2 }; + + Assert.Equal(DictComparer.GetHashCode(first), DictComparer.GetHashCode(second)); + } + + [Fact] + public void HashCode_DifferentValueProducesDifferentHash() + { + var first = new Dictionary { ["a"] = 1 }; + var second = new Dictionary { ["a"] = 2 }; + + // different values → different Combine(key,value) → different sum + Assert.NotEqual(DictComparer.GetHashCode(first), DictComparer.GetHashCode(second)); + } + + [Fact] + public void HashCode_DifferentKeyProducesDifferentHash() + { + var first = new Dictionary { ["a"] = 1 }; + var second = new Dictionary { ["b"] = 1 }; + + Assert.NotEqual(DictComparer.GetHashCode(first), DictComparer.GetHashCode(second)); + } + + [Fact] + public void ReadOnly_HashCode_IsInsertionOrderIndependent() + { + IReadOnlyDictionary first = new Dictionary { ["x"] = 10, ["y"] = 20 }; + IReadOnlyDictionary second = new Dictionary { ["y"] = 20, ["x"] = 10 }; + + Assert.Equal(ReadOnlyComparer.GetHashCode(first), ReadOnlyComparer.GetHashCode(second)); + } + + [Fact] + public void EqualDictionaries_HaveSameHashCode() + { + // two separately constructed dictionaries with identical content + var first = new Dictionary { ["a"] = 1, ["b"] = 2 }; + var second = new Dictionary { ["a"] = 1, ["b"] = 2 }; + + Assert.True(DictComparer.Equals(first, second)); + Assert.Equal(DictComparer.GetHashCode(first), DictComparer.GetHashCode(second)); + } +} diff --git a/test/Equatable.Generator.Tests/Entities/LookupTableTest.cs b/test/Equatable.Generator.Tests/Entities/LookupTableTest.cs new file mode 100644 index 0000000..a837faf --- /dev/null +++ b/test/Equatable.Generator.Tests/Entities/LookupTableTest.cs @@ -0,0 +1,102 @@ +using System.Collections.Generic; + +using Equatable.Entities; + +namespace Equatable.Generator.Tests.Entities; + +public class LookupTableTest +{ + [Fact] + public void EqualsWithReadOnlyDictionary() + { + var left = new LookupTable + { + FlatEntries = new Dictionary { ["a"] = 1.0, ["b"] = 2.0 } + }; + + var right = new LookupTable + { + FlatEntries = new Dictionary { ["b"] = 2.0, ["a"] = 1.0 } + }; + + Assert.True(left.Equals(right)); + } + + [Fact] + public void NotEqualsWithReadOnlyDictionary() + { + var left = new LookupTable + { + FlatEntries = new Dictionary { ["a"] = 1.0, ["b"] = 2.0 } + }; + + var right = new LookupTable + { + FlatEntries = new Dictionary { ["a"] = 1.0, ["b"] = 3.0 } + }; + + Assert.False(left.Equals(right)); + } + + [Fact] + public void EqualsWithNestedReadOnlyDictionary() + { + var left = new LookupTable + { + NestedEntries = new Dictionary> + { + ["outer1"] = new Dictionary { ["x"] = 0.5, ["y"] = 0.5 }, + ["outer2"] = new Dictionary { ["x"] = 0.3, ["y"] = 0.7 } + } + }; + + var right = new LookupTable + { + NestedEntries = new Dictionary> + { + ["outer2"] = new Dictionary { ["y"] = 0.7, ["x"] = 0.3 }, + ["outer1"] = new Dictionary { ["y"] = 0.5, ["x"] = 0.5 } + } + }; + + Assert.True(left.Equals(right)); + } + + [Fact] + public void NotEqualsWithNestedReadOnlyDictionary() + { + var left = new LookupTable + { + NestedEntries = new Dictionary> + { + ["outer1"] = new Dictionary { ["x"] = 0.5, ["y"] = 0.5 } + } + }; + + var right = new LookupTable + { + NestedEntries = new Dictionary> + { + ["outer1"] = new Dictionary { ["x"] = 0.5, ["y"] = 0.6 } + } + }; + + Assert.False(left.Equals(right)); + } + + [Fact] + public void HashCodeEqualsWithReadOnlyDictionary() + { + var left = new LookupTable + { + FlatEntries = new Dictionary { ["a"] = 1.0, ["b"] = 2.0 } + }; + + var right = new LookupTable + { + FlatEntries = new Dictionary { ["b"] = 2.0, ["a"] = 1.0 } + }; + + Assert.Equal(left.GetHashCode(), right.GetHashCode()); + } +} diff --git a/test/Equatable.Generator.Tests/Entities/MarketInputsTest.cs b/test/Equatable.Generator.Tests/Entities/MarketInputsTest.cs deleted file mode 100644 index de41f28..0000000 --- a/test/Equatable.Generator.Tests/Entities/MarketInputsTest.cs +++ /dev/null @@ -1,101 +0,0 @@ -using System.Collections.Generic; -using Equatable.Entities; - -namespace Equatable.Generator.Tests.Entities; - -public class MarketInputsTest -{ - [Fact] - public void EqualsWithReadOnlyDictionary() - { - var left = new MarketInputs - { - FlatInputs = new Dictionary { ["a"] = 1.0, ["b"] = 2.0 } - }; - - var right = new MarketInputs - { - FlatInputs = new Dictionary { ["b"] = 2.0, ["a"] = 1.0 } - }; - - Assert.True(left.Equals(right)); - } - - [Fact] - public void NotEqualsWithReadOnlyDictionary() - { - var left = new MarketInputs - { - FlatInputs = new Dictionary { ["a"] = 1.0, ["b"] = 2.0 } - }; - - var right = new MarketInputs - { - FlatInputs = new Dictionary { ["a"] = 1.0, ["b"] = 3.0 } - }; - - Assert.False(left.Equals(right)); - } - - [Fact] - public void EqualsWithNestedReadOnlyDictionary() - { - var left = new MarketInputs - { - NestedInputs = new Dictionary> - { - ["market1"] = new Dictionary { ["x"] = 0.5, ["y"] = 0.5 }, - ["market2"] = new Dictionary { ["x"] = 0.3, ["y"] = 0.7 } - } - }; - - var right = new MarketInputs - { - NestedInputs = new Dictionary> - { - ["market2"] = new Dictionary { ["y"] = 0.7, ["x"] = 0.3 }, - ["market1"] = new Dictionary { ["y"] = 0.5, ["x"] = 0.5 } - } - }; - - Assert.True(left.Equals(right)); - } - - [Fact] - public void NotEqualsWithNestedReadOnlyDictionary() - { - var left = new MarketInputs - { - NestedInputs = new Dictionary> - { - ["market1"] = new Dictionary { ["x"] = 0.5, ["y"] = 0.5 } - } - }; - - var right = new MarketInputs - { - NestedInputs = new Dictionary> - { - ["market1"] = new Dictionary { ["x"] = 0.5, ["y"] = 0.6 } - } - }; - - Assert.False(left.Equals(right)); - } - - [Fact] - public void HashCodeEqualsWithReadOnlyDictionary() - { - var left = new MarketInputs - { - FlatInputs = new Dictionary { ["a"] = 1.0, ["b"] = 2.0 } - }; - - var right = new MarketInputs - { - FlatInputs = new Dictionary { ["b"] = 2.0, ["a"] = 1.0 } - }; - - Assert.Equal(left.GetHashCode(), right.GetHashCode()); - } -} diff --git a/test/Equatable.Generator.Tests/Equatable.Generator.Tests.csproj b/test/Equatable.Generator.Tests/Equatable.Generator.Tests.csproj index 52a4534..a53ada1 100644 --- a/test/Equatable.Generator.Tests/Equatable.Generator.Tests.csproj +++ b/test/Equatable.Generator.Tests/Equatable.Generator.Tests.csproj @@ -14,6 +14,10 @@ true + + + + diff --git a/test/Equatable.Generator.Tests/EquatableGeneratorTest.cs b/test/Equatable.Generator.Tests/EquatableGeneratorTest.cs index fbb75a2..fa0b172 100644 --- a/test/Equatable.Generator.Tests/EquatableGeneratorTest.cs +++ b/test/Equatable.Generator.Tests/EquatableGeneratorTest.cs @@ -540,6 +540,31 @@ public partial class UserImport .ScrubLinesContaining("GeneratedCodeAttribute"); } + [Fact] + public Task GenerateSequenceEqualityMultiDimensionalArray() + { + var source = @" +using Equatable.Attributes; + +namespace Equatable.Entities; + +[Equatable] +public partial class Grid +{ + [SequenceEquality] + public int[,]? Cells { get; set; } + + public int Id { get; set; } +} +"; + var (diagnostics, output) = GetGeneratedOutput(source); + + return Verifier + .Verify(output) + .UseDirectory("Snapshots") + .ScrubLinesContaining("GeneratedCodeAttribute"); + } + [Fact] public Task GenerateReadOnlyDictionary() { @@ -550,10 +575,10 @@ public Task GenerateReadOnlyDictionary() namespace Equatable.Entities; [Equatable] -public partial class MarketInputs +public partial class LookupTable { [DictionaryEquality] - public IReadOnlyDictionary? FlatInputs { get; set; } + public IReadOnlyDictionary? FlatEntries { get; set; } } "; @@ -567,6 +592,150 @@ public partial class MarketInputs .ScrubLinesContaining("GeneratedCodeAttribute"); } + [Fact] + public Task GenerateNestedDictionary() + { + var source = @" +using System.Collections.Generic; +using Equatable.Attributes; + +namespace Equatable.Entities; + +[Equatable] +public partial class LookupTable +{ + [DictionaryEquality] + public IReadOnlyDictionary>? NestedEntries { get; set; } +} +"; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + + return Verifier + .Verify(output) + .UseDirectory("Snapshots") + .ScrubLinesContaining("GeneratedCodeAttribute"); + } + + [Fact] + public Task GenerateNestedSequenceInDictionary() + { + var source = @" +using System.Collections.Generic; +using Equatable.Attributes; + +namespace Equatable.Entities; + +[Equatable] +public partial class LookupTable +{ + [DictionaryEquality] + public IReadOnlyDictionary>? NestedEntries { get; set; } +} +"; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + + return Verifier + .Verify(output) + .UseDirectory("Snapshots") + .ScrubLinesContaining("GeneratedCodeAttribute"); + } + + [Fact] + public Task GenerateSequenceOfDictionaries() + { + var source = @" +using System.Collections.Generic; +using Equatable.Attributes; + +namespace Equatable.Entities; + +[Equatable] +public partial class LookupTable +{ + [SequenceEquality] + public List>? Items { get; set; } +} +"; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + + return Verifier + .Verify(output) + .UseDirectory("Snapshots") + .ScrubLinesContaining("GeneratedCodeAttribute"); + } + + [Fact] + public Task GenerateDictOfLists() + { + var source = @" +using System.Collections.Generic; +using Equatable.Attributes; + +namespace Equatable.Entities; + +[Equatable] +public partial class NestedCollections +{ + [DictionaryEquality] + public Dictionary>? DictOfLists { get; set; } +} +"; + var (diagnostics, output) = GetGeneratedOutput(source); + Assert.Empty(diagnostics); + return Verifier.Verify(output).UseDirectory("Snapshots").ScrubLinesContaining("GeneratedCodeAttribute"); + } + + [Fact] + public Task GenerateListOfDicts() + { + var source = @" +using System.Collections.Generic; +using Equatable.Attributes; + +namespace Equatable.Entities; + +[Equatable] +public partial class NestedCollections +{ + [SequenceEquality] + public List>? ListOfDicts { get; set; } +} +"; + var (diagnostics, output) = GetGeneratedOutput(source); + Assert.Empty(diagnostics); + return Verifier.Verify(output).UseDirectory("Snapshots").ScrubLinesContaining("GeneratedCodeAttribute"); + } + + [Fact] + public Task GenerateThreeLevelNested() + { + var source = @" +using System.Collections.Generic; +using Equatable.Attributes; + +namespace Equatable.Entities; + +[Equatable] +public partial class NestedCollections +{ + [DictionaryEquality] + public Dictionary>>? ThreeLevelNested { get; set; } +} +"; + var (diagnostics, output) = GetGeneratedOutput(source); + Assert.Empty(diagnostics); + return Verifier.Verify(output).UseDirectory("Snapshots").ScrubLinesContaining("GeneratedCodeAttribute"); + } + [Fact] public Task GenerateDataContractEquatable() { @@ -637,23 +806,296 @@ public partial class PricingContract .ScrubLinesContaining("GeneratedCodeAttribute"); } - private static (ImmutableArray Diagnostics, string Output) GetGeneratedOutput(string source) - where T : IIncrementalGenerator, new() + // ── base class with non-[Equatable] generator attribute ─────────────────────────────────────── + // These tests guard against the GetBaseEquatableType bug: only "EquatableAttribute" was checked, + // so a derived [Equatable] class whose base carries [DataContractEquatable] or + // [MessagePackEquatable] would silently omit base.Equals()/base.GetHashCode() calls. + + [Fact] + public Task GenerateDerivedFromDataContractEquatableBase() { - var syntaxTree = CSharpSyntaxTree.ParseText(source); - var references = AppDomain.CurrentDomain.GetAssemblies() + var source = @" +using System.Runtime.Serialization; +using Equatable.Attributes; + +namespace Equatable.Entities; + +[Equatable] +public partial class ConcreteRecord : ContractBase +{ + public int Rank { get; set; } +} + +[DataContractEquatable] +public abstract partial class ContractBase +{ + [DataMember(Order = 0)] + public int Id { get; set; } + + [DataMember(Order = 1)] + public string? Name { get; set; } +} +"; + var (diagnostics, output) = GetNamedGeneratedOutput(source, "ConcreteRecord"); + Assert.Empty(diagnostics); + return Verifier.Verify(output).UseDirectory("Snapshots").ScrubLinesContaining("GeneratedCodeAttribute"); + } + + [Fact] + public Task GenerateDerivedFromMessagePackEquatableBase() + { + var source = @" +using MessagePack; +using Equatable.Attributes; + +namespace Equatable.Entities; + +[Equatable] +public partial class ConcreteRecord : PackedBase +{ + public string? Label { get; set; } +} + +[MessagePackEquatable] +public abstract partial class PackedBase +{ + [Key(0)] + public int Id { get; set; } + + [Key(1)] + public double Score { get; set; } +} +"; + var (diagnostics, output) = GetNamedGeneratedOutput(source, "ConcreteRecord"); + Assert.Empty(diagnostics); + return Verifier.Verify(output).UseDirectory("Snapshots").ScrubLinesContaining("GeneratedCodeAttribute"); + } + + // ── derived class inherits base properties when base has no generator attribute ────────────────── + // When the derived class carries [DataContractEquatable] or [MessagePackEquatable] but the base + // has no generator attribute, the base's annotated properties must be included directly in the + // derived class's equality (no base.Equals() delegation — the base never generated Equals). + + [Fact] + public Task GenerateDataContractEquatableDerivedIncludesUnannotatedBase() + { + var source = @" +using System.Runtime.Serialization; +using Equatable.Attributes; + +namespace Equatable.Entities; + +[DataContractEquatable] +public partial class ConcreteRecord : UnannotatedBase +{ + [DataMember(Order = 2)] + public int Rank { get; set; } +} + +public abstract class UnannotatedBase +{ + [DataMember(Order = 0)] + public int Id { get; set; } + + [DataMember(Order = 1)] + public string? Name { get; set; } +} +"; + var (diagnostics, output) = GetNamedGeneratedOutput(source, "ConcreteRecord"); + Assert.Empty(diagnostics); + return Verifier.Verify(output).UseDirectory("Snapshots").ScrubLinesContaining("GeneratedCodeAttribute"); + } + + [Fact] + public Task GenerateMessagePackEquatableDerivedIncludesUnannotatedBase() + { + var source = @" +using MessagePack; +using Equatable.Attributes; + +namespace Equatable.Entities; + +[MessagePackEquatable] +public partial class ConcreteRecord : UnannotatedBase +{ + [Key(2)] + public string? Label { get; set; } +} + +public abstract class UnannotatedBase +{ + [Key(0)] + public int Id { get; set; } + + [Key(1)] + public double Score { get; set; } +} +"; + var (diagnostics, output) = GetNamedGeneratedOutput(source, "ConcreteRecord"); + Assert.Empty(diagnostics); + return Verifier.Verify(output).UseDirectory("Snapshots").ScrubLinesContaining("GeneratedCodeAttribute"); + } + + // ── interface-typed collection properties ────────────────────────────────────────────────────── + // These tests guard against regression of the ValidateComparer bug: interface types do not appear + // in their own AllInterfaces list, so the direct-type check must come first. + + [Fact] + public Task GenerateIDictionaryEquality() + { + var source = @" +using System.Collections.Generic; +using Equatable.Attributes; + +namespace Equatable.Entities; + +[Equatable] +public partial class Container +{ + [DictionaryEquality] + public IDictionary? Entries { get; set; } +} +"; + var (diagnostics, output) = GetGeneratedOutput(source); + Assert.Empty(diagnostics); + return Verifier.Verify(output).UseDirectory("Snapshots").ScrubLinesContaining("GeneratedCodeAttribute"); + } + + [Fact] + public Task GenerateIEnumerableSequenceEquality() + { + var source = @" +using System.Collections.Generic; +using Equatable.Attributes; + +namespace Equatable.Entities; + +[Equatable] +public partial class Container +{ + [SequenceEquality] + public IEnumerable? Items { get; set; } +} +"; + var (diagnostics, output) = GetGeneratedOutput(source); + Assert.Empty(diagnostics); + return Verifier.Verify(output).UseDirectory("Snapshots").ScrubLinesContaining("GeneratedCodeAttribute"); + } + + [Fact] + public Task GenerateIListSequenceEquality() + { + var source = @" +using System.Collections.Generic; +using Equatable.Attributes; + +namespace Equatable.Entities; + +[Equatable] +public partial class Container +{ + [SequenceEquality] + public IList? Items { get; set; } +} +"; + var (diagnostics, output) = GetGeneratedOutput(source); + Assert.Empty(diagnostics); + return Verifier.Verify(output).UseDirectory("Snapshots").ScrubLinesContaining("GeneratedCodeAttribute"); + } + + [Fact] + public Task GenerateIReadOnlyListSequenceEquality() + { + var source = @" +using System.Collections.Generic; +using Equatable.Attributes; + +namespace Equatable.Entities; + +[Equatable] +public partial class Container +{ + [SequenceEquality] + public IReadOnlyList? Items { get; set; } +} +"; + var (diagnostics, output) = GetGeneratedOutput(source); + Assert.Empty(diagnostics); + return Verifier.Verify(output).UseDirectory("Snapshots").ScrubLinesContaining("GeneratedCodeAttribute"); + } + + [Fact] + public Task GenerateISetHashSetEquality() + { + var source = @" +using System.Collections.Generic; +using Equatable.Attributes; + +namespace Equatable.Entities; + +[Equatable] +public partial class Container +{ + [HashSetEquality] + public ISet? Tags { get; set; } +} +"; + var (diagnostics, output) = GetGeneratedOutput(source); + Assert.Empty(diagnostics); + return Verifier.Verify(output).UseDirectory("Snapshots").ScrubLinesContaining("GeneratedCodeAttribute"); + } + + [Fact] + public Task GenerateIReadOnlyCollectionSequenceEquality() + { + var source = @" +using System.Collections.Generic; +using Equatable.Attributes; + +namespace Equatable.Entities; + +[Equatable] +public partial class Container +{ + [SequenceEquality] + public IReadOnlyCollection? Items { get; set; } +} +"; + var (diagnostics, output) = GetGeneratedOutput(source); + Assert.Empty(diagnostics); + return Verifier.Verify(output).UseDirectory("Snapshots").ScrubLinesContaining("GeneratedCodeAttribute"); + } + + // Pinned references that must always be present regardless of AppDomain load order. + // DataMemberAttribute and KeyAttribute live in separately-loaded assemblies that may not + // yet be in the AppDomain when a test runs first. + private static readonly IEnumerable PinnedReferences = + [ + MetadataReference.CreateFromFile(typeof(System.Runtime.Serialization.DataMemberAttribute).Assembly.Location), + MetadataReference.CreateFromFile(typeof(MessagePack.KeyAttribute).Assembly.Location), + ]; + + private static IEnumerable BuildReferences() + where T : IIncrementalGenerator, new() + => AppDomain.CurrentDomain.GetAssemblies() .Where(assembly => !assembly.IsDynamic && !string.IsNullOrWhiteSpace(assembly.Location)) .Select(assembly => MetadataReference.CreateFromFile(assembly.Location)) .Concat( [ MetadataReference.CreateFromFile(typeof(T).Assembly.Location), MetadataReference.CreateFromFile(typeof(EquatableAttribute).Assembly.Location), - ]); + ]) + .Concat(PinnedReferences); + + private static (ImmutableArray Diagnostics, string Output) GetGeneratedOutput(string source) + where T : IIncrementalGenerator, new() + { + var syntaxTree = CSharpSyntaxTree.ParseText(source); var compilation = CSharpCompilation.Create( "Test.Generator", [syntaxTree], - references, + BuildReferences(), new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); var originalTreeCount = compilation.SyntaxTrees.Length; @@ -666,4 +1108,28 @@ private static (ImmutableArray Diagnostics, string Output) GetGenera return (diagnostics, trees.Count != originalTreeCount ? trees[^1].ToString() : string.Empty); } + + private static (ImmutableArray Diagnostics, string Output) GetNamedGeneratedOutput(string source, string typeName) + where T : IIncrementalGenerator, new() + { + var syntaxTree = CSharpSyntaxTree.ParseText(source); + + var compilation = CSharpCompilation.Create( + "Test.Generator", + [syntaxTree], + BuildReferences(), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + var originalTreeCount = compilation.SyntaxTrees.Length; + var generator = new T(); + + var driver = CSharpGeneratorDriver.Create(generator); + driver.RunGeneratorsAndUpdateCompilation(compilation, out var outputCompilation, out var diagnostics); + + var generated = outputCompilation.SyntaxTrees.Skip(originalTreeCount).ToList(); + var match = generated.FirstOrDefault(t => t.ToString().Contains($"partial class {typeName}")) + ?? (generated.Count > 0 ? generated[^1] : null); + + return (diagnostics, match?.ToString() ?? string.Empty); + } } diff --git a/test/Equatable.Generator.Tests/Properties/DictionaryComparerProperties.cs b/test/Equatable.Generator.Tests/Properties/DictionaryComparerProperties.cs new file mode 100644 index 0000000..336bac9 --- /dev/null +++ b/test/Equatable.Generator.Tests/Properties/DictionaryComparerProperties.cs @@ -0,0 +1,47 @@ +using Equatable.Comparers; + +namespace Equatable.Generator.Tests.Properties; + +public class DictionaryComparerProperties +{ + private static readonly DictionaryEqualityComparer Comparer = DictionaryEqualityComparer.Default; + + [Property] + public Property Reflexivity(Dictionary dict) + { + return Comparer.Equals(dict, dict).ToProperty(); + } + + [Property] + public Property Symmetry(Dictionary x, Dictionary y) + { + return (Comparer.Equals(x, y) == Comparer.Equals(y, x)).ToProperty(); + } + + [Property] + public Property HashIsInsertionOrderIndependent(Dictionary dict) + { + var reversed = new Dictionary(dict.Reverse()); + return (Comparer.GetHashCode(dict) == Comparer.GetHashCode(reversed)).ToProperty(); + } + + [Property] + public Property EqualDictionariesHaveSameHashCode(Dictionary dict) + { + var copy = new Dictionary(dict); + return (Comparer.Equals(dict, copy) && Comparer.GetHashCode(dict) == Comparer.GetHashCode(copy)).ToProperty(); + } + + [Property] + public Property DifferentValueProducesDifferentHash(Dictionary dict, string key, int v1, int v2) + { + if (v1 == v2) + return true.ToProperty().When(true); + + var a = new Dictionary(dict) { [key] = v1 }; + var b = new Dictionary(dict) { [key] = v2 }; + + // different values must NOT be equal + return (!Comparer.Equals(a, b)).ToProperty(); + } +} diff --git a/test/Equatable.Generator.Tests/Properties/HashSetComparerProperties.cs b/test/Equatable.Generator.Tests/Properties/HashSetComparerProperties.cs new file mode 100644 index 0000000..d9ed3dd --- /dev/null +++ b/test/Equatable.Generator.Tests/Properties/HashSetComparerProperties.cs @@ -0,0 +1,52 @@ +using Equatable.Comparers; + +namespace Equatable.Generator.Tests.Properties; + +public class HashSetComparerProperties +{ + private static readonly HashSetEqualityComparer Comparer = HashSetEqualityComparer.Default; + + [Property] + public Property Reflexivity(HashSet set) + { + return Comparer.Equals(set, set).ToProperty(); + } + + [Property] + public Property Symmetry(HashSet x, HashSet y) + { + return (Comparer.Equals(x, y) == Comparer.Equals(y, x)).ToProperty(); + } + + [Property] + public Property HashIsInsertionOrderIndependent(HashSet set) + { + // build same set from reversed list — HashSet has no guaranteed iteration order, + // but two sets with identical elements must have equal hash regardless of add order + var reversed = new HashSet(set.Reverse()); + return (Comparer.GetHashCode(set) == Comparer.GetHashCode(reversed)).ToProperty(); + } + + [Property] + public Property EqualSetsHaveSameHashCode(HashSet set) + { + var copy = new HashSet(set); + return (Comparer.Equals(set, copy) && Comparer.GetHashCode(set) == Comparer.GetHashCode(copy)).ToProperty(); + } + + [Property] + public Property ExtraElementMakesNotEqual(HashSet set, string extra) + { + if (set.Contains(extra)) + return true.ToProperty().When(true); + + var bigger = new HashSet(set) { extra }; + return (!Comparer.Equals(set, bigger)).ToProperty(); + } + + [Property] + public Property NullEqualsNull() + { + return Comparer.Equals(null, null).ToProperty(); + } +} diff --git a/test/Equatable.Generator.Tests/Properties/NestedCollectionsProperties.cs b/test/Equatable.Generator.Tests/Properties/NestedCollectionsProperties.cs new file mode 100644 index 0000000..c9a0cef --- /dev/null +++ b/test/Equatable.Generator.Tests/Properties/NestedCollectionsProperties.cs @@ -0,0 +1,694 @@ +using Equatable.Entities; + +namespace Equatable.Generator.Tests.Properties; + +/// +/// Property-based tests for auto-composed nested collection comparers. +/// Covers all meaningful 2-level and 3-level combinations of Dict / List / HashSet. +/// Convention per shape: +/// - Dict outer → insertion order must not matter +/// - List outer → insertion order MUST matter +/// - HashSet outer → element order must not matter +/// - List/Sequence inner → element order matters +/// - Dict/HashSet inner → element order does not matter +/// +public class NestedCollectionsProperties +{ + // ══════════════════════════════════════════════════════════════════════ + // 2-level: Dict> + // ══════════════════════════════════════════════════════════════════════ + + [Property] + public Property DictOfLists_EqualWhenSameContent(Dictionary> raw) + { + var a = new NestedCollections { DictOfLists = raw }; + var b = new NestedCollections { DictOfLists = raw.ToDictionary(kv => kv.Key, kv => new List(kv.Value)) }; + return a.Equals(b).ToProperty(); + } + + [Property] + public Property DictOfLists_OuterInsertionOrderDoesNotMatter(Dictionary> raw) + { + var a = new NestedCollections { DictOfLists = raw }; + var b = new NestedCollections { DictOfLists = raw.Reverse().ToDictionary(kv => kv.Key, kv => kv.Value) }; + return a.Equals(b).ToProperty(); + } + + [Property] + public Property DictOfLists_HashIsInsertionOrderIndependent(Dictionary> raw) + { + var a = new NestedCollections { DictOfLists = raw }; + var b = new NestedCollections { DictOfLists = raw.Reverse().ToDictionary(kv => kv.Key, kv => kv.Value) }; + return (a.GetHashCode() == b.GetHashCode()).ToProperty(); + } + + [Property] + public Property DictOfLists_InnerOrderMatters(string key, int v1, int v2) + { + if (v1 == v2) return true.ToProperty().When(true); + var a = new NestedCollections { DictOfLists = new Dictionary> { [key] = [v1, v2] } }; + var b = new NestedCollections { DictOfLists = new Dictionary> { [key] = [v2, v1] } }; + return (!a.Equals(b)).ToProperty(); + } + + [Property] + public Property DictOfLists_EqualImpliesSameHash(Dictionary> raw) + { + var a = new NestedCollections { DictOfLists = raw }; + var b = new NestedCollections { DictOfLists = raw.ToDictionary(kv => kv.Key, kv => new List(kv.Value)) }; + return (a.Equals(b) && a.GetHashCode() == b.GetHashCode()).ToProperty(); + } + + // ══════════════════════════════════════════════════════════════════════ + // 2-level: Dict> + // ══════════════════════════════════════════════════════════════════════ + + [Property] + public Property DictOfSets_EqualWhenSameContent(Dictionary> raw) + { + var a = new NestedCollections { DictOfSets = raw }; + var b = new NestedCollections { DictOfSets = raw.ToDictionary(kv => kv.Key, kv => new HashSet(kv.Value)) }; + return a.Equals(b).ToProperty(); + } + + [Property] + public Property DictOfSets_OuterInsertionOrderDoesNotMatter(Dictionary> raw) + { + var a = new NestedCollections { DictOfSets = raw }; + var b = new NestedCollections { DictOfSets = raw.Reverse().ToDictionary(kv => kv.Key, kv => kv.Value) }; + return a.Equals(b).ToProperty(); + } + + [Property] + public Property DictOfSets_InnerOrderDoesNotMatter(string key, int v1, int v2) + { + // HashSet — insertion order must not matter (even if values differ) + var s1 = new HashSet { v1, v2 }; + var s2 = new HashSet { v2, v1 }; + var a = new NestedCollections { DictOfSets = new Dictionary> { [key] = s1 } }; + var b = new NestedCollections { DictOfSets = new Dictionary> { [key] = s2 } }; + return a.Equals(b).ToProperty(); + } + + [Property] + public Property DictOfSets_HashIsInsertionOrderIndependent(Dictionary> raw) + { + var a = new NestedCollections { DictOfSets = raw }; + var b = new NestedCollections { DictOfSets = raw.Reverse().ToDictionary(kv => kv.Key, kv => kv.Value) }; + return (a.GetHashCode() == b.GetHashCode()).ToProperty(); + } + + // ══════════════════════════════════════════════════════════════════════ + // 2-level: Dict> + // ══════════════════════════════════════════════════════════════════════ + + [Property] + public Property DictOfDicts_EqualWhenSameContent(Dictionary> raw) + { + var a = new NestedCollections { DictOfDicts = raw }; + var b = new NestedCollections { DictOfDicts = raw.ToDictionary(kv => kv.Key, kv => new Dictionary(kv.Value)) }; + return a.Equals(b).ToProperty(); + } + + [Property] + public Property DictOfDicts_OuterInsertionOrderDoesNotMatter(Dictionary> raw) + { + var a = new NestedCollections { DictOfDicts = raw }; + var b = new NestedCollections { DictOfDicts = raw.Reverse().ToDictionary(kv => kv.Key, kv => kv.Value) }; + return a.Equals(b).ToProperty(); + } + + [Property] + public Property DictOfDicts_InnerInsertionOrderDoesNotMatter(Dictionary> raw) + { + var reversed = raw.ToDictionary(kv => kv.Key, kv => kv.Value.Reverse().ToDictionary(p => p.Key, p => p.Value)); + var a = new NestedCollections { DictOfDicts = raw }; + var b = new NestedCollections { DictOfDicts = reversed }; + return a.Equals(b).ToProperty(); + } + + [Property] + public Property DictOfDicts_EqualImpliesSameHash(Dictionary> raw) + { + var a = new NestedCollections { DictOfDicts = raw }; + var b = new NestedCollections { DictOfDicts = raw.ToDictionary(kv => kv.Key, kv => new Dictionary(kv.Value)) }; + return (a.Equals(b) && a.GetHashCode() == b.GetHashCode()).ToProperty(); + } + + // ══════════════════════════════════════════════════════════════════════ + // 2-level: List> + // ══════════════════════════════════════════════════════════════════════ + + [Property] + public Property ListOfDicts_EqualWhenSameContent(List> items) + { + var a = new NestedCollections { ListOfDicts = items }; + var b = new NestedCollections { ListOfDicts = items.Select(d => new Dictionary(d)).ToList() }; + return a.Equals(b).ToProperty(); + } + + [Property] + public Property ListOfDicts_InnerInsertionOrderDoesNotMatter(List> items) + { + var reversed = items.Select(d => d.Reverse().ToDictionary(kv => kv.Key, kv => kv.Value)).ToList(); + var a = new NestedCollections { ListOfDicts = items }; + var b = new NestedCollections { ListOfDicts = reversed }; + return a.Equals(b).ToProperty(); + } + + [Property] + public Property ListOfDicts_OuterOrderMatters(Dictionary d1, Dictionary d2) + { + if (d1.SequenceEqual(d2)) return true.ToProperty().When(true); + var a = new NestedCollections { ListOfDicts = [d1, d2] }; + var b = new NestedCollections { ListOfDicts = [d2, d1] }; + return (!a.Equals(b)).ToProperty(); + } + + [Property] + public Property ListOfDicts_EqualImpliesSameHash(List> items) + { + var a = new NestedCollections { ListOfDicts = items }; + var b = new NestedCollections { ListOfDicts = items.Select(d => new Dictionary(d)).ToList() }; + return (a.Equals(b) && a.GetHashCode() == b.GetHashCode()).ToProperty(); + } + + // ══════════════════════════════════════════════════════════════════════ + // 2-level: List> + // ══════════════════════════════════════════════════════════════════════ + + [Property] + public Property ListOfSets_EqualWhenSameContent(List> items) + { + var a = new NestedCollections { ListOfSets = items }; + var b = new NestedCollections { ListOfSets = items.Select(s => new HashSet(s)).ToList() }; + return a.Equals(b).ToProperty(); + } + + [Property] + public Property ListOfSets_InnerOrderDoesNotMatter(List> items) + { + var a = new NestedCollections { ListOfSets = items }; + var b = new NestedCollections { ListOfSets = items.Select(s => new HashSet(s.Reverse())).ToList() }; + return a.Equals(b).ToProperty(); + } + + [Property] + public Property ListOfSets_OuterOrderMatters(HashSet s1, HashSet s2) + { + // Two distinct non-equal sets — swapping them must break equality + if (s1.SetEquals(s2)) return true.ToProperty().When(true); + var a = new NestedCollections { ListOfSets = [s1, s2] }; + var b = new NestedCollections { ListOfSets = [s2, s1] }; + return (!a.Equals(b)).ToProperty(); + } + + // ══════════════════════════════════════════════════════════════════════ + // 2-level: List> + // ══════════════════════════════════════════════════════════════════════ + + [Property] + public Property ListOfLists_EqualWhenSameContent(List> items) + { + var a = new NestedCollections { ListOfLists = items }; + var b = new NestedCollections { ListOfLists = items.Select(l => new List(l)).ToList() }; + return a.Equals(b).ToProperty(); + } + + [Property] + public Property ListOfLists_OuterOrderMatters(List l1, List l2) + { + if (l1.SequenceEqual(l2)) return true.ToProperty().When(true); + var a = new NestedCollections { ListOfLists = [l1, l2] }; + var b = new NestedCollections { ListOfLists = [l2, l1] }; + return (!a.Equals(b)).ToProperty(); + } + + [Property] + public Property ListOfLists_InnerOrderMatters(string outerTag, int v1, int v2) + { + // inner lists are order-sensitive + if (v1 == v2) return true.ToProperty().When(true); + var a = new NestedCollections { ListOfLists = [[v1, v2]] }; + var b = new NestedCollections { ListOfLists = [[v2, v1]] }; + return (!a.Equals(b)).ToProperty(); + } + + [Property] + public Property ListOfLists_EqualImpliesSameHash(List> items) + { + var a = new NestedCollections { ListOfLists = items }; + var b = new NestedCollections { ListOfLists = items.Select(l => new List(l)).ToList() }; + return (a.Equals(b) && a.GetHashCode() == b.GetHashCode()).ToProperty(); + } + + // ══════════════════════════════════════════════════════════════════════ + // 2-level: HashSet> + // ══════════════════════════════════════════════════════════════════════ + + [Property] + public Property SetOfLists_EqualWhenSameContent(List> items) + { + // build two HashSet> from the same elements + var a = new NestedCollections { SetOfLists = new HashSet>(items) }; + var b = new NestedCollections { SetOfLists = new HashSet>(items.Select(l => new List(l))) }; + return a.Equals(b).ToProperty(); + } + + // ══════════════════════════════════════════════════════════════════════ + // 2-level: HashSet> + // ══════════════════════════════════════════════════════════════════════ + + [Property] + public Property SetOfDicts_EqualWhenSameContent(List> items) + { + var a = new NestedCollections { SetOfDicts = new HashSet>(items) }; + var b = new NestedCollections { SetOfDicts = new HashSet>(items.Select(d => new Dictionary(d))) }; + return a.Equals(b).ToProperty(); + } + + // ══════════════════════════════════════════════════════════════════════ + // 3-level: Dict>> + // ══════════════════════════════════════════════════════════════════════ + + [Property] + public Property ThreeLevelNested_EqualWhenSameContent(Dictionary>> raw) + { + var copy = raw.ToDictionary(o => o.Key, o => o.Value.ToDictionary(i => i.Key, i => new List(i.Value))); + var a = new NestedCollections { ThreeLevelNested = raw }; + var b = new NestedCollections { ThreeLevelNested = copy }; + return a.Equals(b).ToProperty(); + } + + [Property] + public Property ThreeLevelNested_OuterInsertionOrderDoesNotMatter(Dictionary>> raw) + { + var a = new NestedCollections { ThreeLevelNested = raw }; + var b = new NestedCollections { ThreeLevelNested = raw.Reverse().ToDictionary(kv => kv.Key, kv => kv.Value) }; + return a.Equals(b).ToProperty(); + } + + [Property] + public Property ThreeLevelNested_MiddleInsertionOrderDoesNotMatter(Dictionary>> raw) + { + var reversed = raw.ToDictionary(o => o.Key, o => o.Value.Reverse().ToDictionary(kv => kv.Key, kv => kv.Value)); + var a = new NestedCollections { ThreeLevelNested = raw }; + var b = new NestedCollections { ThreeLevelNested = reversed }; + return a.Equals(b).ToProperty(); + } + + [Property] + public Property ThreeLevelNested_InnermostOrderMatters(string outerKey, string innerKey, int v1, int v2) + { + if (v1 == v2) return true.ToProperty().When(true); + var a = new NestedCollections + { + ThreeLevelNested = new Dictionary>> + { + [outerKey] = new Dictionary> { [innerKey] = [v1, v2] } + } + }; + var b = new NestedCollections + { + ThreeLevelNested = new Dictionary>> + { + [outerKey] = new Dictionary> { [innerKey] = [v2, v1] } + } + }; + return (!a.Equals(b)).ToProperty(); + } + + [Property] + public Property ThreeLevelNested_EqualImpliesSameHash(Dictionary>> raw) + { + var copy = raw.ToDictionary(o => o.Key, o => o.Value.ToDictionary(i => i.Key, i => new List(i.Value))); + var a = new NestedCollections { ThreeLevelNested = raw }; + var b = new NestedCollections { ThreeLevelNested = copy }; + return (a.Equals(b) && a.GetHashCode() == b.GetHashCode()).ToProperty(); + } + + // ══════════════════════════════════════════════════════════════════════ + // 3-level: Dict>> + // ══════════════════════════════════════════════════════════════════════ + + [Property] + public Property DictOfListOfSets_EqualWhenSameContent(Dictionary>> raw) + { + var copy = raw.ToDictionary(kv => kv.Key, kv => kv.Value.Select(s => new HashSet(s)).ToList()); + var a = new NestedCollections { DictOfListOfSets = raw }; + var b = new NestedCollections { DictOfListOfSets = copy }; + return a.Equals(b).ToProperty(); + } + + [Property] + public Property DictOfListOfSets_OuterInsertionOrderDoesNotMatter(Dictionary>> raw) + { + var a = new NestedCollections { DictOfListOfSets = raw }; + var b = new NestedCollections { DictOfListOfSets = raw.Reverse().ToDictionary(kv => kv.Key, kv => kv.Value) }; + return a.Equals(b).ToProperty(); + } + + [Property] + public Property DictOfListOfSets_MiddleOrderMatters(string key, HashSet s1, HashSet s2) + { + // middle is List — position matters + if (s1.SetEquals(s2)) return true.ToProperty().When(true); + var a = new NestedCollections { DictOfListOfSets = new Dictionary>> { [key] = [s1, s2] } }; + var b = new NestedCollections { DictOfListOfSets = new Dictionary>> { [key] = [s2, s1] } }; + return (!a.Equals(b)).ToProperty(); + } + + [Property] + public Property DictOfListOfSets_InnermostOrderDoesNotMatter(string key, int v1, int v2) + { + // innermost is HashSet — order must not matter + var a = new NestedCollections + { + DictOfListOfSets = new Dictionary>> + { [key] = [new HashSet { v1, v2 }] } + }; + var b = new NestedCollections + { + DictOfListOfSets = new Dictionary>> + { [key] = [new HashSet { v2, v1 }] } + }; + return a.Equals(b).ToProperty(); + } + + // ══════════════════════════════════════════════════════════════════════ + // 3-level: Dict>> + // ══════════════════════════════════════════════════════════════════════ + + [Property] + public Property DictOfListOfDicts_EqualWhenSameContent(Dictionary>> raw) + { + var copy = raw.ToDictionary( + kv => kv.Key, + kv => kv.Value.Select(d => new Dictionary(d)).ToList()); + var a = new NestedCollections { DictOfListOfDicts = raw }; + var b = new NestedCollections { DictOfListOfDicts = copy }; + return a.Equals(b).ToProperty(); + } + + [Property] + public Property DictOfListOfDicts_OuterInsertionOrderDoesNotMatter(Dictionary>> raw) + { + var a = new NestedCollections { DictOfListOfDicts = raw }; + var b = new NestedCollections { DictOfListOfDicts = raw.Reverse().ToDictionary(kv => kv.Key, kv => kv.Value) }; + return a.Equals(b).ToProperty(); + } + + [Property] + public Property DictOfListOfDicts_MiddleOrderMatters(string key, Dictionary d1, Dictionary d2) + { + // middle is List — position matters + if (d1.SequenceEqual(d2)) return true.ToProperty().When(true); + var a = new NestedCollections { DictOfListOfDicts = new Dictionary>> { [key] = [d1, d2] } }; + var b = new NestedCollections { DictOfListOfDicts = new Dictionary>> { [key] = [d2, d1] } }; + return (!a.Equals(b)).ToProperty(); + } + + [Property] + public Property DictOfListOfDicts_InnermostInsertionOrderDoesNotMatter(Dictionary>> raw) + { + var copy = raw.ToDictionary( + kv => kv.Key, + kv => kv.Value.Select(d => d.Reverse().ToDictionary(p => p.Key, p => p.Value)).ToList()); + var a = new NestedCollections { DictOfListOfDicts = raw }; + var b = new NestedCollections { DictOfListOfDicts = copy }; + return a.Equals(b).ToProperty(); + } + + // ══════════════════════════════════════════════════════════════════════ + // 3-level: List>> + // ══════════════════════════════════════════════════════════════════════ + + [Property] + public Property ListOfDictOfLists_EqualWhenSameContent(List>> items) + { + var copy = items.Select(d => d.ToDictionary(kv => kv.Key, kv => new List(kv.Value))).ToList(); + var a = new NestedCollections { ListOfDictOfLists = items }; + var b = new NestedCollections { ListOfDictOfLists = copy }; + return a.Equals(b).ToProperty(); + } + + [Property] + public Property ListOfDictOfLists_OuterOrderMatters(Dictionary> d1, Dictionary> d2) + { + // outer is List — position matters + Func>, Dictionary>, bool> sameContent = + (x, y) => x.Count == y.Count && x.All(kv => y.TryGetValue(kv.Key, out var v) && kv.Value.SequenceEqual(v)); + + if (sameContent(d1, d2)) return true.ToProperty().When(true); + var a = new NestedCollections { ListOfDictOfLists = [d1, d2] }; + var b = new NestedCollections { ListOfDictOfLists = [d2, d1] }; + return (!a.Equals(b)).ToProperty(); + } + + [Property] + public Property ListOfDictOfLists_MiddleInsertionOrderDoesNotMatter(List>> items) + { + var reversed = items.Select(d => d.Reverse().ToDictionary(kv => kv.Key, kv => kv.Value)).ToList(); + var a = new NestedCollections { ListOfDictOfLists = items }; + var b = new NestedCollections { ListOfDictOfLists = reversed }; + return a.Equals(b).ToProperty(); + } + + [Property] + public Property ListOfDictOfLists_InnermostOrderMatters(string key, int v1, int v2) + { + // innermost is List — position matters + if (v1 == v2) return true.ToProperty().When(true); + var a = new NestedCollections { ListOfDictOfLists = [new Dictionary> { [key] = [v1, v2] }] }; + var b = new NestedCollections { ListOfDictOfLists = [new Dictionary> { [key] = [v2, v1] }] }; + return (!a.Equals(b)).ToProperty(); + } + + // ══════════════════════════════════════════════════════════════════════ + // 3-level: List>> + // ══════════════════════════════════════════════════════════════════════ + + [Property] + public Property ListOfDictOfSets_EqualWhenSameContent(List>> items) + { + var copy = items.Select(d => d.ToDictionary(kv => kv.Key, kv => new HashSet(kv.Value))).ToList(); + var a = new NestedCollections { ListOfDictOfSets = items }; + var b = new NestedCollections { ListOfDictOfSets = copy }; + return a.Equals(b).ToProperty(); + } + + [Property] + public Property ListOfDictOfSets_OuterOrderMatters(Dictionary> d1, Dictionary> d2) + { + Func>, Dictionary>, bool> sameContent = + (x, y) => x.Count == y.Count && x.All(kv => y.TryGetValue(kv.Key, out var v) && kv.Value.SetEquals(v)); + + if (sameContent(d1, d2)) return true.ToProperty().When(true); + var a = new NestedCollections { ListOfDictOfSets = [d1, d2] }; + var b = new NestedCollections { ListOfDictOfSets = [d2, d1] }; + return (!a.Equals(b)).ToProperty(); + } + + [Property] + public Property ListOfDictOfSets_MiddleInsertionOrderDoesNotMatter(List>> items) + { + var reversed = items.Select(d => d.Reverse().ToDictionary(kv => kv.Key, kv => kv.Value)).ToList(); + var a = new NestedCollections { ListOfDictOfSets = items }; + var b = new NestedCollections { ListOfDictOfSets = reversed }; + return a.Equals(b).ToProperty(); + } + + [Property] + public Property ListOfDictOfSets_InnermostOrderDoesNotMatter(string key, int v1, int v2) + { + // innermost is HashSet — insertion order must not matter + var a = new NestedCollections + { + ListOfDictOfSets = [new Dictionary> { [key] = new HashSet { v1, v2 } }] + }; + var b = new NestedCollections + { + ListOfDictOfSets = [new Dictionary> { [key] = new HashSet { v2, v1 } }] + }; + return a.Equals(b).ToProperty(); + } + + // ══════════════════════════════════════════════════════════════════════ + // 3-level: List>> + // ══════════════════════════════════════════════════════════════════════ + + [Property] + public Property ListOfListOfDicts_EqualWhenSameContent(List>> items) + { + var copy = items.Select(l => l.Select(d => new Dictionary(d)).ToList()).ToList(); + var a = new NestedCollections { ListOfListOfDicts = items }; + var b = new NestedCollections { ListOfListOfDicts = copy }; + return a.Equals(b).ToProperty(); + } + + [Property] + public Property ListOfListOfDicts_OuterOrderMatters(List> l1, List> l2) + { + Func>, List>, bool> sameContent = + (x, y) => x.Count == y.Count && + x.Zip(y).All(pair => pair.First.SequenceEqual(pair.Second)); + + if (sameContent(l1, l2)) return true.ToProperty().When(true); + var a = new NestedCollections { ListOfListOfDicts = [l1, l2] }; + var b = new NestedCollections { ListOfListOfDicts = [l2, l1] }; + return (!a.Equals(b)).ToProperty(); + } + + [Property] + public Property ListOfListOfDicts_MiddleOrderMatters(Dictionary d1, Dictionary d2) + { + // middle is also List — position matters + if (d1.SequenceEqual(d2)) return true.ToProperty().When(true); + var a = new NestedCollections { ListOfListOfDicts = [[d1, d2]] }; + var b = new NestedCollections { ListOfListOfDicts = [[d2, d1]] }; + return (!a.Equals(b)).ToProperty(); + } + + [Property] + public Property ListOfListOfDicts_InnermostInsertionOrderDoesNotMatter(List>> items) + { + var copy = items.Select(l => l.Select(d => d.Reverse().ToDictionary(kv => kv.Key, kv => kv.Value)).ToList()).ToList(); + var a = new NestedCollections { ListOfListOfDicts = items }; + var b = new NestedCollections { ListOfListOfDicts = copy }; + return a.Equals(b).ToProperty(); + } + + [Property] + public Property ListOfListOfDicts_EqualImpliesSameHash(List>> items) + { + var copy = items.Select(l => l.Select(d => new Dictionary(d)).ToList()).ToList(); + var a = new NestedCollections { ListOfListOfDicts = items }; + var b = new NestedCollections { ListOfListOfDicts = copy }; + return (a.Equals(b) && a.GetHashCode() == b.GetHashCode()).ToProperty(); + } + + // ══════════════════════════════════════════════════════════════════════ + // Arrays: int[] + // ══════════════════════════════════════════════════════════════════════ + + [Property] + public Property FlatArray_EqualWhenSameContent(int[] arr) + { + var a = new NestedCollections { FlatArray = arr }; + var b = new NestedCollections { FlatArray = (int[])arr.Clone() }; + return a.Equals(b).ToProperty(); + } + + [Property] + public Property FlatArray_OrderMatters(int v1, int v2) + { + if (v1 == v2) return true.ToProperty().When(true); + var a = new NestedCollections { FlatArray = [v1, v2] }; + var b = new NestedCollections { FlatArray = [v2, v1] }; + return (!a.Equals(b)).ToProperty(); + } + + [Property] + public Property FlatArray_EqualImpliesSameHash(int[] arr) + { + var a = new NestedCollections { FlatArray = arr }; + var b = new NestedCollections { FlatArray = (int[])arr.Clone() }; + return (a.Equals(b) && a.GetHashCode() == b.GetHashCode()).ToProperty(); + } + + // ══════════════════════════════════════════════════════════════════════ + // Arrays: int[][] (array of arrays) + // ══════════════════════════════════════════════════════════════════════ + + [Property] + public Property ArrayOfArrays_EqualWhenSameContent(int[][] arr) + { + var a = new NestedCollections { ArrayOfArrays = arr }; + var b = new NestedCollections { ArrayOfArrays = arr.Select(inner => (int[])inner.Clone()).ToArray() }; + return a.Equals(b).ToProperty(); + } + + [Property] + public Property ArrayOfArrays_OuterOrderMatters(int[] inner1, int[] inner2) + { + if (inner1.SequenceEqual(inner2)) return true.ToProperty().When(true); + var a = new NestedCollections { ArrayOfArrays = [inner1, inner2] }; + var b = new NestedCollections { ArrayOfArrays = [inner2, inner1] }; + return (!a.Equals(b)).ToProperty(); + } + + [Property] + public Property ArrayOfArrays_InnerOrderMatters(int v1, int v2) + { + if (v1 == v2) return true.ToProperty().When(true); + var a = new NestedCollections { ArrayOfArrays = [[v1, v2]] }; + var b = new NestedCollections { ArrayOfArrays = [[v2, v1]] }; + return (!a.Equals(b)).ToProperty(); + } + + // ══════════════════════════════════════════════════════════════════════ + // Arrays: Dictionary[] (array of dicts) + // ══════════════════════════════════════════════════════════════════════ + + [Property] + public Property ArrayOfDicts_EqualWhenSameContent(Dictionary[] arr) + { + var a = new NestedCollections { ArrayOfDicts = arr }; + var b = new NestedCollections { ArrayOfDicts = arr.Select(d => new Dictionary(d)).ToArray() }; + return a.Equals(b).ToProperty(); + } + + [Property] + public Property ArrayOfDicts_OuterOrderMatters(Dictionary d1, Dictionary d2) + { + if (d1.SequenceEqual(d2)) return true.ToProperty().When(true); + var a = new NestedCollections { ArrayOfDicts = [d1, d2] }; + var b = new NestedCollections { ArrayOfDicts = [d2, d1] }; + return (!a.Equals(b)).ToProperty(); + } + + [Property] + public Property ArrayOfDicts_InnerInsertionOrderDoesNotMatter(Dictionary[] arr) + { + var a = new NestedCollections { ArrayOfDicts = arr }; + var b = new NestedCollections { ArrayOfDicts = arr.Select(d => d.Reverse().ToDictionary(kv => kv.Key, kv => kv.Value)).ToArray() }; + return a.Equals(b).ToProperty(); + } + + // ══════════════════════════════════════════════════════════════════════ + // Arrays: Dictionary (dict of arrays) + // ══════════════════════════════════════════════════════════════════════ + + [Property] + public Property DictOfArrays_EqualWhenSameContent(Dictionary raw) + { + var a = new NestedCollections { DictOfArrays = raw }; + var b = new NestedCollections { DictOfArrays = raw.ToDictionary(kv => kv.Key, kv => (int[])kv.Value.Clone()) }; + return a.Equals(b).ToProperty(); + } + + [Property] + public Property DictOfArrays_OuterInsertionOrderDoesNotMatter(Dictionary raw) + { + var a = new NestedCollections { DictOfArrays = raw }; + var b = new NestedCollections { DictOfArrays = raw.Reverse().ToDictionary(kv => kv.Key, kv => kv.Value) }; + return a.Equals(b).ToProperty(); + } + + [Property] + public Property DictOfArrays_InnerOrderMatters(string key, int v1, int v2) + { + if (v1 == v2) return true.ToProperty().When(true); + var a = new NestedCollections { DictOfArrays = new Dictionary { [key] = [v1, v2] } }; + var b = new NestedCollections { DictOfArrays = new Dictionary { [key] = [v2, v1] } }; + return (!a.Equals(b)).ToProperty(); + } + + [Property] + public Property DictOfArrays_EqualImpliesSameHash(Dictionary raw) + { + var a = new NestedCollections { DictOfArrays = raw }; + var b = new NestedCollections { DictOfArrays = raw.ToDictionary(kv => kv.Key, kv => (int[])kv.Value.Clone()) }; + return (a.Equals(b) && a.GetHashCode() == b.GetHashCode()).ToProperty(); + } +} diff --git a/test/Equatable.Generator.Tests/Properties/OrderDataContractProperties.cs b/test/Equatable.Generator.Tests/Properties/OrderDataContractProperties.cs new file mode 100644 index 0000000..2a84c80 --- /dev/null +++ b/test/Equatable.Generator.Tests/Properties/OrderDataContractProperties.cs @@ -0,0 +1,72 @@ +using Equatable.Entities; + +namespace Equatable.Generator.Tests.Properties; + +/// +/// Property-based tests for [DataContractEquatable]: +/// only [DataMember] properties participate in equality. +/// +public class OrderDataContractProperties +{ + [Property] + public Property Reflexivity(int id, string? name) + { + var o = new OrderDataContract { Id = id, Name = name }; + return o.Equals(o).ToProperty(); + } + + [Property] + public Property Symmetry(int id1, string? name1, int id2, string? name2) + { + var a = new OrderDataContract { Id = id1, Name = name1 }; + var b = new OrderDataContract { Id = id2, Name = name2 }; + return (a.Equals(b) == b.Equals(a)).ToProperty(); + } + + [Property] + public Property EqualImpliesSameHashCode(int id, string? name) + { + var a = new OrderDataContract { Id = id, Name = name }; + var b = new OrderDataContract { Id = id, Name = name }; + return (a.Equals(b) && a.GetHashCode() == b.GetHashCode()).ToProperty(); + } + + [Property] + public Property NonDataMemberFieldsIgnored(int id, string? name, string? note1, string? note2, string? ignored1, string? ignored2) + { + // InternalNote (no [DataMember]) and IgnoredField ([IgnoreDataMember]) must not affect equality + var a = new OrderDataContract { Id = id, Name = name, InternalNote = note1, IgnoredField = ignored1 }; + var b = new OrderDataContract { Id = id, Name = name, InternalNote = note2, IgnoredField = ignored2 }; + return a.Equals(b).ToProperty(); + } + + [Property] + public Property NonDataMemberFieldsIgnoredInHashCode(int id, string? name, string? note1, string? note2) + { + var a = new OrderDataContract { Id = id, Name = name, InternalNote = note1 }; + var b = new OrderDataContract { Id = id, Name = name, InternalNote = note2 }; + return (a.GetHashCode() == b.GetHashCode()).ToProperty(); + } + + [Property] + public Property DifferentIdNotEqual(string? name, int id1, int id2) + { + if (id1 == id2) + return true.ToProperty().When(true); + + var a = new OrderDataContract { Id = id1, Name = name }; + var b = new OrderDataContract { Id = id2, Name = name }; + return (!a.Equals(b)).ToProperty(); + } + + [Property] + public Property DifferentNameNotEqual(int id, string name1, string name2) + { + if (name1 == name2) + return true.ToProperty().When(true); + + var a = new OrderDataContract { Id = id, Name = name1 }; + var b = new OrderDataContract { Id = id, Name = name2 }; + return (!a.Equals(b)).ToProperty(); + } +} diff --git a/test/Equatable.Generator.Tests/Properties/ReadOnlyDictionaryComparerProperties.cs b/test/Equatable.Generator.Tests/Properties/ReadOnlyDictionaryComparerProperties.cs new file mode 100644 index 0000000..880241a --- /dev/null +++ b/test/Equatable.Generator.Tests/Properties/ReadOnlyDictionaryComparerProperties.cs @@ -0,0 +1,67 @@ +using Equatable.Comparers; + +namespace Equatable.Generator.Tests.Properties; + +public class ReadOnlyDictionaryComparerProperties +{ + private static readonly ReadOnlyDictionaryEqualityComparer Comparer = ReadOnlyDictionaryEqualityComparer.Default; + + [Property] + public Property Reflexivity(Dictionary dict) + { + IReadOnlyDictionary d = dict; + return Comparer.Equals(d, d).ToProperty(); + } + + [Property] + public Property Symmetry(Dictionary x, Dictionary y) + { + IReadOnlyDictionary a = x; + IReadOnlyDictionary b = y; + return (Comparer.Equals(a, b) == Comparer.Equals(b, a)).ToProperty(); + } + + [Property] + public Property EqualImpliesSameHashCode(Dictionary dict) + { + // two dictionaries with same entries in different insertion order must have equal hash + IReadOnlyDictionary a = dict; + IReadOnlyDictionary b = new Dictionary(dict.Reverse()); + return (Comparer.Equals(a, b) == (Comparer.GetHashCode(a) == Comparer.GetHashCode(b))).ToProperty(); + } + + [Property] + public Property HashIsInsertionOrderIndependent(Dictionary dict) + { + IReadOnlyDictionary a = dict; + IReadOnlyDictionary b = new Dictionary(dict.Reverse()); + return (Comparer.GetHashCode(a) == Comparer.GetHashCode(b)).ToProperty(); + } + + [Property] + public Property NullEqualsNull() + { + return Comparer.Equals(null, null).ToProperty(); + } + + [Property] + public Property NullNotEqualsNonNull(Dictionary dict) + { + IReadOnlyDictionary d = dict; + return (!Comparer.Equals(null, d) && !Comparer.Equals(d, null)).ToProperty(); + } + + [Property] + public Property ExtraKeyMakesNotEqual(Dictionary dict, string key, int value) + { + // guard: key must not already be in dict + if (dict.ContainsKey(key)) + return true.ToProperty().When(true); // vacuously true — skip this input + + IReadOnlyDictionary a = dict; + var bigger = new Dictionary(dict) { [key] = value }; + IReadOnlyDictionary b = bigger; + + return (!Comparer.Equals(a, b)).ToProperty(); + } +} diff --git a/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateDataContractEquatable.verified.txt b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateDataContractEquatable.verified.txt new file mode 100644 index 0000000..d7fe908 --- /dev/null +++ b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateDataContractEquatable.verified.txt @@ -0,0 +1,45 @@ +// +#nullable enable + +namespace Equatable.Entities +{ + partial class OrderDataContract : global::System.IEquatable + { + /// + public bool Equals(global::Equatable.Entities.OrderDataContract? other) + { + return !(other is null) + && Id == other.Id + && global::System.Collections.Generic.EqualityComparer.Default.Equals(Name, other.Name); + + } + + /// + public override bool Equals(object? obj) + { + return Equals(obj as global::Equatable.Entities.OrderDataContract); + } + + /// + public static bool operator ==(global::Equatable.Entities.OrderDataContract? left, global::Equatable.Entities.OrderDataContract? right) + { + return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); + } + + /// + public static bool operator !=(global::Equatable.Entities.OrderDataContract? left, global::Equatable.Entities.OrderDataContract? right) + { + return !(left == right); + } + + /// + public override int GetHashCode(){ + int hashCode = -1919740922; + hashCode = (hashCode * -1521134295) + Id.GetHashCode(); + hashCode = (hashCode * -1521134295) + global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(Name!); + return hashCode; + + } + + } +} diff --git a/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateDataContractEquatableDerivedIncludesUnannotatedBase.verified.txt b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateDataContractEquatableDerivedIncludesUnannotatedBase.verified.txt new file mode 100644 index 0000000..43258cd --- /dev/null +++ b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateDataContractEquatableDerivedIncludesUnannotatedBase.verified.txt @@ -0,0 +1,47 @@ +// +#nullable enable + +namespace Equatable.Entities +{ + partial class ConcreteRecord : global::System.IEquatable + { + /// + public bool Equals(global::Equatable.Entities.ConcreteRecord? other) + { + return !(other is null) + && Rank == other.Rank + && Id == other.Id + && global::System.Collections.Generic.EqualityComparer.Default.Equals(Name, other.Name); + + } + + /// + public override bool Equals(object? obj) + { + return Equals(obj as global::Equatable.Entities.ConcreteRecord); + } + + /// + public static bool operator ==(global::Equatable.Entities.ConcreteRecord? left, global::Equatable.Entities.ConcreteRecord? right) + { + return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); + } + + /// + public static bool operator !=(global::Equatable.Entities.ConcreteRecord? left, global::Equatable.Entities.ConcreteRecord? right) + { + return !(left == right); + } + + /// + public override int GetHashCode(){ + int hashCode = 493275453; + hashCode = (hashCode * -1521134295) + Rank.GetHashCode(); + hashCode = (hashCode * -1521134295) + Id.GetHashCode(); + hashCode = (hashCode * -1521134295) + global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(Name!); + return hashCode; + + } + + } +} diff --git a/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateDerivedFromDataContractEquatableBase.verified.txt b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateDerivedFromDataContractEquatableBase.verified.txt new file mode 100644 index 0000000..df5eb7e --- /dev/null +++ b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateDerivedFromDataContractEquatableBase.verified.txt @@ -0,0 +1,45 @@ +// +#nullable enable + +namespace Equatable.Entities +{ + partial class ConcreteRecord : global::System.IEquatable + { + /// + public bool Equals(global::Equatable.Entities.ConcreteRecord? other) + { + return !(other is null) + && base.Equals(other) + && Rank == other.Rank; + + } + + /// + public override bool Equals(object? obj) + { + return Equals(obj as global::Equatable.Entities.ConcreteRecord); + } + + /// + public static bool operator ==(global::Equatable.Entities.ConcreteRecord? left, global::Equatable.Entities.ConcreteRecord? right) + { + return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); + } + + /// + public static bool operator !=(global::Equatable.Entities.ConcreteRecord? left, global::Equatable.Entities.ConcreteRecord? right) + { + return !(left == right); + } + + /// + public override int GetHashCode(){ + int hashCode = 1445696869; + hashCode = (hashCode * -1521134295) + base.GetHashCode(); + hashCode = (hashCode * -1521134295) + Rank.GetHashCode(); + return hashCode; + + } + + } +} diff --git a/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateDerivedFromMessagePackEquatableBase.verified.txt b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateDerivedFromMessagePackEquatableBase.verified.txt new file mode 100644 index 0000000..19475c3 --- /dev/null +++ b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateDerivedFromMessagePackEquatableBase.verified.txt @@ -0,0 +1,45 @@ +// +#nullable enable + +namespace Equatable.Entities +{ + partial class ConcreteRecord : global::System.IEquatable + { + /// + public bool Equals(global::Equatable.Entities.ConcreteRecord? other) + { + return !(other is null) + && base.Equals(other) + && global::System.Collections.Generic.EqualityComparer.Default.Equals(Label, other.Label); + + } + + /// + public override bool Equals(object? obj) + { + return Equals(obj as global::Equatable.Entities.ConcreteRecord); + } + + /// + public static bool operator ==(global::Equatable.Entities.ConcreteRecord? left, global::Equatable.Entities.ConcreteRecord? right) + { + return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); + } + + /// + public static bool operator !=(global::Equatable.Entities.ConcreteRecord? left, global::Equatable.Entities.ConcreteRecord? right) + { + return !(left == right); + } + + /// + public override int GetHashCode(){ + int hashCode = -373672503; + hashCode = (hashCode * -1521134295) + base.GetHashCode(); + hashCode = (hashCode * -1521134295) + global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(Label!); + return hashCode; + + } + + } +} diff --git a/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateDictOfLists.verified.txt b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateDictOfLists.verified.txt new file mode 100644 index 0000000..40dddcf --- /dev/null +++ b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateDictOfLists.verified.txt @@ -0,0 +1,43 @@ +// +#nullable enable + +namespace Equatable.Entities +{ + partial class NestedCollections : global::System.IEquatable + { + /// + public bool Equals(global::Equatable.Entities.NestedCollections? other) + { + return !(other is null) + && (new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer>(global::System.Collections.Generic.EqualityComparer.Default, global::Equatable.Comparers.SequenceEqualityComparer.Default)).Equals(DictOfLists, other.DictOfLists); + + } + + /// + public override bool Equals(object? obj) + { + return Equals(obj as global::Equatable.Entities.NestedCollections); + } + + /// + public static bool operator ==(global::Equatable.Entities.NestedCollections? left, global::Equatable.Entities.NestedCollections? right) + { + return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); + } + + /// + public static bool operator !=(global::Equatable.Entities.NestedCollections? left, global::Equatable.Entities.NestedCollections? right) + { + return !(left == right); + } + + /// + public override int GetHashCode(){ + int hashCode = -668193755; + hashCode = (hashCode * -1521134295) + (new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer>(global::System.Collections.Generic.EqualityComparer.Default, global::Equatable.Comparers.SequenceEqualityComparer.Default)).GetHashCode(DictOfLists!); + return hashCode; + + } + + } +} diff --git a/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateIDictionaryEquality.verified.txt b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateIDictionaryEquality.verified.txt new file mode 100644 index 0000000..5788301 --- /dev/null +++ b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateIDictionaryEquality.verified.txt @@ -0,0 +1,104 @@ +// +#nullable enable + +namespace Equatable.Entities +{ + partial class Container : global::System.IEquatable + { + /// + public bool Equals(global::Equatable.Entities.Container? other) + { + return !(other is null) + && DictionaryEquals(Entries, other.Entries); + + static bool DictionaryEquals(global::System.Collections.Generic.IEnumerable>? left, global::System.Collections.Generic.IEnumerable>? right) + { + if (global::System.Object.ReferenceEquals(left, right)) + return true; + + if (left is null || right is null) + return false; + + if (left is global::System.Collections.Generic.IReadOnlyCollection> leftCollection && + right is global::System.Collections.Generic.IReadOnlyCollection> rightCollection && + leftCollection.Count != rightCollection.Count) + return false; + + if (right is global::System.Collections.Generic.IReadOnlyDictionary rightReadOnly) + { + foreach (var pair in left) + { + if (!rightReadOnly.TryGetValue(pair.Key, out var value)) + return false; + + if (!global::System.Collections.Generic.EqualityComparer.Default.Equals(pair.Value, value)) + return false; + } + return true; + } + + if (right is global::System.Collections.Generic.IDictionary rightDictionary) + { + foreach (var pair in left) + { + if (!rightDictionary.TryGetValue(pair.Key, out var value)) + return false; + + if (!global::System.Collections.Generic.EqualityComparer.Default.Equals(pair.Value, value)) + return false; + } + return true; + } + + return global::System.Linq.Enumerable.SequenceEqual( + global::System.Linq.Enumerable.OrderBy(left, p => p.Key), + global::System.Linq.Enumerable.OrderBy(right, p => p.Key), + global::System.Collections.Generic.EqualityComparer>.Default); + } + + } + + /// + public override bool Equals(object? obj) + { + return Equals(obj as global::Equatable.Entities.Container); + } + + /// + public static bool operator ==(global::Equatable.Entities.Container? left, global::Equatable.Entities.Container? right) + { + return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); + } + + /// + public static bool operator !=(global::Equatable.Entities.Container? left, global::Equatable.Entities.Container? right) + { + return !(left == right); + } + + /// + public override int GetHashCode(){ + int hashCode = -592665965; + hashCode = (hashCode * -1521134295) + DictionaryHashCode(Entries); + return hashCode; + + static int DictionaryHashCode(global::System.Collections.Generic.IEnumerable>? items) + { + if (items is null) + return 0; + + int hashCode = 0; + + // sum of per-pair hashes is order-independent without sorting allocations + foreach (var item in items) + hashCode += global::System.HashCode.Combine( + global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(item.Key!), + global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(item.Value!)); + + return hashCode; + } + + } + + } +} diff --git a/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateIEnumerableSequenceEquality.verified.txt b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateIEnumerableSequenceEquality.verified.txt new file mode 100644 index 0000000..9518fde --- /dev/null +++ b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateIEnumerableSequenceEquality.verified.txt @@ -0,0 +1,67 @@ +// +#nullable enable + +namespace Equatable.Entities +{ + partial class Container : global::System.IEquatable + { + /// + public bool Equals(global::Equatable.Entities.Container? other) + { + return !(other is null) + && SequenceEquals(Items, other.Items); + + static bool SequenceEquals(global::System.Collections.Generic.IEnumerable? left, global::System.Collections.Generic.IEnumerable? right) + { + if (global::System.Object.ReferenceEquals(left, right)) + return true; + + if (left is null || right is null) + return false; + + return global::System.Linq.Enumerable.SequenceEqual(left, right, global::System.Collections.Generic.EqualityComparer.Default); + } + + } + + /// + public override bool Equals(object? obj) + { + return Equals(obj as global::Equatable.Entities.Container); + } + + /// + public static bool operator ==(global::Equatable.Entities.Container? left, global::Equatable.Entities.Container? right) + { + return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); + } + + /// + public static bool operator !=(global::Equatable.Entities.Container? left, global::Equatable.Entities.Container? right) + { + return !(left == right); + } + + /// + public override int GetHashCode(){ + int hashCode = -533317585; + hashCode = (hashCode * -1521134295) + SequenceHashCode(Items); + return hashCode; + + static int SequenceHashCode(global::System.Collections.Generic.IEnumerable? items) + { + if (items is null) + return 0; + + int hashCode = -533317585; + + foreach (var item in items) + hashCode = (hashCode * -1521134295) + global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(item!); + + return hashCode; + } + + } + + } +} diff --git a/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateIListSequenceEquality.verified.txt b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateIListSequenceEquality.verified.txt new file mode 100644 index 0000000..9518fde --- /dev/null +++ b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateIListSequenceEquality.verified.txt @@ -0,0 +1,67 @@ +// +#nullable enable + +namespace Equatable.Entities +{ + partial class Container : global::System.IEquatable + { + /// + public bool Equals(global::Equatable.Entities.Container? other) + { + return !(other is null) + && SequenceEquals(Items, other.Items); + + static bool SequenceEquals(global::System.Collections.Generic.IEnumerable? left, global::System.Collections.Generic.IEnumerable? right) + { + if (global::System.Object.ReferenceEquals(left, right)) + return true; + + if (left is null || right is null) + return false; + + return global::System.Linq.Enumerable.SequenceEqual(left, right, global::System.Collections.Generic.EqualityComparer.Default); + } + + } + + /// + public override bool Equals(object? obj) + { + return Equals(obj as global::Equatable.Entities.Container); + } + + /// + public static bool operator ==(global::Equatable.Entities.Container? left, global::Equatable.Entities.Container? right) + { + return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); + } + + /// + public static bool operator !=(global::Equatable.Entities.Container? left, global::Equatable.Entities.Container? right) + { + return !(left == right); + } + + /// + public override int GetHashCode(){ + int hashCode = -533317585; + hashCode = (hashCode * -1521134295) + SequenceHashCode(Items); + return hashCode; + + static int SequenceHashCode(global::System.Collections.Generic.IEnumerable? items) + { + if (items is null) + return 0; + + int hashCode = -533317585; + + foreach (var item in items) + hashCode = (hashCode * -1521134295) + global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(item!); + + return hashCode; + } + + } + + } +} diff --git a/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateIReadOnlyCollectionSequenceEquality.verified.txt b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateIReadOnlyCollectionSequenceEquality.verified.txt new file mode 100644 index 0000000..9518fde --- /dev/null +++ b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateIReadOnlyCollectionSequenceEquality.verified.txt @@ -0,0 +1,67 @@ +// +#nullable enable + +namespace Equatable.Entities +{ + partial class Container : global::System.IEquatable + { + /// + public bool Equals(global::Equatable.Entities.Container? other) + { + return !(other is null) + && SequenceEquals(Items, other.Items); + + static bool SequenceEquals(global::System.Collections.Generic.IEnumerable? left, global::System.Collections.Generic.IEnumerable? right) + { + if (global::System.Object.ReferenceEquals(left, right)) + return true; + + if (left is null || right is null) + return false; + + return global::System.Linq.Enumerable.SequenceEqual(left, right, global::System.Collections.Generic.EqualityComparer.Default); + } + + } + + /// + public override bool Equals(object? obj) + { + return Equals(obj as global::Equatable.Entities.Container); + } + + /// + public static bool operator ==(global::Equatable.Entities.Container? left, global::Equatable.Entities.Container? right) + { + return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); + } + + /// + public static bool operator !=(global::Equatable.Entities.Container? left, global::Equatable.Entities.Container? right) + { + return !(left == right); + } + + /// + public override int GetHashCode(){ + int hashCode = -533317585; + hashCode = (hashCode * -1521134295) + SequenceHashCode(Items); + return hashCode; + + static int SequenceHashCode(global::System.Collections.Generic.IEnumerable? items) + { + if (items is null) + return 0; + + int hashCode = -533317585; + + foreach (var item in items) + hashCode = (hashCode * -1521134295) + global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(item!); + + return hashCode; + } + + } + + } +} diff --git a/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateIReadOnlyListSequenceEquality.verified.txt b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateIReadOnlyListSequenceEquality.verified.txt new file mode 100644 index 0000000..9518fde --- /dev/null +++ b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateIReadOnlyListSequenceEquality.verified.txt @@ -0,0 +1,67 @@ +// +#nullable enable + +namespace Equatable.Entities +{ + partial class Container : global::System.IEquatable + { + /// + public bool Equals(global::Equatable.Entities.Container? other) + { + return !(other is null) + && SequenceEquals(Items, other.Items); + + static bool SequenceEquals(global::System.Collections.Generic.IEnumerable? left, global::System.Collections.Generic.IEnumerable? right) + { + if (global::System.Object.ReferenceEquals(left, right)) + return true; + + if (left is null || right is null) + return false; + + return global::System.Linq.Enumerable.SequenceEqual(left, right, global::System.Collections.Generic.EqualityComparer.Default); + } + + } + + /// + public override bool Equals(object? obj) + { + return Equals(obj as global::Equatable.Entities.Container); + } + + /// + public static bool operator ==(global::Equatable.Entities.Container? left, global::Equatable.Entities.Container? right) + { + return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); + } + + /// + public static bool operator !=(global::Equatable.Entities.Container? left, global::Equatable.Entities.Container? right) + { + return !(left == right); + } + + /// + public override int GetHashCode(){ + int hashCode = -533317585; + hashCode = (hashCode * -1521134295) + SequenceHashCode(Items); + return hashCode; + + static int SequenceHashCode(global::System.Collections.Generic.IEnumerable? items) + { + if (items is null) + return 0; + + int hashCode = -533317585; + + foreach (var item in items) + hashCode = (hashCode * -1521134295) + global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(item!); + + return hashCode; + } + + } + + } +} diff --git a/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateISetHashSetEquality.verified.txt b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateISetHashSetEquality.verified.txt new file mode 100644 index 0000000..a9bd8b7 --- /dev/null +++ b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateISetHashSetEquality.verified.txt @@ -0,0 +1,75 @@ +// +#nullable enable + +namespace Equatable.Entities +{ + partial class Container : global::System.IEquatable + { + /// + public bool Equals(global::Equatable.Entities.Container? other) + { + return !(other is null) + && HashSetEquals(Tags, other.Tags); + + static bool HashSetEquals(global::System.Collections.Generic.IEnumerable? left, global::System.Collections.Generic.IEnumerable? right) + { + if (global::System.Object.ReferenceEquals(left, right)) + return true; + + if (left is null || right is null) + return false; + + if (left is global::System.Collections.Generic.ISet leftSet) + return leftSet.SetEquals(right); + + if (right is global::System.Collections.Generic.ISet rightSet) + return rightSet.SetEquals(left); + + var hashSet = new global::System.Collections.Generic.HashSet(left, global::System.Collections.Generic.EqualityComparer.Default); + return hashSet.SetEquals(right); + } + + } + + /// + public override bool Equals(object? obj) + { + return Equals(obj as global::Equatable.Entities.Container); + } + + /// + public static bool operator ==(global::Equatable.Entities.Container? left, global::Equatable.Entities.Container? right) + { + return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); + } + + /// + public static bool operator !=(global::Equatable.Entities.Container? left, global::Equatable.Entities.Container? right) + { + return !(left == right); + } + + /// + public override int GetHashCode(){ + int hashCode = 1992138944; + hashCode = (hashCode * -1521134295) + HashSetHashCode(Tags); + return hashCode; + + static int HashSetHashCode(global::System.Collections.Generic.IEnumerable? items) + { + if (items is null) + return 0; + + int hashCode = 0; + + // sum of individual hashes is order-independent without sorting allocations + foreach (var item in items) + hashCode += global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(item!); + + return hashCode; + } + + } + + } +} diff --git a/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateListOfDicts.verified.txt b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateListOfDicts.verified.txt new file mode 100644 index 0000000..2400787 --- /dev/null +++ b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateListOfDicts.verified.txt @@ -0,0 +1,43 @@ +// +#nullable enable + +namespace Equatable.Entities +{ + partial class NestedCollections : global::System.IEquatable + { + /// + public bool Equals(global::Equatable.Entities.NestedCollections? other) + { + return !(other is null) + && (new global::Equatable.Comparers.SequenceEqualityComparer>(new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default))).Equals(ListOfDicts, other.ListOfDicts); + + } + + /// + public override bool Equals(object? obj) + { + return Equals(obj as global::Equatable.Entities.NestedCollections); + } + + /// + public static bool operator ==(global::Equatable.Entities.NestedCollections? left, global::Equatable.Entities.NestedCollections? right) + { + return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); + } + + /// + public static bool operator !=(global::Equatable.Entities.NestedCollections? left, global::Equatable.Entities.NestedCollections? right) + { + return !(left == right); + } + + /// + public override int GetHashCode(){ + int hashCode = -1546075563; + hashCode = (hashCode * -1521134295) + (new global::Equatable.Comparers.SequenceEqualityComparer>(new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default))).GetHashCode(ListOfDicts!); + return hashCode; + + } + + } +} diff --git a/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateMessagePackEquatable.verified.txt b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateMessagePackEquatable.verified.txt new file mode 100644 index 0000000..3cb0954 --- /dev/null +++ b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateMessagePackEquatable.verified.txt @@ -0,0 +1,45 @@ +// +#nullable enable + +namespace Equatable.Entities +{ + partial class PricingContract : global::System.IEquatable + { + /// + public bool Equals(global::Equatable.Entities.PricingContract? other) + { + return !(other is null) + && MarketId == other.MarketId + && Probability == other.Probability; + + } + + /// + public override bool Equals(object? obj) + { + return Equals(obj as global::Equatable.Entities.PricingContract); + } + + /// + public static bool operator ==(global::Equatable.Entities.PricingContract? left, global::Equatable.Entities.PricingContract? right) + { + return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); + } + + /// + public static bool operator !=(global::Equatable.Entities.PricingContract? left, global::Equatable.Entities.PricingContract? right) + { + return !(left == right); + } + + /// + public override int GetHashCode(){ + int hashCode = -1121495104; + hashCode = (hashCode * -1521134295) + MarketId.GetHashCode(); + hashCode = (hashCode * -1521134295) + Probability.GetHashCode(); + return hashCode; + + } + + } +} diff --git a/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateMessagePackEquatableDerivedIncludesUnannotatedBase.verified.txt b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateMessagePackEquatableDerivedIncludesUnannotatedBase.verified.txt new file mode 100644 index 0000000..8e8fb62 --- /dev/null +++ b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateMessagePackEquatableDerivedIncludesUnannotatedBase.verified.txt @@ -0,0 +1,47 @@ +// +#nullable enable + +namespace Equatable.Entities +{ + partial class ConcreteRecord : global::System.IEquatable + { + /// + public bool Equals(global::Equatable.Entities.ConcreteRecord? other) + { + return !(other is null) + && global::System.Collections.Generic.EqualityComparer.Default.Equals(Label, other.Label) + && Id == other.Id + && Score == other.Score; + + } + + /// + public override bool Equals(object? obj) + { + return Equals(obj as global::Equatable.Entities.ConcreteRecord); + } + + /// + public static bool operator ==(global::Equatable.Entities.ConcreteRecord? left, global::Equatable.Entities.ConcreteRecord? right) + { + return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); + } + + /// + public static bool operator !=(global::Equatable.Entities.ConcreteRecord? left, global::Equatable.Entities.ConcreteRecord? right) + { + return !(left == right); + } + + /// + public override int GetHashCode(){ + int hashCode = 242241058; + hashCode = (hashCode * -1521134295) + global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(Label!); + hashCode = (hashCode * -1521134295) + Id.GetHashCode(); + hashCode = (hashCode * -1521134295) + Score.GetHashCode(); + return hashCode; + + } + + } +} diff --git a/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateNestedDictionary.verified.txt b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateNestedDictionary.verified.txt new file mode 100644 index 0000000..2549354 --- /dev/null +++ b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateNestedDictionary.verified.txt @@ -0,0 +1,43 @@ +// +#nullable enable + +namespace Equatable.Entities +{ + partial class LookupTable : global::System.IEquatable + { + /// + public bool Equals(global::Equatable.Entities.LookupTable? other) + { + return !(other is null) + && (new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer>(global::System.Collections.Generic.EqualityComparer.Default, new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default))).Equals(NestedEntries, other.NestedEntries); + + } + + /// + public override bool Equals(object? obj) + { + return Equals(obj as global::Equatable.Entities.LookupTable); + } + + /// + public static bool operator ==(global::Equatable.Entities.LookupTable? left, global::Equatable.Entities.LookupTable? right) + { + return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); + } + + /// + public static bool operator !=(global::Equatable.Entities.LookupTable? left, global::Equatable.Entities.LookupTable? right) + { + return !(left == right); + } + + /// + public override int GetHashCode(){ + int hashCode = 855246738; + hashCode = (hashCode * -1521134295) + (new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer>(global::System.Collections.Generic.EqualityComparer.Default, new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default))).GetHashCode(NestedEntries!); + return hashCode; + + } + + } +} diff --git a/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateNestedSequenceInDictionary.verified.txt b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateNestedSequenceInDictionary.verified.txt new file mode 100644 index 0000000..8618a53 --- /dev/null +++ b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateNestedSequenceInDictionary.verified.txt @@ -0,0 +1,43 @@ +// +#nullable enable + +namespace Equatable.Entities +{ + partial class LookupTable : global::System.IEquatable + { + /// + public bool Equals(global::Equatable.Entities.LookupTable? other) + { + return !(other is null) + && (new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer>(global::System.Collections.Generic.EqualityComparer.Default, global::Equatable.Comparers.SequenceEqualityComparer.Default)).Equals(NestedEntries, other.NestedEntries); + + } + + /// + public override bool Equals(object? obj) + { + return Equals(obj as global::Equatable.Entities.LookupTable); + } + + /// + public static bool operator ==(global::Equatable.Entities.LookupTable? left, global::Equatable.Entities.LookupTable? right) + { + return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); + } + + /// + public static bool operator !=(global::Equatable.Entities.LookupTable? left, global::Equatable.Entities.LookupTable? right) + { + return !(left == right); + } + + /// + public override int GetHashCode(){ + int hashCode = 855246738; + hashCode = (hashCode * -1521134295) + (new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer>(global::System.Collections.Generic.EqualityComparer.Default, global::Equatable.Comparers.SequenceEqualityComparer.Default)).GetHashCode(NestedEntries!); + return hashCode; + + } + + } +} diff --git a/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateReadOnlyDictionary.verified.txt b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateReadOnlyDictionary.verified.txt new file mode 100644 index 0000000..5e289d8 --- /dev/null +++ b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateReadOnlyDictionary.verified.txt @@ -0,0 +1,104 @@ +// +#nullable enable + +namespace Equatable.Entities +{ + partial class LookupTable : global::System.IEquatable + { + /// + public bool Equals(global::Equatable.Entities.LookupTable? other) + { + return !(other is null) + && DictionaryEquals(FlatEntries, other.FlatEntries); + + static bool DictionaryEquals(global::System.Collections.Generic.IEnumerable>? left, global::System.Collections.Generic.IEnumerable>? right) + { + if (global::System.Object.ReferenceEquals(left, right)) + return true; + + if (left is null || right is null) + return false; + + if (left is global::System.Collections.Generic.IReadOnlyCollection> leftCollection && + right is global::System.Collections.Generic.IReadOnlyCollection> rightCollection && + leftCollection.Count != rightCollection.Count) + return false; + + if (right is global::System.Collections.Generic.IReadOnlyDictionary rightReadOnly) + { + foreach (var pair in left) + { + if (!rightReadOnly.TryGetValue(pair.Key, out var value)) + return false; + + if (!global::System.Collections.Generic.EqualityComparer.Default.Equals(pair.Value, value)) + return false; + } + return true; + } + + if (right is global::System.Collections.Generic.IDictionary rightDictionary) + { + foreach (var pair in left) + { + if (!rightDictionary.TryGetValue(pair.Key, out var value)) + return false; + + if (!global::System.Collections.Generic.EqualityComparer.Default.Equals(pair.Value, value)) + return false; + } + return true; + } + + return global::System.Linq.Enumerable.SequenceEqual( + global::System.Linq.Enumerable.OrderBy(left, p => p.Key), + global::System.Linq.Enumerable.OrderBy(right, p => p.Key), + global::System.Collections.Generic.EqualityComparer>.Default); + } + + } + + /// + public override bool Equals(object? obj) + { + return Equals(obj as global::Equatable.Entities.LookupTable); + } + + /// + public static bool operator ==(global::Equatable.Entities.LookupTable? left, global::Equatable.Entities.LookupTable? right) + { + return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); + } + + /// + public static bool operator !=(global::Equatable.Entities.LookupTable? left, global::Equatable.Entities.LookupTable? right) + { + return !(left == right); + } + + /// + public override int GetHashCode(){ + int hashCode = 1812938018; + hashCode = (hashCode * -1521134295) + DictionaryHashCode(FlatEntries); + return hashCode; + + static int DictionaryHashCode(global::System.Collections.Generic.IEnumerable>? items) + { + if (items is null) + return 0; + + int hashCode = 0; + + // sum of per-pair hashes is order-independent without sorting allocations + foreach (var item in items) + hashCode += global::System.HashCode.Combine( + global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(item.Key!), + global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(item.Value!)); + + return hashCode; + } + + } + + } +} diff --git a/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateSequenceEqualityMultiDimensionalArray.verified.txt b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateSequenceEqualityMultiDimensionalArray.verified.txt new file mode 100644 index 0000000..71928ed --- /dev/null +++ b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateSequenceEqualityMultiDimensionalArray.verified.txt @@ -0,0 +1,45 @@ +// +#nullable enable + +namespace Equatable.Entities +{ + partial class Grid : global::System.IEquatable + { + /// + public bool Equals(global::Equatable.Entities.Grid? other) + { + return !(other is null) + && (global::Equatable.Comparers.MultiDimensionalArrayEqualityComparer.Default).Equals(Cells, other.Cells) + && Id == other.Id; + + } + + /// + public override bool Equals(object? obj) + { + return Equals(obj as global::Equatable.Entities.Grid); + } + + /// + public static bool operator ==(global::Equatable.Entities.Grid? left, global::Equatable.Entities.Grid? right) + { + return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); + } + + /// + public static bool operator !=(global::Equatable.Entities.Grid? left, global::Equatable.Entities.Grid? right) + { + return !(left == right); + } + + /// + public override int GetHashCode(){ + int hashCode = -1091135966; + hashCode = (hashCode * -1521134295) + (global::Equatable.Comparers.MultiDimensionalArrayEqualityComparer.Default).GetHashCode(Cells!); + hashCode = (hashCode * -1521134295) + Id.GetHashCode(); + return hashCode; + + } + + } +} diff --git a/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateSequenceOfDictionaries.verified.txt b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateSequenceOfDictionaries.verified.txt new file mode 100644 index 0000000..3b8d7ab --- /dev/null +++ b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateSequenceOfDictionaries.verified.txt @@ -0,0 +1,43 @@ +// +#nullable enable + +namespace Equatable.Entities +{ + partial class LookupTable : global::System.IEquatable + { + /// + public bool Equals(global::Equatable.Entities.LookupTable? other) + { + return !(other is null) + && (new global::Equatable.Comparers.SequenceEqualityComparer>(new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default))).Equals(Items, other.Items); + + } + + /// + public override bool Equals(object? obj) + { + return Equals(obj as global::Equatable.Entities.LookupTable); + } + + /// + public static bool operator ==(global::Equatable.Entities.LookupTable? left, global::Equatable.Entities.LookupTable? right) + { + return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); + } + + /// + public static bool operator !=(global::Equatable.Entities.LookupTable? left, global::Equatable.Entities.LookupTable? right) + { + return !(left == right); + } + + /// + public override int GetHashCode(){ + int hashCode = -533317585; + hashCode = (hashCode * -1521134295) + (new global::Equatable.Comparers.SequenceEqualityComparer>(new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default))).GetHashCode(Items!); + return hashCode; + + } + + } +} diff --git a/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateThreeLevelNested.verified.txt b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateThreeLevelNested.verified.txt new file mode 100644 index 0000000..e769fd8 --- /dev/null +++ b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateThreeLevelNested.verified.txt @@ -0,0 +1,43 @@ +// +#nullable enable + +namespace Equatable.Entities +{ + partial class NestedCollections : global::System.IEquatable + { + /// + public bool Equals(global::Equatable.Entities.NestedCollections? other) + { + return !(other is null) + && (new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer>>(global::System.Collections.Generic.EqualityComparer.Default, new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer>(global::System.Collections.Generic.EqualityComparer.Default, global::Equatable.Comparers.SequenceEqualityComparer.Default))).Equals(ThreeLevelNested, other.ThreeLevelNested); + + } + + /// + public override bool Equals(object? obj) + { + return Equals(obj as global::Equatable.Entities.NestedCollections); + } + + /// + public static bool operator ==(global::Equatable.Entities.NestedCollections? left, global::Equatable.Entities.NestedCollections? right) + { + return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); + } + + /// + public static bool operator !=(global::Equatable.Entities.NestedCollections? left, global::Equatable.Entities.NestedCollections? right) + { + return !(left == right); + } + + /// + public override int GetHashCode(){ + int hashCode = 1356435064; + hashCode = (hashCode * -1521134295) + (new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer>>(global::System.Collections.Generic.EqualityComparer.Default, new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer>(global::System.Collections.Generic.EqualityComparer.Default, global::Equatable.Comparers.SequenceEqualityComparer.Default))).GetHashCode(ThreeLevelNested!); + return hashCode; + + } + + } +} diff --git a/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateUserImport.verified.txt b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateUserImport.verified.txt index effd3b5..03e6366 100644 --- a/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateUserImport.verified.txt +++ b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateUserImport.verified.txt @@ -19,7 +19,7 @@ namespace Equatable.Entities && DictionaryEquals(Permissions, other.Permissions) && SequenceEquals(History, other.History); - static bool DictionaryEquals(global::System.Collections.Generic.IDictionary? left, global::System.Collections.Generic.IDictionary? right) + static bool DictionaryEquals(global::System.Collections.Generic.IEnumerable>? left, global::System.Collections.Generic.IEnumerable>? right) { if (global::System.Object.ReferenceEquals(left, right)) return true; @@ -27,20 +27,41 @@ namespace Equatable.Entities if (left is null || right is null) return false; - if (left.Count != right.Count) + if (left is global::System.Collections.Generic.IReadOnlyCollection> leftCollection && + right is global::System.Collections.Generic.IReadOnlyCollection> rightCollection && + leftCollection.Count != rightCollection.Count) return false; - foreach (var pair in left) + if (right is global::System.Collections.Generic.IReadOnlyDictionary rightReadOnly) { - if (!right.TryGetValue(pair.Key, out var value)) - return false; - - if (!global::System.Collections.Generic.EqualityComparer.Default.Equals(pair.Value, value)) - return false; + foreach (var pair in left) + { + if (!rightReadOnly.TryGetValue(pair.Key, out var value)) + return false; + + if (!global::System.Collections.Generic.EqualityComparer.Default.Equals(pair.Value, value)) + return false; + } + return true; + } + if (right is global::System.Collections.Generic.IDictionary rightDictionary) + { + foreach (var pair in left) + { + if (!rightDictionary.TryGetValue(pair.Key, out var value)) + return false; + + if (!global::System.Collections.Generic.EqualityComparer.Default.Equals(pair.Value, value)) + return false; + } + return true; } - return true; + return global::System.Linq.Enumerable.SequenceEqual( + global::System.Linq.Enumerable.OrderBy(left, p => p.Key), + global::System.Linq.Enumerable.OrderBy(right, p => p.Key), + global::System.Collections.Generic.EqualityComparer>.Default); } static bool HashSetEquals(global::System.Collections.Generic.IEnumerable? left, global::System.Collections.Generic.IEnumerable? right) @@ -106,19 +127,18 @@ namespace Equatable.Entities hashCode = (hashCode * -1521134295) + SequenceHashCode(History); return hashCode; - static int DictionaryHashCode(global::System.Collections.Generic.IDictionary? items) + static int DictionaryHashCode(global::System.Collections.Generic.IEnumerable>? items) { if (items is null) return 0; - int hashCode = 275986352; + int hashCode = 0; - // sort by key to ensure dictionary with different order are the same - foreach (var item in global::System.Linq.Enumerable.OrderBy(items, d => d.Key)) - { - hashCode = (hashCode * -1521134295) + global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(item.Key!); - hashCode = (hashCode * -1521134295) + global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(item.Value!); - } + // sum of per-pair hashes is order-independent without sorting allocations + foreach (var item in items) + hashCode += global::System.HashCode.Combine( + global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(item.Key!), + global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(item.Value!)); return hashCode; } @@ -128,11 +148,11 @@ namespace Equatable.Entities if (items is null) return 0; - int hashCode = 275986352; + int hashCode = 0; - // sort to ensure set with different order are the same - foreach (var item in global::System.Linq.Enumerable.OrderBy(items, d => d)) - hashCode = (hashCode * -1521134295) + global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(item!); + // sum of individual hashes is order-independent without sorting allocations + foreach (var item in items) + hashCode += global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(item!); return hashCode; } diff --git a/test/Equatable.Generator.Tests/Snapshots/EquatableWriterTest.GenerateUserImportHashSetDictionary.verified.txt b/test/Equatable.Generator.Tests/Snapshots/EquatableWriterTest.GenerateUserImportHashSetDictionary.verified.txt index d271508..5ddc89a 100644 --- a/test/Equatable.Generator.Tests/Snapshots/EquatableWriterTest.GenerateUserImportHashSetDictionary.verified.txt +++ b/test/Equatable.Generator.Tests/Snapshots/EquatableWriterTest.GenerateUserImportHashSetDictionary.verified.txt @@ -18,7 +18,7 @@ namespace Equatable.Entities && HashSetEquals(Roles, other.Roles) && DictionaryEquals(Permissions, other.Permissions); - static bool DictionaryEquals(global::System.Collections.Generic.IDictionary? left, global::System.Collections.Generic.IDictionary? right) + static bool DictionaryEquals(global::System.Collections.Generic.IEnumerable>? left, global::System.Collections.Generic.IEnumerable>? right) { if (global::System.Object.ReferenceEquals(left, right)) return true; @@ -26,20 +26,41 @@ namespace Equatable.Entities if (left is null || right is null) return false; - if (left.Count != right.Count) + if (left is global::System.Collections.Generic.IReadOnlyCollection> leftCollection && + right is global::System.Collections.Generic.IReadOnlyCollection> rightCollection && + leftCollection.Count != rightCollection.Count) return false; - foreach (var pair in left) + if (right is global::System.Collections.Generic.IReadOnlyDictionary rightReadOnly) { - if (!right.TryGetValue(pair.Key, out var value)) - return false; - - if (!global::System.Collections.Generic.EqualityComparer.Default.Equals(pair.Value, value)) - return false; + foreach (var pair in left) + { + if (!rightReadOnly.TryGetValue(pair.Key, out var value)) + return false; + + if (!global::System.Collections.Generic.EqualityComparer.Default.Equals(pair.Value, value)) + return false; + } + return true; + } + if (right is global::System.Collections.Generic.IDictionary rightDictionary) + { + foreach (var pair in left) + { + if (!rightDictionary.TryGetValue(pair.Key, out var value)) + return false; + + if (!global::System.Collections.Generic.EqualityComparer.Default.Equals(pair.Value, value)) + return false; + } + return true; } - return true; + return global::System.Linq.Enumerable.SequenceEqual( + global::System.Linq.Enumerable.OrderBy(left, p => p.Key), + global::System.Linq.Enumerable.OrderBy(right, p => p.Key), + global::System.Collections.Generic.EqualityComparer>.Default); } static bool HashSetEquals(global::System.Collections.Generic.IEnumerable? left, global::System.Collections.Generic.IEnumerable? right) @@ -93,19 +114,18 @@ namespace Equatable.Entities hashCode = (hashCode * -1521134295) + DictionaryHashCode(Permissions); return hashCode; - static int DictionaryHashCode(global::System.Collections.Generic.IDictionary? items) + static int DictionaryHashCode(global::System.Collections.Generic.IEnumerable>? items) { if (items is null) return 0; - int hashCode = -1758092530; + int hashCode = 0; - // sort by key to ensure dictionary with different order are the same - foreach (var item in global::System.Linq.Enumerable.OrderBy(items, d => d.Key)) - { - hashCode = (hashCode * -1521134295) + global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(item.Key!); - hashCode = (hashCode * -1521134295) + global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(item.Value!); - } + // sum of per-pair hashes is order-independent without sorting allocations + foreach (var item in items) + hashCode += global::System.HashCode.Combine( + global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(item.Key!), + global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(item.Value!)); return hashCode; } @@ -115,11 +135,11 @@ namespace Equatable.Entities if (items is null) return 0; - int hashCode = -1758092530; + int hashCode = 0; - // sort to ensure set with different order are the same - foreach (var item in global::System.Linq.Enumerable.OrderBy(items, d => d)) - hashCode = (hashCode * -1521134295) + global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(item!); + // sum of individual hashes is order-independent without sorting allocations + foreach (var item in items) + hashCode += global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(item!); return hashCode; } From 4c8c29c032157d5f94d900729f2e049cc457ab65 Mon Sep 17 00:00:00 2001 From: "a.kasyanov" Date: Tue, 12 May 2026 01:13:25 +0300 Subject: [PATCH 03/71] refactor: split adapter generators into separate packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Core EquatableGenerator no longer knows about DataContract or MessagePack. RegisterProvider is now public static so adapter generators can call it directly. GetBaseEquatableType detects any *EquatableAttribute in Equatable.Attributes namespace instead of hard-coding adapter names. New packages: - Equatable.SourceGenerator.DataContract — DataContractEquatableGenerator - Equatable.SourceGenerator.MessagePack — MessagePackEquatableGenerator - Equatable.Generator.DataContract — NuGet wrapper + DataContractEquatableAttribute - Equatable.Generator.MessagePack — NuGet wrapper + MessagePackEquatableAttribute DataContractEquatableAttribute and MessagePackEquatableAttribute moved out of Equatable.Generator into their respective adapter packages. Co-Authored-By: Claude Sonnet 4.5 --- .../DataContractEquatableAttribute.cs | 0 .../Equatable.Generator.DataContract.csproj | 15 +++++ .../MessagePackEquatableAttribute.cs | 0 .../Equatable.Generator.MessagePack.csproj | 15 +++++ .../DataContractEquatableGenerator.cs | 39 +++++++++++++ ...atable.SourceGenerator.DataContract.csproj | 19 +++++++ ...uatable.SourceGenerator.MessagePack.csproj | 19 +++++++ .../MessagePackEquatableGenerator.cs | 30 ++++++++++ .../EquatableAnalyzer.cs | 3 +- .../EquatableGenerator.cs | 56 ++----------------- .../Equatable.Entities.csproj | 10 ++++ .../Equatable.Generator.Tests.csproj | 10 ++++ .../EquatableGeneratorTest.cs | 17 ++++-- 13 files changed, 173 insertions(+), 60 deletions(-) rename src/{Equatable.Generator => Equatable.Generator.DataContract}/Attributes/DataContractEquatableAttribute.cs (100%) create mode 100644 src/Equatable.Generator.DataContract/Equatable.Generator.DataContract.csproj rename src/{Equatable.Generator => Equatable.Generator.MessagePack}/Attributes/MessagePackEquatableAttribute.cs (100%) create mode 100644 src/Equatable.Generator.MessagePack/Equatable.Generator.MessagePack.csproj create mode 100644 src/Equatable.SourceGenerator.DataContract/DataContractEquatableGenerator.cs create mode 100644 src/Equatable.SourceGenerator.DataContract/Equatable.SourceGenerator.DataContract.csproj create mode 100644 src/Equatable.SourceGenerator.MessagePack/Equatable.SourceGenerator.MessagePack.csproj create mode 100644 src/Equatable.SourceGenerator.MessagePack/MessagePackEquatableGenerator.cs diff --git a/src/Equatable.Generator/Attributes/DataContractEquatableAttribute.cs b/src/Equatable.Generator.DataContract/Attributes/DataContractEquatableAttribute.cs similarity index 100% rename from src/Equatable.Generator/Attributes/DataContractEquatableAttribute.cs rename to src/Equatable.Generator.DataContract/Attributes/DataContractEquatableAttribute.cs diff --git a/src/Equatable.Generator.DataContract/Equatable.Generator.DataContract.csproj b/src/Equatable.Generator.DataContract/Equatable.Generator.DataContract.csproj new file mode 100644 index 0000000..47ebc80 --- /dev/null +++ b/src/Equatable.Generator.DataContract/Equatable.Generator.DataContract.csproj @@ -0,0 +1,15 @@ + + + + netstandard2.0;net8.0;net9.0;net10.0 + Equatable + Source generator for Equals and GetHashCode for types using System.Runtime.Serialization DataMember attributes + + + + + + + + + diff --git a/src/Equatable.Generator/Attributes/MessagePackEquatableAttribute.cs b/src/Equatable.Generator.MessagePack/Attributes/MessagePackEquatableAttribute.cs similarity index 100% rename from src/Equatable.Generator/Attributes/MessagePackEquatableAttribute.cs rename to src/Equatable.Generator.MessagePack/Attributes/MessagePackEquatableAttribute.cs diff --git a/src/Equatable.Generator.MessagePack/Equatable.Generator.MessagePack.csproj b/src/Equatable.Generator.MessagePack/Equatable.Generator.MessagePack.csproj new file mode 100644 index 0000000..799c37f --- /dev/null +++ b/src/Equatable.Generator.MessagePack/Equatable.Generator.MessagePack.csproj @@ -0,0 +1,15 @@ + + + + netstandard2.0;net8.0;net9.0;net10.0 + Equatable + Source generator for Equals and GetHashCode for types using MessagePack Key attributes + + + + + + + + + diff --git a/src/Equatable.SourceGenerator.DataContract/DataContractEquatableGenerator.cs b/src/Equatable.SourceGenerator.DataContract/DataContractEquatableGenerator.cs new file mode 100644 index 0000000..453cb91 --- /dev/null +++ b/src/Equatable.SourceGenerator.DataContract/DataContractEquatableGenerator.cs @@ -0,0 +1,39 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Equatable.SourceGenerator.DataContract; + +[Generator] +public class DataContractEquatableGenerator : IIncrementalGenerator +{ + public void Initialize(IncrementalGeneratorInitializationContext context) + { + EquatableGenerator.RegisterProvider(context, + fullyQualifiedMetadataName: "Equatable.Attributes.DataContractEquatableAttribute", + trackingName: "DataContractEquatableAttribute", + propertyFilter: IsIncludedDataContract); + } + + private static bool IsIncludedDataContract(IPropertySymbol propertySymbol) + { + if (propertySymbol.IsIndexer || propertySymbol.DeclaredAccessibility != Accessibility.Public) + return false; + + var attributes = propertySymbol.GetAttributes(); + if (attributes.Length == 0) + return false; + + if (attributes.Any(a => a.AttributeClass is + { + Name: "IgnoreDataMemberAttribute", + ContainingNamespace: { Name: "Serialization", ContainingNamespace: { Name: "Runtime", ContainingNamespace.Name: "System" } } + })) + return false; + + return attributes.Any(a => a.AttributeClass is + { + Name: "DataMemberAttribute", + ContainingNamespace: { Name: "Serialization", ContainingNamespace: { Name: "Runtime", ContainingNamespace.Name: "System" } } + }); + } +} diff --git a/src/Equatable.SourceGenerator.DataContract/Equatable.SourceGenerator.DataContract.csproj b/src/Equatable.SourceGenerator.DataContract/Equatable.SourceGenerator.DataContract.csproj new file mode 100644 index 0000000..5364bd9 --- /dev/null +++ b/src/Equatable.SourceGenerator.DataContract/Equatable.SourceGenerator.DataContract.csproj @@ -0,0 +1,19 @@ + + + + netstandard2.0 + true + false + cs + true + + + + + + + + + + + diff --git a/src/Equatable.SourceGenerator.MessagePack/Equatable.SourceGenerator.MessagePack.csproj b/src/Equatable.SourceGenerator.MessagePack/Equatable.SourceGenerator.MessagePack.csproj new file mode 100644 index 0000000..5364bd9 --- /dev/null +++ b/src/Equatable.SourceGenerator.MessagePack/Equatable.SourceGenerator.MessagePack.csproj @@ -0,0 +1,19 @@ + + + + netstandard2.0 + true + false + cs + true + + + + + + + + + + + diff --git a/src/Equatable.SourceGenerator.MessagePack/MessagePackEquatableGenerator.cs b/src/Equatable.SourceGenerator.MessagePack/MessagePackEquatableGenerator.cs new file mode 100644 index 0000000..e29eac0 --- /dev/null +++ b/src/Equatable.SourceGenerator.MessagePack/MessagePackEquatableGenerator.cs @@ -0,0 +1,30 @@ +using Microsoft.CodeAnalysis; + +namespace Equatable.SourceGenerator.MessagePack; + +[Generator] +public class MessagePackEquatableGenerator : IIncrementalGenerator +{ + public void Initialize(IncrementalGeneratorInitializationContext context) + { + EquatableGenerator.RegisterProvider(context, + fullyQualifiedMetadataName: "Equatable.Attributes.MessagePackEquatableAttribute", + trackingName: "MessagePackEquatableAttribute", + propertyFilter: IsIncludedMessagePack); + } + + private static bool IsIncludedMessagePack(IPropertySymbol propertySymbol) + { + if (propertySymbol.IsIndexer || propertySymbol.DeclaredAccessibility != Accessibility.Public) + return false; + + var attributes = propertySymbol.GetAttributes(); + if (attributes.Length == 0) + return false; + + if (attributes.Any(a => a.AttributeClass is { Name: "IgnoreMemberAttribute", ContainingNamespace.Name: "MessagePack" })) + return false; + + return attributes.Any(a => a.AttributeClass is { Name: "KeyAttribute", ContainingNamespace.Name: "MessagePack" }); + } +} diff --git a/src/Equatable.SourceGenerator/EquatableAnalyzer.cs b/src/Equatable.SourceGenerator/EquatableAnalyzer.cs index c148a05..905c34f 100644 --- a/src/Equatable.SourceGenerator/EquatableAnalyzer.cs +++ b/src/Equatable.SourceGenerator/EquatableAnalyzer.cs @@ -143,8 +143,7 @@ private static void AnalyzeProperty(SymbolAnalysisContext context, IPropertySymb private static bool HasEquatableAttribute(INamedTypeSymbol typeSymbol) { return typeSymbol.GetAttributes().Any( - a => IsKnownAttribute(a) && a.AttributeClass?.Name is - "EquatableAttribute" or "DataContractEquatableAttribute" or "MessagePackEquatableAttribute"); + a => IsKnownAttribute(a) && a.AttributeClass?.Name == "EquatableAttribute"); } private static bool IsIgnored(IPropertySymbol propertySymbol) diff --git a/src/Equatable.SourceGenerator/EquatableGenerator.cs b/src/Equatable.SourceGenerator/EquatableGenerator.cs index 58feed7..804db23 100644 --- a/src/Equatable.SourceGenerator/EquatableGenerator.cs +++ b/src/Equatable.SourceGenerator/EquatableGenerator.cs @@ -18,19 +18,9 @@ public void Initialize(IncrementalGeneratorInitializationContext context) fullyQualifiedMetadataName: "Equatable.Attributes.EquatableAttribute", trackingName: "EquatableAttribute", propertyFilter: IsIncluded); - - RegisterProvider(context, - fullyQualifiedMetadataName: "Equatable.Attributes.DataContractEquatableAttribute", - trackingName: "DataContractEquatableAttribute", - propertyFilter: IsIncludedDataContract); - - RegisterProvider(context, - fullyQualifiedMetadataName: "Equatable.Attributes.MessagePackEquatableAttribute", - trackingName: "MessagePackEquatableAttribute", - propertyFilter: IsIncludedMessagePack); } - private static void RegisterProvider( + public static void RegisterProvider( IncrementalGeneratorInitializationContext context, string fullyQualifiedMetadataName, string trackingName, @@ -49,7 +39,7 @@ private static void RegisterProvider( } - private static void Execute(SourceProductionContext context, EquatableClass? entityClass) + public static void Execute(SourceProductionContext context, EquatableClass? entityClass) { if (entityClass == null) return; @@ -476,44 +466,6 @@ private static bool IsIncluded(IPropertySymbol propertySymbol) return !propertySymbol.IsIndexer && propertySymbol.DeclaredAccessibility == Accessibility.Public; } - private static bool IsIncludedDataContract(IPropertySymbol propertySymbol) - { - if (propertySymbol.IsIndexer || propertySymbol.DeclaredAccessibility != Accessibility.Public) - return false; - - var attributes = propertySymbol.GetAttributes(); - if (attributes.Length == 0) - return false; - - if (attributes.Any(a => a.AttributeClass is - { - Name: "IgnoreDataMemberAttribute", - ContainingNamespace: { Name: "Serialization", ContainingNamespace: { Name: "Runtime", ContainingNamespace.Name: "System" } } - })) - return false; - - return attributes.Any(a => a.AttributeClass is - { - Name: "DataMemberAttribute", - ContainingNamespace: { Name: "Serialization", ContainingNamespace: { Name: "Runtime", ContainingNamespace.Name: "System" } } - }); - } - - private static bool IsIncludedMessagePack(IPropertySymbol propertySymbol) - { - if (propertySymbol.IsIndexer || propertySymbol.DeclaredAccessibility != Accessibility.Public) - return false; - - var attributes = propertySymbol.GetAttributes(); - if (attributes.Length == 0) - return false; - - if (attributes.Any(a => a.AttributeClass is { Name: "IgnoreMemberAttribute", ContainingNamespace.Name: "MessagePack" })) - return false; - - return attributes.Any(a => a.AttributeClass is { Name: "KeyAttribute", ContainingNamespace.Name: "MessagePack" }); - } - private static bool IsKnownAttribute(AttributeData? attribute) { if (attribute == null) @@ -701,8 +653,8 @@ private static EquatableArray GetContainingTypes(INamedTypeSymb return null; var attributes = currentSymbol.GetAttributes(); - if (attributes.Length > 0 && attributes.Any(a => IsKnownAttribute(a) && a.AttributeClass?.Name is - "EquatableAttribute" or "DataContractEquatableAttribute" or "MessagePackEquatableAttribute")) + if (attributes.Length > 0 && attributes.Any(a => IsKnownAttribute(a) + && a.AttributeClass?.Name.EndsWith("EquatableAttribute") == true)) { return currentSymbol; } diff --git a/test/Equatable.Entities/Equatable.Entities.csproj b/test/Equatable.Entities/Equatable.Entities.csproj index 41354ae..31ede8b 100644 --- a/test/Equatable.Entities/Equatable.Entities.csproj +++ b/test/Equatable.Entities/Equatable.Entities.csproj @@ -19,10 +19,20 @@ + + Analyzer false + + Analyzer + false + + + Analyzer + false + diff --git a/test/Equatable.Generator.Tests/Equatable.Generator.Tests.csproj b/test/Equatable.Generator.Tests/Equatable.Generator.Tests.csproj index a53ada1..2ed9b00 100644 --- a/test/Equatable.Generator.Tests/Equatable.Generator.Tests.csproj +++ b/test/Equatable.Generator.Tests/Equatable.Generator.Tests.csproj @@ -28,10 +28,20 @@ + + Analyzer true + + Analyzer + true + + + Analyzer + true + diff --git a/test/Equatable.Generator.Tests/EquatableGeneratorTest.cs b/test/Equatable.Generator.Tests/EquatableGeneratorTest.cs index fa0b172..73c7931 100644 --- a/test/Equatable.Generator.Tests/EquatableGeneratorTest.cs +++ b/test/Equatable.Generator.Tests/EquatableGeneratorTest.cs @@ -2,6 +2,8 @@ using Equatable.Attributes; using Equatable.SourceGenerator; +using Equatable.SourceGenerator.DataContract; +using Equatable.SourceGenerator.MessagePack; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; @@ -761,7 +763,7 @@ public partial class OrderDataContract } "; - var (diagnostics, output) = GetGeneratedOutput(source); + var (diagnostics, output) = GetGeneratedOutput(source); Assert.Empty(diagnostics); @@ -796,7 +798,7 @@ public partial class PricingContract } "; - var (diagnostics, output) = GetGeneratedOutput(source); + var (diagnostics, output) = GetGeneratedOutput(source); Assert.Empty(diagnostics); @@ -901,7 +903,7 @@ public abstract class UnannotatedBase public string? Name { get; set; } } "; - var (diagnostics, output) = GetNamedGeneratedOutput(source, "ConcreteRecord"); + var (diagnostics, output) = GetNamedGeneratedOutput(source, "ConcreteRecord"); Assert.Empty(diagnostics); return Verifier.Verify(output).UseDirectory("Snapshots").ScrubLinesContaining("GeneratedCodeAttribute"); } @@ -931,7 +933,7 @@ public abstract class UnannotatedBase public double Score { get; set; } } "; - var (diagnostics, output) = GetNamedGeneratedOutput(source, "ConcreteRecord"); + var (diagnostics, output) = GetNamedGeneratedOutput(source, "ConcreteRecord"); Assert.Empty(diagnostics); return Verifier.Verify(output).UseDirectory("Snapshots").ScrubLinesContaining("GeneratedCodeAttribute"); } @@ -1067,12 +1069,13 @@ public partial class Container } // Pinned references that must always be present regardless of AppDomain load order. - // DataMemberAttribute and KeyAttribute live in separately-loaded assemblies that may not - // yet be in the AppDomain when a test runs first. + // Adapter attribute assemblies and serialization libraries may not be loaded when a test runs first. private static readonly IEnumerable PinnedReferences = [ MetadataReference.CreateFromFile(typeof(System.Runtime.Serialization.DataMemberAttribute).Assembly.Location), MetadataReference.CreateFromFile(typeof(MessagePack.KeyAttribute).Assembly.Location), + MetadataReference.CreateFromFile(typeof(Equatable.Attributes.DataContractEquatableAttribute).Assembly.Location), + MetadataReference.CreateFromFile(typeof(Equatable.Attributes.MessagePackEquatableAttribute).Assembly.Location), ]; private static IEnumerable BuildReferences() @@ -1084,6 +1087,8 @@ private static IEnumerable BuildReferences() [ MetadataReference.CreateFromFile(typeof(T).Assembly.Location), MetadataReference.CreateFromFile(typeof(EquatableAttribute).Assembly.Location), + MetadataReference.CreateFromFile(typeof(DataContractEquatableGenerator).Assembly.Location), + MetadataReference.CreateFromFile(typeof(MessagePackEquatableGenerator).Assembly.Location), ]) .Concat(PinnedReferences); From ebc5bcbf9709a58922f50f94d258253715f90a09 Mon Sep 17 00:00:00 2001 From: "a.kasyanov" Date: Tue, 12 May 2026 01:28:44 +0300 Subject: [PATCH 04/71] feat: add EQ0020/EQ0021 analyzers for missing DataContract/MessagePackObject MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DataContractEquatableAnalyzer (EQ0020): warns when [DataContractEquatable] is present on a class without [DataContract] — DataMember attributes are silently ignored by DataContractSerializer without it. MessagePackEquatableAnalyzer (EQ0021): warns when [MessagePackEquatable] is present on a class without [MessagePackObject] — Key attributes are ignored by the MessagePack serializer without it. Also add [DataContract]/[MessagePackObject] to all test entities and generator test sources to reflect real-world usage accurately. Co-Authored-By: Claude Sonnet 4.5 --- .../AnalyzerReleases.Shipped.md | 2 + .../AnalyzerReleases.Unshipped.md | 8 ++ .../DataContractEquatableAnalyzer.cs | 57 ++++++++++ .../AnalyzerReleases.Shipped.md | 2 + .../AnalyzerReleases.Unshipped.md | 8 ++ .../MessagePackEquatableAnalyzer.cs | 57 ++++++++++ test/Equatable.Entities/OrderDataContract.cs | 1 + .../EquatableAnalyzerTest.cs | 107 +++++++++++++++++- .../EquatableGeneratorTest.cs | 6 + 9 files changed, 245 insertions(+), 3 deletions(-) create mode 100644 src/Equatable.SourceGenerator.DataContract/AnalyzerReleases.Shipped.md create mode 100644 src/Equatable.SourceGenerator.DataContract/AnalyzerReleases.Unshipped.md create mode 100644 src/Equatable.SourceGenerator.DataContract/DataContractEquatableAnalyzer.cs create mode 100644 src/Equatable.SourceGenerator.MessagePack/AnalyzerReleases.Shipped.md create mode 100644 src/Equatable.SourceGenerator.MessagePack/AnalyzerReleases.Unshipped.md create mode 100644 src/Equatable.SourceGenerator.MessagePack/MessagePackEquatableAnalyzer.cs diff --git a/src/Equatable.SourceGenerator.DataContract/AnalyzerReleases.Shipped.md b/src/Equatable.SourceGenerator.DataContract/AnalyzerReleases.Shipped.md new file mode 100644 index 0000000..ad21bcb --- /dev/null +++ b/src/Equatable.SourceGenerator.DataContract/AnalyzerReleases.Shipped.md @@ -0,0 +1,2 @@ +; Shipped analyzer releases +; https://github.com/dotnet/roslyn-analyzers/blob/master/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md diff --git a/src/Equatable.SourceGenerator.DataContract/AnalyzerReleases.Unshipped.md b/src/Equatable.SourceGenerator.DataContract/AnalyzerReleases.Unshipped.md new file mode 100644 index 0000000..940a865 --- /dev/null +++ b/src/Equatable.SourceGenerator.DataContract/AnalyzerReleases.Unshipped.md @@ -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 diff --git a/src/Equatable.SourceGenerator.DataContract/DataContractEquatableAnalyzer.cs b/src/Equatable.SourceGenerator.DataContract/DataContractEquatableAnalyzer.cs new file mode 100644 index 0000000..a473e3e --- /dev/null +++ b/src/Equatable.SourceGenerator.DataContract/DataContractEquatableAnalyzer.cs @@ -0,0 +1,57 @@ +using System.Collections.Immutable; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Equatable.SourceGenerator.DataContract; + +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public class DataContractEquatableAnalyzer : DiagnosticAnalyzer +{ + private static readonly DiagnosticDescriptor MissingDataContractAttribute = new( + id: "EQ0020", + title: "Missing DataContract Attribute", + messageFormat: "'{0}' is marked with [DataContractEquatable] but is missing [DataContract]. DataMember attributes are ignored by DataContractSerializer without it.", + category: "Usage", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); + + public override ImmutableArray SupportedDiagnostics => + ImmutableArray.Create(MissingDataContractAttribute); + + public override void Initialize(AnalysisContext context) + { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.RegisterSymbolAction(AnalyzeNamedType, SymbolKind.NamedType); + } + + private static void AnalyzeNamedType(SymbolAnalysisContext context) + { + var typeSymbol = (INamedTypeSymbol)context.Symbol; + + if (!HasDataContractEquatableAttribute(typeSymbol)) + return; + + if (HasDataContractAttribute(typeSymbol)) + return; + + var location = typeSymbol.Locations.FirstOrDefault(); + context.ReportDiagnostic(Diagnostic.Create(MissingDataContractAttribute, location, typeSymbol.Name)); + } + + private static bool HasDataContractEquatableAttribute(INamedTypeSymbol typeSymbol) => + typeSymbol.GetAttributes().Any(a => a.AttributeClass is + { + Name: "DataContractEquatableAttribute", + ContainingNamespace: { Name: "Attributes", ContainingNamespace.Name: "Equatable" } + }); + + private static bool HasDataContractAttribute(INamedTypeSymbol typeSymbol) => + typeSymbol.GetAttributes().Any(a => a.AttributeClass is + { + Name: "DataContractAttribute", + ContainingNamespace: { Name: "Serialization", ContainingNamespace: { Name: "Runtime", ContainingNamespace.Name: "System" } } + }); +} diff --git a/src/Equatable.SourceGenerator.MessagePack/AnalyzerReleases.Shipped.md b/src/Equatable.SourceGenerator.MessagePack/AnalyzerReleases.Shipped.md new file mode 100644 index 0000000..ad21bcb --- /dev/null +++ b/src/Equatable.SourceGenerator.MessagePack/AnalyzerReleases.Shipped.md @@ -0,0 +1,2 @@ +; Shipped analyzer releases +; https://github.com/dotnet/roslyn-analyzers/blob/master/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md diff --git a/src/Equatable.SourceGenerator.MessagePack/AnalyzerReleases.Unshipped.md b/src/Equatable.SourceGenerator.MessagePack/AnalyzerReleases.Unshipped.md new file mode 100644 index 0000000..70e2847 --- /dev/null +++ b/src/Equatable.SourceGenerator.MessagePack/AnalyzerReleases.Unshipped.md @@ -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 +--------|----------|----------|------------------ +EQ0021 | Usage | Warning | MessagePackEquatableAnalyzer diff --git a/src/Equatable.SourceGenerator.MessagePack/MessagePackEquatableAnalyzer.cs b/src/Equatable.SourceGenerator.MessagePack/MessagePackEquatableAnalyzer.cs new file mode 100644 index 0000000..23bd951 --- /dev/null +++ b/src/Equatable.SourceGenerator.MessagePack/MessagePackEquatableAnalyzer.cs @@ -0,0 +1,57 @@ +using System.Collections.Immutable; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Equatable.SourceGenerator.MessagePack; + +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public class MessagePackEquatableAnalyzer : DiagnosticAnalyzer +{ + private static readonly DiagnosticDescriptor MissingMessagePackObjectAttribute = new( + id: "EQ0021", + title: "Missing MessagePackObject Attribute", + messageFormat: "'{0}' is marked with [MessagePackEquatable] but is missing [MessagePackObject]. Key attributes are ignored by MessagePack serializer without it.", + category: "Usage", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); + + public override ImmutableArray SupportedDiagnostics => + ImmutableArray.Create(MissingMessagePackObjectAttribute); + + public override void Initialize(AnalysisContext context) + { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.RegisterSymbolAction(AnalyzeNamedType, SymbolKind.NamedType); + } + + private static void AnalyzeNamedType(SymbolAnalysisContext context) + { + var typeSymbol = (INamedTypeSymbol)context.Symbol; + + if (!HasMessagePackEquatableAttribute(typeSymbol)) + return; + + if (HasMessagePackObjectAttribute(typeSymbol)) + return; + + var location = typeSymbol.Locations.FirstOrDefault(); + context.ReportDiagnostic(Diagnostic.Create(MissingMessagePackObjectAttribute, location, typeSymbol.Name)); + } + + private static bool HasMessagePackEquatableAttribute(INamedTypeSymbol typeSymbol) => + typeSymbol.GetAttributes().Any(a => a.AttributeClass is + { + Name: "MessagePackEquatableAttribute", + ContainingNamespace: { Name: "Attributes", ContainingNamespace.Name: "Equatable" } + }); + + private static bool HasMessagePackObjectAttribute(INamedTypeSymbol typeSymbol) => + typeSymbol.GetAttributes().Any(a => a.AttributeClass is + { + Name: "MessagePackObjectAttribute", + ContainingNamespace.Name: "MessagePack" + }); +} diff --git a/test/Equatable.Entities/OrderDataContract.cs b/test/Equatable.Entities/OrderDataContract.cs index 1e34a92..f3ffcd4 100644 --- a/test/Equatable.Entities/OrderDataContract.cs +++ b/test/Equatable.Entities/OrderDataContract.cs @@ -4,6 +4,7 @@ namespace Equatable.Entities; +[DataContract] [DataContractEquatable] public partial class OrderDataContract { diff --git a/test/Equatable.Generator.Tests/EquatableAnalyzerTest.cs b/test/Equatable.Generator.Tests/EquatableAnalyzerTest.cs index ba33aab..dac1ce2 100644 --- a/test/Equatable.Generator.Tests/EquatableAnalyzerTest.cs +++ b/test/Equatable.Generator.Tests/EquatableAnalyzerTest.cs @@ -2,6 +2,8 @@ using Equatable.Attributes; using Equatable.SourceGenerator; +using Equatable.SourceGenerator.DataContract; +using Equatable.SourceGenerator.MessagePack; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; @@ -573,7 +575,102 @@ public partial class Priority : ModelBase Assert.Empty(diagnostics); } - private static async Task> GetAnalyzerDiagnosticsAsync(string source) + [Fact] + public async Task AnalyzeDataContractEquatableMissingDataContract() + { + const string source = @" +using System.Runtime.Serialization; +using Equatable.Attributes; + +namespace Equatable.Entities; + +[DataContractEquatable] +public partial class OrderDataContract +{ + [DataMember(Order = 0)] + public int Id { get; set; } +} +"; + var diagnostics = await GetAnalyzerDiagnosticsAsync(source, + new DataContractEquatableAnalyzer()); + + var diagnostic = Assert.Single(diagnostics); + Assert.Equal("EQ0020", diagnostic.Id); + Assert.Contains("OrderDataContract", diagnostic.GetMessage()); + } + + [Fact] + public async Task AnalyzeDataContractEquatableWithDataContractIsValid() + { + const string source = @" +using System.Runtime.Serialization; +using Equatable.Attributes; + +namespace Equatable.Entities; + +[DataContract] +[DataContractEquatable] +public partial class OrderDataContract +{ + [DataMember(Order = 0)] + public int Id { get; set; } +} +"; + var diagnostics = await GetAnalyzerDiagnosticsAsync(source, + new DataContractEquatableAnalyzer()); + + Assert.Empty(diagnostics); + } + + [Fact] + public async Task AnalyzeMessagePackEquatableMissingMessagePackObject() + { + const string source = @" +using MessagePack; +using Equatable.Attributes; + +namespace Equatable.Entities; + +[MessagePackEquatable] +public partial class PricingContract +{ + [Key(0)] + public int MarketId { get; set; } +} +"; + var diagnostics = await GetAnalyzerDiagnosticsAsync(source, + new MessagePackEquatableAnalyzer()); + + var diagnostic = Assert.Single(diagnostics); + Assert.Equal("EQ0021", diagnostic.Id); + Assert.Contains("PricingContract", diagnostic.GetMessage()); + } + + [Fact] + public async Task AnalyzeMessagePackEquatableWithMessagePackObjectIsValid() + { + const string source = @" +using MessagePack; +using Equatable.Attributes; + +namespace Equatable.Entities; + +[MessagePackObject] +[MessagePackEquatable] +public partial class PricingContract +{ + [Key(0)] + public int MarketId { get; set; } +} +"; + var diagnostics = await GetAnalyzerDiagnosticsAsync(source, + new MessagePackEquatableAnalyzer()); + + Assert.Empty(diagnostics); + } + + private static async Task> GetAnalyzerDiagnosticsAsync( + string source, params DiagnosticAnalyzer[] additionalAnalyzers) { var syntaxTree = CSharpSyntaxTree.ParseText(source); var references = AppDomain.CurrentDomain.GetAssemblies() @@ -582,6 +679,10 @@ private static async Task> GetAnalyzerDiagnosticsAsyn .Concat( [ MetadataReference.CreateFromFile(typeof(EquatableAttribute).Assembly.Location), + MetadataReference.CreateFromFile(typeof(Equatable.Attributes.DataContractEquatableAttribute).Assembly.Location), + MetadataReference.CreateFromFile(typeof(Equatable.Attributes.MessagePackEquatableAttribute).Assembly.Location), + MetadataReference.CreateFromFile(typeof(System.Runtime.Serialization.DataMemberAttribute).Assembly.Location), + MetadataReference.CreateFromFile(typeof(MessagePack.KeyAttribute).Assembly.Location), ]); var compilation = CSharpCompilation.Create( @@ -590,8 +691,8 @@ private static async Task> GetAnalyzerDiagnosticsAsyn references, new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); - var analyzer = new EquatableAnalyzer(); - var compilationWithAnalyzers = compilation.WithAnalyzers([analyzer]); + DiagnosticAnalyzer[] analyzers = [new EquatableAnalyzer(), .. additionalAnalyzers]; + var compilationWithAnalyzers = compilation.WithAnalyzers([.. analyzers]); return await compilationWithAnalyzers.GetAnalyzerDiagnosticsAsync(); } diff --git a/test/Equatable.Generator.Tests/EquatableGeneratorTest.cs b/test/Equatable.Generator.Tests/EquatableGeneratorTest.cs index 73c7931..3356ca7 100644 --- a/test/Equatable.Generator.Tests/EquatableGeneratorTest.cs +++ b/test/Equatable.Generator.Tests/EquatableGeneratorTest.cs @@ -747,6 +747,7 @@ public Task GenerateDataContractEquatable() namespace Equatable.Entities; +[DataContract] [DataContractEquatable] public partial class OrderDataContract { @@ -782,6 +783,7 @@ public Task GenerateMessagePackEquatable() namespace Equatable.Entities; +[MessagePackObject] [MessagePackEquatable] public partial class PricingContract { @@ -828,6 +830,7 @@ public partial class ConcreteRecord : ContractBase public int Rank { get; set; } } +[DataContract] [DataContractEquatable] public abstract partial class ContractBase { @@ -858,6 +861,7 @@ public partial class ConcreteRecord : PackedBase public string? Label { get; set; } } +[MessagePackObject] [MessagePackEquatable] public abstract partial class PackedBase { @@ -887,6 +891,7 @@ public Task GenerateDataContractEquatableDerivedIncludesUnannotatedBase() namespace Equatable.Entities; +[DataContract] [DataContractEquatable] public partial class ConcreteRecord : UnannotatedBase { @@ -917,6 +922,7 @@ public Task GenerateMessagePackEquatableDerivedIncludesUnannotatedBase() namespace Equatable.Entities; +[MessagePackObject] [MessagePackEquatable] public partial class ConcreteRecord : UnannotatedBase { From 6a20cfe4eff91d7965b38b79fb71f20c398b82c3 Mon Sep 17 00:00:00 2001 From: "a.kasyanov" Date: Tue, 12 May 2026 01:32:49 +0300 Subject: [PATCH 05/71] refactor: extract IsPublicInstanceProperty helper to eliminate adapter duplication Both adapter generators repeated the same indexer/accessibility guard. Moved to EquatableGenerator.IsPublicInstanceProperty (public static) so adapters call the shared helper instead of copying the logic. Co-Authored-By: Claude Sonnet 4.5 --- .../DataContractEquatableGenerator.cs | 3 +-- .../MessagePackEquatableGenerator.cs | 2 +- src/Equatable.SourceGenerator/EquatableGenerator.cs | 3 +++ 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/Equatable.SourceGenerator.DataContract/DataContractEquatableGenerator.cs b/src/Equatable.SourceGenerator.DataContract/DataContractEquatableGenerator.cs index 453cb91..3d28d20 100644 --- a/src/Equatable.SourceGenerator.DataContract/DataContractEquatableGenerator.cs +++ b/src/Equatable.SourceGenerator.DataContract/DataContractEquatableGenerator.cs @@ -1,5 +1,4 @@ using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp.Syntax; namespace Equatable.SourceGenerator.DataContract; @@ -16,7 +15,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) private static bool IsIncludedDataContract(IPropertySymbol propertySymbol) { - if (propertySymbol.IsIndexer || propertySymbol.DeclaredAccessibility != Accessibility.Public) + if (!EquatableGenerator.IsPublicInstanceProperty(propertySymbol)) return false; var attributes = propertySymbol.GetAttributes(); diff --git a/src/Equatable.SourceGenerator.MessagePack/MessagePackEquatableGenerator.cs b/src/Equatable.SourceGenerator.MessagePack/MessagePackEquatableGenerator.cs index e29eac0..dfe1368 100644 --- a/src/Equatable.SourceGenerator.MessagePack/MessagePackEquatableGenerator.cs +++ b/src/Equatable.SourceGenerator.MessagePack/MessagePackEquatableGenerator.cs @@ -15,7 +15,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) private static bool IsIncludedMessagePack(IPropertySymbol propertySymbol) { - if (propertySymbol.IsIndexer || propertySymbol.DeclaredAccessibility != Accessibility.Public) + if (!EquatableGenerator.IsPublicInstanceProperty(propertySymbol)) return false; var attributes = propertySymbol.GetAttributes(); diff --git a/src/Equatable.SourceGenerator/EquatableGenerator.cs b/src/Equatable.SourceGenerator/EquatableGenerator.cs index 804db23..81e3b79 100644 --- a/src/Equatable.SourceGenerator/EquatableGenerator.cs +++ b/src/Equatable.SourceGenerator/EquatableGenerator.cs @@ -446,6 +446,9 @@ private static (ComparerTypes? comparerType, string? comparerName, string? compa } + public static bool IsPublicInstanceProperty(IPropertySymbol propertySymbol) => + !propertySymbol.IsIndexer && propertySymbol.DeclaredAccessibility == Accessibility.Public; + private static bool IsIncluded(IPropertySymbol propertySymbol) { var attributes = propertySymbol.GetAttributes(); From 4eeb879a71ba56735c16d788946ae2f1f2e83eef Mon Sep 17 00:00:00 2001 From: "a.kasyanov" Date: Tue, 12 May 2026 12:44:02 +0300 Subject: [PATCH 06/71] fix: use EqualityComparer.Default for value types without == operator Structs that don't define op_Equality (e.g. Nullable wrapping a plain struct) would cause a CS0019 compile error in generated code because the generator unconditionally emitted == for all value types. HasEqualityOperator() now checks the underlying type for op_Equality before choosing ComparerTypes.ValueType, falling back to EqualityComparer.Default. Co-Authored-By: Claude Sonnet 4.5 --- .../EquatableGenerator.cs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/Equatable.SourceGenerator/EquatableGenerator.cs b/src/Equatable.SourceGenerator/EquatableGenerator.cs index 81e3b79..9c33a17 100644 --- a/src/Equatable.SourceGenerator/EquatableGenerator.cs +++ b/src/Equatable.SourceGenerator/EquatableGenerator.cs @@ -144,7 +144,7 @@ private static EquatableProperty CreateProperty(IPropertySymbol propertySymbol) var propertyType = propertySymbol.Type.ToDisplayString(FullyQualifiedNullableFormat); var propertyName = propertySymbol.Name; var isValueType = propertySymbol.Type.IsValueType; - var defaultComparer = isValueType ? ComparerTypes.ValueType : ComparerTypes.Default; + var defaultComparer = isValueType && HasEqualityOperator(propertySymbol.Type) ? ComparerTypes.ValueType : ComparerTypes.Default; // look for an explicit equality attribute var attributes = propertySymbol.GetAttributes(); @@ -485,6 +485,21 @@ private static bool IsKnownAttribute(AttributeData? attribute) } + private static bool HasEqualityOperator(ITypeSymbol typeSymbol) + { + // For Nullable, check the underlying type T + var typeToCheck = typeSymbol is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } nullable + ? nullable.TypeArguments[0] + : typeSymbol; + + // Primitive types and enums always support == + if (typeToCheck.SpecialType != SpecialType.None || typeToCheck.TypeKind == TypeKind.Enum) + return true; + + // Check for user-defined == operator + return typeToCheck.GetMembers("op_Equality").Any(); + } + private static bool IsValueType(INamedTypeSymbol targetSymbol) { return targetSymbol is From cd892ca01766224ce1c43d938954689b729f9d85 Mon Sep 17 00:00:00 2001 From: "a.kasyanov" Date: Tue, 12 May 2026 17:25:29 +0300 Subject: [PATCH 07/71] feat: add ordered: bool parameter to [DictionaryEquality] [DictionaryEquality] defaults to order-independent equality (key-lookup Equals, sum-of-pairs hash). [DictionaryEquality(ordered: true)] opts into order-sensitive equality: SequenceEqual on the key-value pair sequence for Equals, sequential HashCode.Add per pair for GetHashCode. Both modes are guaranteed in sync: - Plain types: single ComparerTypes.OrderedDictionary enum value drives both the Equals and GetHashCode switch arms in the writer. - Nested types: BuildCollectionComparerExpression now always emits an OrderedDictionaryEqualityComparer / OrderedReadOnlyDictionaryEqualityComparer instance for ordered properties, so both Equals and GetHashCode go through the same comparer object and can never diverge. Adds OrderedDictionaryEqualityComparer and OrderedReadOnlyDictionaryEqualityComparer to Equatable.Comparers. Co-Authored-By: Claude Sonnet 4.5 --- .../OrderedDictionaryEqualityComparer.cs | 65 +++++++++++++++++++ ...deredReadOnlyDictionaryEqualityComparer.cs | 65 +++++++++++++++++++ .../Attributes/DictionaryEqualityAttribute.cs | 17 ++++- .../EquatableGenerator.cs | 50 +++++++++++--- .../EquatableWriter.cs | 59 +++++++++++++++++ .../Models/ComparerTypes.cs | 1 + 6 files changed, 247 insertions(+), 10 deletions(-) create mode 100644 src/Equatable.Comparers/OrderedDictionaryEqualityComparer.cs create mode 100644 src/Equatable.Comparers/OrderedReadOnlyDictionaryEqualityComparer.cs diff --git a/src/Equatable.Comparers/OrderedDictionaryEqualityComparer.cs b/src/Equatable.Comparers/OrderedDictionaryEqualityComparer.cs new file mode 100644 index 0000000..00c472c --- /dev/null +++ b/src/Equatable.Comparers/OrderedDictionaryEqualityComparer.cs @@ -0,0 +1,65 @@ +namespace Equatable.Comparers; + +/// +/// Order-sensitive equality comparer. +/// Two dictionaries are equal only if they contain the same key/value pairs in the same insertion order. +/// +public class OrderedDictionaryEqualityComparer : IEqualityComparer> +{ + /// Gets the default equality comparer for the specified generic arguments. + public static OrderedDictionaryEqualityComparer Default { get; } = new(); + + public OrderedDictionaryEqualityComparer() : this(EqualityComparer.Default, EqualityComparer.Default) + { + } + + public OrderedDictionaryEqualityComparer(IEqualityComparer keyComparer, IEqualityComparer valueComparer) + { + KeyComparer = keyComparer ?? throw new ArgumentNullException(nameof(keyComparer)); + ValueComparer = valueComparer ?? throw new ArgumentNullException(nameof(valueComparer)); + } + + public IEqualityComparer KeyComparer { get; } + public IEqualityComparer ValueComparer { get; } + + /// + public bool Equals(IDictionary? x, IDictionary? y) + { + if (ReferenceEquals(x, y)) + return true; + + if (x is null || y is null) + return false; + + return x.SequenceEqual(y, PairComparer); + } + + /// + public int GetHashCode(IDictionary obj) + { + if (obj == null) + return 0; + + var hashCode = new HashCode(); + + foreach (var pair in obj) + { + hashCode.Add(pair.Key, KeyComparer); + hashCode.Add(pair.Value, ValueComparer); + } + + return hashCode.ToHashCode(); + } + + private KeyValuePairEqualityComparer PairComparer => new(KeyComparer, ValueComparer); + + private sealed class KeyValuePairEqualityComparer(IEqualityComparer keyComparer, IEqualityComparer valueComparer) + : IEqualityComparer> + { + public bool Equals(KeyValuePair x, KeyValuePair y) => + keyComparer.Equals(x.Key, y.Key) && valueComparer.Equals(x.Value, y.Value); + + public int GetHashCode(KeyValuePair obj) => + HashCode.Combine(keyComparer.GetHashCode(obj.Key!), valueComparer.GetHashCode(obj.Value!)); + } +} diff --git a/src/Equatable.Comparers/OrderedReadOnlyDictionaryEqualityComparer.cs b/src/Equatable.Comparers/OrderedReadOnlyDictionaryEqualityComparer.cs new file mode 100644 index 0000000..e16a038 --- /dev/null +++ b/src/Equatable.Comparers/OrderedReadOnlyDictionaryEqualityComparer.cs @@ -0,0 +1,65 @@ +namespace Equatable.Comparers; + +/// +/// Order-sensitive equality comparer. +/// Two dictionaries are equal only if they contain the same key/value pairs in the same insertion order. +/// +public class OrderedReadOnlyDictionaryEqualityComparer : IEqualityComparer> +{ + /// Gets the default equality comparer for the specified generic arguments. + public static OrderedReadOnlyDictionaryEqualityComparer Default { get; } = new(); + + public OrderedReadOnlyDictionaryEqualityComparer() : this(EqualityComparer.Default, EqualityComparer.Default) + { + } + + public OrderedReadOnlyDictionaryEqualityComparer(IEqualityComparer keyComparer, IEqualityComparer valueComparer) + { + KeyComparer = keyComparer ?? throw new ArgumentNullException(nameof(keyComparer)); + ValueComparer = valueComparer ?? throw new ArgumentNullException(nameof(valueComparer)); + } + + public IEqualityComparer KeyComparer { get; } + public IEqualityComparer ValueComparer { get; } + + /// + public bool Equals(IReadOnlyDictionary? x, IReadOnlyDictionary? y) + { + if (ReferenceEquals(x, y)) + return true; + + if (x is null || y is null) + return false; + + return x.SequenceEqual(y, PairComparer); + } + + /// + public int GetHashCode(IReadOnlyDictionary obj) + { + if (obj == null) + return 0; + + var hashCode = new HashCode(); + + foreach (var pair in obj) + { + hashCode.Add(pair.Key, KeyComparer); + hashCode.Add(pair.Value, ValueComparer); + } + + return hashCode.ToHashCode(); + } + + private KeyValuePairEqualityComparer PairComparer => new(KeyComparer, ValueComparer); + + private sealed class KeyValuePairEqualityComparer(IEqualityComparer keyComparer, IEqualityComparer valueComparer) + : IEqualityComparer> + { + public bool Equals(KeyValuePair x, KeyValuePair y) => + keyComparer.Equals(x.Key, y.Key) && valueComparer.Equals(x.Value, y.Value); + + public int GetHashCode(KeyValuePair obj) => + HashCode.Combine(keyComparer.GetHashCode(obj.Key!), valueComparer.GetHashCode(obj.Value!)); + } +} diff --git a/src/Equatable.Generator/Attributes/DictionaryEqualityAttribute.cs b/src/Equatable.Generator/Attributes/DictionaryEqualityAttribute.cs index c7bf7b9..c3387f3 100644 --- a/src/Equatable.Generator/Attributes/DictionaryEqualityAttribute.cs +++ b/src/Equatable.Generator/Attributes/DictionaryEqualityAttribute.cs @@ -3,8 +3,21 @@ namespace Equatable.Attributes; /// -/// Use a dictionary based comparer to determine if dictionaries are equal +/// Use a dictionary based comparer to determine if dictionaries are equal. /// +/// +/// When is false (default), equality and hash code are +/// insertion-order independent — two dictionaries with the same key/value pairs in any order +/// are considered equal and produce the same hash code. +/// +/// When is true, insertion order is part of the semantic: +/// equality uses SequenceEqual on the key-value pair sequence and hash code is computed +/// sequentially — two dictionaries with the same pairs in different order are NOT equal. +/// Use this only when dictionary ordering carries business meaning. +/// [Conditional("EQUATABLE_GENERATOR")] [AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)] -public class DictionaryEqualityAttribute : Attribute; +public class DictionaryEqualityAttribute(bool ordered = false) : Attribute +{ + public bool Ordered { get; } = ordered; +} diff --git a/src/Equatable.SourceGenerator/EquatableGenerator.cs b/src/Equatable.SourceGenerator/EquatableGenerator.cs index 9c33a17..177737a 100644 --- a/src/Equatable.SourceGenerator/EquatableGenerator.cs +++ b/src/Equatable.SourceGenerator/EquatableGenerator.cs @@ -163,7 +163,7 @@ private static EquatableProperty CreateProperty(IPropertySymbol propertySymbol) // that need a composed comparer rather than EqualityComparer.Default. // BuildArrayComparerExpression always returns a non-null expression for arrays // (including multi-dimensional, which use MultiDimensionalArrayEqualityComparer). - if (comparerType is ComparerTypes.Dictionary or ComparerTypes.HashSet or ComparerTypes.Sequence) + if (comparerType is ComparerTypes.Dictionary or ComparerTypes.OrderedDictionary or ComparerTypes.HashSet or ComparerTypes.Sequence) { string? expression = propertySymbol.Type switch { @@ -202,7 +202,7 @@ private static EquatableProperty CreateProperty(IPropertySymbol propertySymbol) INamedTypeSymbol? enumInterface = IsEnumerable(unwrapped) ? unwrapped : unwrapped.AllInterfaces.FirstOrDefault(IsEnumerable); - if (kind == ComparerTypes.Dictionary && dictInterface != null) + if ((kind == ComparerTypes.Dictionary || kind == ComparerTypes.OrderedDictionary) && dictInterface != null) { var keyType = dictInterface.TypeArguments[0]; var valueType = dictInterface.TypeArguments[1]; @@ -210,16 +210,32 @@ private static EquatableProperty CreateProperty(IPropertySymbol propertySymbol) var keyExpr = BuildElementComparerExpression(keyType); var valueExpr = BuildElementComparerExpression(valueType); - // only compose if at least one argument needs a non-default comparer + var keyTypeFq = keyType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + var valueTypeFq = valueType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + + var isReadOnly = IsReadOnlyDictionary(unwrapped) || unwrapped.AllInterfaces.Any(IsReadOnlyDictionary); + + if (kind == ComparerTypes.OrderedDictionary) + { + // ordered: always emit an explicit comparer so the ordered semantics are enforced, + // even when key/value types don't need composition themselves + keyExpr ??= $"global::System.Collections.Generic.EqualityComparer<{keyTypeFq}>.Default"; + valueExpr ??= $"global::System.Collections.Generic.EqualityComparer<{valueTypeFq}>.Default"; + + var orderedClass = isReadOnly + ? "global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer" + : "global::Equatable.Comparers.OrderedDictionaryEqualityComparer"; + + return $"new {orderedClass}<{keyTypeFq}, {valueTypeFq}>({keyExpr}, {valueExpr})"; + } + + // unordered: only compose when at least one argument needs a non-default comparer if (keyExpr == null && valueExpr == null) return null; - var keyTypeFq = keyType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - var valueTypeFq = valueType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); keyExpr ??= $"global::System.Collections.Generic.EqualityComparer<{keyTypeFq}>.Default"; valueExpr ??= $"global::System.Collections.Generic.EqualityComparer<{valueTypeFq}>.Default"; - var isReadOnly = IsReadOnlyDictionary(unwrapped) || unwrapped.AllInterfaces.Any(IsReadOnlyDictionary); var comparerClass = isReadOnly ? "global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer" : "global::Equatable.Comparers.DictionaryEqualityComparer"; @@ -368,7 +384,7 @@ private static bool ValidateComparer(IPropertySymbol propertySymbol, ComparerTyp if (comparerType == ComparerTypes.String) return IsString(propertySymbol.Type); - if (comparerType == ComparerTypes.Dictionary) + if (comparerType == ComparerTypes.Dictionary || comparerType == ComparerTypes.OrderedDictionary) return (propertySymbol.Type is INamedTypeSymbol nt && IsDictionary(nt)) || propertySymbol.Type.AllInterfaces.Any(IsDictionary); @@ -394,7 +410,7 @@ private static (ComparerTypes? comparerType, string? comparerName, string? compa return className switch { - "DictionaryEqualityAttribute" => (ComparerTypes.Dictionary, null, null), + "DictionaryEqualityAttribute" => GetDictionaryComparer(attribute), "HashSetEqualityAttribute" => (ComparerTypes.HashSet, null, null), "ReferenceEqualityAttribute" => (ComparerTypes.Reference, null, null), "SequenceEqualityAttribute" => (ComparerTypes.Sequence, null, null), @@ -404,6 +420,24 @@ private static (ComparerTypes? comparerType, string? comparerName, string? compa }; } + private static (ComparerTypes? comparerType, string? comparerName, string? comparerInstance) GetDictionaryComparer(AttributeData? attribute) + { + if (attribute == null) + return (ComparerTypes.Dictionary, null, null); + + // named arg: [DictionaryEquality(ordered: true)] + var namedArg = attribute.NamedArguments.FirstOrDefault(a => a.Key == "Ordered"); + if (namedArg.Key != null && namedArg.Value.Value is bool namedOrdered && namedOrdered) + return (ComparerTypes.OrderedDictionary, null, null); + + // positional arg: [DictionaryEquality(true)] + if (attribute.ConstructorArguments.Length > 0 && + attribute.ConstructorArguments[0].Value is bool positionalOrdered && positionalOrdered) + return (ComparerTypes.OrderedDictionary, null, null); + + return (ComparerTypes.Dictionary, null, null); + } + private static (ComparerTypes? comparerType, string? comparerName, string? comparerInstance) GetStringComparer(AttributeData? attribute) { if (attribute == null || attribute.ConstructorArguments.Length != 1) diff --git a/src/Equatable.SourceGenerator/EquatableWriter.cs b/src/Equatable.SourceGenerator/EquatableWriter.cs index 9507445..b5e49ef 100644 --- a/src/Equatable.SourceGenerator/EquatableWriter.cs +++ b/src/Equatable.SourceGenerator/EquatableWriter.cs @@ -135,6 +135,14 @@ private static void GenerateEquatable(IndentedStringBuilder codeBuilder, Equatab .Append(", other.") .Append(entityProperty.PropertyName) .Append(")"); + break; + case ComparerTypes.OrderedDictionary: + codeBuilder + .Append(" OrderedDictionaryEquals(") + .Append(entityProperty.PropertyName) + .Append(", other.") + .Append(entityProperty.PropertyName) + .Append(")"); break; case ComparerTypes.HashSet: @@ -300,6 +308,25 @@ private static void GenerateEquatableFunctions(IndentedStringBuilder codeBuilder .AppendLine(); } + if (entityClass.Properties.Any(p => p.ComparerType == ComparerTypes.OrderedDictionary)) + { + codeBuilder + .AppendLine("static bool OrderedDictionaryEquals(global::System.Collections.Generic.IEnumerable>? left, global::System.Collections.Generic.IEnumerable>? right)") + .AppendLine("{") + .IncrementIndent() + .AppendLine("if (global::System.Object.ReferenceEquals(left, right))") + .AppendLine(" return true;") + .AppendLine() + .AppendLine("if (left is null || right is null)") + .AppendLine(" return false;") + .AppendLine() + .AppendLine("return global::System.Linq.Enumerable.SequenceEqual(left, right,") + .AppendLine(" global::System.Collections.Generic.EqualityComparer>.Default);") + .DecrementIndent() + .AppendLine("}") + .AppendLine(); + } + if (entityClass.Properties.Any(p => p.ComparerType == ComparerTypes.HashSet)) { codeBuilder @@ -459,6 +486,13 @@ private static void GenerateHashCode(IndentedStringBuilder codeBuilder, Equatabl .Append(entityProperty.PropertyName) .AppendLine(");"); break; + case ComparerTypes.OrderedDictionary: + codeBuilder + .Append("hashCode = (hashCode * -1521134295) + ") + .Append("OrderedDictionaryHashCode(") + .Append(entityProperty.PropertyName) + .AppendLine(");"); + break; case ComparerTypes.HashSet: codeBuilder .Append("hashCode = (hashCode * -1521134295) + ") @@ -565,6 +599,31 @@ private static void GenerateHashCodeFunctions(IndentedStringBuilder codeBuilder, .AppendLine(); } + if (entityClass.Properties.Any(p => p.ComparerType == ComparerTypes.OrderedDictionary)) + { + codeBuilder + .AppendLine("static int OrderedDictionaryHashCode(global::System.Collections.Generic.IEnumerable>? items)") + .AppendLine("{") + .IncrementIndent() + .AppendLine("if (items is null)") + .AppendLine(" return 0;") + .AppendLine() + .AppendLine("var hashCode = new global::System.HashCode();") + .AppendLine() + .AppendLine("foreach (var item in items)") + .AppendLine("{") + .IncrementIndent() + .AppendLine("hashCode.Add(global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(item.Key!));") + .AppendLine("hashCode.Add(global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(item.Value!));") + .DecrementIndent() + .AppendLine("}") + .AppendLine() + .AppendLine("return hashCode.ToHashCode();") + .DecrementIndent() + .AppendLine("}") + .AppendLine(); + } + if (entityClass.Properties.Any(p => p.ComparerType == ComparerTypes.HashSet)) { codeBuilder diff --git a/src/Equatable.SourceGenerator/Models/ComparerTypes.cs b/src/Equatable.SourceGenerator/Models/ComparerTypes.cs index 8839db2..07f2d2d 100644 --- a/src/Equatable.SourceGenerator/Models/ComparerTypes.cs +++ b/src/Equatable.SourceGenerator/Models/ComparerTypes.cs @@ -4,6 +4,7 @@ public enum ComparerTypes { Default, Dictionary, + OrderedDictionary, HashSet, Reference, Sequence, From 1a2949616054e919192431234bcdef7443102e1f Mon Sep 17 00:00:00 2001 From: "a.kasyanov" Date: Tue, 12 May 2026 23:07:13 +0300 Subject: [PATCH 08/71] fix: sync dictionary/hashset Equals and GetHashCode, add sequential mode, enable property tests - Fix equality contract violation: GetHashCode used order-dependent logic while Equals was order-independent for dictionaries and hashsets - DictionaryEqualityComparer/ReadOnlyDictionaryEqualityComparer: GetHashCode now uses commutative SUM (insertion-order independent, consistent with TryGetValue Equals) - HashSetEqualityComparer: GetHashCode now uses commutative SUM (consistent with SetEquals) - Add [DictionaryEquality(sequential: true)] mode: both Equals and GetHashCode use OrderBy(key) for deterministic key-sorted comparison - OrderedDictionary comparers: fix OrderBy sort comparer to use hash tiebreaker when IComparer treats distinct keys as equal (e.g. culture-sensitive string sort) - Migrate property-based tests from FsCheck v2 to FsCheck.Xunit.v3 (xUnit v3 compatible) - Add Arbitraries class with custom generators for HashSet (not auto-generated in v3) - Fix property test semantics for SetOfLists/SetOfDicts (reference equality for inner elements) Co-Authored-By: Claude Sonnet 4.5 --- Directory.Packages.props | 3 +- .../DictionaryEqualityComparer.cs | 1 - .../HashSetEqualityComparer.cs | 1 - .../OrderedDictionaryEqualityComparer.cs | 29 ++- ...deredReadOnlyDictionaryEqualityComparer.cs | 29 ++- .../ReadOnlyDictionaryEqualityComparer.cs | 1 - .../Attributes/DictionaryEqualityAttribute.cs | 8 +- .../Attributes/HashSetEqualityAttribute.cs | 3 +- .../EquatableGenerator.cs | 13 +- .../EquatableWriter.cs | 6 +- .../SequentialDictionary.cs | 15 ++ .../OrderedDictionaryEqualityComparerTest.cs | 100 ++++++++++ .../DictionaryHashCodeTest.cs | 5 + .../Entities/SequentialDictionaryTest.cs | 75 ++++++++ .../Equatable.Generator.Tests.csproj | 8 +- .../EquatableGeneratorTest.cs | 24 +++ .../Properties/Arbitraries.cs | 32 ++++ .../DictionaryComparerProperties.cs | 12 +- .../Properties/HashSetComparerProperties.cs | 15 +- .../Properties/NestedCollectionsProperties.cs | 178 +++++++++--------- .../Properties/OrderDataContractProperties.cs | 18 +- .../OrderedDictionaryComparerProperties.cs | 49 +++++ .../ReadOnlyDictionaryComparerProperties.cs | 16 +- ...t.GenerateIDictionaryEquality.verified.txt | 1 - ...t.GenerateISetHashSetEquality.verified.txt | 1 - ...st.GenerateReadOnlyDictionary.verified.txt | 1 - ...eSequentialDictionaryEquality.verified.txt | 45 +++++ ...eratorTest.GenerateUserImport.verified.txt | 2 - ...teUserImportHashSetDictionary.verified.txt | 2 - 29 files changed, 540 insertions(+), 153 deletions(-) create mode 100644 test/Equatable.Entities/SequentialDictionary.cs create mode 100644 test/Equatable.Generator.Tests/Comparers/OrderedDictionaryEqualityComparerTest.cs create mode 100644 test/Equatable.Generator.Tests/Entities/SequentialDictionaryTest.cs create mode 100644 test/Equatable.Generator.Tests/Properties/Arbitraries.cs create mode 100644 test/Equatable.Generator.Tests/Properties/OrderedDictionaryComparerProperties.cs create mode 100644 test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateSequentialDictionaryEquality.verified.txt diff --git a/Directory.Packages.props b/Directory.Packages.props index e56f1fb..a286fd4 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -10,7 +10,8 @@ - + + diff --git a/src/Equatable.Comparers/DictionaryEqualityComparer.cs b/src/Equatable.Comparers/DictionaryEqualityComparer.cs index c2afabb..1fd36cb 100644 --- a/src/Equatable.Comparers/DictionaryEqualityComparer.cs +++ b/src/Equatable.Comparers/DictionaryEqualityComparer.cs @@ -73,7 +73,6 @@ public int GetHashCode(IDictionary obj) int hashCode = 0; - // sum of per-pair hashes is order-independent without sorting allocations foreach (var pair in obj) hashCode += HashCode.Combine(KeyComparer.GetHashCode(pair.Key!), ValueComparer.GetHashCode(pair.Value!)); diff --git a/src/Equatable.Comparers/HashSetEqualityComparer.cs b/src/Equatable.Comparers/HashSetEqualityComparer.cs index ea2ac04..a2b0255 100644 --- a/src/Equatable.Comparers/HashSetEqualityComparer.cs +++ b/src/Equatable.Comparers/HashSetEqualityComparer.cs @@ -58,7 +58,6 @@ public int GetHashCode(IEnumerable obj) int hashCode = 0; - // sum of individual hashes is order-independent without sorting allocations foreach (var item in obj) hashCode += Comparer.GetHashCode(item!); diff --git a/src/Equatable.Comparers/OrderedDictionaryEqualityComparer.cs b/src/Equatable.Comparers/OrderedDictionaryEqualityComparer.cs index 00c472c..0bce404 100644 --- a/src/Equatable.Comparers/OrderedDictionaryEqualityComparer.cs +++ b/src/Equatable.Comparers/OrderedDictionaryEqualityComparer.cs @@ -2,7 +2,7 @@ namespace Equatable.Comparers; /// /// Order-sensitive equality comparer. -/// Two dictionaries are equal only if they contain the same key/value pairs in the same insertion order. +/// Two dictionaries are equal only if they contain the same key/value pairs in the same key-sorted order. /// public class OrderedDictionaryEqualityComparer : IEqualityComparer> { @@ -17,10 +17,16 @@ public OrderedDictionaryEqualityComparer(IEqualityComparer keyComparer, IE { KeyComparer = keyComparer ?? throw new ArgumentNullException(nameof(keyComparer)); ValueComparer = valueComparer ?? throw new ArgumentNullException(nameof(valueComparer)); + // Prefer IComparer from keyComparer for sort; fall back to a hash-tiebreaker + // to guarantee strict total order (dictionary keys are unique, so ties indicate + // a sort comparer inconsistent with key equality — hash tiebreaker fixes this). + KeySortComparer = keyComparer as IComparer + ?? new HashTiebreakerComparer(keyComparer); } public IEqualityComparer KeyComparer { get; } public IEqualityComparer ValueComparer { get; } + private IComparer KeySortComparer { get; } /// public bool Equals(IDictionary? x, IDictionary? y) @@ -31,7 +37,7 @@ public bool Equals(IDictionary? x, IDictionary? y) if (x is null || y is null) return false; - return x.SequenceEqual(y, PairComparer); + return x.OrderBy(p => p.Key, KeySortComparer).SequenceEqual(y.OrderBy(p => p.Key, KeySortComparer), PairComparer); } /// @@ -42,7 +48,7 @@ public int GetHashCode(IDictionary obj) var hashCode = new HashCode(); - foreach (var pair in obj) + foreach (var pair in obj.OrderBy(p => p.Key, KeySortComparer)) { hashCode.Add(pair.Key, KeyComparer); hashCode.Add(pair.Value, ValueComparer); @@ -62,4 +68,21 @@ public bool Equals(KeyValuePair x, KeyValuePair y) = public int GetHashCode(KeyValuePair obj) => HashCode.Combine(keyComparer.GetHashCode(obj.Key!), valueComparer.GetHashCode(obj.Value!)); } + + // Provides a strict total order for keys that have no natural IComparer, + // using hash code as tiebreaker when the natural comparer returns 0 for distinct keys. + private sealed class HashTiebreakerComparer(IEqualityComparer equalityComparer) : IComparer + { + private static readonly IComparer _natural = Comparer.Default; + + public int Compare(TKey? x, TKey? y) + { + int cmp = _natural.Compare(x, y); + if (cmp != 0) return cmp; + // Natural comparer considers them equal; break tie by hash code + int hx = x is null ? 0 : equalityComparer.GetHashCode(x); + int hy = y is null ? 0 : equalityComparer.GetHashCode(y); + return hx.CompareTo(hy); + } + } } diff --git a/src/Equatable.Comparers/OrderedReadOnlyDictionaryEqualityComparer.cs b/src/Equatable.Comparers/OrderedReadOnlyDictionaryEqualityComparer.cs index e16a038..758fd2c 100644 --- a/src/Equatable.Comparers/OrderedReadOnlyDictionaryEqualityComparer.cs +++ b/src/Equatable.Comparers/OrderedReadOnlyDictionaryEqualityComparer.cs @@ -2,7 +2,7 @@ namespace Equatable.Comparers; /// /// Order-sensitive equality comparer. -/// Two dictionaries are equal only if they contain the same key/value pairs in the same insertion order. +/// Two dictionaries are equal only if they contain the same key/value pairs in the same key-sorted order. /// public class OrderedReadOnlyDictionaryEqualityComparer : IEqualityComparer> { @@ -17,10 +17,16 @@ public OrderedReadOnlyDictionaryEqualityComparer(IEqualityComparer keyComp { KeyComparer = keyComparer ?? throw new ArgumentNullException(nameof(keyComparer)); ValueComparer = valueComparer ?? throw new ArgumentNullException(nameof(valueComparer)); + // Prefer IComparer from keyComparer for sort; fall back to a hash-tiebreaker + // to guarantee strict total order (dictionary keys are unique, so ties indicate + // a sort comparer inconsistent with key equality — hash tiebreaker fixes this). + KeySortComparer = keyComparer as IComparer + ?? new HashTiebreakerComparer(keyComparer); } public IEqualityComparer KeyComparer { get; } public IEqualityComparer ValueComparer { get; } + private IComparer KeySortComparer { get; } /// public bool Equals(IReadOnlyDictionary? x, IReadOnlyDictionary? y) @@ -31,7 +37,7 @@ public bool Equals(IReadOnlyDictionary? x, IReadOnlyDictionary p.Key, KeySortComparer).SequenceEqual(y.OrderBy(p => p.Key, KeySortComparer), PairComparer); } /// @@ -42,7 +48,7 @@ public int GetHashCode(IReadOnlyDictionary obj) var hashCode = new HashCode(); - foreach (var pair in obj) + foreach (var pair in obj.OrderBy(p => p.Key, KeySortComparer)) { hashCode.Add(pair.Key, KeyComparer); hashCode.Add(pair.Value, ValueComparer); @@ -62,4 +68,21 @@ public bool Equals(KeyValuePair x, KeyValuePair y) = public int GetHashCode(KeyValuePair obj) => HashCode.Combine(keyComparer.GetHashCode(obj.Key!), valueComparer.GetHashCode(obj.Value!)); } + + // Provides a strict total order for keys that have no natural IComparer, + // using hash code as tiebreaker when the natural comparer returns 0 for distinct keys. + private sealed class HashTiebreakerComparer(IEqualityComparer equalityComparer) : IComparer + { + private static readonly IComparer _natural = Comparer.Default; + + public int Compare(TKey? x, TKey? y) + { + int cmp = _natural.Compare(x, y); + if (cmp != 0) return cmp; + // Natural comparer considers them equal; break tie by hash code + int hx = x is null ? 0 : equalityComparer.GetHashCode(x); + int hy = y is null ? 0 : equalityComparer.GetHashCode(y); + return hx.CompareTo(hy); + } + } } diff --git a/src/Equatable.Comparers/ReadOnlyDictionaryEqualityComparer.cs b/src/Equatable.Comparers/ReadOnlyDictionaryEqualityComparer.cs index 01303f3..09a91ac 100644 --- a/src/Equatable.Comparers/ReadOnlyDictionaryEqualityComparer.cs +++ b/src/Equatable.Comparers/ReadOnlyDictionaryEqualityComparer.cs @@ -73,7 +73,6 @@ public int GetHashCode(IReadOnlyDictionary obj) int hashCode = 0; - // sum of per-pair hashes is order-independent without sorting allocations foreach (var pair in obj) hashCode += HashCode.Combine(KeyComparer.GetHashCode(pair.Key!), ValueComparer.GetHashCode(pair.Value!)); diff --git a/src/Equatable.Generator/Attributes/DictionaryEqualityAttribute.cs b/src/Equatable.Generator/Attributes/DictionaryEqualityAttribute.cs index c3387f3..9ba3380 100644 --- a/src/Equatable.Generator/Attributes/DictionaryEqualityAttribute.cs +++ b/src/Equatable.Generator/Attributes/DictionaryEqualityAttribute.cs @@ -6,18 +6,18 @@ namespace Equatable.Attributes; /// Use a dictionary based comparer to determine if dictionaries are equal. /// /// -/// When is false (default), equality and hash code are +/// When is false (default), equality and hash code are /// insertion-order independent — two dictionaries with the same key/value pairs in any order /// are considered equal and produce the same hash code. /// -/// When is true, insertion order is part of the semantic: +/// When is true, insertion order is part of the semantic: /// equality uses SequenceEqual on the key-value pair sequence and hash code is computed /// sequentially — two dictionaries with the same pairs in different order are NOT equal. /// Use this only when dictionary ordering carries business meaning. /// [Conditional("EQUATABLE_GENERATOR")] [AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)] -public class DictionaryEqualityAttribute(bool ordered = false) : Attribute +public class DictionaryEqualityAttribute(bool sequential = false) : Attribute { - public bool Ordered { get; } = ordered; + public bool Sequential { get; } = sequential; } diff --git a/src/Equatable.Generator/Attributes/HashSetEqualityAttribute.cs b/src/Equatable.Generator/Attributes/HashSetEqualityAttribute.cs index 9efa192..3bd0191 100644 --- a/src/Equatable.Generator/Attributes/HashSetEqualityAttribute.cs +++ b/src/Equatable.Generator/Attributes/HashSetEqualityAttribute.cs @@ -3,7 +3,8 @@ namespace Equatable.Attributes; /// -/// Use in Equals and GetHashCode implementations +/// Use in Equals and GetHashCode implementations. +/// Comparison is order-independent — use for order-sensitive comparison. /// [Conditional("EQUATABLE_GENERATOR")] [AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)] diff --git a/src/Equatable.SourceGenerator/EquatableGenerator.cs b/src/Equatable.SourceGenerator/EquatableGenerator.cs index 177737a..16c63d9 100644 --- a/src/Equatable.SourceGenerator/EquatableGenerator.cs +++ b/src/Equatable.SourceGenerator/EquatableGenerator.cs @@ -213,7 +213,8 @@ private static EquatableProperty CreateProperty(IPropertySymbol propertySymbol) var keyTypeFq = keyType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); var valueTypeFq = valueType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - var isReadOnly = IsReadOnlyDictionary(unwrapped) || unwrapped.AllInterfaces.Any(IsReadOnlyDictionary); + var isReadOnly = IsReadOnlyDictionary(unwrapped) + || (IsDictionary(unwrapped) is false && unwrapped.AllInterfaces.Any(IsReadOnlyDictionary)); if (kind == ComparerTypes.OrderedDictionary) { @@ -289,7 +290,7 @@ private static EquatableProperty CreateProperty(IPropertySymbol propertySymbol) if (asDictInterface != null) { var isReadOnly = IsReadOnlyDictionary(named) - || named.AllInterfaces.Any(IsReadOnlyDictionary); + || (IsDictionary(named) is false && named.AllInterfaces.Any(IsReadOnlyDictionary)); return BuildDictComparerExpression(asDictInterface, isReadOnly, visited); } @@ -425,14 +426,14 @@ private static (ComparerTypes? comparerType, string? comparerName, string? compa if (attribute == null) return (ComparerTypes.Dictionary, null, null); - // named arg: [DictionaryEquality(ordered: true)] - var namedArg = attribute.NamedArguments.FirstOrDefault(a => a.Key == "Ordered"); - if (namedArg.Key != null && namedArg.Value.Value is bool namedOrdered && namedOrdered) + // named arg: [DictionaryEquality(sequential: true)] + var namedArg = attribute.NamedArguments.FirstOrDefault(a => a.Key == "Sequential"); + if (namedArg.Key != null && namedArg.Value.Value is bool namedSequential && namedSequential) return (ComparerTypes.OrderedDictionary, null, null); // positional arg: [DictionaryEquality(true)] if (attribute.ConstructorArguments.Length > 0 && - attribute.ConstructorArguments[0].Value is bool positionalOrdered && positionalOrdered) + attribute.ConstructorArguments[0].Value is bool positionalSequential && positionalSequential) return (ComparerTypes.OrderedDictionary, null, null); return (ComparerTypes.Dictionary, null, null); diff --git a/src/Equatable.SourceGenerator/EquatableWriter.cs b/src/Equatable.SourceGenerator/EquatableWriter.cs index b5e49ef..0ed6fa0 100644 --- a/src/Equatable.SourceGenerator/EquatableWriter.cs +++ b/src/Equatable.SourceGenerator/EquatableWriter.cs @@ -320,7 +320,9 @@ private static void GenerateEquatableFunctions(IndentedStringBuilder codeBuilder .AppendLine("if (left is null || right is null)") .AppendLine(" return false;") .AppendLine() - .AppendLine("return global::System.Linq.Enumerable.SequenceEqual(left, right,") + .AppendLine("return global::System.Linq.Enumerable.SequenceEqual(") + .AppendLine(" global::System.Linq.Enumerable.OrderBy(left, p => p.Key),") + .AppendLine(" global::System.Linq.Enumerable.OrderBy(right, p => p.Key),") .AppendLine(" global::System.Collections.Generic.EqualityComparer>.Default);") .DecrementIndent() .AppendLine("}") @@ -587,7 +589,6 @@ private static void GenerateHashCodeFunctions(IndentedStringBuilder codeBuilder, .AppendLine() .AppendLine("int hashCode = 0;") .AppendLine() - .AppendLine("// sum of per-pair hashes is order-independent without sorting allocations") .AppendLine("foreach (var item in items)") .AppendLine(" hashCode += global::System.HashCode.Combine(") .AppendLine(" global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(item.Key!),") @@ -635,7 +636,6 @@ private static void GenerateHashCodeFunctions(IndentedStringBuilder codeBuilder, .AppendLine() .AppendLine("int hashCode = 0;") .AppendLine() - .AppendLine("// sum of individual hashes is order-independent without sorting allocations") .AppendLine("foreach (var item in items)") .AppendLine(" hashCode += global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(item!);") .AppendLine() diff --git a/test/Equatable.Entities/SequentialDictionary.cs b/test/Equatable.Entities/SequentialDictionary.cs new file mode 100644 index 0000000..ca0afe7 --- /dev/null +++ b/test/Equatable.Entities/SequentialDictionary.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; + +using Equatable.Attributes; + +namespace Equatable.Entities; + +[Equatable] +public partial class SequentialDictionary +{ + [DictionaryEquality(sequential: true)] + public Dictionary? Entries { get; set; } + + [DictionaryEquality(sequential: true)] + public IReadOnlyDictionary? ReadOnlyEntries { get; set; } +} diff --git a/test/Equatable.Generator.Tests/Comparers/OrderedDictionaryEqualityComparerTest.cs b/test/Equatable.Generator.Tests/Comparers/OrderedDictionaryEqualityComparerTest.cs new file mode 100644 index 0000000..d7ecb1a --- /dev/null +++ b/test/Equatable.Generator.Tests/Comparers/OrderedDictionaryEqualityComparerTest.cs @@ -0,0 +1,100 @@ +using System.Collections.Generic; +using Equatable.Comparers; + +namespace Equatable.Generator.Tests.Comparers; + +public class OrderedDictionaryEqualityComparerTest +{ + private static readonly OrderedDictionaryEqualityComparer Comparer + = OrderedDictionaryEqualityComparer.Default; + + private static readonly OrderedReadOnlyDictionaryEqualityComparer ReadOnlyComparer + = OrderedReadOnlyDictionaryEqualityComparer.Default; + + [Fact] + public void Equals_SamePairs_DifferentInsertionOrder_ReturnsTrue() + { + var a = new Dictionary { ["a"] = 1, ["b"] = 2 }; + var b = new Dictionary { ["b"] = 2, ["a"] = 1 }; + + Assert.True(Comparer.Equals(a, b)); + } + + [Fact] + public void Equals_DifferentValues_ReturnsFalse() + { + var a = new Dictionary { ["a"] = 1, ["b"] = 2 }; + var b = new Dictionary { ["a"] = 1, ["b"] = 99 }; + + Assert.False(Comparer.Equals(a, b)); + } + + [Fact] + public void Equals_DifferentKeys_ReturnsFalse() + { + var a = new Dictionary { ["a"] = 1 }; + var b = new Dictionary { ["z"] = 1 }; + + Assert.False(Comparer.Equals(a, b)); + } + + [Fact] + public void Equals_NullBoth_ReturnsTrue() + { + Assert.True(Comparer.Equals(null, null)); + } + + [Fact] + public void Equals_NullOne_ReturnsFalse() + { + var a = new Dictionary { ["a"] = 1 }; + Assert.False(Comparer.Equals(a, null)); + Assert.False(Comparer.Equals(null, a)); + } + + [Fact] + public void GetHashCode_SamePairs_DifferentInsertionOrder_Equal() + { + var a = new Dictionary { ["a"] = 1, ["b"] = 2, ["c"] = 3 }; + var b = new Dictionary { ["c"] = 3, ["a"] = 1, ["b"] = 2 }; + + Assert.Equal(Comparer.GetHashCode(a), Comparer.GetHashCode(b)); + } + + [Fact] + public void GetHashCode_DifferentValues_NotEqual() + { + var a = new Dictionary { ["a"] = 1 }; + var b = new Dictionary { ["a"] = 2 }; + + Assert.NotEqual(Comparer.GetHashCode(a), Comparer.GetHashCode(b)); + } + + [Fact] + public void EqualDictionaries_HaveSameHashCode() + { + var a = new Dictionary { ["a"] = 1, ["b"] = 2 }; + var b = new Dictionary { ["b"] = 2, ["a"] = 1 }; + + Assert.True(Comparer.Equals(a, b)); + Assert.Equal(Comparer.GetHashCode(a), Comparer.GetHashCode(b)); + } + + [Fact] + public void ReadOnly_Equals_SamePairs_DifferentInsertionOrder_ReturnsTrue() + { + IReadOnlyDictionary a = new Dictionary { ["x"] = 10, ["y"] = 20 }; + IReadOnlyDictionary b = new Dictionary { ["y"] = 20, ["x"] = 10 }; + + Assert.True(ReadOnlyComparer.Equals(a, b)); + } + + [Fact] + public void ReadOnly_GetHashCode_SamePairs_DifferentInsertionOrder_Equal() + { + IReadOnlyDictionary a = new Dictionary { ["x"] = 10, ["y"] = 20 }; + IReadOnlyDictionary b = new Dictionary { ["y"] = 20, ["x"] = 10 }; + + Assert.Equal(ReadOnlyComparer.GetHashCode(a), ReadOnlyComparer.GetHashCode(b)); + } +} diff --git a/test/Equatable.Generator.Tests/DictionaryHashCodeTest.cs b/test/Equatable.Generator.Tests/DictionaryHashCodeTest.cs index 2d36c05..7f4355d 100644 --- a/test/Equatable.Generator.Tests/DictionaryHashCodeTest.cs +++ b/test/Equatable.Generator.Tests/DictionaryHashCodeTest.cs @@ -6,6 +6,11 @@ namespace Equatable.Generator.Tests; /// Demonstrates and verifies the order-independent hash code algorithm used by /// DictionaryEqualityComparer and ReadOnlyDictionaryEqualityComparer. /// +/// The hash/equals contract requires: if Equals(x, y) then GetHashCode(x) == GetHashCode(y). +/// DictionaryEquals uses TryGetValue (order-independent), so GetHashCode MUST also be +/// order-independent — otherwise two equal dictionaries in different insertion order would +/// produce different hash codes and violate the contract. +/// /// The implementation sums HashCode.Combine(key, value) over every entry. /// Addition is commutative, so the total is the same regardless of insertion order: /// diff --git a/test/Equatable.Generator.Tests/Entities/SequentialDictionaryTest.cs b/test/Equatable.Generator.Tests/Entities/SequentialDictionaryTest.cs new file mode 100644 index 0000000..c427859 --- /dev/null +++ b/test/Equatable.Generator.Tests/Entities/SequentialDictionaryTest.cs @@ -0,0 +1,75 @@ +using System.Collections.Generic; +using Equatable.Entities; + +namespace Equatable.Generator.Tests.Entities; + +/// +/// Tests for [DictionaryEquality(sequential: true)]. +/// Equality and hash code both sort by key before comparing, +/// so two dictionaries are equal iff they have the same key/value pairs regardless of insertion order. +/// +public class SequentialDictionaryTest +{ + [Fact] + public void Equals_SamePairs_DifferentInsertionOrder_ReturnsTrue() + { + var left = new SequentialDictionary { Entries = new Dictionary { ["a"] = 1, ["b"] = 2 } }; + var right = new SequentialDictionary { Entries = new Dictionary { ["b"] = 2, ["a"] = 1 } }; + + Assert.True(left.Equals(right)); + } + + [Fact] + public void Equals_DifferentValues_ReturnsFalse() + { + var left = new SequentialDictionary { Entries = new Dictionary { ["a"] = 1, ["b"] = 2 } }; + var right = new SequentialDictionary { Entries = new Dictionary { ["a"] = 1, ["b"] = 99 } }; + + Assert.False(left.Equals(right)); + } + + [Fact] + public void Equals_DifferentKeys_ReturnsFalse() + { + var left = new SequentialDictionary { Entries = new Dictionary { ["a"] = 1 } }; + var right = new SequentialDictionary { Entries = new Dictionary { ["z"] = 1 } }; + + Assert.False(left.Equals(right)); + } + + [Fact] + public void HashCode_SamePairs_DifferentInsertionOrder_Equal() + { + var left = new SequentialDictionary { Entries = new Dictionary { ["a"] = 1, ["b"] = 2, ["c"] = 3 } }; + var right = new SequentialDictionary { Entries = new Dictionary { ["c"] = 3, ["a"] = 1, ["b"] = 2 } }; + + Assert.Equal(left.GetHashCode(), right.GetHashCode()); + } + + [Fact] + public void HashCode_DifferentValues_NotEqual() + { + var left = new SequentialDictionary { Entries = new Dictionary { ["a"] = 1 } }; + var right = new SequentialDictionary { Entries = new Dictionary { ["a"] = 2 } }; + + Assert.NotEqual(left.GetHashCode(), right.GetHashCode()); + } + + [Fact] + public void ReadOnly_Equals_SamePairs_DifferentInsertionOrder_ReturnsTrue() + { + var left = new SequentialDictionary { ReadOnlyEntries = new Dictionary { ["x"] = 10, ["y"] = 20 } }; + var right = new SequentialDictionary { ReadOnlyEntries = new Dictionary { ["y"] = 20, ["x"] = 10 } }; + + Assert.True(left.Equals(right)); + } + + [Fact] + public void ReadOnly_HashCode_SamePairs_DifferentInsertionOrder_Equal() + { + var left = new SequentialDictionary { ReadOnlyEntries = new Dictionary { ["x"] = 10, ["y"] = 20 } }; + var right = new SequentialDictionary { ReadOnlyEntries = new Dictionary { ["y"] = 20, ["x"] = 10 } }; + + Assert.Equal(left.GetHashCode(), right.GetHashCode()); + } +} diff --git a/test/Equatable.Generator.Tests/Equatable.Generator.Tests.csproj b/test/Equatable.Generator.Tests/Equatable.Generator.Tests.csproj index 2ed9b00..683c9ad 100644 --- a/test/Equatable.Generator.Tests/Equatable.Generator.Tests.csproj +++ b/test/Equatable.Generator.Tests/Equatable.Generator.Tests.csproj @@ -14,12 +14,9 @@ true - - - - + @@ -47,6 +44,9 @@ + + + diff --git a/test/Equatable.Generator.Tests/EquatableGeneratorTest.cs b/test/Equatable.Generator.Tests/EquatableGeneratorTest.cs index 3356ca7..f8afec8 100644 --- a/test/Equatable.Generator.Tests/EquatableGeneratorTest.cs +++ b/test/Equatable.Generator.Tests/EquatableGeneratorTest.cs @@ -969,6 +969,30 @@ public partial class Container return Verifier.Verify(output).UseDirectory("Snapshots").ScrubLinesContaining("GeneratedCodeAttribute"); } + [Fact] + public Task GenerateSequentialDictionaryEquality() + { + var source = @" +using System.Collections.Generic; +using Equatable.Attributes; + +namespace Equatable.Entities; + +[Equatable] +public partial class Container +{ + [DictionaryEquality(sequential: true)] + public Dictionary? Entries { get; set; } + + [DictionaryEquality(sequential: true)] + public IReadOnlyDictionary? ReadOnlyEntries { get; set; } +} +"; + var (diagnostics, output) = GetGeneratedOutput(source); + Assert.Empty(diagnostics); + return Verifier.Verify(output).UseDirectory("Snapshots").ScrubLinesContaining("GeneratedCodeAttribute"); + } + [Fact] public Task GenerateIEnumerableSequenceEquality() { diff --git a/test/Equatable.Generator.Tests/Properties/Arbitraries.cs b/test/Equatable.Generator.Tests/Properties/Arbitraries.cs new file mode 100644 index 0000000..0f27b39 --- /dev/null +++ b/test/Equatable.Generator.Tests/Properties/Arbitraries.cs @@ -0,0 +1,32 @@ +namespace Equatable.Generator.Tests.Properties; + +/// +/// Custom FsCheck v3 Arbitrary instances for types not auto-generated (HashSet). +/// FsCheck v3 doesn't auto-derive HashSet — register via [Properties(Arbitrary = new[] { typeof(Arbitraries) })]. +/// +public static class Arbitraries +{ + public static Arbitrary> HashSetOfString() => + Arb.From( + Gen.ArrayOf(Gen.Choose(0, 100).Select(i => i.ToString())) + .Select(arr => new HashSet(arr ?? []))); + + public static Arbitrary> HashSetOfInt() => + Arb.From( + Gen.ArrayOf(Gen.Choose(-100, 100)) + .Select(arr => new HashSet(arr ?? []))); + + public static Arbitrary>> HashSetOfListOfInt() => + Arb.From( + Gen.ArrayOf(Gen.ArrayOf(Gen.Choose(-100, 100)).Select(a => a.ToList())) + .Select(arr => new HashSet>(arr ?? []))); + + public static Arbitrary>> HashSetOfDictionaryOfStringInt() => + Arb.From( + Gen.ArrayOf( + Gen.ArrayOf( + Gen.Zip(Gen.Choose(0, 20).Select(i => i.ToString()), Gen.Choose(-100, 100))) + .Select(pairs => pairs.DistinctBy(p => p.Item1) + .ToDictionary(p => p.Item1, p => p.Item2))) + .Select(arr => new HashSet>(arr ?? []))); +} diff --git a/test/Equatable.Generator.Tests/Properties/DictionaryComparerProperties.cs b/test/Equatable.Generator.Tests/Properties/DictionaryComparerProperties.cs index 336bac9..2d130fa 100644 --- a/test/Equatable.Generator.Tests/Properties/DictionaryComparerProperties.cs +++ b/test/Equatable.Generator.Tests/Properties/DictionaryComparerProperties.cs @@ -9,39 +9,39 @@ public class DictionaryComparerProperties [Property] public Property Reflexivity(Dictionary dict) { - return Comparer.Equals(dict, dict).ToProperty(); + return Prop.ToProperty(Comparer.Equals(dict, dict)); } [Property] public Property Symmetry(Dictionary x, Dictionary y) { - return (Comparer.Equals(x, y) == Comparer.Equals(y, x)).ToProperty(); + return Prop.ToProperty(Comparer.Equals(x, y) == Comparer.Equals(y, x)); } [Property] public Property HashIsInsertionOrderIndependent(Dictionary dict) { var reversed = new Dictionary(dict.Reverse()); - return (Comparer.GetHashCode(dict) == Comparer.GetHashCode(reversed)).ToProperty(); + return Prop.ToProperty(Comparer.GetHashCode(dict) == Comparer.GetHashCode(reversed)); } [Property] public Property EqualDictionariesHaveSameHashCode(Dictionary dict) { var copy = new Dictionary(dict); - return (Comparer.Equals(dict, copy) && Comparer.GetHashCode(dict) == Comparer.GetHashCode(copy)).ToProperty(); + return Prop.ToProperty(Comparer.Equals(dict, copy) && Comparer.GetHashCode(dict) == Comparer.GetHashCode(copy)); } [Property] public Property DifferentValueProducesDifferentHash(Dictionary dict, string key, int v1, int v2) { if (v1 == v2) - return true.ToProperty().When(true); + return Prop.When(true, true); var a = new Dictionary(dict) { [key] = v1 }; var b = new Dictionary(dict) { [key] = v2 }; // different values must NOT be equal - return (!Comparer.Equals(a, b)).ToProperty(); + return Prop.ToProperty(!Comparer.Equals(a, b)); } } diff --git a/test/Equatable.Generator.Tests/Properties/HashSetComparerProperties.cs b/test/Equatable.Generator.Tests/Properties/HashSetComparerProperties.cs index d9ed3dd..364eeb7 100644 --- a/test/Equatable.Generator.Tests/Properties/HashSetComparerProperties.cs +++ b/test/Equatable.Generator.Tests/Properties/HashSetComparerProperties.cs @@ -2,6 +2,7 @@ namespace Equatable.Generator.Tests.Properties; +[Properties(Arbitrary = new[] { typeof(Arbitraries) })] public class HashSetComparerProperties { private static readonly HashSetEqualityComparer Comparer = HashSetEqualityComparer.Default; @@ -9,13 +10,13 @@ public class HashSetComparerProperties [Property] public Property Reflexivity(HashSet set) { - return Comparer.Equals(set, set).ToProperty(); + return Prop.ToProperty(Comparer.Equals(set, set)); } [Property] public Property Symmetry(HashSet x, HashSet y) { - return (Comparer.Equals(x, y) == Comparer.Equals(y, x)).ToProperty(); + return Prop.ToProperty(Comparer.Equals(x, y) == Comparer.Equals(y, x)); } [Property] @@ -24,29 +25,29 @@ public Property HashIsInsertionOrderIndependent(HashSet set) // build same set from reversed list — HashSet has no guaranteed iteration order, // but two sets with identical elements must have equal hash regardless of add order var reversed = new HashSet(set.Reverse()); - return (Comparer.GetHashCode(set) == Comparer.GetHashCode(reversed)).ToProperty(); + return Prop.ToProperty(Comparer.GetHashCode(set) == Comparer.GetHashCode(reversed)); } [Property] public Property EqualSetsHaveSameHashCode(HashSet set) { var copy = new HashSet(set); - return (Comparer.Equals(set, copy) && Comparer.GetHashCode(set) == Comparer.GetHashCode(copy)).ToProperty(); + return Prop.ToProperty(Comparer.Equals(set, copy) && Comparer.GetHashCode(set) == Comparer.GetHashCode(copy)); } [Property] public Property ExtraElementMakesNotEqual(HashSet set, string extra) { if (set.Contains(extra)) - return true.ToProperty().When(true); + return Prop.When(true, true); var bigger = new HashSet(set) { extra }; - return (!Comparer.Equals(set, bigger)).ToProperty(); + return Prop.ToProperty(!Comparer.Equals(set, bigger)); } [Property] public Property NullEqualsNull() { - return Comparer.Equals(null, null).ToProperty(); + return Prop.ToProperty(Comparer.Equals(null, null)); } } diff --git a/test/Equatable.Generator.Tests/Properties/NestedCollectionsProperties.cs b/test/Equatable.Generator.Tests/Properties/NestedCollectionsProperties.cs index c9a0cef..527be7a 100644 --- a/test/Equatable.Generator.Tests/Properties/NestedCollectionsProperties.cs +++ b/test/Equatable.Generator.Tests/Properties/NestedCollectionsProperties.cs @@ -12,6 +12,7 @@ namespace Equatable.Generator.Tests.Properties; /// - List/Sequence inner → element order matters /// - Dict/HashSet inner → element order does not matter /// +[Properties(Arbitrary = new[] { typeof(Arbitraries) })] public class NestedCollectionsProperties { // ══════════════════════════════════════════════════════════════════════ @@ -23,7 +24,7 @@ public Property DictOfLists_EqualWhenSameContent(Dictionary> r { var a = new NestedCollections { DictOfLists = raw }; var b = new NestedCollections { DictOfLists = raw.ToDictionary(kv => kv.Key, kv => new List(kv.Value)) }; - return a.Equals(b).ToProperty(); + return Prop.ToProperty(a.Equals(b)); } [Property] @@ -31,7 +32,7 @@ public Property DictOfLists_OuterInsertionOrderDoesNotMatter(Dictionary kv.Key, kv => kv.Value) }; - return a.Equals(b).ToProperty(); + return Prop.ToProperty(a.Equals(b)); } [Property] @@ -39,16 +40,16 @@ public Property DictOfLists_HashIsInsertionOrderIndependent(Dictionary kv.Key, kv => kv.Value) }; - return (a.GetHashCode() == b.GetHashCode()).ToProperty(); + return Prop.ToProperty((a.GetHashCode() == b.GetHashCode())); } [Property] public Property DictOfLists_InnerOrderMatters(string key, int v1, int v2) { - if (v1 == v2) return true.ToProperty().When(true); + if (v1 == v2) return Prop.When(true, true); var a = new NestedCollections { DictOfLists = new Dictionary> { [key] = [v1, v2] } }; var b = new NestedCollections { DictOfLists = new Dictionary> { [key] = [v2, v1] } }; - return (!a.Equals(b)).ToProperty(); + return Prop.ToProperty((!a.Equals(b))); } [Property] @@ -56,7 +57,7 @@ public Property DictOfLists_EqualImpliesSameHash(Dictionary> r { var a = new NestedCollections { DictOfLists = raw }; var b = new NestedCollections { DictOfLists = raw.ToDictionary(kv => kv.Key, kv => new List(kv.Value)) }; - return (a.Equals(b) && a.GetHashCode() == b.GetHashCode()).ToProperty(); + return Prop.ToProperty((a.Equals(b) && a.GetHashCode() == b.GetHashCode())); } // ══════════════════════════════════════════════════════════════════════ @@ -68,7 +69,7 @@ public Property DictOfSets_EqualWhenSameContent(Dictionary> { var a = new NestedCollections { DictOfSets = raw }; var b = new NestedCollections { DictOfSets = raw.ToDictionary(kv => kv.Key, kv => new HashSet(kv.Value)) }; - return a.Equals(b).ToProperty(); + return Prop.ToProperty(a.Equals(b)); } [Property] @@ -76,7 +77,7 @@ public Property DictOfSets_OuterInsertionOrderDoesNotMatter(Dictionary kv.Key, kv => kv.Value) }; - return a.Equals(b).ToProperty(); + return Prop.ToProperty(a.Equals(b)); } [Property] @@ -87,7 +88,7 @@ public Property DictOfSets_InnerOrderDoesNotMatter(string key, int v1, int v2) var s2 = new HashSet { v2, v1 }; var a = new NestedCollections { DictOfSets = new Dictionary> { [key] = s1 } }; var b = new NestedCollections { DictOfSets = new Dictionary> { [key] = s2 } }; - return a.Equals(b).ToProperty(); + return Prop.ToProperty(a.Equals(b)); } [Property] @@ -95,7 +96,7 @@ public Property DictOfSets_HashIsInsertionOrderIndependent(Dictionary kv.Key, kv => kv.Value) }; - return (a.GetHashCode() == b.GetHashCode()).ToProperty(); + return Prop.ToProperty((a.GetHashCode() == b.GetHashCode())); } // ══════════════════════════════════════════════════════════════════════ @@ -107,7 +108,7 @@ public Property DictOfDicts_EqualWhenSameContent(Dictionary kv.Key, kv => new Dictionary(kv.Value)) }; - return a.Equals(b).ToProperty(); + return Prop.ToProperty(a.Equals(b)); } [Property] @@ -115,7 +116,7 @@ public Property DictOfDicts_OuterInsertionOrderDoesNotMatter(Dictionary kv.Key, kv => kv.Value) }; - return a.Equals(b).ToProperty(); + return Prop.ToProperty(a.Equals(b)); } [Property] @@ -124,7 +125,7 @@ public Property DictOfDicts_InnerInsertionOrderDoesNotMatter(Dictionary kv.Key, kv => kv.Value.Reverse().ToDictionary(p => p.Key, p => p.Value)); var a = new NestedCollections { DictOfDicts = raw }; var b = new NestedCollections { DictOfDicts = reversed }; - return a.Equals(b).ToProperty(); + return Prop.ToProperty(a.Equals(b)); } [Property] @@ -132,7 +133,7 @@ public Property DictOfDicts_EqualImpliesSameHash(Dictionary kv.Key, kv => new Dictionary(kv.Value)) }; - return (a.Equals(b) && a.GetHashCode() == b.GetHashCode()).ToProperty(); + return Prop.ToProperty((a.Equals(b) && a.GetHashCode() == b.GetHashCode())); } // ══════════════════════════════════════════════════════════════════════ @@ -144,7 +145,7 @@ public Property ListOfDicts_EqualWhenSameContent(List> i { var a = new NestedCollections { ListOfDicts = items }; var b = new NestedCollections { ListOfDicts = items.Select(d => new Dictionary(d)).ToList() }; - return a.Equals(b).ToProperty(); + return Prop.ToProperty(a.Equals(b)); } [Property] @@ -153,16 +154,16 @@ public Property ListOfDicts_InnerInsertionOrderDoesNotMatter(List d.Reverse().ToDictionary(kv => kv.Key, kv => kv.Value)).ToList(); var a = new NestedCollections { ListOfDicts = items }; var b = new NestedCollections { ListOfDicts = reversed }; - return a.Equals(b).ToProperty(); + return Prop.ToProperty(a.Equals(b)); } [Property] public Property ListOfDicts_OuterOrderMatters(Dictionary d1, Dictionary d2) { - if (d1.SequenceEqual(d2)) return true.ToProperty().When(true); + if (d1.SequenceEqual(d2)) return Prop.When(true, true); var a = new NestedCollections { ListOfDicts = [d1, d2] }; var b = new NestedCollections { ListOfDicts = [d2, d1] }; - return (!a.Equals(b)).ToProperty(); + return Prop.ToProperty((!a.Equals(b))); } [Property] @@ -170,7 +171,7 @@ public Property ListOfDicts_EqualImpliesSameHash(List> i { var a = new NestedCollections { ListOfDicts = items }; var b = new NestedCollections { ListOfDicts = items.Select(d => new Dictionary(d)).ToList() }; - return (a.Equals(b) && a.GetHashCode() == b.GetHashCode()).ToProperty(); + return Prop.ToProperty((a.Equals(b) && a.GetHashCode() == b.GetHashCode())); } // ══════════════════════════════════════════════════════════════════════ @@ -182,7 +183,7 @@ public Property ListOfSets_EqualWhenSameContent(List> items) { var a = new NestedCollections { ListOfSets = items }; var b = new NestedCollections { ListOfSets = items.Select(s => new HashSet(s)).ToList() }; - return a.Equals(b).ToProperty(); + return Prop.ToProperty(a.Equals(b)); } [Property] @@ -190,17 +191,17 @@ public Property ListOfSets_InnerOrderDoesNotMatter(List> items) { var a = new NestedCollections { ListOfSets = items }; var b = new NestedCollections { ListOfSets = items.Select(s => new HashSet(s.Reverse())).ToList() }; - return a.Equals(b).ToProperty(); + return Prop.ToProperty(a.Equals(b)); } [Property] public Property ListOfSets_OuterOrderMatters(HashSet s1, HashSet s2) { // Two distinct non-equal sets — swapping them must break equality - if (s1.SetEquals(s2)) return true.ToProperty().When(true); + if (s1.SetEquals(s2)) return Prop.When(true, true); var a = new NestedCollections { ListOfSets = [s1, s2] }; var b = new NestedCollections { ListOfSets = [s2, s1] }; - return (!a.Equals(b)).ToProperty(); + return Prop.ToProperty((!a.Equals(b))); } // ══════════════════════════════════════════════════════════════════════ @@ -212,26 +213,26 @@ public Property ListOfLists_EqualWhenSameContent(List> items) { var a = new NestedCollections { ListOfLists = items }; var b = new NestedCollections { ListOfLists = items.Select(l => new List(l)).ToList() }; - return a.Equals(b).ToProperty(); + return Prop.ToProperty(a.Equals(b)); } [Property] public Property ListOfLists_OuterOrderMatters(List l1, List l2) { - if (l1.SequenceEqual(l2)) return true.ToProperty().When(true); + if (l1.SequenceEqual(l2)) return Prop.When(true, true); var a = new NestedCollections { ListOfLists = [l1, l2] }; var b = new NestedCollections { ListOfLists = [l2, l1] }; - return (!a.Equals(b)).ToProperty(); + return Prop.ToProperty((!a.Equals(b))); } [Property] public Property ListOfLists_InnerOrderMatters(string outerTag, int v1, int v2) { // inner lists are order-sensitive - if (v1 == v2) return true.ToProperty().When(true); + if (v1 == v2) return Prop.When(true, true); var a = new NestedCollections { ListOfLists = [[v1, v2]] }; var b = new NestedCollections { ListOfLists = [[v2, v1]] }; - return (!a.Equals(b)).ToProperty(); + return Prop.ToProperty((!a.Equals(b))); } [Property] @@ -239,7 +240,7 @@ public Property ListOfLists_EqualImpliesSameHash(List> items) { var a = new NestedCollections { ListOfLists = items }; var b = new NestedCollections { ListOfLists = items.Select(l => new List(l)).ToList() }; - return (a.Equals(b) && a.GetHashCode() == b.GetHashCode()).ToProperty(); + return Prop.ToProperty((a.Equals(b) && a.GetHashCode() == b.GetHashCode())); } // ══════════════════════════════════════════════════════════════════════ @@ -247,12 +248,12 @@ public Property ListOfLists_EqualImpliesSameHash(List> items) // ══════════════════════════════════════════════════════════════════════ [Property] - public Property SetOfLists_EqualWhenSameContent(List> items) + public Property SetOfLists_EqualWhenSameReferences(List> items) { - // build two HashSet> from the same elements + // HashSet> uses reference equality for List elements — same refs must be equal var a = new NestedCollections { SetOfLists = new HashSet>(items) }; - var b = new NestedCollections { SetOfLists = new HashSet>(items.Select(l => new List(l))) }; - return a.Equals(b).ToProperty(); + var b = new NestedCollections { SetOfLists = new HashSet>(((IEnumerable>)items).Reverse()) }; + return Prop.ToProperty(a.Equals(b)); } // ══════════════════════════════════════════════════════════════════════ @@ -260,11 +261,12 @@ public Property SetOfLists_EqualWhenSameContent(List> items) // ══════════════════════════════════════════════════════════════════════ [Property] - public Property SetOfDicts_EqualWhenSameContent(List> items) + public Property SetOfDicts_EqualWhenSameReferences(List> items) { + // HashSet> uses reference equality for dict elements — same refs must be equal var a = new NestedCollections { SetOfDicts = new HashSet>(items) }; - var b = new NestedCollections { SetOfDicts = new HashSet>(items.Select(d => new Dictionary(d))) }; - return a.Equals(b).ToProperty(); + var b = new NestedCollections { SetOfDicts = new HashSet>(((IEnumerable>)items).Reverse()) }; + return Prop.ToProperty(a.Equals(b)); } // ══════════════════════════════════════════════════════════════════════ @@ -277,7 +279,7 @@ public Property ThreeLevelNested_EqualWhenSameContent(Dictionary o.Key, o => o.Value.ToDictionary(i => i.Key, i => new List(i.Value))); var a = new NestedCollections { ThreeLevelNested = raw }; var b = new NestedCollections { ThreeLevelNested = copy }; - return a.Equals(b).ToProperty(); + return Prop.ToProperty(a.Equals(b)); } [Property] @@ -285,7 +287,7 @@ public Property ThreeLevelNested_OuterInsertionOrderDoesNotMatter(Dictionary kv.Key, kv => kv.Value) }; - return a.Equals(b).ToProperty(); + return Prop.ToProperty(a.Equals(b)); } [Property] @@ -294,13 +296,13 @@ public Property ThreeLevelNested_MiddleInsertionOrderDoesNotMatter(Dictionary o.Key, o => o.Value.Reverse().ToDictionary(kv => kv.Key, kv => kv.Value)); var a = new NestedCollections { ThreeLevelNested = raw }; var b = new NestedCollections { ThreeLevelNested = reversed }; - return a.Equals(b).ToProperty(); + return Prop.ToProperty(a.Equals(b)); } [Property] public Property ThreeLevelNested_InnermostOrderMatters(string outerKey, string innerKey, int v1, int v2) { - if (v1 == v2) return true.ToProperty().When(true); + if (v1 == v2) return Prop.When(true, true); var a = new NestedCollections { ThreeLevelNested = new Dictionary>> @@ -315,7 +317,7 @@ public Property ThreeLevelNested_InnermostOrderMatters(string outerKey, string i [outerKey] = new Dictionary> { [innerKey] = [v2, v1] } } }; - return (!a.Equals(b)).ToProperty(); + return Prop.ToProperty((!a.Equals(b))); } [Property] @@ -324,7 +326,7 @@ public Property ThreeLevelNested_EqualImpliesSameHash(Dictionary o.Key, o => o.Value.ToDictionary(i => i.Key, i => new List(i.Value))); var a = new NestedCollections { ThreeLevelNested = raw }; var b = new NestedCollections { ThreeLevelNested = copy }; - return (a.Equals(b) && a.GetHashCode() == b.GetHashCode()).ToProperty(); + return Prop.ToProperty((a.Equals(b) && a.GetHashCode() == b.GetHashCode())); } // ══════════════════════════════════════════════════════════════════════ @@ -337,7 +339,7 @@ public Property DictOfListOfSets_EqualWhenSameContent(Dictionary kv.Key, kv => kv.Value.Select(s => new HashSet(s)).ToList()); var a = new NestedCollections { DictOfListOfSets = raw }; var b = new NestedCollections { DictOfListOfSets = copy }; - return a.Equals(b).ToProperty(); + return Prop.ToProperty(a.Equals(b)); } [Property] @@ -345,17 +347,17 @@ public Property DictOfListOfSets_OuterInsertionOrderDoesNotMatter(Dictionary kv.Key, kv => kv.Value) }; - return a.Equals(b).ToProperty(); + return Prop.ToProperty(a.Equals(b)); } [Property] public Property DictOfListOfSets_MiddleOrderMatters(string key, HashSet s1, HashSet s2) { // middle is List — position matters - if (s1.SetEquals(s2)) return true.ToProperty().When(true); + if (s1.SetEquals(s2)) return Prop.When(true, true); var a = new NestedCollections { DictOfListOfSets = new Dictionary>> { [key] = [s1, s2] } }; var b = new NestedCollections { DictOfListOfSets = new Dictionary>> { [key] = [s2, s1] } }; - return (!a.Equals(b)).ToProperty(); + return Prop.ToProperty((!a.Equals(b))); } [Property] @@ -372,7 +374,7 @@ public Property DictOfListOfSets_InnermostOrderDoesNotMatter(string key, int v1, DictOfListOfSets = new Dictionary>> { [key] = [new HashSet { v2, v1 }] } }; - return a.Equals(b).ToProperty(); + return Prop.ToProperty(a.Equals(b)); } // ══════════════════════════════════════════════════════════════════════ @@ -387,7 +389,7 @@ public Property DictOfListOfDicts_EqualWhenSameContent(Dictionary kv.Value.Select(d => new Dictionary(d)).ToList()); var a = new NestedCollections { DictOfListOfDicts = raw }; var b = new NestedCollections { DictOfListOfDicts = copy }; - return a.Equals(b).ToProperty(); + return Prop.ToProperty(a.Equals(b)); } [Property] @@ -395,17 +397,17 @@ public Property DictOfListOfDicts_OuterInsertionOrderDoesNotMatter(Dictionary kv.Key, kv => kv.Value) }; - return a.Equals(b).ToProperty(); + return Prop.ToProperty(a.Equals(b)); } [Property] public Property DictOfListOfDicts_MiddleOrderMatters(string key, Dictionary d1, Dictionary d2) { // middle is List — position matters - if (d1.SequenceEqual(d2)) return true.ToProperty().When(true); + if (d1.SequenceEqual(d2)) return Prop.When(true, true); var a = new NestedCollections { DictOfListOfDicts = new Dictionary>> { [key] = [d1, d2] } }; var b = new NestedCollections { DictOfListOfDicts = new Dictionary>> { [key] = [d2, d1] } }; - return (!a.Equals(b)).ToProperty(); + return Prop.ToProperty((!a.Equals(b))); } [Property] @@ -416,7 +418,7 @@ public Property DictOfListOfDicts_InnermostInsertionOrderDoesNotMatter(Dictionar kv => kv.Value.Select(d => d.Reverse().ToDictionary(p => p.Key, p => p.Value)).ToList()); var a = new NestedCollections { DictOfListOfDicts = raw }; var b = new NestedCollections { DictOfListOfDicts = copy }; - return a.Equals(b).ToProperty(); + return Prop.ToProperty(a.Equals(b)); } // ══════════════════════════════════════════════════════════════════════ @@ -429,7 +431,7 @@ public Property ListOfDictOfLists_EqualWhenSameContent(List d.ToDictionary(kv => kv.Key, kv => new List(kv.Value))).ToList(); var a = new NestedCollections { ListOfDictOfLists = items }; var b = new NestedCollections { ListOfDictOfLists = copy }; - return a.Equals(b).ToProperty(); + return Prop.ToProperty(a.Equals(b)); } [Property] @@ -439,10 +441,10 @@ public Property ListOfDictOfLists_OuterOrderMatters(Dictionary Func>, Dictionary>, bool> sameContent = (x, y) => x.Count == y.Count && x.All(kv => y.TryGetValue(kv.Key, out var v) && kv.Value.SequenceEqual(v)); - if (sameContent(d1, d2)) return true.ToProperty().When(true); + if (sameContent(d1, d2)) return Prop.When(true, true); var a = new NestedCollections { ListOfDictOfLists = [d1, d2] }; var b = new NestedCollections { ListOfDictOfLists = [d2, d1] }; - return (!a.Equals(b)).ToProperty(); + return Prop.ToProperty((!a.Equals(b))); } [Property] @@ -451,17 +453,17 @@ public Property ListOfDictOfLists_MiddleInsertionOrderDoesNotMatter(List d.Reverse().ToDictionary(kv => kv.Key, kv => kv.Value)).ToList(); var a = new NestedCollections { ListOfDictOfLists = items }; var b = new NestedCollections { ListOfDictOfLists = reversed }; - return a.Equals(b).ToProperty(); + return Prop.ToProperty(a.Equals(b)); } [Property] public Property ListOfDictOfLists_InnermostOrderMatters(string key, int v1, int v2) { // innermost is List — position matters - if (v1 == v2) return true.ToProperty().When(true); + if (v1 == v2) return Prop.When(true, true); var a = new NestedCollections { ListOfDictOfLists = [new Dictionary> { [key] = [v1, v2] }] }; var b = new NestedCollections { ListOfDictOfLists = [new Dictionary> { [key] = [v2, v1] }] }; - return (!a.Equals(b)).ToProperty(); + return Prop.ToProperty((!a.Equals(b))); } // ══════════════════════════════════════════════════════════════════════ @@ -474,7 +476,7 @@ public Property ListOfDictOfSets_EqualWhenSameContent(List d.ToDictionary(kv => kv.Key, kv => new HashSet(kv.Value))).ToList(); var a = new NestedCollections { ListOfDictOfSets = items }; var b = new NestedCollections { ListOfDictOfSets = copy }; - return a.Equals(b).ToProperty(); + return Prop.ToProperty(a.Equals(b)); } [Property] @@ -483,10 +485,10 @@ public Property ListOfDictOfSets_OuterOrderMatters(Dictionary>, Dictionary>, bool> sameContent = (x, y) => x.Count == y.Count && x.All(kv => y.TryGetValue(kv.Key, out var v) && kv.Value.SetEquals(v)); - if (sameContent(d1, d2)) return true.ToProperty().When(true); + if (sameContent(d1, d2)) return Prop.When(true, true); var a = new NestedCollections { ListOfDictOfSets = [d1, d2] }; var b = new NestedCollections { ListOfDictOfSets = [d2, d1] }; - return (!a.Equals(b)).ToProperty(); + return Prop.ToProperty((!a.Equals(b))); } [Property] @@ -495,7 +497,7 @@ public Property ListOfDictOfSets_MiddleInsertionOrderDoesNotMatter(List d.Reverse().ToDictionary(kv => kv.Key, kv => kv.Value)).ToList(); var a = new NestedCollections { ListOfDictOfSets = items }; var b = new NestedCollections { ListOfDictOfSets = reversed }; - return a.Equals(b).ToProperty(); + return Prop.ToProperty(a.Equals(b)); } [Property] @@ -510,7 +512,7 @@ public Property ListOfDictOfSets_InnermostOrderDoesNotMatter(string key, int v1, { ListOfDictOfSets = [new Dictionary> { [key] = new HashSet { v2, v1 } }] }; - return a.Equals(b).ToProperty(); + return Prop.ToProperty(a.Equals(b)); } // ══════════════════════════════════════════════════════════════════════ @@ -523,7 +525,7 @@ public Property ListOfListOfDicts_EqualWhenSameContent(List l.Select(d => new Dictionary(d)).ToList()).ToList(); var a = new NestedCollections { ListOfListOfDicts = items }; var b = new NestedCollections { ListOfListOfDicts = copy }; - return a.Equals(b).ToProperty(); + return Prop.ToProperty(a.Equals(b)); } [Property] @@ -533,20 +535,20 @@ public Property ListOfListOfDicts_OuterOrderMatters(List (x, y) => x.Count == y.Count && x.Zip(y).All(pair => pair.First.SequenceEqual(pair.Second)); - if (sameContent(l1, l2)) return true.ToProperty().When(true); + if (sameContent(l1, l2)) return Prop.When(true, true); var a = new NestedCollections { ListOfListOfDicts = [l1, l2] }; var b = new NestedCollections { ListOfListOfDicts = [l2, l1] }; - return (!a.Equals(b)).ToProperty(); + return Prop.ToProperty((!a.Equals(b))); } [Property] public Property ListOfListOfDicts_MiddleOrderMatters(Dictionary d1, Dictionary d2) { // middle is also List — position matters - if (d1.SequenceEqual(d2)) return true.ToProperty().When(true); + if (d1.SequenceEqual(d2)) return Prop.When(true, true); var a = new NestedCollections { ListOfListOfDicts = [[d1, d2]] }; var b = new NestedCollections { ListOfListOfDicts = [[d2, d1]] }; - return (!a.Equals(b)).ToProperty(); + return Prop.ToProperty((!a.Equals(b))); } [Property] @@ -555,7 +557,7 @@ public Property ListOfListOfDicts_InnermostInsertionOrderDoesNotMatter(List l.Select(d => d.Reverse().ToDictionary(kv => kv.Key, kv => kv.Value)).ToList()).ToList(); var a = new NestedCollections { ListOfListOfDicts = items }; var b = new NestedCollections { ListOfListOfDicts = copy }; - return a.Equals(b).ToProperty(); + return Prop.ToProperty(a.Equals(b)); } [Property] @@ -564,7 +566,7 @@ public Property ListOfListOfDicts_EqualImpliesSameHash(List l.Select(d => new Dictionary(d)).ToList()).ToList(); var a = new NestedCollections { ListOfListOfDicts = items }; var b = new NestedCollections { ListOfListOfDicts = copy }; - return (a.Equals(b) && a.GetHashCode() == b.GetHashCode()).ToProperty(); + return Prop.ToProperty((a.Equals(b) && a.GetHashCode() == b.GetHashCode())); } // ══════════════════════════════════════════════════════════════════════ @@ -576,16 +578,16 @@ public Property FlatArray_EqualWhenSameContent(int[] arr) { var a = new NestedCollections { FlatArray = arr }; var b = new NestedCollections { FlatArray = (int[])arr.Clone() }; - return a.Equals(b).ToProperty(); + return Prop.ToProperty(a.Equals(b)); } [Property] public Property FlatArray_OrderMatters(int v1, int v2) { - if (v1 == v2) return true.ToProperty().When(true); + if (v1 == v2) return Prop.When(true, true); var a = new NestedCollections { FlatArray = [v1, v2] }; var b = new NestedCollections { FlatArray = [v2, v1] }; - return (!a.Equals(b)).ToProperty(); + return Prop.ToProperty((!a.Equals(b))); } [Property] @@ -593,7 +595,7 @@ public Property FlatArray_EqualImpliesSameHash(int[] arr) { var a = new NestedCollections { FlatArray = arr }; var b = new NestedCollections { FlatArray = (int[])arr.Clone() }; - return (a.Equals(b) && a.GetHashCode() == b.GetHashCode()).ToProperty(); + return Prop.ToProperty((a.Equals(b) && a.GetHashCode() == b.GetHashCode())); } // ══════════════════════════════════════════════════════════════════════ @@ -605,25 +607,25 @@ public Property ArrayOfArrays_EqualWhenSameContent(int[][] arr) { var a = new NestedCollections { ArrayOfArrays = arr }; var b = new NestedCollections { ArrayOfArrays = arr.Select(inner => (int[])inner.Clone()).ToArray() }; - return a.Equals(b).ToProperty(); + return Prop.ToProperty(a.Equals(b)); } [Property] public Property ArrayOfArrays_OuterOrderMatters(int[] inner1, int[] inner2) { - if (inner1.SequenceEqual(inner2)) return true.ToProperty().When(true); + if (inner1.SequenceEqual(inner2)) return Prop.When(true, true); var a = new NestedCollections { ArrayOfArrays = [inner1, inner2] }; var b = new NestedCollections { ArrayOfArrays = [inner2, inner1] }; - return (!a.Equals(b)).ToProperty(); + return Prop.ToProperty((!a.Equals(b))); } [Property] public Property ArrayOfArrays_InnerOrderMatters(int v1, int v2) { - if (v1 == v2) return true.ToProperty().When(true); + if (v1 == v2) return Prop.When(true, true); var a = new NestedCollections { ArrayOfArrays = [[v1, v2]] }; var b = new NestedCollections { ArrayOfArrays = [[v2, v1]] }; - return (!a.Equals(b)).ToProperty(); + return Prop.ToProperty((!a.Equals(b))); } // ══════════════════════════════════════════════════════════════════════ @@ -635,16 +637,16 @@ public Property ArrayOfDicts_EqualWhenSameContent(Dictionary[] arr) { var a = new NestedCollections { ArrayOfDicts = arr }; var b = new NestedCollections { ArrayOfDicts = arr.Select(d => new Dictionary(d)).ToArray() }; - return a.Equals(b).ToProperty(); + return Prop.ToProperty(a.Equals(b)); } [Property] public Property ArrayOfDicts_OuterOrderMatters(Dictionary d1, Dictionary d2) { - if (d1.SequenceEqual(d2)) return true.ToProperty().When(true); + if (d1.SequenceEqual(d2)) return Prop.When(true, true); var a = new NestedCollections { ArrayOfDicts = [d1, d2] }; var b = new NestedCollections { ArrayOfDicts = [d2, d1] }; - return (!a.Equals(b)).ToProperty(); + return Prop.ToProperty((!a.Equals(b))); } [Property] @@ -652,7 +654,7 @@ public Property ArrayOfDicts_InnerInsertionOrderDoesNotMatter(Dictionary d.Reverse().ToDictionary(kv => kv.Key, kv => kv.Value)).ToArray() }; - return a.Equals(b).ToProperty(); + return Prop.ToProperty(a.Equals(b)); } // ══════════════════════════════════════════════════════════════════════ @@ -664,7 +666,7 @@ public Property DictOfArrays_EqualWhenSameContent(Dictionary raw) { var a = new NestedCollections { DictOfArrays = raw }; var b = new NestedCollections { DictOfArrays = raw.ToDictionary(kv => kv.Key, kv => (int[])kv.Value.Clone()) }; - return a.Equals(b).ToProperty(); + return Prop.ToProperty(a.Equals(b)); } [Property] @@ -672,16 +674,16 @@ public Property DictOfArrays_OuterInsertionOrderDoesNotMatter(Dictionary kv.Key, kv => kv.Value) }; - return a.Equals(b).ToProperty(); + return Prop.ToProperty(a.Equals(b)); } [Property] public Property DictOfArrays_InnerOrderMatters(string key, int v1, int v2) { - if (v1 == v2) return true.ToProperty().When(true); + if (v1 == v2) return Prop.When(true, true); var a = new NestedCollections { DictOfArrays = new Dictionary { [key] = [v1, v2] } }; var b = new NestedCollections { DictOfArrays = new Dictionary { [key] = [v2, v1] } }; - return (!a.Equals(b)).ToProperty(); + return Prop.ToProperty((!a.Equals(b))); } [Property] @@ -689,6 +691,6 @@ public Property DictOfArrays_EqualImpliesSameHash(Dictionary raw) { var a = new NestedCollections { DictOfArrays = raw }; var b = new NestedCollections { DictOfArrays = raw.ToDictionary(kv => kv.Key, kv => (int[])kv.Value.Clone()) }; - return (a.Equals(b) && a.GetHashCode() == b.GetHashCode()).ToProperty(); + return Prop.ToProperty((a.Equals(b) && a.GetHashCode() == b.GetHashCode())); } } diff --git a/test/Equatable.Generator.Tests/Properties/OrderDataContractProperties.cs b/test/Equatable.Generator.Tests/Properties/OrderDataContractProperties.cs index 2a84c80..aef1d60 100644 --- a/test/Equatable.Generator.Tests/Properties/OrderDataContractProperties.cs +++ b/test/Equatable.Generator.Tests/Properties/OrderDataContractProperties.cs @@ -12,7 +12,7 @@ public class OrderDataContractProperties public Property Reflexivity(int id, string? name) { var o = new OrderDataContract { Id = id, Name = name }; - return o.Equals(o).ToProperty(); + return Prop.ToProperty(o.Equals(o)); } [Property] @@ -20,7 +20,7 @@ public Property Symmetry(int id1, string? name1, int id2, string? name2) { var a = new OrderDataContract { Id = id1, Name = name1 }; var b = new OrderDataContract { Id = id2, Name = name2 }; - return (a.Equals(b) == b.Equals(a)).ToProperty(); + return Prop.ToProperty(a.Equals(b) == b.Equals(a)); } [Property] @@ -28,7 +28,7 @@ public Property EqualImpliesSameHashCode(int id, string? name) { var a = new OrderDataContract { Id = id, Name = name }; var b = new OrderDataContract { Id = id, Name = name }; - return (a.Equals(b) && a.GetHashCode() == b.GetHashCode()).ToProperty(); + return Prop.ToProperty(a.Equals(b) && a.GetHashCode() == b.GetHashCode()); } [Property] @@ -37,7 +37,7 @@ public Property NonDataMemberFieldsIgnored(int id, string? name, string? note1, // InternalNote (no [DataMember]) and IgnoredField ([IgnoreDataMember]) must not affect equality var a = new OrderDataContract { Id = id, Name = name, InternalNote = note1, IgnoredField = ignored1 }; var b = new OrderDataContract { Id = id, Name = name, InternalNote = note2, IgnoredField = ignored2 }; - return a.Equals(b).ToProperty(); + return Prop.ToProperty(a.Equals(b)); } [Property] @@ -45,28 +45,28 @@ public Property NonDataMemberFieldsIgnoredInHashCode(int id, string? name, strin { var a = new OrderDataContract { Id = id, Name = name, InternalNote = note1 }; var b = new OrderDataContract { Id = id, Name = name, InternalNote = note2 }; - return (a.GetHashCode() == b.GetHashCode()).ToProperty(); + return Prop.ToProperty(a.GetHashCode() == b.GetHashCode()); } [Property] public Property DifferentIdNotEqual(string? name, int id1, int id2) { if (id1 == id2) - return true.ToProperty().When(true); + return Prop.When(true, true); var a = new OrderDataContract { Id = id1, Name = name }; var b = new OrderDataContract { Id = id2, Name = name }; - return (!a.Equals(b)).ToProperty(); + return Prop.ToProperty(!a.Equals(b)); } [Property] public Property DifferentNameNotEqual(int id, string name1, string name2) { if (name1 == name2) - return true.ToProperty().When(true); + return Prop.When(true, true); var a = new OrderDataContract { Id = id, Name = name1 }; var b = new OrderDataContract { Id = id, Name = name2 }; - return (!a.Equals(b)).ToProperty(); + return Prop.ToProperty(!a.Equals(b)); } } diff --git a/test/Equatable.Generator.Tests/Properties/OrderedDictionaryComparerProperties.cs b/test/Equatable.Generator.Tests/Properties/OrderedDictionaryComparerProperties.cs new file mode 100644 index 0000000..271cb49 --- /dev/null +++ b/test/Equatable.Generator.Tests/Properties/OrderedDictionaryComparerProperties.cs @@ -0,0 +1,49 @@ +using System.Collections.Generic; +using System.Linq; +using Equatable.Comparers; + +namespace Equatable.Generator.Tests.Properties; + +public class OrderedDictionaryComparerProperties +{ + private static readonly OrderedDictionaryEqualityComparer Comparer + = OrderedDictionaryEqualityComparer.Default; + + [Property] + public Property Reflexivity(Dictionary dict) + { + return Prop.ToProperty(Comparer.Equals(dict, dict)); + } + + [Property] + public Property Symmetry(Dictionary x, Dictionary y) + { + return Prop.ToProperty(Comparer.Equals(x, y) == Comparer.Equals(y, x)); + } + + [Property] + public Property HashIsInsertionOrderIndependent(Dictionary dict) + { + var reversed = new Dictionary(dict.Reverse()); + return Prop.ToProperty(Comparer.GetHashCode(dict) == Comparer.GetHashCode(reversed)); + } + + [Property] + public Property EqualImpliesSameHashCode(Dictionary dict) + { + var reversed = new Dictionary(dict.Reverse()); + return Prop.ToProperty(Comparer.Equals(dict, reversed) && Comparer.GetHashCode(dict) == Comparer.GetHashCode(reversed)); + } + + [Property] + public Property DifferentValueMakesNotEqual(Dictionary dict, string key, int v1, int v2) + { + if (v1 == v2) + return Prop.When(true, true); + + var a = new Dictionary(dict) { [key] = v1 }; + var b = new Dictionary(dict) { [key] = v2 }; + + return Prop.ToProperty(!Comparer.Equals(a, b)); + } +} diff --git a/test/Equatable.Generator.Tests/Properties/ReadOnlyDictionaryComparerProperties.cs b/test/Equatable.Generator.Tests/Properties/ReadOnlyDictionaryComparerProperties.cs index 880241a..20d1cf9 100644 --- a/test/Equatable.Generator.Tests/Properties/ReadOnlyDictionaryComparerProperties.cs +++ b/test/Equatable.Generator.Tests/Properties/ReadOnlyDictionaryComparerProperties.cs @@ -10,7 +10,7 @@ public class ReadOnlyDictionaryComparerProperties public Property Reflexivity(Dictionary dict) { IReadOnlyDictionary d = dict; - return Comparer.Equals(d, d).ToProperty(); + return Prop.ToProperty(Comparer.Equals(d, d)); } [Property] @@ -18,7 +18,7 @@ public Property Symmetry(Dictionary x, Dictionary y) { IReadOnlyDictionary a = x; IReadOnlyDictionary b = y; - return (Comparer.Equals(a, b) == Comparer.Equals(b, a)).ToProperty(); + return Prop.ToProperty(Comparer.Equals(a, b) == Comparer.Equals(b, a)); } [Property] @@ -27,7 +27,7 @@ public Property EqualImpliesSameHashCode(Dictionary dict) // two dictionaries with same entries in different insertion order must have equal hash IReadOnlyDictionary a = dict; IReadOnlyDictionary b = new Dictionary(dict.Reverse()); - return (Comparer.Equals(a, b) == (Comparer.GetHashCode(a) == Comparer.GetHashCode(b))).ToProperty(); + return Prop.ToProperty(Comparer.Equals(a, b) == (Comparer.GetHashCode(a) == Comparer.GetHashCode(b))); } [Property] @@ -35,20 +35,20 @@ public Property HashIsInsertionOrderIndependent(Dictionary dict) { IReadOnlyDictionary a = dict; IReadOnlyDictionary b = new Dictionary(dict.Reverse()); - return (Comparer.GetHashCode(a) == Comparer.GetHashCode(b)).ToProperty(); + return Prop.ToProperty(Comparer.GetHashCode(a) == Comparer.GetHashCode(b)); } [Property] public Property NullEqualsNull() { - return Comparer.Equals(null, null).ToProperty(); + return Prop.ToProperty(Comparer.Equals(null, null)); } [Property] public Property NullNotEqualsNonNull(Dictionary dict) { IReadOnlyDictionary d = dict; - return (!Comparer.Equals(null, d) && !Comparer.Equals(d, null)).ToProperty(); + return Prop.ToProperty(!Comparer.Equals(null, d) && !Comparer.Equals(d, null)); } [Property] @@ -56,12 +56,12 @@ public Property ExtraKeyMakesNotEqual(Dictionary dict, string key, { // guard: key must not already be in dict if (dict.ContainsKey(key)) - return true.ToProperty().When(true); // vacuously true — skip this input + return Prop.When(true, true); // vacuously true — skip this input IReadOnlyDictionary a = dict; var bigger = new Dictionary(dict) { [key] = value }; IReadOnlyDictionary b = bigger; - return (!Comparer.Equals(a, b)).ToProperty(); + return Prop.ToProperty(!Comparer.Equals(a, b)); } } diff --git a/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateIDictionaryEquality.verified.txt b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateIDictionaryEquality.verified.txt index 5788301..888d62c 100644 --- a/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateIDictionaryEquality.verified.txt +++ b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateIDictionaryEquality.verified.txt @@ -89,7 +89,6 @@ namespace Equatable.Entities int hashCode = 0; - // sum of per-pair hashes is order-independent without sorting allocations foreach (var item in items) hashCode += global::System.HashCode.Combine( global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(item.Key!), diff --git a/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateISetHashSetEquality.verified.txt b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateISetHashSetEquality.verified.txt index a9bd8b7..4dbb0be 100644 --- a/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateISetHashSetEquality.verified.txt +++ b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateISetHashSetEquality.verified.txt @@ -62,7 +62,6 @@ namespace Equatable.Entities int hashCode = 0; - // sum of individual hashes is order-independent without sorting allocations foreach (var item in items) hashCode += global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(item!); diff --git a/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateReadOnlyDictionary.verified.txt b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateReadOnlyDictionary.verified.txt index 5e289d8..2f7aeee 100644 --- a/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateReadOnlyDictionary.verified.txt +++ b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateReadOnlyDictionary.verified.txt @@ -89,7 +89,6 @@ namespace Equatable.Entities int hashCode = 0; - // sum of per-pair hashes is order-independent without sorting allocations foreach (var item in items) hashCode += global::System.HashCode.Combine( global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(item.Key!), diff --git a/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateSequentialDictionaryEquality.verified.txt b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateSequentialDictionaryEquality.verified.txt new file mode 100644 index 0000000..49f0ca2 --- /dev/null +++ b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateSequentialDictionaryEquality.verified.txt @@ -0,0 +1,45 @@ +// +#nullable enable + +namespace Equatable.Entities +{ + partial class Container : global::System.IEquatable + { + /// + public bool Equals(global::Equatable.Entities.Container? other) + { + return !(other is null) + && (new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default)).Equals(Entries, other.Entries) + && (new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default)).Equals(ReadOnlyEntries, other.ReadOnlyEntries); + + } + + /// + public override bool Equals(object? obj) + { + return Equals(obj as global::Equatable.Entities.Container); + } + + /// + public static bool operator ==(global::Equatable.Entities.Container? left, global::Equatable.Entities.Container? right) + { + return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); + } + + /// + public static bool operator !=(global::Equatable.Entities.Container? left, global::Equatable.Entities.Container? right) + { + return !(left == right); + } + + /// + public override int GetHashCode(){ + int hashCode = 612607000; + hashCode = (hashCode * -1521134295) + (new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default)).GetHashCode(Entries!); + hashCode = (hashCode * -1521134295) + (new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default)).GetHashCode(ReadOnlyEntries!); + return hashCode; + + } + + } +} diff --git a/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateUserImport.verified.txt b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateUserImport.verified.txt index 03e6366..1d613b8 100644 --- a/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateUserImport.verified.txt +++ b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateUserImport.verified.txt @@ -134,7 +134,6 @@ namespace Equatable.Entities int hashCode = 0; - // sum of per-pair hashes is order-independent without sorting allocations foreach (var item in items) hashCode += global::System.HashCode.Combine( global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(item.Key!), @@ -150,7 +149,6 @@ namespace Equatable.Entities int hashCode = 0; - // sum of individual hashes is order-independent without sorting allocations foreach (var item in items) hashCode += global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(item!); diff --git a/test/Equatable.Generator.Tests/Snapshots/EquatableWriterTest.GenerateUserImportHashSetDictionary.verified.txt b/test/Equatable.Generator.Tests/Snapshots/EquatableWriterTest.GenerateUserImportHashSetDictionary.verified.txt index 5ddc89a..47e5179 100644 --- a/test/Equatable.Generator.Tests/Snapshots/EquatableWriterTest.GenerateUserImportHashSetDictionary.verified.txt +++ b/test/Equatable.Generator.Tests/Snapshots/EquatableWriterTest.GenerateUserImportHashSetDictionary.verified.txt @@ -121,7 +121,6 @@ namespace Equatable.Entities int hashCode = 0; - // sum of per-pair hashes is order-independent without sorting allocations foreach (var item in items) hashCode += global::System.HashCode.Combine( global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(item.Key!), @@ -137,7 +136,6 @@ namespace Equatable.Entities int hashCode = 0; - // sum of individual hashes is order-independent without sorting allocations foreach (var item in items) hashCode += global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(item!); From 390b187a4ab026622e41ad63c2a43529f480d3be Mon Sep 17 00:00:00 2001 From: "a.kasyanov" Date: Tue, 12 May 2026 23:14:00 +0300 Subject: [PATCH 09/71] docs: add comments explaining equality contract fix in dictionary and hashset comparers Co-Authored-By: Claude Sonnet 4.5 --- src/Equatable.Comparers/DictionaryEqualityComparer.cs | 5 +++++ src/Equatable.Comparers/HashSetEqualityComparer.cs | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/src/Equatable.Comparers/DictionaryEqualityComparer.cs b/src/Equatable.Comparers/DictionaryEqualityComparer.cs index 1fd36cb..6a6d258 100644 --- a/src/Equatable.Comparers/DictionaryEqualityComparer.cs +++ b/src/Equatable.Comparers/DictionaryEqualityComparer.cs @@ -73,6 +73,11 @@ public int GetHashCode(IDictionary obj) int hashCode = 0; + // 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!)); diff --git a/src/Equatable.Comparers/HashSetEqualityComparer.cs b/src/Equatable.Comparers/HashSetEqualityComparer.cs index a2b0255..937e525 100644 --- a/src/Equatable.Comparers/HashSetEqualityComparer.cs +++ b/src/Equatable.Comparers/HashSetEqualityComparer.cs @@ -58,6 +58,11 @@ public int GetHashCode(IEnumerable obj) int hashCode = 0; + // 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!); From 287d7324bda3b832d4659483caccf68025003163 Mon Sep 17 00:00:00 2001 From: "a.kasyanov" Date: Wed, 13 May 2026 18:39:55 +0300 Subject: [PATCH 10/71] fix: use sentinel value 1 for empty collection hash codes to distinguish null from empty DictionaryHashCode and HashSetHashCode previously initialized hashCode to 0, causing null and empty collections to produce the same hash (0). This violates the expectation that null != empty in hash-based collections. Using 1 as the sentinel for empty matches the pattern already established in EqualityHelper. Co-Authored-By: Claude Sonnet 4.5 --- src/Equatable.SourceGenerator/EquatableWriter.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Equatable.SourceGenerator/EquatableWriter.cs b/src/Equatable.SourceGenerator/EquatableWriter.cs index 0ed6fa0..899d725 100644 --- a/src/Equatable.SourceGenerator/EquatableWriter.cs +++ b/src/Equatable.SourceGenerator/EquatableWriter.cs @@ -587,7 +587,7 @@ private static void GenerateHashCodeFunctions(IndentedStringBuilder codeBuilder, .AppendLine("if (items is null)") .AppendLine(" return 0;") .AppendLine() - .AppendLine("int hashCode = 0;") + .AppendLine("int hashCode = 1;") .AppendLine() .AppendLine("foreach (var item in items)") .AppendLine(" hashCode += global::System.HashCode.Combine(") @@ -634,7 +634,7 @@ private static void GenerateHashCodeFunctions(IndentedStringBuilder codeBuilder, .AppendLine("if (items is null)") .AppendLine(" return 0;") .AppendLine() - .AppendLine("int hashCode = 0;") + .AppendLine("int hashCode = 1;") .AppendLine() .AppendLine("foreach (var item in items)") .AppendLine(" hashCode += global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(item!);") From 1418950eaad93cbf25e58a9645f09d17e9daf420 Mon Sep 17 00:00:00 2001 From: "a.kasyanov" Date: Wed, 13 May 2026 18:52:33 +0300 Subject: [PATCH 11/71] fix: use sentinel value 1 for empty collection hash codes in equality comparers DictionaryEqualityComparer, ReadOnlyDictionaryEqualityComparer, and HashSetEqualityComparer all initialized GetHashCode to 0, causing null and empty collections to produce the same hash. This violates the Equals/GetHashCode contract: Equals correctly returns false for (null, empty), so GetHashCode must also differ to avoid unnecessary hash table collisions. Starting from 1 ensures empty collections are always distinguishable from null (which returns 0). Added comments explaining why the seed is 1 and what problem it solves. Co-Authored-By: Claude Sonnet 4.5 --- src/Equatable.Comparers/DictionaryEqualityComparer.cs | 7 ++++++- src/Equatable.Comparers/HashSetEqualityComparer.cs | 7 ++++++- .../ReadOnlyDictionaryEqualityComparer.cs | 7 ++++++- 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/Equatable.Comparers/DictionaryEqualityComparer.cs b/src/Equatable.Comparers/DictionaryEqualityComparer.cs index 6a6d258..07398b8 100644 --- a/src/Equatable.Comparers/DictionaryEqualityComparer.cs +++ b/src/Equatable.Comparers/DictionaryEqualityComparer.cs @@ -71,7 +71,12 @@ public int GetHashCode(IDictionary obj) if (obj == null) return 0; - int hashCode = 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; // Commutative SUM ensures hash is insertion-order independent, consistent with // Equals which uses TryGetValue (also order-independent). Previously GetHashCode diff --git a/src/Equatable.Comparers/HashSetEqualityComparer.cs b/src/Equatable.Comparers/HashSetEqualityComparer.cs index 937e525..8ea56df 100644 --- a/src/Equatable.Comparers/HashSetEqualityComparer.cs +++ b/src/Equatable.Comparers/HashSetEqualityComparer.cs @@ -56,7 +56,12 @@ public int GetHashCode(IEnumerable obj) if (obj == null) return 0; - int hashCode = 0; + // 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; // Commutative SUM ensures hash is iteration-order independent, consistent with // Equals which uses SetEquals (also order-independent). Previously GetHashCode diff --git a/src/Equatable.Comparers/ReadOnlyDictionaryEqualityComparer.cs b/src/Equatable.Comparers/ReadOnlyDictionaryEqualityComparer.cs index 09a91ac..fb5ec7c 100644 --- a/src/Equatable.Comparers/ReadOnlyDictionaryEqualityComparer.cs +++ b/src/Equatable.Comparers/ReadOnlyDictionaryEqualityComparer.cs @@ -71,7 +71,12 @@ public int GetHashCode(IReadOnlyDictionary obj) if (obj == null) return 0; - int hashCode = 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!)); From 14555a8affee91262a24ecb41eb071b0fd1fdc1a Mon Sep 17 00:00:00 2001 From: "a.kasyanov" Date: Wed, 13 May 2026 22:56:07 +0300 Subject: [PATCH 12/71] test: rename property tests to encode invariants and fix bidirectional hash check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename all property-based test methods across four files to follow the Invariant_Subject_Condition pattern, making the tested invariant explicit in the name rather than describing the operation. Fix incorrect EqualImpliesSameHashCode property in ReadOnlyDictionaryComparerProperties and OrderedDictionaryComparerProperties: was asserting Equals(a,b) ↔ hash(a)==hash(b) (bidirectional), which fails on hash collisions. Corrected to one-directional Prop.When(Equals(a,b), hash(a)==hash(b)), which is the actual contract. Co-Authored-By: Claude Sonnet 4.5 --- .../Properties/DictionaryComparerProperties.cs | 10 +++++----- .../Properties/HashSetComparerProperties.cs | 12 ++++++------ .../OrderedDictionaryComparerProperties.cs | 13 +++++++------ .../ReadOnlyDictionaryComparerProperties.cs | 18 +++++++++--------- 4 files changed, 27 insertions(+), 26 deletions(-) diff --git a/test/Equatable.Generator.Tests/Properties/DictionaryComparerProperties.cs b/test/Equatable.Generator.Tests/Properties/DictionaryComparerProperties.cs index 2d130fa..4751944 100644 --- a/test/Equatable.Generator.Tests/Properties/DictionaryComparerProperties.cs +++ b/test/Equatable.Generator.Tests/Properties/DictionaryComparerProperties.cs @@ -7,33 +7,33 @@ public class DictionaryComparerProperties private static readonly DictionaryEqualityComparer Comparer = DictionaryEqualityComparer.Default; [Property] - public Property Reflexivity(Dictionary dict) + public Property Equals_Reflexivity_SameInstance_ReturnsTrue(Dictionary dict) { return Prop.ToProperty(Comparer.Equals(dict, dict)); } [Property] - public Property Symmetry(Dictionary x, Dictionary y) + public Property Equals_Symmetry_AEqualsB_ImpliesBEqualsA(Dictionary x, Dictionary y) { return Prop.ToProperty(Comparer.Equals(x, y) == Comparer.Equals(y, x)); } [Property] - public Property HashIsInsertionOrderIndependent(Dictionary dict) + public Property HashCode_InsertionOrderIndependent(Dictionary dict) { var reversed = new Dictionary(dict.Reverse()); return Prop.ToProperty(Comparer.GetHashCode(dict) == Comparer.GetHashCode(reversed)); } [Property] - public Property EqualDictionariesHaveSameHashCode(Dictionary dict) + public Property HashCode_EqualDictionaries_HaveSameHash(Dictionary dict) { var copy = new Dictionary(dict); return Prop.ToProperty(Comparer.Equals(dict, copy) && Comparer.GetHashCode(dict) == Comparer.GetHashCode(copy)); } [Property] - public Property DifferentValueProducesDifferentHash(Dictionary dict, string key, int v1, int v2) + public Property Equals_DifferentValue_ReturnsFalse(Dictionary dict, string key, int v1, int v2) { if (v1 == v2) return Prop.When(true, true); diff --git a/test/Equatable.Generator.Tests/Properties/HashSetComparerProperties.cs b/test/Equatable.Generator.Tests/Properties/HashSetComparerProperties.cs index 364eeb7..991feed 100644 --- a/test/Equatable.Generator.Tests/Properties/HashSetComparerProperties.cs +++ b/test/Equatable.Generator.Tests/Properties/HashSetComparerProperties.cs @@ -8,19 +8,19 @@ public class HashSetComparerProperties private static readonly HashSetEqualityComparer Comparer = HashSetEqualityComparer.Default; [Property] - public Property Reflexivity(HashSet set) + public Property Equals_Reflexivity_SameInstance_ReturnsTrue(HashSet set) { return Prop.ToProperty(Comparer.Equals(set, set)); } [Property] - public Property Symmetry(HashSet x, HashSet y) + public Property Equals_Symmetry_AEqualsB_ImpliesBEqualsA(HashSet x, HashSet y) { return Prop.ToProperty(Comparer.Equals(x, y) == Comparer.Equals(y, x)); } [Property] - public Property HashIsInsertionOrderIndependent(HashSet set) + public Property HashCode_InsertionOrderIndependent(HashSet set) { // build same set from reversed list — HashSet has no guaranteed iteration order, // but two sets with identical elements must have equal hash regardless of add order @@ -29,14 +29,14 @@ public Property HashIsInsertionOrderIndependent(HashSet set) } [Property] - public Property EqualSetsHaveSameHashCode(HashSet set) + public Property HashCode_EqualSets_HaveSameHash(HashSet set) { var copy = new HashSet(set); return Prop.ToProperty(Comparer.Equals(set, copy) && Comparer.GetHashCode(set) == Comparer.GetHashCode(copy)); } [Property] - public Property ExtraElementMakesNotEqual(HashSet set, string extra) + public Property Equals_SupersetOfElements_ReturnsFalse(HashSet set, string extra) { if (set.Contains(extra)) return Prop.When(true, true); @@ -46,7 +46,7 @@ public Property ExtraElementMakesNotEqual(HashSet set, string extra) } [Property] - public Property NullEqualsNull() + public Property Equals_BothNull_ReturnsTrue() { return Prop.ToProperty(Comparer.Equals(null, null)); } diff --git a/test/Equatable.Generator.Tests/Properties/OrderedDictionaryComparerProperties.cs b/test/Equatable.Generator.Tests/Properties/OrderedDictionaryComparerProperties.cs index 271cb49..6727adf 100644 --- a/test/Equatable.Generator.Tests/Properties/OrderedDictionaryComparerProperties.cs +++ b/test/Equatable.Generator.Tests/Properties/OrderedDictionaryComparerProperties.cs @@ -10,33 +10,34 @@ private static readonly OrderedDictionaryEqualityComparer Comparer = OrderedDictionaryEqualityComparer.Default; [Property] - public Property Reflexivity(Dictionary dict) + public Property Equals_Reflexivity_SameInstance_ReturnsTrue(Dictionary dict) { return Prop.ToProperty(Comparer.Equals(dict, dict)); } [Property] - public Property Symmetry(Dictionary x, Dictionary y) + public Property Equals_Symmetry_AEqualsB_ImpliesBEqualsA(Dictionary x, Dictionary y) { return Prop.ToProperty(Comparer.Equals(x, y) == Comparer.Equals(y, x)); } [Property] - public Property HashIsInsertionOrderIndependent(Dictionary dict) + public Property HashCode_InsertionOrderIndependent(Dictionary dict) { var reversed = new Dictionary(dict.Reverse()); return Prop.ToProperty(Comparer.GetHashCode(dict) == Comparer.GetHashCode(reversed)); } [Property] - public Property EqualImpliesSameHashCode(Dictionary dict) + public Property HashCode_EqualDictionaries_HaveSameHash(Dictionary dict) { + // equal → same hash (one direction only — hash collisions can make unequal produce same hash) var reversed = new Dictionary(dict.Reverse()); - return Prop.ToProperty(Comparer.Equals(dict, reversed) && Comparer.GetHashCode(dict) == Comparer.GetHashCode(reversed)); + return Prop.When(Comparer.Equals(dict, reversed), Comparer.GetHashCode(dict) == Comparer.GetHashCode(reversed)); } [Property] - public Property DifferentValueMakesNotEqual(Dictionary dict, string key, int v1, int v2) + public Property Equals_DifferentValue_ReturnsFalse(Dictionary dict, string key, int v1, int v2) { if (v1 == v2) return Prop.When(true, true); diff --git a/test/Equatable.Generator.Tests/Properties/ReadOnlyDictionaryComparerProperties.cs b/test/Equatable.Generator.Tests/Properties/ReadOnlyDictionaryComparerProperties.cs index 20d1cf9..b72a429 100644 --- a/test/Equatable.Generator.Tests/Properties/ReadOnlyDictionaryComparerProperties.cs +++ b/test/Equatable.Generator.Tests/Properties/ReadOnlyDictionaryComparerProperties.cs @@ -7,14 +7,14 @@ public class ReadOnlyDictionaryComparerProperties private static readonly ReadOnlyDictionaryEqualityComparer Comparer = ReadOnlyDictionaryEqualityComparer.Default; [Property] - public Property Reflexivity(Dictionary dict) + public Property Equals_Reflexivity_SameInstance_ReturnsTrue(Dictionary dict) { IReadOnlyDictionary d = dict; return Prop.ToProperty(Comparer.Equals(d, d)); } [Property] - public Property Symmetry(Dictionary x, Dictionary y) + public Property Equals_Symmetry_AEqualsB_ImpliesBEqualsA(Dictionary x, Dictionary y) { IReadOnlyDictionary a = x; IReadOnlyDictionary b = y; @@ -22,16 +22,16 @@ public Property Symmetry(Dictionary x, Dictionary y) } [Property] - public Property EqualImpliesSameHashCode(Dictionary dict) + public Property HashCode_EqualDictionaries_HaveSameHash(Dictionary dict) { - // two dictionaries with same entries in different insertion order must have equal hash + // equal → same hash (one direction only — hash collisions can make unequal produce same hash) IReadOnlyDictionary a = dict; IReadOnlyDictionary b = new Dictionary(dict.Reverse()); - return Prop.ToProperty(Comparer.Equals(a, b) == (Comparer.GetHashCode(a) == Comparer.GetHashCode(b))); + return Prop.When(Comparer.Equals(a, b), Comparer.GetHashCode(a) == Comparer.GetHashCode(b)); } [Property] - public Property HashIsInsertionOrderIndependent(Dictionary dict) + public Property HashCode_InsertionOrderIndependent(Dictionary dict) { IReadOnlyDictionary a = dict; IReadOnlyDictionary b = new Dictionary(dict.Reverse()); @@ -39,20 +39,20 @@ public Property HashIsInsertionOrderIndependent(Dictionary dict) } [Property] - public Property NullEqualsNull() + public Property Equals_BothNull_ReturnsTrue() { return Prop.ToProperty(Comparer.Equals(null, null)); } [Property] - public Property NullNotEqualsNonNull(Dictionary dict) + public Property Equals_OneNull_ReturnsFalse(Dictionary dict) { IReadOnlyDictionary d = dict; return Prop.ToProperty(!Comparer.Equals(null, d) && !Comparer.Equals(d, null)); } [Property] - public Property ExtraKeyMakesNotEqual(Dictionary dict, string key, int value) + public Property Equals_DictionaryWithExtraKey_ReturnsFalse(Dictionary dict, string key, int value) { // guard: key must not already be in dict if (dict.ContainsKey(key)) From 1bf9f1ca47f16fbf5e8992a74733836ab2fbc21f Mon Sep 17 00:00:00 2001 From: "a.kasyanov" Date: Wed, 13 May 2026 23:00:01 +0300 Subject: [PATCH 13/71] feat: add InferCollectionComparer and IsEquatableGeneratorAttribute to EquatableGenerator - Add InferCollectionComparer public hook: infers structural collection comparers (DictionaryEqualityComparer, SequenceEqualityComparer, ReadOnlyDictionaryEqualityComparer) for adapter generators so [Key] / [DataMember] properties don't require explicit equality attributes. Recurses into nested types via BuildElementComparerExpression. - Add IsEquatableGeneratorAttribute: recognises *EquatableAttribute across all three adapter namespaces (Equatable.Attributes, .DataContract, .MessagePack) so derived classes correctly delegate to base.Equals() regardless of which adapter annotates the base. - Wire InferCollectionComparer into DataContractEquatableGenerator and MessagePackEquatableGenerator as postProcessProperty delegate. - Update DataContractEquatableAnalyzer and MessagePackEquatableAnalyzer to use IsEquatableGeneratorAttribute for base-class detection. Co-Authored-By: Claude Sonnet 4.5 --- .../DataContractEquatableAttribute.cs | 2 +- .../MessagePackEquatableAttribute.cs | 2 +- .../DataContractEquatableAnalyzer.cs | 2 +- .../DataContractEquatableGenerator.cs | 5 +- .../MessagePackEquatableAnalyzer.cs | 2 +- .../MessagePackEquatableGenerator.cs | 5 +- .../EquatableGenerator.cs | 110 +++++++++++++++++- 7 files changed, 114 insertions(+), 14 deletions(-) diff --git a/src/Equatable.Generator.DataContract/Attributes/DataContractEquatableAttribute.cs b/src/Equatable.Generator.DataContract/Attributes/DataContractEquatableAttribute.cs index b6251fa..6c949d8 100644 --- a/src/Equatable.Generator.DataContract/Attributes/DataContractEquatableAttribute.cs +++ b/src/Equatable.Generator.DataContract/Attributes/DataContractEquatableAttribute.cs @@ -1,6 +1,6 @@ using System.Diagnostics; -namespace Equatable.Attributes; +namespace Equatable.Attributes.DataContract; /// /// Marks the class to source generate overrides for Equals and GetHashCode, diff --git a/src/Equatable.Generator.MessagePack/Attributes/MessagePackEquatableAttribute.cs b/src/Equatable.Generator.MessagePack/Attributes/MessagePackEquatableAttribute.cs index d874b21..5c67097 100644 --- a/src/Equatable.Generator.MessagePack/Attributes/MessagePackEquatableAttribute.cs +++ b/src/Equatable.Generator.MessagePack/Attributes/MessagePackEquatableAttribute.cs @@ -1,6 +1,6 @@ using System.Diagnostics; -namespace Equatable.Attributes; +namespace Equatable.Attributes.MessagePack; /// /// Marks the class to source generate overrides for Equals and GetHashCode, diff --git a/src/Equatable.SourceGenerator.DataContract/DataContractEquatableAnalyzer.cs b/src/Equatable.SourceGenerator.DataContract/DataContractEquatableAnalyzer.cs index a473e3e..25d81ac 100644 --- a/src/Equatable.SourceGenerator.DataContract/DataContractEquatableAnalyzer.cs +++ b/src/Equatable.SourceGenerator.DataContract/DataContractEquatableAnalyzer.cs @@ -45,7 +45,7 @@ private static bool HasDataContractEquatableAttribute(INamedTypeSymbol typeSymbo typeSymbol.GetAttributes().Any(a => a.AttributeClass is { Name: "DataContractEquatableAttribute", - ContainingNamespace: { Name: "Attributes", ContainingNamespace.Name: "Equatable" } + ContainingNamespace: { Name: "DataContract", ContainingNamespace: { Name: "Attributes", ContainingNamespace.Name: "Equatable" } } }); private static bool HasDataContractAttribute(INamedTypeSymbol typeSymbol) => diff --git a/src/Equatable.SourceGenerator.DataContract/DataContractEquatableGenerator.cs b/src/Equatable.SourceGenerator.DataContract/DataContractEquatableGenerator.cs index 3d28d20..2b67afa 100644 --- a/src/Equatable.SourceGenerator.DataContract/DataContractEquatableGenerator.cs +++ b/src/Equatable.SourceGenerator.DataContract/DataContractEquatableGenerator.cs @@ -8,9 +8,10 @@ public class DataContractEquatableGenerator : IIncrementalGenerator public void Initialize(IncrementalGeneratorInitializationContext context) { EquatableGenerator.RegisterProvider(context, - fullyQualifiedMetadataName: "Equatable.Attributes.DataContractEquatableAttribute", + fullyQualifiedMetadataName: "Equatable.Attributes.DataContract.DataContractEquatableAttribute", trackingName: "DataContractEquatableAttribute", - propertyFilter: IsIncludedDataContract); + propertyFilter: IsIncludedDataContract, + postProcessProperty: EquatableGenerator.InferCollectionComparer); } private static bool IsIncludedDataContract(IPropertySymbol propertySymbol) diff --git a/src/Equatable.SourceGenerator.MessagePack/MessagePackEquatableAnalyzer.cs b/src/Equatable.SourceGenerator.MessagePack/MessagePackEquatableAnalyzer.cs index 23bd951..f430cd5 100644 --- a/src/Equatable.SourceGenerator.MessagePack/MessagePackEquatableAnalyzer.cs +++ b/src/Equatable.SourceGenerator.MessagePack/MessagePackEquatableAnalyzer.cs @@ -45,7 +45,7 @@ private static bool HasMessagePackEquatableAttribute(INamedTypeSymbol typeSymbol typeSymbol.GetAttributes().Any(a => a.AttributeClass is { Name: "MessagePackEquatableAttribute", - ContainingNamespace: { Name: "Attributes", ContainingNamespace.Name: "Equatable" } + ContainingNamespace: { Name: "MessagePack", ContainingNamespace: { Name: "Attributes", ContainingNamespace.Name: "Equatable" } } }); private static bool HasMessagePackObjectAttribute(INamedTypeSymbol typeSymbol) => diff --git a/src/Equatable.SourceGenerator.MessagePack/MessagePackEquatableGenerator.cs b/src/Equatable.SourceGenerator.MessagePack/MessagePackEquatableGenerator.cs index dfe1368..3b685c1 100644 --- a/src/Equatable.SourceGenerator.MessagePack/MessagePackEquatableGenerator.cs +++ b/src/Equatable.SourceGenerator.MessagePack/MessagePackEquatableGenerator.cs @@ -8,9 +8,10 @@ public class MessagePackEquatableGenerator : IIncrementalGenerator public void Initialize(IncrementalGeneratorInitializationContext context) { EquatableGenerator.RegisterProvider(context, - fullyQualifiedMetadataName: "Equatable.Attributes.MessagePackEquatableAttribute", + fullyQualifiedMetadataName: "Equatable.Attributes.MessagePack.MessagePackEquatableAttribute", trackingName: "MessagePackEquatableAttribute", - propertyFilter: IsIncludedMessagePack); + propertyFilter: IsIncludedMessagePack, + postProcessProperty: EquatableGenerator.InferCollectionComparer); } private static bool IsIncludedMessagePack(IPropertySymbol propertySymbol) diff --git a/src/Equatable.SourceGenerator/EquatableGenerator.cs b/src/Equatable.SourceGenerator/EquatableGenerator.cs index 16c63d9..eb4bcb4 100644 --- a/src/Equatable.SourceGenerator/EquatableGenerator.cs +++ b/src/Equatable.SourceGenerator/EquatableGenerator.cs @@ -24,13 +24,14 @@ public static void RegisterProvider( IncrementalGeneratorInitializationContext context, string fullyQualifiedMetadataName, string trackingName, - Func propertyFilter) + Func propertyFilter, + Func? postProcessProperty = null) { var provider = context.SyntaxProvider .ForAttributeWithMetadataName( fullyQualifiedMetadataName: fullyQualifiedMetadataName, predicate: SyntacticPredicate, - transform: (ctx, ct) => SemanticTransform(ctx, ct, propertyFilter) + transform: (ctx, ct) => SemanticTransform(ctx, ct, propertyFilter, postProcessProperty) ) .Where(static item => item is not null) .WithTrackingName(trackingName); @@ -57,7 +58,7 @@ private static bool SyntacticPredicate(SyntaxNode syntaxNode, CancellationToken || (syntaxNode is StructDeclarationSyntax structDeclaration && !structDeclaration.Modifiers.Any(SyntaxKind.StaticKeyword)); } - private static EquatableClass? SemanticTransform(GeneratorAttributeSyntaxContext context, CancellationToken cancellationToken, Func propertyFilter) + private static EquatableClass? SemanticTransform(GeneratorAttributeSyntaxContext context, CancellationToken cancellationToken, Func propertyFilter, Func? postProcessProperty = null) { if (context.TargetSymbol is not INamedTypeSymbol targetSymbol) return null; @@ -78,7 +79,7 @@ private static bool SyntacticPredicate(SyntaxNode syntaxNode, CancellationToken var propertySymbols = GetProperties(targetSymbol, baseHashCode == null && baseEquatable == null, propertyFilter); var propertyArray = propertySymbols - .Select(CreateProperty) + .Select(p => postProcessProperty != null ? postProcessProperty(p, CreateProperty(p)) : CreateProperty(p)) .ToArray() ?? []; // the seed value of the hash code method @@ -181,6 +182,89 @@ private static EquatableProperty CreateProperty(IPropertySymbol propertySymbol) return new EquatableProperty(propertyName, propertyType, defaultComparer); } + // Infers the appropriate equality comparer for a property based on its type shape. + // Called by adapter generators (DataContractEquatable, MessagePackEquatable) as a + // post-processing step so that collection/dictionary properties don't require explicit + // [DictionaryEquality] / [SequenceEquality] annotations. + // The base [Equatable] generator does NOT call this — developers must be explicit there. + public static EquatableProperty InferCollectionComparer(IPropertySymbol propertySymbol, EquatableProperty property) + { + // Only apply inference when no explicit equality attribute was already resolved + if (property.ComparerType != ComparerTypes.Default && property.ComparerType != ComparerTypes.ValueType) + return property; + + var inferredExpression = propertySymbol.Type switch + { + IArrayTypeSymbol arrayType => BuildArrayComparerExpression(arrayType), + INamedTypeSymbol namedType when IsDictionary(namedType) || namedType.AllInterfaces.Any(IsDictionary) + => BuildInferredCollectionExpression(namedType, ComparerTypes.Dictionary), + INamedTypeSymbol namedType when !IsString(namedType) + && (IsEnumerable(namedType) || namedType.AllInterfaces.Any(IsEnumerable)) + => BuildInferredCollectionExpression(namedType, ComparerTypes.Sequence), + _ => null + }; + + if (inferredExpression != null) + return property with { ComparerType = ComparerTypes.Expression, ComparerExpression = inferredExpression }; + + return property; + } + + // Like BuildCollectionComparerExpression but always emits a comparer even when element types are + // simple. Used by InferCollectionComparer so that adapter generators always produce structural + // equality for collection properties, not EqualityComparer.Default (reference equality). + private static string? BuildInferredCollectionExpression(INamedTypeSymbol collectionType, ComparerTypes kind) + { + var unwrapped = collectionType.IsGenericType + && collectionType.OriginalDefinition.SpecialType == SpecialType.None + && collectionType.Name == "Nullable" + ? collectionType.TypeArguments[0] as INamedTypeSymbol ?? collectionType + : collectionType; + + INamedTypeSymbol? dictInterface = IsDictionary(unwrapped) ? unwrapped + : unwrapped.AllInterfaces.FirstOrDefault(IsDictionary); + + INamedTypeSymbol? enumInterface = IsEnumerable(unwrapped) ? unwrapped + : unwrapped.AllInterfaces.FirstOrDefault(IsEnumerable); + + if (kind == ComparerTypes.Dictionary && dictInterface != null) + { + var keyType = dictInterface.TypeArguments[0]; + var valueType = dictInterface.TypeArguments[1]; + var keyTypeFq = keyType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + var valueTypeFq = valueType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + + var keyExpr = BuildElementComparerExpression(keyType) + ?? $"global::System.Collections.Generic.EqualityComparer<{keyTypeFq}>.Default"; + var valueExpr = BuildElementComparerExpression(valueType) + ?? $"global::System.Collections.Generic.EqualityComparer<{valueTypeFq}>.Default"; + + var isReadOnly = IsReadOnlyDictionary(unwrapped) + || (IsDictionary(unwrapped) is false && unwrapped.AllInterfaces.Any(IsReadOnlyDictionary)); + + var comparerClass = isReadOnly + ? "global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer" + : "global::Equatable.Comparers.DictionaryEqualityComparer"; + + return $"new {comparerClass}<{keyTypeFq}, {valueTypeFq}>({keyExpr}, {valueExpr})"; + } + + if (kind == ComparerTypes.Sequence && enumInterface != null) + { + var elementType = enumInterface.TypeArguments[0]; + var elementTypeFq = elementType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + + var innerExpr = BuildElementComparerExpression(elementType); + + if (innerExpr != null) + return $"new global::Equatable.Comparers.SequenceEqualityComparer<{elementTypeFq}>({innerExpr})"; + + return $"global::Equatable.Comparers.SequenceEqualityComparer<{elementTypeFq}>.Default"; + } + + return null; + } + // Returns a fully-qualified IEqualityComparer instance expression for a collection type // when its element type is itself a collection (requires composition). // Returns null when EqualityComparer.Default is sufficient (element is a plain type). @@ -517,7 +601,22 @@ private static bool IsKnownAttribute(AttributeData? attribute) ContainingNamespace.Name: "Equatable" } }; + } + + // Recognises any *EquatableAttribute from any Equatable adapter namespace: + // - Equatable.Attributes (base [Equatable]) + // - Equatable.Attributes.DataContract ([DataContractEquatable]) + // - Equatable.Attributes.MessagePack ([MessagePackEquatable]) + private static bool IsEquatableGeneratorAttribute(AttributeData? a) + { + if (a?.AttributeClass?.Name.EndsWith("EquatableAttribute") != true) + return false; + var ns = a.AttributeClass.ContainingNamespace; + // Equatable.Attributes.* + return ns?.ContainingNamespace?.Name == "Equatable" && ns.Name == "Attributes" + || ns?.ContainingNamespace?.ContainingNamespace?.Name == "Equatable" + && ns.ContainingNamespace?.Name == "Attributes"; } private static bool HasEqualityOperator(ITypeSymbol typeSymbol) @@ -706,8 +805,7 @@ private static EquatableArray GetContainingTypes(INamedTypeSymb return null; var attributes = currentSymbol.GetAttributes(); - if (attributes.Length > 0 && attributes.Any(a => IsKnownAttribute(a) - && a.AttributeClass?.Name.EndsWith("EquatableAttribute") == true)) + if (attributes.Length > 0 && attributes.Any(IsEquatableGeneratorAttribute)) { return currentSymbol; } From cf696eb180ac3a64d68f4c923dd54157cc3d5d2b Mon Sep 17 00:00:00 2001 From: "a.kasyanov" Date: Wed, 13 May 2026 23:00:11 +0300 Subject: [PATCH 14/71] test: add nested collection entity fixtures for DataContract and MessagePack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add OrderDataContractNested and SerializedRecordNested — partial classes annotated with [DataContractEquatable] / [MessagePackEquatable] carrying nested collection properties (Dict>, Dict>, List>, IReadOnlyDictionary>) used by entity-level integration tests. Update existing OrderDataContract and SerializedRecord entities as needed. Co-Authored-By: Claude Sonnet 4.5 --- test/Equatable.Entities/OrderDataContract.cs | 2 +- .../OrderDataContractNested.cs | 29 +++++++++++++++++++ test/Equatable.Entities/SerializedRecord.cs | 2 +- .../SerializedRecordNested.cs | 29 +++++++++++++++++++ 4 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 test/Equatable.Entities/OrderDataContractNested.cs create mode 100644 test/Equatable.Entities/SerializedRecordNested.cs diff --git a/test/Equatable.Entities/OrderDataContract.cs b/test/Equatable.Entities/OrderDataContract.cs index f3ffcd4..5cfdc87 100644 --- a/test/Equatable.Entities/OrderDataContract.cs +++ b/test/Equatable.Entities/OrderDataContract.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; using System.Runtime.Serialization; -using Equatable.Attributes; +using Equatable.Attributes.DataContract; namespace Equatable.Entities; diff --git a/test/Equatable.Entities/OrderDataContractNested.cs b/test/Equatable.Entities/OrderDataContractNested.cs new file mode 100644 index 0000000..4c4e41d --- /dev/null +++ b/test/Equatable.Entities/OrderDataContractNested.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; +using System.Runtime.Serialization; +using Equatable.Attributes.DataContract; + +namespace Equatable.Entities; + +[DataContract] +[DataContractEquatable] +public partial class OrderDataContractNested +{ + [DataMember(Order = 0)] + public int Id { get; set; } + + // Dict> — inferred: DictionaryEqualityComparer(…, SequenceEqualityComparer.Default) + [DataMember(Order = 1)] + public Dictionary>? TagGroups { get; set; } + + // Dict> — inferred: DictionaryEqualityComparer(…, DictionaryEqualityComparer(…)) + [DataMember(Order = 2)] + public Dictionary>? NestedMap { get; set; } + + // List> — inferred: SequenceEqualityComparer(DictionaryEqualityComparer(…)) + [DataMember(Order = 3)] + public List>? Records { get; set; } + + // IReadOnlyDictionary> — inferred: ReadOnlyDictionaryEqualityComparer(…, SequenceEqualityComparer.Default) + [DataMember(Order = 4)] + public IReadOnlyDictionary>? ReadOnlyTagGroups { get; set; } +} diff --git a/test/Equatable.Entities/SerializedRecord.cs b/test/Equatable.Entities/SerializedRecord.cs index dd16d1e..9ed7503 100644 --- a/test/Equatable.Entities/SerializedRecord.cs +++ b/test/Equatable.Entities/SerializedRecord.cs @@ -1,4 +1,4 @@ -using Equatable.Attributes; +using Equatable.Attributes.MessagePack; using MessagePack; namespace Equatable.Entities; diff --git a/test/Equatable.Entities/SerializedRecordNested.cs b/test/Equatable.Entities/SerializedRecordNested.cs new file mode 100644 index 0000000..778df74 --- /dev/null +++ b/test/Equatable.Entities/SerializedRecordNested.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; +using Equatable.Attributes.MessagePack; +using MessagePack; + +namespace Equatable.Entities; + +[MessagePackObject] +[MessagePackEquatable] +public partial class SerializedRecordNested +{ + [Key(0)] + public int Id { get; set; } + + // Dict> — inferred: DictionaryEqualityComparer(…, SequenceEqualityComparer.Default) + [Key(1)] + public Dictionary>? TagGroups { get; set; } + + // Dict> — inferred: DictionaryEqualityComparer(…, DictionaryEqualityComparer(…)) + [Key(2)] + public Dictionary>? NestedMap { get; set; } + + // List> — inferred: SequenceEqualityComparer(DictionaryEqualityComparer(…)) + [Key(3)] + public List>? Records { get; set; } + + // IReadOnlyDictionary> — inferred: ReadOnlyDictionaryEqualityComparer(…, SequenceEqualityComparer.Default) + [Key(4)] + public IReadOnlyDictionary>? ReadOnlyTagGroups { get; set; } +} From 190aaa2ee40abe44189a97402d9af256f8827df9 Mon Sep 17 00:00:00 2001 From: "a.kasyanov" Date: Wed, 13 May 2026 23:00:26 +0300 Subject: [PATCH 15/71] =?UTF-8?q?test:=20expand=20comparer=20unit=20tests?= =?UTF-8?q?=20=E2=80=94=20GetHashCode=20contract,=20nested=20collections,?= =?UTF-8?q?=20multi-dim=20arrays?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ComparerGetHashCodeTest: covers null→0, empty→non-zero, empty≠null, and equal-implies-same-hash for all five comparers (Dictionary, ReadOnlyDictionary, HashSet, Sequence, OrderedDictionary). - MultiDimensionalArrayEqualityComparerTest: full coverage of MultiDimensionalArrayEqualityComparer — rank checks, dimension checks, 2D/3D content equality, custom comparer, GetHashCode row-major order sensitivity. - NestedDictionaryEqualityComparerTest: explicitly-composed nested comparers (Dict, Dict, Dict, 3-level, ReadOnlyDict). - Add custom-comparer constructor path tests (Path C) to HashSetEqualityComparerTest and SequenceEqualityComparerTest. Co-Authored-By: Claude Sonnet 4.5 --- .../Comparers/ComparerGetHashCodeTest.cs | 244 +++++++++++++++ .../Comparers/HashSetEqualityComparerTest.cs | 38 +++ ...ltiDimensionalArrayEqualityComparerTest.cs | 146 +++++++++ .../NestedDictionaryEqualityComparerTest.cs | 279 ++++++++++++++++++ .../Comparers/SequenceEqualityComparerTest.cs | 26 ++ 5 files changed, 733 insertions(+) create mode 100644 test/Equatable.Generator.Tests/Comparers/ComparerGetHashCodeTest.cs create mode 100644 test/Equatable.Generator.Tests/Comparers/MultiDimensionalArrayEqualityComparerTest.cs create mode 100644 test/Equatable.Generator.Tests/Comparers/NestedDictionaryEqualityComparerTest.cs diff --git a/test/Equatable.Generator.Tests/Comparers/ComparerGetHashCodeTest.cs b/test/Equatable.Generator.Tests/Comparers/ComparerGetHashCodeTest.cs new file mode 100644 index 0000000..4cedbe1 --- /dev/null +++ b/test/Equatable.Generator.Tests/Comparers/ComparerGetHashCodeTest.cs @@ -0,0 +1,244 @@ +using Equatable.Comparers; + +namespace Equatable.Generator.Tests.Comparers; + +/// +/// Verifies the GetHashCode contract for all collection comparers: +/// 1. null → 0 +/// 2. empty → non-zero (must differ from null) +/// 3. equal collections → same hash code +/// 4. unequal collections → different hash code (best-effort; not a contract but expected for these cases) +/// 5. hash code is consistent with Equals (if Equals returns true, GetHashCode must match) +/// +public class ComparerGetHashCodeTest +{ + // ── DictionaryEqualityComparer ─────────────────────────────────────────────────────────────── + + private static readonly DictionaryEqualityComparer DictComparer + = DictionaryEqualityComparer.Default; + + [Fact] + public void Dictionary_Null_HashIsZero() + { + Assert.Equal(0, DictComparer.GetHashCode(null!)); + } + + [Fact] + public void Dictionary_Empty_HashIsNotZero() + { + Assert.NotEqual(0, DictComparer.GetHashCode(new Dictionary())); + } + + [Fact] + public void Dictionary_EmptyAndNull_HashDiffers() + { + Assert.NotEqual( + DictComparer.GetHashCode(null!), + DictComparer.GetHashCode(new Dictionary())); + } + + [Fact] + public void Dictionary_EqualCollections_SameHash() + { + var a = new Dictionary { ["a"] = 1, ["b"] = 2 }; + var b = new Dictionary { ["b"] = 2, ["a"] = 1 }; + + Assert.True(DictComparer.Equals(a, b)); + Assert.Equal(DictComparer.GetHashCode(a), DictComparer.GetHashCode(b)); + } + + [Fact] + public void Dictionary_DifferentValues_DifferentHash() + { + var a = new Dictionary { ["a"] = 1 }; + var b = new Dictionary { ["a"] = 2 }; + + Assert.NotEqual(DictComparer.GetHashCode(a), DictComparer.GetHashCode(b)); + } + + // ── ReadOnlyDictionaryEqualityComparer ─────────────────────────────────────────────────────── + + private static readonly ReadOnlyDictionaryEqualityComparer ReadOnlyDictComparer + = ReadOnlyDictionaryEqualityComparer.Default; + + [Fact] + public void ReadOnlyDictionary_Null_HashIsZero() + { + Assert.Equal(0, ReadOnlyDictComparer.GetHashCode(null!)); + } + + [Fact] + public void ReadOnlyDictionary_Empty_HashIsNotZero() + { + Assert.NotEqual(0, ReadOnlyDictComparer.GetHashCode(new Dictionary())); + } + + [Fact] + public void ReadOnlyDictionary_EmptyAndNull_HashDiffers() + { + Assert.NotEqual( + ReadOnlyDictComparer.GetHashCode(null!), + ReadOnlyDictComparer.GetHashCode(new Dictionary())); + } + + [Fact] + public void ReadOnlyDictionary_EqualCollections_SameHash() + { + IReadOnlyDictionary a = new Dictionary { ["x"] = 10, ["y"] = 20 }; + IReadOnlyDictionary b = new Dictionary { ["y"] = 20, ["x"] = 10 }; + + Assert.True(ReadOnlyDictComparer.Equals(a, b)); + Assert.Equal(ReadOnlyDictComparer.GetHashCode(a), ReadOnlyDictComparer.GetHashCode(b)); + } + + // ── HashSetEqualityComparer ────────────────────────────────────────────────────────────────── + + private static readonly HashSetEqualityComparer SetComparer + = HashSetEqualityComparer.Default; + + [Fact] + public void HashSet_Null_HashIsZero() + { + Assert.Equal(0, SetComparer.GetHashCode(null!)); + } + + [Fact] + public void HashSet_Empty_HashIsNotZero() + { + Assert.NotEqual(0, SetComparer.GetHashCode(new HashSet())); + } + + [Fact] + public void HashSet_EmptyAndNull_HashDiffers() + { + Assert.NotEqual( + SetComparer.GetHashCode(null!), + SetComparer.GetHashCode(new HashSet())); + } + + [Fact] + public void HashSet_EqualCollections_SameHash() + { + var a = new HashSet { 1, 2, 3 }; + var b = new HashSet { 3, 1, 2 }; + + Assert.True(SetComparer.Equals(a, b)); + Assert.Equal(SetComparer.GetHashCode(a), SetComparer.GetHashCode(b)); + } + + [Fact] + public void HashSet_DifferentElements_DifferentHash() + { + var a = new HashSet { 1, 2 }; + var b = new HashSet { 1, 3 }; + + Assert.NotEqual(SetComparer.GetHashCode(a), SetComparer.GetHashCode(b)); + } + + [Fact] + public void HashSet_SameElementsDifferentCount_DifferentHash() + { + // {1, 2} vs {1} — different sets, should produce different hashes + var a = new HashSet { 1, 2 }; + var b = new HashSet { 1 }; + + Assert.NotEqual(SetComparer.GetHashCode(a), SetComparer.GetHashCode(b)); + } + + // ── SequenceEqualityComparer ───────────────────────────────────────────────────────────────── + + private static readonly SequenceEqualityComparer SeqComparer + = SequenceEqualityComparer.Default; + + [Fact] + public void Sequence_Null_HashIsZero() + { + Assert.Equal(0, SeqComparer.GetHashCode(null!)); + } + + [Fact] + public void Sequence_Empty_HashDiffersFromNull() + { + Assert.NotEqual(SeqComparer.GetHashCode(null!), SeqComparer.GetHashCode(new List())); + } + + [Fact] + public void Sequence_EqualCollections_SameHash() + { + var a = new List { 1, 2, 3 }; + var b = new List { 1, 2, 3 }; + + Assert.True(SeqComparer.Equals(a, b)); + Assert.Equal(SeqComparer.GetHashCode(a), SeqComparer.GetHashCode(b)); + } + + [Fact] + public void Sequence_DifferentOrder_DifferentHash() + { + // SequenceEqualityComparer is order-sensitive — reversed order must produce a different hash + var a = new List { 1, 2 }; + var b = new List { 2, 1 }; + + Assert.False(SeqComparer.Equals(a, b)); + Assert.NotEqual(SeqComparer.GetHashCode(a), SeqComparer.GetHashCode(b)); + } + + // ── OrderedDictionaryEqualityComparer ──────────────────────────────────────────────────────── + + private static readonly OrderedDictionaryEqualityComparer OrderedDictComparer + = OrderedDictionaryEqualityComparer.Default; + + [Fact] + public void OrderedDictionary_Null_HashIsZero() + { + Assert.Equal(0, OrderedDictComparer.GetHashCode(null!)); + } + + [Fact] + public void OrderedDictionary_Empty_HashDiffersFromNull() + { + Assert.NotEqual( + OrderedDictComparer.GetHashCode(null!), + OrderedDictComparer.GetHashCode(new Dictionary())); + } + + [Fact] + public void OrderedDictionary_EqualCollections_SameHash() + { + // Ordered comparer is key-sorted — same content, different insertion order → equal + var a = new Dictionary { ["a"] = 1, ["b"] = 2, ["c"] = 3 }; + var b = new Dictionary { ["c"] = 3, ["a"] = 1, ["b"] = 2 }; + + Assert.True(OrderedDictComparer.Equals(a, b)); + Assert.Equal(OrderedDictComparer.GetHashCode(a), OrderedDictComparer.GetHashCode(b)); + } + + [Fact] + public void OrderedDictionary_DifferentValues_DifferentHash() + { + var a = new Dictionary { ["a"] = 1 }; + var b = new Dictionary { ["a"] = 2 }; + + Assert.NotEqual(OrderedDictComparer.GetHashCode(a), OrderedDictComparer.GetHashCode(b)); + } + + // ── Cross-comparer: empty vs single-element ────────────────────────────────────────────────── + + [Fact] + public void Dictionary_SingleEntry_DiffersFromEmpty() + { + var single = new Dictionary { ["a"] = 1 }; + var empty = new Dictionary(); + + Assert.NotEqual(DictComparer.GetHashCode(single), DictComparer.GetHashCode(empty)); + } + + [Fact] + public void HashSet_SingleEntry_DiffersFromEmpty() + { + var single = new HashSet { 42 }; + var empty = new HashSet(); + + Assert.NotEqual(SetComparer.GetHashCode(single), SetComparer.GetHashCode(empty)); + } +} diff --git a/test/Equatable.Generator.Tests/Comparers/HashSetEqualityComparerTest.cs b/test/Equatable.Generator.Tests/Comparers/HashSetEqualityComparerTest.cs index 5ce9cb2..e83f0cc 100644 --- a/test/Equatable.Generator.Tests/Comparers/HashSetEqualityComparerTest.cs +++ b/test/Equatable.Generator.Tests/Comparers/HashSetEqualityComparerTest.cs @@ -93,4 +93,42 @@ public void GetHashCodeSame() Assert.Equal(bHash, aHash); } + + // ── custom comparer (Path C: plain IEnumerable inputs, neither is ISet) ───────────────────── + // When both inputs are plain IEnumerable (not ISet), the code builds + // new HashSet(x, Comparer) and calls SetEquals(y), using this.Comparer. + + [Fact] + public void CustomComparer_PlainEnumerable_EqualByCustomRule() + { + // OrdinalIgnoreCase: "A" and "a" are the same element + var comparer = new HashSetEqualityComparer(StringComparer.OrdinalIgnoreCase); + IEnumerable a = new List { "A", "B" }; + IEnumerable b = new List { "b", "a" }; + + Assert.True(comparer.Equals(a, b)); + } + + [Fact] + public void CustomComparer_PlainEnumerable_DefaultComparerWouldNotMatch() + { + // Ordinal default: "A" != "a" + var comparer = HashSetEqualityComparer.Default; + IEnumerable a = new List { "A" }; + IEnumerable b = new List { "a" }; + + Assert.False(comparer.Equals(a, b)); + } + + [Fact] + public void CustomComparer_GetHashCode_UsesComparer() + { + // With OrdinalIgnoreCase, "A" and "a" must produce the same hash + var comparer = new HashSetEqualityComparer(StringComparer.OrdinalIgnoreCase); + IEnumerable a = new List { "A" }; + IEnumerable b = new List { "a" }; + + Assert.True(comparer.Equals(a, b)); + Assert.Equal(comparer.GetHashCode(a), comparer.GetHashCode(b)); + } } diff --git a/test/Equatable.Generator.Tests/Comparers/MultiDimensionalArrayEqualityComparerTest.cs b/test/Equatable.Generator.Tests/Comparers/MultiDimensionalArrayEqualityComparerTest.cs new file mode 100644 index 0000000..58144c7 --- /dev/null +++ b/test/Equatable.Generator.Tests/Comparers/MultiDimensionalArrayEqualityComparerTest.cs @@ -0,0 +1,146 @@ +using Equatable.Comparers; + +namespace Equatable.Generator.Tests.Comparers; + +public class MultiDimensionalArrayEqualityComparerTest +{ + private static readonly MultiDimensionalArrayEqualityComparer Comparer + = MultiDimensionalArrayEqualityComparer.Default; + + // ── Equals ─────────────────────────────────────────────────────────────────────────────────── + + [Fact] + public void Equals_SameReference_ReturnsTrue() + { + int[,] a = { { 1, 2 }, { 3, 4 } }; + Assert.True(Comparer.Equals(a, a)); + } + + [Fact] + public void Equals_BothNull_ReturnsTrue() + { + Assert.True(Comparer.Equals(null, null)); + } + + [Fact] + public void Equals_OneNull_ReturnsFalse() + { + int[,] a = { { 1 } }; + Assert.False(Comparer.Equals(a, null)); + Assert.False(Comparer.Equals(null, a)); + } + + [Fact] + public void Equals_DifferentRank_ReturnsFalse() + { + // int[,] (rank 2) vs int[,,] (rank 3) + Array rank2 = new int[2, 2]; + Array rank3 = new int[2, 2, 2]; + var comparerObj = new MultiDimensionalArrayEqualityComparer(); + Assert.False(comparerObj.Equals(rank2, rank3)); + } + + [Fact] + public void Equals_SameRankDifferentDimensions_ReturnsFalse() + { + // int[2,3] vs int[3,2] — same rank, different dimension lengths + int[,] a = new int[2, 3]; + int[,] b = new int[3, 2]; + Assert.False(Comparer.Equals(a, b)); + } + + [Fact] + public void Equals_2D_EqualContent_ReturnsTrue() + { + int[,] a = { { 1, 2 }, { 3, 4 } }; + int[,] b = { { 1, 2 }, { 3, 4 } }; + Assert.True(Comparer.Equals(a, b)); + } + + [Fact] + public void Equals_2D_DifferentContent_ReturnsFalse() + { + int[,] a = { { 1, 2 }, { 3, 4 } }; + int[,] b = { { 1, 2 }, { 3, 99 } }; + Assert.False(Comparer.Equals(a, b)); + } + + [Fact] + public void Equals_3D_EqualContent_ReturnsTrue() + { + int[,,] a = { { { 1, 2 }, { 3, 4 } }, { { 5, 6 }, { 7, 8 } } }; + int[,,] b = { { { 1, 2 }, { 3, 4 } }, { { 5, 6 }, { 7, 8 } } }; + var comparer3D = new MultiDimensionalArrayEqualityComparer(); + Assert.True(comparer3D.Equals(a, b)); + } + + [Fact] + public void Equals_EmptyArrays_BothEmpty_ReturnsTrue() + { + Array a = new int[0, 0]; + Array b = new int[0, 0]; + Assert.True(Comparer.Equals(a, b)); + } + + [Fact] + public void Equals_CustomComparer_UsedForElementComparison() + { + // OrdinalIgnoreCase: "A" and "a" are equal elements + var ci = new MultiDimensionalArrayEqualityComparer(StringComparer.OrdinalIgnoreCase); + string[,] a = { { "Hello", "World" } }; + string[,] b = { { "hello", "WORLD" } }; + Assert.True(ci.Equals(a, b)); + } + + [Fact] + public void Equals_CustomComparer_DefaultWouldDiffer() + { + // Default (ordinal) comparer would treat "A" and "a" as different + var ordinal = new MultiDimensionalArrayEqualityComparer(StringComparer.Ordinal); + string[,] a = { { "A" } }; + string[,] b = { { "a" } }; + Assert.False(ordinal.Equals(a, b)); + } + + // ── GetHashCode ─────────────────────────────────────────────────────────────────────────────── + + [Fact] + public void GetHashCode_Null_ReturnsZero() + { + Assert.Equal(0, Comparer.GetHashCode(null)); + } + + [Fact] + public void GetHashCode_Empty_DiffersFromNull() + { + // HashCode.ToHashCode() on zero iterations returns a non-zero seed, so empty ≠ null + Array empty = new int[0, 0]; + Assert.NotEqual(0, Comparer.GetHashCode(empty)); + } + + [Fact] + public void GetHashCode_EqualArrays_SameHash() + { + int[,] a = { { 1, 2 }, { 3, 4 } }; + int[,] b = { { 1, 2 }, { 3, 4 } }; + Assert.Equal(Comparer.GetHashCode(a), Comparer.GetHashCode(b)); + } + + [Fact] + public void GetHashCode_DifferentContent_DifferentHash() + { + int[,] a = { { 1, 2 }, { 3, 4 } }; + int[,] b = { { 1, 2 }, { 3, 99 } }; + Assert.NotEqual(Comparer.GetHashCode(a), Comparer.GetHashCode(b)); + } + + [Fact] + public void GetHashCode_RowMajorOrder_SensitiveToTransposition() + { + // {{1,2},{3,4}} in row-major = [1,2,3,4] + // {{1,3},{2,4}} in row-major = [1,3,2,4] — different hash + int[,] a = { { 1, 2 }, { 3, 4 } }; + int[,] b = { { 1, 3 }, { 2, 4 } }; + Assert.NotEqual(Comparer.GetHashCode(a), Comparer.GetHashCode(b)); + } +} diff --git a/test/Equatable.Generator.Tests/Comparers/NestedDictionaryEqualityComparerTest.cs b/test/Equatable.Generator.Tests/Comparers/NestedDictionaryEqualityComparerTest.cs new file mode 100644 index 0000000..0ac2b38 --- /dev/null +++ b/test/Equatable.Generator.Tests/Comparers/NestedDictionaryEqualityComparerTest.cs @@ -0,0 +1,279 @@ +using Equatable.Comparers; + +namespace Equatable.Generator.Tests.Comparers; + +/// +/// Tests for nested collection comparisons using explicitly composed comparers. +/// +/// DictionaryEqualityComparer.Default uses EqualityComparer<TValue>.Default for values, +/// which is reference equality for any collection type. To compare dictionaries whose +/// values are themselves collections structurally, pass an explicit valueComparer: +/// +/// new DictionaryEqualityComparer<string, Dictionary<string,int>>( +/// EqualityComparer<string>.Default, +/// DictionaryEqualityComparer<string,int>.Default) +/// +public class NestedDictionaryEqualityComparerTest +{ + // ── Dict> ───────────────────────────────────────────────────────────────────── + + private static readonly DictionaryEqualityComparer> NestedDictComparer = + new(EqualityComparer.Default, + new DictionaryEqualityComparer()); + + [Fact] + public void NestedDict_EqualContent_ReturnsTrue() + { + var a = new Dictionary> + { + ["x"] = new() { ["a"] = 1, ["b"] = 2 }, + ["y"] = new() { ["c"] = 3 }, + }; + var b = new Dictionary> + { + ["x"] = new() { ["a"] = 1, ["b"] = 2 }, + ["y"] = new() { ["c"] = 3 }, + }; + + Assert.True(NestedDictComparer.Equals(a, b)); + } + + [Fact] + public void NestedDict_DifferentInnerValue_ReturnsFalse() + { + var a = new Dictionary> + { + ["x"] = new() { ["a"] = 1 }, + }; + var b = new Dictionary> + { + ["x"] = new() { ["a"] = 99 }, + }; + + Assert.False(NestedDictComparer.Equals(a, b)); + } + + [Fact] + public void NestedDict_InnerInsertionOrderIndependent_ReturnsTrue() + { + var a = new Dictionary> + { + ["x"] = new() { ["a"] = 1, ["b"] = 2 }, + }; + var b = new Dictionary> + { + ["x"] = new() { ["b"] = 2, ["a"] = 1 }, + }; + + Assert.True(NestedDictComparer.Equals(a, b)); + } + + [Fact] + public void NestedDict_OuterInsertionOrderIndependent_ReturnsTrue() + { + var a = new Dictionary> + { + ["x"] = new() { ["a"] = 1 }, + ["y"] = new() { ["b"] = 2 }, + }; + var b = new Dictionary> + { + ["y"] = new() { ["b"] = 2 }, + ["x"] = new() { ["a"] = 1 }, + }; + + Assert.True(NestedDictComparer.Equals(a, b)); + } + + [Fact] + public void NestedDict_EqualContent_SameHashCode() + { + var a = new Dictionary> + { + ["x"] = new() { ["a"] = 1, ["b"] = 2 }, + ["y"] = new() { ["c"] = 3 }, + }; + var b = new Dictionary> + { + ["y"] = new() { ["c"] = 3 }, + ["x"] = new() { ["b"] = 2, ["a"] = 1 }, + }; + + Assert.True(NestedDictComparer.Equals(a, b)); + Assert.Equal(NestedDictComparer.GetHashCode(a), NestedDictComparer.GetHashCode(b)); + } + + [Fact] + public void NestedDict_NullValue_EqualsBothNull_ReturnsTrue() + { + // DictionaryEqualityComparer.Default uses EqualityComparer.Default. + // EqualityComparer.Default.Equals(null, null) == true, so two entries with + // null values compare equal even without a custom inner comparer. + var comparer = DictionaryEqualityComparer?>.Default; + + var a = new Dictionary?> { ["x"] = null }; + var b = new Dictionary?> { ["x"] = null }; + + Assert.True(comparer.Equals(a, b)); + } + + [Fact] + public void NestedDict_NullValueVsEmpty_ReturnsFalse() + { + // null and empty dict are different values, so entries with null vs [] are not equal. + var comparer = DictionaryEqualityComparer?>.Default; + + var a = new Dictionary?> { ["x"] = null }; + var b = new Dictionary?> { ["x"] = [] }; + + Assert.False(comparer.Equals(a, b)); + } + + // ── Dict> ───────────────────────────────────────────────────────────────────────── + + private static readonly DictionaryEqualityComparer> DictOfListComparer = + new(EqualityComparer.Default, + new SequenceEqualityComparer()); + + [Fact] + public void DictOfList_EqualContent_ReturnsTrue() + { + var a = new Dictionary> { ["a"] = [1, 2, 3], ["b"] = [4] }; + var b = new Dictionary> { ["a"] = [1, 2, 3], ["b"] = [4] }; + + Assert.True(DictOfListComparer.Equals(a, b)); + } + + [Fact] + public void DictOfList_InnerOrderMatters_ReturnsFalse() + { + var a = new Dictionary> { ["a"] = [1, 2] }; + var b = new Dictionary> { ["a"] = [2, 1] }; + + Assert.False(DictOfListComparer.Equals(a, b)); + } + + [Fact] + public void DictOfList_EqualContent_SameHashCode() + { + var a = new Dictionary> { ["a"] = [1, 2], ["b"] = [3] }; + var b = new Dictionary> { ["b"] = [3], ["a"] = [1, 2] }; + + Assert.True(DictOfListComparer.Equals(a, b)); + Assert.Equal(DictOfListComparer.GetHashCode(a), DictOfListComparer.GetHashCode(b)); + } + + // ── Dict> ────────────────────────────────────────────────────────────────────── + + private static readonly DictionaryEqualityComparer> DictOfSetComparer = + new(EqualityComparer.Default, + new HashSetEqualityComparer()); + + [Fact] + public void DictOfSet_EqualContent_ReturnsTrue() + { + var a = new Dictionary> { ["a"] = [1, 2], ["b"] = [3] }; + var b = new Dictionary> { ["a"] = [1, 2], ["b"] = [3] }; + + Assert.True(DictOfSetComparer.Equals(a, b)); + } + + [Fact] + public void DictOfSet_InnerSetOrderIndependent_ReturnsTrue() + { + var a = new Dictionary> { ["a"] = [1, 2] }; + var b = new Dictionary> { ["a"] = [2, 1] }; + + Assert.True(DictOfSetComparer.Equals(a, b)); + } + + [Fact] + public void DictOfSet_EqualContent_SameHashCode() + { + var a = new Dictionary> { ["a"] = [1, 2], ["b"] = [3] }; + var b = new Dictionary> { ["b"] = [3], ["a"] = [2, 1] }; + + Assert.True(DictOfSetComparer.Equals(a, b)); + Assert.Equal(DictOfSetComparer.GetHashCode(a), DictOfSetComparer.GetHashCode(b)); + } + + // ── Dict>> (3-level) ──────────────────────────────────────────────────── + + private static readonly DictionaryEqualityComparer>> ThreeLevelComparer = + new(EqualityComparer.Default, + new DictionaryEqualityComparer>( + EqualityComparer.Default, + new SequenceEqualityComparer())); + + [Fact] + public void ThreeLevel_EqualContent_ReturnsTrue() + { + var a = new Dictionary>> + { + ["outer"] = new() { ["inner"] = [1, 2, 3] }, + }; + var b = new Dictionary>> + { + ["outer"] = new() { ["inner"] = [1, 2, 3] }, + }; + + Assert.True(ThreeLevelComparer.Equals(a, b)); + } + + [Fact] + public void ThreeLevel_DifferentLeafValue_ReturnsFalse() + { + var a = new Dictionary>> + { + ["outer"] = new() { ["inner"] = [1, 2, 3] }, + }; + var b = new Dictionary>> + { + ["outer"] = new() { ["inner"] = [1, 2, 99] }, + }; + + Assert.False(ThreeLevelComparer.Equals(a, b)); + } + + [Fact] + public void ThreeLevel_InsertionOrderIndependent_EqualContent_SameHashCode() + { + var a = new Dictionary>> + { + ["x"] = new() { ["p"] = [10, 20], ["q"] = [30] }, + ["y"] = new() { ["r"] = [40] }, + }; + var b = new Dictionary>> + { + ["y"] = new() { ["r"] = [40] }, + ["x"] = new() { ["q"] = [30], ["p"] = [10, 20] }, + }; + + Assert.True(ThreeLevelComparer.Equals(a, b)); + Assert.Equal(ThreeLevelComparer.GetHashCode(a), ThreeLevelComparer.GetHashCode(b)); + } + + // ── ReadOnlyDictionary variants ────────────────────────────────────────────────────────────── + + private static readonly ReadOnlyDictionaryEqualityComparer> ReadOnlyNestedComparer = + new(EqualityComparer.Default, + new SequenceEqualityComparer()); + + [Fact] + public void ReadOnlyNestedDict_EqualContent_ReturnsTrue() + { + IReadOnlyDictionary> a = new Dictionary> + { + ["a"] = [1, 2], + ["b"] = [3], + }; + IReadOnlyDictionary> b = new Dictionary> + { + ["b"] = [3], + ["a"] = [1, 2], + }; + + Assert.True(ReadOnlyNestedComparer.Equals(a, b)); + Assert.Equal(ReadOnlyNestedComparer.GetHashCode(a), ReadOnlyNestedComparer.GetHashCode(b)); + } +} diff --git a/test/Equatable.Generator.Tests/Comparers/SequenceEqualityComparerTest.cs b/test/Equatable.Generator.Tests/Comparers/SequenceEqualityComparerTest.cs index d4f278e..0f4981a 100644 --- a/test/Equatable.Generator.Tests/Comparers/SequenceEqualityComparerTest.cs +++ b/test/Equatable.Generator.Tests/Comparers/SequenceEqualityComparerTest.cs @@ -82,4 +82,30 @@ public void GetHashCodeSame() Assert.Equal(bHash, aHash); } + + // ── custom comparer constructor path ───────────────────────────────────────────────────────── + + [Fact] + public void CustomComparer_EqualByCustomRule() + { + // OrdinalIgnoreCase: ["A","B"] and ["a","b"] are element-wise equal + var comparer = new SequenceEqualityComparer(StringComparer.OrdinalIgnoreCase); + Assert.True(comparer.Equals(["A", "B"], ["a", "b"])); + } + + [Fact] + public void CustomComparer_StillOrderSensitive() + { + // Sequence equality is always position-sensitive regardless of element comparer + var comparer = new SequenceEqualityComparer(StringComparer.OrdinalIgnoreCase); + Assert.False(comparer.Equals(["A", "B"], ["B", "A"])); + } + + [Fact] + public void CustomComparer_GetHashCode_UsesComparer() + { + // Equal sequences under the custom comparer must produce the same hash + var comparer = new SequenceEqualityComparer(StringComparer.OrdinalIgnoreCase); + Assert.Equal(comparer.GetHashCode(["A", "B"]), comparer.GetHashCode(["a", "b"])); + } } From e928996d88b57d92b2d15150f656fac5979f8831 Mon Sep 17 00:00:00 2001 From: "a.kasyanov" Date: Wed, 13 May 2026 23:00:38 +0300 Subject: [PATCH 16/71] test: split adapter analyzer tests and expand ISet/array analyzer coverage - Extract shared AnalyzerTestHelper from EquatableAnalyzerTest. - Add DataContractAnalyzerTest: EQ0020 (missing [DataContract]) and derived-class checks. - Add MessagePackAnalyzerTest: EQ0021 (missing [MessagePackObject]) and derived-class checks. - Add 7 tests to EquatableAnalyzerTest covering ISet, IReadOnlySet, int[], int[,] missing-attribute diagnostics (EQ0002) and valid [HashSetEquality]/[SequenceEquality] on those types. Co-Authored-By: Claude Sonnet 4.5 --- .../AnalyzerTestHelper.cs | 41 ++++ .../DataContractAnalyzerTest.cs | 108 +++++++++ .../EquatableAnalyzerTest.cs | 218 ++++++++++-------- .../MessagePackAnalyzerTest.cs | 108 +++++++++ 4 files changed, 378 insertions(+), 97 deletions(-) create mode 100644 test/Equatable.Generator.Tests/AnalyzerTestHelper.cs create mode 100644 test/Equatable.Generator.Tests/DataContractAnalyzerTest.cs create mode 100644 test/Equatable.Generator.Tests/MessagePackAnalyzerTest.cs diff --git a/test/Equatable.Generator.Tests/AnalyzerTestHelper.cs b/test/Equatable.Generator.Tests/AnalyzerTestHelper.cs new file mode 100644 index 0000000..7cbf789 --- /dev/null +++ b/test/Equatable.Generator.Tests/AnalyzerTestHelper.cs @@ -0,0 +1,41 @@ +using System.Collections.Immutable; + +using Equatable.Attributes; +using Equatable.SourceGenerator; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Equatable.Generator.Tests; + +internal static class AnalyzerTestHelper +{ + public static async Task> GetAnalyzerDiagnosticsAsync( + string source, params DiagnosticAnalyzer[] additionalAnalyzers) + { + var syntaxTree = CSharpSyntaxTree.ParseText(source); + var references = AppDomain.CurrentDomain.GetAssemblies() + .Where(assembly => !assembly.IsDynamic && !string.IsNullOrWhiteSpace(assembly.Location)) + .Select(assembly => MetadataReference.CreateFromFile(assembly.Location)) + .Concat( + [ + MetadataReference.CreateFromFile(typeof(EquatableAttribute).Assembly.Location), + MetadataReference.CreateFromFile(typeof(Equatable.Attributes.DataContract.DataContractEquatableAttribute).Assembly.Location), + MetadataReference.CreateFromFile(typeof(Equatable.Attributes.MessagePack.MessagePackEquatableAttribute).Assembly.Location), + MetadataReference.CreateFromFile(typeof(System.Runtime.Serialization.DataMemberAttribute).Assembly.Location), + MetadataReference.CreateFromFile(typeof(MessagePack.KeyAttribute).Assembly.Location), + ]); + + var compilation = CSharpCompilation.Create( + "Test.Analyzer", + [syntaxTree], + references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + DiagnosticAnalyzer[] analyzers = [new EquatableAnalyzer(), .. additionalAnalyzers]; + var compilationWithAnalyzers = compilation.WithAnalyzers([.. analyzers]); + + return await compilationWithAnalyzers.GetAnalyzerDiagnosticsAsync(); + } +} diff --git a/test/Equatable.Generator.Tests/DataContractAnalyzerTest.cs b/test/Equatable.Generator.Tests/DataContractAnalyzerTest.cs new file mode 100644 index 0000000..dff6842 --- /dev/null +++ b/test/Equatable.Generator.Tests/DataContractAnalyzerTest.cs @@ -0,0 +1,108 @@ +using Equatable.SourceGenerator.DataContract; + +namespace Equatable.Generator.Tests; + +public class DataContractAnalyzerTest +{ + [Fact] + public async Task AnalyzeDataContractEquatableMissingDataContract() + { + const string source = @" +using System.Runtime.Serialization; +using Equatable.Attributes.DataContract; + +namespace Equatable.Entities; + +[DataContractEquatable] +public partial class OrderDataContract +{ + [DataMember(Order = 0)] + public int Id { get; set; } +} +"; + var diagnostics = await AnalyzerTestHelper.GetAnalyzerDiagnosticsAsync(source, + new DataContractEquatableAnalyzer()); + + var diagnostic = Assert.Single(diagnostics); + Assert.Equal("EQ0020", diagnostic.Id); + Assert.Contains("OrderDataContract", diagnostic.GetMessage()); + } + + [Fact] + public async Task AnalyzeDataContractEquatableWithDataContractIsValid() + { + const string source = @" +using System.Runtime.Serialization; +using Equatable.Attributes.DataContract; + +namespace Equatable.Entities; + +[DataContract] +[DataContractEquatable] +public partial class OrderDataContract +{ + [DataMember(Order = 0)] + public int Id { get; set; } +} +"; + var diagnostics = await AnalyzerTestHelper.GetAnalyzerDiagnosticsAsync(source, + new DataContractEquatableAnalyzer()); + + Assert.Empty(diagnostics); + } + + [Fact] + public async Task AnalyzeDerivedWithoutDataContractFiresEQ0020() + { + // The derived class itself lacks [DataContract] — EQ0020 fires on it regardless of the base. + const string source = @" +using System.Runtime.Serialization; +using Equatable.Attributes.DataContract; + +namespace Equatable.Entities; + +[DataContractEquatable] +public partial class DerivedOrder : BaseOrder { } + +[DataContract] +public abstract class BaseOrder +{ + [DataMember(Order = 0)] + public int Id { get; set; } +} +"; + var diagnostics = await AnalyzerTestHelper.GetAnalyzerDiagnosticsAsync(source, + new DataContractEquatableAnalyzer()); + + var diagnostic = Assert.Single(diagnostics); + Assert.Equal("EQ0020", diagnostic.Id); + Assert.Contains("DerivedOrder", diagnostic.GetMessage()); + } + + [Fact] + public async Task AnalyzeDerivedWithDataContractIsValid() + { + // Both levels have [DataContract] — no diagnostic. + const string source = @" +using System.Runtime.Serialization; +using Equatable.Attributes.DataContract; + +namespace Equatable.Entities; + +[DataContract] +[DataContractEquatable] +public partial class DerivedOrder : BaseOrder { } + +[DataContract] +public abstract class BaseOrder +{ + [DataMember(Order = 0)] + public int Id { get; set; } +} +"; + var diagnostics = await AnalyzerTestHelper.GetAnalyzerDiagnosticsAsync(source, + new DataContractEquatableAnalyzer()); + + Assert.Empty(diagnostics); + } +} diff --git a/test/Equatable.Generator.Tests/EquatableAnalyzerTest.cs b/test/Equatable.Generator.Tests/EquatableAnalyzerTest.cs index dac1ce2..a3c6fc3 100644 --- a/test/Equatable.Generator.Tests/EquatableAnalyzerTest.cs +++ b/test/Equatable.Generator.Tests/EquatableAnalyzerTest.cs @@ -1,14 +1,3 @@ -using System.Collections.Immutable; - -using Equatable.Attributes; -using Equatable.SourceGenerator; -using Equatable.SourceGenerator.DataContract; -using Equatable.SourceGenerator.MessagePack; - -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.Diagnostics; - namespace Equatable.Generator.Tests; public class EquatableAnalyzerTest @@ -42,7 +31,7 @@ public partial class UserImport } "; - var diagnostics = await GetAnalyzerDiagnosticsAsync(source); + var diagnostics = await AnalyzerTestHelper.GetAnalyzerDiagnosticsAsync(source); Assert.Empty(diagnostics); } @@ -67,7 +56,7 @@ public partial class UserImport } "; - var diagnostics = await GetAnalyzerDiagnosticsAsync(source); + var diagnostics = await AnalyzerTestHelper.GetAnalyzerDiagnosticsAsync(source); Assert.Empty(diagnostics); } @@ -90,7 +79,7 @@ public partial class Audit } "; - var diagnostics = await GetAnalyzerDiagnosticsAsync(source); + var diagnostics = await AnalyzerTestHelper.GetAnalyzerDiagnosticsAsync(source); Assert.Empty(diagnostics); } @@ -123,7 +112,7 @@ public class LengthEqualityComparer : IEqualityComparer } "; - var diagnostics = await GetAnalyzerDiagnosticsAsync(source); + var diagnostics = await AnalyzerTestHelper.GetAnalyzerDiagnosticsAsync(source); Assert.Empty(diagnostics); } @@ -143,7 +132,7 @@ public class NotEquatable } "; - var diagnostics = await GetAnalyzerDiagnosticsAsync(source); + var diagnostics = await AnalyzerTestHelper.GetAnalyzerDiagnosticsAsync(source); Assert.Empty(diagnostics); } @@ -167,7 +156,7 @@ public partial class UserImport } "; - var diagnostics = await GetAnalyzerDiagnosticsAsync(source); + var diagnostics = await AnalyzerTestHelper.GetAnalyzerDiagnosticsAsync(source); var diagnostic = Assert.Single(diagnostics); Assert.Equal("EQ0010", diagnostic.Id); @@ -193,7 +182,7 @@ public partial class UserImport } "; - var diagnostics = await GetAnalyzerDiagnosticsAsync(source); + var diagnostics = await AnalyzerTestHelper.GetAnalyzerDiagnosticsAsync(source); var diagnostic = Assert.Single(diagnostics); Assert.Equal("EQ0011", diagnostic.Id); @@ -219,7 +208,7 @@ public partial class UserImport } "; - var diagnostics = await GetAnalyzerDiagnosticsAsync(source); + var diagnostics = await AnalyzerTestHelper.GetAnalyzerDiagnosticsAsync(source); var diagnostic = Assert.Single(diagnostics); Assert.Equal("EQ0012", diagnostic.Id); @@ -245,7 +234,7 @@ public partial class UserImport } "; - var diagnostics = await GetAnalyzerDiagnosticsAsync(source); + var diagnostics = await AnalyzerTestHelper.GetAnalyzerDiagnosticsAsync(source); var diagnostic = Assert.Single(diagnostics); Assert.Equal("EQ0013", diagnostic.Id); @@ -270,7 +259,7 @@ public partial class UserImport } "; - var diagnostics = await GetAnalyzerDiagnosticsAsync(source); + var diagnostics = await AnalyzerTestHelper.GetAnalyzerDiagnosticsAsync(source); var diagnostic = Assert.Single(diagnostics); Assert.Equal("EQ0001", diagnostic.Id); @@ -295,7 +284,7 @@ public partial class UserImport } "; - var diagnostics = await GetAnalyzerDiagnosticsAsync(source); + var diagnostics = await AnalyzerTestHelper.GetAnalyzerDiagnosticsAsync(source); var diagnostic = Assert.Single(diagnostics); Assert.Equal("EQ0002", diagnostic.Id); @@ -320,7 +309,7 @@ public partial class UserImport } "; - var diagnostics = await GetAnalyzerDiagnosticsAsync(source); + var diagnostics = await AnalyzerTestHelper.GetAnalyzerDiagnosticsAsync(source); var diagnostic = Assert.Single(diagnostics); Assert.Equal("EQ0002", diagnostic.Id); @@ -343,7 +332,7 @@ public partial class UserImport } "; - var diagnostics = await GetAnalyzerDiagnosticsAsync(source); + var diagnostics = await AnalyzerTestHelper.GetAnalyzerDiagnosticsAsync(source); Assert.Empty(diagnostics); } @@ -365,7 +354,7 @@ public partial class UserImport } "; - var diagnostics = await GetAnalyzerDiagnosticsAsync(source); + var diagnostics = await AnalyzerTestHelper.GetAnalyzerDiagnosticsAsync(source); var diagnostic = Assert.Single(diagnostics); Assert.Equal("EQ0001", diagnostic.Id); @@ -389,7 +378,7 @@ public partial class UserImport } "; - var diagnostics = await GetAnalyzerDiagnosticsAsync(source); + var diagnostics = await AnalyzerTestHelper.GetAnalyzerDiagnosticsAsync(source); Assert.Equal(2, diagnostics.Length); Assert.Contains(diagnostics, d => d.Id == "EQ0001" && d.GetMessage().Contains("Permissions")); @@ -414,7 +403,7 @@ public partial class UserImport } "; - var diagnostics = await GetAnalyzerDiagnosticsAsync(source); + var diagnostics = await AnalyzerTestHelper.GetAnalyzerDiagnosticsAsync(source); var diagnostic = Assert.Single(diagnostics); Assert.Equal("EQ0001", diagnostic.Id); @@ -439,7 +428,7 @@ public partial class UserImport } "; - var diagnostics = await GetAnalyzerDiagnosticsAsync(source); + var diagnostics = await AnalyzerTestHelper.GetAnalyzerDiagnosticsAsync(source); var diagnostic = Assert.Single(diagnostics); Assert.Equal("EQ0002", diagnostic.Id); @@ -464,7 +453,7 @@ public partial class UserImport } "; - var diagnostics = await GetAnalyzerDiagnosticsAsync(source); + var diagnostics = await AnalyzerTestHelper.GetAnalyzerDiagnosticsAsync(source); var diagnostic = Assert.Single(diagnostics); Assert.Equal("EQ0002", diagnostic.Id); @@ -489,7 +478,7 @@ public partial class UserImport } "; - var diagnostics = await GetAnalyzerDiagnosticsAsync(source); + var diagnostics = await AnalyzerTestHelper.GetAnalyzerDiagnosticsAsync(source); Assert.Empty(diagnostics); } @@ -512,7 +501,7 @@ public partial class UserImport } "; - var diagnostics = await GetAnalyzerDiagnosticsAsync(source); + var diagnostics = await AnalyzerTestHelper.GetAnalyzerDiagnosticsAsync(source); Assert.Empty(diagnostics); } @@ -539,7 +528,7 @@ public partial class Priority : ModelBase } "; - var diagnostics = await GetAnalyzerDiagnosticsAsync(source); + var diagnostics = await AnalyzerTestHelper.GetAnalyzerDiagnosticsAsync(source); var diagnostic = Assert.Single(diagnostics); Assert.Equal("EQ0002", diagnostic.Id); @@ -570,130 +559,165 @@ public partial class Priority : ModelBase } "; - var diagnostics = await GetAnalyzerDiagnosticsAsync(source); + var diagnostics = await AnalyzerTestHelper.GetAnalyzerDiagnosticsAsync(source); Assert.Empty(diagnostics); } + // ── ISet / IReadOnlySet / array diagnostics ─────────────────────────────────────────────────── + [Fact] - public async Task AnalyzeDataContractEquatableMissingDataContract() + public async Task AnalyzeMissingAttributeForISet() { + // ISet implements IEnumerable → EQ0002 fires when no attribute is present const string source = @" -using System.Runtime.Serialization; +using System.Collections.Generic; using Equatable.Attributes; namespace Equatable.Entities; -[DataContractEquatable] -public partial class OrderDataContract +[Equatable] +public partial class UserImport { - [DataMember(Order = 0)] - public int Id { get; set; } + public ISet? Tags { get; set; } } "; - var diagnostics = await GetAnalyzerDiagnosticsAsync(source, - new DataContractEquatableAnalyzer()); + var diagnostics = await AnalyzerTestHelper.GetAnalyzerDiagnosticsAsync(source); var diagnostic = Assert.Single(diagnostics); - Assert.Equal("EQ0020", diagnostic.Id); - Assert.Contains("OrderDataContract", diagnostic.GetMessage()); + Assert.Equal("EQ0002", diagnostic.Id); + Assert.Contains("Tags", diagnostic.GetMessage()); } [Fact] - public async Task AnalyzeDataContractEquatableWithDataContractIsValid() + public async Task AnalyzeMissingAttributeForIReadOnlySet() { + // IReadOnlySet implements IEnumerable → EQ0002 fires when no attribute is present const string source = @" -using System.Runtime.Serialization; +using System.Collections.Generic; using Equatable.Attributes; namespace Equatable.Entities; -[DataContract] -[DataContractEquatable] -public partial class OrderDataContract +[Equatable] +public partial class UserImport { - [DataMember(Order = 0)] - public int Id { get; set; } + public IReadOnlySet? Roles { get; set; } } "; - var diagnostics = await GetAnalyzerDiagnosticsAsync(source, - new DataContractEquatableAnalyzer()); + var diagnostics = await AnalyzerTestHelper.GetAnalyzerDiagnosticsAsync(source); - Assert.Empty(diagnostics); + var diagnostic = Assert.Single(diagnostics); + Assert.Equal("EQ0002", diagnostic.Id); + Assert.Contains("Roles", diagnostic.GetMessage()); } [Fact] - public async Task AnalyzeMessagePackEquatableMissingMessagePackObject() + public async Task AnalyzeMissingAttributeForArray() { + // int[] without [SequenceEquality] → EQ0002 const string source = @" -using MessagePack; using Equatable.Attributes; namespace Equatable.Entities; -[MessagePackEquatable] -public partial class PricingContract +[Equatable] +public partial class UserImport +{ + public int[]? Codes { get; set; } +} +"; + var diagnostics = await AnalyzerTestHelper.GetAnalyzerDiagnosticsAsync(source); + + var diagnostic = Assert.Single(diagnostics); + Assert.Equal("EQ0002", diagnostic.Id); + Assert.Contains("Codes", diagnostic.GetMessage()); + } + + [Fact] + public async Task AnalyzeMissingAttributeForMultiDimensionalArray() + { + // int[,] without [SequenceEquality] → EQ0002 + const string source = @" +using Equatable.Attributes; + +namespace Equatable.Entities; + +[Equatable] +public partial class Grid { - [Key(0)] - public int MarketId { get; set; } + public int[,]? Cells { get; set; } } "; - var diagnostics = await GetAnalyzerDiagnosticsAsync(source, - new MessagePackEquatableAnalyzer()); + var diagnostics = await AnalyzerTestHelper.GetAnalyzerDiagnosticsAsync(source); var diagnostic = Assert.Single(diagnostics); - Assert.Equal("EQ0021", diagnostic.Id); - Assert.Contains("PricingContract", diagnostic.GetMessage()); + Assert.Equal("EQ0002", diagnostic.Id); + Assert.Contains("Cells", diagnostic.GetMessage()); } [Fact] - public async Task AnalyzeMessagePackEquatableWithMessagePackObjectIsValid() + public async Task AnalyzeHashSetEqualityOnISetIsValid() { + // [HashSetEquality] on ISet must NOT emit EQ0012 const string source = @" -using MessagePack; +using System.Collections.Generic; using Equatable.Attributes; namespace Equatable.Entities; -[MessagePackObject] -[MessagePackEquatable] -public partial class PricingContract +[Equatable] +public partial class UserImport { - [Key(0)] - public int MarketId { get; set; } + [HashSetEquality] + public ISet? Tags { get; set; } } "; - var diagnostics = await GetAnalyzerDiagnosticsAsync(source, - new MessagePackEquatableAnalyzer()); + var diagnostics = await AnalyzerTestHelper.GetAnalyzerDiagnosticsAsync(source); Assert.Empty(diagnostics); } - private static async Task> GetAnalyzerDiagnosticsAsync( - string source, params DiagnosticAnalyzer[] additionalAnalyzers) + [Fact] + public async Task AnalyzeHashSetEqualityOnIReadOnlySetIsValid() { - var syntaxTree = CSharpSyntaxTree.ParseText(source); - var references = AppDomain.CurrentDomain.GetAssemblies() - .Where(assembly => !assembly.IsDynamic && !string.IsNullOrWhiteSpace(assembly.Location)) - .Select(assembly => MetadataReference.CreateFromFile(assembly.Location)) - .Concat( - [ - MetadataReference.CreateFromFile(typeof(EquatableAttribute).Assembly.Location), - MetadataReference.CreateFromFile(typeof(Equatable.Attributes.DataContractEquatableAttribute).Assembly.Location), - MetadataReference.CreateFromFile(typeof(Equatable.Attributes.MessagePackEquatableAttribute).Assembly.Location), - MetadataReference.CreateFromFile(typeof(System.Runtime.Serialization.DataMemberAttribute).Assembly.Location), - MetadataReference.CreateFromFile(typeof(MessagePack.KeyAttribute).Assembly.Location), - ]); - - var compilation = CSharpCompilation.Create( - "Test.Analyzer", - [syntaxTree], - references, - new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); - - DiagnosticAnalyzer[] analyzers = [new EquatableAnalyzer(), .. additionalAnalyzers]; - var compilationWithAnalyzers = compilation.WithAnalyzers([.. analyzers]); - - return await compilationWithAnalyzers.GetAnalyzerDiagnosticsAsync(); + // [HashSetEquality] on IReadOnlySet must NOT emit EQ0012 + const string source = @" +using System.Collections.Generic; +using Equatable.Attributes; + +namespace Equatable.Entities; + +[Equatable] +public partial class UserImport +{ + [HashSetEquality] + public IReadOnlySet? Roles { get; set; } +} +"; + var diagnostics = await AnalyzerTestHelper.GetAnalyzerDiagnosticsAsync(source); + + Assert.Empty(diagnostics); + } + + [Fact] + public async Task AnalyzeSequenceEqualityOnArrayIsValid() + { + // [SequenceEquality] on int[] must NOT emit EQ0013 + const string source = @" +using Equatable.Attributes; + +namespace Equatable.Entities; + +[Equatable] +public partial class UserImport +{ + [SequenceEquality] + public int[]? Codes { get; set; } +} +"; + var diagnostics = await AnalyzerTestHelper.GetAnalyzerDiagnosticsAsync(source); + + Assert.Empty(diagnostics); } } diff --git a/test/Equatable.Generator.Tests/MessagePackAnalyzerTest.cs b/test/Equatable.Generator.Tests/MessagePackAnalyzerTest.cs new file mode 100644 index 0000000..9d84eee --- /dev/null +++ b/test/Equatable.Generator.Tests/MessagePackAnalyzerTest.cs @@ -0,0 +1,108 @@ +using Equatable.SourceGenerator.MessagePack; + +namespace Equatable.Generator.Tests; + +public class MessagePackAnalyzerTest +{ + [Fact] + public async Task AnalyzeMessagePackEquatableMissingMessagePackObject() + { + const string source = @" +using MessagePack; +using Equatable.Attributes.MessagePack; + +namespace Equatable.Entities; + +[MessagePackEquatable] +public partial class PricingContract +{ + [Key(0)] + public int MarketId { get; set; } +} +"; + var diagnostics = await AnalyzerTestHelper.GetAnalyzerDiagnosticsAsync(source, + new MessagePackEquatableAnalyzer()); + + var diagnostic = Assert.Single(diagnostics); + Assert.Equal("EQ0021", diagnostic.Id); + Assert.Contains("PricingContract", diagnostic.GetMessage()); + } + + [Fact] + public async Task AnalyzeMessagePackEquatableWithMessagePackObjectIsValid() + { + const string source = @" +using MessagePack; +using Equatable.Attributes.MessagePack; + +namespace Equatable.Entities; + +[MessagePackObject] +[MessagePackEquatable] +public partial class PricingContract +{ + [Key(0)] + public int MarketId { get; set; } +} +"; + var diagnostics = await AnalyzerTestHelper.GetAnalyzerDiagnosticsAsync(source, + new MessagePackEquatableAnalyzer()); + + Assert.Empty(diagnostics); + } + + [Fact] + public async Task AnalyzeDerivedWithoutMessagePackObjectFiresEQ0021() + { + // The derived class itself lacks [MessagePackObject] — EQ0021 fires on it. + const string source = @" +using MessagePack; +using Equatable.Attributes.MessagePack; + +namespace Equatable.Entities; + +[MessagePackEquatable] +public partial class DerivedContract : BaseContract { } + +[MessagePackObject] +public abstract class BaseContract +{ + [Key(0)] + public int Id { get; set; } +} +"; + var diagnostics = await AnalyzerTestHelper.GetAnalyzerDiagnosticsAsync(source, + new MessagePackEquatableAnalyzer()); + + var diagnostic = Assert.Single(diagnostics); + Assert.Equal("EQ0021", diagnostic.Id); + Assert.Contains("DerivedContract", diagnostic.GetMessage()); + } + + [Fact] + public async Task AnalyzeDerivedWithMessagePackObjectIsValid() + { + // Both levels have [MessagePackObject] — no diagnostic. + const string source = @" +using MessagePack; +using Equatable.Attributes.MessagePack; + +namespace Equatable.Entities; + +[MessagePackObject] +[MessagePackEquatable] +public partial class DerivedContract : BaseContract { } + +[MessagePackObject] +public abstract class BaseContract +{ + [Key(0)] + public int Id { get; set; } +} +"; + var diagnostics = await AnalyzerTestHelper.GetAnalyzerDiagnosticsAsync(source, + new MessagePackEquatableAnalyzer()); + + Assert.Empty(diagnostics); + } +} From b49792ea9f39acf0820c4ac540983e7d8651f41b Mon Sep 17 00:00:00 2001 From: "a.kasyanov" Date: Wed, 13 May 2026 23:00:51 +0300 Subject: [PATCH 17/71] test: add adapter generator snapshot tests and expand generator/writer coverage - AdapterGeneratorTestBase: shared base class with BuildReferences and GetGeneratedOutput helpers for DataContract and MessagePack generator tests. - DataContractGeneratorTest: 7 snapshot tests covering basic generation, derived-from-annotated-base delegation, unannotated-base inclusion, explicit comparer override, inferred collection comparers, nested collection comparers, and zero-property edge case. - MessagePackGeneratorTest: same 7 scenarios for MessagePack. - EquatableGeneratorTest: add GenerateEquatableWithNoPublicProperties snapshot (Equals reduces to !(other is null)). - EquatableWriterTest: add 4 snapshot tests for record, sealed, derived, and nested-class writer paths previously uncovered. - Accept all new verified snapshots. Co-Authored-By: Claude Sonnet 4.5 --- .../AdapterGeneratorTestBase.cs | 75 ++++++ .../DataContractGeneratorTest.cs | 247 +++++++++++++++++ .../EquatableGeneratorTest.cs | 167 ++---------- .../EquatableWriterTest.cs | 111 ++++++++ .../MessagePackGeneratorTest.cs | 248 ++++++++++++++++++ ...GenerateDataContractEquatable.verified.txt | 45 ++++ ...erivedIncludesUnannotatedBase.verified.txt | 47 ++++ ...thInferredCollectionComparers.verified.txt | 51 ++++ ...tableWithNoIncludedProperties.verified.txt | 41 +++ ...WithOrderedDictionaryOverride.verified.txt | 45 ++++ ....GenerateMessagePackEquatable.verified.txt | 45 ++++ ...erivedIncludesUnannotatedBase.verified.txt | 47 ++++ ...thInferredCollectionComparers.verified.txt | 51 ++++ ...tableWithNoIncludedProperties.verified.txt | 41 +++ ...WithOrderedDictionaryOverride.verified.txt | 45 ++++ ...GenerateDataContractEquatable.verified.txt | 45 ++++ ...FromDataContractEquatableBase.verified.txt | 45 ++++ ...erivedIncludesUnannotatedBase.verified.txt | 47 ++++ ...thInferredCollectionComparers.verified.txt | 51 ++++ ...WithNestedCollectionComparers.verified.txt | 51 ++++ ...tableWithNoIncludedProperties.verified.txt | 41 +++ ...WithOrderedDictionaryOverride.verified.txt | 45 ++++ ...thInferredCollectionComparers.verified.txt | 51 ++++ ...tableWithNoIncludedProperties.verified.txt | 41 +++ ...WithOrderedDictionaryOverride.verified.txt | 45 ++++ ...uatableWithNoPublicProperties.verified.txt | 41 +++ ...t.GenerateIDictionaryEquality.verified.txt | 2 +- ...t.GenerateISetHashSetEquality.verified.txt | 2 +- ...thInferredCollectionComparers.verified.txt | 51 ++++ ...tableWithNoIncludedProperties.verified.txt | 41 +++ ...WithOrderedDictionaryOverride.verified.txt | 45 ++++ ...st.GenerateReadOnlyDictionary.verified.txt | 2 +- ...eratorTest.GenerateUserImport.verified.txt | 4 +- ...elegatesBaseEqualsAndHashCode.verified.txt | 45 ++++ ...eNested_WrapsInContainingType.verified.txt | 46 ++++ ...rateRecord_EmitsVirtualEquals.verified.txt | 27 ++ ...nerateSealed_NonVirtualEquals.verified.txt | 43 +++ ...teUserImportHashSetDictionary.verified.txt | 4 +- ....GenerateMessagePackEquatable.verified.txt | 45 ++++ ...dFromMessagePackEquatableBase.verified.txt | 45 ++++ ...erivedIncludesUnannotatedBase.verified.txt | 47 ++++ ...thInferredCollectionComparers.verified.txt | 51 ++++ ...WithNestedCollectionComparers.verified.txt | 51 ++++ ...tableWithNoIncludedProperties.verified.txt | 41 +++ ...WithOrderedDictionaryOverride.verified.txt | 45 ++++ 45 files changed, 2298 insertions(+), 148 deletions(-) create mode 100644 test/Equatable.Generator.Tests/AdapterGeneratorTestBase.cs create mode 100644 test/Equatable.Generator.Tests/DataContractGeneratorTest.cs create mode 100644 test/Equatable.Generator.Tests/MessagePackGeneratorTest.cs create mode 100644 test/Equatable.Generator.Tests/Snapshots/AdapterGeneratorTest.GenerateDataContractEquatable.verified.txt create mode 100644 test/Equatable.Generator.Tests/Snapshots/AdapterGeneratorTest.GenerateDataContractEquatableDerivedIncludesUnannotatedBase.verified.txt create mode 100644 test/Equatable.Generator.Tests/Snapshots/AdapterGeneratorTest.GenerateDataContractEquatableWithInferredCollectionComparers.verified.txt create mode 100644 test/Equatable.Generator.Tests/Snapshots/AdapterGeneratorTest.GenerateDataContractEquatableWithNoIncludedProperties.verified.txt create mode 100644 test/Equatable.Generator.Tests/Snapshots/AdapterGeneratorTest.GenerateDataContractEquatableWithOrderedDictionaryOverride.verified.txt create mode 100644 test/Equatable.Generator.Tests/Snapshots/AdapterGeneratorTest.GenerateMessagePackEquatable.verified.txt create mode 100644 test/Equatable.Generator.Tests/Snapshots/AdapterGeneratorTest.GenerateMessagePackEquatableDerivedIncludesUnannotatedBase.verified.txt create mode 100644 test/Equatable.Generator.Tests/Snapshots/AdapterGeneratorTest.GenerateMessagePackEquatableWithInferredCollectionComparers.verified.txt create mode 100644 test/Equatable.Generator.Tests/Snapshots/AdapterGeneratorTest.GenerateMessagePackEquatableWithNoIncludedProperties.verified.txt create mode 100644 test/Equatable.Generator.Tests/Snapshots/AdapterGeneratorTest.GenerateMessagePackEquatableWithOrderedDictionaryOverride.verified.txt create mode 100644 test/Equatable.Generator.Tests/Snapshots/DataContractGeneratorTest.GenerateDataContractEquatable.verified.txt create mode 100644 test/Equatable.Generator.Tests/Snapshots/DataContractGeneratorTest.GenerateDataContractEquatableDerivedFromDataContractEquatableBase.verified.txt create mode 100644 test/Equatable.Generator.Tests/Snapshots/DataContractGeneratorTest.GenerateDataContractEquatableDerivedIncludesUnannotatedBase.verified.txt create mode 100644 test/Equatable.Generator.Tests/Snapshots/DataContractGeneratorTest.GenerateDataContractEquatableWithInferredCollectionComparers.verified.txt create mode 100644 test/Equatable.Generator.Tests/Snapshots/DataContractGeneratorTest.GenerateDataContractEquatableWithNestedCollectionComparers.verified.txt create mode 100644 test/Equatable.Generator.Tests/Snapshots/DataContractGeneratorTest.GenerateDataContractEquatableWithNoIncludedProperties.verified.txt create mode 100644 test/Equatable.Generator.Tests/Snapshots/DataContractGeneratorTest.GenerateDataContractEquatableWithOrderedDictionaryOverride.verified.txt create mode 100644 test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateDataContractEquatableWithInferredCollectionComparers.verified.txt create mode 100644 test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateDataContractEquatableWithNoIncludedProperties.verified.txt create mode 100644 test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateDataContractEquatableWithOrderedDictionaryOverride.verified.txt create mode 100644 test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateEquatableWithNoPublicProperties.verified.txt create mode 100644 test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateMessagePackEquatableWithInferredCollectionComparers.verified.txt create mode 100644 test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateMessagePackEquatableWithNoIncludedProperties.verified.txt create mode 100644 test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateMessagePackEquatableWithOrderedDictionaryOverride.verified.txt create mode 100644 test/Equatable.Generator.Tests/Snapshots/EquatableWriterTest.GenerateDerived_DelegatesBaseEqualsAndHashCode.verified.txt create mode 100644 test/Equatable.Generator.Tests/Snapshots/EquatableWriterTest.GenerateNested_WrapsInContainingType.verified.txt create mode 100644 test/Equatable.Generator.Tests/Snapshots/EquatableWriterTest.GenerateRecord_EmitsVirtualEquals.verified.txt create mode 100644 test/Equatable.Generator.Tests/Snapshots/EquatableWriterTest.GenerateSealed_NonVirtualEquals.verified.txt create mode 100644 test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatable.verified.txt create mode 100644 test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatableDerivedFromMessagePackEquatableBase.verified.txt create mode 100644 test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatableDerivedIncludesUnannotatedBase.verified.txt create mode 100644 test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatableWithInferredCollectionComparers.verified.txt create mode 100644 test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatableWithNestedCollectionComparers.verified.txt create mode 100644 test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatableWithNoIncludedProperties.verified.txt create mode 100644 test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatableWithOrderedDictionaryOverride.verified.txt diff --git a/test/Equatable.Generator.Tests/AdapterGeneratorTestBase.cs b/test/Equatable.Generator.Tests/AdapterGeneratorTestBase.cs new file mode 100644 index 0000000..857e9e7 --- /dev/null +++ b/test/Equatable.Generator.Tests/AdapterGeneratorTestBase.cs @@ -0,0 +1,75 @@ +using System.Collections.Immutable; + +using Equatable.Attributes; +using Equatable.SourceGenerator; +using Equatable.SourceGenerator.DataContract; +using Equatable.SourceGenerator.MessagePack; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + +namespace Equatable.Generator.Tests; + +public abstract class AdapterGeneratorTestBase +{ + private static readonly IEnumerable PinnedReferences = + [ + MetadataReference.CreateFromFile(typeof(System.Runtime.Serialization.DataMemberAttribute).Assembly.Location), + MetadataReference.CreateFromFile(typeof(MessagePack.KeyAttribute).Assembly.Location), + MetadataReference.CreateFromFile(typeof(Equatable.Attributes.DataContract.DataContractEquatableAttribute).Assembly.Location), + MetadataReference.CreateFromFile(typeof(Equatable.Attributes.MessagePack.MessagePackEquatableAttribute).Assembly.Location), + ]; + + protected static IEnumerable BuildReferences() + where T : IIncrementalGenerator, new() + => AppDomain.CurrentDomain.GetAssemblies() + .Where(assembly => !assembly.IsDynamic && !string.IsNullOrWhiteSpace(assembly.Location)) + .Select(assembly => MetadataReference.CreateFromFile(assembly.Location)) + .Concat( + [ + MetadataReference.CreateFromFile(typeof(T).Assembly.Location), + MetadataReference.CreateFromFile(typeof(EquatableAttribute).Assembly.Location), + MetadataReference.CreateFromFile(typeof(DataContractEquatableGenerator).Assembly.Location), + MetadataReference.CreateFromFile(typeof(MessagePackEquatableGenerator).Assembly.Location), + ]) + .Concat(PinnedReferences); + + protected static (ImmutableArray Diagnostics, string Output) GetGeneratedOutput(string source) + where T : IIncrementalGenerator, new() + { + var syntaxTree = CSharpSyntaxTree.ParseText(source); + var compilation = CSharpCompilation.Create( + "Test.Generator", + [syntaxTree], + BuildReferences(), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + var originalTreeCount = compilation.SyntaxTrees.Length; + var driver = CSharpGeneratorDriver.Create(new T()); + driver.RunGeneratorsAndUpdateCompilation(compilation, out var outputCompilation, out var diagnostics); + + var trees = outputCompilation.SyntaxTrees.ToList(); + return (diagnostics, trees.Count != originalTreeCount ? trees[^1].ToString() : string.Empty); + } + + protected static (ImmutableArray Diagnostics, string Output) GetNamedGeneratedOutput(string source, string typeName) + where T : IIncrementalGenerator, new() + { + var syntaxTree = CSharpSyntaxTree.ParseText(source); + var compilation = CSharpCompilation.Create( + "Test.Generator", + [syntaxTree], + BuildReferences(), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + var originalTreeCount = compilation.SyntaxTrees.Length; + var driver = CSharpGeneratorDriver.Create(new T()); + driver.RunGeneratorsAndUpdateCompilation(compilation, out var outputCompilation, out var diagnostics); + + var generated = outputCompilation.SyntaxTrees.Skip(originalTreeCount).ToList(); + var match = generated.FirstOrDefault(t => t.ToString().Contains($"partial class {typeName}")) + ?? (generated.Count > 0 ? generated[^1] : null); + + return (diagnostics, match?.ToString() ?? string.Empty); + } +} diff --git a/test/Equatable.Generator.Tests/DataContractGeneratorTest.cs b/test/Equatable.Generator.Tests/DataContractGeneratorTest.cs new file mode 100644 index 0000000..bc24ad1 --- /dev/null +++ b/test/Equatable.Generator.Tests/DataContractGeneratorTest.cs @@ -0,0 +1,247 @@ +using Equatable.SourceGenerator.DataContract; + +namespace Equatable.Generator.Tests; + +public class DataContractGeneratorTest : AdapterGeneratorTestBase +{ + [Fact] + public Task GenerateDataContractEquatable() + { + var source = @" +using System.Runtime.Serialization; +using Equatable.Attributes.DataContract; + +namespace Equatable.Entities; + +[DataContract] +[DataContractEquatable] +public partial class OrderDataContract +{ + [DataMember(Order = 0)] + public int Id { get; set; } + + [DataMember(Order = 1)] + public string? Name { get; set; } + + public string? InternalNote { get; set; } + + [IgnoreDataMember] + public string? IgnoredField { get; set; } +} +"; + var (diagnostics, output) = GetGeneratedOutput(source); + Assert.Empty(diagnostics); + return Verifier.Verify(output).UseDirectory("Snapshots").ScrubLinesContaining("GeneratedCodeAttribute"); + } + + // ── derived class from another [DataContractEquatable] base — must call base.Equals() ─────────── + // When both levels carry [DataContractEquatable], the derived class must delegate to + // base.Equals() rather than re-including the base properties directly. + + [Fact] + public Task GenerateDataContractEquatableDerivedFromDataContractEquatableBase() + { + var source = @" +using System.Runtime.Serialization; +using Equatable.Attributes.DataContract; + +namespace Equatable.Entities; + +[DataContract] +[DataContractEquatable] +public partial class DerivedContract : BaseContract +{ + [DataMember(Order = 2)] + public int Rank { get; set; } +} + +[DataContract] +[DataContractEquatable] +public partial class BaseContract +{ + [DataMember(Order = 0)] + public int Id { get; set; } + + [DataMember(Order = 1)] + public string? Name { get; set; } +} +"; + var (diagnostics, output) = GetNamedGeneratedOutput(source, "DerivedContract"); + Assert.Empty(diagnostics); + return Verifier.Verify(output).UseDirectory("Snapshots").ScrubLinesContaining("GeneratedCodeAttribute"); + } + + // ── derived class inherits base properties when base has no generator attribute ────────────────── + // When the derived class carries [DataContractEquatable] but the base has no generator attribute, + // the base's [DataMember] properties must be included directly (no base.Equals() delegation). + + [Fact] + public Task GenerateDataContractEquatableDerivedIncludesUnannotatedBase() + { + var source = @" +using System.Runtime.Serialization; +using Equatable.Attributes.DataContract; + +namespace Equatable.Entities; + +[DataContract] +[DataContractEquatable] +public partial class ConcreteRecord : UnannotatedBase +{ + [DataMember(Order = 2)] + public int Rank { get; set; } +} + +public abstract class UnannotatedBase +{ + [DataMember(Order = 0)] + public int Id { get; set; } + + [DataMember(Order = 1)] + public string? Name { get; set; } +} +"; + var (diagnostics, output) = GetNamedGeneratedOutput(source, "ConcreteRecord"); + Assert.Empty(diagnostics); + return Verifier.Verify(output).UseDirectory("Snapshots").ScrubLinesContaining("GeneratedCodeAttribute"); + } + + // ── explicit comparer override ───────────────────────────────────────────────────────────────── + // An explicit equality attribute on a [DataMember] property must override the inferred comparer. + + [Fact] + public Task GenerateDataContractEquatableWithOrderedDictionaryOverride() + { + var source = @" +using System.Collections.Generic; +using System.Runtime.Serialization; +using Equatable.Attributes; +using Equatable.Attributes.DataContract; + +namespace Equatable.Entities; + +[DataContract] +[DataContractEquatable] +public partial class OrderedContract +{ + [DataMember(Order = 0)] + public int Id { get; set; } + + [DataMember(Order = 1)] + [DictionaryEquality(sequential: true)] + public Dictionary? Tags { get; set; } +} +"; + var (diagnostics, output) = GetGeneratedOutput(source); + Assert.Empty(diagnostics); + return Verifier.Verify(output).UseDirectory("Snapshots").ScrubLinesContaining("GeneratedCodeAttribute"); + } + + // ── inferred collection comparers ───────────────────────────────────────────────────────────── + // [DataMember] collection properties with no explicit equality attribute get structural comparers + // inferred automatically by InferCollectionComparer. + + [Fact] + public Task GenerateDataContractEquatableWithInferredCollectionComparers() + { + var source = @" +using System.Collections.Generic; +using System.Runtime.Serialization; +using Equatable.Attributes.DataContract; + +namespace Equatable.Entities; + +[DataContract] +[DataContractEquatable] +public partial class ContractWithCollections +{ + [DataMember(Order = 0)] + public int Id { get; set; } + + [DataMember(Order = 1)] + public Dictionary? Tags { get; set; } + + [DataMember(Order = 2)] + public List? Labels { get; set; } + + [DataMember(Order = 3)] + public int[]? Codes { get; set; } + + [DataMember(Order = 4)] + public IReadOnlyDictionary? Rates { get; set; } + + public string? NotIncluded { get; set; } +} +"; + var (diagnostics, output) = GetGeneratedOutput(source); + Assert.Empty(diagnostics); + return Verifier.Verify(output).UseDirectory("Snapshots").ScrubLinesContaining("GeneratedCodeAttribute"); + } + + // ── nested collection comparers ─────────────────────────────────────────────────────────────── + // Adapter inference must recurse into nested types and compose structural comparers. + // e.g. Dictionary> → DictionaryEqualityComparer with SequenceEqualityComparer + // List> → SequenceEqualityComparer with DictionaryEqualityComparer + + [Fact] + public Task GenerateDataContractEquatableWithNestedCollectionComparers() + { + var source = @" +using System.Collections.Generic; +using System.Runtime.Serialization; +using Equatable.Attributes.DataContract; + +namespace Equatable.Entities; + +[DataContract] +[DataContractEquatable] +public partial class ContractWithNestedCollections +{ + [DataMember(Order = 0)] + public int Id { get; set; } + + [DataMember(Order = 1)] + public Dictionary>? TagGroups { get; set; } + + [DataMember(Order = 2)] + public Dictionary>? NestedMap { get; set; } + + [DataMember(Order = 3)] + public List>? Records { get; set; } + + [DataMember(Order = 4)] + public IReadOnlyDictionary>? ReadOnlyTagGroups { get; set; } +} +"; + var (diagnostics, output) = GetGeneratedOutput(source); + Assert.Empty(diagnostics); + return Verifier.Verify(output).UseDirectory("Snapshots").ScrubLinesContaining("GeneratedCodeAttribute"); + } + + // ── all properties excluded edge case ───────────────────────────────────────────────────────── + // When no properties survive the filter the generated Equals reduces to !(other is null). + + [Fact] + public Task GenerateDataContractEquatableWithNoIncludedProperties() + { + var source = @" +using System.Runtime.Serialization; +using Equatable.Attributes.DataContract; + +namespace Equatable.Entities; + +[DataContract] +[DataContractEquatable] +public partial class AllIgnored +{ + [IgnoreDataMember] + public int Id { get; set; } + + public string? InternalNote { get; set; } +} +"; + var (diagnostics, output) = GetGeneratedOutput(source); + Assert.Empty(diagnostics); + return Verifier.Verify(output).UseDirectory("Snapshots").ScrubLinesContaining("GeneratedCodeAttribute"); + } +} diff --git a/test/Equatable.Generator.Tests/EquatableGeneratorTest.cs b/test/Equatable.Generator.Tests/EquatableGeneratorTest.cs index f8afec8..2e62e23 100644 --- a/test/Equatable.Generator.Tests/EquatableGeneratorTest.cs +++ b/test/Equatable.Generator.Tests/EquatableGeneratorTest.cs @@ -738,78 +738,6 @@ public partial class NestedCollections return Verifier.Verify(output).UseDirectory("Snapshots").ScrubLinesContaining("GeneratedCodeAttribute"); } - [Fact] - public Task GenerateDataContractEquatable() - { - var source = @" -using System.Runtime.Serialization; -using Equatable.Attributes; - -namespace Equatable.Entities; - -[DataContract] -[DataContractEquatable] -public partial class OrderDataContract -{ - [DataMember(Order = 0)] - public int Id { get; set; } - - [DataMember(Order = 1)] - public string? Name { get; set; } - - public string? InternalNote { get; set; } - - [IgnoreDataMember] - public string? IgnoredField { get; set; } -} -"; - - var (diagnostics, output) = GetGeneratedOutput(source); - - Assert.Empty(diagnostics); - - return Verifier - .Verify(output) - .UseDirectory("Snapshots") - .ScrubLinesContaining("GeneratedCodeAttribute"); - } - - [Fact] - public Task GenerateMessagePackEquatable() - { - var source = @" -using MessagePack; -using Equatable.Attributes; - -namespace Equatable.Entities; - -[MessagePackObject] -[MessagePackEquatable] -public partial class PricingContract -{ - [Key(0)] - public int MarketId { get; set; } - - [Key(1)] - public double Probability { get; set; } - - [IgnoreMember] - public string? DebugInfo { get; set; } - - public string? NotIncluded { get; set; } -} -"; - - var (diagnostics, output) = GetGeneratedOutput(source); - - Assert.Empty(diagnostics); - - return Verifier - .Verify(output) - .UseDirectory("Snapshots") - .ScrubLinesContaining("GeneratedCodeAttribute"); - } - // ── base class with non-[Equatable] generator attribute ─────────────────────────────────────── // These tests guard against the GetBaseEquatableType bug: only "EquatableAttribute" was checked, // so a derived [Equatable] class whose base carries [DataContractEquatable] or @@ -821,6 +749,7 @@ public Task GenerateDerivedFromDataContractEquatableBase() var source = @" using System.Runtime.Serialization; using Equatable.Attributes; +using Equatable.Attributes.DataContract; namespace Equatable.Entities; @@ -852,6 +781,7 @@ public Task GenerateDerivedFromMessagePackEquatableBase() var source = @" using MessagePack; using Equatable.Attributes; +using Equatable.Attributes.MessagePack; namespace Equatable.Entities; @@ -877,73 +807,6 @@ public abstract partial class PackedBase return Verifier.Verify(output).UseDirectory("Snapshots").ScrubLinesContaining("GeneratedCodeAttribute"); } - // ── derived class inherits base properties when base has no generator attribute ────────────────── - // When the derived class carries [DataContractEquatable] or [MessagePackEquatable] but the base - // has no generator attribute, the base's annotated properties must be included directly in the - // derived class's equality (no base.Equals() delegation — the base never generated Equals). - - [Fact] - public Task GenerateDataContractEquatableDerivedIncludesUnannotatedBase() - { - var source = @" -using System.Runtime.Serialization; -using Equatable.Attributes; - -namespace Equatable.Entities; - -[DataContract] -[DataContractEquatable] -public partial class ConcreteRecord : UnannotatedBase -{ - [DataMember(Order = 2)] - public int Rank { get; set; } -} - -public abstract class UnannotatedBase -{ - [DataMember(Order = 0)] - public int Id { get; set; } - - [DataMember(Order = 1)] - public string? Name { get; set; } -} -"; - var (diagnostics, output) = GetNamedGeneratedOutput(source, "ConcreteRecord"); - Assert.Empty(diagnostics); - return Verifier.Verify(output).UseDirectory("Snapshots").ScrubLinesContaining("GeneratedCodeAttribute"); - } - - [Fact] - public Task GenerateMessagePackEquatableDerivedIncludesUnannotatedBase() - { - var source = @" -using MessagePack; -using Equatable.Attributes; - -namespace Equatable.Entities; - -[MessagePackObject] -[MessagePackEquatable] -public partial class ConcreteRecord : UnannotatedBase -{ - [Key(2)] - public string? Label { get; set; } -} - -public abstract class UnannotatedBase -{ - [Key(0)] - public int Id { get; set; } - - [Key(1)] - public double Score { get; set; } -} -"; - var (diagnostics, output) = GetNamedGeneratedOutput(source, "ConcreteRecord"); - Assert.Empty(diagnostics); - return Verifier.Verify(output).UseDirectory("Snapshots").ScrubLinesContaining("GeneratedCodeAttribute"); - } - // ── interface-typed collection properties ────────────────────────────────────────────────────── // These tests guard against regression of the ValidateComparer bug: interface types do not appear // in their own AllInterfaces list, so the direct-type check must come first. @@ -1098,14 +961,36 @@ public partial class Container return Verifier.Verify(output).UseDirectory("Snapshots").ScrubLinesContaining("GeneratedCodeAttribute"); } + // ── zero-property edge case ─────────────────────────────────────────────────────────────────── + // A class with [Equatable] and no public properties should generate an Equals body that + // reduces to !(other is null) with an empty GetHashCode. + + [Fact] + public Task GenerateEquatableWithNoPublicProperties() + { + var source = @" +using Equatable.Attributes; + +namespace Equatable.Entities; + +[Equatable] +public partial class Empty +{ +} +"; + var (diagnostics, output) = GetGeneratedOutput(source); + Assert.Empty(diagnostics); + return Verifier.Verify(output).UseDirectory("Snapshots").ScrubLinesContaining("GeneratedCodeAttribute"); + } + // Pinned references that must always be present regardless of AppDomain load order. // Adapter attribute assemblies and serialization libraries may not be loaded when a test runs first. private static readonly IEnumerable PinnedReferences = [ MetadataReference.CreateFromFile(typeof(System.Runtime.Serialization.DataMemberAttribute).Assembly.Location), MetadataReference.CreateFromFile(typeof(MessagePack.KeyAttribute).Assembly.Location), - MetadataReference.CreateFromFile(typeof(Equatable.Attributes.DataContractEquatableAttribute).Assembly.Location), - MetadataReference.CreateFromFile(typeof(Equatable.Attributes.MessagePackEquatableAttribute).Assembly.Location), + MetadataReference.CreateFromFile(typeof(Equatable.Attributes.DataContract.DataContractEquatableAttribute).Assembly.Location), + MetadataReference.CreateFromFile(typeof(Equatable.Attributes.MessagePack.MessagePackEquatableAttribute).Assembly.Location), ]; private static IEnumerable BuildReferences() diff --git a/test/Equatable.Generator.Tests/EquatableWriterTest.cs b/test/Equatable.Generator.Tests/EquatableWriterTest.cs index 210d9a0..a6f874a 100644 --- a/test/Equatable.Generator.Tests/EquatableWriterTest.cs +++ b/test/Equatable.Generator.Tests/EquatableWriterTest.cs @@ -104,4 +104,115 @@ public async Task GenerateUserImportHashSetDictionary() .UseDirectory("Snapshots") .ScrubLinesContaining("GeneratedCodeAttribute"); } + + // ── record type — generates EqualityContract check and virtual Equals ───────────────────────── + + [Fact] + public async Task GenerateRecord_EmitsVirtualEquals() + { + var entityClass = new EquatableClass( + FullyQualified: "global::Equatable.Entities.PricingRecord", + EntityNamespace: "Equatable.Entities", + EntityName: "PricingRecord", + FileName: "Equatable.Entities.PricingRecord.Equatable.g.cs", + ContainingTypes: Array.Empty(), + Properties: new EquatableArray([ + new EquatableProperty("MarketId", "int"), + new EquatableProperty("Probability", "double"), + ]), + IsRecord: true, + IsValueType: false, + IsSealed: false, + IncludeBaseEqualsMethod: false, + IncludeBaseHashMethod: false, + SeedHash: 42 + ); + + var output = EquatableWriter.Generate(entityClass); + + await Verify(output).UseDirectory("Snapshots").ScrubLinesContaining("GeneratedCodeAttribute"); + } + + // ── sealed class — Equals is not virtual, no object-typed override needed ──────────────────── + + [Fact] + public async Task GenerateSealed_NonVirtualEquals() + { + var entityClass = new EquatableClass( + FullyQualified: "global::Equatable.Entities.FinalEntity", + EntityNamespace: "Equatable.Entities", + EntityName: "FinalEntity", + FileName: "Equatable.Entities.FinalEntity.Equatable.g.cs", + ContainingTypes: Array.Empty(), + Properties: new EquatableArray([ + new EquatableProperty("Id", "int"), + ]), + IsRecord: false, + IsValueType: false, + IsSealed: true, + IncludeBaseEqualsMethod: false, + IncludeBaseHashMethod: false, + SeedHash: 0 + ); + + var output = EquatableWriter.Generate(entityClass); + + await Verify(output).UseDirectory("Snapshots").ScrubLinesContaining("GeneratedCodeAttribute"); + } + + // ── derived class — generated Equals and GetHashCode include base delegation ───────────────── + + [Fact] + public async Task GenerateDerived_DelegatesBaseEqualsAndHashCode() + { + var entityClass = new EquatableClass( + FullyQualified: "global::Equatable.Entities.DerivedEntity", + EntityNamespace: "Equatable.Entities", + EntityName: "DerivedEntity", + FileName: "Equatable.Entities.DerivedEntity.Equatable.g.cs", + ContainingTypes: Array.Empty(), + Properties: new EquatableArray([ + new EquatableProperty("Label", "string?"), + ]), + IsRecord: false, + IsValueType: false, + IsSealed: false, + IncludeBaseEqualsMethod: true, + IncludeBaseHashMethod: true, + SeedHash: 99 + ); + + var output = EquatableWriter.Generate(entityClass); + + await Verify(output).UseDirectory("Snapshots").ScrubLinesContaining("GeneratedCodeAttribute"); + } + + // ── nested class — generated file is wrapped in containing-type partial declarations ───────── + + [Fact] + public async Task GenerateNested_WrapsInContainingType() + { + var entityClass = new EquatableClass( + FullyQualified: "global::Equatable.Entities.Outer.Inner", + EntityNamespace: "Equatable.Entities", + EntityName: "Inner", + FileName: "Equatable.Entities.Outer.Inner.Equatable.g.cs", + ContainingTypes: new EquatableArray([ + new ContainingClass("Outer", IsRecord: false, IsValueType: false), + ]), + Properties: new EquatableArray([ + new EquatableProperty("Value", "int"), + ]), + IsRecord: false, + IsValueType: false, + IsSealed: false, + IncludeBaseEqualsMethod: false, + IncludeBaseHashMethod: false, + SeedHash: 7 + ); + + var output = EquatableWriter.Generate(entityClass); + + await Verify(output).UseDirectory("Snapshots").ScrubLinesContaining("GeneratedCodeAttribute"); + } } diff --git a/test/Equatable.Generator.Tests/MessagePackGeneratorTest.cs b/test/Equatable.Generator.Tests/MessagePackGeneratorTest.cs new file mode 100644 index 0000000..fb76e3c --- /dev/null +++ b/test/Equatable.Generator.Tests/MessagePackGeneratorTest.cs @@ -0,0 +1,248 @@ +using Equatable.SourceGenerator.MessagePack; + +namespace Equatable.Generator.Tests; + +public class MessagePackGeneratorTest : AdapterGeneratorTestBase +{ + [Fact] + public Task GenerateMessagePackEquatable() + { + var source = @" +using MessagePack; +using Equatable.Attributes.MessagePack; + +namespace Equatable.Entities; + +[MessagePackObject] +[MessagePackEquatable] +public partial class PricingContract +{ + [Key(0)] + public int MarketId { get; set; } + + [Key(1)] + public double Probability { get; set; } + + [IgnoreMember] + public string? DebugInfo { get; set; } + + public string? NotIncluded { get; set; } +} +"; + var (diagnostics, output) = GetGeneratedOutput(source); + Assert.Empty(diagnostics); + return Verifier.Verify(output).UseDirectory("Snapshots").ScrubLinesContaining("GeneratedCodeAttribute"); + } + + // ── derived class from another [MessagePackEquatable] base — must call base.Equals() ──────────── + // When both levels carry [MessagePackEquatable], the derived class must delegate to + // base.Equals() rather than re-including the base properties directly. + + [Fact] + public Task GenerateMessagePackEquatableDerivedFromMessagePackEquatableBase() + { + var source = @" +using MessagePack; +using Equatable.Attributes.MessagePack; + +namespace Equatable.Entities; + +[MessagePackObject] +[MessagePackEquatable] +public partial class DerivedRecord : BaseRecord +{ + [Key(2)] + public string? Label { get; set; } +} + +[MessagePackObject] +[MessagePackEquatable] +public partial class BaseRecord +{ + [Key(0)] + public int Id { get; set; } + + [Key(1)] + public double Score { get; set; } +} +"; + var (diagnostics, output) = GetNamedGeneratedOutput(source, "DerivedRecord"); + Assert.Empty(diagnostics); + return Verifier.Verify(output).UseDirectory("Snapshots").ScrubLinesContaining("GeneratedCodeAttribute"); + } + + // ── derived class inherits base properties when base has no generator attribute ────────────────── + // When the derived class carries [MessagePackEquatable] but the base has no generator attribute, + // the base's [Key] properties must be included directly (no base.Equals() delegation). + + [Fact] + public Task GenerateMessagePackEquatableDerivedIncludesUnannotatedBase() + { + var source = @" +using MessagePack; +using Equatable.Attributes.MessagePack; + +namespace Equatable.Entities; + +[MessagePackObject] +[MessagePackEquatable] +public partial class ConcreteRecord : UnannotatedBase +{ + [Key(2)] + public string? Label { get; set; } +} + +public abstract class UnannotatedBase +{ + [Key(0)] + public int Id { get; set; } + + [Key(1)] + public double Score { get; set; } +} +"; + var (diagnostics, output) = GetNamedGeneratedOutput(source, "ConcreteRecord"); + Assert.Empty(diagnostics); + return Verifier.Verify(output).UseDirectory("Snapshots").ScrubLinesContaining("GeneratedCodeAttribute"); + } + + // ── explicit comparer override ───────────────────────────────────────────────────────────────── + // An explicit equality attribute on a [Key] property must override the inferred comparer. + + [Fact] + public Task GenerateMessagePackEquatableWithOrderedDictionaryOverride() + { + var source = @" +using System.Collections.Generic; +using MessagePack; +using Equatable.Attributes; +using Equatable.Attributes.MessagePack; + +namespace Equatable.Entities; + +[MessagePackObject] +[MessagePackEquatable] +public partial class OrderedPackedContract +{ + [Key(0)] + public int Id { get; set; } + + [Key(1)] + [DictionaryEquality(sequential: true)] + public Dictionary? Tags { get; set; } +} +"; + var (diagnostics, output) = GetGeneratedOutput(source); + Assert.Empty(diagnostics); + return Verifier.Verify(output).UseDirectory("Snapshots").ScrubLinesContaining("GeneratedCodeAttribute"); + } + + // ── inferred collection comparers ───────────────────────────────────────────────────────────── + // [Key] collection properties with no explicit equality attribute get structural comparers + // inferred automatically by InferCollectionComparer. + + [Fact] + public Task GenerateMessagePackEquatableWithInferredCollectionComparers() + { + var source = @" +using System.Collections.Generic; +using MessagePack; +using Equatable.Attributes.MessagePack; + +namespace Equatable.Entities; + +[MessagePackObject] +[MessagePackEquatable] +public partial class PackedWithCollections +{ + [Key(0)] + public int Id { get; set; } + + [Key(1)] + public Dictionary? Tags { get; set; } + + [Key(2)] + public List? Labels { get; set; } + + [Key(3)] + public int[]? Codes { get; set; } + + [Key(4)] + public IReadOnlyDictionary? Rates { get; set; } + + [IgnoreMember] + public string? Ignored { get; set; } + + public string? NotIncluded { get; set; } +} +"; + var (diagnostics, output) = GetGeneratedOutput(source); + Assert.Empty(diagnostics); + return Verifier.Verify(output).UseDirectory("Snapshots").ScrubLinesContaining("GeneratedCodeAttribute"); + } + + // ── nested collection comparers ─────────────────────────────────────────────────────────────── + // Adapter inference must recurse into nested types and compose structural comparers. + + [Fact] + public Task GenerateMessagePackEquatableWithNestedCollectionComparers() + { + var source = @" +using System.Collections.Generic; +using MessagePack; +using Equatable.Attributes.MessagePack; + +namespace Equatable.Entities; + +[MessagePackObject] +[MessagePackEquatable] +public partial class PackedWithNestedCollections +{ + [Key(0)] + public int Id { get; set; } + + [Key(1)] + public Dictionary>? TagGroups { get; set; } + + [Key(2)] + public Dictionary>? NestedMap { get; set; } + + [Key(3)] + public List>? Records { get; set; } + + [Key(4)] + public IReadOnlyDictionary>? ReadOnlyTagGroups { get; set; } +} +"; + var (diagnostics, output) = GetGeneratedOutput(source); + Assert.Empty(diagnostics); + return Verifier.Verify(output).UseDirectory("Snapshots").ScrubLinesContaining("GeneratedCodeAttribute"); + } + + // ── all properties excluded edge case ───────────────────────────────────────────────────────── + // When no properties survive the filter the generated Equals reduces to !(other is null). + + [Fact] + public Task GenerateMessagePackEquatableWithNoIncludedProperties() + { + var source = @" +using MessagePack; +using Equatable.Attributes.MessagePack; + +namespace Equatable.Entities; + +[MessagePackObject] +[MessagePackEquatable] +public partial class AllIgnored +{ + [IgnoreMember] + public int Id { get; set; } + + public string? NotIncluded { get; set; } +} +"; + var (diagnostics, output) = GetGeneratedOutput(source); + Assert.Empty(diagnostics); + return Verifier.Verify(output).UseDirectory("Snapshots").ScrubLinesContaining("GeneratedCodeAttribute"); + } +} diff --git a/test/Equatable.Generator.Tests/Snapshots/AdapterGeneratorTest.GenerateDataContractEquatable.verified.txt b/test/Equatable.Generator.Tests/Snapshots/AdapterGeneratorTest.GenerateDataContractEquatable.verified.txt new file mode 100644 index 0000000..d7fe908 --- /dev/null +++ b/test/Equatable.Generator.Tests/Snapshots/AdapterGeneratorTest.GenerateDataContractEquatable.verified.txt @@ -0,0 +1,45 @@ +// +#nullable enable + +namespace Equatable.Entities +{ + partial class OrderDataContract : global::System.IEquatable + { + /// + public bool Equals(global::Equatable.Entities.OrderDataContract? other) + { + return !(other is null) + && Id == other.Id + && global::System.Collections.Generic.EqualityComparer.Default.Equals(Name, other.Name); + + } + + /// + public override bool Equals(object? obj) + { + return Equals(obj as global::Equatable.Entities.OrderDataContract); + } + + /// + public static bool operator ==(global::Equatable.Entities.OrderDataContract? left, global::Equatable.Entities.OrderDataContract? right) + { + return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); + } + + /// + public static bool operator !=(global::Equatable.Entities.OrderDataContract? left, global::Equatable.Entities.OrderDataContract? right) + { + return !(left == right); + } + + /// + public override int GetHashCode(){ + int hashCode = -1919740922; + hashCode = (hashCode * -1521134295) + Id.GetHashCode(); + hashCode = (hashCode * -1521134295) + global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(Name!); + return hashCode; + + } + + } +} diff --git a/test/Equatable.Generator.Tests/Snapshots/AdapterGeneratorTest.GenerateDataContractEquatableDerivedIncludesUnannotatedBase.verified.txt b/test/Equatable.Generator.Tests/Snapshots/AdapterGeneratorTest.GenerateDataContractEquatableDerivedIncludesUnannotatedBase.verified.txt new file mode 100644 index 0000000..43258cd --- /dev/null +++ b/test/Equatable.Generator.Tests/Snapshots/AdapterGeneratorTest.GenerateDataContractEquatableDerivedIncludesUnannotatedBase.verified.txt @@ -0,0 +1,47 @@ +// +#nullable enable + +namespace Equatable.Entities +{ + partial class ConcreteRecord : global::System.IEquatable + { + /// + public bool Equals(global::Equatable.Entities.ConcreteRecord? other) + { + return !(other is null) + && Rank == other.Rank + && Id == other.Id + && global::System.Collections.Generic.EqualityComparer.Default.Equals(Name, other.Name); + + } + + /// + public override bool Equals(object? obj) + { + return Equals(obj as global::Equatable.Entities.ConcreteRecord); + } + + /// + public static bool operator ==(global::Equatable.Entities.ConcreteRecord? left, global::Equatable.Entities.ConcreteRecord? right) + { + return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); + } + + /// + public static bool operator !=(global::Equatable.Entities.ConcreteRecord? left, global::Equatable.Entities.ConcreteRecord? right) + { + return !(left == right); + } + + /// + public override int GetHashCode(){ + int hashCode = 493275453; + hashCode = (hashCode * -1521134295) + Rank.GetHashCode(); + hashCode = (hashCode * -1521134295) + Id.GetHashCode(); + hashCode = (hashCode * -1521134295) + global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(Name!); + return hashCode; + + } + + } +} diff --git a/test/Equatable.Generator.Tests/Snapshots/AdapterGeneratorTest.GenerateDataContractEquatableWithInferredCollectionComparers.verified.txt b/test/Equatable.Generator.Tests/Snapshots/AdapterGeneratorTest.GenerateDataContractEquatableWithInferredCollectionComparers.verified.txt new file mode 100644 index 0000000..034c428 --- /dev/null +++ b/test/Equatable.Generator.Tests/Snapshots/AdapterGeneratorTest.GenerateDataContractEquatableWithInferredCollectionComparers.verified.txt @@ -0,0 +1,51 @@ +// +#nullable enable + +namespace Equatable.Entities +{ + partial class ContractWithCollections : global::System.IEquatable + { + /// + public bool Equals(global::Equatable.Entities.ContractWithCollections? other) + { + return !(other is null) + && Id == other.Id + && (new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default)).Equals(Tags, other.Tags) + && (global::Equatable.Comparers.SequenceEqualityComparer.Default).Equals(Labels, other.Labels) + && (global::Equatable.Comparers.SequenceEqualityComparer.Default).Equals(Codes, other.Codes) + && (new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default)).Equals(Rates, other.Rates); + + } + + /// + public override bool Equals(object? obj) + { + return Equals(obj as global::Equatable.Entities.ContractWithCollections); + } + + /// + public static bool operator ==(global::Equatable.Entities.ContractWithCollections? left, global::Equatable.Entities.ContractWithCollections? right) + { + return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); + } + + /// + public static bool operator !=(global::Equatable.Entities.ContractWithCollections? left, global::Equatable.Entities.ContractWithCollections? right) + { + return !(left == right); + } + + /// + public override int GetHashCode(){ + int hashCode = -1662182381; + hashCode = (hashCode * -1521134295) + Id.GetHashCode(); + hashCode = (hashCode * -1521134295) + (new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default)).GetHashCode(Tags!); + hashCode = (hashCode * -1521134295) + (global::Equatable.Comparers.SequenceEqualityComparer.Default).GetHashCode(Labels!); + hashCode = (hashCode * -1521134295) + (global::Equatable.Comparers.SequenceEqualityComparer.Default).GetHashCode(Codes!); + hashCode = (hashCode * -1521134295) + (new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default)).GetHashCode(Rates!); + return hashCode; + + } + + } +} diff --git a/test/Equatable.Generator.Tests/Snapshots/AdapterGeneratorTest.GenerateDataContractEquatableWithNoIncludedProperties.verified.txt b/test/Equatable.Generator.Tests/Snapshots/AdapterGeneratorTest.GenerateDataContractEquatableWithNoIncludedProperties.verified.txt new file mode 100644 index 0000000..e4b355d --- /dev/null +++ b/test/Equatable.Generator.Tests/Snapshots/AdapterGeneratorTest.GenerateDataContractEquatableWithNoIncludedProperties.verified.txt @@ -0,0 +1,41 @@ +// +#nullable enable + +namespace Equatable.Entities +{ + partial class AllIgnored : global::System.IEquatable + { + /// + public bool Equals(global::Equatable.Entities.AllIgnored? other) + { + return !(other is null); + + } + + /// + public override bool Equals(object? obj) + { + return Equals(obj as global::Equatable.Entities.AllIgnored); + } + + /// + public static bool operator ==(global::Equatable.Entities.AllIgnored? left, global::Equatable.Entities.AllIgnored? right) + { + return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); + } + + /// + public static bool operator !=(global::Equatable.Entities.AllIgnored? left, global::Equatable.Entities.AllIgnored? right) + { + return !(left == right); + } + + /// + public override int GetHashCode(){ + int hashCode = 0; + return hashCode; + + } + + } +} diff --git a/test/Equatable.Generator.Tests/Snapshots/AdapterGeneratorTest.GenerateDataContractEquatableWithOrderedDictionaryOverride.verified.txt b/test/Equatable.Generator.Tests/Snapshots/AdapterGeneratorTest.GenerateDataContractEquatableWithOrderedDictionaryOverride.verified.txt new file mode 100644 index 0000000..1c5c671 --- /dev/null +++ b/test/Equatable.Generator.Tests/Snapshots/AdapterGeneratorTest.GenerateDataContractEquatableWithOrderedDictionaryOverride.verified.txt @@ -0,0 +1,45 @@ +// +#nullable enable + +namespace Equatable.Entities +{ + partial class OrderedContract : global::System.IEquatable + { + /// + public bool Equals(global::Equatable.Entities.OrderedContract? other) + { + return !(other is null) + && Id == other.Id + && (new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default)).Equals(Tags, other.Tags); + + } + + /// + public override bool Equals(object? obj) + { + return Equals(obj as global::Equatable.Entities.OrderedContract); + } + + /// + public static bool operator ==(global::Equatable.Entities.OrderedContract? left, global::Equatable.Entities.OrderedContract? right) + { + return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); + } + + /// + public static bool operator !=(global::Equatable.Entities.OrderedContract? left, global::Equatable.Entities.OrderedContract? right) + { + return !(left == right); + } + + /// + public override int GetHashCode(){ + int hashCode = -193969728; + hashCode = (hashCode * -1521134295) + Id.GetHashCode(); + hashCode = (hashCode * -1521134295) + (new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default)).GetHashCode(Tags!); + return hashCode; + + } + + } +} diff --git a/test/Equatable.Generator.Tests/Snapshots/AdapterGeneratorTest.GenerateMessagePackEquatable.verified.txt b/test/Equatable.Generator.Tests/Snapshots/AdapterGeneratorTest.GenerateMessagePackEquatable.verified.txt new file mode 100644 index 0000000..3cb0954 --- /dev/null +++ b/test/Equatable.Generator.Tests/Snapshots/AdapterGeneratorTest.GenerateMessagePackEquatable.verified.txt @@ -0,0 +1,45 @@ +// +#nullable enable + +namespace Equatable.Entities +{ + partial class PricingContract : global::System.IEquatable + { + /// + public bool Equals(global::Equatable.Entities.PricingContract? other) + { + return !(other is null) + && MarketId == other.MarketId + && Probability == other.Probability; + + } + + /// + public override bool Equals(object? obj) + { + return Equals(obj as global::Equatable.Entities.PricingContract); + } + + /// + public static bool operator ==(global::Equatable.Entities.PricingContract? left, global::Equatable.Entities.PricingContract? right) + { + return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); + } + + /// + public static bool operator !=(global::Equatable.Entities.PricingContract? left, global::Equatable.Entities.PricingContract? right) + { + return !(left == right); + } + + /// + public override int GetHashCode(){ + int hashCode = -1121495104; + hashCode = (hashCode * -1521134295) + MarketId.GetHashCode(); + hashCode = (hashCode * -1521134295) + Probability.GetHashCode(); + return hashCode; + + } + + } +} diff --git a/test/Equatable.Generator.Tests/Snapshots/AdapterGeneratorTest.GenerateMessagePackEquatableDerivedIncludesUnannotatedBase.verified.txt b/test/Equatable.Generator.Tests/Snapshots/AdapterGeneratorTest.GenerateMessagePackEquatableDerivedIncludesUnannotatedBase.verified.txt new file mode 100644 index 0000000..8e8fb62 --- /dev/null +++ b/test/Equatable.Generator.Tests/Snapshots/AdapterGeneratorTest.GenerateMessagePackEquatableDerivedIncludesUnannotatedBase.verified.txt @@ -0,0 +1,47 @@ +// +#nullable enable + +namespace Equatable.Entities +{ + partial class ConcreteRecord : global::System.IEquatable + { + /// + public bool Equals(global::Equatable.Entities.ConcreteRecord? other) + { + return !(other is null) + && global::System.Collections.Generic.EqualityComparer.Default.Equals(Label, other.Label) + && Id == other.Id + && Score == other.Score; + + } + + /// + public override bool Equals(object? obj) + { + return Equals(obj as global::Equatable.Entities.ConcreteRecord); + } + + /// + public static bool operator ==(global::Equatable.Entities.ConcreteRecord? left, global::Equatable.Entities.ConcreteRecord? right) + { + return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); + } + + /// + public static bool operator !=(global::Equatable.Entities.ConcreteRecord? left, global::Equatable.Entities.ConcreteRecord? right) + { + return !(left == right); + } + + /// + public override int GetHashCode(){ + int hashCode = 242241058; + hashCode = (hashCode * -1521134295) + global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(Label!); + hashCode = (hashCode * -1521134295) + Id.GetHashCode(); + hashCode = (hashCode * -1521134295) + Score.GetHashCode(); + return hashCode; + + } + + } +} diff --git a/test/Equatable.Generator.Tests/Snapshots/AdapterGeneratorTest.GenerateMessagePackEquatableWithInferredCollectionComparers.verified.txt b/test/Equatable.Generator.Tests/Snapshots/AdapterGeneratorTest.GenerateMessagePackEquatableWithInferredCollectionComparers.verified.txt new file mode 100644 index 0000000..8d0efa4 --- /dev/null +++ b/test/Equatable.Generator.Tests/Snapshots/AdapterGeneratorTest.GenerateMessagePackEquatableWithInferredCollectionComparers.verified.txt @@ -0,0 +1,51 @@ +// +#nullable enable + +namespace Equatable.Entities +{ + partial class PackedWithCollections : global::System.IEquatable + { + /// + public bool Equals(global::Equatable.Entities.PackedWithCollections? other) + { + return !(other is null) + && Id == other.Id + && (new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default)).Equals(Tags, other.Tags) + && (global::Equatable.Comparers.SequenceEqualityComparer.Default).Equals(Labels, other.Labels) + && (global::Equatable.Comparers.SequenceEqualityComparer.Default).Equals(Codes, other.Codes) + && (new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default)).Equals(Rates, other.Rates); + + } + + /// + public override bool Equals(object? obj) + { + return Equals(obj as global::Equatable.Entities.PackedWithCollections); + } + + /// + public static bool operator ==(global::Equatable.Entities.PackedWithCollections? left, global::Equatable.Entities.PackedWithCollections? right) + { + return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); + } + + /// + public static bool operator !=(global::Equatable.Entities.PackedWithCollections? left, global::Equatable.Entities.PackedWithCollections? right) + { + return !(left == right); + } + + /// + public override int GetHashCode(){ + int hashCode = -1662182381; + hashCode = (hashCode * -1521134295) + Id.GetHashCode(); + hashCode = (hashCode * -1521134295) + (new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default)).GetHashCode(Tags!); + hashCode = (hashCode * -1521134295) + (global::Equatable.Comparers.SequenceEqualityComparer.Default).GetHashCode(Labels!); + hashCode = (hashCode * -1521134295) + (global::Equatable.Comparers.SequenceEqualityComparer.Default).GetHashCode(Codes!); + hashCode = (hashCode * -1521134295) + (new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default)).GetHashCode(Rates!); + return hashCode; + + } + + } +} diff --git a/test/Equatable.Generator.Tests/Snapshots/AdapterGeneratorTest.GenerateMessagePackEquatableWithNoIncludedProperties.verified.txt b/test/Equatable.Generator.Tests/Snapshots/AdapterGeneratorTest.GenerateMessagePackEquatableWithNoIncludedProperties.verified.txt new file mode 100644 index 0000000..e4b355d --- /dev/null +++ b/test/Equatable.Generator.Tests/Snapshots/AdapterGeneratorTest.GenerateMessagePackEquatableWithNoIncludedProperties.verified.txt @@ -0,0 +1,41 @@ +// +#nullable enable + +namespace Equatable.Entities +{ + partial class AllIgnored : global::System.IEquatable + { + /// + public bool Equals(global::Equatable.Entities.AllIgnored? other) + { + return !(other is null); + + } + + /// + public override bool Equals(object? obj) + { + return Equals(obj as global::Equatable.Entities.AllIgnored); + } + + /// + public static bool operator ==(global::Equatable.Entities.AllIgnored? left, global::Equatable.Entities.AllIgnored? right) + { + return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); + } + + /// + public static bool operator !=(global::Equatable.Entities.AllIgnored? left, global::Equatable.Entities.AllIgnored? right) + { + return !(left == right); + } + + /// + public override int GetHashCode(){ + int hashCode = 0; + return hashCode; + + } + + } +} diff --git a/test/Equatable.Generator.Tests/Snapshots/AdapterGeneratorTest.GenerateMessagePackEquatableWithOrderedDictionaryOverride.verified.txt b/test/Equatable.Generator.Tests/Snapshots/AdapterGeneratorTest.GenerateMessagePackEquatableWithOrderedDictionaryOverride.verified.txt new file mode 100644 index 0000000..4bc235b --- /dev/null +++ b/test/Equatable.Generator.Tests/Snapshots/AdapterGeneratorTest.GenerateMessagePackEquatableWithOrderedDictionaryOverride.verified.txt @@ -0,0 +1,45 @@ +// +#nullable enable + +namespace Equatable.Entities +{ + partial class OrderedPackedContract : global::System.IEquatable + { + /// + public bool Equals(global::Equatable.Entities.OrderedPackedContract? other) + { + return !(other is null) + && Id == other.Id + && (new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default)).Equals(Tags, other.Tags); + + } + + /// + public override bool Equals(object? obj) + { + return Equals(obj as global::Equatable.Entities.OrderedPackedContract); + } + + /// + public static bool operator ==(global::Equatable.Entities.OrderedPackedContract? left, global::Equatable.Entities.OrderedPackedContract? right) + { + return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); + } + + /// + public static bool operator !=(global::Equatable.Entities.OrderedPackedContract? left, global::Equatable.Entities.OrderedPackedContract? right) + { + return !(left == right); + } + + /// + public override int GetHashCode(){ + int hashCode = -193969728; + hashCode = (hashCode * -1521134295) + Id.GetHashCode(); + hashCode = (hashCode * -1521134295) + (new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default)).GetHashCode(Tags!); + return hashCode; + + } + + } +} diff --git a/test/Equatable.Generator.Tests/Snapshots/DataContractGeneratorTest.GenerateDataContractEquatable.verified.txt b/test/Equatable.Generator.Tests/Snapshots/DataContractGeneratorTest.GenerateDataContractEquatable.verified.txt new file mode 100644 index 0000000..d7fe908 --- /dev/null +++ b/test/Equatable.Generator.Tests/Snapshots/DataContractGeneratorTest.GenerateDataContractEquatable.verified.txt @@ -0,0 +1,45 @@ +// +#nullable enable + +namespace Equatable.Entities +{ + partial class OrderDataContract : global::System.IEquatable + { + /// + public bool Equals(global::Equatable.Entities.OrderDataContract? other) + { + return !(other is null) + && Id == other.Id + && global::System.Collections.Generic.EqualityComparer.Default.Equals(Name, other.Name); + + } + + /// + public override bool Equals(object? obj) + { + return Equals(obj as global::Equatable.Entities.OrderDataContract); + } + + /// + public static bool operator ==(global::Equatable.Entities.OrderDataContract? left, global::Equatable.Entities.OrderDataContract? right) + { + return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); + } + + /// + public static bool operator !=(global::Equatable.Entities.OrderDataContract? left, global::Equatable.Entities.OrderDataContract? right) + { + return !(left == right); + } + + /// + public override int GetHashCode(){ + int hashCode = -1919740922; + hashCode = (hashCode * -1521134295) + Id.GetHashCode(); + hashCode = (hashCode * -1521134295) + global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(Name!); + return hashCode; + + } + + } +} diff --git a/test/Equatable.Generator.Tests/Snapshots/DataContractGeneratorTest.GenerateDataContractEquatableDerivedFromDataContractEquatableBase.verified.txt b/test/Equatable.Generator.Tests/Snapshots/DataContractGeneratorTest.GenerateDataContractEquatableDerivedFromDataContractEquatableBase.verified.txt new file mode 100644 index 0000000..c07dab0 --- /dev/null +++ b/test/Equatable.Generator.Tests/Snapshots/DataContractGeneratorTest.GenerateDataContractEquatableDerivedFromDataContractEquatableBase.verified.txt @@ -0,0 +1,45 @@ +// +#nullable enable + +namespace Equatable.Entities +{ + partial class DerivedContract : global::System.IEquatable + { + /// + public bool Equals(global::Equatable.Entities.DerivedContract? other) + { + return !(other is null) + && base.Equals(other) + && Rank == other.Rank; + + } + + /// + public override bool Equals(object? obj) + { + return Equals(obj as global::Equatable.Entities.DerivedContract); + } + + /// + public static bool operator ==(global::Equatable.Entities.DerivedContract? left, global::Equatable.Entities.DerivedContract? right) + { + return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); + } + + /// + public static bool operator !=(global::Equatable.Entities.DerivedContract? left, global::Equatable.Entities.DerivedContract? right) + { + return !(left == right); + } + + /// + public override int GetHashCode(){ + int hashCode = -2095922015; + hashCode = (hashCode * -1521134295) + base.GetHashCode(); + hashCode = (hashCode * -1521134295) + Rank.GetHashCode(); + return hashCode; + + } + + } +} diff --git a/test/Equatable.Generator.Tests/Snapshots/DataContractGeneratorTest.GenerateDataContractEquatableDerivedIncludesUnannotatedBase.verified.txt b/test/Equatable.Generator.Tests/Snapshots/DataContractGeneratorTest.GenerateDataContractEquatableDerivedIncludesUnannotatedBase.verified.txt new file mode 100644 index 0000000..43258cd --- /dev/null +++ b/test/Equatable.Generator.Tests/Snapshots/DataContractGeneratorTest.GenerateDataContractEquatableDerivedIncludesUnannotatedBase.verified.txt @@ -0,0 +1,47 @@ +// +#nullable enable + +namespace Equatable.Entities +{ + partial class ConcreteRecord : global::System.IEquatable + { + /// + public bool Equals(global::Equatable.Entities.ConcreteRecord? other) + { + return !(other is null) + && Rank == other.Rank + && Id == other.Id + && global::System.Collections.Generic.EqualityComparer.Default.Equals(Name, other.Name); + + } + + /// + public override bool Equals(object? obj) + { + return Equals(obj as global::Equatable.Entities.ConcreteRecord); + } + + /// + public static bool operator ==(global::Equatable.Entities.ConcreteRecord? left, global::Equatable.Entities.ConcreteRecord? right) + { + return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); + } + + /// + public static bool operator !=(global::Equatable.Entities.ConcreteRecord? left, global::Equatable.Entities.ConcreteRecord? right) + { + return !(left == right); + } + + /// + public override int GetHashCode(){ + int hashCode = 493275453; + hashCode = (hashCode * -1521134295) + Rank.GetHashCode(); + hashCode = (hashCode * -1521134295) + Id.GetHashCode(); + hashCode = (hashCode * -1521134295) + global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(Name!); + return hashCode; + + } + + } +} diff --git a/test/Equatable.Generator.Tests/Snapshots/DataContractGeneratorTest.GenerateDataContractEquatableWithInferredCollectionComparers.verified.txt b/test/Equatable.Generator.Tests/Snapshots/DataContractGeneratorTest.GenerateDataContractEquatableWithInferredCollectionComparers.verified.txt new file mode 100644 index 0000000..034c428 --- /dev/null +++ b/test/Equatable.Generator.Tests/Snapshots/DataContractGeneratorTest.GenerateDataContractEquatableWithInferredCollectionComparers.verified.txt @@ -0,0 +1,51 @@ +// +#nullable enable + +namespace Equatable.Entities +{ + partial class ContractWithCollections : global::System.IEquatable + { + /// + public bool Equals(global::Equatable.Entities.ContractWithCollections? other) + { + return !(other is null) + && Id == other.Id + && (new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default)).Equals(Tags, other.Tags) + && (global::Equatable.Comparers.SequenceEqualityComparer.Default).Equals(Labels, other.Labels) + && (global::Equatable.Comparers.SequenceEqualityComparer.Default).Equals(Codes, other.Codes) + && (new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default)).Equals(Rates, other.Rates); + + } + + /// + public override bool Equals(object? obj) + { + return Equals(obj as global::Equatable.Entities.ContractWithCollections); + } + + /// + public static bool operator ==(global::Equatable.Entities.ContractWithCollections? left, global::Equatable.Entities.ContractWithCollections? right) + { + return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); + } + + /// + public static bool operator !=(global::Equatable.Entities.ContractWithCollections? left, global::Equatable.Entities.ContractWithCollections? right) + { + return !(left == right); + } + + /// + public override int GetHashCode(){ + int hashCode = -1662182381; + hashCode = (hashCode * -1521134295) + Id.GetHashCode(); + hashCode = (hashCode * -1521134295) + (new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default)).GetHashCode(Tags!); + hashCode = (hashCode * -1521134295) + (global::Equatable.Comparers.SequenceEqualityComparer.Default).GetHashCode(Labels!); + hashCode = (hashCode * -1521134295) + (global::Equatable.Comparers.SequenceEqualityComparer.Default).GetHashCode(Codes!); + hashCode = (hashCode * -1521134295) + (new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default)).GetHashCode(Rates!); + return hashCode; + + } + + } +} diff --git a/test/Equatable.Generator.Tests/Snapshots/DataContractGeneratorTest.GenerateDataContractEquatableWithNestedCollectionComparers.verified.txt b/test/Equatable.Generator.Tests/Snapshots/DataContractGeneratorTest.GenerateDataContractEquatableWithNestedCollectionComparers.verified.txt new file mode 100644 index 0000000..e39793b --- /dev/null +++ b/test/Equatable.Generator.Tests/Snapshots/DataContractGeneratorTest.GenerateDataContractEquatableWithNestedCollectionComparers.verified.txt @@ -0,0 +1,51 @@ +// +#nullable enable + +namespace Equatable.Entities +{ + partial class ContractWithNestedCollections : global::System.IEquatable + { + /// + public bool Equals(global::Equatable.Entities.ContractWithNestedCollections? other) + { + return !(other is null) + && Id == other.Id + && (new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer>(global::System.Collections.Generic.EqualityComparer.Default, global::Equatable.Comparers.SequenceEqualityComparer.Default)).Equals(TagGroups, other.TagGroups) + && (new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer>(global::System.Collections.Generic.EqualityComparer.Default, new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default))).Equals(NestedMap, other.NestedMap) + && (new global::Equatable.Comparers.SequenceEqualityComparer>(new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default))).Equals(Records, other.Records) + && (new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer>(global::System.Collections.Generic.EqualityComparer.Default, global::Equatable.Comparers.SequenceEqualityComparer.Default)).Equals(ReadOnlyTagGroups, other.ReadOnlyTagGroups); + + } + + /// + public override bool Equals(object? obj) + { + return Equals(obj as global::Equatable.Entities.ContractWithNestedCollections); + } + + /// + public static bool operator ==(global::Equatable.Entities.ContractWithNestedCollections? left, global::Equatable.Entities.ContractWithNestedCollections? right) + { + return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); + } + + /// + public static bool operator !=(global::Equatable.Entities.ContractWithNestedCollections? left, global::Equatable.Entities.ContractWithNestedCollections? right) + { + return !(left == right); + } + + /// + public override int GetHashCode(){ + int hashCode = -1611759231; + hashCode = (hashCode * -1521134295) + Id.GetHashCode(); + hashCode = (hashCode * -1521134295) + (new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer>(global::System.Collections.Generic.EqualityComparer.Default, global::Equatable.Comparers.SequenceEqualityComparer.Default)).GetHashCode(TagGroups!); + hashCode = (hashCode * -1521134295) + (new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer>(global::System.Collections.Generic.EqualityComparer.Default, new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default))).GetHashCode(NestedMap!); + hashCode = (hashCode * -1521134295) + (new global::Equatable.Comparers.SequenceEqualityComparer>(new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default))).GetHashCode(Records!); + hashCode = (hashCode * -1521134295) + (new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer>(global::System.Collections.Generic.EqualityComparer.Default, global::Equatable.Comparers.SequenceEqualityComparer.Default)).GetHashCode(ReadOnlyTagGroups!); + return hashCode; + + } + + } +} diff --git a/test/Equatable.Generator.Tests/Snapshots/DataContractGeneratorTest.GenerateDataContractEquatableWithNoIncludedProperties.verified.txt b/test/Equatable.Generator.Tests/Snapshots/DataContractGeneratorTest.GenerateDataContractEquatableWithNoIncludedProperties.verified.txt new file mode 100644 index 0000000..e4b355d --- /dev/null +++ b/test/Equatable.Generator.Tests/Snapshots/DataContractGeneratorTest.GenerateDataContractEquatableWithNoIncludedProperties.verified.txt @@ -0,0 +1,41 @@ +// +#nullable enable + +namespace Equatable.Entities +{ + partial class AllIgnored : global::System.IEquatable + { + /// + public bool Equals(global::Equatable.Entities.AllIgnored? other) + { + return !(other is null); + + } + + /// + public override bool Equals(object? obj) + { + return Equals(obj as global::Equatable.Entities.AllIgnored); + } + + /// + public static bool operator ==(global::Equatable.Entities.AllIgnored? left, global::Equatable.Entities.AllIgnored? right) + { + return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); + } + + /// + public static bool operator !=(global::Equatable.Entities.AllIgnored? left, global::Equatable.Entities.AllIgnored? right) + { + return !(left == right); + } + + /// + public override int GetHashCode(){ + int hashCode = 0; + return hashCode; + + } + + } +} diff --git a/test/Equatable.Generator.Tests/Snapshots/DataContractGeneratorTest.GenerateDataContractEquatableWithOrderedDictionaryOverride.verified.txt b/test/Equatable.Generator.Tests/Snapshots/DataContractGeneratorTest.GenerateDataContractEquatableWithOrderedDictionaryOverride.verified.txt new file mode 100644 index 0000000..1c5c671 --- /dev/null +++ b/test/Equatable.Generator.Tests/Snapshots/DataContractGeneratorTest.GenerateDataContractEquatableWithOrderedDictionaryOverride.verified.txt @@ -0,0 +1,45 @@ +// +#nullable enable + +namespace Equatable.Entities +{ + partial class OrderedContract : global::System.IEquatable + { + /// + public bool Equals(global::Equatable.Entities.OrderedContract? other) + { + return !(other is null) + && Id == other.Id + && (new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default)).Equals(Tags, other.Tags); + + } + + /// + public override bool Equals(object? obj) + { + return Equals(obj as global::Equatable.Entities.OrderedContract); + } + + /// + public static bool operator ==(global::Equatable.Entities.OrderedContract? left, global::Equatable.Entities.OrderedContract? right) + { + return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); + } + + /// + public static bool operator !=(global::Equatable.Entities.OrderedContract? left, global::Equatable.Entities.OrderedContract? right) + { + return !(left == right); + } + + /// + public override int GetHashCode(){ + int hashCode = -193969728; + hashCode = (hashCode * -1521134295) + Id.GetHashCode(); + hashCode = (hashCode * -1521134295) + (new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default)).GetHashCode(Tags!); + return hashCode; + + } + + } +} diff --git a/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateDataContractEquatableWithInferredCollectionComparers.verified.txt b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateDataContractEquatableWithInferredCollectionComparers.verified.txt new file mode 100644 index 0000000..034c428 --- /dev/null +++ b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateDataContractEquatableWithInferredCollectionComparers.verified.txt @@ -0,0 +1,51 @@ +// +#nullable enable + +namespace Equatable.Entities +{ + partial class ContractWithCollections : global::System.IEquatable + { + /// + public bool Equals(global::Equatable.Entities.ContractWithCollections? other) + { + return !(other is null) + && Id == other.Id + && (new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default)).Equals(Tags, other.Tags) + && (global::Equatable.Comparers.SequenceEqualityComparer.Default).Equals(Labels, other.Labels) + && (global::Equatable.Comparers.SequenceEqualityComparer.Default).Equals(Codes, other.Codes) + && (new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default)).Equals(Rates, other.Rates); + + } + + /// + public override bool Equals(object? obj) + { + return Equals(obj as global::Equatable.Entities.ContractWithCollections); + } + + /// + public static bool operator ==(global::Equatable.Entities.ContractWithCollections? left, global::Equatable.Entities.ContractWithCollections? right) + { + return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); + } + + /// + public static bool operator !=(global::Equatable.Entities.ContractWithCollections? left, global::Equatable.Entities.ContractWithCollections? right) + { + return !(left == right); + } + + /// + public override int GetHashCode(){ + int hashCode = -1662182381; + hashCode = (hashCode * -1521134295) + Id.GetHashCode(); + hashCode = (hashCode * -1521134295) + (new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default)).GetHashCode(Tags!); + hashCode = (hashCode * -1521134295) + (global::Equatable.Comparers.SequenceEqualityComparer.Default).GetHashCode(Labels!); + hashCode = (hashCode * -1521134295) + (global::Equatable.Comparers.SequenceEqualityComparer.Default).GetHashCode(Codes!); + hashCode = (hashCode * -1521134295) + (new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default)).GetHashCode(Rates!); + return hashCode; + + } + + } +} diff --git a/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateDataContractEquatableWithNoIncludedProperties.verified.txt b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateDataContractEquatableWithNoIncludedProperties.verified.txt new file mode 100644 index 0000000..e4b355d --- /dev/null +++ b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateDataContractEquatableWithNoIncludedProperties.verified.txt @@ -0,0 +1,41 @@ +// +#nullable enable + +namespace Equatable.Entities +{ + partial class AllIgnored : global::System.IEquatable + { + /// + public bool Equals(global::Equatable.Entities.AllIgnored? other) + { + return !(other is null); + + } + + /// + public override bool Equals(object? obj) + { + return Equals(obj as global::Equatable.Entities.AllIgnored); + } + + /// + public static bool operator ==(global::Equatable.Entities.AllIgnored? left, global::Equatable.Entities.AllIgnored? right) + { + return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); + } + + /// + public static bool operator !=(global::Equatable.Entities.AllIgnored? left, global::Equatable.Entities.AllIgnored? right) + { + return !(left == right); + } + + /// + public override int GetHashCode(){ + int hashCode = 0; + return hashCode; + + } + + } +} diff --git a/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateDataContractEquatableWithOrderedDictionaryOverride.verified.txt b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateDataContractEquatableWithOrderedDictionaryOverride.verified.txt new file mode 100644 index 0000000..1c5c671 --- /dev/null +++ b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateDataContractEquatableWithOrderedDictionaryOverride.verified.txt @@ -0,0 +1,45 @@ +// +#nullable enable + +namespace Equatable.Entities +{ + partial class OrderedContract : global::System.IEquatable + { + /// + public bool Equals(global::Equatable.Entities.OrderedContract? other) + { + return !(other is null) + && Id == other.Id + && (new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default)).Equals(Tags, other.Tags); + + } + + /// + public override bool Equals(object? obj) + { + return Equals(obj as global::Equatable.Entities.OrderedContract); + } + + /// + public static bool operator ==(global::Equatable.Entities.OrderedContract? left, global::Equatable.Entities.OrderedContract? right) + { + return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); + } + + /// + public static bool operator !=(global::Equatable.Entities.OrderedContract? left, global::Equatable.Entities.OrderedContract? right) + { + return !(left == right); + } + + /// + public override int GetHashCode(){ + int hashCode = -193969728; + hashCode = (hashCode * -1521134295) + Id.GetHashCode(); + hashCode = (hashCode * -1521134295) + (new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default)).GetHashCode(Tags!); + return hashCode; + + } + + } +} diff --git a/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateEquatableWithNoPublicProperties.verified.txt b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateEquatableWithNoPublicProperties.verified.txt new file mode 100644 index 0000000..52872b9 --- /dev/null +++ b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateEquatableWithNoPublicProperties.verified.txt @@ -0,0 +1,41 @@ +// +#nullable enable + +namespace Equatable.Entities +{ + partial class Empty : global::System.IEquatable + { + /// + public bool Equals(global::Equatable.Entities.Empty? other) + { + return !(other is null); + + } + + /// + public override bool Equals(object? obj) + { + return Equals(obj as global::Equatable.Entities.Empty); + } + + /// + public static bool operator ==(global::Equatable.Entities.Empty? left, global::Equatable.Entities.Empty? right) + { + return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); + } + + /// + public static bool operator !=(global::Equatable.Entities.Empty? left, global::Equatable.Entities.Empty? right) + { + return !(left == right); + } + + /// + public override int GetHashCode(){ + int hashCode = 0; + return hashCode; + + } + + } +} diff --git a/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateIDictionaryEquality.verified.txt b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateIDictionaryEquality.verified.txt index 888d62c..ea79ec8 100644 --- a/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateIDictionaryEquality.verified.txt +++ b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateIDictionaryEquality.verified.txt @@ -87,7 +87,7 @@ namespace Equatable.Entities if (items is null) return 0; - int hashCode = 0; + int hashCode = 1; foreach (var item in items) hashCode += global::System.HashCode.Combine( diff --git a/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateISetHashSetEquality.verified.txt b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateISetHashSetEquality.verified.txt index 4dbb0be..2d7356a 100644 --- a/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateISetHashSetEquality.verified.txt +++ b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateISetHashSetEquality.verified.txt @@ -60,7 +60,7 @@ namespace Equatable.Entities if (items is null) return 0; - int hashCode = 0; + int hashCode = 1; foreach (var item in items) hashCode += global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(item!); diff --git a/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateMessagePackEquatableWithInferredCollectionComparers.verified.txt b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateMessagePackEquatableWithInferredCollectionComparers.verified.txt new file mode 100644 index 0000000..8d0efa4 --- /dev/null +++ b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateMessagePackEquatableWithInferredCollectionComparers.verified.txt @@ -0,0 +1,51 @@ +// +#nullable enable + +namespace Equatable.Entities +{ + partial class PackedWithCollections : global::System.IEquatable + { + /// + public bool Equals(global::Equatable.Entities.PackedWithCollections? other) + { + return !(other is null) + && Id == other.Id + && (new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default)).Equals(Tags, other.Tags) + && (global::Equatable.Comparers.SequenceEqualityComparer.Default).Equals(Labels, other.Labels) + && (global::Equatable.Comparers.SequenceEqualityComparer.Default).Equals(Codes, other.Codes) + && (new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default)).Equals(Rates, other.Rates); + + } + + /// + public override bool Equals(object? obj) + { + return Equals(obj as global::Equatable.Entities.PackedWithCollections); + } + + /// + public static bool operator ==(global::Equatable.Entities.PackedWithCollections? left, global::Equatable.Entities.PackedWithCollections? right) + { + return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); + } + + /// + public static bool operator !=(global::Equatable.Entities.PackedWithCollections? left, global::Equatable.Entities.PackedWithCollections? right) + { + return !(left == right); + } + + /// + public override int GetHashCode(){ + int hashCode = -1662182381; + hashCode = (hashCode * -1521134295) + Id.GetHashCode(); + hashCode = (hashCode * -1521134295) + (new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default)).GetHashCode(Tags!); + hashCode = (hashCode * -1521134295) + (global::Equatable.Comparers.SequenceEqualityComparer.Default).GetHashCode(Labels!); + hashCode = (hashCode * -1521134295) + (global::Equatable.Comparers.SequenceEqualityComparer.Default).GetHashCode(Codes!); + hashCode = (hashCode * -1521134295) + (new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default)).GetHashCode(Rates!); + return hashCode; + + } + + } +} diff --git a/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateMessagePackEquatableWithNoIncludedProperties.verified.txt b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateMessagePackEquatableWithNoIncludedProperties.verified.txt new file mode 100644 index 0000000..e4b355d --- /dev/null +++ b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateMessagePackEquatableWithNoIncludedProperties.verified.txt @@ -0,0 +1,41 @@ +// +#nullable enable + +namespace Equatable.Entities +{ + partial class AllIgnored : global::System.IEquatable + { + /// + public bool Equals(global::Equatable.Entities.AllIgnored? other) + { + return !(other is null); + + } + + /// + public override bool Equals(object? obj) + { + return Equals(obj as global::Equatable.Entities.AllIgnored); + } + + /// + public static bool operator ==(global::Equatable.Entities.AllIgnored? left, global::Equatable.Entities.AllIgnored? right) + { + return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); + } + + /// + public static bool operator !=(global::Equatable.Entities.AllIgnored? left, global::Equatable.Entities.AllIgnored? right) + { + return !(left == right); + } + + /// + public override int GetHashCode(){ + int hashCode = 0; + return hashCode; + + } + + } +} diff --git a/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateMessagePackEquatableWithOrderedDictionaryOverride.verified.txt b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateMessagePackEquatableWithOrderedDictionaryOverride.verified.txt new file mode 100644 index 0000000..4bc235b --- /dev/null +++ b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateMessagePackEquatableWithOrderedDictionaryOverride.verified.txt @@ -0,0 +1,45 @@ +// +#nullable enable + +namespace Equatable.Entities +{ + partial class OrderedPackedContract : global::System.IEquatable + { + /// + public bool Equals(global::Equatable.Entities.OrderedPackedContract? other) + { + return !(other is null) + && Id == other.Id + && (new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default)).Equals(Tags, other.Tags); + + } + + /// + public override bool Equals(object? obj) + { + return Equals(obj as global::Equatable.Entities.OrderedPackedContract); + } + + /// + public static bool operator ==(global::Equatable.Entities.OrderedPackedContract? left, global::Equatable.Entities.OrderedPackedContract? right) + { + return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); + } + + /// + public static bool operator !=(global::Equatable.Entities.OrderedPackedContract? left, global::Equatable.Entities.OrderedPackedContract? right) + { + return !(left == right); + } + + /// + public override int GetHashCode(){ + int hashCode = -193969728; + hashCode = (hashCode * -1521134295) + Id.GetHashCode(); + hashCode = (hashCode * -1521134295) + (new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default)).GetHashCode(Tags!); + return hashCode; + + } + + } +} diff --git a/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateReadOnlyDictionary.verified.txt b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateReadOnlyDictionary.verified.txt index 2f7aeee..e7d6295 100644 --- a/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateReadOnlyDictionary.verified.txt +++ b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateReadOnlyDictionary.verified.txt @@ -87,7 +87,7 @@ namespace Equatable.Entities if (items is null) return 0; - int hashCode = 0; + int hashCode = 1; foreach (var item in items) hashCode += global::System.HashCode.Combine( diff --git a/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateUserImport.verified.txt b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateUserImport.verified.txt index 1d613b8..853bdc5 100644 --- a/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateUserImport.verified.txt +++ b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateUserImport.verified.txt @@ -132,7 +132,7 @@ namespace Equatable.Entities if (items is null) return 0; - int hashCode = 0; + int hashCode = 1; foreach (var item in items) hashCode += global::System.HashCode.Combine( @@ -147,7 +147,7 @@ namespace Equatable.Entities if (items is null) return 0; - int hashCode = 0; + int hashCode = 1; foreach (var item in items) hashCode += global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(item!); diff --git a/test/Equatable.Generator.Tests/Snapshots/EquatableWriterTest.GenerateDerived_DelegatesBaseEqualsAndHashCode.verified.txt b/test/Equatable.Generator.Tests/Snapshots/EquatableWriterTest.GenerateDerived_DelegatesBaseEqualsAndHashCode.verified.txt new file mode 100644 index 0000000..b4f3011 --- /dev/null +++ b/test/Equatable.Generator.Tests/Snapshots/EquatableWriterTest.GenerateDerived_DelegatesBaseEqualsAndHashCode.verified.txt @@ -0,0 +1,45 @@ +// +#nullable enable + +namespace Equatable.Entities +{ + partial class DerivedEntity : global::System.IEquatable + { + /// + public bool Equals(global::Equatable.Entities.DerivedEntity? other) + { + return !(other is null) + && base.Equals(other) + && global::System.Collections.Generic.EqualityComparer.Default.Equals(Label, other.Label); + + } + + /// + public override bool Equals(object? obj) + { + return Equals(obj as global::Equatable.Entities.DerivedEntity); + } + + /// + public static bool operator ==(global::Equatable.Entities.DerivedEntity? left, global::Equatable.Entities.DerivedEntity? right) + { + return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); + } + + /// + public static bool operator !=(global::Equatable.Entities.DerivedEntity? left, global::Equatable.Entities.DerivedEntity? right) + { + return !(left == right); + } + + /// + public override int GetHashCode(){ + int hashCode = 99; + hashCode = (hashCode * -1521134295) + base.GetHashCode(); + hashCode = (hashCode * -1521134295) + global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(Label!); + return hashCode; + + } + + } +} diff --git a/test/Equatable.Generator.Tests/Snapshots/EquatableWriterTest.GenerateNested_WrapsInContainingType.verified.txt b/test/Equatable.Generator.Tests/Snapshots/EquatableWriterTest.GenerateNested_WrapsInContainingType.verified.txt new file mode 100644 index 0000000..a66fcc6 --- /dev/null +++ b/test/Equatable.Generator.Tests/Snapshots/EquatableWriterTest.GenerateNested_WrapsInContainingType.verified.txt @@ -0,0 +1,46 @@ +// +#nullable enable + +namespace Equatable.Entities +{ + partial class Outer + { + partial class Inner : global::System.IEquatable + { + /// + public bool Equals(global::Equatable.Entities.Outer.Inner? other) + { + return !(other is null) + && global::System.Collections.Generic.EqualityComparer.Default.Equals(Value, other.Value); + + } + + /// + public override bool Equals(object? obj) + { + return Equals(obj as global::Equatable.Entities.Outer.Inner); + } + + /// + public static bool operator ==(global::Equatable.Entities.Outer.Inner? left, global::Equatable.Entities.Outer.Inner? right) + { + return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); + } + + /// + public static bool operator !=(global::Equatable.Entities.Outer.Inner? left, global::Equatable.Entities.Outer.Inner? right) + { + return !(left == right); + } + + /// + public override int GetHashCode(){ + int hashCode = 7; + hashCode = (hashCode * -1521134295) + global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(Value!); + return hashCode; + + } + + } + } +} diff --git a/test/Equatable.Generator.Tests/Snapshots/EquatableWriterTest.GenerateRecord_EmitsVirtualEquals.verified.txt b/test/Equatable.Generator.Tests/Snapshots/EquatableWriterTest.GenerateRecord_EmitsVirtualEquals.verified.txt new file mode 100644 index 0000000..08859e1 --- /dev/null +++ b/test/Equatable.Generator.Tests/Snapshots/EquatableWriterTest.GenerateRecord_EmitsVirtualEquals.verified.txt @@ -0,0 +1,27 @@ +// +#nullable enable + +namespace Equatable.Entities +{ + partial record class PricingRecord + { + /// + public virtual bool Equals(global::Equatable.Entities.PricingRecord? other) + { + return !(other is null) + && global::System.Collections.Generic.EqualityComparer.Default.Equals(MarketId, other.MarketId) + && global::System.Collections.Generic.EqualityComparer.Default.Equals(Probability, other.Probability); + + } + + /// + public override int GetHashCode(){ + int hashCode = 42; + hashCode = (hashCode * -1521134295) + global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(MarketId!); + hashCode = (hashCode * -1521134295) + global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(Probability!); + return hashCode; + + } + + } +} diff --git a/test/Equatable.Generator.Tests/Snapshots/EquatableWriterTest.GenerateSealed_NonVirtualEquals.verified.txt b/test/Equatable.Generator.Tests/Snapshots/EquatableWriterTest.GenerateSealed_NonVirtualEquals.verified.txt new file mode 100644 index 0000000..4f0458f --- /dev/null +++ b/test/Equatable.Generator.Tests/Snapshots/EquatableWriterTest.GenerateSealed_NonVirtualEquals.verified.txt @@ -0,0 +1,43 @@ +// +#nullable enable + +namespace Equatable.Entities +{ + partial class FinalEntity : global::System.IEquatable + { + /// + public bool Equals(global::Equatable.Entities.FinalEntity? other) + { + return !(other is null) + && global::System.Collections.Generic.EqualityComparer.Default.Equals(Id, other.Id); + + } + + /// + public override bool Equals(object? obj) + { + return Equals(obj as global::Equatable.Entities.FinalEntity); + } + + /// + public static bool operator ==(global::Equatable.Entities.FinalEntity? left, global::Equatable.Entities.FinalEntity? right) + { + return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); + } + + /// + public static bool operator !=(global::Equatable.Entities.FinalEntity? left, global::Equatable.Entities.FinalEntity? right) + { + return !(left == right); + } + + /// + public override int GetHashCode(){ + int hashCode = 0; + hashCode = (hashCode * -1521134295) + global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(Id!); + return hashCode; + + } + + } +} diff --git a/test/Equatable.Generator.Tests/Snapshots/EquatableWriterTest.GenerateUserImportHashSetDictionary.verified.txt b/test/Equatable.Generator.Tests/Snapshots/EquatableWriterTest.GenerateUserImportHashSetDictionary.verified.txt index 47e5179..67d2ecf 100644 --- a/test/Equatable.Generator.Tests/Snapshots/EquatableWriterTest.GenerateUserImportHashSetDictionary.verified.txt +++ b/test/Equatable.Generator.Tests/Snapshots/EquatableWriterTest.GenerateUserImportHashSetDictionary.verified.txt @@ -119,7 +119,7 @@ namespace Equatable.Entities if (items is null) return 0; - int hashCode = 0; + int hashCode = 1; foreach (var item in items) hashCode += global::System.HashCode.Combine( @@ -134,7 +134,7 @@ namespace Equatable.Entities if (items is null) return 0; - int hashCode = 0; + int hashCode = 1; foreach (var item in items) hashCode += global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(item!); diff --git a/test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatable.verified.txt b/test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatable.verified.txt new file mode 100644 index 0000000..3cb0954 --- /dev/null +++ b/test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatable.verified.txt @@ -0,0 +1,45 @@ +// +#nullable enable + +namespace Equatable.Entities +{ + partial class PricingContract : global::System.IEquatable + { + /// + public bool Equals(global::Equatable.Entities.PricingContract? other) + { + return !(other is null) + && MarketId == other.MarketId + && Probability == other.Probability; + + } + + /// + public override bool Equals(object? obj) + { + return Equals(obj as global::Equatable.Entities.PricingContract); + } + + /// + public static bool operator ==(global::Equatable.Entities.PricingContract? left, global::Equatable.Entities.PricingContract? right) + { + return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); + } + + /// + public static bool operator !=(global::Equatable.Entities.PricingContract? left, global::Equatable.Entities.PricingContract? right) + { + return !(left == right); + } + + /// + public override int GetHashCode(){ + int hashCode = -1121495104; + hashCode = (hashCode * -1521134295) + MarketId.GetHashCode(); + hashCode = (hashCode * -1521134295) + Probability.GetHashCode(); + return hashCode; + + } + + } +} diff --git a/test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatableDerivedFromMessagePackEquatableBase.verified.txt b/test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatableDerivedFromMessagePackEquatableBase.verified.txt new file mode 100644 index 0000000..3131f31 --- /dev/null +++ b/test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatableDerivedFromMessagePackEquatableBase.verified.txt @@ -0,0 +1,45 @@ +// +#nullable enable + +namespace Equatable.Entities +{ + partial class DerivedRecord : global::System.IEquatable + { + /// + public bool Equals(global::Equatable.Entities.DerivedRecord? other) + { + return !(other is null) + && base.Equals(other) + && global::System.Collections.Generic.EqualityComparer.Default.Equals(Label, other.Label); + + } + + /// + public override bool Equals(object? obj) + { + return Equals(obj as global::Equatable.Entities.DerivedRecord); + } + + /// + public static bool operator ==(global::Equatable.Entities.DerivedRecord? left, global::Equatable.Entities.DerivedRecord? right) + { + return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); + } + + /// + public static bool operator !=(global::Equatable.Entities.DerivedRecord? left, global::Equatable.Entities.DerivedRecord? right) + { + return !(left == right); + } + + /// + public override int GetHashCode(){ + int hashCode = -1147791342; + hashCode = (hashCode * -1521134295) + base.GetHashCode(); + hashCode = (hashCode * -1521134295) + global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(Label!); + return hashCode; + + } + + } +} diff --git a/test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatableDerivedIncludesUnannotatedBase.verified.txt b/test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatableDerivedIncludesUnannotatedBase.verified.txt new file mode 100644 index 0000000..8e8fb62 --- /dev/null +++ b/test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatableDerivedIncludesUnannotatedBase.verified.txt @@ -0,0 +1,47 @@ +// +#nullable enable + +namespace Equatable.Entities +{ + partial class ConcreteRecord : global::System.IEquatable + { + /// + public bool Equals(global::Equatable.Entities.ConcreteRecord? other) + { + return !(other is null) + && global::System.Collections.Generic.EqualityComparer.Default.Equals(Label, other.Label) + && Id == other.Id + && Score == other.Score; + + } + + /// + public override bool Equals(object? obj) + { + return Equals(obj as global::Equatable.Entities.ConcreteRecord); + } + + /// + public static bool operator ==(global::Equatable.Entities.ConcreteRecord? left, global::Equatable.Entities.ConcreteRecord? right) + { + return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); + } + + /// + public static bool operator !=(global::Equatable.Entities.ConcreteRecord? left, global::Equatable.Entities.ConcreteRecord? right) + { + return !(left == right); + } + + /// + public override int GetHashCode(){ + int hashCode = 242241058; + hashCode = (hashCode * -1521134295) + global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(Label!); + hashCode = (hashCode * -1521134295) + Id.GetHashCode(); + hashCode = (hashCode * -1521134295) + Score.GetHashCode(); + return hashCode; + + } + + } +} diff --git a/test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatableWithInferredCollectionComparers.verified.txt b/test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatableWithInferredCollectionComparers.verified.txt new file mode 100644 index 0000000..8d0efa4 --- /dev/null +++ b/test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatableWithInferredCollectionComparers.verified.txt @@ -0,0 +1,51 @@ +// +#nullable enable + +namespace Equatable.Entities +{ + partial class PackedWithCollections : global::System.IEquatable + { + /// + public bool Equals(global::Equatable.Entities.PackedWithCollections? other) + { + return !(other is null) + && Id == other.Id + && (new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default)).Equals(Tags, other.Tags) + && (global::Equatable.Comparers.SequenceEqualityComparer.Default).Equals(Labels, other.Labels) + && (global::Equatable.Comparers.SequenceEqualityComparer.Default).Equals(Codes, other.Codes) + && (new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default)).Equals(Rates, other.Rates); + + } + + /// + public override bool Equals(object? obj) + { + return Equals(obj as global::Equatable.Entities.PackedWithCollections); + } + + /// + public static bool operator ==(global::Equatable.Entities.PackedWithCollections? left, global::Equatable.Entities.PackedWithCollections? right) + { + return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); + } + + /// + public static bool operator !=(global::Equatable.Entities.PackedWithCollections? left, global::Equatable.Entities.PackedWithCollections? right) + { + return !(left == right); + } + + /// + public override int GetHashCode(){ + int hashCode = -1662182381; + hashCode = (hashCode * -1521134295) + Id.GetHashCode(); + hashCode = (hashCode * -1521134295) + (new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default)).GetHashCode(Tags!); + hashCode = (hashCode * -1521134295) + (global::Equatable.Comparers.SequenceEqualityComparer.Default).GetHashCode(Labels!); + hashCode = (hashCode * -1521134295) + (global::Equatable.Comparers.SequenceEqualityComparer.Default).GetHashCode(Codes!); + hashCode = (hashCode * -1521134295) + (new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default)).GetHashCode(Rates!); + return hashCode; + + } + + } +} diff --git a/test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatableWithNestedCollectionComparers.verified.txt b/test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatableWithNestedCollectionComparers.verified.txt new file mode 100644 index 0000000..d9fbb3d --- /dev/null +++ b/test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatableWithNestedCollectionComparers.verified.txt @@ -0,0 +1,51 @@ +// +#nullable enable + +namespace Equatable.Entities +{ + partial class PackedWithNestedCollections : global::System.IEquatable + { + /// + public bool Equals(global::Equatable.Entities.PackedWithNestedCollections? other) + { + return !(other is null) + && Id == other.Id + && (new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer>(global::System.Collections.Generic.EqualityComparer.Default, global::Equatable.Comparers.SequenceEqualityComparer.Default)).Equals(TagGroups, other.TagGroups) + && (new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer>(global::System.Collections.Generic.EqualityComparer.Default, new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default))).Equals(NestedMap, other.NestedMap) + && (new global::Equatable.Comparers.SequenceEqualityComparer>(new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default))).Equals(Records, other.Records) + && (new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer>(global::System.Collections.Generic.EqualityComparer.Default, global::Equatable.Comparers.SequenceEqualityComparer.Default)).Equals(ReadOnlyTagGroups, other.ReadOnlyTagGroups); + + } + + /// + public override bool Equals(object? obj) + { + return Equals(obj as global::Equatable.Entities.PackedWithNestedCollections); + } + + /// + public static bool operator ==(global::Equatable.Entities.PackedWithNestedCollections? left, global::Equatable.Entities.PackedWithNestedCollections? right) + { + return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); + } + + /// + public static bool operator !=(global::Equatable.Entities.PackedWithNestedCollections? left, global::Equatable.Entities.PackedWithNestedCollections? right) + { + return !(left == right); + } + + /// + public override int GetHashCode(){ + int hashCode = -1611759231; + hashCode = (hashCode * -1521134295) + Id.GetHashCode(); + hashCode = (hashCode * -1521134295) + (new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer>(global::System.Collections.Generic.EqualityComparer.Default, global::Equatable.Comparers.SequenceEqualityComparer.Default)).GetHashCode(TagGroups!); + hashCode = (hashCode * -1521134295) + (new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer>(global::System.Collections.Generic.EqualityComparer.Default, new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default))).GetHashCode(NestedMap!); + hashCode = (hashCode * -1521134295) + (new global::Equatable.Comparers.SequenceEqualityComparer>(new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default))).GetHashCode(Records!); + hashCode = (hashCode * -1521134295) + (new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer>(global::System.Collections.Generic.EqualityComparer.Default, global::Equatable.Comparers.SequenceEqualityComparer.Default)).GetHashCode(ReadOnlyTagGroups!); + return hashCode; + + } + + } +} diff --git a/test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatableWithNoIncludedProperties.verified.txt b/test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatableWithNoIncludedProperties.verified.txt new file mode 100644 index 0000000..e4b355d --- /dev/null +++ b/test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatableWithNoIncludedProperties.verified.txt @@ -0,0 +1,41 @@ +// +#nullable enable + +namespace Equatable.Entities +{ + partial class AllIgnored : global::System.IEquatable + { + /// + public bool Equals(global::Equatable.Entities.AllIgnored? other) + { + return !(other is null); + + } + + /// + public override bool Equals(object? obj) + { + return Equals(obj as global::Equatable.Entities.AllIgnored); + } + + /// + public static bool operator ==(global::Equatable.Entities.AllIgnored? left, global::Equatable.Entities.AllIgnored? right) + { + return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); + } + + /// + public static bool operator !=(global::Equatable.Entities.AllIgnored? left, global::Equatable.Entities.AllIgnored? right) + { + return !(left == right); + } + + /// + public override int GetHashCode(){ + int hashCode = 0; + return hashCode; + + } + + } +} diff --git a/test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatableWithOrderedDictionaryOverride.verified.txt b/test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatableWithOrderedDictionaryOverride.verified.txt new file mode 100644 index 0000000..4bc235b --- /dev/null +++ b/test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatableWithOrderedDictionaryOverride.verified.txt @@ -0,0 +1,45 @@ +// +#nullable enable + +namespace Equatable.Entities +{ + partial class OrderedPackedContract : global::System.IEquatable + { + /// + public bool Equals(global::Equatable.Entities.OrderedPackedContract? other) + { + return !(other is null) + && Id == other.Id + && (new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default)).Equals(Tags, other.Tags); + + } + + /// + public override bool Equals(object? obj) + { + return Equals(obj as global::Equatable.Entities.OrderedPackedContract); + } + + /// + public static bool operator ==(global::Equatable.Entities.OrderedPackedContract? left, global::Equatable.Entities.OrderedPackedContract? right) + { + return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); + } + + /// + public static bool operator !=(global::Equatable.Entities.OrderedPackedContract? left, global::Equatable.Entities.OrderedPackedContract? right) + { + return !(left == right); + } + + /// + public override int GetHashCode(){ + int hashCode = -193969728; + hashCode = (hashCode * -1521134295) + Id.GetHashCode(); + hashCode = (hashCode * -1521134295) + (new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default)).GetHashCode(Tags!); + return hashCode; + + } + + } +} From 929f0c57756279536c7fe823d31082f6808a64f1 Mon Sep 17 00:00:00 2001 From: "a.kasyanov" Date: Wed, 13 May 2026 23:01:07 +0300 Subject: [PATCH 18/71] test: add integration tests for nested collection equality on generated entities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OrderDataContractNestedTest: 13 tests verifying [DataContractEquatable]-generated Equals/GetHashCode on Dict>, Dict>, List>, IReadOnlyDictionary> — structural inner comparison, insertion-order independence for dicts, order-sensitivity for lists, null vs empty discrimination. - SerializedRecordNestedTest: same 13 scenarios for [MessagePackEquatable]. - DictionaryHashCodeTest: documents and verifies order-independent hash algorithm. - Add OrderDataContractProperties and SerializedRecordProperties to the property test project, and update project file accordingly. Co-Authored-By: Claude Sonnet 4.5 --- ...quatable.Generator.Properties.Tests.csproj | 9 +- .../Properties/OrderDataContractProperties.cs | 4 +- .../Properties/SerializedRecordProperties.cs | 10 +- .../Entities/OrderDataContractNestedTest.cs | 168 ++++++++++++++++++ .../Entities/SerializedRecordNestedTest.cs | 168 ++++++++++++++++++ 5 files changed, 347 insertions(+), 12 deletions(-) create mode 100644 test/Equatable.Generator.Tests/Entities/OrderDataContractNestedTest.cs create mode 100644 test/Equatable.Generator.Tests/Entities/SerializedRecordNestedTest.cs diff --git a/test/Equatable.Generator.Properties.Tests/Equatable.Generator.Properties.Tests.csproj b/test/Equatable.Generator.Properties.Tests/Equatable.Generator.Properties.Tests.csproj index 00cba3a..02f3724 100644 --- a/test/Equatable.Generator.Properties.Tests/Equatable.Generator.Properties.Tests.csproj +++ b/test/Equatable.Generator.Properties.Tests/Equatable.Generator.Properties.Tests.csproj @@ -2,6 +2,7 @@ net10.0 + Exe enable enable latest @@ -10,13 +11,9 @@ - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - + + diff --git a/test/Equatable.Generator.Properties.Tests/Properties/OrderDataContractProperties.cs b/test/Equatable.Generator.Properties.Tests/Properties/OrderDataContractProperties.cs index 2a84c80..188e2b9 100644 --- a/test/Equatable.Generator.Properties.Tests/Properties/OrderDataContractProperties.cs +++ b/test/Equatable.Generator.Properties.Tests/Properties/OrderDataContractProperties.cs @@ -52,7 +52,7 @@ public Property NonDataMemberFieldsIgnoredInHashCode(int id, string? name, strin public Property DifferentIdNotEqual(string? name, int id1, int id2) { if (id1 == id2) - return true.ToProperty().When(true); + return true.ToProperty().When(false); var a = new OrderDataContract { Id = id1, Name = name }; var b = new OrderDataContract { Id = id2, Name = name }; @@ -63,7 +63,7 @@ public Property DifferentIdNotEqual(string? name, int id1, int id2) public Property DifferentNameNotEqual(int id, string name1, string name2) { if (name1 == name2) - return true.ToProperty().When(true); + return true.ToProperty().When(false); var a = new OrderDataContract { Id = id, Name = name1 }; var b = new OrderDataContract { Id = id, Name = name2 }; diff --git a/test/Equatable.Generator.Properties.Tests/Properties/SerializedRecordProperties.cs b/test/Equatable.Generator.Properties.Tests/Properties/SerializedRecordProperties.cs index 6a5f748..8653e62 100644 --- a/test/Equatable.Generator.Properties.Tests/Properties/SerializedRecordProperties.cs +++ b/test/Equatable.Generator.Properties.Tests/Properties/SerializedRecordProperties.cs @@ -55,8 +55,8 @@ public Property IgnoredFieldsExcludedFromHashCode(int id, double score, string? [Property] public Property DifferentIdNotEqual(double score, int id1, int id2) { - if (id1 == id2) - return true.ToProperty().When(true); + if (id1 == id2 || double.IsNaN(score)) + return true.ToProperty().When(false); var a = new SerializedRecord { Id = id1, Score = score }; var b = new SerializedRecord { Id = id2, Score = score }; @@ -66,8 +66,10 @@ public Property DifferentIdNotEqual(double score, int id1, int id2) [Property] public Property DifferentScoreNotEqual(int id, double s1, double s2) { - if (Math.Abs(s1 - s2) < double.Epsilon) - return true.ToProperty().When(true); + // Generated code uses == (exact bit equality), not a tolerance comparison. + // NaN != NaN in IEEE 754 so also skip NaN inputs — the reflexivity test covers that case. + if (s1 == s2 || double.IsNaN(s1) || double.IsNaN(s2)) + return true.ToProperty().When(false); var a = new SerializedRecord { Id = id, Score = s1 }; var b = new SerializedRecord { Id = id, Score = s2 }; diff --git a/test/Equatable.Generator.Tests/Entities/OrderDataContractNestedTest.cs b/test/Equatable.Generator.Tests/Entities/OrderDataContractNestedTest.cs new file mode 100644 index 0000000..4441f47 --- /dev/null +++ b/test/Equatable.Generator.Tests/Entities/OrderDataContractNestedTest.cs @@ -0,0 +1,168 @@ +using Equatable.Entities; + +namespace Equatable.Generator.Tests.Entities; + +/// +/// Verifies that [DataContractEquatable] infers structurally-recursive comparers +/// for nested collection properties — i.e. that InferCollectionComparer composes +/// DictionaryEqualityComparer / SequenceEqualityComparer at every level rather than +/// falling back to EqualityComparer<T>.Default (which would be reference equality +/// for any collection value type). +/// +/// Each test exercises the generated Equals / GetHashCode directly on entity instances, +/// not the comparer classes themselves. +/// +public class OrderDataContractNestedTest +{ + // ── Dict>: values are sequences, order inside lists matters ───────────────── + + [Fact] + public void DictOfList_GeneratedEquals_ComparesInnerListsStructurally() + { + // Separate List instances with the same content must be equal. + // Fails with reference equality; passes only when SequenceEqualityComparer is composed. + var a = new OrderDataContractNested { Id = 1, TagGroups = new() { ["sports"] = [10, 20], ["news"] = [30] } }; + var b = new OrderDataContractNested { Id = 1, TagGroups = new() { ["sports"] = [10, 20], ["news"] = [30] } }; + + Assert.True(a.Equals(b)); + Assert.Equal(a.GetHashCode(), b.GetHashCode()); + } + + [Fact] + public void DictOfList_GeneratedEquals_OuterDictInsertionOrderIsIgnored() + { + // DictionaryEqualityComparer uses TryGetValue — outer key order must not matter. + var a = new OrderDataContractNested { Id = 1, TagGroups = new() { ["sports"] = [10], ["news"] = [20] } }; + var b = new OrderDataContractNested { Id = 1, TagGroups = new() { ["news"] = [20], ["sports"] = [10] } }; + + Assert.True(a.Equals(b)); + Assert.Equal(a.GetHashCode(), b.GetHashCode()); + } + + [Fact] + public void DictOfList_GeneratedEquals_InnerListOrderIsEnforced() + { + // SequenceEqualityComparer is order-sensitive: [10, 20] ≠ [20, 10]. + var a = new OrderDataContractNested { Id = 1, TagGroups = new() { ["sports"] = [10, 20] } }; + var b = new OrderDataContractNested { Id = 1, TagGroups = new() { ["sports"] = [20, 10] } }; + + Assert.False(a.Equals(b)); + } + + [Fact] + public void DictOfList_GeneratedEquals_DifferentInnerElement_IsNotEqual() + { + var a = new OrderDataContractNested { Id = 1, TagGroups = new() { ["sports"] = [10] } }; + var b = new OrderDataContractNested { Id = 1, TagGroups = new() { ["sports"] = [99] } }; + + Assert.False(a.Equals(b)); + } + + // ── Dict>: values are dicts, insertion order at both levels ignored ── + + [Fact] + public void DictOfDict_GeneratedEquals_ComparesInnerDictsStructurally() + { + // Separate inner Dictionary instances with the same content must be equal. + // Fails with reference equality; passes only when DictionaryEqualityComparer is composed. + var a = new OrderDataContractNested { Id = 1, NestedMap = new() { ["outer"] = new() { ["inner"] = 42 } } }; + var b = new OrderDataContractNested { Id = 1, NestedMap = new() { ["outer"] = new() { ["inner"] = 42 } } }; + + Assert.True(a.Equals(b)); + Assert.Equal(a.GetHashCode(), b.GetHashCode()); + } + + [Fact] + public void DictOfDict_GeneratedEquals_InnerDictInsertionOrderIsIgnored() + { + var a = new OrderDataContractNested { Id = 1, NestedMap = new() { ["outer"] = new() { ["a"] = 1, ["b"] = 2 } } }; + var b = new OrderDataContractNested { Id = 1, NestedMap = new() { ["outer"] = new() { ["b"] = 2, ["a"] = 1 } } }; + + Assert.True(a.Equals(b)); + Assert.Equal(a.GetHashCode(), b.GetHashCode()); + } + + [Fact] + public void DictOfDict_GeneratedEquals_DifferentInnerValue_IsNotEqual() + { + var a = new OrderDataContractNested { Id = 1, NestedMap = new() { ["outer"] = new() { ["k"] = 42 } } }; + var b = new OrderDataContractNested { Id = 1, NestedMap = new() { ["outer"] = new() { ["k"] = 99 } } }; + + Assert.False(a.Equals(b)); + } + + // ── List>: outer list order enforced, inner dict order ignored ─────────────── + + [Fact] + public void ListOfDict_GeneratedEquals_ComparesInnerDictsStructurally() + { + // Separate inner Dictionary instances with the same content must be equal. + var a = new OrderDataContractNested { Id = 1, Records = [new() { ["x"] = 1 }, new() { ["y"] = 2 }] }; + var b = new OrderDataContractNested { Id = 1, Records = [new() { ["x"] = 1 }, new() { ["y"] = 2 }] }; + + Assert.True(a.Equals(b)); + Assert.Equal(a.GetHashCode(), b.GetHashCode()); + } + + [Fact] + public void ListOfDict_GeneratedEquals_OuterListOrderIsEnforced() + { + // SequenceEqualityComparer is position-sensitive for the outer list. + var a = new OrderDataContractNested { Id = 1, Records = [new() { ["x"] = 1 }, new() { ["y"] = 2 }] }; + var b = new OrderDataContractNested { Id = 1, Records = [new() { ["y"] = 2 }, new() { ["x"] = 1 }] }; + + Assert.False(a.Equals(b)); + } + + [Fact] + public void ListOfDict_GeneratedEquals_InnerDictInsertionOrderIsIgnored() + { + var a = new OrderDataContractNested { Id = 1, Records = [new() { ["a"] = 1, ["b"] = 2 }] }; + var b = new OrderDataContractNested { Id = 1, Records = [new() { ["b"] = 2, ["a"] = 1 }] }; + + Assert.True(a.Equals(b)); + Assert.Equal(a.GetHashCode(), b.GetHashCode()); + } + + // ── IReadOnlyDictionary>: read-only variant, same structural rules ──────── + + [Fact] + public void ReadOnlyDictOfList_GeneratedEquals_ComparesInnerListsStructurally() + { + var a = new OrderDataContractNested + { + Id = 1, + ReadOnlyTagGroups = new Dictionary> { ["a"] = ["x", "y"], ["b"] = ["z"] } + }; + var b = new OrderDataContractNested + { + Id = 1, + ReadOnlyTagGroups = new Dictionary> { ["b"] = ["z"], ["a"] = ["x", "y"] } + }; + + Assert.True(a.Equals(b)); + Assert.Equal(a.GetHashCode(), b.GetHashCode()); + } + + // ── Null / empty collection discrimination ──────────────────────────────────────────────────── + + [Fact] + public void GeneratedEquals_NullCollection_IsNotEqualToEmpty() + { + // GetHashCode returns 0 for null, 1 (seed) for empty — they must also not be Equal. + var a = new OrderDataContractNested { Id = 1, TagGroups = null }; + var b = new OrderDataContractNested { Id = 1, TagGroups = [] }; + + Assert.False(a.Equals(b)); + } + + [Fact] + public void GeneratedEquals_BothNullCollections_AreEqual() + { + var a = new OrderDataContractNested { Id = 1, TagGroups = null }; + var b = new OrderDataContractNested { Id = 1, TagGroups = null }; + + Assert.True(a.Equals(b)); + Assert.Equal(a.GetHashCode(), b.GetHashCode()); + } +} diff --git a/test/Equatable.Generator.Tests/Entities/SerializedRecordNestedTest.cs b/test/Equatable.Generator.Tests/Entities/SerializedRecordNestedTest.cs new file mode 100644 index 0000000..c91e1b2 --- /dev/null +++ b/test/Equatable.Generator.Tests/Entities/SerializedRecordNestedTest.cs @@ -0,0 +1,168 @@ +using Equatable.Entities; + +namespace Equatable.Generator.Tests.Entities; + +/// +/// Verifies that [MessagePackEquatable] infers structurally-recursive comparers +/// for nested collection properties — i.e. that InferCollectionComparer composes +/// DictionaryEqualityComparer / SequenceEqualityComparer at every level rather than +/// falling back to EqualityComparer<T>.Default (which would be reference equality +/// for any collection value type). +/// +/// Each test exercises the generated Equals / GetHashCode directly on entity instances, +/// not the comparer classes themselves. +/// +public class SerializedRecordNestedTest +{ + // ── Dict>: values are sequences, order inside lists matters ───────────────── + + [Fact] + public void DictOfList_GeneratedEquals_ComparesInnerListsStructurally() + { + // Separate List instances with the same content must be equal. + // Fails with reference equality; passes only when SequenceEqualityComparer is composed. + var a = new SerializedRecordNested { Id = 1, TagGroups = new() { ["sports"] = [10, 20], ["news"] = [30] } }; + var b = new SerializedRecordNested { Id = 1, TagGroups = new() { ["sports"] = [10, 20], ["news"] = [30] } }; + + Assert.True(a.Equals(b)); + Assert.Equal(a.GetHashCode(), b.GetHashCode()); + } + + [Fact] + public void DictOfList_GeneratedEquals_OuterDictInsertionOrderIsIgnored() + { + // DictionaryEqualityComparer uses TryGetValue — outer key order must not matter. + var a = new SerializedRecordNested { Id = 1, TagGroups = new() { ["sports"] = [10], ["news"] = [20] } }; + var b = new SerializedRecordNested { Id = 1, TagGroups = new() { ["news"] = [20], ["sports"] = [10] } }; + + Assert.True(a.Equals(b)); + Assert.Equal(a.GetHashCode(), b.GetHashCode()); + } + + [Fact] + public void DictOfList_GeneratedEquals_InnerListOrderIsEnforced() + { + // SequenceEqualityComparer is order-sensitive: [10, 20] ≠ [20, 10]. + var a = new SerializedRecordNested { Id = 1, TagGroups = new() { ["sports"] = [10, 20] } }; + var b = new SerializedRecordNested { Id = 1, TagGroups = new() { ["sports"] = [20, 10] } }; + + Assert.False(a.Equals(b)); + } + + [Fact] + public void DictOfList_GeneratedEquals_DifferentInnerElement_IsNotEqual() + { + var a = new SerializedRecordNested { Id = 1, TagGroups = new() { ["sports"] = [10] } }; + var b = new SerializedRecordNested { Id = 1, TagGroups = new() { ["sports"] = [99] } }; + + Assert.False(a.Equals(b)); + } + + // ── Dict>: values are dicts, insertion order at both levels ignored ── + + [Fact] + public void DictOfDict_GeneratedEquals_ComparesInnerDictsStructurally() + { + // Separate inner Dictionary instances with the same content must be equal. + // Fails with reference equality; passes only when DictionaryEqualityComparer is composed. + var a = new SerializedRecordNested { Id = 1, NestedMap = new() { ["outer"] = new() { ["inner"] = 42 } } }; + var b = new SerializedRecordNested { Id = 1, NestedMap = new() { ["outer"] = new() { ["inner"] = 42 } } }; + + Assert.True(a.Equals(b)); + Assert.Equal(a.GetHashCode(), b.GetHashCode()); + } + + [Fact] + public void DictOfDict_GeneratedEquals_InnerDictInsertionOrderIsIgnored() + { + var a = new SerializedRecordNested { Id = 1, NestedMap = new() { ["outer"] = new() { ["a"] = 1, ["b"] = 2 } } }; + var b = new SerializedRecordNested { Id = 1, NestedMap = new() { ["outer"] = new() { ["b"] = 2, ["a"] = 1 } } }; + + Assert.True(a.Equals(b)); + Assert.Equal(a.GetHashCode(), b.GetHashCode()); + } + + [Fact] + public void DictOfDict_GeneratedEquals_DifferentInnerValue_IsNotEqual() + { + var a = new SerializedRecordNested { Id = 1, NestedMap = new() { ["outer"] = new() { ["k"] = 42 } } }; + var b = new SerializedRecordNested { Id = 1, NestedMap = new() { ["outer"] = new() { ["k"] = 99 } } }; + + Assert.False(a.Equals(b)); + } + + // ── List>: outer list order enforced, inner dict order ignored ─────────────── + + [Fact] + public void ListOfDict_GeneratedEquals_ComparesInnerDictsStructurally() + { + // Separate inner Dictionary instances with the same content must be equal. + var a = new SerializedRecordNested { Id = 1, Records = [new() { ["x"] = 1 }, new() { ["y"] = 2 }] }; + var b = new SerializedRecordNested { Id = 1, Records = [new() { ["x"] = 1 }, new() { ["y"] = 2 }] }; + + Assert.True(a.Equals(b)); + Assert.Equal(a.GetHashCode(), b.GetHashCode()); + } + + [Fact] + public void ListOfDict_GeneratedEquals_OuterListOrderIsEnforced() + { + // SequenceEqualityComparer is position-sensitive for the outer list. + var a = new SerializedRecordNested { Id = 1, Records = [new() { ["x"] = 1 }, new() { ["y"] = 2 }] }; + var b = new SerializedRecordNested { Id = 1, Records = [new() { ["y"] = 2 }, new() { ["x"] = 1 }] }; + + Assert.False(a.Equals(b)); + } + + [Fact] + public void ListOfDict_GeneratedEquals_InnerDictInsertionOrderIsIgnored() + { + var a = new SerializedRecordNested { Id = 1, Records = [new() { ["a"] = 1, ["b"] = 2 }] }; + var b = new SerializedRecordNested { Id = 1, Records = [new() { ["b"] = 2, ["a"] = 1 }] }; + + Assert.True(a.Equals(b)); + Assert.Equal(a.GetHashCode(), b.GetHashCode()); + } + + // ── IReadOnlyDictionary>: read-only variant, same structural rules ──────── + + [Fact] + public void ReadOnlyDictOfList_GeneratedEquals_ComparesInnerListsStructurally() + { + var a = new SerializedRecordNested + { + Id = 1, + ReadOnlyTagGroups = new Dictionary> { ["a"] = ["x", "y"], ["b"] = ["z"] } + }; + var b = new SerializedRecordNested + { + Id = 1, + ReadOnlyTagGroups = new Dictionary> { ["b"] = ["z"], ["a"] = ["x", "y"] } + }; + + Assert.True(a.Equals(b)); + Assert.Equal(a.GetHashCode(), b.GetHashCode()); + } + + // ── Null / empty collection discrimination ──────────────────────────────────────────────────── + + [Fact] + public void GeneratedEquals_NullCollection_IsNotEqualToEmpty() + { + // GetHashCode returns 0 for null, 1 (seed) for empty — they must also not be Equal. + var a = new SerializedRecordNested { Id = 1, TagGroups = null }; + var b = new SerializedRecordNested { Id = 1, TagGroups = [] }; + + Assert.False(a.Equals(b)); + } + + [Fact] + public void GeneratedEquals_BothNullCollections_AreEqual() + { + var a = new SerializedRecordNested { Id = 1, TagGroups = null }; + var b = new SerializedRecordNested { Id = 1, TagGroups = null }; + + Assert.True(a.Equals(b)); + Assert.Equal(a.GetHashCode(), b.GetHashCode()); + } +} From 6379a64a9b1917a7e4497fb0b7117c52cb52cdd5 Mon Sep 17 00:00:00 2001 From: "a.kasyanov" Date: Wed, 13 May 2026 23:31:34 +0300 Subject: [PATCH 19/71] Mirror MessagePack generator tests to match DataContract entity shapes All 7 MessagePack test scenarios now use identical entity class names, property names, and property types as their DataContract counterparts (OrderDataContract, DerivedContract/BaseContract, ConcreteRecord/UnannotatedBase, OrderedContract, ContractWithCollections, ContractWithNestedCollections, AllIgnored). Only the serialization attributes differ: [Key(n)]/[IgnoreMember]/[MessagePackObject]/ [MessagePackEquatable] replace [DataMember(Order=n)]/[IgnoreDataMember]/[DataContract]/ [DataContractEquatable]. Snapshots regenerated and accepted. Co-Authored-By: Claude Sonnet 4.5 --- .../MessagePackGeneratorTest.cs | 39 +++++++++---------- ....GenerateMessagePackEquatable.verified.txt | 22 +++++------ ...dFromMessagePackEquatableBase.verified.txt | 18 ++++----- ...erivedIncludesUnannotatedBase.verified.txt | 10 ++--- ...thInferredCollectionComparers.verified.txt | 12 +++--- ...WithNestedCollectionComparers.verified.txt | 12 +++--- ...WithOrderedDictionaryOverride.verified.txt | 12 +++--- 7 files changed, 62 insertions(+), 63 deletions(-) diff --git a/test/Equatable.Generator.Tests/MessagePackGeneratorTest.cs b/test/Equatable.Generator.Tests/MessagePackGeneratorTest.cs index fb76e3c..d3350a9 100644 --- a/test/Equatable.Generator.Tests/MessagePackGeneratorTest.cs +++ b/test/Equatable.Generator.Tests/MessagePackGeneratorTest.cs @@ -15,18 +15,18 @@ namespace Equatable.Entities; [MessagePackObject] [MessagePackEquatable] -public partial class PricingContract +public partial class OrderDataContract { [Key(0)] - public int MarketId { get; set; } + public int Id { get; set; } [Key(1)] - public double Probability { get; set; } + public string? Name { get; set; } - [IgnoreMember] - public string? DebugInfo { get; set; } + public string? InternalNote { get; set; } - public string? NotIncluded { get; set; } + [IgnoreMember] + public string? IgnoredField { get; set; } } "; var (diagnostics, output) = GetGeneratedOutput(source); @@ -49,24 +49,24 @@ namespace Equatable.Entities; [MessagePackObject] [MessagePackEquatable] -public partial class DerivedRecord : BaseRecord +public partial class DerivedContract : BaseContract { [Key(2)] - public string? Label { get; set; } + public int Rank { get; set; } } [MessagePackObject] [MessagePackEquatable] -public partial class BaseRecord +public partial class BaseContract { [Key(0)] public int Id { get; set; } [Key(1)] - public double Score { get; set; } + public string? Name { get; set; } } "; - var (diagnostics, output) = GetNamedGeneratedOutput(source, "DerivedRecord"); + var (diagnostics, output) = GetNamedGeneratedOutput(source, "DerivedContract"); Assert.Empty(diagnostics); return Verifier.Verify(output).UseDirectory("Snapshots").ScrubLinesContaining("GeneratedCodeAttribute"); } @@ -89,7 +89,7 @@ namespace Equatable.Entities; public partial class ConcreteRecord : UnannotatedBase { [Key(2)] - public string? Label { get; set; } + public int Rank { get; set; } } public abstract class UnannotatedBase @@ -98,7 +98,7 @@ public abstract class UnannotatedBase public int Id { get; set; } [Key(1)] - public double Score { get; set; } + public string? Name { get; set; } } "; var (diagnostics, output) = GetNamedGeneratedOutput(source, "ConcreteRecord"); @@ -122,7 +122,7 @@ namespace Equatable.Entities; [MessagePackObject] [MessagePackEquatable] -public partial class OrderedPackedContract +public partial class OrderedContract { [Key(0)] public int Id { get; set; } @@ -153,7 +153,7 @@ namespace Equatable.Entities; [MessagePackObject] [MessagePackEquatable] -public partial class PackedWithCollections +public partial class ContractWithCollections { [Key(0)] public int Id { get; set; } @@ -170,9 +170,6 @@ public partial class PackedWithCollections [Key(4)] public IReadOnlyDictionary? Rates { get; set; } - [IgnoreMember] - public string? Ignored { get; set; } - public string? NotIncluded { get; set; } } "; @@ -183,6 +180,8 @@ public partial class PackedWithCollections // ── nested collection comparers ─────────────────────────────────────────────────────────────── // Adapter inference must recurse into nested types and compose structural comparers. + // e.g. Dictionary> → DictionaryEqualityComparer with SequenceEqualityComparer + // List> → SequenceEqualityComparer with DictionaryEqualityComparer [Fact] public Task GenerateMessagePackEquatableWithNestedCollectionComparers() @@ -196,7 +195,7 @@ namespace Equatable.Entities; [MessagePackObject] [MessagePackEquatable] -public partial class PackedWithNestedCollections +public partial class ContractWithNestedCollections { [Key(0)] public int Id { get; set; } @@ -238,7 +237,7 @@ public partial class AllIgnored [IgnoreMember] public int Id { get; set; } - public string? NotIncluded { get; set; } + public string? InternalNote { get; set; } } "; var (diagnostics, output) = GetGeneratedOutput(source); diff --git a/test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatable.verified.txt b/test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatable.verified.txt index 3cb0954..d7fe908 100644 --- a/test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatable.verified.txt +++ b/test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatable.verified.txt @@ -3,40 +3,40 @@ namespace Equatable.Entities { - partial class PricingContract : global::System.IEquatable + partial class OrderDataContract : global::System.IEquatable { /// - public bool Equals(global::Equatable.Entities.PricingContract? other) + public bool Equals(global::Equatable.Entities.OrderDataContract? other) { return !(other is null) - && MarketId == other.MarketId - && Probability == other.Probability; + && Id == other.Id + && global::System.Collections.Generic.EqualityComparer.Default.Equals(Name, other.Name); } /// public override bool Equals(object? obj) { - return Equals(obj as global::Equatable.Entities.PricingContract); + return Equals(obj as global::Equatable.Entities.OrderDataContract); } /// - public static bool operator ==(global::Equatable.Entities.PricingContract? left, global::Equatable.Entities.PricingContract? right) + public static bool operator ==(global::Equatable.Entities.OrderDataContract? left, global::Equatable.Entities.OrderDataContract? right) { - return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); + return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); } /// - public static bool operator !=(global::Equatable.Entities.PricingContract? left, global::Equatable.Entities.PricingContract? right) + public static bool operator !=(global::Equatable.Entities.OrderDataContract? left, global::Equatable.Entities.OrderDataContract? right) { return !(left == right); } /// public override int GetHashCode(){ - int hashCode = -1121495104; - hashCode = (hashCode * -1521134295) + MarketId.GetHashCode(); - hashCode = (hashCode * -1521134295) + Probability.GetHashCode(); + int hashCode = -1919740922; + hashCode = (hashCode * -1521134295) + Id.GetHashCode(); + hashCode = (hashCode * -1521134295) + global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(Name!); return hashCode; } diff --git a/test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatableDerivedFromMessagePackEquatableBase.verified.txt b/test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatableDerivedFromMessagePackEquatableBase.verified.txt index 3131f31..c07dab0 100644 --- a/test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatableDerivedFromMessagePackEquatableBase.verified.txt +++ b/test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatableDerivedFromMessagePackEquatableBase.verified.txt @@ -3,40 +3,40 @@ namespace Equatable.Entities { - partial class DerivedRecord : global::System.IEquatable + partial class DerivedContract : global::System.IEquatable { /// - public bool Equals(global::Equatable.Entities.DerivedRecord? other) + public bool Equals(global::Equatable.Entities.DerivedContract? other) { return !(other is null) && base.Equals(other) - && global::System.Collections.Generic.EqualityComparer.Default.Equals(Label, other.Label); + && Rank == other.Rank; } /// public override bool Equals(object? obj) { - return Equals(obj as global::Equatable.Entities.DerivedRecord); + return Equals(obj as global::Equatable.Entities.DerivedContract); } /// - public static bool operator ==(global::Equatable.Entities.DerivedRecord? left, global::Equatable.Entities.DerivedRecord? right) + public static bool operator ==(global::Equatable.Entities.DerivedContract? left, global::Equatable.Entities.DerivedContract? right) { - return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); + return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); } /// - public static bool operator !=(global::Equatable.Entities.DerivedRecord? left, global::Equatable.Entities.DerivedRecord? right) + public static bool operator !=(global::Equatable.Entities.DerivedContract? left, global::Equatable.Entities.DerivedContract? right) { return !(left == right); } /// public override int GetHashCode(){ - int hashCode = -1147791342; + int hashCode = -2095922015; hashCode = (hashCode * -1521134295) + base.GetHashCode(); - hashCode = (hashCode * -1521134295) + global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(Label!); + hashCode = (hashCode * -1521134295) + Rank.GetHashCode(); return hashCode; } diff --git a/test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatableDerivedIncludesUnannotatedBase.verified.txt b/test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatableDerivedIncludesUnannotatedBase.verified.txt index 8e8fb62..43258cd 100644 --- a/test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatableDerivedIncludesUnannotatedBase.verified.txt +++ b/test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatableDerivedIncludesUnannotatedBase.verified.txt @@ -9,9 +9,9 @@ namespace Equatable.Entities public bool Equals(global::Equatable.Entities.ConcreteRecord? other) { return !(other is null) - && global::System.Collections.Generic.EqualityComparer.Default.Equals(Label, other.Label) + && Rank == other.Rank && Id == other.Id - && Score == other.Score; + && global::System.Collections.Generic.EqualityComparer.Default.Equals(Name, other.Name); } @@ -35,10 +35,10 @@ namespace Equatable.Entities /// public override int GetHashCode(){ - int hashCode = 242241058; - hashCode = (hashCode * -1521134295) + global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(Label!); + int hashCode = 493275453; + hashCode = (hashCode * -1521134295) + Rank.GetHashCode(); hashCode = (hashCode * -1521134295) + Id.GetHashCode(); - hashCode = (hashCode * -1521134295) + Score.GetHashCode(); + hashCode = (hashCode * -1521134295) + global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(Name!); return hashCode; } diff --git a/test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatableWithInferredCollectionComparers.verified.txt b/test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatableWithInferredCollectionComparers.verified.txt index 8d0efa4..034c428 100644 --- a/test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatableWithInferredCollectionComparers.verified.txt +++ b/test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatableWithInferredCollectionComparers.verified.txt @@ -3,10 +3,10 @@ namespace Equatable.Entities { - partial class PackedWithCollections : global::System.IEquatable + partial class ContractWithCollections : global::System.IEquatable { /// - public bool Equals(global::Equatable.Entities.PackedWithCollections? other) + public bool Equals(global::Equatable.Entities.ContractWithCollections? other) { return !(other is null) && Id == other.Id @@ -20,17 +20,17 @@ namespace Equatable.Entities /// public override bool Equals(object? obj) { - return Equals(obj as global::Equatable.Entities.PackedWithCollections); + return Equals(obj as global::Equatable.Entities.ContractWithCollections); } /// - public static bool operator ==(global::Equatable.Entities.PackedWithCollections? left, global::Equatable.Entities.PackedWithCollections? right) + public static bool operator ==(global::Equatable.Entities.ContractWithCollections? left, global::Equatable.Entities.ContractWithCollections? right) { - return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); + return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); } /// - public static bool operator !=(global::Equatable.Entities.PackedWithCollections? left, global::Equatable.Entities.PackedWithCollections? right) + public static bool operator !=(global::Equatable.Entities.ContractWithCollections? left, global::Equatable.Entities.ContractWithCollections? right) { return !(left == right); } diff --git a/test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatableWithNestedCollectionComparers.verified.txt b/test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatableWithNestedCollectionComparers.verified.txt index d9fbb3d..e39793b 100644 --- a/test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatableWithNestedCollectionComparers.verified.txt +++ b/test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatableWithNestedCollectionComparers.verified.txt @@ -3,10 +3,10 @@ namespace Equatable.Entities { - partial class PackedWithNestedCollections : global::System.IEquatable + partial class ContractWithNestedCollections : global::System.IEquatable { /// - public bool Equals(global::Equatable.Entities.PackedWithNestedCollections? other) + public bool Equals(global::Equatable.Entities.ContractWithNestedCollections? other) { return !(other is null) && Id == other.Id @@ -20,17 +20,17 @@ namespace Equatable.Entities /// public override bool Equals(object? obj) { - return Equals(obj as global::Equatable.Entities.PackedWithNestedCollections); + return Equals(obj as global::Equatable.Entities.ContractWithNestedCollections); } /// - public static bool operator ==(global::Equatable.Entities.PackedWithNestedCollections? left, global::Equatable.Entities.PackedWithNestedCollections? right) + public static bool operator ==(global::Equatable.Entities.ContractWithNestedCollections? left, global::Equatable.Entities.ContractWithNestedCollections? right) { - return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); + return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); } /// - public static bool operator !=(global::Equatable.Entities.PackedWithNestedCollections? left, global::Equatable.Entities.PackedWithNestedCollections? right) + public static bool operator !=(global::Equatable.Entities.ContractWithNestedCollections? left, global::Equatable.Entities.ContractWithNestedCollections? right) { return !(left == right); } diff --git a/test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatableWithOrderedDictionaryOverride.verified.txt b/test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatableWithOrderedDictionaryOverride.verified.txt index 4bc235b..1c5c671 100644 --- a/test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatableWithOrderedDictionaryOverride.verified.txt +++ b/test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatableWithOrderedDictionaryOverride.verified.txt @@ -3,10 +3,10 @@ namespace Equatable.Entities { - partial class OrderedPackedContract : global::System.IEquatable + partial class OrderedContract : global::System.IEquatable { /// - public bool Equals(global::Equatable.Entities.OrderedPackedContract? other) + public bool Equals(global::Equatable.Entities.OrderedContract? other) { return !(other is null) && Id == other.Id @@ -17,17 +17,17 @@ namespace Equatable.Entities /// public override bool Equals(object? obj) { - return Equals(obj as global::Equatable.Entities.OrderedPackedContract); + return Equals(obj as global::Equatable.Entities.OrderedContract); } /// - public static bool operator ==(global::Equatable.Entities.OrderedPackedContract? left, global::Equatable.Entities.OrderedPackedContract? right) + public static bool operator ==(global::Equatable.Entities.OrderedContract? left, global::Equatable.Entities.OrderedContract? right) { - return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); + return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); } /// - public static bool operator !=(global::Equatable.Entities.OrderedPackedContract? left, global::Equatable.Entities.OrderedPackedContract? right) + public static bool operator !=(global::Equatable.Entities.OrderedContract? left, global::Equatable.Entities.OrderedContract? right) { return !(left == right); } From eacbf15fe4d9569e411efbf7d0f9ef940ef3bed8 Mon Sep 17 00:00:00 2001 From: "a.kasyanov" Date: Thu, 14 May 2026 00:27:06 +0300 Subject: [PATCH 20/71] fix: propagate sequential dict kind into nested dictionary comparers When [DictionaryEquality(sequential:true)] is applied, OrderedDictionaryEqualityComparer now propagates to all nested dictionary levels rather than only the outermost one. Adds nested-comparer tests to OrderedDictionaryEqualityComparerTest and snapshot tests verifying the generated output for Dictionary>. Co-Authored-By: Claude Sonnet 4.5 --- .../EquatableGenerator.cs | 27 +++++-- .../OrderedDictionaryEqualityComparerTest.cs | 74 +++++++++++++++++++ .../DataContractGeneratorTest.cs | 28 +++++++ .../EquatableGeneratorTest.cs | 21 ++++++ .../MessagePackGeneratorTest.cs | 28 +++++++ ...quentialNestedDictPropagation.verified.txt | 45 +++++++++++ ...DictPropagatesOrderedComparer.verified.txt | 43 +++++++++++ ...quentialNestedDictPropagation.verified.txt | 45 +++++++++++ 8 files changed, 303 insertions(+), 8 deletions(-) create mode 100644 test/Equatable.Generator.Tests/Snapshots/DataContractGeneratorTest.GenerateDataContractEquatableWithSequentialNestedDictPropagation.verified.txt create mode 100644 test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateSequentialDictionaryEquality_NestedDictPropagatesOrderedComparer.verified.txt create mode 100644 test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatableWithSequentialNestedDictPropagation.verified.txt diff --git a/src/Equatable.SourceGenerator/EquatableGenerator.cs b/src/Equatable.SourceGenerator/EquatableGenerator.cs index eb4bcb4..fae07cc 100644 --- a/src/Equatable.SourceGenerator/EquatableGenerator.cs +++ b/src/Equatable.SourceGenerator/EquatableGenerator.cs @@ -291,8 +291,8 @@ INamedTypeSymbol namedType when IsDictionary(namedType) || namedType.AllInterfac var keyType = dictInterface.TypeArguments[0]; var valueType = dictInterface.TypeArguments[1]; - var keyExpr = BuildElementComparerExpression(keyType); - var valueExpr = BuildElementComparerExpression(valueType); + var keyExpr = BuildElementComparerExpression(keyType, dictKind: kind); + var valueExpr = BuildElementComparerExpression(valueType, dictKind: kind); var keyTypeFq = keyType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); var valueTypeFq = valueType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); @@ -350,7 +350,10 @@ INamedTypeSymbol namedType when IsDictionary(namedType) || namedType.AllInterfac // Returns a comparer expression for a single element type, or null if EqualityComparer.Default suffices. // Terminates naturally when elementType is not a recognised collection interface. // visited guards against self-referential types (e.g. class A : IEnumerable). - private static string? BuildElementComparerExpression(ITypeSymbol elementType, HashSet? visited = null) + // dictKind propagates the outer dictionary ordering intent: when OrderedDictionary, any nested + // dictionary encountered as a value type also uses OrderedDictionaryEqualityComparer so that + // the ordering semantics are consistent at every level of nesting. + private static string? BuildElementComparerExpression(ITypeSymbol elementType, HashSet? visited = null, ComparerTypes dictKind = ComparerTypes.Dictionary) { if (elementType is IArrayTypeSymbol arrayType) return BuildArrayComparerExpression(arrayType); @@ -375,7 +378,7 @@ INamedTypeSymbol namedType when IsDictionary(namedType) || namedType.AllInterfac { var isReadOnly = IsReadOnlyDictionary(named) || (IsDictionary(named) is false && named.AllInterfaces.Any(IsReadOnlyDictionary)); - return BuildDictComparerExpression(asDictInterface, isReadOnly, visited); + return BuildDictComparerExpression(asDictInterface, isReadOnly, visited, dictKind); } var asEnumInterface = IsEnumerable(named) ? named @@ -384,7 +387,7 @@ INamedTypeSymbol namedType when IsDictionary(namedType) || namedType.AllInterfac if (asEnumInterface != null) { var innerType = asEnumInterface.TypeArguments[0]; - var innerExpr = BuildElementComparerExpression(innerType, visited); + var innerExpr = BuildElementComparerExpression(innerType, visited, dictKind); var innerTypeFq = innerType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); var isSet = named.AllInterfaces.Any(i => i is { Name: "ISet" or "IReadOnlySet", IsGenericType: true }) @@ -402,18 +405,26 @@ INamedTypeSymbol namedType when IsDictionary(namedType) || namedType.AllInterfac return null; } - private static string BuildDictComparerExpression(INamedTypeSymbol dictInterface, bool isReadOnly, HashSet? visited = null) + private static string BuildDictComparerExpression(INamedTypeSymbol dictInterface, bool isReadOnly, HashSet? visited = null, ComparerTypes dictKind = ComparerTypes.Dictionary) { var keyType = dictInterface.TypeArguments[0]; var valueType = dictInterface.TypeArguments[1]; var keyTypeFq = keyType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); var valueTypeFq = valueType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - var keyExpr = BuildElementComparerExpression(keyType, visited) + var keyExpr = BuildElementComparerExpression(keyType, visited, dictKind) ?? $"global::System.Collections.Generic.EqualityComparer<{keyTypeFq}>.Default"; - var valueExpr = BuildElementComparerExpression(valueType, visited) + var valueExpr = BuildElementComparerExpression(valueType, visited, dictKind) ?? $"global::System.Collections.Generic.EqualityComparer<{valueTypeFq}>.Default"; + if (dictKind == ComparerTypes.OrderedDictionary) + { + var orderedClass = isReadOnly + ? "global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer" + : "global::Equatable.Comparers.OrderedDictionaryEqualityComparer"; + return $"new {orderedClass}<{keyTypeFq}, {valueTypeFq}>({keyExpr}, {valueExpr})"; + } + var comparerClass = isReadOnly ? "global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer" : "global::Equatable.Comparers.DictionaryEqualityComparer"; diff --git a/test/Equatable.Generator.Tests/Comparers/OrderedDictionaryEqualityComparerTest.cs b/test/Equatable.Generator.Tests/Comparers/OrderedDictionaryEqualityComparerTest.cs index d7ecb1a..413f722 100644 --- a/test/Equatable.Generator.Tests/Comparers/OrderedDictionaryEqualityComparerTest.cs +++ b/test/Equatable.Generator.Tests/Comparers/OrderedDictionaryEqualityComparerTest.cs @@ -5,6 +5,80 @@ namespace Equatable.Generator.Tests.Comparers; public class OrderedDictionaryEqualityComparerTest { + // ── nested: OrderedDictionaryEqualityComparer> ── + // Verifies that when the value comparer is itself an OrderedDictionaryEqualityComparer, + // inner-dict insertion order is also irrelevant (both levels sorted by key). + + private static OrderedDictionaryEqualityComparer> NestedOrdered() + => new(EqualityComparer.Default, + new OrderedDictionaryEqualityComparer()); + + [Fact] + public void Nested_OrderedBothLevels_InnerInsertionOrderDiffers_ReturnsTrue() + { + var inner1 = new Dictionary { ["x"] = 1, ["y"] = 2 }; + var inner2 = new Dictionary { ["y"] = 2, ["x"] = 1 }; + var a = new Dictionary> { ["k"] = inner1 }; + var b = new Dictionary> { ["k"] = inner2 }; + + Assert.True(NestedOrdered().Equals(a, b)); + } + + [Fact] + public void Nested_OrderedBothLevels_OuterInsertionOrderDiffers_ReturnsTrue() + { + var a = new Dictionary> + { + ["a"] = new Dictionary { ["x"] = 1 }, + ["b"] = new Dictionary { ["y"] = 2 }, + }; + var b = new Dictionary> + { + ["b"] = new Dictionary { ["y"] = 2 }, + ["a"] = new Dictionary { ["x"] = 1 }, + }; + + Assert.True(NestedOrdered().Equals(a, b)); + } + + [Fact] + public void Nested_OrderedBothLevels_InnerValueDiffers_ReturnsFalse() + { + var a = new Dictionary> { ["k"] = new Dictionary { ["x"] = 1 } }; + var b = new Dictionary> { ["k"] = new Dictionary { ["x"] = 99 } }; + + Assert.False(NestedOrdered().Equals(a, b)); + } + + [Fact] + public void Nested_OrderedBothLevels_SamePairs_GetHashCodesEqual() + { + var inner1 = new Dictionary { ["a"] = 1, ["b"] = 2 }; + var inner2 = new Dictionary { ["b"] = 2, ["a"] = 1 }; + var a = new Dictionary> { ["k"] = inner1 }; + var b = new Dictionary> { ["k"] = inner2 }; + + var cmp = NestedOrdered(); + Assert.Equal(cmp.GetHashCode(a), cmp.GetHashCode(b)); + } + + // ── nested with unordered inner: verify behaviour when inner is DictionaryEqualityComparer ── + + private static OrderedDictionaryEqualityComparer> NestedUnordered() + => new(EqualityComparer.Default, + new DictionaryEqualityComparer()); + + [Fact] + public void Nested_OrderedOuter_UnorderedInner_InnerInsertionOrderDiffers_ReturnsTrue() + { + // Inner uses DictionaryEqualityComparer (unordered) → inner insertion order irrelevant. + var a = new Dictionary> { ["k"] = new Dictionary { ["x"] = 1, ["y"] = 2 } }; + var b = new Dictionary> { ["k"] = new Dictionary { ["y"] = 2, ["x"] = 1 } }; + + Assert.True(NestedUnordered().Equals(a, b)); + } + + private static readonly OrderedDictionaryEqualityComparer Comparer = OrderedDictionaryEqualityComparer.Default; diff --git a/test/Equatable.Generator.Tests/DataContractGeneratorTest.cs b/test/Equatable.Generator.Tests/DataContractGeneratorTest.cs index bc24ad1..c8f1dcc 100644 --- a/test/Equatable.Generator.Tests/DataContractGeneratorTest.cs +++ b/test/Equatable.Generator.Tests/DataContractGeneratorTest.cs @@ -239,6 +239,34 @@ public partial class AllIgnored public string? InternalNote { get; set; } } +"; + var (diagnostics, output) = GetGeneratedOutput(source); + Assert.Empty(diagnostics); + return Verifier.Verify(output).UseDirectory("Snapshots").ScrubLinesContaining("GeneratedCodeAttribute"); + } + + [Fact] + public Task GenerateDataContractEquatableWithSequentialNestedDictPropagation() + { + var source = @" +using System.Collections.Generic; +using System.Runtime.Serialization; +using Equatable.Attributes; +using Equatable.Attributes.DataContract; + +namespace Equatable.Entities; + +[DataContract] +[DataContractEquatable] +public partial class NestedOrderedContract +{ + [DataMember(Order = 0)] + public int Id { get; set; } + + [DataMember(Order = 1)] + [DictionaryEquality(sequential: true)] + public Dictionary>? NestedDicts { get; set; } +} "; var (diagnostics, output) = GetGeneratedOutput(source); Assert.Empty(diagnostics); diff --git a/test/Equatable.Generator.Tests/EquatableGeneratorTest.cs b/test/Equatable.Generator.Tests/EquatableGeneratorTest.cs index 2e62e23..d10800c 100644 --- a/test/Equatable.Generator.Tests/EquatableGeneratorTest.cs +++ b/test/Equatable.Generator.Tests/EquatableGeneratorTest.cs @@ -983,6 +983,27 @@ public partial class Empty return Verifier.Verify(output).UseDirectory("Snapshots").ScrubLinesContaining("GeneratedCodeAttribute"); } + [Fact] + public Task GenerateSequentialDictionaryEquality_NestedDictPropagatesOrderedComparer() + { + var source = @" +using System.Collections.Generic; +using Equatable.Attributes; + +namespace Equatable.Entities; + +[Equatable] +public partial class Container +{ + [DictionaryEquality(sequential: true)] + public Dictionary>? NestedDicts { get; set; } +} +"; + var (diagnostics, output) = GetGeneratedOutput(source); + Assert.Empty(diagnostics); + return Verifier.Verify(output).UseDirectory("Snapshots").ScrubLinesContaining("GeneratedCodeAttribute"); + } + // Pinned references that must always be present regardless of AppDomain load order. // Adapter attribute assemblies and serialization libraries may not be loaded when a test runs first. private static readonly IEnumerable PinnedReferences = diff --git a/test/Equatable.Generator.Tests/MessagePackGeneratorTest.cs b/test/Equatable.Generator.Tests/MessagePackGeneratorTest.cs index d3350a9..e5e0ed9 100644 --- a/test/Equatable.Generator.Tests/MessagePackGeneratorTest.cs +++ b/test/Equatable.Generator.Tests/MessagePackGeneratorTest.cs @@ -239,6 +239,34 @@ public partial class AllIgnored public string? InternalNote { get; set; } } +"; + var (diagnostics, output) = GetGeneratedOutput(source); + Assert.Empty(diagnostics); + return Verifier.Verify(output).UseDirectory("Snapshots").ScrubLinesContaining("GeneratedCodeAttribute"); + } + + [Fact] + public Task GenerateMessagePackEquatableWithSequentialNestedDictPropagation() + { + var source = @" +using System.Collections.Generic; +using MessagePack; +using Equatable.Attributes; +using Equatable.Attributes.MessagePack; + +namespace Equatable.Entities; + +[MessagePackObject] +[MessagePackEquatable] +public partial class NestedOrderedContract +{ + [Key(0)] + public int Id { get; set; } + + [Key(1)] + [DictionaryEquality(sequential: true)] + public Dictionary>? NestedDicts { get; set; } +} "; var (diagnostics, output) = GetGeneratedOutput(source); Assert.Empty(diagnostics); diff --git a/test/Equatable.Generator.Tests/Snapshots/DataContractGeneratorTest.GenerateDataContractEquatableWithSequentialNestedDictPropagation.verified.txt b/test/Equatable.Generator.Tests/Snapshots/DataContractGeneratorTest.GenerateDataContractEquatableWithSequentialNestedDictPropagation.verified.txt new file mode 100644 index 0000000..eeb199e --- /dev/null +++ b/test/Equatable.Generator.Tests/Snapshots/DataContractGeneratorTest.GenerateDataContractEquatableWithSequentialNestedDictPropagation.verified.txt @@ -0,0 +1,45 @@ +// +#nullable enable + +namespace Equatable.Entities +{ + partial class NestedOrderedContract : global::System.IEquatable + { + /// + public bool Equals(global::Equatable.Entities.NestedOrderedContract? other) + { + return !(other is null) + && Id == other.Id + && (new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer>(global::System.Collections.Generic.EqualityComparer.Default, new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default))).Equals(NestedDicts, other.NestedDicts); + + } + + /// + public override bool Equals(object? obj) + { + return Equals(obj as global::Equatable.Entities.NestedOrderedContract); + } + + /// + public static bool operator ==(global::Equatable.Entities.NestedOrderedContract? left, global::Equatable.Entities.NestedOrderedContract? right) + { + return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); + } + + /// + public static bool operator !=(global::Equatable.Entities.NestedOrderedContract? left, global::Equatable.Entities.NestedOrderedContract? right) + { + return !(left == right); + } + + /// + public override int GetHashCode(){ + int hashCode = 1020457703; + hashCode = (hashCode * -1521134295) + Id.GetHashCode(); + hashCode = (hashCode * -1521134295) + (new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer>(global::System.Collections.Generic.EqualityComparer.Default, new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default))).GetHashCode(NestedDicts!); + return hashCode; + + } + + } +} diff --git a/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateSequentialDictionaryEquality_NestedDictPropagatesOrderedComparer.verified.txt b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateSequentialDictionaryEquality_NestedDictPropagatesOrderedComparer.verified.txt new file mode 100644 index 0000000..fd0cdd8 --- /dev/null +++ b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateSequentialDictionaryEquality_NestedDictPropagatesOrderedComparer.verified.txt @@ -0,0 +1,43 @@ +// +#nullable enable + +namespace Equatable.Entities +{ + partial class Container : global::System.IEquatable + { + /// + public bool Equals(global::Equatable.Entities.Container? other) + { + return !(other is null) + && (new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer>(global::System.Collections.Generic.EqualityComparer.Default, new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default))).Equals(NestedDicts, other.NestedDicts); + + } + + /// + public override bool Equals(object? obj) + { + return Equals(obj as global::Equatable.Entities.Container); + } + + /// + public static bool operator ==(global::Equatable.Entities.Container? left, global::Equatable.Entities.Container? right) + { + return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); + } + + /// + public static bool operator !=(global::Equatable.Entities.Container? left, global::Equatable.Entities.Container? right) + { + return !(left == right); + } + + /// + public override int GetHashCode(){ + int hashCode = -1088400921; + hashCode = (hashCode * -1521134295) + (new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer>(global::System.Collections.Generic.EqualityComparer.Default, new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default))).GetHashCode(NestedDicts!); + return hashCode; + + } + + } +} diff --git a/test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatableWithSequentialNestedDictPropagation.verified.txt b/test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatableWithSequentialNestedDictPropagation.verified.txt new file mode 100644 index 0000000..eeb199e --- /dev/null +++ b/test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatableWithSequentialNestedDictPropagation.verified.txt @@ -0,0 +1,45 @@ +// +#nullable enable + +namespace Equatable.Entities +{ + partial class NestedOrderedContract : global::System.IEquatable + { + /// + public bool Equals(global::Equatable.Entities.NestedOrderedContract? other) + { + return !(other is null) + && Id == other.Id + && (new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer>(global::System.Collections.Generic.EqualityComparer.Default, new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default))).Equals(NestedDicts, other.NestedDicts); + + } + + /// + public override bool Equals(object? obj) + { + return Equals(obj as global::Equatable.Entities.NestedOrderedContract); + } + + /// + public static bool operator ==(global::Equatable.Entities.NestedOrderedContract? left, global::Equatable.Entities.NestedOrderedContract? right) + { + return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); + } + + /// + public static bool operator !=(global::Equatable.Entities.NestedOrderedContract? left, global::Equatable.Entities.NestedOrderedContract? right) + { + return !(left == right); + } + + /// + public override int GetHashCode(){ + int hashCode = 1020457703; + hashCode = (hashCode * -1521134295) + Id.GetHashCode(); + hashCode = (hashCode * -1521134295) + (new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer>(global::System.Collections.Generic.EqualityComparer.Default, new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default))).GetHashCode(NestedDicts!); + return hashCode; + + } + + } +} From 5db8f93a81a4aefa3f7208bd618aed7773f94a39 Mon Sep 17 00:00:00 2001 From: "a.kasyanov" Date: Thu, 14 May 2026 00:46:56 +0300 Subject: [PATCH 21/71] feat: support [HashSetEquality] on List/T[] and [SequenceEquality] on HashSet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes two missing cases for explicit comparer overrides that reverse natural order-sensitivity: 1. [HashSetEquality] on T[] (array): ValidateComparer now accepts rank-1 arrays for HashSet kind; new BuildHashSetArrayComparerExpression emits HashSetEqualityComparer instead of SequenceEqualityComparer. 2. [SequenceEquality] on HashSet: already validated and emitted correctly (no code change needed); documented with explicit snapshot tests. Adds 6 snapshot tests (base + DataContract + MessagePack × 2 directions). Co-Authored-By: Claude Sonnet 4.5 --- .../EquatableGenerator.cs | 16 +++- .../DataContractGeneratorTest.cs | 60 ++++++++++++++ .../EquatableGeneratorTest.cs | 48 ++++++++++++ .../MessagePackGeneratorTest.cs | 60 ++++++++++++++ ...HashSetEqualityOnListAndArray.verified.txt | 78 +++++++++++++++++++ ...WithSequenceEqualityOnHashSet.verified.txt | 69 ++++++++++++++++ ...HashSetEqualityOnListAndArray.verified.txt | 76 ++++++++++++++++++ ...rateSequenceEqualityOnHashSet.verified.txt | 67 ++++++++++++++++ ...HashSetEqualityOnListAndArray.verified.txt | 78 +++++++++++++++++++ ...WithSequenceEqualityOnHashSet.verified.txt | 69 ++++++++++++++++ 10 files changed, 620 insertions(+), 1 deletion(-) create mode 100644 test/Equatable.Generator.Tests/Snapshots/DataContractGeneratorTest.GenerateDataContractEquatableWithHashSetEqualityOnListAndArray.verified.txt create mode 100644 test/Equatable.Generator.Tests/Snapshots/DataContractGeneratorTest.GenerateDataContractEquatableWithSequenceEqualityOnHashSet.verified.txt create mode 100644 test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateHashSetEqualityOnListAndArray.verified.txt create mode 100644 test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateSequenceEqualityOnHashSet.verified.txt create mode 100644 test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatableWithHashSetEqualityOnListAndArray.verified.txt create mode 100644 test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatableWithSequenceEqualityOnHashSet.verified.txt diff --git a/src/Equatable.SourceGenerator/EquatableGenerator.cs b/src/Equatable.SourceGenerator/EquatableGenerator.cs index fae07cc..841a67c 100644 --- a/src/Equatable.SourceGenerator/EquatableGenerator.cs +++ b/src/Equatable.SourceGenerator/EquatableGenerator.cs @@ -169,6 +169,8 @@ private static EquatableProperty CreateProperty(IPropertySymbol propertySymbol) string? expression = propertySymbol.Type switch { INamedTypeSymbol namedType => BuildCollectionComparerExpression(namedType, comparerType.Value), + IArrayTypeSymbol arrayType when comparerType == ComparerTypes.HashSet + => BuildHashSetArrayComparerExpression(arrayType), IArrayTypeSymbol arrayType => BuildArrayComparerExpression(arrayType), _ => null }; @@ -452,6 +454,17 @@ private static string BuildArrayComparerExpression(IArrayTypeSymbol arrayType) return $"global::Equatable.Comparers.SequenceEqualityComparer<{elementTypeFq}>.Default"; } + private static string BuildHashSetArrayComparerExpression(IArrayTypeSymbol arrayType) + { + var elementType = arrayType.ElementType; + var elementTypeFq = elementType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + var innerExpr = BuildElementComparerExpression(elementType); + + if (innerExpr != null) + return $"new global::Equatable.Comparers.HashSetEqualityComparer<{elementTypeFq}>({innerExpr})"; + return $"global::Equatable.Comparers.HashSetEqualityComparer<{elementTypeFq}>.Default"; + } + private static bool IsReadOnlyDictionary(INamedTypeSymbol targetSymbol) { return targetSymbol is @@ -485,7 +498,8 @@ private static bool ValidateComparer(IPropertySymbol propertySymbol, ComparerTyp || propertySymbol.Type.AllInterfaces.Any(IsDictionary); if (comparerType == ComparerTypes.HashSet) - return (propertySymbol.Type is INamedTypeSymbol ntHs && IsEnumerable(ntHs)) + return propertySymbol.Type is IArrayTypeSymbol { Rank: 1 } + || (propertySymbol.Type is INamedTypeSymbol ntHs && IsEnumerable(ntHs)) || propertySymbol.Type.AllInterfaces.Any(IsEnumerable); if (comparerType == ComparerTypes.Sequence) diff --git a/test/Equatable.Generator.Tests/DataContractGeneratorTest.cs b/test/Equatable.Generator.Tests/DataContractGeneratorTest.cs index c8f1dcc..2b6622e 100644 --- a/test/Equatable.Generator.Tests/DataContractGeneratorTest.cs +++ b/test/Equatable.Generator.Tests/DataContractGeneratorTest.cs @@ -245,6 +245,66 @@ public partial class AllIgnored return Verifier.Verify(output).UseDirectory("Snapshots").ScrubLinesContaining("GeneratedCodeAttribute"); } + [Fact] + public Task GenerateDataContractEquatableWithHashSetEqualityOnListAndArray() + { + var source = @" +using System.Collections.Generic; +using System.Runtime.Serialization; +using Equatable.Attributes; +using Equatable.Attributes.DataContract; + +namespace Equatable.Entities; + +[DataContract] +[DataContractEquatable] +public partial class HashSetOverrideContract +{ + [DataMember(Order = 0)] + public int Id { get; set; } + + [DataMember(Order = 1)] + [HashSetEquality] + public List? Tags { get; set; } + + [DataMember(Order = 2)] + [HashSetEquality] + public int[]? Codes { get; set; } +} +"; + var (diagnostics, output) = GetGeneratedOutput(source); + Assert.Empty(diagnostics); + return Verifier.Verify(output).UseDirectory("Snapshots").ScrubLinesContaining("GeneratedCodeAttribute"); + } + + [Fact] + public Task GenerateDataContractEquatableWithSequenceEqualityOnHashSet() + { + var source = @" +using System.Collections.Generic; +using System.Runtime.Serialization; +using Equatable.Attributes; +using Equatable.Attributes.DataContract; + +namespace Equatable.Entities; + +[DataContract] +[DataContractEquatable] +public partial class SequenceOverrideContract +{ + [DataMember(Order = 0)] + public int Id { get; set; } + + [DataMember(Order = 1)] + [SequenceEquality] + public HashSet? Tags { get; set; } +} +"; + var (diagnostics, output) = GetGeneratedOutput(source); + Assert.Empty(diagnostics); + return Verifier.Verify(output).UseDirectory("Snapshots").ScrubLinesContaining("GeneratedCodeAttribute"); + } + [Fact] public Task GenerateDataContractEquatableWithSequentialNestedDictPropagation() { diff --git a/test/Equatable.Generator.Tests/EquatableGeneratorTest.cs b/test/Equatable.Generator.Tests/EquatableGeneratorTest.cs index d10800c..7a63167 100644 --- a/test/Equatable.Generator.Tests/EquatableGeneratorTest.cs +++ b/test/Equatable.Generator.Tests/EquatableGeneratorTest.cs @@ -983,6 +983,54 @@ public partial class Empty return Verifier.Verify(output).UseDirectory("Snapshots").ScrubLinesContaining("GeneratedCodeAttribute"); } + [Fact] + public Task GenerateHashSetEqualityOnListAndArray() + { + var source = @" +using System.Collections.Generic; +using Equatable.Attributes; + +namespace Equatable.Entities; + +[Equatable] +public partial class Container +{ + /// List is normally order-sensitive; [HashSetEquality] makes it order-insensitive. + [HashSetEquality] + public List? Tags { get; set; } + + /// Array is normally order-sensitive; [HashSetEquality] makes it order-insensitive. + [HashSetEquality] + public int[]? Codes { get; set; } +} +"; + var (diagnostics, output) = GetGeneratedOutput(source); + Assert.Empty(diagnostics); + return Verifier.Verify(output).UseDirectory("Snapshots").ScrubLinesContaining("GeneratedCodeAttribute"); + } + + [Fact] + public Task GenerateSequenceEqualityOnHashSet() + { + var source = @" +using System.Collections.Generic; +using Equatable.Attributes; + +namespace Equatable.Entities; + +[Equatable] +public partial class Container +{ + /// HashSet is normally order-insensitive; [SequenceEquality] makes it order-sensitive. + [SequenceEquality] + public HashSet? Tags { get; set; } +} +"; + var (diagnostics, output) = GetGeneratedOutput(source); + Assert.Empty(diagnostics); + return Verifier.Verify(output).UseDirectory("Snapshots").ScrubLinesContaining("GeneratedCodeAttribute"); + } + [Fact] public Task GenerateSequentialDictionaryEquality_NestedDictPropagatesOrderedComparer() { diff --git a/test/Equatable.Generator.Tests/MessagePackGeneratorTest.cs b/test/Equatable.Generator.Tests/MessagePackGeneratorTest.cs index e5e0ed9..0f65119 100644 --- a/test/Equatable.Generator.Tests/MessagePackGeneratorTest.cs +++ b/test/Equatable.Generator.Tests/MessagePackGeneratorTest.cs @@ -245,6 +245,66 @@ public partial class AllIgnored return Verifier.Verify(output).UseDirectory("Snapshots").ScrubLinesContaining("GeneratedCodeAttribute"); } + [Fact] + public Task GenerateMessagePackEquatableWithHashSetEqualityOnListAndArray() + { + var source = @" +using System.Collections.Generic; +using MessagePack; +using Equatable.Attributes; +using Equatable.Attributes.MessagePack; + +namespace Equatable.Entities; + +[MessagePackObject] +[MessagePackEquatable] +public partial class HashSetOverrideContract +{ + [Key(0)] + public int Id { get; set; } + + [Key(1)] + [HashSetEquality] + public List? Tags { get; set; } + + [Key(2)] + [HashSetEquality] + public int[]? Codes { get; set; } +} +"; + var (diagnostics, output) = GetGeneratedOutput(source); + Assert.Empty(diagnostics); + return Verifier.Verify(output).UseDirectory("Snapshots").ScrubLinesContaining("GeneratedCodeAttribute"); + } + + [Fact] + public Task GenerateMessagePackEquatableWithSequenceEqualityOnHashSet() + { + var source = @" +using System.Collections.Generic; +using MessagePack; +using Equatable.Attributes; +using Equatable.Attributes.MessagePack; + +namespace Equatable.Entities; + +[MessagePackObject] +[MessagePackEquatable] +public partial class SequenceOverrideContract +{ + [Key(0)] + public int Id { get; set; } + + [Key(1)] + [SequenceEquality] + public HashSet? Tags { get; set; } +} +"; + var (diagnostics, output) = GetGeneratedOutput(source); + Assert.Empty(diagnostics); + return Verifier.Verify(output).UseDirectory("Snapshots").ScrubLinesContaining("GeneratedCodeAttribute"); + } + [Fact] public Task GenerateMessagePackEquatableWithSequentialNestedDictPropagation() { diff --git a/test/Equatable.Generator.Tests/Snapshots/DataContractGeneratorTest.GenerateDataContractEquatableWithHashSetEqualityOnListAndArray.verified.txt b/test/Equatable.Generator.Tests/Snapshots/DataContractGeneratorTest.GenerateDataContractEquatableWithHashSetEqualityOnListAndArray.verified.txt new file mode 100644 index 0000000..e01ef02 --- /dev/null +++ b/test/Equatable.Generator.Tests/Snapshots/DataContractGeneratorTest.GenerateDataContractEquatableWithHashSetEqualityOnListAndArray.verified.txt @@ -0,0 +1,78 @@ +// +#nullable enable + +namespace Equatable.Entities +{ + partial class HashSetOverrideContract : global::System.IEquatable + { + /// + public bool Equals(global::Equatable.Entities.HashSetOverrideContract? other) + { + return !(other is null) + && Id == other.Id + && HashSetEquals(Tags, other.Tags) + && (global::Equatable.Comparers.HashSetEqualityComparer.Default).Equals(Codes, other.Codes); + + static bool HashSetEquals(global::System.Collections.Generic.IEnumerable? left, global::System.Collections.Generic.IEnumerable? right) + { + if (global::System.Object.ReferenceEquals(left, right)) + return true; + + if (left is null || right is null) + return false; + + if (left is global::System.Collections.Generic.ISet leftSet) + return leftSet.SetEquals(right); + + if (right is global::System.Collections.Generic.ISet rightSet) + return rightSet.SetEquals(left); + + var hashSet = new global::System.Collections.Generic.HashSet(left, global::System.Collections.Generic.EqualityComparer.Default); + return hashSet.SetEquals(right); + } + + } + + /// + public override bool Equals(object? obj) + { + return Equals(obj as global::Equatable.Entities.HashSetOverrideContract); + } + + /// + public static bool operator ==(global::Equatable.Entities.HashSetOverrideContract? left, global::Equatable.Entities.HashSetOverrideContract? right) + { + return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); + } + + /// + public static bool operator !=(global::Equatable.Entities.HashSetOverrideContract? left, global::Equatable.Entities.HashSetOverrideContract? right) + { + return !(left == right); + } + + /// + public override int GetHashCode(){ + int hashCode = 1981424869; + hashCode = (hashCode * -1521134295) + Id.GetHashCode(); + hashCode = (hashCode * -1521134295) + HashSetHashCode(Tags); + hashCode = (hashCode * -1521134295) + (global::Equatable.Comparers.HashSetEqualityComparer.Default).GetHashCode(Codes!); + return hashCode; + + static int HashSetHashCode(global::System.Collections.Generic.IEnumerable? items) + { + if (items is null) + return 0; + + int hashCode = 1; + + foreach (var item in items) + hashCode += global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(item!); + + return hashCode; + } + + } + + } +} diff --git a/test/Equatable.Generator.Tests/Snapshots/DataContractGeneratorTest.GenerateDataContractEquatableWithSequenceEqualityOnHashSet.verified.txt b/test/Equatable.Generator.Tests/Snapshots/DataContractGeneratorTest.GenerateDataContractEquatableWithSequenceEqualityOnHashSet.verified.txt new file mode 100644 index 0000000..cc1f46a --- /dev/null +++ b/test/Equatable.Generator.Tests/Snapshots/DataContractGeneratorTest.GenerateDataContractEquatableWithSequenceEqualityOnHashSet.verified.txt @@ -0,0 +1,69 @@ +// +#nullable enable + +namespace Equatable.Entities +{ + partial class SequenceOverrideContract : global::System.IEquatable + { + /// + public bool Equals(global::Equatable.Entities.SequenceOverrideContract? other) + { + return !(other is null) + && Id == other.Id + && SequenceEquals(Tags, other.Tags); + + static bool SequenceEquals(global::System.Collections.Generic.IEnumerable? left, global::System.Collections.Generic.IEnumerable? right) + { + if (global::System.Object.ReferenceEquals(left, right)) + return true; + + if (left is null || right is null) + return false; + + return global::System.Linq.Enumerable.SequenceEqual(left, right, global::System.Collections.Generic.EqualityComparer.Default); + } + + } + + /// + public override bool Equals(object? obj) + { + return Equals(obj as global::Equatable.Entities.SequenceOverrideContract); + } + + /// + public static bool operator ==(global::Equatable.Entities.SequenceOverrideContract? left, global::Equatable.Entities.SequenceOverrideContract? right) + { + return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); + } + + /// + public static bool operator !=(global::Equatable.Entities.SequenceOverrideContract? left, global::Equatable.Entities.SequenceOverrideContract? right) + { + return !(left == right); + } + + /// + public override int GetHashCode(){ + int hashCode = -193969728; + hashCode = (hashCode * -1521134295) + Id.GetHashCode(); + hashCode = (hashCode * -1521134295) + SequenceHashCode(Tags); + return hashCode; + + static int SequenceHashCode(global::System.Collections.Generic.IEnumerable? items) + { + if (items is null) + return 0; + + int hashCode = -193969728; + + foreach (var item in items) + hashCode = (hashCode * -1521134295) + global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(item!); + + return hashCode; + } + + } + + } +} diff --git a/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateHashSetEqualityOnListAndArray.verified.txt b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateHashSetEqualityOnListAndArray.verified.txt new file mode 100644 index 0000000..342db83 --- /dev/null +++ b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateHashSetEqualityOnListAndArray.verified.txt @@ -0,0 +1,76 @@ +// +#nullable enable + +namespace Equatable.Entities +{ + partial class Container : global::System.IEquatable + { + /// + public bool Equals(global::Equatable.Entities.Container? other) + { + return !(other is null) + && HashSetEquals(Tags, other.Tags) + && (global::Equatable.Comparers.HashSetEqualityComparer.Default).Equals(Codes, other.Codes); + + static bool HashSetEquals(global::System.Collections.Generic.IEnumerable? left, global::System.Collections.Generic.IEnumerable? right) + { + if (global::System.Object.ReferenceEquals(left, right)) + return true; + + if (left is null || right is null) + return false; + + if (left is global::System.Collections.Generic.ISet leftSet) + return leftSet.SetEquals(right); + + if (right is global::System.Collections.Generic.ISet rightSet) + return rightSet.SetEquals(left); + + var hashSet = new global::System.Collections.Generic.HashSet(left, global::System.Collections.Generic.EqualityComparer.Default); + return hashSet.SetEquals(right); + } + + } + + /// + public override bool Equals(object? obj) + { + return Equals(obj as global::Equatable.Entities.Container); + } + + /// + public static bool operator ==(global::Equatable.Entities.Container? left, global::Equatable.Entities.Container? right) + { + return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); + } + + /// + public static bool operator !=(global::Equatable.Entities.Container? left, global::Equatable.Entities.Container? right) + { + return !(left == right); + } + + /// + public override int GetHashCode(){ + int hashCode = -1878434843; + hashCode = (hashCode * -1521134295) + HashSetHashCode(Tags); + hashCode = (hashCode * -1521134295) + (global::Equatable.Comparers.HashSetEqualityComparer.Default).GetHashCode(Codes!); + return hashCode; + + static int HashSetHashCode(global::System.Collections.Generic.IEnumerable? items) + { + if (items is null) + return 0; + + int hashCode = 1; + + foreach (var item in items) + hashCode += global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(item!); + + return hashCode; + } + + } + + } +} diff --git a/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateSequenceEqualityOnHashSet.verified.txt b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateSequenceEqualityOnHashSet.verified.txt new file mode 100644 index 0000000..0cb100e --- /dev/null +++ b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateSequenceEqualityOnHashSet.verified.txt @@ -0,0 +1,67 @@ +// +#nullable enable + +namespace Equatable.Entities +{ + partial class Container : global::System.IEquatable + { + /// + public bool Equals(global::Equatable.Entities.Container? other) + { + return !(other is null) + && SequenceEquals(Tags, other.Tags); + + static bool SequenceEquals(global::System.Collections.Generic.IEnumerable? left, global::System.Collections.Generic.IEnumerable? right) + { + if (global::System.Object.ReferenceEquals(left, right)) + return true; + + if (left is null || right is null) + return false; + + return global::System.Linq.Enumerable.SequenceEqual(left, right, global::System.Collections.Generic.EqualityComparer.Default); + } + + } + + /// + public override bool Equals(object? obj) + { + return Equals(obj as global::Equatable.Entities.Container); + } + + /// + public static bool operator ==(global::Equatable.Entities.Container? left, global::Equatable.Entities.Container? right) + { + return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); + } + + /// + public static bool operator !=(global::Equatable.Entities.Container? left, global::Equatable.Entities.Container? right) + { + return !(left == right); + } + + /// + public override int GetHashCode(){ + int hashCode = 1992138944; + hashCode = (hashCode * -1521134295) + SequenceHashCode(Tags); + return hashCode; + + static int SequenceHashCode(global::System.Collections.Generic.IEnumerable? items) + { + if (items is null) + return 0; + + int hashCode = 1992138944; + + foreach (var item in items) + hashCode = (hashCode * -1521134295) + global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(item!); + + return hashCode; + } + + } + + } +} diff --git a/test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatableWithHashSetEqualityOnListAndArray.verified.txt b/test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatableWithHashSetEqualityOnListAndArray.verified.txt new file mode 100644 index 0000000..e01ef02 --- /dev/null +++ b/test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatableWithHashSetEqualityOnListAndArray.verified.txt @@ -0,0 +1,78 @@ +// +#nullable enable + +namespace Equatable.Entities +{ + partial class HashSetOverrideContract : global::System.IEquatable + { + /// + public bool Equals(global::Equatable.Entities.HashSetOverrideContract? other) + { + return !(other is null) + && Id == other.Id + && HashSetEquals(Tags, other.Tags) + && (global::Equatable.Comparers.HashSetEqualityComparer.Default).Equals(Codes, other.Codes); + + static bool HashSetEquals(global::System.Collections.Generic.IEnumerable? left, global::System.Collections.Generic.IEnumerable? right) + { + if (global::System.Object.ReferenceEquals(left, right)) + return true; + + if (left is null || right is null) + return false; + + if (left is global::System.Collections.Generic.ISet leftSet) + return leftSet.SetEquals(right); + + if (right is global::System.Collections.Generic.ISet rightSet) + return rightSet.SetEquals(left); + + var hashSet = new global::System.Collections.Generic.HashSet(left, global::System.Collections.Generic.EqualityComparer.Default); + return hashSet.SetEquals(right); + } + + } + + /// + public override bool Equals(object? obj) + { + return Equals(obj as global::Equatable.Entities.HashSetOverrideContract); + } + + /// + public static bool operator ==(global::Equatable.Entities.HashSetOverrideContract? left, global::Equatable.Entities.HashSetOverrideContract? right) + { + return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); + } + + /// + public static bool operator !=(global::Equatable.Entities.HashSetOverrideContract? left, global::Equatable.Entities.HashSetOverrideContract? right) + { + return !(left == right); + } + + /// + public override int GetHashCode(){ + int hashCode = 1981424869; + hashCode = (hashCode * -1521134295) + Id.GetHashCode(); + hashCode = (hashCode * -1521134295) + HashSetHashCode(Tags); + hashCode = (hashCode * -1521134295) + (global::Equatable.Comparers.HashSetEqualityComparer.Default).GetHashCode(Codes!); + return hashCode; + + static int HashSetHashCode(global::System.Collections.Generic.IEnumerable? items) + { + if (items is null) + return 0; + + int hashCode = 1; + + foreach (var item in items) + hashCode += global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(item!); + + return hashCode; + } + + } + + } +} diff --git a/test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatableWithSequenceEqualityOnHashSet.verified.txt b/test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatableWithSequenceEqualityOnHashSet.verified.txt new file mode 100644 index 0000000..cc1f46a --- /dev/null +++ b/test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatableWithSequenceEqualityOnHashSet.verified.txt @@ -0,0 +1,69 @@ +// +#nullable enable + +namespace Equatable.Entities +{ + partial class SequenceOverrideContract : global::System.IEquatable + { + /// + public bool Equals(global::Equatable.Entities.SequenceOverrideContract? other) + { + return !(other is null) + && Id == other.Id + && SequenceEquals(Tags, other.Tags); + + static bool SequenceEquals(global::System.Collections.Generic.IEnumerable? left, global::System.Collections.Generic.IEnumerable? right) + { + if (global::System.Object.ReferenceEquals(left, right)) + return true; + + if (left is null || right is null) + return false; + + return global::System.Linq.Enumerable.SequenceEqual(left, right, global::System.Collections.Generic.EqualityComparer.Default); + } + + } + + /// + public override bool Equals(object? obj) + { + return Equals(obj as global::Equatable.Entities.SequenceOverrideContract); + } + + /// + public static bool operator ==(global::Equatable.Entities.SequenceOverrideContract? left, global::Equatable.Entities.SequenceOverrideContract? right) + { + return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); + } + + /// + public static bool operator !=(global::Equatable.Entities.SequenceOverrideContract? left, global::Equatable.Entities.SequenceOverrideContract? right) + { + return !(left == right); + } + + /// + public override int GetHashCode(){ + int hashCode = -193969728; + hashCode = (hashCode * -1521134295) + Id.GetHashCode(); + hashCode = (hashCode * -1521134295) + SequenceHashCode(Tags); + return hashCode; + + static int SequenceHashCode(global::System.Collections.Generic.IEnumerable? items) + { + if (items is null) + return 0; + + int hashCode = -193969728; + + foreach (var item in items) + hashCode = (hashCode * -1521134295) + global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(item!); + + return hashCode; + } + + } + + } +} From b4be828d42e14c7bc115a5376389dd8f0eff6b37 Mon Sep 17 00:00:00 2001 From: "a.kasyanov" Date: Thu, 14 May 2026 01:09:50 +0300 Subject: [PATCH 22/71] feat: propagate explicit enumKind/dictKind annotations into all nested collection levels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When [HashSetEquality] or [SequenceEquality] is explicitly annotated on a property, the chosen comparer class now propagates into every nested enumerable/array level rather than only the outermost. Similarly, [DictionaryEquality(sequential:true)] already propagated dictKind to nested dicts; tests now cover 3-level nesting, mixed dict+list, and the unordered case symmetrically with the enumKind tests. - EquatableGenerator: thread enumKind through all nested collection builder calls; remove IsEnumKindOverride guard so all explicit annotations always propagate - ValidateComparer: accept IArrayTypeSymbol{Rank:1} as valid target for [HashSetEquality] - BuildHashSetArrayComparerExpression: new helper for [HashSetEquality] on T[] - NestedCollectionsProperties: rename ListOfSets_InnerOrderDoesNotMatter → ListOfSets_InnerOrderMatters now that [SequenceEquality] propagates into inner sets - New snapshot tests: GenerateHashSetEqualityPropagatesIntoNestedCollections, GenerateSequenceEqualityPropagatesIntoNestedCollections, GenerateDictionaryEqualityPropagatesIntoNestedDictionaries (+ DataContract/MessagePack variants) Co-Authored-By: Claude Sonnet 4.5 --- .../EquatableGenerator.cs | 79 ++++++++++----- .../Properties/NestedCollectionsProperties.cs | 7 +- .../DataContractGeneratorTest.cs | 44 +++++++++ .../EquatableGeneratorTest.cs | 95 +++++++++++++++++++ .../MessagePackGeneratorTest.cs | 44 +++++++++ .../Properties/NestedCollectionsProperties.cs | 11 ++- ...DictionaryEqualityPropagation.verified.txt | 51 ++++++++++ ...pagatesIntoNestedDictionaries.verified.txt | 49 ++++++++++ ...opagatesIntoNestedCollections.verified.txt | 47 +++++++++ ...opagatesIntoNestedCollections.verified.txt | 45 +++++++++ ...DictionaryEqualityPropagation.verified.txt | 51 ++++++++++ 11 files changed, 495 insertions(+), 28 deletions(-) create mode 100644 test/Equatable.Generator.Tests/Snapshots/DataContractGeneratorTest.GenerateDataContractEquatableWithDictionaryEqualityPropagation.verified.txt create mode 100644 test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateDictionaryEqualityPropagatesIntoNestedDictionaries.verified.txt create mode 100644 test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateHashSetEqualityPropagatesIntoNestedCollections.verified.txt create mode 100644 test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateSequenceEqualityPropagatesIntoNestedCollections.verified.txt create mode 100644 test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatableWithDictionaryEqualityPropagation.verified.txt diff --git a/src/Equatable.SourceGenerator/EquatableGenerator.cs b/src/Equatable.SourceGenerator/EquatableGenerator.cs index 841a67c..d47066d 100644 --- a/src/Equatable.SourceGenerator/EquatableGenerator.cs +++ b/src/Equatable.SourceGenerator/EquatableGenerator.cs @@ -166,12 +166,21 @@ private static EquatableProperty CreateProperty(IPropertySymbol propertySymbol) // (including multi-dimensional, which use MultiDimensionalArrayEqualityComparer). if (comparerType is ComparerTypes.Dictionary or ComparerTypes.OrderedDictionary or ComparerTypes.HashSet or ComparerTypes.Sequence) { + // enumKind is always propagated for explicit HashSet/Sequence annotations — the user's + // explicit declaration signals intent for ALL nested collection levels, not just the + // outermost one. [SequenceEquality] on List> means every level is + // order-sensitive; [HashSetEquality] on HashSet> means every level is + // order-insensitive. Without an explicit annotation (inference path), nested + // collections keep their own natural comparer. + var enumKind = comparerType is ComparerTypes.HashSet or ComparerTypes.Sequence + ? comparerType + : (ComparerTypes?)null; string? expression = propertySymbol.Type switch { - INamedTypeSymbol namedType => BuildCollectionComparerExpression(namedType, comparerType.Value), + INamedTypeSymbol namedType => BuildCollectionComparerExpression(namedType, comparerType.Value, enumKind), IArrayTypeSymbol arrayType when comparerType == ComparerTypes.HashSet - => BuildHashSetArrayComparerExpression(arrayType), - IArrayTypeSymbol arrayType => BuildArrayComparerExpression(arrayType), + => BuildHashSetArrayComparerExpression(arrayType, enumKind), + IArrayTypeSymbol arrayType => BuildArrayComparerExpression(arrayType, enumKind), _ => null }; if (expression != null) @@ -272,7 +281,7 @@ INamedTypeSymbol namedType when IsDictionary(namedType) || namedType.AllInterfac // Returns null when EqualityComparer.Default is sufficient (element is a plain type). // Depth is unbounded — recursion terminates naturally when an element type is not a // recognised collection interface (BuildElementComparerExpression returns null). - private static string? BuildCollectionComparerExpression(INamedTypeSymbol collectionType, ComparerTypes kind) + private static string? BuildCollectionComparerExpression(INamedTypeSymbol collectionType, ComparerTypes kind, ComparerTypes? enumKind = null) { // unwrap nullable wrapper var unwrapped = collectionType.IsGenericType @@ -333,7 +342,7 @@ INamedTypeSymbol namedType when IsDictionary(namedType) || namedType.AllInterfac if ((kind == ComparerTypes.HashSet || kind == ComparerTypes.Sequence) && enumInterface != null) { var elementType = enumInterface.TypeArguments[0]; - var elementExpr = BuildElementComparerExpression(elementType); + var elementExpr = BuildElementComparerExpression(elementType, enumKind: enumKind); if (elementExpr == null) return null; @@ -352,13 +361,16 @@ INamedTypeSymbol namedType when IsDictionary(namedType) || namedType.AllInterfac // Returns a comparer expression for a single element type, or null if EqualityComparer.Default suffices. // Terminates naturally when elementType is not a recognised collection interface. // visited guards against self-referential types (e.g. class A : IEnumerable). - // dictKind propagates the outer dictionary ordering intent: when OrderedDictionary, any nested - // dictionary encountered as a value type also uses OrderedDictionaryEqualityComparer so that - // the ordering semantics are consistent at every level of nesting. - private static string? BuildElementComparerExpression(ITypeSymbol elementType, HashSet? visited = null, ComparerTypes dictKind = ComparerTypes.Dictionary) + // dictKind propagates the outer dictionary ordering intent into nested dicts. + // enumKind propagates the outer enumerable intent (HashSet/Sequence) into nested enumerables/arrays: + // when set, all nested List/array/HashSet levels use the same comparer class as the outermost + // explicit annotation, so [HashSetEquality] on List> makes every level order-insensitive. + private static string? BuildElementComparerExpression(ITypeSymbol elementType, HashSet? visited = null, ComparerTypes dictKind = ComparerTypes.Dictionary, ComparerTypes? enumKind = null) { if (elementType is IArrayTypeSymbol arrayType) - return BuildArrayComparerExpression(arrayType); + return enumKind == ComparerTypes.HashSet + ? BuildHashSetArrayComparerExpression(arrayType, enumKind) + : BuildArrayComparerExpression(arrayType, enumKind); if (elementType is not INamedTypeSymbol named) return null; @@ -389,14 +401,23 @@ INamedTypeSymbol namedType when IsDictionary(namedType) || namedType.AllInterfac if (asEnumInterface != null) { var innerType = asEnumInterface.TypeArguments[0]; - var innerExpr = BuildElementComparerExpression(innerType, visited, dictKind); + var innerExpr = BuildElementComparerExpression(innerType, visited, dictKind, enumKind); var innerTypeFq = innerType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - var isSet = named.AllInterfaces.Any(i => i is { Name: "ISet" or "IReadOnlySet", IsGenericType: true }) - || named is { Name: "ISet" or "IReadOnlySet", IsGenericType: true }; - var comparerClass = isSet - ? "global::Equatable.Comparers.HashSetEqualityComparer" - : "global::Equatable.Comparers.SequenceEqualityComparer"; + // enumKind overrides the natural comparer choice for this nested level + string comparerClass; + if (enumKind == ComparerTypes.HashSet) + comparerClass = "global::Equatable.Comparers.HashSetEqualityComparer"; + else if (enumKind == ComparerTypes.Sequence) + comparerClass = "global::Equatable.Comparers.SequenceEqualityComparer"; + else + { + var isSet = named.AllInterfaces.Any(i => i is { Name: "ISet" or "IReadOnlySet", IsGenericType: true }) + || named is { Name: "ISet" or "IReadOnlySet", IsGenericType: true }; + comparerClass = isSet + ? "global::Equatable.Comparers.HashSetEqualityComparer" + : "global::Equatable.Comparers.SequenceEqualityComparer"; + } if (innerExpr != null) return $"new {comparerClass}<{innerTypeFq}>({innerExpr})"; @@ -434,11 +455,11 @@ private static string BuildDictComparerExpression(INamedTypeSymbol dictInterface return $"new {comparerClass}<{keyTypeFq}, {valueTypeFq}>({keyExpr}, {valueExpr})"; } - private static string BuildArrayComparerExpression(IArrayTypeSymbol arrayType) + private static string BuildArrayComparerExpression(IArrayTypeSymbol arrayType, ComparerTypes? enumKind = null) { var elementType = arrayType.ElementType; var elementTypeFq = elementType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - var innerExpr = BuildElementComparerExpression(elementType); + var innerExpr = BuildElementComparerExpression(elementType, enumKind: enumKind); if (arrayType.Rank > 1) { @@ -449,20 +470,30 @@ private static string BuildArrayComparerExpression(IArrayTypeSymbol arrayType) return $"global::Equatable.Comparers.MultiDimensionalArrayEqualityComparer<{elementTypeFq}>.Default"; } + // When enumKind overrides to HashSet, outer arrays also use HashSetEqualityComparer + var comparerClass = enumKind == ComparerTypes.HashSet + ? "global::Equatable.Comparers.HashSetEqualityComparer" + : "global::Equatable.Comparers.SequenceEqualityComparer"; + if (innerExpr != null) - return $"new global::Equatable.Comparers.SequenceEqualityComparer<{elementTypeFq}>({innerExpr})"; - return $"global::Equatable.Comparers.SequenceEqualityComparer<{elementTypeFq}>.Default"; + return $"new {comparerClass}<{elementTypeFq}>({innerExpr})"; + return $"{comparerClass}<{elementTypeFq}>.Default"; } - private static string BuildHashSetArrayComparerExpression(IArrayTypeSymbol arrayType) + private static string BuildHashSetArrayComparerExpression(IArrayTypeSymbol arrayType, ComparerTypes? enumKind = null) { var elementType = arrayType.ElementType; var elementTypeFq = elementType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - var innerExpr = BuildElementComparerExpression(elementType); + var innerExpr = BuildElementComparerExpression(elementType, enumKind: enumKind); + + // When enumKind overrides to Sequence, outer arrays also use SequenceEqualityComparer + var comparerClass = enumKind == ComparerTypes.Sequence + ? "global::Equatable.Comparers.SequenceEqualityComparer" + : "global::Equatable.Comparers.HashSetEqualityComparer"; if (innerExpr != null) - return $"new global::Equatable.Comparers.HashSetEqualityComparer<{elementTypeFq}>({innerExpr})"; - return $"global::Equatable.Comparers.HashSetEqualityComparer<{elementTypeFq}>.Default"; + return $"new {comparerClass}<{elementTypeFq}>({innerExpr})"; + return $"{comparerClass}<{elementTypeFq}>.Default"; } private static bool IsReadOnlyDictionary(INamedTypeSymbol targetSymbol) diff --git a/test/Equatable.Generator.Properties.Tests/Properties/NestedCollectionsProperties.cs b/test/Equatable.Generator.Properties.Tests/Properties/NestedCollectionsProperties.cs index e854f39..748067a 100644 --- a/test/Equatable.Generator.Properties.Tests/Properties/NestedCollectionsProperties.cs +++ b/test/Equatable.Generator.Properties.Tests/Properties/NestedCollectionsProperties.cs @@ -193,11 +193,14 @@ public Property ListOfSets_EqualWhenSameContent(int[][] raw) } [Property] - public Property ListOfSets_InnerOrderDoesNotMatter(int[][] raw) + public Property ListOfSets_InnerOrderMatters(int[][] raw) { + // [SequenceEquality] is explicit: propagates to nested HashSet → inner order is now significant. var a = new NestedCollections { ListOfSets = raw.Select(x => new HashSet(x)).ToList() }; var b = new NestedCollections { ListOfSets = raw.Select(x => new HashSet(x.Reverse())).ToList() }; - return a.Equals(b).ToProperty(); + // If any inner array has >1 distinct elements and their order changes, the sets differ. + var anyReversalChangesOrder = raw.Any(x => x.Distinct().Count() > 1 && !x.SequenceEqual(x.Reverse())); + return Prop.When(anyReversalChangesOrder, !a.Equals(b)); } [Property] diff --git a/test/Equatable.Generator.Tests/DataContractGeneratorTest.cs b/test/Equatable.Generator.Tests/DataContractGeneratorTest.cs index 2b6622e..d7d59d7 100644 --- a/test/Equatable.Generator.Tests/DataContractGeneratorTest.cs +++ b/test/Equatable.Generator.Tests/DataContractGeneratorTest.cs @@ -327,6 +327,50 @@ public partial class NestedOrderedContract [DictionaryEquality(sequential: true)] public Dictionary>? NestedDicts { get; set; } } +"; + var (diagnostics, output) = GetGeneratedOutput(source); + Assert.Empty(diagnostics); + return Verifier.Verify(output).UseDirectory("Snapshots").ScrubLinesContaining("GeneratedCodeAttribute"); + } + + // ── dictKind propagation ────────────────────────────────────────────────────────────────────── + // Explicit [DictionaryEquality] kind propagates to ALL nested dictionary levels; nested + // enumerables keep their natural comparer. + + [Fact] + public Task GenerateDataContractEquatableWithDictionaryEqualityPropagation() + { + var source = @" +using System.Collections.Generic; +using System.Runtime.Serialization; +using Equatable.Attributes; +using Equatable.Attributes.DataContract; + +namespace Equatable.Entities; + +[DataContract] +[DataContractEquatable] +public partial class DictPropagationContract +{ + [DataMember(Order = 0)] + public int Id { get; set; } + + [DataMember(Order = 1)] + [DictionaryEquality(sequential: true)] + public Dictionary>>? ThreeLevelOrdered { get; set; } + + [DataMember(Order = 2)] + [DictionaryEquality(sequential: true)] + public Dictionary>? OrderedDictOfList { get; set; } + + [DataMember(Order = 3)] + [DictionaryEquality(sequential: true)] + public Dictionary>>? OrderedDictOfDictOfList { get; set; } + + [DataMember(Order = 4)] + [DictionaryEquality] + public Dictionary>? UnorderedNestedDict { get; set; } +} "; var (diagnostics, output) = GetGeneratedOutput(source); Assert.Empty(diagnostics); diff --git a/test/Equatable.Generator.Tests/EquatableGeneratorTest.cs b/test/Equatable.Generator.Tests/EquatableGeneratorTest.cs index 7a63167..e843778 100644 --- a/test/Equatable.Generator.Tests/EquatableGeneratorTest.cs +++ b/test/Equatable.Generator.Tests/EquatableGeneratorTest.cs @@ -1031,6 +1031,62 @@ public partial class Container return Verifier.Verify(output).UseDirectory("Snapshots").ScrubLinesContaining("GeneratedCodeAttribute"); } + [Fact] + public Task GenerateHashSetEqualityPropagatesIntoNestedCollections() + { + var source = @" +using System.Collections.Generic; +using Equatable.Attributes; + +namespace Equatable.Entities; + +[Equatable] +public partial class Container +{ + /// [HashSetEquality] on List: List uses HashSet, inner array also uses HashSet. + [HashSetEquality] + public List? ListOfArrays { get; set; } + + /// [HashSetEquality] on List>: both levels use HashSet. + [HashSetEquality] + public List>? ListOfLists { get; set; } + + /// [HashSetEquality] on int[][]: both levels use HashSet. + [HashSetEquality] + public int[][]? JaggedArray { get; set; } +} +"; + var (diagnostics, output) = GetGeneratedOutput(source); + Assert.Empty(diagnostics); + return Verifier.Verify(output).UseDirectory("Snapshots").ScrubLinesContaining("GeneratedCodeAttribute"); + } + + [Fact] + public Task GenerateSequenceEqualityPropagatesIntoNestedCollections() + { + var source = @" +using System.Collections.Generic; +using Equatable.Attributes; + +namespace Equatable.Entities; + +[Equatable] +public partial class Container +{ + /// [SequenceEquality] on HashSet>: both levels use Sequence. + [SequenceEquality] + public HashSet>? SetOfSets { get; set; } + + /// [SequenceEquality] on HashSet: outer HashSet uses Sequence, inner array also uses Sequence. + [SequenceEquality] + public HashSet? SetOfArrays { get; set; } +} +"; + var (diagnostics, output) = GetGeneratedOutput(source); + Assert.Empty(diagnostics); + return Verifier.Verify(output).UseDirectory("Snapshots").ScrubLinesContaining("GeneratedCodeAttribute"); + } + [Fact] public Task GenerateSequentialDictionaryEquality_NestedDictPropagatesOrderedComparer() { @@ -1052,6 +1108,45 @@ public partial class Container return Verifier.Verify(output).UseDirectory("Snapshots").ScrubLinesContaining("GeneratedCodeAttribute"); } + // ── dictKind propagation ────────────────────────────────────────────────────────────────────── + // When [DictionaryEquality] / [DictionaryEquality(sequential:true)] is set explicitly, the + // annotated kind propagates into ALL nested dictionary levels. Nested enumerables (List, array, + // HashSet) keep their natural comparer regardless. + + [Fact] + public Task GenerateDictionaryEqualityPropagatesIntoNestedDictionaries() + { + var source = @" +using System.Collections.Generic; +using Equatable.Attributes; + +namespace Equatable.Entities; + +[Equatable] +public partial class Container +{ + /// [DictionaryEquality(sequential:true)] on 3-level nest: all dict levels use Ordered. + [DictionaryEquality(sequential: true)] + public Dictionary>>? ThreeLevelOrdered { get; set; } + + /// [DictionaryEquality(sequential:true)] on dict-of-list: dict is ordered, inner list is natural Sequence. + [DictionaryEquality(sequential: true)] + public Dictionary>? OrderedDictOfList { get; set; } + + /// [DictionaryEquality(sequential:true)] on dict-of-dict-of-list: both dict levels ordered, inner list natural. + [DictionaryEquality(sequential: true)] + public Dictionary>>? OrderedDictOfDictOfList { get; set; } + + /// [DictionaryEquality] (unordered) on dict-of-dict: both dict levels unordered. + [DictionaryEquality] + public Dictionary>? UnorderedNestedDict { get; set; } +} +"; + var (diagnostics, output) = GetGeneratedOutput(source); + Assert.Empty(diagnostics); + return Verifier.Verify(output).UseDirectory("Snapshots").ScrubLinesContaining("GeneratedCodeAttribute"); + } + // Pinned references that must always be present regardless of AppDomain load order. // Adapter attribute assemblies and serialization libraries may not be loaded when a test runs first. private static readonly IEnumerable PinnedReferences = diff --git a/test/Equatable.Generator.Tests/MessagePackGeneratorTest.cs b/test/Equatable.Generator.Tests/MessagePackGeneratorTest.cs index 0f65119..e7b63b8 100644 --- a/test/Equatable.Generator.Tests/MessagePackGeneratorTest.cs +++ b/test/Equatable.Generator.Tests/MessagePackGeneratorTest.cs @@ -327,6 +327,50 @@ public partial class NestedOrderedContract [DictionaryEquality(sequential: true)] public Dictionary>? NestedDicts { get; set; } } +"; + var (diagnostics, output) = GetGeneratedOutput(source); + Assert.Empty(diagnostics); + return Verifier.Verify(output).UseDirectory("Snapshots").ScrubLinesContaining("GeneratedCodeAttribute"); + } + + // ── dictKind propagation ────────────────────────────────────────────────────────────────────── + // Explicit [DictionaryEquality] kind propagates to ALL nested dictionary levels; nested + // enumerables keep their natural comparer. + + [Fact] + public Task GenerateMessagePackEquatableWithDictionaryEqualityPropagation() + { + var source = @" +using System.Collections.Generic; +using MessagePack; +using Equatable.Attributes; +using Equatable.Attributes.MessagePack; + +namespace Equatable.Entities; + +[MessagePackObject] +[MessagePackEquatable] +public partial class DictPropagationContract +{ + [Key(0)] + public int Id { get; set; } + + [Key(1)] + [DictionaryEquality(sequential: true)] + public Dictionary>>? ThreeLevelOrdered { get; set; } + + [Key(2)] + [DictionaryEquality(sequential: true)] + public Dictionary>? OrderedDictOfList { get; set; } + + [Key(3)] + [DictionaryEquality(sequential: true)] + public Dictionary>>? OrderedDictOfDictOfList { get; set; } + + [Key(4)] + [DictionaryEquality] + public Dictionary>? UnorderedNestedDict { get; set; } +} "; var (diagnostics, output) = GetGeneratedOutput(source); Assert.Empty(diagnostics); diff --git a/test/Equatable.Generator.Tests/Properties/NestedCollectionsProperties.cs b/test/Equatable.Generator.Tests/Properties/NestedCollectionsProperties.cs index 527be7a..f59bccb 100644 --- a/test/Equatable.Generator.Tests/Properties/NestedCollectionsProperties.cs +++ b/test/Equatable.Generator.Tests/Properties/NestedCollectionsProperties.cs @@ -187,11 +187,18 @@ public Property ListOfSets_EqualWhenSameContent(List> items) } [Property] - public Property ListOfSets_InnerOrderDoesNotMatter(List> items) + public Property ListOfSets_InnerOrderMatters(List> items) { + // [SequenceEquality] is explicit: propagates to nested HashSet → inner order is now significant. + // Reversing inner elements produces a different sequence — equal only when all inner sets are singletons + // or empty (Reverse() is a no-op), so skip those trivial cases. + var nonTrivial = items.All(s => s.Count <= 1); + if (nonTrivial) return Prop.When(true, true); var a = new NestedCollections { ListOfSets = items }; var b = new NestedCollections { ListOfSets = items.Select(s => new HashSet(s.Reverse())).ToList() }; - return Prop.ToProperty(a.Equals(b)); + // With SequenceEqualityComparer on inner level: reversed sets that are non-trivial are not equal. + var anyReversalChangesOrder = items.Any(s => s.Count > 1 && !s.SequenceEqual(s.Reverse())); + return Prop.When(anyReversalChangesOrder, !a.Equals(b)); } [Property] diff --git a/test/Equatable.Generator.Tests/Snapshots/DataContractGeneratorTest.GenerateDataContractEquatableWithDictionaryEqualityPropagation.verified.txt b/test/Equatable.Generator.Tests/Snapshots/DataContractGeneratorTest.GenerateDataContractEquatableWithDictionaryEqualityPropagation.verified.txt new file mode 100644 index 0000000..0b1b752 --- /dev/null +++ b/test/Equatable.Generator.Tests/Snapshots/DataContractGeneratorTest.GenerateDataContractEquatableWithDictionaryEqualityPropagation.verified.txt @@ -0,0 +1,51 @@ +// +#nullable enable + +namespace Equatable.Entities +{ + partial class DictPropagationContract : global::System.IEquatable + { + /// + public bool Equals(global::Equatable.Entities.DictPropagationContract? other) + { + return !(other is null) + && Id == other.Id + && (new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer>>(global::System.Collections.Generic.EqualityComparer.Default, new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer>(global::System.Collections.Generic.EqualityComparer.Default, new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default)))).Equals(ThreeLevelOrdered, other.ThreeLevelOrdered) + && (new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer>(global::System.Collections.Generic.EqualityComparer.Default, global::Equatable.Comparers.SequenceEqualityComparer.Default)).Equals(OrderedDictOfList, other.OrderedDictOfList) + && (new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer>>(global::System.Collections.Generic.EqualityComparer.Default, new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer>(global::System.Collections.Generic.EqualityComparer.Default, global::Equatable.Comparers.SequenceEqualityComparer.Default))).Equals(OrderedDictOfDictOfList, other.OrderedDictOfDictOfList) + && (new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer>(global::System.Collections.Generic.EqualityComparer.Default, new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default))).Equals(UnorderedNestedDict, other.UnorderedNestedDict); + + } + + /// + public override bool Equals(object? obj) + { + return Equals(obj as global::Equatable.Entities.DictPropagationContract); + } + + /// + public static bool operator ==(global::Equatable.Entities.DictPropagationContract? left, global::Equatable.Entities.DictPropagationContract? right) + { + return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); + } + + /// + public static bool operator !=(global::Equatable.Entities.DictPropagationContract? left, global::Equatable.Entities.DictPropagationContract? right) + { + return !(left == right); + } + + /// + public override int GetHashCode(){ + int hashCode = -1477941023; + hashCode = (hashCode * -1521134295) + Id.GetHashCode(); + hashCode = (hashCode * -1521134295) + (new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer>>(global::System.Collections.Generic.EqualityComparer.Default, new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer>(global::System.Collections.Generic.EqualityComparer.Default, new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default)))).GetHashCode(ThreeLevelOrdered!); + hashCode = (hashCode * -1521134295) + (new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer>(global::System.Collections.Generic.EqualityComparer.Default, global::Equatable.Comparers.SequenceEqualityComparer.Default)).GetHashCode(OrderedDictOfList!); + hashCode = (hashCode * -1521134295) + (new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer>>(global::System.Collections.Generic.EqualityComparer.Default, new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer>(global::System.Collections.Generic.EqualityComparer.Default, global::Equatable.Comparers.SequenceEqualityComparer.Default))).GetHashCode(OrderedDictOfDictOfList!); + hashCode = (hashCode * -1521134295) + (new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer>(global::System.Collections.Generic.EqualityComparer.Default, new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default))).GetHashCode(UnorderedNestedDict!); + return hashCode; + + } + + } +} diff --git a/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateDictionaryEqualityPropagatesIntoNestedDictionaries.verified.txt b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateDictionaryEqualityPropagatesIntoNestedDictionaries.verified.txt new file mode 100644 index 0000000..9e04aa3 --- /dev/null +++ b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateDictionaryEqualityPropagatesIntoNestedDictionaries.verified.txt @@ -0,0 +1,49 @@ +// +#nullable enable + +namespace Equatable.Entities +{ + partial class Container : global::System.IEquatable + { + /// + public bool Equals(global::Equatable.Entities.Container? other) + { + return !(other is null) + && (new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer>>(global::System.Collections.Generic.EqualityComparer.Default, new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer>(global::System.Collections.Generic.EqualityComparer.Default, new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default)))).Equals(ThreeLevelOrdered, other.ThreeLevelOrdered) + && (new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer>(global::System.Collections.Generic.EqualityComparer.Default, global::Equatable.Comparers.SequenceEqualityComparer.Default)).Equals(OrderedDictOfList, other.OrderedDictOfList) + && (new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer>>(global::System.Collections.Generic.EqualityComparer.Default, new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer>(global::System.Collections.Generic.EqualityComparer.Default, global::Equatable.Comparers.SequenceEqualityComparer.Default))).Equals(OrderedDictOfDictOfList, other.OrderedDictOfDictOfList) + && (new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer>(global::System.Collections.Generic.EqualityComparer.Default, new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default))).Equals(UnorderedNestedDict, other.UnorderedNestedDict); + + } + + /// + public override bool Equals(object? obj) + { + return Equals(obj as global::Equatable.Entities.Container); + } + + /// + public static bool operator ==(global::Equatable.Entities.Container? left, global::Equatable.Entities.Container? right) + { + return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); + } + + /// + public static bool operator !=(global::Equatable.Entities.Container? left, global::Equatable.Entities.Container? right) + { + return !(left == right); + } + + /// + public override int GetHashCode(){ + int hashCode = -1473867807; + hashCode = (hashCode * -1521134295) + (new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer>>(global::System.Collections.Generic.EqualityComparer.Default, new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer>(global::System.Collections.Generic.EqualityComparer.Default, new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default)))).GetHashCode(ThreeLevelOrdered!); + hashCode = (hashCode * -1521134295) + (new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer>(global::System.Collections.Generic.EqualityComparer.Default, global::Equatable.Comparers.SequenceEqualityComparer.Default)).GetHashCode(OrderedDictOfList!); + hashCode = (hashCode * -1521134295) + (new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer>>(global::System.Collections.Generic.EqualityComparer.Default, new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer>(global::System.Collections.Generic.EqualityComparer.Default, global::Equatable.Comparers.SequenceEqualityComparer.Default))).GetHashCode(OrderedDictOfDictOfList!); + hashCode = (hashCode * -1521134295) + (new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer>(global::System.Collections.Generic.EqualityComparer.Default, new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default))).GetHashCode(UnorderedNestedDict!); + return hashCode; + + } + + } +} diff --git a/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateHashSetEqualityPropagatesIntoNestedCollections.verified.txt b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateHashSetEqualityPropagatesIntoNestedCollections.verified.txt new file mode 100644 index 0000000..88f0bc9 --- /dev/null +++ b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateHashSetEqualityPropagatesIntoNestedCollections.verified.txt @@ -0,0 +1,47 @@ +// +#nullable enable + +namespace Equatable.Entities +{ + partial class Container : global::System.IEquatable + { + /// + public bool Equals(global::Equatable.Entities.Container? other) + { + return !(other is null) + && (new global::Equatable.Comparers.HashSetEqualityComparer(global::Equatable.Comparers.HashSetEqualityComparer.Default)).Equals(ListOfArrays, other.ListOfArrays) + && (new global::Equatable.Comparers.HashSetEqualityComparer>(global::Equatable.Comparers.HashSetEqualityComparer.Default)).Equals(ListOfLists, other.ListOfLists) + && (new global::Equatable.Comparers.HashSetEqualityComparer(global::Equatable.Comparers.HashSetEqualityComparer.Default)).Equals(JaggedArray, other.JaggedArray); + + } + + /// + public override bool Equals(object? obj) + { + return Equals(obj as global::Equatable.Entities.Container); + } + + /// + public static bool operator ==(global::Equatable.Entities.Container? left, global::Equatable.Entities.Container? right) + { + return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); + } + + /// + public static bool operator !=(global::Equatable.Entities.Container? left, global::Equatable.Entities.Container? right) + { + return !(left == right); + } + + /// + public override int GetHashCode(){ + int hashCode = 1468643043; + hashCode = (hashCode * -1521134295) + (new global::Equatable.Comparers.HashSetEqualityComparer(global::Equatable.Comparers.HashSetEqualityComparer.Default)).GetHashCode(ListOfArrays!); + hashCode = (hashCode * -1521134295) + (new global::Equatable.Comparers.HashSetEqualityComparer>(global::Equatable.Comparers.HashSetEqualityComparer.Default)).GetHashCode(ListOfLists!); + hashCode = (hashCode * -1521134295) + (new global::Equatable.Comparers.HashSetEqualityComparer(global::Equatable.Comparers.HashSetEqualityComparer.Default)).GetHashCode(JaggedArray!); + return hashCode; + + } + + } +} diff --git a/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateSequenceEqualityPropagatesIntoNestedCollections.verified.txt b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateSequenceEqualityPropagatesIntoNestedCollections.verified.txt new file mode 100644 index 0000000..88b98a9 --- /dev/null +++ b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateSequenceEqualityPropagatesIntoNestedCollections.verified.txt @@ -0,0 +1,45 @@ +// +#nullable enable + +namespace Equatable.Entities +{ + partial class Container : global::System.IEquatable + { + /// + public bool Equals(global::Equatable.Entities.Container? other) + { + return !(other is null) + && (new global::Equatable.Comparers.SequenceEqualityComparer>(global::Equatable.Comparers.SequenceEqualityComparer.Default)).Equals(SetOfSets, other.SetOfSets) + && (new global::Equatable.Comparers.SequenceEqualityComparer(global::Equatable.Comparers.SequenceEqualityComparer.Default)).Equals(SetOfArrays, other.SetOfArrays); + + } + + /// + public override bool Equals(object? obj) + { + return Equals(obj as global::Equatable.Entities.Container); + } + + /// + public static bool operator ==(global::Equatable.Entities.Container? left, global::Equatable.Entities.Container? right) + { + return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); + } + + /// + public static bool operator !=(global::Equatable.Entities.Container? left, global::Equatable.Entities.Container? right) + { + return !(left == right); + } + + /// + public override int GetHashCode(){ + int hashCode = 1907615939; + hashCode = (hashCode * -1521134295) + (new global::Equatable.Comparers.SequenceEqualityComparer>(global::Equatable.Comparers.SequenceEqualityComparer.Default)).GetHashCode(SetOfSets!); + hashCode = (hashCode * -1521134295) + (new global::Equatable.Comparers.SequenceEqualityComparer(global::Equatable.Comparers.SequenceEqualityComparer.Default)).GetHashCode(SetOfArrays!); + return hashCode; + + } + + } +} diff --git a/test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatableWithDictionaryEqualityPropagation.verified.txt b/test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatableWithDictionaryEqualityPropagation.verified.txt new file mode 100644 index 0000000..0b1b752 --- /dev/null +++ b/test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatableWithDictionaryEqualityPropagation.verified.txt @@ -0,0 +1,51 @@ +// +#nullable enable + +namespace Equatable.Entities +{ + partial class DictPropagationContract : global::System.IEquatable + { + /// + public bool Equals(global::Equatable.Entities.DictPropagationContract? other) + { + return !(other is null) + && Id == other.Id + && (new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer>>(global::System.Collections.Generic.EqualityComparer.Default, new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer>(global::System.Collections.Generic.EqualityComparer.Default, new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default)))).Equals(ThreeLevelOrdered, other.ThreeLevelOrdered) + && (new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer>(global::System.Collections.Generic.EqualityComparer.Default, global::Equatable.Comparers.SequenceEqualityComparer.Default)).Equals(OrderedDictOfList, other.OrderedDictOfList) + && (new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer>>(global::System.Collections.Generic.EqualityComparer.Default, new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer>(global::System.Collections.Generic.EqualityComparer.Default, global::Equatable.Comparers.SequenceEqualityComparer.Default))).Equals(OrderedDictOfDictOfList, other.OrderedDictOfDictOfList) + && (new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer>(global::System.Collections.Generic.EqualityComparer.Default, new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default))).Equals(UnorderedNestedDict, other.UnorderedNestedDict); + + } + + /// + public override bool Equals(object? obj) + { + return Equals(obj as global::Equatable.Entities.DictPropagationContract); + } + + /// + public static bool operator ==(global::Equatable.Entities.DictPropagationContract? left, global::Equatable.Entities.DictPropagationContract? right) + { + return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); + } + + /// + public static bool operator !=(global::Equatable.Entities.DictPropagationContract? left, global::Equatable.Entities.DictPropagationContract? right) + { + return !(left == right); + } + + /// + public override int GetHashCode(){ + int hashCode = -1477941023; + hashCode = (hashCode * -1521134295) + Id.GetHashCode(); + hashCode = (hashCode * -1521134295) + (new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer>>(global::System.Collections.Generic.EqualityComparer.Default, new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer>(global::System.Collections.Generic.EqualityComparer.Default, new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default)))).GetHashCode(ThreeLevelOrdered!); + hashCode = (hashCode * -1521134295) + (new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer>(global::System.Collections.Generic.EqualityComparer.Default, global::Equatable.Comparers.SequenceEqualityComparer.Default)).GetHashCode(OrderedDictOfList!); + hashCode = (hashCode * -1521134295) + (new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer>>(global::System.Collections.Generic.EqualityComparer.Default, new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer>(global::System.Collections.Generic.EqualityComparer.Default, global::Equatable.Comparers.SequenceEqualityComparer.Default))).GetHashCode(OrderedDictOfDictOfList!); + hashCode = (hashCode * -1521134295) + (new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer>(global::System.Collections.Generic.EqualityComparer.Default, new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default))).GetHashCode(UnorderedNestedDict!); + return hashCode; + + } + + } +} From 65e9d2ce176f721e4b50da972c01510275ff75b9 Mon Sep 17 00:00:00 2001 From: "a.kasyanov" Date: Thu, 14 May 2026 01:30:22 +0300 Subject: [PATCH 23/71] Add symmetry property tests for nested collection equality Assert a.Equals(b) == b.Equals(a) across all nested collection shapes (Dict/List/HashSet/Array combinations) in both property test projects. Co-Authored-By: Claude Sonnet 4.5 --- .../Properties/NestedCollectionsProperties.cs | 60 +++++++++++++++ .../Properties/NestedCollectionsProperties.cs | 76 +++++++++++++++++++ 2 files changed, 136 insertions(+) diff --git a/test/Equatable.Generator.Properties.Tests/Properties/NestedCollectionsProperties.cs b/test/Equatable.Generator.Properties.Tests/Properties/NestedCollectionsProperties.cs index 748067a..3e56ccf 100644 --- a/test/Equatable.Generator.Properties.Tests/Properties/NestedCollectionsProperties.cs +++ b/test/Equatable.Generator.Properties.Tests/Properties/NestedCollectionsProperties.cs @@ -724,4 +724,64 @@ public Property DictOfArrays_EqualImpliesSameHash(Dictionary raw) var b = new NestedCollections { DictOfArrays = raw.ToDictionary(kv => kv.Key, kv => (int[])kv.Value.Clone()) }; return (a.Equals(b) && a.GetHashCode() == b.GetHashCode()).ToProperty(); } + + // ══════════════════════════════════════════════════════════════════════ + // Symmetry: Equals(a, b) == Equals(b, a) for all nested collection shapes + // ══════════════════════════════════════════════════════════════════════ + + [Property] + public Property Symmetry_DictOfLists(Dictionary> raw1, Dictionary> raw2) + { + var a = new NestedCollections { DictOfLists = raw1 }; + var b = new NestedCollections { DictOfLists = raw2 }; + return (a.Equals(b) == b.Equals(a)).ToProperty(); + } + + [Property] + public Property Symmetry_DictOfDicts(Dictionary> raw1, Dictionary> raw2) + { + var a = new NestedCollections { DictOfDicts = raw1 }; + var b = new NestedCollections { DictOfDicts = raw2 }; + return (a.Equals(b) == b.Equals(a)).ToProperty(); + } + + [Property] + public Property Symmetry_ListOfDicts(List> items1, List> items2) + { + var a = new NestedCollections { ListOfDicts = items1 }; + var b = new NestedCollections { ListOfDicts = items2 }; + return (a.Equals(b) == b.Equals(a)).ToProperty(); + } + + [Property] + public Property Symmetry_ListOfLists(List> items1, List> items2) + { + var a = new NestedCollections { ListOfLists = items1 }; + var b = new NestedCollections { ListOfLists = items2 }; + return (a.Equals(b) == b.Equals(a)).ToProperty(); + } + + [Property] + public Property Symmetry_ThreeLevelNested(Dictionary>> raw1, Dictionary>> raw2) + { + var a = new NestedCollections { ThreeLevelNested = raw1 }; + var b = new NestedCollections { ThreeLevelNested = raw2 }; + return (a.Equals(b) == b.Equals(a)).ToProperty(); + } + + [Property] + public Property Symmetry_FlatArray(int[] arr1, int[] arr2) + { + var a = new NestedCollections { FlatArray = arr1 }; + var b = new NestedCollections { FlatArray = arr2 }; + return (a.Equals(b) == b.Equals(a)).ToProperty(); + } + + [Property] + public Property Symmetry_DictOfArrays(Dictionary raw1, Dictionary raw2) + { + var a = new NestedCollections { DictOfArrays = raw1 }; + var b = new NestedCollections { DictOfArrays = raw2 }; + return (a.Equals(b) == b.Equals(a)).ToProperty(); + } } diff --git a/test/Equatable.Generator.Tests/Properties/NestedCollectionsProperties.cs b/test/Equatable.Generator.Tests/Properties/NestedCollectionsProperties.cs index f59bccb..13daea4 100644 --- a/test/Equatable.Generator.Tests/Properties/NestedCollectionsProperties.cs +++ b/test/Equatable.Generator.Tests/Properties/NestedCollectionsProperties.cs @@ -700,4 +700,80 @@ public Property DictOfArrays_EqualImpliesSameHash(Dictionary raw) var b = new NestedCollections { DictOfArrays = raw.ToDictionary(kv => kv.Key, kv => (int[])kv.Value.Clone()) }; return Prop.ToProperty((a.Equals(b) && a.GetHashCode() == b.GetHashCode())); } + + // ══════════════════════════════════════════════════════════════════════ + // Symmetry: Equals(a, b) == Equals(b, a) for all nested collection shapes + // ══════════════════════════════════════════════════════════════════════ + + [Property] + public Property Symmetry_DictOfLists(Dictionary> raw1, Dictionary> raw2) + { + var a = new NestedCollections { DictOfLists = raw1 }; + var b = new NestedCollections { DictOfLists = raw2 }; + return Prop.ToProperty(a.Equals(b) == b.Equals(a)); + } + + [Property] + public Property Symmetry_DictOfSets(Dictionary> raw1, Dictionary> raw2) + { + var a = new NestedCollections { DictOfSets = raw1 }; + var b = new NestedCollections { DictOfSets = raw2 }; + return Prop.ToProperty(a.Equals(b) == b.Equals(a)); + } + + [Property] + public Property Symmetry_DictOfDicts(Dictionary> raw1, Dictionary> raw2) + { + var a = new NestedCollections { DictOfDicts = raw1 }; + var b = new NestedCollections { DictOfDicts = raw2 }; + return Prop.ToProperty(a.Equals(b) == b.Equals(a)); + } + + [Property] + public Property Symmetry_ListOfDicts(List> items1, List> items2) + { + var a = new NestedCollections { ListOfDicts = items1 }; + var b = new NestedCollections { ListOfDicts = items2 }; + return Prop.ToProperty(a.Equals(b) == b.Equals(a)); + } + + [Property] + public Property Symmetry_ListOfSets(List> items1, List> items2) + { + var a = new NestedCollections { ListOfSets = items1 }; + var b = new NestedCollections { ListOfSets = items2 }; + return Prop.ToProperty(a.Equals(b) == b.Equals(a)); + } + + [Property] + public Property Symmetry_ListOfLists(List> items1, List> items2) + { + var a = new NestedCollections { ListOfLists = items1 }; + var b = new NestedCollections { ListOfLists = items2 }; + return Prop.ToProperty(a.Equals(b) == b.Equals(a)); + } + + [Property] + public Property Symmetry_ThreeLevelNested(Dictionary>> raw1, Dictionary>> raw2) + { + var a = new NestedCollections { ThreeLevelNested = raw1 }; + var b = new NestedCollections { ThreeLevelNested = raw2 }; + return Prop.ToProperty(a.Equals(b) == b.Equals(a)); + } + + [Property] + public Property Symmetry_FlatArray(int[] arr1, int[] arr2) + { + var a = new NestedCollections { FlatArray = arr1 }; + var b = new NestedCollections { FlatArray = arr2 }; + return Prop.ToProperty(a.Equals(b) == b.Equals(a)); + } + + [Property] + public Property Symmetry_DictOfArrays(Dictionary raw1, Dictionary raw2) + { + var a = new NestedCollections { DictOfArrays = raw1 }; + var b = new NestedCollections { DictOfArrays = raw2 }; + return Prop.ToProperty(a.Equals(b) == b.Equals(a)); + } } From fb782e1348fd681f8176b42e9c46d3beb11a9214 Mon Sep 17 00:00:00 2001 From: "a.kasyanov" Date: Thu, 14 May 2026 01:50:11 +0300 Subject: [PATCH 24/71] Update README with new abilities: adapters, comparer propagation, direction overrides Documents DataContractEquatable/MessagePackEquatable adapters, nested collection comparer propagation (single annotation propagates all levels), direction override examples (HashSetEquality on List, SequenceEquality on HashSet), and custom comparer pattern with clear table format. Co-Authored-By: Claude Sonnet 4.5 --- README.md | 233 ++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 172 insertions(+), 61 deletions(-) diff --git a/README.md b/README.md index 7065546..b1730b8 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Equatable.Generator -Source generator for `Equals` and `GetHashCode` with attribute based control of equality implementation +Source generator for `Equals` and `GetHashCode` with attribute-based control of equality implementation. [![Build Project](https://github.com/loresoft/Equatable.Generator/actions/workflows/dotnet.yml/badge.svg)](https://github.com/loresoft/Equatable.Generator/actions/workflows/dotnet.yml) @@ -8,98 +8,209 @@ Source generator for `Equals` and `GetHashCode` with attribute based control of [![Equatable.Generator](https://img.shields.io/nuget/v/Equatable.Generator.svg)](https://www.nuget.org/packages/Equatable.Generator/) -## Features +## What it does -- Override `Equals` and `GetHashCode` -- Implement `IEquatable` -- Support `class`, `record` and `struct` types -- Support `EqualityComparer` per property via attribute -- Attribute based control of equality implementation. -- Attribute comparers supported: String, Sequence, Dictionary, HashSet, Reference, and Custom -- No runtime dependencies. Library is compile time dependence only. +In C# every class inherits `Equals` from `object`, which compares **references** (memory addresses), not values: -### Usage +```csharp +var a = new Product { Id = 1, Name = "Widget" }; +var b = new Product { Id = 1, Name = "Widget" }; +Console.WriteLine(a == b); // false — different objects, even though values are identical +``` -#### Add package +This library generates correct `Equals` + `GetHashCode` at compile-time — zero runtime overhead, zero boilerplate. -Add the nuget package to your projects. +## Packages -`dotnet add package Equatable.Generator` +| Package | What it does | +|---|---| +| `Equatable.Generator` | Generates equality for `[Equatable]` classes/records/structs. Includes all collection attributes. | +| `Equatable.Generator.DataContract` | Adapter — reads `[DataMember]` attributes (WCF / protobuf-net contracts) | +| `Equatable.Generator.MessagePack` | Adapter — reads `[Key(n)]` attributes (MessagePack serialisation) | +| `Equatable.Comparers` | Ships the runtime comparers used by the generated code | -Prevent including Equatable.Generator as a dependency +## Getting started ```xml + ``` -### Requirements +Mark your class as `partial` and add `[Equatable]`: -This library requires: +```csharp +[Equatable] +public partial class Product +{ + public int Id { get; set; } + public string? Name { get; set; } + public decimal Price { get; set; } +} +``` -- Target framework .NET Standard 2.0 or greater -- Project C# `LangVersion` 8.0 or higher +The generator writes `Equals` and `GetHashCode` for every public property. Works on `class`, `record`, and `readonly struct`. -### Equatable Attributes +## All attributes at a glance -Place `[Equatable]` attribute on a `class`, `record` or `struct`. The source generator will create a partial with overrides for `Equals` and `GetHashCode` for all public properties. +| Attribute | What it generates | Default for | +|---|---|---| +| `[Equatable]` | Triggers generation; includes all public properties | — | +| `[IgnoreEquality]` | Skip this property | — | +| `[StringEquality(StringComparison.X)]` | `StringComparer.X.Equals(a, b)` | — | +| `[EqualityComparer(typeof(T))]` | `T.Default.Equals(a, b)` — any custom comparer | — | +| `[SequenceEquality]` | `SequenceEqualityComparer` — element order matters | `List`, `T[]` | +| `[HashSetEquality]` | `HashSetEqualityComparer` — element order ignored | `HashSet` | +| `[DictionaryEquality]` | `ReadOnlyDictionaryEqualityComparer` — key-value equality | `Dictionary` | +| `[DictionaryEquality(sequential:true)]` | `OrderedReadOnlyDictionaryEqualityComparer` — key-sorted | — | +| `[ReferenceEquality]` | `Object.ReferenceEquals(a, b)` | — | -- `[Equatable]` Marks the class to generate overrides for `Equals` and `GetHashCode` +## Adapter generators - The default comparer used in the implementation of `Equals` and `GetHashCode` is `EqualityComparer.Default`. Customize the comparer used with the following attributes. +Use `[DataContractEquatable]` or `[MessagePackEquatable]` when your class is already annotated for serialisation. They work exactly like `[Equatable]` but only include the properties the serialiser knows about: -- `[IgnoreEquality]` Ignore property in `Equals` and `GetHashCode` implementations -- `[StringEquality]` Use specified `StringComparer` when comparing strings -- `[SequenceEquality]` Use `Enumerable.SequenceEqual` to determine whether enumerables are equal -- `[DictionaryEquality]` Use to determine if dictionaries are equal -- `[HashSetEquality]` Use `ISet.SetEquals` to determine whether enumerables are equal -- `[ReferenceEquality]` Use `Object.ReferenceEquals` to determines whether instances are the same instance -- `[EqualityComparer]` Use the specified `EqualityComparer` +```csharp +// Only [DataMember] properties are included in equality. +// [IgnoreDataMember] and un-annotated properties are skipped. +[DataContract] +[DataContractEquatable] +public partial class EventContract +{ + [DataMember(Order = 0)] public int EventId { get; set; } -### Example Usage + [DataMember(Order = 1)] + [SequenceEquality] + public string[]? Tags { get; set; } -Example of using the attributes to customize the source generation of `Equals` and `GetHashCode` + [IgnoreDataMember] + public DateTime LastSeen { get; set; } // excluded from equality +} +``` -``` c# -[Equatable] -public partial class UserImport +```csharp +// Only [Key(n)] properties are included. +// [IgnoreMember] properties are skipped. +[MessagePackObject] +[MessagePackEquatable] +public partial class LiveScore { - [StringEquality(StringComparison.OrdinalIgnoreCase)] - public string EmailAddress { get; set; } = null!; + [Key(0)] public int MatchId { get; set; } + [Key(1)] public int HomeScore { get; set; } + + [IgnoreMember] + public DateTime ReceivedAt { get; set; } // excluded +} +``` - public string? DisplayName { get; set; } +## Collection attributes in detail - public string? FirstName { get; set; } +### `[SequenceEquality]` — order matters - public string? LastName { get; set; } +```csharp +[SequenceEquality] +public List? Tracks { get; set; } +``` - public DateTimeOffset? LockoutEnd { get; set; } +`["A","B","C"]` equals `["A","B","C"]` ✓ +`["A","B","C"]` does NOT equal `["C","B","A"]` ✓ - public DateTimeOffset? LastLogin { get; set; } +Also works on `int[]`, `IEnumerable`, and any sequence type. - [IgnoreEquality] - public string FullName => $"{FirstName} {LastName}"; +**Direction override:** apply to `HashSet` to force order-sensitive comparison on a normally unordered set. - [HashSetEquality] - public HashSet? Roles { get; set; } +--- - [DictionaryEquality] - public Dictionary? Permissions { get; set; } +### `[HashSetEquality]` — order does not matter - [SequenceEquality] - public List? History { get; set; } -} +```csharp +[HashSetEquality] +public HashSet? Roles { get; set; } ``` -Works for `record` types too +`{"admin","editor"}` equals `{"editor","admin"}` ✓ -```c# -[Equatable] -public partial record StatusRecord( - int Id, - [property: StringEquality(StringComparison.OrdinalIgnoreCase)] string Name, - string? Description, - int DisplayOrder, - bool IsActive, - [property: SequenceEquality] List Versions -); +**Direction override:** apply to `List` or `T[]` to make them order-insensitive. + +--- + +### `[DictionaryEquality]` — insertion order does not matter + +```csharp +[DictionaryEquality] +public Dictionary? OddsBySource { get; set; } +``` + +`{betgenius:1.85, abelson:1.90}` equals `{abelson:1.90, betgenius:1.85}` ✓ + +### `[DictionaryEquality(sequential: true)]` — key-sorted comparison + +Both sides are sorted by key before comparison. Insertion order is still irrelevant, but the result is deterministic — useful for snapshots and logs. + +```csharp +[DictionaryEquality(sequential: true)] +public Dictionary? RankByRegion { get; set; } ``` + +--- + +## Comparer propagation into nested collections + +Annotate the **outer property once** — the chosen comparer kind propagates automatically into all nested levels. + +```csharp +// outer dict → key-sorted +// inner dict → key-sorted (propagated automatically) +[DictionaryEquality(sequential: true)] +public Dictionary>? ScoresByRegionAndTeam { get; set; } + +// outer dict → key-sorted +// inner list → order-sensitive (inferred from List) +[DictionaryEquality(sequential: true)] +public Dictionary>? HistoryByRegion { get; set; } + +// three levels deep — propagation goes all the way +[DictionaryEquality(sequential: true)] +public Dictionary>>? ThreeLevelConfig { get; set; } +``` + +You never need to annotate nested properties separately. + +--- + +## `[EqualityComparer]` — fully custom comparer + +When no built-in attribute fits, write your own `IEqualityComparer`: + +```csharp +public sealed class CountOnlyComparer : IEqualityComparer?> +{ + public static readonly CountOnlyComparer Default = new(); + public bool Equals(Dictionary? x, Dictionary? y) => + x is null ? y is null : y is not null && x.Count == y.Count; + public int GetHashCode(Dictionary? obj) => obj?.Count ?? 0; +} + +[EqualityComparer(typeof(CountOnlyComparer))] +public Dictionary? AssetWeights { get; set; } +``` + +--- + +## Equality invariants + +Every generated implementation satisfies: + +| Property | Meaning | +|---|---| +| **Reflexive** | `a.Equals(a)` is always `true` | +| **Symmetric** | `a.Equals(b) == b.Equals(a)` always | +| **Null-safe** | `a.Equals(null)` is always `false` | +| **Hash contract** | `a.Equals(b)` implies `a.GetHashCode() == b.GetHashCode()` | + +The hash contract is critical for using objects as dictionary keys or in hash sets. + +--- + +## Requirements + +- Target framework .NET Standard 2.0 or greater +- C# `LangVersion` 8.0 or higher From 355e4d9a97ae3c67ad9becca41414788a3681eec Mon Sep 17 00:00:00 2001 From: "a.kasyanov" Date: Thu, 14 May 2026 02:03:59 +0300 Subject: [PATCH 25/71] README: document collection defaults and remove redundant attribute examples Co-Authored-By: Claude Sonnet 4.5 --- README.md | 33 +++++++++++++++++++++++---------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index b1730b8..71d8dc5 100644 --- a/README.md +++ b/README.md @@ -77,9 +77,8 @@ public partial class EventContract { [DataMember(Order = 0)] public int EventId { get; set; } - [DataMember(Order = 1)] - [SequenceEquality] - public string[]? Tags { get; set; } + // string[] defaults to [SequenceEquality] — no attribute needed. + [DataMember(Order = 1)] public string[]? Tags { get; set; } [IgnoreDataMember] public DateTime LastSeen { get; set; } // excluded from equality @@ -105,38 +104,52 @@ public partial class LiveScore ### `[SequenceEquality]` — order matters +**Default for:** `List`, `T[]` — no attribute needed on these types. + ```csharp -[SequenceEquality] -public List? Tracks { get; set; } +public List? Tracks { get; set; } // SequenceEquality by default +public int[]? Scores { get; set; } // SequenceEquality by default ``` `["A","B","C"]` equals `["A","B","C"]` ✓ `["A","B","C"]` does NOT equal `["C","B","A"]` ✓ -Also works on `int[]`, `IEnumerable`, and any sequence type. +Also works on `IEnumerable` and any sequence type when the attribute is applied explicitly. **Direction override:** apply to `HashSet` to force order-sensitive comparison on a normally unordered set. +```csharp +[SequenceEquality] +public HashSet? OrderedTags { get; set; } // override: order now matters +``` + --- ### `[HashSetEquality]` — order does not matter +**Default for:** `HashSet` — no attribute needed on plain hash sets. + ```csharp -[HashSetEquality] -public HashSet? Roles { get; set; } +public HashSet? Roles { get; set; } // HashSetEquality by default ``` `{"admin","editor"}` equals `{"editor","admin"}` ✓ **Direction override:** apply to `List` or `T[]` to make them order-insensitive. +```csharp +[HashSetEquality] +public List? PermissionCodes { get; set; } // override: order no longer matters +``` + --- ### `[DictionaryEquality]` — insertion order does not matter +**Default for:** `Dictionary` — no attribute needed on plain dictionaries. + ```csharp -[DictionaryEquality] -public Dictionary? OddsBySource { get; set; } +public Dictionary? OddsBySource { get; set; } // DictionaryEquality by default ``` `{betgenius:1.85, abelson:1.90}` equals `{abelson:1.90, betgenius:1.85}` ✓ From 41dbe47aba98683e037460e74ddf8b5898429465 Mon Sep 17 00:00:00 2001 From: "a.kasyanov" Date: Thu, 14 May 2026 02:04:17 +0300 Subject: [PATCH 26/71] README: replace domain-specific names with generic examples Co-Authored-By: Claude Sonnet 4.5 --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 71d8dc5..b1cf888 100644 --- a/README.md +++ b/README.md @@ -149,10 +149,10 @@ public List? PermissionCodes { get; set; } // override: order no longer **Default for:** `Dictionary` — no attribute needed on plain dictionaries. ```csharp -public Dictionary? OddsBySource { get; set; } // DictionaryEquality by default +public Dictionary? Prices { get; set; } // DictionaryEquality by default ``` -`{betgenius:1.85, abelson:1.90}` equals `{abelson:1.90, betgenius:1.85}` ✓ +`{a:1.85, b:1.90}` equals `{b:1.90, a:1.85}` ✓ ### `[DictionaryEquality(sequential: true)]` — key-sorted comparison From 90b2841759ac8e92886754f69fdd1ca86982053b Mon Sep 17 00:00:00 2001 From: "a.kasyanov" Date: Thu, 14 May 2026 02:07:58 +0300 Subject: [PATCH 27/71] README: add nested collections section with propagation rules and explicit override explanation Co-Authored-By: Claude Sonnet 4.5 --- README.md | 34 ++++++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index b1cf888..e51aaf8 100644 --- a/README.md +++ b/README.md @@ -165,18 +165,24 @@ public Dictionary? RankByRegion { get; set; } --- -## Comparer propagation into nested collections +## Nested collections -Annotate the **outer property once** — the chosen comparer kind propagates automatically into all nested levels. +Every collection attribute works on nested collection types without any extra annotation. The outer attribute propagates its intent inward; inner types that have their own default use it. + +### Propagation rules + +| Outer annotation | Inner `Dictionary` | Inner `List` / `T[]` | Inner `HashSet` | +|---|---|---|---| +| `[DictionaryEquality]` | `DictionaryEquality` | `SequenceEquality` | `HashSetEquality` | +| `[DictionaryEquality(sequential:true)]` | `DictionaryEquality(sequential:true)` | `SequenceEquality` | `HashSetEquality` | +| `[SequenceEquality]` | `DictionaryEquality` | `SequenceEquality` | `HashSetEquality` | ```csharp -// outer dict → key-sorted -// inner dict → key-sorted (propagated automatically) +// outer dict → key-sorted; inner dict → key-sorted (propagated) [DictionaryEquality(sequential: true)] -public Dictionary>? ScoresByRegionAndTeam { get; set; } +public Dictionary>? ByRegionAndTeam { get; set; } -// outer dict → key-sorted -// inner list → order-sensitive (inferred from List) +// outer dict → key-sorted; inner list → order-sensitive (default for List) [DictionaryEquality(sequential: true)] public Dictionary>? HistoryByRegion { get; set; } @@ -185,7 +191,19 @@ public Dictionary>? HistoryByRegion { get; set; } public Dictionary>>? ThreeLevelConfig { get; set; } ``` -You never need to annotate nested properties separately. +### Explicit overrides are always transparent + +Annotations on a property are the single source of truth — they are never implied or hidden. If a `List` property has no attribute, it uses `SequenceEquality`. If it has `[HashSetEquality]`, it uses `HashSetEquality`. There is no magic inference that could surprise you. + +```csharp +public List? Tags { get; set; } // SequenceEquality (default) + +[HashSetEquality] +public List? Permissions { get; set; } // HashSetEquality (explicit override) + +[SequenceEquality] +public HashSet? OrderedSet { get; set; } // SequenceEquality (explicit override) +``` --- From 7a4da0192f8b0cec95ea782c9ba7c7abab33dca5 Mon Sep 17 00:00:00 2001 From: "a.kasyanov" Date: Thu, 14 May 2026 02:08:47 +0300 Subject: [PATCH 28/71] README: document multi-dimensional array support Co-Authored-By: Claude Sonnet 4.5 --- README.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/README.md b/README.md index e51aaf8..1543be5 100644 --- a/README.md +++ b/README.md @@ -207,6 +207,34 @@ public HashSet? OrderedSet { get; set; } // SequenceEquality (explicit o --- +## Multi-dimensional arrays + +`T[,]`, `T[,,]`, and higher-rank arrays are supported via `MultiDimensionalArrayEqualityComparer`. Use `[SequenceEquality]` to opt in: + +```csharp +[SequenceEquality] +public int[,] Grid { get; set; } + +[SequenceEquality] +public double[,,] Cube { get; set; } +``` + +Two arrays are equal when: +1. They have the **same rank** (`int[,]` ≠ `int[,,]`) +2. Every **dimension length** matches (`[2,3]` ≠ `[3,2]`) +3. Every **element** is equal in row-major order + +```csharp +var a = new int[,] { { 1, 2 }, { 3, 4 } }; +var b = new int[,] { { 1, 2 }, { 3, 4 } }; +// a == b ✓ + +var c = new int[,] { { 1, 3 }, { 2, 4 } }; // transposed +// a != c ✓ (row-major order is sensitive to transposition) +``` + +--- + ## `[EqualityComparer]` — fully custom comparer When no built-in attribute fits, write your own `IEqualityComparer`: From 04baf85b6dca9ae416f1f17538c7b478118acb2c Mon Sep 17 00:00:00 2001 From: "a.kasyanov" Date: Thu, 14 May 2026 02:10:05 +0300 Subject: [PATCH 29/71] README: document supported types for each collection attribute Co-Authored-By: Claude Sonnet 4.5 --- README.md | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 1543be5..6b17f54 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,8 @@ public partial class LiveScore **Default for:** `List`, `T[]` — no attribute needed on these types. +**Supported types:** any `IEnumerable` — `List`, `T[]`, `ICollection`, `IReadOnlyList`, `IEnumerable`, `HashSet` (via override), and more. + ```csharp public List? Tracks { get; set; } // SequenceEquality by default public int[]? Scores { get; set; } // SequenceEquality by default @@ -114,8 +116,6 @@ public int[]? Scores { get; set; } // SequenceEquality by default `["A","B","C"]` equals `["A","B","C"]` ✓ `["A","B","C"]` does NOT equal `["C","B","A"]` ✓ -Also works on `IEnumerable` and any sequence type when the attribute is applied explicitly. - **Direction override:** apply to `HashSet` to force order-sensitive comparison on a normally unordered set. ```csharp @@ -129,6 +129,8 @@ public HashSet? OrderedTags { get; set; } // override: order now matter **Default for:** `HashSet` — no attribute needed on plain hash sets. +**Supported types:** any `IEnumerable` — `HashSet`, `ISet`, `IReadOnlySet`, `List`, `T[]` (via override), and more. When the value implements `ISet`, `SetEquals` is called directly (fast path). Otherwise a temporary `HashSet` is constructed for the comparison. + ```csharp public HashSet? Roles { get; set; } // HashSetEquality by default ``` @@ -148,6 +150,8 @@ public List? PermissionCodes { get; set; } // override: order no longer **Default for:** `Dictionary` — no attribute needed on plain dictionaries. +**Supported types:** any `IReadOnlyDictionary` — `Dictionary`, `IReadOnlyDictionary`, `SortedDictionary`, `ConcurrentDictionary`, and more. + ```csharp public Dictionary? Prices { get; set; } // DictionaryEquality by default ``` @@ -158,6 +162,8 @@ public Dictionary? Prices { get; set; } // DictionaryEquality b Both sides are sorted by key before comparison. Insertion order is still irrelevant, but the result is deterministic — useful for snapshots and logs. +**Supported types:** same as `[DictionaryEquality]` — any `IReadOnlyDictionary`. + ```csharp [DictionaryEquality(sequential: true)] public Dictionary? RankByRegion { get; set; } @@ -209,7 +215,9 @@ public HashSet? OrderedSet { get; set; } // SequenceEquality (explicit o ## Multi-dimensional arrays -`T[,]`, `T[,,]`, and higher-rank arrays are supported via `MultiDimensionalArrayEqualityComparer`. Use `[SequenceEquality]` to opt in: +`T[,]`, `T[,,]`, and higher-rank arrays are supported via `MultiDimensionalArrayEqualityComparer`. Use `[SequenceEquality]` to opt in. + +**Supported types:** any `System.Array` with rank ≥ 2 — `T[,]`, `T[,,]`, and beyond. Single-dimensional `T[]` uses the standard `[SequenceEquality]` path instead. ```csharp [SequenceEquality] From c7b822f57bccc28c5b96431539deb08894db9c39 Mon Sep 17 00:00:00 2001 From: "a.kasyanov" Date: Thu, 14 May 2026 02:12:28 +0300 Subject: [PATCH 30/71] README: clarify PrivateAssets is optional, add Equatable.Comparers to getting started Co-Authored-By: Claude Sonnet 4.5 --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 6b17f54..89f7e73 100644 --- a/README.md +++ b/README.md @@ -32,10 +32,12 @@ This library generates correct `Equals` + `GetHashCode` at compile-time — zero ## Getting started ```xml - + ``` +`PrivateAssets="all"` on the generator is optional — it prevents the compile-time-only package from flowing to consumers of your library. `Equatable.Comparers` is the runtime package the generated code calls into and must be a real dependency. + Mark your class as `partial` and add `[Equatable]`: ```csharp From 3541e7780156c94375081dbc828632cb76951429 Mon Sep 17 00:00:00 2001 From: "a.kasyanov" Date: Thu, 14 May 2026 02:13:51 +0300 Subject: [PATCH 31/71] README: explain how explicit overrides propagate into nested collections Co-Authored-By: Claude Sonnet 4.5 --- README.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/README.md b/README.md index 89f7e73..ff62ce3 100644 --- a/README.md +++ b/README.md @@ -213,6 +213,24 @@ public List? Permissions { get; set; } // HashSetEquality (explicit ov public HashSet? OrderedSet { get; set; } // SequenceEquality (explicit override) ``` +The same logic applies inside nested collections. The outer annotation sets the comparer kind; inner types follow their own defaults unless they are themselves the type you are overriding at the outer level. + +```csharp +// outer List → SequenceEquality (default, no attribute needed) +// inner HashSet → HashSetEquality (default for HashSet) +public List>? Groups { get; set; } + +// outer List → HashSetEquality (override — treat the list as a set of sets) +// inner HashSet → HashSetEquality (propagated from outer override) +[HashSetEquality] +public List>? GroupsUnordered { get; set; } + +// outer HashSet → SequenceEquality (override — order now matters for the outer set) +// inner List → SequenceEquality (propagated from outer override) +[SequenceEquality] +public HashSet>? OrderedGroups { get; set; } +``` + --- ## Multi-dimensional arrays From 4124d067d15d58a1098cd66d6014e0c514df5900 Mon Sep 17 00:00:00 2001 From: "a.kasyanov" Date: Thu, 14 May 2026 02:14:27 +0300 Subject: [PATCH 32/71] README: split propagation rules into one table per outer annotation Co-Authored-By: Claude Sonnet 4.5 --- README.md | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index ff62ce3..0ebafee 100644 --- a/README.md +++ b/README.md @@ -179,11 +179,29 @@ Every collection attribute works on nested collection types without any extra an ### Propagation rules -| Outer annotation | Inner `Dictionary` | Inner `List` / `T[]` | Inner `HashSet` | -|---|---|---|---| -| `[DictionaryEquality]` | `DictionaryEquality` | `SequenceEquality` | `HashSetEquality` | -| `[DictionaryEquality(sequential:true)]` | `DictionaryEquality(sequential:true)` | `SequenceEquality` | `HashSetEquality` | -| `[SequenceEquality]` | `DictionaryEquality` | `SequenceEquality` | `HashSetEquality` | +**`[DictionaryEquality]` on outer property** + +| Inner type | Comparer used | +|---|---| +| `Dictionary` | `DictionaryEquality` | +| `List` / `T[]` | `SequenceEquality` | +| `HashSet` | `HashSetEquality` | + +**`[DictionaryEquality(sequential:true)]` on outer property** + +| Inner type | Comparer used | +|---|---| +| `Dictionary` | `DictionaryEquality(sequential:true)` (key-sorted, propagated) | +| `List` / `T[]` | `SequenceEquality` | +| `HashSet` | `HashSetEquality` | + +**`[SequenceEquality]` on outer property** + +| Inner type | Comparer used | +|---|---| +| `Dictionary` | `DictionaryEquality` | +| `List` / `T[]` | `SequenceEquality` | +| `HashSet` | `HashSetEquality` | ```csharp // outer dict → key-sorted; inner dict → key-sorted (propagated) From 40d852fbfd9ee9b444c3bf6f4bb94c2e6be7ef70 Mon Sep 17 00:00:00 2001 From: "a.kasyanov" Date: Thu, 14 May 2026 02:43:28 +0300 Subject: [PATCH 33/71] ci: point GitHub Packages publish to fork owner Co-Authored-By: Claude Sonnet 4.5 --- .github/workflows/dotnet.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index bdcf277..1c793be 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -85,7 +85,7 @@ jobs: run: | for package in $(find -name "*.nupkg"); do echo "${0##*/}": Pushing $package... - dotnet nuget push $package --source https://nuget.pkg.github.com/loresoft/index.json --api-key ${{ secrets.GITHUB_TOKEN }} --skip-duplicate + dotnet nuget push $package --source https://nuget.pkg.github.com/kasyanovandrii/index.json --api-key ${{ secrets.GITHUB_TOKEN }} --skip-duplicate done - name: Publish Packages feedz From 5bd1254f8978ec73097ae62e48833e0b81306b82 Mon Sep 17 00:00:00 2001 From: "a.kasyanov" Date: Thu, 14 May 2026 02:44:58 +0300 Subject: [PATCH 34/71] ci: use github.repository_owner for GitHub Packages feed URL Co-Authored-By: Claude Sonnet 4.5 --- .github/workflows/dotnet.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 1c793be..0f23d88 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -85,7 +85,7 @@ jobs: run: | for package in $(find -name "*.nupkg"); do echo "${0##*/}": Pushing $package... - dotnet nuget push $package --source https://nuget.pkg.github.com/kasyanovandrii/index.json --api-key ${{ secrets.GITHUB_TOKEN }} --skip-duplicate + dotnet nuget push $package --source https://nuget.pkg.github.com/${{ github.repository_owner }}/index.json --api-key ${{ secrets.GITHUB_TOKEN }} --skip-duplicate done - name: Publish Packages feedz From b53d24f0c9e1a07717a0f64ae23e33c04f6041be Mon Sep 17 00:00:00 2001 From: "a.kasyanov" Date: Thu, 14 May 2026 02:45:38 +0300 Subject: [PATCH 35/71] ci: revert GitHub Packages feed URL to original Co-Authored-By: Claude Sonnet 4.5 --- .github/workflows/dotnet.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 0f23d88..bdcf277 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -85,7 +85,7 @@ jobs: run: | for package in $(find -name "*.nupkg"); do echo "${0##*/}": Pushing $package... - dotnet nuget push $package --source https://nuget.pkg.github.com/${{ github.repository_owner }}/index.json --api-key ${{ secrets.GITHUB_TOKEN }} --skip-duplicate + dotnet nuget push $package --source https://nuget.pkg.github.com/loresoft/index.json --api-key ${{ secrets.GITHUB_TOKEN }} --skip-duplicate done - name: Publish Packages feedz From 5465ef923d3605e9e3c019c8216294e34ee14f66 Mon Sep 17 00:00:00 2001 From: "a.kasyanov" Date: Thu, 14 May 2026 03:30:14 +0300 Subject: [PATCH 36/71] build: add adapter projects to solution so they are packed by dotnet pack Co-Authored-By: Claude Sonnet 4.5 --- Equatable.Generator.slnx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Equatable.Generator.slnx b/Equatable.Generator.slnx index bfdee4c..21b0e6c 100644 --- a/Equatable.Generator.slnx +++ b/Equatable.Generator.slnx @@ -12,5 +12,9 @@ + + + + From d750ba7d388e5319ab05f054fcbf84c1d35c825c Mon Sep 17 00:00:00 2001 From: "a.kasyanov" Date: Thu, 14 May 2026 11:03:26 +0300 Subject: [PATCH 37/71] README: replace propagation tables with concrete code examples per attribute Co-Authored-By: Claude Sonnet 4.5 --- README.md | 61 ++++++++++++++++++++++++++++++++++--------------------- 1 file changed, 38 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 0ebafee..a949f5e 100644 --- a/README.md +++ b/README.md @@ -175,40 +175,36 @@ public Dictionary? RankByRegion { get; set; } ## Nested collections -Every collection attribute works on nested collection types without any extra annotation. The outer attribute propagates its intent inward; inner types that have their own default use it. +Annotate the **outer property once** — the generator infers the right comparer for every nested level automatically. -### Propagation rules +### `[DictionaryEquality]` -**`[DictionaryEquality]` on outer property** - -| Inner type | Comparer used | -|---|---| -| `Dictionary` | `DictionaryEquality` | -| `List` / `T[]` | `SequenceEquality` | -| `HashSet` | `HashSetEquality` | +```csharp +// outer: DictionaryEquality +// inner Dictionary: DictionaryEquality (propagated) +public Dictionary>? ByRegion { get; set; } -**`[DictionaryEquality(sequential:true)]` on outer property** +// outer: DictionaryEquality +// inner List: SequenceEquality (default for List) +public Dictionary>? ScoresByRegion { get; set; } -| Inner type | Comparer used | -|---|---| -| `Dictionary` | `DictionaryEquality(sequential:true)` (key-sorted, propagated) | -| `List` / `T[]` | `SequenceEquality` | -| `HashSet` | `HashSetEquality` | +// outer: DictionaryEquality +// inner HashSet: HashSetEquality (default for HashSet) +public Dictionary>? TagsByRegion { get; set; } +``` -**`[SequenceEquality]` on outer property** +### `[DictionaryEquality(sequential: true)]` -| Inner type | Comparer used | -|---|---| -| `Dictionary` | `DictionaryEquality` | -| `List` / `T[]` | `SequenceEquality` | -| `HashSet` | `HashSetEquality` | +Key-sorted comparison propagates into every nested dictionary level. ```csharp -// outer dict → key-sorted; inner dict → key-sorted (propagated) +// outer: key-sorted dict +// inner Dictionary: key-sorted (propagated) [DictionaryEquality(sequential: true)] public Dictionary>? ByRegionAndTeam { get; set; } -// outer dict → key-sorted; inner list → order-sensitive (default for List) +// outer: key-sorted dict +// inner List: SequenceEquality (default for List) [DictionaryEquality(sequential: true)] public Dictionary>? HistoryByRegion { get; set; } @@ -217,6 +213,25 @@ public Dictionary>? HistoryByRegion { get; set; } public Dictionary>>? ThreeLevelConfig { get; set; } ``` +### `[SequenceEquality]` + +```csharp +// outer: SequenceEquality (order matters for the outer list) +// inner Dictionary: DictionaryEquality (default for Dictionary) +[SequenceEquality] +public List>? Steps { get; set; } + +// outer: SequenceEquality +// inner List: SequenceEquality (default for List) +[SequenceEquality] +public List>? Matrix { get; set; } + +// outer: SequenceEquality +// inner HashSet: HashSetEquality (default for HashSet) +[SequenceEquality] +public List>? Groups { get; set; } +``` + ### Explicit overrides are always transparent Annotations on a property are the single source of truth — they are never implied or hidden. If a `List` property has no attribute, it uses `SequenceEquality`. If it has `[HashSetEquality]`, it uses `HashSetEquality`. There is no magic inference that could surprise you. From d6b21b75d9b898aeb8698a6eb8b764bab78638d3 Mon Sep 17 00:00:00 2001 From: "a.kasyanov" Date: Thu, 14 May 2026 11:17:17 +0300 Subject: [PATCH 38/71] README: add inline comments and rank/transpose examples for multi-dimensional arrays Co-Authored-By: Claude Sonnet 4.5 --- README.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a949f5e..aacda9d 100644 --- a/README.md +++ b/README.md @@ -273,9 +273,11 @@ public HashSet>? OrderedGroups { get; set; } **Supported types:** any `System.Array` with rank ≥ 2 — `T[,]`, `T[,,]`, and beyond. Single-dimensional `T[]` uses the standard `[SequenceEquality]` path instead. ```csharp +// 2D array — [SequenceEquality] required (no default for multi-dimensional arrays) [SequenceEquality] public int[,] Grid { get; set; } +// 3D array — same attribute, rank detected automatically [SequenceEquality] public double[,,] Cube { get; set; } ``` @@ -288,10 +290,13 @@ Two arrays are equal when: ```csharp var a = new int[,] { { 1, 2 }, { 3, 4 } }; var b = new int[,] { { 1, 2 }, { 3, 4 } }; -// a == b ✓ +// a == b ✓ (same rank, same dimensions, same elements) var c = new int[,] { { 1, 3 }, { 2, 4 } }; // transposed -// a != c ✓ (row-major order is sensitive to transposition) +// a != c ✓ (row-major order: [0,0]=1,[0,1]=2,[1,0]=3,[1,1]=4 vs [0,0]=1,[0,1]=3,...) + +var d = new int[,,] { { { 1, 2 }, { 3, 4 } } }; +// a != d ✓ (rank 2 vs rank 3 — always unequal regardless of content) ``` --- From e5b283bdae80aabaf3005b87581929ef2d251a54 Mon Sep 17 00:00:00 2001 From: "a.kasyanov" Date: Thu, 14 May 2026 11:19:15 +0300 Subject: [PATCH 39/71] =?UTF-8?q?README:=20multi-dimensional=20arrays=20de?= =?UTF-8?q?fault=20like=20T[]=20=E2=80=94=20no=20attribute=20needed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.5 --- README.md | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index aacda9d..7d53f9e 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ The generator writes `Equals` and `GetHashCode` for every public property. Works | `[IgnoreEquality]` | Skip this property | — | | `[StringEquality(StringComparison.X)]` | `StringComparer.X.Equals(a, b)` | — | | `[EqualityComparer(typeof(T))]` | `T.Default.Equals(a, b)` — any custom comparer | — | -| `[SequenceEquality]` | `SequenceEqualityComparer` — element order matters | `List`, `T[]` | +| `[SequenceEquality]` | `SequenceEqualityComparer` — element order matters | `List`, `T[]`, `T[,]`, `T[,,]` | | `[HashSetEquality]` | `HashSetEqualityComparer` — element order ignored | `HashSet` | | `[DictionaryEquality]` | `ReadOnlyDictionaryEqualityComparer` — key-value equality | `Dictionary` | | `[DictionaryEquality(sequential:true)]` | `OrderedReadOnlyDictionaryEqualityComparer` — key-sorted | — | @@ -268,17 +268,15 @@ public HashSet>? OrderedGroups { get; set; } ## Multi-dimensional arrays -`T[,]`, `T[,,]`, and higher-rank arrays are supported via `MultiDimensionalArrayEqualityComparer`. Use `[SequenceEquality]` to opt in. +`T[,]`, `T[,,]`, and higher-rank arrays are supported via `MultiDimensionalArrayEqualityComparer` — no attribute needed, just like `T[]`. -**Supported types:** any `System.Array` with rank ≥ 2 — `T[,]`, `T[,,]`, and beyond. Single-dimensional `T[]` uses the standard `[SequenceEquality]` path instead. +**Default for:** any array with rank ≥ 2 — `T[,]`, `T[,,]`, and beyond. Single-dimensional `T[]` uses `SequenceEqualityComparer` instead. ```csharp -// 2D array — [SequenceEquality] required (no default for multi-dimensional arrays) -[SequenceEquality] +// 2D array — MultiDimensionalArrayEqualityComparer used by default, no attribute needed public int[,] Grid { get; set; } -// 3D array — same attribute, rank detected automatically -[SequenceEquality] +// 3D array — same default, rank detected automatically public double[,,] Cube { get; set; } ``` From ee11f93d5449e4bbc5106ea63e0eb80bc4129b87 Mon Sep 17 00:00:00 2001 From: "a.kasyanov" Date: Thu, 14 May 2026 11:32:12 +0300 Subject: [PATCH 40/71] README: document multi-dimensional array override rules and contrast with T[] Co-Authored-By: Claude Sonnet 4.5 --- README.md | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 7d53f9e..a0381a3 100644 --- a/README.md +++ b/README.md @@ -268,15 +268,15 @@ public HashSet>? OrderedGroups { get; set; } ## Multi-dimensional arrays -`T[,]`, `T[,,]`, and higher-rank arrays are supported via `MultiDimensionalArrayEqualityComparer` — no attribute needed, just like `T[]`. +`T[,]`, `T[,,]`, and higher-rank arrays are handled by `MultiDimensionalArrayEqualityComparer` — no attribute needed, just like `T[]`. -**Default for:** any array with rank ≥ 2 — `T[,]`, `T[,,]`, and beyond. Single-dimensional `T[]` uses `SequenceEqualityComparer` instead. +**Default for:** any array with rank ≥ 2. Single-dimensional `T[]` uses `SequenceEqualityComparer` instead. ```csharp -// 2D array — MultiDimensionalArrayEqualityComparer used by default, no attribute needed +// 2D array — MultiDimensionalArrayEqualityComparer by default, no attribute needed public int[,] Grid { get; set; } -// 3D array — same default, rank detected automatically +// 3D array — same default, rank detected automatically at compile time public double[,,] Cube { get; set; } ``` @@ -297,6 +297,28 @@ var d = new int[,,] { { { 1, 2 }, { 3, 4 } } }; // a != d ✓ (rank 2 vs rank 3 — always unequal regardless of content) ``` +### Overrides for multi-dimensional arrays + +The outer comparer is always `MultiDimensionalArrayEqualityComparer` for rank ≥ 2 — it cannot be swapped for `SequenceEqualityComparer` or `HashSetEqualityComparer`. The only supported override is **element-level** via `[EqualityComparer]`: + +```csharp +// outer: MultiDimensionalArrayEqualityComparer (cannot be changed) +// inner elements: OrdinalIgnoreCase comparer (propagated into element comparison) +[EqualityComparer(typeof(StringComparer), nameof(StringComparer.OrdinalIgnoreCase))] +public string[,] Labels { get; set; } +``` + +Single-dimensional `T[]` is more flexible — it supports all comparer overrides: + +```csharp +// T[] default: SequenceEquality (order matters) +public int[] Scores { get; set; } + +// T[] override: HashSetEquality (order no longer matters) +[HashSetEquality] +public int[] GroupIds { get; set; } +``` + --- ## `[EqualityComparer]` — fully custom comparer From 522e4ce6bc43af8aac81aff9c0043a7db5fa2811 Mon Sep 17 00:00:00 2001 From: "a.kasyanov" Date: Thu, 14 May 2026 11:48:01 +0300 Subject: [PATCH 41/71] =?UTF-8?q?fix:=20correct=20README=20=E2=80=94=20[Eq?= =?UTF-8?q?ualityComparer]=20on=20T[,]=20bypasses=20multi-dim=20comparer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.5 --- README.md | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/README.md b/README.md index a0381a3..048bd96 100644 --- a/README.md +++ b/README.md @@ -299,14 +299,7 @@ var d = new int[,,] { { { 1, 2 }, { 3, 4 } } }; ### Overrides for multi-dimensional arrays -The outer comparer is always `MultiDimensionalArrayEqualityComparer` for rank ≥ 2 — it cannot be swapped for `SequenceEqualityComparer` or `HashSetEqualityComparer`. The only supported override is **element-level** via `[EqualityComparer]`: - -```csharp -// outer: MultiDimensionalArrayEqualityComparer (cannot be changed) -// inner elements: OrdinalIgnoreCase comparer (propagated into element comparison) -[EqualityComparer(typeof(StringComparer), nameof(StringComparer.OrdinalIgnoreCase))] -public string[,] Labels { get; set; } -``` +The outer comparer is always `MultiDimensionalArrayEqualityComparer` for rank ≥ 2 — it cannot be swapped for `SequenceEqualityComparer` or `HashSetEqualityComparer`. There is no supported element-level override: `[EqualityComparer]` on a `T[,]` property bypasses `MultiDimensionalArrayEqualityComparer` entirely and compares the array as a single reference, which is incorrect. Use the default and rely on element type's own equality instead. Single-dimensional `T[]` is more flexible — it supports all comparer overrides: From 95cf6cdd69f1ec2b65271d801cefbda5bce1270d Mon Sep 17 00:00:00 2001 From: "a.kasyanov" Date: Thu, 14 May 2026 11:53:29 +0300 Subject: [PATCH 42/71] test: prove [EqualityComparer] on T[,] bypasses MultiDimensionalArrayEqualityComparer Co-Authored-By: Claude Sonnet 4.5 --- .../EquatableGeneratorTest.cs | 30 +++++++++++++ ...parerBypassesMultiDimComparer.verified.txt | 45 +++++++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateMultiDimensionalArrayWithEqualityComparerBypassesMultiDimComparer.verified.txt diff --git a/test/Equatable.Generator.Tests/EquatableGeneratorTest.cs b/test/Equatable.Generator.Tests/EquatableGeneratorTest.cs index e843778..3345a1a 100644 --- a/test/Equatable.Generator.Tests/EquatableGeneratorTest.cs +++ b/test/Equatable.Generator.Tests/EquatableGeneratorTest.cs @@ -567,6 +567,36 @@ public partial class Grid .ScrubLinesContaining("GeneratedCodeAttribute"); } + /// + /// Proves that [EqualityComparer] on a T[,] property bypasses MultiDimensionalArrayEqualityComparer — + /// the custom comparer receives the whole array as a single value, not individual elements. + /// This means element-level overrides on multi-dimensional arrays are NOT supported. + /// + [Fact] + public Task GenerateMultiDimensionalArrayWithEqualityComparerBypassesMultiDimComparer() + { + var source = @" +using Equatable.Attributes; + +namespace Equatable.Entities; + +[Equatable] +public partial class Labels +{ + [EqualityComparer(typeof(System.StringComparer), nameof(System.StringComparer.OrdinalIgnoreCase))] + public string[,]? Grid { get; set; } + + public int Id { get; set; } +} +"; + var (diagnostics, output) = GetGeneratedOutput(source); + + return Verifier + .Verify(output) + .UseDirectory("Snapshots") + .ScrubLinesContaining("GeneratedCodeAttribute"); + } + [Fact] public Task GenerateReadOnlyDictionary() { diff --git a/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateMultiDimensionalArrayWithEqualityComparerBypassesMultiDimComparer.verified.txt b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateMultiDimensionalArrayWithEqualityComparerBypassesMultiDimComparer.verified.txt new file mode 100644 index 0000000..9548ec9 --- /dev/null +++ b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateMultiDimensionalArrayWithEqualityComparerBypassesMultiDimComparer.verified.txt @@ -0,0 +1,45 @@ +// +#nullable enable + +namespace Equatable.Entities +{ + partial class Labels : global::System.IEquatable + { + /// + public bool Equals(global::Equatable.Entities.Labels? other) + { + return !(other is null) + && global::System.StringComparer.OrdinalIgnoreCase.Equals(Grid, other.Grid) + && Id == other.Id; + + } + + /// + public override bool Equals(object? obj) + { + return Equals(obj as global::Equatable.Entities.Labels); + } + + /// + public static bool operator ==(global::Equatable.Entities.Labels? left, global::Equatable.Entities.Labels? right) + { + return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); + } + + /// + public static bool operator !=(global::Equatable.Entities.Labels? left, global::Equatable.Entities.Labels? right) + { + return !(left == right); + } + + /// + public override int GetHashCode(){ + int hashCode = -1719537575; + hashCode = (hashCode * -1521134295) + global::System.StringComparer.OrdinalIgnoreCase.GetHashCode(Grid!); + hashCode = (hashCode * -1521134295) + Id.GetHashCode(); + return hashCode; + + } + + } +} From 0152c8330cd263e1238954e44509ec07fdece9de Mon Sep 17 00:00:00 2001 From: "a.kasyanov" Date: Thu, 14 May 2026 12:10:58 +0300 Subject: [PATCH 43/71] =?UTF-8?q?feat:=20EQ0014=20=E2=80=94=20warn=20when?= =?UTF-8?q?=20any=20attribute=20is=20applied=20to=20a=20multi-dimensional?= =?UTF-8?q?=20array?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multi-dimensional arrays (rank ≥ 2) always use MultiDimensionalArrayEqualityComparer regardless of any annotation. Any collection or equality attribute on such a property has no effect (or worse, [EqualityComparer] silently bypasses the multi-dim comparer and produces reference equality). EQ0014 turns this silent wrong behavior into a visible compile-time warning. Changes: - DiagnosticDescriptors.cs: add EQ0014 descriptor - EquatableAnalyzer.cs: register EQ0014 in SupportedDiagnostics; skip EQ0002 for multi-dim arrays (they have a default); fire EQ0014 for any collection/equality attribute on rank ≥ 2 arrays - EquatableAnalyzerTest.cs: fix existing test (int[,] with no attribute → no warning); add 6 new tests covering EQ0014 for SequenceEquality, HashSetEquality, EqualityComparer, ReferenceEquality, 3D arrays, and the no-attribute baseline - README.md: add build-time diagnostics section documenting all EQ00xx codes with special explanation of EQ0014 and why override is impossible on multi-dim arrays Co-Authored-By: Claude Sonnet 4.5 --- README.md | 43 +++++ .../DiagnosticDescriptors.cs | 9 + .../EquatableAnalyzer.cs | 28 +++- .../EquatableAnalyzerTest.cs | 156 +++++++++++++++++- 4 files changed, 228 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 048bd96..cca981b 100644 --- a/README.md +++ b/README.md @@ -333,6 +333,49 @@ public Dictionary? AssetWeights { get; set; } --- +## Build-time diagnostics + +The analyzer validates every `[Equatable]` class at compile time and emits warnings when attributes are missing or misused. These diagnostics are designed to surface mistakes that would otherwise produce silent wrong behavior at runtime. + +### Missing attribute warnings + +| Diagnostic | Condition | Example | +|---|---|---| +| `EQ0001` | `IDictionary` or `IReadOnlyDictionary` property with no attribute | `Dictionary? Map` | +| `EQ0002` | `IEnumerable` property (including `T[]`) with no attribute | `List? Tags`, `int[]? Ids` | + +Multi-dimensional arrays (`T[,]`, `T[,,]`) are exempt from EQ0002 because `MultiDimensionalArrayEqualityComparer` is always the default — no annotation is needed or accepted. + +### Invalid attribute warnings + +| Diagnostic | Condition | +|---|---| +| `EQ0010` | `[StringEquality]` on a non-`string` property | +| `EQ0011` | `[DictionaryEquality]` on a type that does not implement `IDictionary` or `IReadOnlyDictionary` | +| `EQ0012` | `[HashSetEquality]` on a type that does not implement `IEnumerable` | +| `EQ0013` | `[SequenceEquality]` on a type that does not implement `IEnumerable` | +| `EQ0014` | Any collection or equality attribute on a multi-dimensional array (`rank ≥ 2`) | + +### EQ0014 — attributes have no effect on multi-dimensional arrays + +`EQ0014` fires whenever any collection or equality attribute (`[SequenceEquality]`, `[HashSetEquality]`, `[DictionaryEquality]`, `[EqualityComparer]`, `[ReferenceEquality]`) is placed on a `T[,]` or higher-rank array property. This is intentional and expected — it is not possible to override the comparer for a multi-dimensional array: + +- `[SequenceEquality]` and `[HashSetEquality]` are silently ignored; `MultiDimensionalArrayEqualityComparer` is used regardless. +- `[EqualityComparer(typeof(MyComparer))]` appears to work but actually bypasses `MultiDimensionalArrayEqualityComparer` entirely, passing the whole array object as a single value to `MyComparer`. The result is effectively reference equality — almost certainly not what was intended. + +The diagnostic turns a silent, surprising behavior into a loud, visible one at compile time: + +```csharp +// EQ0014 — attribute has no effect on rank-2 array +[SequenceEquality] +public int[,]? Grid { get; set; } + +// Correct — no attribute needed; MultiDimensionalArrayEqualityComparer is the default +public int[,]? Grid { get; set; } +``` + +--- + ## Equality invariants Every generated implementation satisfies: diff --git a/src/Equatable.SourceGenerator/DiagnosticDescriptors.cs b/src/Equatable.SourceGenerator/DiagnosticDescriptors.cs index e04eb8f..126d7ef 100644 --- a/src/Equatable.SourceGenerator/DiagnosticDescriptors.cs +++ b/src/Equatable.SourceGenerator/DiagnosticDescriptors.cs @@ -58,4 +58,13 @@ internal static class DiagnosticDescriptors isEnabledByDefault: true ); + public static readonly DiagnosticDescriptor InvalidAttributeOnMultiDimensionalArray = new( + id: "EQ0014", + title: "Invalid Attribute on Multi-Dimensional Array", + messageFormat: "Attribute on property '{0}' has no effect on a multi-dimensional array (rank ≥ 2). MultiDimensionalArrayEqualityComparer is always used regardless of this attribute.", + category: "Usage", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); + } diff --git a/src/Equatable.SourceGenerator/EquatableAnalyzer.cs b/src/Equatable.SourceGenerator/EquatableAnalyzer.cs index 905c34f..3d38e71 100644 --- a/src/Equatable.SourceGenerator/EquatableAnalyzer.cs +++ b/src/Equatable.SourceGenerator/EquatableAnalyzer.cs @@ -16,7 +16,8 @@ public class EquatableAnalyzer : DiagnosticAnalyzer DiagnosticDescriptors.InvalidStringEqualityAttributeUsage, DiagnosticDescriptors.InvalidDictionaryEqualityAttributeUsage, DiagnosticDescriptors.InvalidHashSetEqualityAttributeUsage, - DiagnosticDescriptors.InvalidSequenceEqualityAttributeUsage + DiagnosticDescriptors.InvalidSequenceEqualityAttributeUsage, + DiagnosticDescriptors.InvalidAttributeOnMultiDimensionalArray ); public override void Initialize(AnalysisContext context) @@ -72,6 +73,7 @@ private static void AnalyzeProperty(SymbolAnalysisContext context, IPropertySymb { var attributes = property.GetAttributes(); var hasEqualityAttribute = false; + var isMultiDimArray = property.Type is IArrayTypeSymbol { Rank: > 1 }; foreach (var attribute in attributes) { @@ -85,6 +87,16 @@ private static void AnalyzeProperty(SymbolAnalysisContext context, IPropertySymb ?.GetSyntax(context.CancellationToken).GetLocation() ?? property.Locations.FirstOrDefault(); + // Any collection/equality attribute on a multi-dimensional array has no effect: + // MultiDimensionalArrayEqualityComparer is always used regardless of the annotation. + if (isMultiDimArray && IsCollectionOrEqualityAttribute(className)) + { + context.ReportDiagnostic(Diagnostic.Create( + DiagnosticDescriptors.InvalidAttributeOnMultiDimensionalArray, + attributeLocation, + property.Name)); + } + if (className == "StringEqualityAttribute" && !IsString(property.Type)) { context.ReportDiagnostic(Diagnostic.Create( @@ -118,8 +130,10 @@ private static void AnalyzeProperty(SymbolAnalysisContext context, IPropertySymb } } - // Warn when a collection/dictionary property has no equality attribute - if (!hasEqualityAttribute) + // Warn when a collection/dictionary property has no equality attribute. + // Multi-dimensional arrays are exempt: they always use MultiDimensionalArrayEqualityComparer + // by default and do not require an explicit annotation. + if (!hasEqualityAttribute && !isMultiDimArray) { var propertyLocation = property.Locations.FirstOrDefault(); @@ -140,6 +154,14 @@ private static void AnalyzeProperty(SymbolAnalysisContext context, IPropertySymb } } + private static bool IsCollectionOrEqualityAttribute(string? className) => + className is "SequenceEqualityAttribute" + or "HashSetEqualityAttribute" + or "DictionaryEqualityAttribute" + or "EqualityComparerAttribute" + or "ReferenceEqualityAttribute" + or "StringEqualityAttribute"; + private static bool HasEquatableAttribute(INamedTypeSymbol typeSymbol) { return typeSymbol.GetAttributes().Any( diff --git a/test/Equatable.Generator.Tests/EquatableAnalyzerTest.cs b/test/Equatable.Generator.Tests/EquatableAnalyzerTest.cs index a3c6fc3..c928595 100644 --- a/test/Equatable.Generator.Tests/EquatableAnalyzerTest.cs +++ b/test/Equatable.Generator.Tests/EquatableAnalyzerTest.cs @@ -635,9 +635,9 @@ public partial class UserImport } [Fact] - public async Task AnalyzeMissingAttributeForMultiDimensionalArray() + public async Task AnalyzeMultiDimensionalArrayNoAttributeNoWarning() { - // int[,] without [SequenceEquality] → EQ0002 + // int[,] without any attribute → no diagnostic (MultiDimensionalArrayEqualityComparer is the default) const string source = @" using Equatable.Attributes; @@ -651,9 +651,7 @@ public partial class Grid "; var diagnostics = await AnalyzerTestHelper.GetAnalyzerDiagnosticsAsync(source); - var diagnostic = Assert.Single(diagnostics); - Assert.Equal("EQ0002", diagnostic.Id); - Assert.Contains("Cells", diagnostic.GetMessage()); + Assert.Empty(diagnostics); } [Fact] @@ -720,4 +718,152 @@ public partial class UserImport Assert.Empty(diagnostics); } + + // ── EQ0014 — invalid attribute on multi-dimensional array ───────────────────────────────────── + + [Fact] + public async Task AnalyzeSequenceEqualityOnMultiDimArrayEmitsEQ0014() + { + // [SequenceEquality] on int[,] → EQ0014 (MultiDimensionalArrayEqualityComparer is always used) + const string source = @" +using Equatable.Attributes; + +namespace Equatable.Entities; + +[Equatable] +public partial class Grid +{ + [SequenceEquality] + public int[,]? Cells { get; set; } +} +"; + var diagnostics = await AnalyzerTestHelper.GetAnalyzerDiagnosticsAsync(source); + + var diagnostic = Assert.Single(diagnostics); + Assert.Equal("EQ0014", diagnostic.Id); + Assert.Contains("Cells", diagnostic.GetMessage()); + } + + [Fact] + public async Task AnalyzeHashSetEqualityOnMultiDimArrayEmitsEQ0014() + { + // [HashSetEquality] on int[,] → EQ0014 only + // (ImplementsEnumerable returns true for all arrays, so EQ0012 does not fire; + // the runtime validator in the generator would reject Rank > 1, but the analyzer + // only emits EQ0014 to keep diagnostics non-overlapping) + const string source = @" +using Equatable.Attributes; + +namespace Equatable.Entities; + +[Equatable] +public partial class Grid +{ + [HashSetEquality] + public int[,]? Cells { get; set; } +} +"; + var diagnostics = await AnalyzerTestHelper.GetAnalyzerDiagnosticsAsync(source); + + var diagnostic = Assert.Single(diagnostics); + Assert.Equal("EQ0014", diagnostic.Id); + Assert.Contains("Cells", diagnostic.GetMessage()); + } + + [Fact] + public async Task AnalyzeEqualityComparerOnMultiDimArrayEmitsEQ0014() + { + // [EqualityComparer] on int[,] → EQ0014 (bypasses MultiDimensionalArrayEqualityComparer entirely) + const string source = @" +using System.Collections.Generic; +using Equatable.Attributes; + +namespace Equatable.Entities; + +[Equatable] +public partial class Grid +{ + [EqualityComparer(typeof(MyComparer))] + public int[,]? Cells { get; set; } +} + +public class MyComparer : IEqualityComparer +{ + public static readonly MyComparer Default = new(); + public bool Equals(int[,]? x, int[,]? y) => ReferenceEquals(x, y); + public int GetHashCode(int[,]? obj) => obj?.GetHashCode() ?? 0; +} +"; + var diagnostics = await AnalyzerTestHelper.GetAnalyzerDiagnosticsAsync(source); + + var diagnostic = Assert.Single(diagnostics); + Assert.Equal("EQ0014", diagnostic.Id); + Assert.Contains("Cells", diagnostic.GetMessage()); + } + + [Fact] + public async Task AnalyzeReferenceEqualityOnMultiDimArrayEmitsEQ0014() + { + // [ReferenceEquality] on int[,] → EQ0014 + const string source = @" +using Equatable.Attributes; + +namespace Equatable.Entities; + +[Equatable] +public partial class Grid +{ + [ReferenceEquality] + public int[,]? Cells { get; set; } +} +"; + var diagnostics = await AnalyzerTestHelper.GetAnalyzerDiagnosticsAsync(source); + + var diagnostic = Assert.Single(diagnostics); + Assert.Equal("EQ0014", diagnostic.Id); + Assert.Contains("Cells", diagnostic.GetMessage()); + } + + [Fact] + public async Task AnalyzeMultiDimArrayThreeDimensionsEmitsEQ0014() + { + // int[,,] with [SequenceEquality] → EQ0014 (rank 3 also triggers) + const string source = @" +using Equatable.Attributes; + +namespace Equatable.Entities; + +[Equatable] +public partial class Cube +{ + [SequenceEquality] + public double[,,]? Data { get; set; } +} +"; + var diagnostics = await AnalyzerTestHelper.GetAnalyzerDiagnosticsAsync(source); + + var diagnostic = Assert.Single(diagnostics); + Assert.Equal("EQ0014", diagnostic.Id); + Assert.Contains("Data", diagnostic.GetMessage()); + } + + [Fact] + public async Task AnalyzeMultiDimArrayNoAttributeNoWarning() + { + // int[,,] without any attribute → no diagnostic (default comparer handles it) + const string source = @" +using Equatable.Attributes; + +namespace Equatable.Entities; + +[Equatable] +public partial class Cube +{ + public double[,,]? Data { get; set; } +} +"; + var diagnostics = await AnalyzerTestHelper.GetAnalyzerDiagnosticsAsync(source); + + Assert.Empty(diagnostics); + } } From ec7a20731451b3d4532374a7b0656ae9c7b0b2ff Mon Sep 17 00:00:00 2001 From: "a.kasyanov" Date: Thu, 14 May 2026 12:13:54 +0300 Subject: [PATCH 44/71] =?UTF-8?q?feat:=20EQ0015=20=E2=80=94=20warn=20when?= =?UTF-8?q?=20[SequenceEquality]=20or=20[HashSetEquality]=20is=20used=20on?= =?UTF-8?q?=20a=20dictionary=20type?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applying an enumerable attribute to a Dictionary treats it as a flat sequence of KeyValuePair entries, discarding key-lookup semantics. This is almost always wrong. EQ0015 fires for [SequenceEquality] and [HashSetEquality] on any type that implements IDictionary or IReadOnlyDictionary; [DictionaryEquality] is the correct choice. Changes: - DiagnosticDescriptors.cs: add EQ0015 descriptor - EquatableAnalyzer.cs: register EQ0015; detect SequenceEquality/HashSetEquality on dictionary types and report EQ0015 - EquatableAnalyzerTest.cs: 5 new tests covering Dictionary, IDictionary, IReadOnlyDictionary, and the valid [DictionaryEquality] baseline - README.md: add EQ0015 to the diagnostics table and add an explanation section Co-Authored-By: Claude Sonnet 4.5 --- README.md | 19 +++ .../DiagnosticDescriptors.cs | 9 ++ .../EquatableAnalyzer.cs | 14 +- .../EquatableAnalyzerTest.cs | 120 ++++++++++++++++++ 4 files changed, 161 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index cca981b..ca6f578 100644 --- a/README.md +++ b/README.md @@ -355,6 +355,7 @@ Multi-dimensional arrays (`T[,]`, `T[,,]`) are exempt from EQ0002 because `Multi | `EQ0012` | `[HashSetEquality]` on a type that does not implement `IEnumerable` | | `EQ0013` | `[SequenceEquality]` on a type that does not implement `IEnumerable` | | `EQ0014` | Any collection or equality attribute on a multi-dimensional array (`rank ≥ 2`) | +| `EQ0015` | `[SequenceEquality]` or `[HashSetEquality]` on a dictionary type (`IDictionary` or `IReadOnlyDictionary`) | ### EQ0014 — attributes have no effect on multi-dimensional arrays @@ -374,6 +375,24 @@ public int[,]? Grid { get; set; } public int[,]? Grid { get; set; } ``` +### EQ0015 — enumerable attributes have no useful meaning on dictionary types + +`EQ0015` fires when `[SequenceEquality]` or `[HashSetEquality]` is applied to a property whose type implements `IDictionary` or `IReadOnlyDictionary`. These attributes treat the dictionary as a flat sequence of `KeyValuePair` entries, which discards key-lookup semantics and produces comparisons that are sensitive to insertion order. Use `[DictionaryEquality]` instead: + +```csharp +// EQ0015 — treats Dictionary as a sequence of KeyValuePair entries (order-sensitive, wrong) +[SequenceEquality] +public Dictionary? Scores { get; set; } + +// EQ0015 — treats Dictionary as a set of KeyValuePair entries (still wrong) +[HashSetEquality] +public Dictionary? Scores { get; set; } + +// Correct — key-value equality, insertion order irrelevant +[DictionaryEquality] +public Dictionary? Scores { get; set; } +``` + --- ## Equality invariants diff --git a/src/Equatable.SourceGenerator/DiagnosticDescriptors.cs b/src/Equatable.SourceGenerator/DiagnosticDescriptors.cs index 126d7ef..1027128 100644 --- a/src/Equatable.SourceGenerator/DiagnosticDescriptors.cs +++ b/src/Equatable.SourceGenerator/DiagnosticDescriptors.cs @@ -67,4 +67,13 @@ internal static class DiagnosticDescriptors isEnabledByDefault: true ); + public static readonly DiagnosticDescriptor InvalidEnumerableAttributeOnDictionary = new( + id: "EQ0015", + title: "Invalid Enumerable Attribute on Dictionary Type", + messageFormat: "Invalid attribute usage for property '{0}'. Property type implements IDictionary or IReadOnlyDictionary; use [DictionaryEquality] instead of [SequenceEquality] or [HashSetEquality].", + category: "Usage", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); + } diff --git a/src/Equatable.SourceGenerator/EquatableAnalyzer.cs b/src/Equatable.SourceGenerator/EquatableAnalyzer.cs index 3d38e71..a8f2f92 100644 --- a/src/Equatable.SourceGenerator/EquatableAnalyzer.cs +++ b/src/Equatable.SourceGenerator/EquatableAnalyzer.cs @@ -17,7 +17,8 @@ public class EquatableAnalyzer : DiagnosticAnalyzer DiagnosticDescriptors.InvalidDictionaryEqualityAttributeUsage, DiagnosticDescriptors.InvalidHashSetEqualityAttributeUsage, DiagnosticDescriptors.InvalidSequenceEqualityAttributeUsage, - DiagnosticDescriptors.InvalidAttributeOnMultiDimensionalArray + DiagnosticDescriptors.InvalidAttributeOnMultiDimensionalArray, + DiagnosticDescriptors.InvalidEnumerableAttributeOnDictionary ); public override void Initialize(AnalysisContext context) @@ -97,6 +98,17 @@ private static void AnalyzeProperty(SymbolAnalysisContext context, IPropertySymb property.Name)); } + // [SequenceEquality] or [HashSetEquality] on a dictionary type treats the dict as a + // sequence of KeyValuePair entries, discarding key-lookup semantics entirely. + if ((className == "SequenceEqualityAttribute" || className == "HashSetEqualityAttribute") + && ImplementsDictionary(property.Type)) + { + context.ReportDiagnostic(Diagnostic.Create( + DiagnosticDescriptors.InvalidEnumerableAttributeOnDictionary, + attributeLocation, + property.Name)); + } + if (className == "StringEqualityAttribute" && !IsString(property.Type)) { context.ReportDiagnostic(Diagnostic.Create( diff --git a/test/Equatable.Generator.Tests/EquatableAnalyzerTest.cs b/test/Equatable.Generator.Tests/EquatableAnalyzerTest.cs index c928595..00441cf 100644 --- a/test/Equatable.Generator.Tests/EquatableAnalyzerTest.cs +++ b/test/Equatable.Generator.Tests/EquatableAnalyzerTest.cs @@ -866,4 +866,124 @@ public partial class Cube Assert.Empty(diagnostics); } + + // ── EQ0015 — enumerable attribute on dictionary type ───────────────────────────────────────── + + [Fact] + public async Task AnalyzeSequenceEqualityOnDictionaryEmitsEQ0015() + { + // [SequenceEquality] on Dictionary treats it as a sequence of KeyValuePair — wrong intent + const string source = @" +using System.Collections.Generic; +using Equatable.Attributes; + +namespace Equatable.Entities; + +[Equatable] +public partial class UserImport +{ + [SequenceEquality] + public Dictionary? Permissions { get; set; } +} +"; + var diagnostics = await AnalyzerTestHelper.GetAnalyzerDiagnosticsAsync(source); + + var diagnostic = Assert.Single(diagnostics); + Assert.Equal("EQ0015", diagnostic.Id); + Assert.Contains("Permissions", diagnostic.GetMessage()); + } + + [Fact] + public async Task AnalyzeHashSetEqualityOnDictionaryEmitsEQ0015() + { + // [HashSetEquality] on Dictionary treats it as a set of KeyValuePair — wrong intent + const string source = @" +using System.Collections.Generic; +using Equatable.Attributes; + +namespace Equatable.Entities; + +[Equatable] +public partial class UserImport +{ + [HashSetEquality] + public Dictionary? Permissions { get; set; } +} +"; + var diagnostics = await AnalyzerTestHelper.GetAnalyzerDiagnosticsAsync(source); + + var diagnostic = Assert.Single(diagnostics); + Assert.Equal("EQ0015", diagnostic.Id); + Assert.Contains("Permissions", diagnostic.GetMessage()); + } + + [Fact] + public async Task AnalyzeSequenceEqualityOnIDictionaryEmitsEQ0015() + { + // [SequenceEquality] on IDictionary → EQ0015 + const string source = @" +using System.Collections.Generic; +using Equatable.Attributes; + +namespace Equatable.Entities; + +[Equatable] +public partial class UserImport +{ + [SequenceEquality] + public IDictionary? Permissions { get; set; } +} +"; + var diagnostics = await AnalyzerTestHelper.GetAnalyzerDiagnosticsAsync(source); + + var diagnostic = Assert.Single(diagnostics); + Assert.Equal("EQ0015", diagnostic.Id); + Assert.Contains("Permissions", diagnostic.GetMessage()); + } + + [Fact] + public async Task AnalyzeHashSetEqualityOnIReadOnlyDictionaryEmitsEQ0015() + { + // [HashSetEquality] on IReadOnlyDictionary → EQ0015 + const string source = @" +using System.Collections.Generic; +using Equatable.Attributes; + +namespace Equatable.Entities; + +[Equatable] +public partial class UserImport +{ + [HashSetEquality] + public IReadOnlyDictionary? Scores { get; set; } +} +"; + var diagnostics = await AnalyzerTestHelper.GetAnalyzerDiagnosticsAsync(source); + + var diagnostic = Assert.Single(diagnostics); + Assert.Equal("EQ0015", diagnostic.Id); + Assert.Contains("Scores", diagnostic.GetMessage()); + } + + [Fact] + public async Task AnalyzeDictionaryEqualityOnDictionaryIsValid() + { + // [DictionaryEquality] on Dictionary → no diagnostic (correct attribute) + const string source = @" +using System.Collections.Generic; +using Equatable.Attributes; + +namespace Equatable.Entities; + +[Equatable] +public partial class UserImport +{ + [DictionaryEquality] + public Dictionary? Permissions { get; set; } +} +"; + var diagnostics = await AnalyzerTestHelper.GetAnalyzerDiagnosticsAsync(source); + + Assert.Empty(diagnostics); + } } From 1ba29ece5c0c5b5dfc08fc301e7c1b46895dbf17 Mon Sep 17 00:00:00 2001 From: "a.kasyanov" Date: Thu, 14 May 2026 12:17:33 +0300 Subject: [PATCH 45/71] docs: clarify row-major order in multi-dimensional array equality example Co-Authored-By: Claude Sonnet 4.5 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ca6f578..8a97964 100644 --- a/README.md +++ b/README.md @@ -288,7 +288,7 @@ Two arrays are equal when: ```csharp var a = new int[,] { { 1, 2 }, { 3, 4 } }; var b = new int[,] { { 1, 2 }, { 3, 4 } }; -// a == b ✓ (same rank, same dimensions, same elements) +// a == b ✓ (same rank, same dimensions, same elements in row-major order) var c = new int[,] { { 1, 3 }, { 2, 4 } }; // transposed // a != c ✓ (row-major order: [0,0]=1,[0,1]=2,[1,0]=3,[1,1]=4 vs [0,0]=1,[0,1]=3,...) From 7cb634b635cb109ae891668373aed27869287dd8 Mon Sep 17 00:00:00 2001 From: "a.kasyanov" Date: Thu, 14 May 2026 12:20:21 +0300 Subject: [PATCH 46/71] docs: emphasize row-major order in multi-dimensional array equality bullet list Co-Authored-By: Claude Sonnet 4.5 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8a97964..4a2077d 100644 --- a/README.md +++ b/README.md @@ -283,7 +283,7 @@ public double[,,] Cube { get; set; } Two arrays are equal when: 1. They have the **same rank** (`int[,]` ≠ `int[,,]`) 2. Every **dimension length** matches (`[2,3]` ≠ `[3,2]`) -3. Every **element** is equal in row-major order +3. Every **element** is equal **in row-major order** (position matters) ```csharp var a = new int[,] { { 1, 2 }, { 3, 4 } }; From 0467be9f3af09928372b949b6bc5e71d7692c3a6 Mon Sep 17 00:00:00 2001 From: "a.kasyanov" Date: Thu, 14 May 2026 13:14:59 +0300 Subject: [PATCH 47/71] docs: document EQ0020 and EQ0021 in build-time diagnostics section Co-Authored-By: Claude Sonnet 4.5 --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 4a2077d..cbfb8eb 100644 --- a/README.md +++ b/README.md @@ -343,9 +343,13 @@ The analyzer validates every `[Equatable]` class at compile time and emits warni |---|---|---| | `EQ0001` | `IDictionary` or `IReadOnlyDictionary` property with no attribute | `Dictionary? Map` | | `EQ0002` | `IEnumerable` property (including `T[]`) with no attribute | `List? Tags`, `int[]? Ids` | +| `EQ0020` | `[DataContractEquatable]` used without `[DataContract]` on the same class | — | +| `EQ0021` | `[MessagePackEquatable]` used without `[MessagePackObject]` on the same class | — | Multi-dimensional arrays (`T[,]`, `T[,,]`) are exempt from EQ0002 because `MultiDimensionalArrayEqualityComparer` is always the default — no annotation is needed or accepted. +EQ0020 and EQ0021 catch the case where the adapter attribute is added but the corresponding serialisation attribute is missing. Without `[DataContract]` the serialiser ignores all `[DataMember]` annotations, so the generated equality would silently include no properties. The same applies to `[MessagePackObject]` / `[Key(n)]`. + ### Invalid attribute warnings | Diagnostic | Condition | From 2a3ec83fc2178e1c56a62213fbb27fd31965fb1e Mon Sep 17 00:00:00 2001 From: "a.kasyanov" Date: Thu, 14 May 2026 13:16:56 +0300 Subject: [PATCH 48/71] docs: note that PrivateAssets="all" applies to adapter generator packages too Co-Authored-By: Claude Sonnet 4.5 --- README.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index cbfb8eb..b77d931 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,17 @@ This library generates correct `Equals` + `GetHashCode` at compile-time — zero ``` -`PrivateAssets="all"` on the generator is optional — it prevents the compile-time-only package from flowing to consumers of your library. `Equatable.Comparers` is the runtime package the generated code calls into and must be a real dependency. +When using the adapter packages, add the corresponding adapter generator the same way: + +```xml + + + + + +``` + +`PrivateAssets="all"` is optional on all generator packages — it prevents the compile-time-only package from flowing to consumers of your library. `Equatable.Comparers` is the runtime package the generated code calls into and must be a real dependency. Mark your class as `partial` and add `[Equatable]`: From 097b8beb3e3d5db0daff7cef740d8dfdb17a9727 Mon Sep 17 00:00:00 2001 From: "a.kasyanov" Date: Thu, 14 May 2026 13:26:36 +0300 Subject: [PATCH 49/71] =?UTF-8?q?feat:=20EQ0022/EQ0023=20=E2=80=94=20warn?= =?UTF-8?q?=20on=20unannotated=20properties=20on=20adapter-equatable=20typ?= =?UTF-8?q?es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adapter generators only include properties that carry the serialisation inclusion attribute ([DataMember] / [Key(n)]). All other public properties are silently skipped. Accidental omissions are easy to miss, so EQ0022/EQ0023 force the intent to be explicit: either add the inclusion attribute or suppress with [IgnoreDataMember] / [IgnoreMember] / [IgnoreEquality]. Also clarified in README: - EQ0001/EQ0002 apply to [Equatable] only; adapters auto-infer comparers - EQ0022/EQ0023 explanation with code example showing both paths to suppress Changes: - DataContractEquatableAnalyzer.cs: add EQ0022, check every public property - MessagePackEquatableAnalyzer.cs: add EQ0023, same pattern - DataContractAnalyzerTest.cs: 4 new tests for EQ0022 (fires, IgnoreDataMember, IgnoreEquality, multiple) - MessagePackAnalyzerTest.cs: 4 new tests for EQ0023 (fires, IgnoreMember, IgnoreEquality, multiple) - README.md: update diagnostics table and add explanations for all groups Co-Authored-By: Claude Sonnet 4.5 --- README.md | 36 ++++-- .../DataContractEquatableAnalyzer.cs | 58 ++++++++- .../MessagePackEquatableAnalyzer.cs | 50 +++++++- .../DataContractAnalyzerTest.cs | 118 ++++++++++++++++++ .../MessagePackAnalyzerTest.cs | 118 ++++++++++++++++++ 5 files changed, 363 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index b77d931..3f414e5 100644 --- a/README.md +++ b/README.md @@ -349,16 +349,38 @@ The analyzer validates every `[Equatable]` class at compile time and emits warni ### Missing attribute warnings -| Diagnostic | Condition | Example | -|---|---|---| -| `EQ0001` | `IDictionary` or `IReadOnlyDictionary` property with no attribute | `Dictionary? Map` | -| `EQ0002` | `IEnumerable` property (including `T[]`) with no attribute | `List? Tags`, `int[]? Ids` | -| `EQ0020` | `[DataContractEquatable]` used without `[DataContract]` on the same class | — | -| `EQ0021` | `[MessagePackEquatable]` used without `[MessagePackObject]` on the same class | — | +| Diagnostic | Applies to | Condition | Example | +|---|---|---|---| +| `EQ0001` | `[Equatable]` | `IDictionary` or `IReadOnlyDictionary` property has no attribute | `Dictionary? Map` | +| `EQ0002` | `[Equatable]` | `IEnumerable` property (including `T[]`) has no attribute | `List? Tags`, `int[]? Ids` | +| `EQ0020` | `[DataContractEquatable]` | Class has no `[DataContract]` | — | +| `EQ0021` | `[MessagePackEquatable]` | Class has no `[MessagePackObject]` | — | +| `EQ0022` | `[DataContractEquatable]` | Public property has no `[DataMember]`, `[IgnoreDataMember]`, or `[IgnoreEquality]` | — | +| `EQ0023` | `[MessagePackEquatable]` | Public property has no `[Key(n)]`, `[IgnoreMember]`, or `[IgnoreEquality]` | — | + +**EQ0001 / EQ0002** only apply to `[Equatable]` classes, where every public property is included by default and must be explicitly annotated for collection/dictionary types. Adapter generators (`[DataContractEquatable]`, `[MessagePackEquatable]`) auto-infer the correct comparer from the property type, so collection properties annotated with `[DataMember]` or `[Key(n)]` never need `[SequenceEquality]` or `[DictionaryEquality]`. Multi-dimensional arrays (`T[,]`, `T[,,]`) are exempt from EQ0002 because `MultiDimensionalArrayEqualityComparer` is always the default — no annotation is needed or accepted. -EQ0020 and EQ0021 catch the case where the adapter attribute is added but the corresponding serialisation attribute is missing. Without `[DataContract]` the serialiser ignores all `[DataMember]` annotations, so the generated equality would silently include no properties. The same applies to `[MessagePackObject]` / `[Key(n)]`. +**EQ0020 / EQ0021** catch the case where the adapter attribute is added but the corresponding serialisation attribute is missing. Without `[DataContract]` the serialiser ignores all `[DataMember]` annotations, so the generated equality would silently include no properties. The same applies to `[MessagePackObject]` / `[Key(n)]`. + +**EQ0022 / EQ0023** catch silently excluded properties on adapter-annotated types. The adapters only include properties that carry the serialisation inclusion attribute (`[DataMember]` / `[Key(n)]`) — all other public properties are silently skipped. This is intentional for computed properties or infrastructure fields you never want serialised, but an accidental omission is hard to notice. EQ0022/EQ0023 force the intent to be explicit: either add the inclusion attribute or add an explicit exclusion to suppress the warning: + +```csharp +[DataContract] +[DataContractEquatable] +public partial class EventContract +{ + [DataMember(Order = 0)] public int EventId { get; set; } // included ✓ + + // EQ0022 — silently excluded; was this intentional? + public DateTime LastSeen { get; set; } + + [IgnoreDataMember] public DateTime LastSeen { get; set; } // explicit exclusion ✓ + // or + [IgnoreEquality] public DateTime LastSeen { get; set; } // explicit exclusion ✓ +} +``` ### Invalid attribute warnings diff --git a/src/Equatable.SourceGenerator.DataContract/DataContractEquatableAnalyzer.cs b/src/Equatable.SourceGenerator.DataContract/DataContractEquatableAnalyzer.cs index 25d81ac..9a06433 100644 --- a/src/Equatable.SourceGenerator.DataContract/DataContractEquatableAnalyzer.cs +++ b/src/Equatable.SourceGenerator.DataContract/DataContractEquatableAnalyzer.cs @@ -17,8 +17,17 @@ public class DataContractEquatableAnalyzer : DiagnosticAnalyzer isEnabledByDefault: true ); + private static readonly DiagnosticDescriptor UnannotatedPropertyOnDataContractEquatable = new( + id: "EQ0022", + title: "Unannotated Property on DataContractEquatable Type", + messageFormat: "Property '{0}' on [DataContractEquatable] type '{1}' has no [DataMember] or [IgnoreDataMember] attribute. It will be silently excluded from equality. Add [DataMember] to include it or [IgnoreDataMember] to suppress this warning.", + category: "Usage", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); + public override ImmutableArray SupportedDiagnostics => - ImmutableArray.Create(MissingDataContractAttribute); + ImmutableArray.Create(MissingDataContractAttribute, UnannotatedPropertyOnDataContractEquatable); public override void Initialize(AnalysisContext context) { @@ -34,11 +43,29 @@ private static void AnalyzeNamedType(SymbolAnalysisContext context) if (!HasDataContractEquatableAttribute(typeSymbol)) return; - if (HasDataContractAttribute(typeSymbol)) - return; + if (!HasDataContractAttribute(typeSymbol)) + { + var location = typeSymbol.Locations.FirstOrDefault(); + context.ReportDiagnostic(Diagnostic.Create(MissingDataContractAttribute, location, typeSymbol.Name)); + } + + foreach (var property in typeSymbol.GetMembers().OfType()) + { + if (!EquatableGenerator.IsPublicInstanceProperty(property)) + continue; + + var attributes = property.GetAttributes(); + + if (HasDataMemberAttribute(attributes) || HasIgnoreDataMemberAttribute(attributes) || HasIgnoreEqualityAttribute(attributes)) + continue; - var location = typeSymbol.Locations.FirstOrDefault(); - context.ReportDiagnostic(Diagnostic.Create(MissingDataContractAttribute, location, typeSymbol.Name)); + var propertyLocation = property.Locations.FirstOrDefault(); + context.ReportDiagnostic(Diagnostic.Create( + UnannotatedPropertyOnDataContractEquatable, + propertyLocation, + property.Name, + typeSymbol.Name)); + } } private static bool HasDataContractEquatableAttribute(INamedTypeSymbol typeSymbol) => @@ -54,4 +81,25 @@ private static bool HasDataContractAttribute(INamedTypeSymbol typeSymbol) => Name: "DataContractAttribute", ContainingNamespace: { Name: "Serialization", ContainingNamespace: { Name: "Runtime", ContainingNamespace.Name: "System" } } }); + + private static bool HasDataMemberAttribute(ImmutableArray attributes) => + attributes.Any(a => a.AttributeClass is + { + Name: "DataMemberAttribute", + ContainingNamespace: { Name: "Serialization", ContainingNamespace: { Name: "Runtime", ContainingNamespace.Name: "System" } } + }); + + private static bool HasIgnoreDataMemberAttribute(ImmutableArray attributes) => + attributes.Any(a => a.AttributeClass is + { + Name: "IgnoreDataMemberAttribute", + ContainingNamespace: { Name: "Serialization", ContainingNamespace: { Name: "Runtime", ContainingNamespace.Name: "System" } } + }); + + private static bool HasIgnoreEqualityAttribute(ImmutableArray attributes) => + attributes.Any(a => a.AttributeClass is + { + Name: "IgnoreEqualityAttribute", + ContainingNamespace: { Name: "Attributes", ContainingNamespace.Name: "Equatable" } + }); } diff --git a/src/Equatable.SourceGenerator.MessagePack/MessagePackEquatableAnalyzer.cs b/src/Equatable.SourceGenerator.MessagePack/MessagePackEquatableAnalyzer.cs index f430cd5..0a7bdae 100644 --- a/src/Equatable.SourceGenerator.MessagePack/MessagePackEquatableAnalyzer.cs +++ b/src/Equatable.SourceGenerator.MessagePack/MessagePackEquatableAnalyzer.cs @@ -17,8 +17,17 @@ public class MessagePackEquatableAnalyzer : DiagnosticAnalyzer isEnabledByDefault: true ); + private static readonly DiagnosticDescriptor UnannotatedPropertyOnMessagePackEquatable = new( + id: "EQ0023", + title: "Unannotated Property on MessagePackEquatable Type", + messageFormat: "Property '{0}' on [MessagePackEquatable] type '{1}' has no [Key] or [IgnoreMember] attribute. It will be silently excluded from equality. Add [Key(n)] to include it or [IgnoreMember] to suppress this warning.", + category: "Usage", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); + public override ImmutableArray SupportedDiagnostics => - ImmutableArray.Create(MissingMessagePackObjectAttribute); + ImmutableArray.Create(MissingMessagePackObjectAttribute, UnannotatedPropertyOnMessagePackEquatable); public override void Initialize(AnalysisContext context) { @@ -34,11 +43,29 @@ private static void AnalyzeNamedType(SymbolAnalysisContext context) if (!HasMessagePackEquatableAttribute(typeSymbol)) return; - if (HasMessagePackObjectAttribute(typeSymbol)) - return; + if (!HasMessagePackObjectAttribute(typeSymbol)) + { + var location = typeSymbol.Locations.FirstOrDefault(); + context.ReportDiagnostic(Diagnostic.Create(MissingMessagePackObjectAttribute, location, typeSymbol.Name)); + } + + foreach (var property in typeSymbol.GetMembers().OfType()) + { + if (!EquatableGenerator.IsPublicInstanceProperty(property)) + continue; + + var attributes = property.GetAttributes(); - var location = typeSymbol.Locations.FirstOrDefault(); - context.ReportDiagnostic(Diagnostic.Create(MissingMessagePackObjectAttribute, location, typeSymbol.Name)); + if (HasKeyAttribute(attributes) || HasIgnoreMemberAttribute(attributes) || HasIgnoreEqualityAttribute(attributes)) + continue; + + var propertyLocation = property.Locations.FirstOrDefault(); + context.ReportDiagnostic(Diagnostic.Create( + UnannotatedPropertyOnMessagePackEquatable, + propertyLocation, + property.Name, + typeSymbol.Name)); + } } private static bool HasMessagePackEquatableAttribute(INamedTypeSymbol typeSymbol) => @@ -54,4 +81,17 @@ private static bool HasMessagePackObjectAttribute(INamedTypeSymbol typeSymbol) = Name: "MessagePackObjectAttribute", ContainingNamespace.Name: "MessagePack" }); + + private static bool HasKeyAttribute(ImmutableArray attributes) => + attributes.Any(a => a.AttributeClass is { Name: "KeyAttribute", ContainingNamespace.Name: "MessagePack" }); + + private static bool HasIgnoreMemberAttribute(ImmutableArray attributes) => + attributes.Any(a => a.AttributeClass is { Name: "IgnoreMemberAttribute", ContainingNamespace.Name: "MessagePack" }); + + private static bool HasIgnoreEqualityAttribute(ImmutableArray attributes) => + attributes.Any(a => a.AttributeClass is + { + Name: "IgnoreEqualityAttribute", + ContainingNamespace: { Name: "Attributes", ContainingNamespace.Name: "Equatable" } + }); } diff --git a/test/Equatable.Generator.Tests/DataContractAnalyzerTest.cs b/test/Equatable.Generator.Tests/DataContractAnalyzerTest.cs index dff6842..0546a9a 100644 --- a/test/Equatable.Generator.Tests/DataContractAnalyzerTest.cs +++ b/test/Equatable.Generator.Tests/DataContractAnalyzerTest.cs @@ -105,4 +105,122 @@ public abstract class BaseOrder Assert.Empty(diagnostics); } + + // ── EQ0022 — unannotated property on DataContractEquatable type ─────────────────────────────── + + [Fact] + public async Task AnalyzeUnannotatedPropertyEmitsEQ0022() + { + // LastSeen has no [DataMember] or [IgnoreDataMember] — silently excluded, EQ0022 fires + const string source = @" +using System; +using System.Runtime.Serialization; +using Equatable.Attributes.DataContract; + +namespace Equatable.Entities; + +[DataContract] +[DataContractEquatable] +public partial class OrderDataContract +{ + [DataMember(Order = 0)] + public int Id { get; set; } + + public DateTime LastSeen { get; set; } +} +"; + var diagnostics = await AnalyzerTestHelper.GetAnalyzerDiagnosticsAsync(source, + new DataContractEquatableAnalyzer()); + + var diagnostic = Assert.Single(diagnostics); + Assert.Equal("EQ0022", diagnostic.Id); + Assert.Contains("LastSeen", diagnostic.GetMessage()); + Assert.Contains("OrderDataContract", diagnostic.GetMessage()); + } + + [Fact] + public async Task AnalyzeIgnoreDataMemberSuppressesEQ0022() + { + // [IgnoreDataMember] is explicit exclusion — no EQ0022 + const string source = @" +using System; +using System.Runtime.Serialization; +using Equatable.Attributes.DataContract; + +namespace Equatable.Entities; + +[DataContract] +[DataContractEquatable] +public partial class OrderDataContract +{ + [DataMember(Order = 0)] + public int Id { get; set; } + + [IgnoreDataMember] + public DateTime LastSeen { get; set; } +} +"; + var diagnostics = await AnalyzerTestHelper.GetAnalyzerDiagnosticsAsync(source, + new DataContractEquatableAnalyzer()); + + Assert.Empty(diagnostics); + } + + [Fact] + public async Task AnalyzeIgnoreEqualitySuppressesEQ0022() + { + // [IgnoreEquality] is also an explicit exclusion — no EQ0022 + const string source = @" +using System; +using System.Runtime.Serialization; +using Equatable.Attributes; +using Equatable.Attributes.DataContract; + +namespace Equatable.Entities; + +[DataContract] +[DataContractEquatable] +public partial class OrderDataContract +{ + [DataMember(Order = 0)] + public int Id { get; set; } + + [IgnoreEquality] + public DateTime LastSeen { get; set; } +} +"; + var diagnostics = await AnalyzerTestHelper.GetAnalyzerDiagnosticsAsync(source, + new DataContractEquatableAnalyzer()); + + Assert.Empty(diagnostics); + } + + [Fact] + public async Task AnalyzeMultipleUnannotatedPropertiesEmitMultipleEQ0022() + { + const string source = @" +using System; +using System.Runtime.Serialization; +using Equatable.Attributes.DataContract; + +namespace Equatable.Entities; + +[DataContract] +[DataContractEquatable] +public partial class OrderDataContract +{ + [DataMember(Order = 0)] + public int Id { get; set; } + + public DateTime CreatedAt { get; set; } + public string? Notes { get; set; } +} +"; + var diagnostics = await AnalyzerTestHelper.GetAnalyzerDiagnosticsAsync(source, + new DataContractEquatableAnalyzer()); + + Assert.Equal(2, diagnostics.Length); + Assert.Contains(diagnostics, d => d.Id == "EQ0022" && d.GetMessage().Contains("CreatedAt")); + Assert.Contains(diagnostics, d => d.Id == "EQ0022" && d.GetMessage().Contains("Notes")); + } } diff --git a/test/Equatable.Generator.Tests/MessagePackAnalyzerTest.cs b/test/Equatable.Generator.Tests/MessagePackAnalyzerTest.cs index 9d84eee..74cb263 100644 --- a/test/Equatable.Generator.Tests/MessagePackAnalyzerTest.cs +++ b/test/Equatable.Generator.Tests/MessagePackAnalyzerTest.cs @@ -105,4 +105,122 @@ public abstract class BaseContract Assert.Empty(diagnostics); } + + // ── EQ0023 — unannotated property on MessagePackEquatable type ──────────────────────────────── + + [Fact] + public async Task AnalyzeUnannotatedPropertyEmitsEQ0023() + { + // ReceivedAt has no [Key] or [IgnoreMember] — silently excluded, EQ0023 fires + const string source = @" +using System; +using MessagePack; +using Equatable.Attributes.MessagePack; + +namespace Equatable.Entities; + +[MessagePackObject] +[MessagePackEquatable] +public partial class PricingContract +{ + [Key(0)] + public int MarketId { get; set; } + + public DateTime ReceivedAt { get; set; } +} +"; + var diagnostics = await AnalyzerTestHelper.GetAnalyzerDiagnosticsAsync(source, + new MessagePackEquatableAnalyzer()); + + var diagnostic = Assert.Single(diagnostics); + Assert.Equal("EQ0023", diagnostic.Id); + Assert.Contains("ReceivedAt", diagnostic.GetMessage()); + Assert.Contains("PricingContract", diagnostic.GetMessage()); + } + + [Fact] + public async Task AnalyzeIgnoreMemberSuppressesEQ0023() + { + // [IgnoreMember] is explicit exclusion — no EQ0023 + const string source = @" +using System; +using MessagePack; +using Equatable.Attributes.MessagePack; + +namespace Equatable.Entities; + +[MessagePackObject] +[MessagePackEquatable] +public partial class PricingContract +{ + [Key(0)] + public int MarketId { get; set; } + + [IgnoreMember] + public DateTime ReceivedAt { get; set; } +} +"; + var diagnostics = await AnalyzerTestHelper.GetAnalyzerDiagnosticsAsync(source, + new MessagePackEquatableAnalyzer()); + + Assert.Empty(diagnostics); + } + + [Fact] + public async Task AnalyzeIgnoreEqualitySuppressesEQ0023() + { + // [IgnoreEquality] is also an explicit exclusion — no EQ0023 + const string source = @" +using System; +using MessagePack; +using Equatable.Attributes; +using Equatable.Attributes.MessagePack; + +namespace Equatable.Entities; + +[MessagePackObject] +[MessagePackEquatable] +public partial class PricingContract +{ + [Key(0)] + public int MarketId { get; set; } + + [IgnoreEquality] + public DateTime ReceivedAt { get; set; } +} +"; + var diagnostics = await AnalyzerTestHelper.GetAnalyzerDiagnosticsAsync(source, + new MessagePackEquatableAnalyzer()); + + Assert.Empty(diagnostics); + } + + [Fact] + public async Task AnalyzeMultipleUnannotatedPropertiesEmitMultipleEQ0023() + { + const string source = @" +using System; +using MessagePack; +using Equatable.Attributes.MessagePack; + +namespace Equatable.Entities; + +[MessagePackObject] +[MessagePackEquatable] +public partial class PricingContract +{ + [Key(0)] + public int MarketId { get; set; } + + public DateTime ReceivedAt { get; set; } + public string? Notes { get; set; } +} +"; + var diagnostics = await AnalyzerTestHelper.GetAnalyzerDiagnosticsAsync(source, + new MessagePackEquatableAnalyzer()); + + Assert.Equal(2, diagnostics.Length); + Assert.Contains(diagnostics, d => d.Id == "EQ0023" && d.GetMessage().Contains("ReceivedAt")); + Assert.Contains(diagnostics, d => d.Id == "EQ0023" && d.GetMessage().Contains("Notes")); + } } From 293663fc4f137ac965046188797a82357aa626e3 Mon Sep 17 00:00:00 2001 From: "a.kasyanov" Date: Thu, 14 May 2026 13:29:05 +0300 Subject: [PATCH 50/71] =?UTF-8?q?docs:=20expand=20adapter=20generators=20s?= =?UTF-8?q?ection=20=E2=80=94=20property=20selection,=20comparer=20inferen?= =?UTF-8?q?ce,=20overrides?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Document which attributes include/exclude properties per adapter (table) - Explain EQ0022/EQ0023 in context of silent exclusion - Show that collection comparers are inferred — no [SequenceEquality] etc. needed - Show explicit attribute overrides still work on adapter-included properties - Fix package table: [DataContractEquatable] uses System.Runtime.Serialization, not WCF/protobuf-net specifically Co-Authored-By: Claude Sonnet 4.5 --- README.md | 57 +++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 49 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 3f414e5..345e67b 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ This library generates correct `Equals` + `GetHashCode` at compile-time — zero | Package | What it does | |---|---| | `Equatable.Generator` | Generates equality for `[Equatable]` classes/records/structs. Includes all collection attributes. | -| `Equatable.Generator.DataContract` | Adapter — reads `[DataMember]` attributes (WCF / protobuf-net contracts) | +| `Equatable.Generator.DataContract` | Adapter — reads `[DataMember]` / `[DataContract]` attributes (`System.Runtime.Serialization`) | | `Equatable.Generator.MessagePack` | Adapter — reads `[Key(n)]` attributes (MessagePack serialisation) | | `Equatable.Comparers` | Ships the runtime comparers used by the generated code | @@ -78,19 +78,33 @@ The generator writes `Equals` and `GetHashCode` for every public property. Works ## Adapter generators -Use `[DataContractEquatable]` or `[MessagePackEquatable]` when your class is already annotated for serialisation. They work exactly like `[Equatable]` but only include the properties the serialiser knows about: +Use `[DataContractEquatable]` or `[MessagePackEquatable]` when your class is already annotated for serialisation. The adapter reads the existing serialisation attributes to decide which properties to include — no duplication of intent required. + +### Property selection + +| Adapter | Included | Explicitly excluded | Silently skipped → EQ0022/EQ0023 | +|---|---|---|---| +| `[DataContractEquatable]` | `[DataMember]` | `[IgnoreDataMember]` or `[IgnoreEquality]` | all other public properties | +| `[MessagePackEquatable]` | `[Key(n)]` | `[IgnoreMember]` or `[IgnoreEquality]` | all other public properties | + +Properties with no annotation at all are silently excluded from equality. If that omission was accidental the build will warn (EQ0022 / EQ0023) — add the inclusion or exclusion attribute to make the intent explicit. + +### Comparer inference — no equality attributes needed on collection properties + +Adapters auto-infer the correct collection comparer from the property type, so `[DataMember]` / `[Key(n)]` properties never need an explicit `[SequenceEquality]`, `[DictionaryEquality]`, or `[HashSetEquality]`. The same defaults apply as for `[Equatable]`: `List` / `T[]` → `SequenceEquality`; `HashSet` → `HashSetEquality`; `Dictionary` → `DictionaryEquality`. ```csharp -// Only [DataMember] properties are included in equality. -// [IgnoreDataMember] and un-annotated properties are skipped. [DataContract] [DataContractEquatable] public partial class EventContract { [DataMember(Order = 0)] public int EventId { get; set; } - // string[] defaults to [SequenceEquality] — no attribute needed. - [DataMember(Order = 1)] public string[]? Tags { get; set; } + // List → SequenceEqualityComparer inferred — no attribute needed + [DataMember(Order = 1)] public List? Tags { get; set; } + + // Dictionary → DictionaryEqualityComparer inferred — no attribute needed + [DataMember(Order = 2)] public Dictionary? Scores { get; set; } [IgnoreDataMember] public DateTime LastSeen { get; set; } // excluded from equality @@ -98,8 +112,6 @@ public partial class EventContract ``` ```csharp -// Only [Key(n)] properties are included. -// [IgnoreMember] properties are skipped. [MessagePackObject] [MessagePackEquatable] public partial class LiveScore @@ -107,11 +119,40 @@ public partial class LiveScore [Key(0)] public int MatchId { get; set; } [Key(1)] public int HomeScore { get; set; } + // HashSet → HashSetEqualityComparer inferred — no attribute needed + [Key(2)] public HashSet? Tags { get; set; } + [IgnoreMember] public DateTime ReceivedAt { get; set; } // excluded } ``` +### Overriding the inferred comparer + +Explicit equality attributes take priority over inference. All the same attributes from the `[Equatable]` table work on adapter-included properties: + +```csharp +[DataContract] +[DataContractEquatable] +public partial class EventContract +{ + // Override: treat list as a set (order irrelevant) + [DataMember(Order = 0)] + [HashSetEquality] + public List? PermissionCodes { get; set; } + + // Override: case-insensitive string comparison + [DataMember(Order = 1)] + [StringEquality(StringComparison.OrdinalIgnoreCase)] + public string? Region { get; set; } + + // Override: fully custom comparer + [DataMember(Order = 2)] + [EqualityComparer(typeof(CountOnlyComparer))] + public Dictionary? Weights { get; set; } +} +``` + ## Collection attributes in detail ### `[SequenceEquality]` — order matters From b1adb808c9039475f7ac916d6ccfc304043c6579 Mon Sep 17 00:00:00 2001 From: "a.kasyanov" Date: Thu, 14 May 2026 13:30:43 +0300 Subject: [PATCH 51/71] =?UTF-8?q?docs:=20split=20attributes=20table=20by?= =?UTF-8?q?=20package=20=E2=80=94=20Equatable.Generator,=20DataContract,?= =?UTF-8?q?=20MessagePack?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.5 --- README.md | 49 +++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 45 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 345e67b..8c83a22 100644 --- a/README.md +++ b/README.md @@ -64,17 +64,58 @@ The generator writes `Equals` and `GetHashCode` for every public property. Works ## All attributes at a glance +### `Equatable.Generator` — class-level trigger + +| Attribute | What it does | +|---|---| +| `[Equatable]` | Triggers generation; includes all public properties | + +### `Equatable.Generator` — property-level equality control + +These attributes live in `Equatable.Attributes` and control how each property is compared. + | Attribute | What it generates | Default for | |---|---|---| -| `[Equatable]` | Triggers generation; includes all public properties | — | | `[IgnoreEquality]` | Skip this property | — | | `[StringEquality(StringComparison.X)]` | `StringComparer.X.Equals(a, b)` | — | | `[EqualityComparer(typeof(T))]` | `T.Default.Equals(a, b)` — any custom comparer | — | +| `[ReferenceEquality]` | `Object.ReferenceEquals(a, b)` | — | | `[SequenceEquality]` | `SequenceEqualityComparer` — element order matters | `List`, `T[]`, `T[,]`, `T[,,]` | | `[HashSetEquality]` | `HashSetEqualityComparer` — element order ignored | `HashSet` | -| `[DictionaryEquality]` | `ReadOnlyDictionaryEqualityComparer` — key-value equality | `Dictionary` | -| `[DictionaryEquality(sequential:true)]` | `OrderedReadOnlyDictionaryEqualityComparer` — key-sorted | — | -| `[ReferenceEquality]` | `Object.ReferenceEquals(a, b)` | — | +| `[DictionaryEquality]` | `DictionaryEqualityComparer` — key-value equality, insertion order irrelevant | `Dictionary` | +| `[DictionaryEquality(sequential:true)]` | `OrderedDictionaryEqualityComparer` — key-sorted comparison | — | + +### `Equatable.Generator.DataContract` — class-level trigger + +| Attribute | Package | What it does | +|---|---|---| +| `[DataContractEquatable]` | `Equatable.Generator.DataContract` | Triggers generation; reads `[DataMember]` to select properties | + +Property selection uses `System.Runtime.Serialization` attributes — these are not part of `Equatable.Generator.DataContract` itself, they come from the BCL or the serialisation library you already use: + +| Attribute | Source | Effect | +|---|---|---| +| `[DataMember]` | `System.Runtime.Serialization` | Include this property in equality | +| `[IgnoreDataMember]` | `System.Runtime.Serialization` | Explicitly exclude this property | +| `[IgnoreEquality]` | `Equatable.Attributes` | Explicitly exclude this property | + +All property-level equality attributes from `Equatable.Generator` (`[SequenceEquality]`, `[DictionaryEquality]`, `[StringEquality]`, `[EqualityComparer]`, `[ReferenceEquality]`) work as overrides on `[DataMember]` properties. Collection comparers are inferred automatically when no override is present. + +### `Equatable.Generator.MessagePack` — class-level trigger + +| Attribute | Package | What it does | +|---|---|---| +| `[MessagePackEquatable]` | `Equatable.Generator.MessagePack` | Triggers generation; reads `[Key(n)]` to select properties | + +Property selection uses MessagePack attributes — these come from the `MessagePack` package you already use: + +| Attribute | Source | Effect | +|---|---|---| +| `[Key(n)]` | `MessagePack` | Include this property in equality | +| `[IgnoreMember]` | `MessagePack` | Explicitly exclude this property | +| `[IgnoreEquality]` | `Equatable.Attributes` | Explicitly exclude this property | + +All property-level equality attributes from `Equatable.Generator` work as overrides on `[Key(n)]` properties. Collection comparers are inferred automatically when no override is present. ## Adapter generators From 944de054585e18582064bea197b37621214d0e36 Mon Sep 17 00:00:00 2001 From: "a.kasyanov" Date: Thu, 14 May 2026 13:32:10 +0300 Subject: [PATCH 52/71] docs: mention skip/ignore attributes in packages table summary Co-Authored-By: Claude Sonnet 4.5 --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8c83a22..506098f 100644 --- a/README.md +++ b/README.md @@ -25,8 +25,8 @@ This library generates correct `Equals` + `GetHashCode` at compile-time — zero | Package | What it does | |---|---| | `Equatable.Generator` | Generates equality for `[Equatable]` classes/records/structs. Includes all collection attributes. | -| `Equatable.Generator.DataContract` | Adapter — reads `[DataMember]` / `[DataContract]` attributes (`System.Runtime.Serialization`) | -| `Equatable.Generator.MessagePack` | Adapter — reads `[Key(n)]` attributes (MessagePack serialisation) | +| `Equatable.Generator.DataContract` | Adapter — reads `[DataMember]` attributes (`System.Runtime.Serialization`), skips `[IgnoreDataMember]` | +| `Equatable.Generator.MessagePack` | Adapter — reads `[Key(n)]` attributes (MessagePack serialisation), skips `[IgnoreMember]` | | `Equatable.Comparers` | Ships the runtime comparers used by the generated code | ## Getting started From 33b6ab843e7d80ef5c542f6b30a7526984399e48 Mon Sep 17 00:00:00 2001 From: "a.kasyanov" Date: Thu, 14 May 2026 13:33:48 +0300 Subject: [PATCH 53/71] =?UTF-8?q?docs:=20clarify=20adapter=20packages=20ta?= =?UTF-8?q?ble=20=E2=80=94=20include/exclude/silently-skip=20distinction?= =?UTF-8?q?=20with=20EQ022x=20reference?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.5 --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 506098f..9fa352d 100644 --- a/README.md +++ b/README.md @@ -25,8 +25,8 @@ This library generates correct `Equals` + `GetHashCode` at compile-time — zero | Package | What it does | |---|---| | `Equatable.Generator` | Generates equality for `[Equatable]` classes/records/structs. Includes all collection attributes. | -| `Equatable.Generator.DataContract` | Adapter — reads `[DataMember]` attributes (`System.Runtime.Serialization`), skips `[IgnoreDataMember]` | -| `Equatable.Generator.MessagePack` | Adapter — reads `[Key(n)]` attributes (MessagePack serialisation), skips `[IgnoreMember]` | +| `Equatable.Generator.DataContract` | Adapter — includes `[DataMember]`, explicitly excludes `[IgnoreDataMember]`, silently skips unannotated properties (EQ0022 warns) | +| `Equatable.Generator.MessagePack` | Adapter — includes `[Key(n)]`, explicitly excludes `[IgnoreMember]`, silently skips unannotated properties (EQ0023 warns) | | `Equatable.Comparers` | Ships the runtime comparers used by the generated code | ## Getting started From 593c040a74330a8d658d6f068bf4873602e3cf24 Mon Sep 17 00:00:00 2001 From: "a.kasyanov" Date: Thu, 14 May 2026 13:42:32 +0300 Subject: [PATCH 54/71] docs: add What's new section covering all new packages, features, fixes, and improvements Co-Authored-By: Claude Sonnet 4.5 --- README.md | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/README.md b/README.md index 9fa352d..95cd607 100644 --- a/README.md +++ b/README.md @@ -528,6 +528,44 @@ The hash contract is critical for using objects as dictionary keys or in hash se --- +## What's new + +### New packages + +- **`Equatable.Generator.DataContract`** — adapter generator that reads `[DataMember]` attributes (`System.Runtime.Serialization`). Only properties annotated with `[DataMember]` are included in equality; `[IgnoreDataMember]` and unannotated properties are excluded. A build warning (EQ0022) fires for any public property with no annotation at all, forcing the intent to be explicit. +- **`Equatable.Generator.MessagePack`** — adapter generator that reads `[Key(n)]` attributes. Only `[Key]` properties are included; `[IgnoreMember]` properties and unannotated properties are excluded. EQ0023 warns on unannotated properties. + +### New features + +- **`[DictionaryEquality(sequential: true)]`** — key-sorted dictionary comparison. Both sides are sorted by key before comparing, making equality deterministic regardless of insertion order. Useful for snapshots and logs. Propagates into nested dictionary values. +- **Direction overrides** — apply `[HashSetEquality]` to `List` or `T[]` to make them order-insensitive; apply `[SequenceEquality]` to `HashSet` to force order-sensitive comparison. +- **Nested collection comparer propagation** — annotate the outer property once; the chosen comparer kind propagates automatically into all nested levels. `Dictionary>`, `Dictionary>`, three-level nesting — all handled with a single annotation. +- **`MultiDimensionalArrayEqualityComparer`** — structural equality for `T[,]`, `T[,,]`, and higher-rank arrays. Applied automatically as the default — no attribute needed. Checks rank, dimension lengths, and elements in row-major order. +- **`IReadOnlyDictionary` support** — dictionary comparers now accept any `IReadOnlyDictionary`, not just `Dictionary`. +- **Base class delegation** — generated `Equals` calls `base.Equals()` when the base class is also an equatable-generated type. Works across adapter boundaries (e.g. `[Equatable]` derived from `[DataContractEquatable]`). +- **Analyzer diagnostics** + - `EQ0020` — `[DataContractEquatable]` without `[DataContract]` + - `EQ0021` — `[MessagePackEquatable]` without `[MessagePackObject]` + - `EQ0022` — unannotated public property on a `[DataContractEquatable]` type + - `EQ0023` — unannotated public property on a `[MessagePackEquatable]` type + - `EQ0014` — any collection or equality attribute on a multi-dimensional array (`rank ≥ 2`), where the comparer cannot be overridden + - `EQ0015` — `[SequenceEquality]` or `[HashSetEquality]` on a dictionary type + +### Bug fixes + +- **Empty collections hash differently from null** — a sentinel value is used for empty sequences and dictionaries, satisfying the hash contract (`GetHashCode(empty) != GetHashCode(null)`). +- **`HashSetEqualityComparer.GetHashCode` is order-independent** — uses a commutative sum, consistent with `SetEquals`-based `Equals`. +- **`[DictionaryEquality(sequential: true)]` propagates correctly** — key-sorted mode is applied to nested dictionary values, not just the outermost level. +- **Value types without `==`** — `EqualityComparer.Default` is used instead of direct comparison. + +### Improvements + +- `Equatable.Generator.DataContract` and `Equatable.Generator.MessagePack` are separate NuGet packages — add only what you need. +- `IsPublicInstanceProperty` extracted as a shared helper, eliminating duplication between adapter generators. +- Allocation-free hash code computation for dictionary comparers. + +--- + ## Requirements - Target framework .NET Standard 2.0 or greater From c03cf42707ca9048d356466e1732592f2fc862cb Mon Sep 17 00:00:00 2001 From: "a.kasyanov" Date: Thu, 14 May 2026 16:15:11 +0300 Subject: [PATCH 55/71] =?UTF-8?q?docs:=20expand=20What=20it=20does=20?= =?UTF-8?q?=E2=80=94=20explain=20manual=20IEquatable=20pain=20points=20and?= =?UTF-8?q?=20declarative=20approach;=20add=20struct=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.5 --- README.md | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 95cd607..2b8ca44 100644 --- a/README.md +++ b/README.md @@ -18,13 +18,21 @@ var b = new Product { Id = 1, Name = "Widget" }; Console.WriteLine(a == b); // false — different objects, even though values are identical ``` -This library generates correct `Equals` + `GetHashCode` at compile-time — zero runtime overhead, zero boilerplate. +The correct fix is to implement `IEquatable` manually — but that creates a different set of problems. A hand-written `Equals` method must list every property explicitly, and in a large codebase it is easy to: + +- **forget a property** — equality silently ignores a field that should matter +- **include a property by mistake** — a computed or infrastructure field ends up in the comparison +- **miss the update** — a new property is added to the class but not to the `Equals` / `GetHashCode` methods + +These bugs are hard to spot in code review because the missing or extra property is somewhere in a long method body, not at the declaration site. + +This library solves the problem with a declarative, annotation-driven approach. Mark the class and each property **at the declaration** — the generator writes `Equals` and `GetHashCode` at compile time, and the analyzer warns immediately when an annotation is missing or misused. The intent is visible right next to the property; there is no separate method to keep in sync. ## Packages | Package | What it does | |---|---| -| `Equatable.Generator` | Generates equality for `[Equatable]` classes/records/structs. Includes all collection attributes. | +| `Equatable.Generator` | Generates equality for `[Equatable]` classes, records, structs, and readonly structs. Includes all collection attributes. | | `Equatable.Generator.DataContract` | Adapter — includes `[DataMember]`, explicitly excludes `[IgnoreDataMember]`, silently skips unannotated properties (EQ0022 warns) | | `Equatable.Generator.MessagePack` | Adapter — includes `[Key(n)]`, explicitly excludes `[IgnoreMember]`, silently skips unannotated properties (EQ0023 warns) | | `Equatable.Comparers` | Ships the runtime comparers used by the generated code | @@ -60,7 +68,7 @@ public partial class Product } ``` -The generator writes `Equals` and `GetHashCode` for every public property. Works on `class`, `record`, and `readonly struct`. +The generator writes `Equals` and `GetHashCode` for every public property. Works on `class`, `record`, `struct`, and `readonly struct`. ## All attributes at a glance From 89efa8c955303f6af2195fd413a88553fbd9d990 Mon Sep 17 00:00:00 2001 From: "a.kasyanov" Date: Thu, 14 May 2026 16:15:33 +0300 Subject: [PATCH 56/71] docs: show Order param in DataMember in packages table for consistency with Key(n) Co-Authored-By: Claude Sonnet 4.5 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2b8ca44..bd1ddd3 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ This library solves the problem with a declarative, annotation-driven approach. | Package | What it does | |---|---| | `Equatable.Generator` | Generates equality for `[Equatable]` classes, records, structs, and readonly structs. Includes all collection attributes. | -| `Equatable.Generator.DataContract` | Adapter — includes `[DataMember]`, explicitly excludes `[IgnoreDataMember]`, silently skips unannotated properties (EQ0022 warns) | +| `Equatable.Generator.DataContract` | Adapter — includes `[DataMember(Order = n)]`, explicitly excludes `[IgnoreDataMember]`, silently skips unannotated properties (EQ0022 warns) | | `Equatable.Generator.MessagePack` | Adapter — includes `[Key(n)]`, explicitly excludes `[IgnoreMember]`, silently skips unannotated properties (EQ0023 warns) | | `Equatable.Comparers` | Ships the runtime comparers used by the generated code | From 840c052306fbbcedf7604a3bc78758222d243164 Mon Sep 17 00:00:00 2001 From: "a.kasyanov" Date: Thu, 14 May 2026 16:17:27 +0300 Subject: [PATCH 57/71] docs: add hash contract drift as a manual IEquatable pitfall Co-Authored-By: Claude Sonnet 4.5 --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index bd1ddd3..e99439c 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ The correct fix is to implement `IEquatable` manually — but that creates a - **forget a property** — equality silently ignores a field that should matter - **include a property by mistake** — a computed or infrastructure field ends up in the comparison - **miss the update** — a new property is added to the class but not to the `Equals` / `GetHashCode` methods +- **break the hash contract** — `Equals` and `GetHashCode` are maintained separately and can drift out of sync; objects that compare equal must produce the same hash code, but nothing enforces this in hand-written code — the result is silent corruption when the object is used as a dictionary key or in a hash set These bugs are hard to spot in code review because the missing or extra property is somewhere in a long method body, not at the declaration site. From 470a0166c5faa753da72ac56a4a00118bd3eeb85 Mon Sep 17 00:00:00 2001 From: "a.kasyanov" Date: Thu, 14 May 2026 16:17:41 +0300 Subject: [PATCH 58/71] =?UTF-8?q?docs:=20ground=20pitfall=20list=20in=20pr?= =?UTF-8?q?actical=20observation=20=E2=80=94=20not=20theoretical?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.5 --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e99439c..125f435 100644 --- a/README.md +++ b/README.md @@ -23,9 +23,9 @@ The correct fix is to implement `IEquatable` manually — but that creates a - **forget a property** — equality silently ignores a field that should matter - **include a property by mistake** — a computed or infrastructure field ends up in the comparison - **miss the update** — a new property is added to the class but not to the `Equals` / `GetHashCode` methods -- **break the hash contract** — `Equals` and `GetHashCode` are maintained separately and can drift out of sync; objects that compare equal must produce the same hash code, but nothing enforces this in hand-written code — the result is silent corruption when the object is used as a dictionary key or in a hash set +- **break the hash contract** — `Equals` and `GetHashCode` are maintained separately and drift out of sync in practice; objects that compare equal must produce the same hash code, but nothing enforces this in hand-written code — the result is silent corruption when the object is used as a dictionary key or in a hash set -These bugs are hard to spot in code review because the missing or extra property is somewhere in a long method body, not at the declaration site. +These are not theoretical concerns. They appear regularly in real codebases, and they are hard to spot in code review because the missing or extra property is buried in a long method body, not visible at the declaration site. This library solves the problem with a declarative, annotation-driven approach. Mark the class and each property **at the declaration** — the generator writes `Equals` and `GetHashCode` at compile time, and the analyzer warns immediately when an annotation is missing or misused. The intent is visible right next to the property; there is no separate method to keep in sync. From 40c7dd9dc0b4d2ee303ffd6484715379ffcc86a2 Mon Sep 17 00:00:00 2001 From: "a.kasyanov" Date: Thu, 14 May 2026 16:23:44 +0300 Subject: [PATCH 59/71] =?UTF-8?q?docs:=20explain=20record=20equality=20lim?= =?UTF-8?q?itations=20=E2=80=94=20reference=20types=20and=20collections=20?= =?UTF-8?q?still=20use=20reference=20equality?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.5 --- README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/README.md b/README.md index 125f435..a47afd3 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,18 @@ var b = new Product { Id = 1, Name = "Widget" }; Console.WriteLine(a == b); // false — different objects, even though values are identical ``` +In practice, structural comparison — comparing by value rather than by identity — is almost always what developers expect. C# `record` types partially address this: they generate `Equals` and `GetHashCode` automatically for all properties. But the generated equality is only correct for simple value types held directly in the record. **Reference-type properties and collections still use reference equality**, which is silent and easy to miss: + +```csharp +record Order(int Id, List Tags); + +var a = new Order(1, new List { "vip" }); +var b = new Order(1, new List { "vip" }); +Console.WriteLine(a == b); // false — List uses reference equality inside the record +``` + +Every reference type nested inside the record requires its own correct `IEquatable` implementation, all the way down the object graph. That obligation compounds quickly in real domain models, and a missing implementation anywhere silently breaks equality without a compile error or warning. + The correct fix is to implement `IEquatable` manually — but that creates a different set of problems. A hand-written `Equals` method must list every property explicitly, and in a large codebase it is easy to: - **forget a property** — equality silently ignores a field that should matter From bab3dc1c1263b3f38be09267349cb22635c7c1c6 Mon Sep 17 00:00:00 2001 From: "a.kasyanov" Date: Thu, 14 May 2026 16:28:33 +0300 Subject: [PATCH 60/71] =?UTF-8?q?docs:=20add=20edge=20case=20note=20?= =?UTF-8?q?=E2=80=94=20string=20works=20in=20records=20because=20it=20impl?= =?UTF-8?q?ements=20IEquatable,=20not=20by=20special=20treatment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.5 --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index a47afd3..e11a4de 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,8 @@ Console.WriteLine(a == b); // false — List uses reference equality inside the Every reference type nested inside the record requires its own correct `IEquatable` implementation, all the way down the object graph. That obligation compounds quickly in real domain models, and a missing implementation anywhere silently breaks equality without a compile error or warning. +> **Edge case — `string` looks correct but for a different reason.** `string` is a reference type, yet record equality for `string` properties works as expected. This is not because records treat strings specially — it is because `string` already implements `IEquatable` with value semantics. Any reference type that does *not* implement `IEquatable` (such as `List`, `Dictionary`, or a plain class) silently falls back to reference equality inside a record. + The correct fix is to implement `IEquatable` manually — but that creates a different set of problems. A hand-written `Equals` method must list every property explicitly, and in a large codebase it is easy to: - **forget a property** — equality silently ignores a field that should matter From dd17427858839da9273ebf64e08a9643bf020eef Mon Sep 17 00:00:00 2001 From: "a.kasyanov" Date: Thu, 14 May 2026 16:29:14 +0300 Subject: [PATCH 61/71] =?UTF-8?q?docs:=20reword=20string=20edge=20case=20n?= =?UTF-8?q?ote=20=E2=80=94=20cleaner=20phrasing=20for=20public=20library?= =?UTF-8?q?=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.5 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e11a4de..a769381 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ Console.WriteLine(a == b); // false — List uses reference equality inside the Every reference type nested inside the record requires its own correct `IEquatable` implementation, all the way down the object graph. That obligation compounds quickly in real domain models, and a missing implementation anywhere silently breaks equality without a compile error or warning. -> **Edge case — `string` looks correct but for a different reason.** `string` is a reference type, yet record equality for `string` properties works as expected. This is not because records treat strings specially — it is because `string` already implements `IEquatable` with value semantics. Any reference type that does *not* implement `IEquatable` (such as `List`, `Dictionary`, or a plain class) silently falls back to reference equality inside a record. +Note: `string` properties appear to work correctly in records because `string` already implements `IEquatable` with value semantics — not because records provide any special handling. Any reference type that does not implement `IEquatable` (`List`, `Dictionary`, custom classes) will silently use reference equality. The correct fix is to implement `IEquatable` manually — but that creates a different set of problems. A hand-written `Equals` method must list every property explicitly, and in a large codebase it is easy to: From 3471cc58085198dad8b9d1f3bc477af932e753aa Mon Sep 17 00:00:00 2001 From: "a.kasyanov" Date: Thu, 14 May 2026 16:32:05 +0300 Subject: [PATCH 62/71] docs: professional rewrite of all prose sections throughout README Co-Authored-By: Claude Sonnet 4.5 --- README.md | 226 +++++++++++++++++++++++++++++------------------------- 1 file changed, 120 insertions(+), 106 deletions(-) diff --git a/README.md b/README.md index a769381..c877f17 100644 --- a/README.md +++ b/README.md @@ -8,40 +8,54 @@ Source generator for `Equals` and `GetHashCode` with attribute-based control of [![Equatable.Generator](https://img.shields.io/nuget/v/Equatable.Generator.svg)](https://www.nuget.org/packages/Equatable.Generator/) -## What it does +## Overview -In C# every class inherits `Equals` from `object`, which compares **references** (memory addresses), not values: +By default, C# classes inherit `Equals` from `object`, which compares object references rather than values: ```csharp var a = new Product { Id = 1, Name = "Widget" }; var b = new Product { Id = 1, Name = "Widget" }; -Console.WriteLine(a == b); // false — different objects, even though values are identical +Console.WriteLine(a == b); // false — distinct instances, even though all values are identical ``` -In practice, structural comparison — comparing by value rather than by identity — is almost always what developers expect. C# `record` types partially address this: they generate `Equals` and `GetHashCode` automatically for all properties. But the generated equality is only correct for simple value types held directly in the record. **Reference-type properties and collections still use reference equality**, which is silent and easy to miss: +Structural equality — comparing objects by their field values rather than by identity — is what most application code requires. C# `record` types partially address this by generating `Equals` and `GetHashCode` from all properties automatically. However, the generated equality is only structurally correct for value types and `string`. **Reference-type properties and collection properties still fall back to reference equality**, which is silent and easily overlooked: ```csharp record Order(int Id, List Tags); var a = new Order(1, new List { "vip" }); var b = new Order(1, new List { "vip" }); -Console.WriteLine(a == b); // false — List uses reference equality inside the record +Console.WriteLine(a == b); // false — List uses reference equality inside the record ``` -Every reference type nested inside the record requires its own correct `IEquatable` implementation, all the way down the object graph. That obligation compounds quickly in real domain models, and a missing implementation anywhere silently breaks equality without a compile error or warning. +Every reference type in the object graph requires its own correct `IEquatable` implementation. That obligation compounds rapidly in real domain models, and a missing implementation anywhere produces incorrect equality silently, with no compile-time indication. -Note: `string` properties appear to work correctly in records because `string` already implements `IEquatable` with value semantics — not because records provide any special handling. Any reference type that does not implement `IEquatable` (`List`, `Dictionary`, custom classes) will silently use reference equality. +> `string` properties behave correctly inside records because `string` implements `IEquatable` with value semantics. This is not special treatment by the record infrastructure — any reference type that does not implement `IEquatable` (such as `List`, `Dictionary`, or a plain class) will silently use reference equality. -The correct fix is to implement `IEquatable` manually — but that creates a different set of problems. A hand-written `Equals` method must list every property explicitly, and in a large codebase it is easy to: +The standard remedy is a manual `IEquatable` implementation, but this introduces a different category of problems. A hand-written `Equals` method enumerates every property explicitly, and in any non-trivial codebase the following failure modes are common in practice: -- **forget a property** — equality silently ignores a field that should matter -- **include a property by mistake** — a computed or infrastructure field ends up in the comparison -- **miss the update** — a new property is added to the class but not to the `Equals` / `GetHashCode` methods -- **break the hash contract** — `Equals` and `GetHashCode` are maintained separately and drift out of sync in practice; objects that compare equal must produce the same hash code, but nothing enforces this in hand-written code — the result is silent corruption when the object is used as a dictionary key or in a hash set +- **Omitted property** — a field is absent from `Equals`, causing equality to silently disregard data that should influence the result. +- **Unintended inclusion** — a computed or infrastructure property is included, producing incorrect comparisons. +- **Stale implementation** — a new property is added to the type but not reflected in `Equals` or `GetHashCode`. +- **Hash contract violation** — `Equals` and `GetHashCode` are maintained independently and diverge over time. The contract that equal objects must produce equal hash codes is not enforced by the compiler; a violation causes silent corruption when instances are used as dictionary keys or hash set members. -These are not theoretical concerns. They appear regularly in real codebases, and they are hard to spot in code review because the missing or extra property is buried in a long method body, not visible at the declaration site. +These defects are difficult to detect in code review because the missing or extraneous property is buried inside a method body rather than visible at the property declaration. -This library solves the problem with a declarative, annotation-driven approach. Mark the class and each property **at the declaration** — the generator writes `Equals` and `GetHashCode` at compile time, and the analyzer warns immediately when an annotation is missing or misused. The intent is visible right next to the property; there is no separate method to keep in sync. +This library addresses the problem through a declarative, annotation-driven approach. Equality intent is expressed directly on each property at its declaration site. The source generator produces correct `Equals` and `GetHashCode` implementations at compile time, and the accompanying Roslyn analyzer emits build warnings when an annotation is absent or incorrectly applied. There is no separate method body to maintain or keep consistent. + +```csharp +[Equatable] +public partial class Product +{ + public int Id { get; set; } + + [StringEquality(StringComparison.OrdinalIgnoreCase)] + public string? Name { get; set; } + + [IgnoreEquality] // excluded — computed, not part of identity + public decimal TaxAmount { get; set; } +} +``` ## Packages @@ -83,7 +97,7 @@ public partial class Product } ``` -The generator writes `Equals` and `GetHashCode` for every public property. Works on `class`, `record`, `struct`, and `readonly struct`. +The generator produces `Equals` and `GetHashCode` implementations covering every public property. Supported type declarations: `class`, `record`, `struct`, and `readonly struct`. ## All attributes at a glance @@ -99,12 +113,12 @@ These attributes live in `Equatable.Attributes` and control how each property is | Attribute | What it generates | Default for | |---|---|---| -| `[IgnoreEquality]` | Skip this property | — | +| `[IgnoreEquality]` | Exclude this property from equality | — | | `[StringEquality(StringComparison.X)]` | `StringComparer.X.Equals(a, b)` | — | -| `[EqualityComparer(typeof(T))]` | `T.Default.Equals(a, b)` — any custom comparer | — | +| `[EqualityComparer(typeof(T))]` | `T.Default.Equals(a, b)` — any custom `IEqualityComparer` | — | | `[ReferenceEquality]` | `Object.ReferenceEquals(a, b)` | — | -| `[SequenceEquality]` | `SequenceEqualityComparer` — element order matters | `List`, `T[]`, `T[,]`, `T[,,]` | -| `[HashSetEquality]` | `HashSetEqualityComparer` — element order ignored | `HashSet` | +| `[SequenceEquality]` | `SequenceEqualityComparer` — order-sensitive element comparison | `List`, `T[]`, `T[,]`, `T[,,]` | +| `[HashSetEquality]` | `HashSetEqualityComparer` — order-insensitive element comparison | `HashSet` | | `[DictionaryEquality]` | `DictionaryEqualityComparer` — key-value equality, insertion order irrelevant | `Dictionary` | | `[DictionaryEquality(sequential:true)]` | `OrderedDictionaryEqualityComparer` — key-sorted comparison | — | @@ -114,7 +128,7 @@ These attributes live in `Equatable.Attributes` and control how each property is |---|---|---| | `[DataContractEquatable]` | `Equatable.Generator.DataContract` | Triggers generation; reads `[DataMember]` to select properties | -Property selection uses `System.Runtime.Serialization` attributes — these are not part of `Equatable.Generator.DataContract` itself, they come from the BCL or the serialisation library you already use: +Property selection is driven by `System.Runtime.Serialization` attributes, which come from the BCL or the serialisation library already in use: | Attribute | Source | Effect | |---|---|---| @@ -122,7 +136,7 @@ Property selection uses `System.Runtime.Serialization` attributes — these are | `[IgnoreDataMember]` | `System.Runtime.Serialization` | Explicitly exclude this property | | `[IgnoreEquality]` | `Equatable.Attributes` | Explicitly exclude this property | -All property-level equality attributes from `Equatable.Generator` (`[SequenceEquality]`, `[DictionaryEquality]`, `[StringEquality]`, `[EqualityComparer]`, `[ReferenceEquality]`) work as overrides on `[DataMember]` properties. Collection comparers are inferred automatically when no override is present. +All property-level equality attributes from `Equatable.Generator` (`[SequenceEquality]`, `[DictionaryEquality]`, `[StringEquality]`, `[EqualityComparer]`, `[ReferenceEquality]`) are accepted as overrides on `[DataMember]` properties. Collection comparers are inferred automatically from the property type when no override is present. ### `Equatable.Generator.MessagePack` — class-level trigger @@ -130,7 +144,7 @@ All property-level equality attributes from `Equatable.Generator` (`[SequenceEqu |---|---|---| | `[MessagePackEquatable]` | `Equatable.Generator.MessagePack` | Triggers generation; reads `[Key(n)]` to select properties | -Property selection uses MessagePack attributes — these come from the `MessagePack` package you already use: +Property selection is driven by MessagePack attributes from the `MessagePack` package already in use: | Attribute | Source | Effect | |---|---|---| @@ -138,11 +152,11 @@ Property selection uses MessagePack attributes — these come from the `MessageP | `[IgnoreMember]` | `MessagePack` | Explicitly exclude this property | | `[IgnoreEquality]` | `Equatable.Attributes` | Explicitly exclude this property | -All property-level equality attributes from `Equatable.Generator` work as overrides on `[Key(n)]` properties. Collection comparers are inferred automatically when no override is present. +All property-level equality attributes from `Equatable.Generator` are accepted as overrides on `[Key(n)]` properties. Collection comparers are inferred automatically when no override is present. ## Adapter generators -Use `[DataContractEquatable]` or `[MessagePackEquatable]` when your class is already annotated for serialisation. The adapter reads the existing serialisation attributes to decide which properties to include — no duplication of intent required. +Use `[DataContractEquatable]` or `[MessagePackEquatable]` when the type is already annotated for serialisation. The adapter derives property selection from the existing serialisation attributes, requiring no duplication of intent. ### Property selection @@ -151,11 +165,11 @@ Use `[DataContractEquatable]` or `[MessagePackEquatable]` when your class is alr | `[DataContractEquatable]` | `[DataMember]` | `[IgnoreDataMember]` or `[IgnoreEquality]` | all other public properties | | `[MessagePackEquatable]` | `[Key(n)]` | `[IgnoreMember]` or `[IgnoreEquality]` | all other public properties | -Properties with no annotation at all are silently excluded from equality. If that omission was accidental the build will warn (EQ0022 / EQ0023) — add the inclusion or exclusion attribute to make the intent explicit. +Public properties that carry no annotation are silently excluded from equality. When that omission is unintentional, the build emits EQ0022 or EQ0023. Resolve the warning by adding the appropriate inclusion or explicit exclusion attribute. -### Comparer inference — no equality attributes needed on collection properties +### Comparer inference -Adapters auto-infer the correct collection comparer from the property type, so `[DataMember]` / `[Key(n)]` properties never need an explicit `[SequenceEquality]`, `[DictionaryEquality]`, or `[HashSetEquality]`. The same defaults apply as for `[Equatable]`: `List` / `T[]` → `SequenceEquality`; `HashSet` → `HashSetEquality`; `Dictionary` → `DictionaryEquality`. +Adapter generators infer the correct collection comparer from the property type automatically. Properties annotated with `[DataMember]` or `[Key(n)]` do not require an explicit `[SequenceEquality]`, `[DictionaryEquality]`, or `[HashSetEquality]`. The same defaults apply as for `[Equatable]`: `List` / `T[]` → `SequenceEquality`; `HashSet` → `HashSetEquality`; `Dictionary` → `DictionaryEquality`. ```csharp [DataContract] @@ -164,10 +178,10 @@ public partial class EventContract { [DataMember(Order = 0)] public int EventId { get; set; } - // List → SequenceEqualityComparer inferred — no attribute needed + // List → SequenceEqualityComparer inferred — no attribute required [DataMember(Order = 1)] public List? Tags { get; set; } - // Dictionary → DictionaryEqualityComparer inferred — no attribute needed + // Dictionary → DictionaryEqualityComparer inferred — no attribute required [DataMember(Order = 2)] public Dictionary? Scores { get; set; } [IgnoreDataMember] @@ -183,7 +197,7 @@ public partial class LiveScore [Key(0)] public int MatchId { get; set; } [Key(1)] public int HomeScore { get; set; } - // HashSet → HashSetEqualityComparer inferred — no attribute needed + // HashSet → HashSetEqualityComparer inferred — no attribute required [Key(2)] public HashSet? Tags { get; set; } [IgnoreMember] @@ -193,14 +207,14 @@ public partial class LiveScore ### Overriding the inferred comparer -Explicit equality attributes take priority over inference. All the same attributes from the `[Equatable]` table work on adapter-included properties: +Explicit equality attributes take precedence over inference. All attributes from the `[Equatable]` table are applicable to adapter-included properties: ```csharp [DataContract] [DataContractEquatable] public partial class EventContract { - // Override: treat list as a set (order irrelevant) + // Override: treat list as a set — element order is irrelevant [DataMember(Order = 0)] [HashSetEquality] public List? PermissionCodes { get; set; } @@ -219,9 +233,9 @@ public partial class EventContract ## Collection attributes in detail -### `[SequenceEquality]` — order matters +### `[SequenceEquality]` — order-sensitive comparison -**Default for:** `List`, `T[]` — no attribute needed on these types. +**Default for:** `List`, `T[]` — no attribute required on these types. **Supported types:** any `IEnumerable` — `List`, `T[]`, `ICollection`, `IReadOnlyList`, `IEnumerable`, `HashSet` (via override), and more. @@ -233,18 +247,18 @@ public int[]? Scores { get; set; } // SequenceEquality by default `["A","B","C"]` equals `["A","B","C"]` ✓ `["A","B","C"]` does NOT equal `["C","B","A"]` ✓ -**Direction override:** apply to `HashSet` to force order-sensitive comparison on a normally unordered set. +**Direction override:** apply to `HashSet` to enforce order-sensitive comparison on a normally unordered type. ```csharp [SequenceEquality] -public HashSet? OrderedTags { get; set; } // override: order now matters +public HashSet? OrderedTags { get; set; } // override: element order now matters ``` --- -### `[HashSetEquality]` — order does not matter +### `[HashSetEquality]` — order-insensitive comparison -**Default for:** `HashSet` — no attribute needed on plain hash sets. +**Default for:** `HashSet` — no attribute required on plain hash sets. **Supported types:** any `IEnumerable` — `HashSet`, `ISet`, `IReadOnlySet`, `List`, `T[]` (via override), and more. When the value implements `ISet`, `SetEquals` is called directly (fast path). Otherwise a temporary `HashSet` is constructed for the comparison. @@ -254,18 +268,18 @@ public HashSet? Roles { get; set; } // HashSetEquality by default `{"admin","editor"}` equals `{"editor","admin"}` ✓ -**Direction override:** apply to `List` or `T[]` to make them order-insensitive. +**Direction override:** apply to `List` or `T[]` to make the comparison order-insensitive. ```csharp [HashSetEquality] -public List? PermissionCodes { get; set; } // override: order no longer matters +public List? PermissionCodes { get; set; } // override: element order no longer matters ``` --- -### `[DictionaryEquality]` — insertion order does not matter +### `[DictionaryEquality]` — key-value comparison, insertion order irrelevant -**Default for:** `Dictionary` — no attribute needed on plain dictionaries. +**Default for:** `Dictionary` — no attribute required on plain dictionaries. **Supported types:** any `IReadOnlyDictionary` — `Dictionary`, `IReadOnlyDictionary`, `SortedDictionary`, `ConcurrentDictionary`, and more. @@ -277,7 +291,7 @@ public Dictionary? Prices { get; set; } // DictionaryEquality b ### `[DictionaryEquality(sequential: true)]` — key-sorted comparison -Both sides are sorted by key before comparison. Insertion order is still irrelevant, but the result is deterministic — useful for snapshots and logs. +Both sides are sorted by key before comparison. Insertion order is irrelevant, and the result is deterministic — useful for snapshot testing and diagnostic logging. **Supported types:** same as `[DictionaryEquality]` — any `IReadOnlyDictionary`. @@ -290,7 +304,7 @@ public Dictionary? RankByRegion { get; set; } ## Nested collections -Annotate the **outer property once** — the generator infers the right comparer for every nested level automatically. +Annotate the **outer property once** — the generator selects the appropriate comparer for every nested level automatically. ### `[DictionaryEquality]` @@ -323,7 +337,7 @@ public Dictionary>? ByRegionAndTeam { get; set; [DictionaryEquality(sequential: true)] public Dictionary>? HistoryByRegion { get; set; } -// three levels deep — propagation goes all the way +// three levels deep — propagation applies at every level [DictionaryEquality(sequential: true)] public Dictionary>>? ThreeLevelConfig { get; set; } ``` @@ -331,7 +345,7 @@ public Dictionary>>? ThreeLev ### `[SequenceEquality]` ```csharp -// outer: SequenceEquality (order matters for the outer list) +// outer: SequenceEquality (element order matters for the outer list) // inner Dictionary: DictionaryEquality (default for Dictionary) [SequenceEquality] public List>? Steps { get; set; } @@ -347,9 +361,9 @@ public List>? Matrix { get; set; } public List>? Groups { get; set; } ``` -### Explicit overrides are always transparent +### Explicit overrides propagate transparently -Annotations on a property are the single source of truth — they are never implied or hidden. If a `List` property has no attribute, it uses `SequenceEquality`. If it has `[HashSetEquality]`, it uses `HashSetEquality`. There is no magic inference that could surprise you. +The annotation on a property is the sole source of truth. A `List` property with no attribute uses `SequenceEquality`; with `[HashSetEquality]` it uses `HashSetEquality`. There is no implicit inference that could produce unexpected results. ```csharp public List? Tags { get; set; } // SequenceEquality (default) @@ -361,19 +375,19 @@ public List? Permissions { get; set; } // HashSetEquality (explicit ov public HashSet? OrderedSet { get; set; } // SequenceEquality (explicit override) ``` -The same logic applies inside nested collections. The outer annotation sets the comparer kind; inner types follow their own defaults unless they are themselves the type you are overriding at the outer level. +The same principle applies inside nested collections. The outer annotation establishes the comparer kind; inner types follow their own defaults unless the outer annotation overrides them. ```csharp -// outer List → SequenceEquality (default, no attribute needed) -// inner HashSet → HashSetEquality (default for HashSet) +// outer List → SequenceEquality (default) +// inner HashSet → HashSetEquality (default for HashSet) public List>? Groups { get; set; } -// outer List → HashSetEquality (override — treat the list as a set of sets) +// outer List → HashSetEquality (override — the list is treated as a set of sets) // inner HashSet → HashSetEquality (propagated from outer override) [HashSetEquality] public List>? GroupsUnordered { get; set; } -// outer HashSet → SequenceEquality (override — order now matters for the outer set) +// outer HashSet → SequenceEquality (override — element order now matters) // inner List → SequenceEquality (propagated from outer override) [SequenceEquality] public HashSet>? OrderedGroups { get; set; } @@ -383,15 +397,15 @@ public HashSet>? OrderedGroups { get; set; } ## Multi-dimensional arrays -`T[,]`, `T[,,]`, and higher-rank arrays are handled by `MultiDimensionalArrayEqualityComparer` — no attribute needed, just like `T[]`. +`T[,]`, `T[,,]`, and higher-rank arrays are handled automatically by `MultiDimensionalArrayEqualityComparer` — no attribute is required. **Default for:** any array with rank ≥ 2. Single-dimensional `T[]` uses `SequenceEqualityComparer` instead. ```csharp -// 2D array — MultiDimensionalArrayEqualityComparer by default, no attribute needed +// 2D array — MultiDimensionalArrayEqualityComparer applied by default public int[,] Grid { get; set; } -// 3D array — same default, rank detected automatically at compile time +// 3D array — rank is detected at compile time; same default applies public double[,,] Cube { get; set; } ``` @@ -412,11 +426,11 @@ var d = new int[,,] { { { 1, 2 }, { 3, 4 } } }; // a != d ✓ (rank 2 vs rank 3 — always unequal regardless of content) ``` -### Overrides for multi-dimensional arrays +### Comparer overrides for multi-dimensional arrays -The outer comparer is always `MultiDimensionalArrayEqualityComparer` for rank ≥ 2 — it cannot be swapped for `SequenceEqualityComparer` or `HashSetEqualityComparer`. There is no supported element-level override: `[EqualityComparer]` on a `T[,]` property bypasses `MultiDimensionalArrayEqualityComparer` entirely and compares the array as a single reference, which is incorrect. Use the default and rely on element type's own equality instead. +`MultiDimensionalArrayEqualityComparer` is always used for rank ≥ 2 and cannot be replaced with `SequenceEqualityComparer` or `HashSetEqualityComparer`. Applying `[EqualityComparer(typeof(MyComparer))]` to a `T[,]` property does not wrap the comparer around `MultiDimensionalArrayEqualityComparer` — it bypasses it entirely, passing the array instance as a single opaque value to the custom comparer. The effective behaviour is reference equality, which is almost certainly incorrect. Rely on the default and ensure the element type defines its own correct equality. -Single-dimensional `T[]` is more flexible — it supports all comparer overrides: +Single-dimensional `T[]` supports the full range of comparer overrides: ```csharp // T[] default: SequenceEquality (order matters) @@ -429,9 +443,9 @@ public int[] GroupIds { get; set; } --- -## `[EqualityComparer]` — fully custom comparer +## `[EqualityComparer]` — custom comparer -When no built-in attribute fits, write your own `IEqualityComparer`: +When no built-in attribute is appropriate, supply a custom `IEqualityComparer`: ```csharp public sealed class CountOnlyComparer : IEqualityComparer?> @@ -450,7 +464,7 @@ public Dictionary? AssetWeights { get; set; } ## Build-time diagnostics -The analyzer validates every `[Equatable]` class at compile time and emits warnings when attributes are missing or misused. These diagnostics are designed to surface mistakes that would otherwise produce silent wrong behavior at runtime. +The Roslyn analyzer validates every `[Equatable]` type at compile time and emits warnings when attributes are absent or incorrectly applied. These diagnostics are designed to surface mistakes that would otherwise produce silent incorrect behaviour at runtime. ### Missing attribute warnings @@ -463,13 +477,13 @@ The analyzer validates every `[Equatable]` class at compile time and emits warni | `EQ0022` | `[DataContractEquatable]` | Public property has no `[DataMember]`, `[IgnoreDataMember]`, or `[IgnoreEquality]` | — | | `EQ0023` | `[MessagePackEquatable]` | Public property has no `[Key(n)]`, `[IgnoreMember]`, or `[IgnoreEquality]` | — | -**EQ0001 / EQ0002** only apply to `[Equatable]` classes, where every public property is included by default and must be explicitly annotated for collection/dictionary types. Adapter generators (`[DataContractEquatable]`, `[MessagePackEquatable]`) auto-infer the correct comparer from the property type, so collection properties annotated with `[DataMember]` or `[Key(n)]` never need `[SequenceEquality]` or `[DictionaryEquality]`. +**EQ0001 / EQ0002** apply exclusively to `[Equatable]` types, where all public properties are included by default and collection or dictionary types must be explicitly annotated. Adapter generators infer the correct comparer automatically, so `[DataMember]` and `[Key(n)]` properties never require an additional `[SequenceEquality]` or `[DictionaryEquality]` annotation. -Multi-dimensional arrays (`T[,]`, `T[,,]`) are exempt from EQ0002 because `MultiDimensionalArrayEqualityComparer` is always the default — no annotation is needed or accepted. +Multi-dimensional arrays (`T[,]`, `T[,,]`) are exempt from EQ0002 because `MultiDimensionalArrayEqualityComparer` is always applied as the default — no annotation is required or accepted. -**EQ0020 / EQ0021** catch the case where the adapter attribute is added but the corresponding serialisation attribute is missing. Without `[DataContract]` the serialiser ignores all `[DataMember]` annotations, so the generated equality would silently include no properties. The same applies to `[MessagePackObject]` / `[Key(n)]`. +**EQ0020 / EQ0021** detect the case where an adapter attribute is present but its corresponding serialisation attribute is absent. Without `[DataContract]`, the serialiser ignores all `[DataMember]` annotations; the generated equality would include no properties. The same applies to `[MessagePackObject]` and `[Key(n)]`. -**EQ0022 / EQ0023** catch silently excluded properties on adapter-annotated types. The adapters only include properties that carry the serialisation inclusion attribute (`[DataMember]` / `[Key(n)]`) — all other public properties are silently skipped. This is intentional for computed properties or infrastructure fields you never want serialised, but an accidental omission is hard to notice. EQ0022/EQ0023 force the intent to be explicit: either add the inclusion attribute or add an explicit exclusion to suppress the warning: +**EQ0022 / EQ0023** detect public properties that are silently excluded on adapter-annotated types. The adapters include only properties that carry the serialisation inclusion attribute (`[DataMember]` / `[Key(n)]`); all other public properties are excluded without warning. This is intentional for computed or infrastructure properties, but an accidental omission is difficult to identify. EQ0022 and EQ0023 require the intent to be made explicit: add the inclusion attribute or an explicit exclusion attribute to suppress the diagnostic. ```csharp [DataContract] @@ -478,12 +492,12 @@ public partial class EventContract { [DataMember(Order = 0)] public int EventId { get; set; } // included ✓ - // EQ0022 — silently excluded; was this intentional? + // EQ0022 — excluded without annotation; intent is ambiguous public DateTime LastSeen { get; set; } - [IgnoreDataMember] public DateTime LastSeen { get; set; } // explicit exclusion ✓ + [IgnoreDataMember] public DateTime LastSeen { get; set; } // explicit exclusion ✓ // or - [IgnoreEquality] public DateTime LastSeen { get; set; } // explicit exclusion ✓ + [IgnoreEquality] public DateTime LastSeen { get; set; } // explicit exclusion ✓ } ``` @@ -491,41 +505,41 @@ public partial class EventContract | Diagnostic | Condition | |---|---| -| `EQ0010` | `[StringEquality]` on a non-`string` property | -| `EQ0011` | `[DictionaryEquality]` on a type that does not implement `IDictionary` or `IReadOnlyDictionary` | -| `EQ0012` | `[HashSetEquality]` on a type that does not implement `IEnumerable` | -| `EQ0013` | `[SequenceEquality]` on a type that does not implement `IEnumerable` | -| `EQ0014` | Any collection or equality attribute on a multi-dimensional array (`rank ≥ 2`) | -| `EQ0015` | `[SequenceEquality]` or `[HashSetEquality]` on a dictionary type (`IDictionary` or `IReadOnlyDictionary`) | +| `EQ0010` | `[StringEquality]` applied to a non-`string` property | +| `EQ0011` | `[DictionaryEquality]` applied to a type that does not implement `IDictionary` or `IReadOnlyDictionary` | +| `EQ0012` | `[HashSetEquality]` applied to a type that does not implement `IEnumerable` | +| `EQ0013` | `[SequenceEquality]` applied to a type that does not implement `IEnumerable` | +| `EQ0014` | Any collection or equality attribute applied to a multi-dimensional array (`rank ≥ 2`) | +| `EQ0015` | `[SequenceEquality]` or `[HashSetEquality]` applied to a dictionary type (`IDictionary` or `IReadOnlyDictionary`) | -### EQ0014 — attributes have no effect on multi-dimensional arrays +### EQ0014 — equality attributes have no effect on multi-dimensional arrays -`EQ0014` fires whenever any collection or equality attribute (`[SequenceEquality]`, `[HashSetEquality]`, `[DictionaryEquality]`, `[EqualityComparer]`, `[ReferenceEquality]`) is placed on a `T[,]` or higher-rank array property. This is intentional and expected — it is not possible to override the comparer for a multi-dimensional array: +`EQ0014` is emitted when any collection or equality attribute (`[SequenceEquality]`, `[HashSetEquality]`, `[DictionaryEquality]`, `[EqualityComparer]`, `[ReferenceEquality]`) is placed on a property of type `T[,]` or higher rank. The comparer for multi-dimensional arrays cannot be overridden: -- `[SequenceEquality]` and `[HashSetEquality]` are silently ignored; `MultiDimensionalArrayEqualityComparer` is used regardless. -- `[EqualityComparer(typeof(MyComparer))]` appears to work but actually bypasses `MultiDimensionalArrayEqualityComparer` entirely, passing the whole array object as a single value to `MyComparer`. The result is effectively reference equality — almost certainly not what was intended. +- `[SequenceEquality]` and `[HashSetEquality]` are ignored; `MultiDimensionalArrayEqualityComparer` is used regardless. +- `[EqualityComparer(typeof(MyComparer))]` bypasses `MultiDimensionalArrayEqualityComparer` entirely and passes the array instance as a single value to the custom comparer, producing reference equality behaviour. -The diagnostic turns a silent, surprising behavior into a loud, visible one at compile time: +The diagnostic converts silent, incorrect behaviour into a visible compile-time warning: ```csharp -// EQ0014 — attribute has no effect on rank-2 array +// EQ0014 — attribute has no effect on a rank-2 array [SequenceEquality] public int[,]? Grid { get; set; } -// Correct — no attribute needed; MultiDimensionalArrayEqualityComparer is the default +// Correct — no attribute required; MultiDimensionalArrayEqualityComparer is the default public int[,]? Grid { get; set; } ``` -### EQ0015 — enumerable attributes have no useful meaning on dictionary types +### EQ0015 — enumerable attributes are not applicable to dictionary types -`EQ0015` fires when `[SequenceEquality]` or `[HashSetEquality]` is applied to a property whose type implements `IDictionary` or `IReadOnlyDictionary`. These attributes treat the dictionary as a flat sequence of `KeyValuePair` entries, which discards key-lookup semantics and produces comparisons that are sensitive to insertion order. Use `[DictionaryEquality]` instead: +`EQ0015` is emitted when `[SequenceEquality]` or `[HashSetEquality]` is applied to a property whose type implements `IDictionary` or `IReadOnlyDictionary`. These attributes treat the dictionary as a flat sequence of `KeyValuePair` entries, discarding key-lookup semantics and producing insertion-order-sensitive comparisons. Use `[DictionaryEquality]` instead: ```csharp -// EQ0015 — treats Dictionary as a sequence of KeyValuePair entries (order-sensitive, wrong) +// EQ0015 — treats Dictionary as a sequence of KeyValuePair entries (order-sensitive) [SequenceEquality] public Dictionary? Scores { get; set; } -// EQ0015 — treats Dictionary as a set of KeyValuePair entries (still wrong) +// EQ0015 — treats Dictionary as a set of KeyValuePair entries [HashSetEquality] public Dictionary? Scores { get; set; } @@ -538,16 +552,16 @@ public Dictionary? Scores { get; set; } ## Equality invariants -Every generated implementation satisfies: +Every generated implementation satisfies the following properties: -| Property | Meaning | +| Property | Guarantee | |---|---| | **Reflexive** | `a.Equals(a)` is always `true` | | **Symmetric** | `a.Equals(b) == b.Equals(a)` always | | **Null-safe** | `a.Equals(null)` is always `false` | | **Hash contract** | `a.Equals(b)` implies `a.GetHashCode() == b.GetHashCode()` | -The hash contract is critical for using objects as dictionary keys or in hash sets. +The hash contract is critical for correct behaviour when instances are used as dictionary keys or hash set members. --- @@ -555,41 +569,41 @@ The hash contract is critical for using objects as dictionary keys or in hash se ### New packages -- **`Equatable.Generator.DataContract`** — adapter generator that reads `[DataMember]` attributes (`System.Runtime.Serialization`). Only properties annotated with `[DataMember]` are included in equality; `[IgnoreDataMember]` and unannotated properties are excluded. A build warning (EQ0022) fires for any public property with no annotation at all, forcing the intent to be explicit. -- **`Equatable.Generator.MessagePack`** — adapter generator that reads `[Key(n)]` attributes. Only `[Key]` properties are included; `[IgnoreMember]` properties and unannotated properties are excluded. EQ0023 warns on unannotated properties. +- **`Equatable.Generator.DataContract`** — adapter generator that reads `[DataMember]` attributes (`System.Runtime.Serialization`). Only properties annotated with `[DataMember]` are included in equality; `[IgnoreDataMember]` and unannotated properties are excluded. EQ0022 is emitted for any public property with no annotation, requiring the intent to be made explicit. +- **`Equatable.Generator.MessagePack`** — adapter generator that reads `[Key(n)]` attributes. Only `[Key]` properties are included; `[IgnoreMember]` and unannotated properties are excluded. EQ0023 is emitted for unannotated properties. ### New features -- **`[DictionaryEquality(sequential: true)]`** — key-sorted dictionary comparison. Both sides are sorted by key before comparing, making equality deterministic regardless of insertion order. Useful for snapshots and logs. Propagates into nested dictionary values. -- **Direction overrides** — apply `[HashSetEquality]` to `List` or `T[]` to make them order-insensitive; apply `[SequenceEquality]` to `HashSet` to force order-sensitive comparison. -- **Nested collection comparer propagation** — annotate the outer property once; the chosen comparer kind propagates automatically into all nested levels. `Dictionary>`, `Dictionary>`, three-level nesting — all handled with a single annotation. -- **`MultiDimensionalArrayEqualityComparer`** — structural equality for `T[,]`, `T[,,]`, and higher-rank arrays. Applied automatically as the default — no attribute needed. Checks rank, dimension lengths, and elements in row-major order. -- **`IReadOnlyDictionary` support** — dictionary comparers now accept any `IReadOnlyDictionary`, not just `Dictionary`. -- **Base class delegation** — generated `Equals` calls `base.Equals()` when the base class is also an equatable-generated type. Works across adapter boundaries (e.g. `[Equatable]` derived from `[DataContractEquatable]`). +- **`[DictionaryEquality(sequential: true)]`** — key-sorted dictionary comparison. Both sides are sorted by key before comparison, producing deterministic equality regardless of insertion order. Useful for snapshot testing and diagnostic logging. Propagates into nested dictionary values. +- **Direction overrides** — `[HashSetEquality]` on `List` or `T[]` produces order-insensitive comparison; `[SequenceEquality]` on `HashSet` enforces order-sensitive comparison. +- **Nested collection comparer propagation** — a single annotation on the outer property propagates the chosen comparer kind into all nested levels. `Dictionary>`, `Dictionary>`, and three-level nesting are all handled with a single annotation. +- **`MultiDimensionalArrayEqualityComparer`** — structural equality for `T[,]`, `T[,,]`, and higher-rank arrays, applied automatically as the default. Checks rank, dimension lengths, and element values in row-major order. +- **`IReadOnlyDictionary` support** — dictionary comparers accept any `IReadOnlyDictionary`, not only `Dictionary`. +- **Base class delegation** — the generated `Equals` method calls `base.Equals()` when the base class is also an equatable-generated type, including across adapter boundaries. - **Analyzer diagnostics** - `EQ0020` — `[DataContractEquatable]` without `[DataContract]` - `EQ0021` — `[MessagePackEquatable]` without `[MessagePackObject]` - `EQ0022` — unannotated public property on a `[DataContractEquatable]` type - `EQ0023` — unannotated public property on a `[MessagePackEquatable]` type - - `EQ0014` — any collection or equality attribute on a multi-dimensional array (`rank ≥ 2`), where the comparer cannot be overridden + - `EQ0014` — equality attribute on a multi-dimensional array (`rank ≥ 2`), where the comparer cannot be overridden - `EQ0015` — `[SequenceEquality]` or `[HashSetEquality]` on a dictionary type ### Bug fixes -- **Empty collections hash differently from null** — a sentinel value is used for empty sequences and dictionaries, satisfying the hash contract (`GetHashCode(empty) != GetHashCode(null)`). -- **`HashSetEqualityComparer.GetHashCode` is order-independent** — uses a commutative sum, consistent with `SetEquals`-based `Equals`. -- **`[DictionaryEquality(sequential: true)]` propagates correctly** — key-sorted mode is applied to nested dictionary values, not just the outermost level. -- **Value types without `==`** — `EqualityComparer.Default` is used instead of direct comparison. +- **Empty collections hash differently from null** — a sentinel value ensures `GetHashCode(empty) != GetHashCode(null)`, satisfying the hash contract. +- **`HashSetEqualityComparer.GetHashCode` is order-independent** — uses a commutative accumulation strategy, consistent with `SetEquals`-based `Equals`. +- **`[DictionaryEquality(sequential: true)]` propagates correctly** — key-sorted mode is applied to nested dictionary values, not only the outermost level. +- **Value types without `==`** — `EqualityComparer.Default` is used instead of direct operator comparison. ### Improvements -- `Equatable.Generator.DataContract` and `Equatable.Generator.MessagePack` are separate NuGet packages — add only what you need. -- `IsPublicInstanceProperty` extracted as a shared helper, eliminating duplication between adapter generators. +- `Equatable.Generator.DataContract` and `Equatable.Generator.MessagePack` are independent NuGet packages — include only what the project requires. +- `IsPublicInstanceProperty` extracted as a shared helper, eliminating duplication across adapter generators. - Allocation-free hash code computation for dictionary comparers. --- ## Requirements -- Target framework .NET Standard 2.0 or greater -- C# `LangVersion` 8.0 or higher +- Target framework: .NET Standard 2.0 or later +- C# language version: 8.0 or higher From 9e18db9efcf895e9deb68ff5c1950c1ff6659cdc Mon Sep 17 00:00:00 2001 From: "a.kasyanov" Date: Thu, 14 May 2026 17:48:10 +0300 Subject: [PATCH 63/71] feat: honour [IgnoreEquality] on [DataMember]/[Key(n)] properties in adapter generators Properties annotated with both a serialisation attribute ([DataMember] or [Key(n)]) and [IgnoreEquality] are now correctly excluded from generated Equals / GetHashCode. Previously IsIncludedDataContract and IsIncludedMessagePack only checked the serialisation-specific exclusion attributes; the [IgnoreEquality] check was missing from both adapters. Adds snapshot tests proving the fix and a README section documenting the [DataMember(Order=n)][IgnoreEquality] pattern with a DataContract and a MessagePack example. Co-Authored-By: Claude Sonnet 4.5 --- README.md | 45 +++++++++++++++++++ .../DataContractEquatableGenerator.cs | 7 +++ .../MessagePackEquatableGenerator.cs | 7 +++ .../DataContractGeneratorTest.cs | 36 +++++++++++++++ .../MessagePackGeneratorTest.cs | 36 +++++++++++++++ ...bleIgnoreEqualityOnDataMember.verified.txt | 45 +++++++++++++++++++ ...rrayWithCustomElementComparer.verified.txt | 45 +++++++++++++++++++ ...kEquatableIgnoreEqualityOnKey.verified.txt | 45 +++++++++++++++++++ 8 files changed, 266 insertions(+) create mode 100644 test/Equatable.Generator.Tests/Snapshots/DataContractGeneratorTest.GenerateDataContractEquatableIgnoreEqualityOnDataMember.verified.txt create mode 100644 test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateMultiDimensionalArrayWithCustomElementComparer.verified.txt create mode 100644 test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatableIgnoreEqualityOnKey.verified.txt diff --git a/README.md b/README.md index c877f17..c8e9e1f 100644 --- a/README.md +++ b/README.md @@ -231,6 +231,51 @@ public partial class EventContract } ``` +### Serialised but excluded from equality + +A property can participate in serialisation while being excluded from equality. This is useful when a field tracks operational metadata — timestamps, audit info, internal sequence numbers — that is transmitted on the wire but must not affect the equality contract of the domain object. + +Combine `[DataMember]` or `[Key(n)]` with `[IgnoreEquality]`: + +```csharp +[DataContract] +[DataContractEquatable] +public partial class OrderDataContract +{ + [DataMember(Order = 0)] + public int Id { get; set; } + + [DataMember(Order = 1)] + public string? Name { get; set; } + + // Included in serialisation (Order = 2) but excluded from equality. + // Two OrderDataContract values are equal when Id and Name match, + // regardless of when they were last modified. + [DataMember(Order = 2)] + [IgnoreEquality] + public DateTime LastModified { get; set; } +} +``` + +```csharp +[MessagePackObject] +[MessagePackEquatable] +public partial class PricingContract +{ + [Key(0)] public int MarketId { get; set; } + [Key(1)] public string? Name { get; set; } + + // Serialised at key 2 but omitted from generated Equals / GetHashCode. + [Key(2)] + [IgnoreEquality] + public DateTime ReceivedAt { get; set; } +} +``` + +The generated `Equals` and `GetHashCode` will not reference `LastModified` or `ReceivedAt` even though both properties are present in the serialised form. + +--- + ## Collection attributes in detail ### `[SequenceEquality]` — order-sensitive comparison diff --git a/src/Equatable.SourceGenerator.DataContract/DataContractEquatableGenerator.cs b/src/Equatable.SourceGenerator.DataContract/DataContractEquatableGenerator.cs index 2b67afa..ca06cc4 100644 --- a/src/Equatable.SourceGenerator.DataContract/DataContractEquatableGenerator.cs +++ b/src/Equatable.SourceGenerator.DataContract/DataContractEquatableGenerator.cs @@ -30,6 +30,13 @@ private static bool IsIncludedDataContract(IPropertySymbol propertySymbol) })) return false; + if (attributes.Any(a => a.AttributeClass is + { + Name: "IgnoreEqualityAttribute", + ContainingNamespace: { Name: "Attributes", ContainingNamespace.Name: "Equatable" } + })) + return false; + return attributes.Any(a => a.AttributeClass is { Name: "DataMemberAttribute", diff --git a/src/Equatable.SourceGenerator.MessagePack/MessagePackEquatableGenerator.cs b/src/Equatable.SourceGenerator.MessagePack/MessagePackEquatableGenerator.cs index 3b685c1..6ca4017 100644 --- a/src/Equatable.SourceGenerator.MessagePack/MessagePackEquatableGenerator.cs +++ b/src/Equatable.SourceGenerator.MessagePack/MessagePackEquatableGenerator.cs @@ -26,6 +26,13 @@ private static bool IsIncludedMessagePack(IPropertySymbol propertySymbol) if (attributes.Any(a => a.AttributeClass is { Name: "IgnoreMemberAttribute", ContainingNamespace.Name: "MessagePack" })) return false; + if (attributes.Any(a => a.AttributeClass is + { + Name: "IgnoreEqualityAttribute", + ContainingNamespace: { Name: "Attributes", ContainingNamespace.Name: "Equatable" } + })) + return false; + return attributes.Any(a => a.AttributeClass is { Name: "KeyAttribute", ContainingNamespace.Name: "MessagePack" }); } } diff --git a/test/Equatable.Generator.Tests/DataContractGeneratorTest.cs b/test/Equatable.Generator.Tests/DataContractGeneratorTest.cs index d7d59d7..c00db9f 100644 --- a/test/Equatable.Generator.Tests/DataContractGeneratorTest.cs +++ b/test/Equatable.Generator.Tests/DataContractGeneratorTest.cs @@ -245,6 +245,42 @@ public partial class AllIgnored return Verifier.Verify(output).UseDirectory("Snapshots").ScrubLinesContaining("GeneratedCodeAttribute"); } + // ── [DataMember] + [IgnoreEquality] — property serialised but excluded from equality ───────────── + // A property can carry [DataMember] for serialisation while [IgnoreEquality] opts it out + // of the generated Equals / GetHashCode. The generator must honour [IgnoreEquality] even + // when [DataMember] is present. + + [Fact] + public Task GenerateDataContractEquatableIgnoreEqualityOnDataMember() + { + var source = @" +using System; +using System.Runtime.Serialization; +using Equatable.Attributes; +using Equatable.Attributes.DataContract; + +namespace Equatable.Entities; + +[DataContract] +[DataContractEquatable] +public partial class OrderDataContract +{ + [DataMember(Order = 0)] + public int Id { get; set; } + + [DataMember(Order = 1)] + public string? Name { get; set; } + + [DataMember(Order = 2)] + [IgnoreEquality] + public DateTime LastModified { get; set; } +} +"; + var (diagnostics, output) = GetGeneratedOutput(source); + Assert.Empty(diagnostics); + return Verifier.Verify(output).UseDirectory("Snapshots").ScrubLinesContaining("GeneratedCodeAttribute"); + } + [Fact] public Task GenerateDataContractEquatableWithHashSetEqualityOnListAndArray() { diff --git a/test/Equatable.Generator.Tests/MessagePackGeneratorTest.cs b/test/Equatable.Generator.Tests/MessagePackGeneratorTest.cs index e7b63b8..5ad91dc 100644 --- a/test/Equatable.Generator.Tests/MessagePackGeneratorTest.cs +++ b/test/Equatable.Generator.Tests/MessagePackGeneratorTest.cs @@ -245,6 +245,42 @@ public partial class AllIgnored return Verifier.Verify(output).UseDirectory("Snapshots").ScrubLinesContaining("GeneratedCodeAttribute"); } + // ── [Key] + [IgnoreEquality] — property serialised but excluded from equality ──────────────────── + // A property can carry [Key(n)] for MessagePack serialisation while [IgnoreEquality] opts it + // out of the generated Equals / GetHashCode. The generator must honour [IgnoreEquality] even + // when [Key(n)] is present. + + [Fact] + public Task GenerateMessagePackEquatableIgnoreEqualityOnKey() + { + var source = @" +using System; +using MessagePack; +using Equatable.Attributes; +using Equatable.Attributes.MessagePack; + +namespace Equatable.Entities; + +[MessagePackObject] +[MessagePackEquatable] +public partial class PricingContract +{ + [Key(0)] + public int MarketId { get; set; } + + [Key(1)] + public string? Name { get; set; } + + [Key(2)] + [IgnoreEquality] + public DateTime ReceivedAt { get; set; } +} +"; + var (diagnostics, output) = GetGeneratedOutput(source); + Assert.Empty(diagnostics); + return Verifier.Verify(output).UseDirectory("Snapshots").ScrubLinesContaining("GeneratedCodeAttribute"); + } + [Fact] public Task GenerateMessagePackEquatableWithHashSetEqualityOnListAndArray() { diff --git a/test/Equatable.Generator.Tests/Snapshots/DataContractGeneratorTest.GenerateDataContractEquatableIgnoreEqualityOnDataMember.verified.txt b/test/Equatable.Generator.Tests/Snapshots/DataContractGeneratorTest.GenerateDataContractEquatableIgnoreEqualityOnDataMember.verified.txt new file mode 100644 index 0000000..d7fe908 --- /dev/null +++ b/test/Equatable.Generator.Tests/Snapshots/DataContractGeneratorTest.GenerateDataContractEquatableIgnoreEqualityOnDataMember.verified.txt @@ -0,0 +1,45 @@ +// +#nullable enable + +namespace Equatable.Entities +{ + partial class OrderDataContract : global::System.IEquatable + { + /// + public bool Equals(global::Equatable.Entities.OrderDataContract? other) + { + return !(other is null) + && Id == other.Id + && global::System.Collections.Generic.EqualityComparer.Default.Equals(Name, other.Name); + + } + + /// + public override bool Equals(object? obj) + { + return Equals(obj as global::Equatable.Entities.OrderDataContract); + } + + /// + public static bool operator ==(global::Equatable.Entities.OrderDataContract? left, global::Equatable.Entities.OrderDataContract? right) + { + return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); + } + + /// + public static bool operator !=(global::Equatable.Entities.OrderDataContract? left, global::Equatable.Entities.OrderDataContract? right) + { + return !(left == right); + } + + /// + public override int GetHashCode(){ + int hashCode = -1919740922; + hashCode = (hashCode * -1521134295) + Id.GetHashCode(); + hashCode = (hashCode * -1521134295) + global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(Name!); + return hashCode; + + } + + } +} diff --git a/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateMultiDimensionalArrayWithCustomElementComparer.verified.txt b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateMultiDimensionalArrayWithCustomElementComparer.verified.txt new file mode 100644 index 0000000..9548ec9 --- /dev/null +++ b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateMultiDimensionalArrayWithCustomElementComparer.verified.txt @@ -0,0 +1,45 @@ +// +#nullable enable + +namespace Equatable.Entities +{ + partial class Labels : global::System.IEquatable + { + /// + public bool Equals(global::Equatable.Entities.Labels? other) + { + return !(other is null) + && global::System.StringComparer.OrdinalIgnoreCase.Equals(Grid, other.Grid) + && Id == other.Id; + + } + + /// + public override bool Equals(object? obj) + { + return Equals(obj as global::Equatable.Entities.Labels); + } + + /// + public static bool operator ==(global::Equatable.Entities.Labels? left, global::Equatable.Entities.Labels? right) + { + return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); + } + + /// + public static bool operator !=(global::Equatable.Entities.Labels? left, global::Equatable.Entities.Labels? right) + { + return !(left == right); + } + + /// + public override int GetHashCode(){ + int hashCode = -1719537575; + hashCode = (hashCode * -1521134295) + global::System.StringComparer.OrdinalIgnoreCase.GetHashCode(Grid!); + hashCode = (hashCode * -1521134295) + Id.GetHashCode(); + return hashCode; + + } + + } +} diff --git a/test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatableIgnoreEqualityOnKey.verified.txt b/test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatableIgnoreEqualityOnKey.verified.txt new file mode 100644 index 0000000..abdec10 --- /dev/null +++ b/test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatableIgnoreEqualityOnKey.verified.txt @@ -0,0 +1,45 @@ +// +#nullable enable + +namespace Equatable.Entities +{ + partial class PricingContract : global::System.IEquatable + { + /// + public bool Equals(global::Equatable.Entities.PricingContract? other) + { + return !(other is null) + && MarketId == other.MarketId + && global::System.Collections.Generic.EqualityComparer.Default.Equals(Name, other.Name); + + } + + /// + public override bool Equals(object? obj) + { + return Equals(obj as global::Equatable.Entities.PricingContract); + } + + /// + public static bool operator ==(global::Equatable.Entities.PricingContract? left, global::Equatable.Entities.PricingContract? right) + { + return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); + } + + /// + public static bool operator !=(global::Equatable.Entities.PricingContract? left, global::Equatable.Entities.PricingContract? right) + { + return !(left == right); + } + + /// + public override int GetHashCode(){ + int hashCode = -2025916246; + hashCode = (hashCode * -1521134295) + MarketId.GetHashCode(); + hashCode = (hashCode * -1521134295) + global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(Name!); + return hashCode; + + } + + } +} From b8f78f524cb3b06dde3830112652bfb5cad15ab3 Mon Sep 17 00:00:00 2001 From: "a.kasyanov" Date: Thu, 14 May 2026 20:26:50 +0300 Subject: [PATCH 64/71] fix: correct OrderedDictionaryHashCode inline helper to sort before hashing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The inline OrderedDictionaryHashCode helper in EquatableWriter iterated entries without OrderBy, while OrderedDictionaryEquals sorted by key. An equal pair with different insertion order would produce the same Equals result but different hash codes — a direct hash contract violation. Also adds missing multi-entry swapped-values tests for DictionaryEqualityComparer: - SwappedValues_UnequalDictionaries_EqualIsFalse — contract test (always passes) - SwappedValues_ProduceDifferentHashInPractice — regression guard for systematic collisions in the commutative sum hash Note: the inline helper is currently dead code (the generator always emits an explicit OrderedReadOnlyDictionaryEqualityComparer expression for ordered dicts). The fix prevents confusion and is correct if the path is ever reached. Co-Authored-By: Claude Sonnet 4.5 --- .../EquatableWriter.cs | 2 +- .../DictionaryHashCodeTest.cs | 50 +++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/src/Equatable.SourceGenerator/EquatableWriter.cs b/src/Equatable.SourceGenerator/EquatableWriter.cs index 899d725..682b80f 100644 --- a/src/Equatable.SourceGenerator/EquatableWriter.cs +++ b/src/Equatable.SourceGenerator/EquatableWriter.cs @@ -611,7 +611,7 @@ private static void GenerateHashCodeFunctions(IndentedStringBuilder codeBuilder, .AppendLine() .AppendLine("var hashCode = new global::System.HashCode();") .AppendLine() - .AppendLine("foreach (var item in items)") + .AppendLine("foreach (var item in global::System.Linq.Enumerable.OrderBy(items, p => p.Key))") .AppendLine("{") .IncrementIndent() .AppendLine("hashCode.Add(global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(item.Key!));") diff --git a/test/Equatable.Generator.Tests/DictionaryHashCodeTest.cs b/test/Equatable.Generator.Tests/DictionaryHashCodeTest.cs index 7f4355d..0ad4602 100644 --- a/test/Equatable.Generator.Tests/DictionaryHashCodeTest.cs +++ b/test/Equatable.Generator.Tests/DictionaryHashCodeTest.cs @@ -74,4 +74,54 @@ public void EqualDictionaries_HaveSameHashCode() Assert.True(DictComparer.Equals(first, second)); Assert.Equal(DictComparer.GetHashCode(first), DictComparer.GetHashCode(second)); } + + /// + /// The critical multi-entry case: same keys, values swapped across keys. + /// {a→1, b→2} != {a→2, b→1} — Equals correctly returns false (TryGetValue finds "a"→2≠1). + /// The user's concern: can sum(Combine(k,v)) produce the same value for both? + /// + /// Commutative sum does NOT guarantee no collision here — it only guarantees the contract: + /// Equals(x,y) → GetHashCode(x) == GetHashCode(y) + /// The contract only runs one direction. Unequal dicts MAY share a hash code (collision). + /// + /// This test verifies: + /// 1. Equals returns false (correctness — always guaranteed by TryGetValue logic) + /// 2. Hash codes differ in practice for this common pattern (collision absent here) + /// + /// If this test ever fails on (2), the fix is NOT in Equals (already correct) but in + /// accepting the collision as a legitimate hash table trade-off — the contract is not violated. + /// + [Fact] + public void HashCode_SwappedValues_UnequalDictionaries_EqualIsFalse() + { + // {a→1, b→2} vs {a→2, b→1}: same key set, values assigned to different keys + var first = new Dictionary { ["a"] = 1, ["b"] = 2 }; + var second = new Dictionary { ["a"] = 2, ["b"] = 1 }; + + // Equals must always be false — TryGetValue("a") finds 2≠1 + Assert.False(DictComparer.Equals(first, second)); + Assert.False(DictComparer.Equals(second, first)); + } + + [Fact] + public void HashCode_SwappedValues_ProduceDifferentHashInPractice() + { + // Verifies no systematic collision for the swapped-values pattern. + // Note: hash collisions are theoretically allowed; this test catches regressions + // in the hash function that make them routine. + var first = new Dictionary { ["a"] = 1, ["b"] = 2 }; + var second = new Dictionary { ["a"] = 2, ["b"] = 1 }; + + Assert.NotEqual(DictComparer.GetHashCode(first), DictComparer.GetHashCode(second)); + } + + [Fact] + public void ReadOnly_HashCode_SwappedValues_ProduceDifferentHashInPractice() + { + IReadOnlyDictionary first = new Dictionary { ["x"] = 10, ["y"] = 20 }; + IReadOnlyDictionary second = new Dictionary { ["x"] = 20, ["y"] = 10 }; + + Assert.False(ReadOnlyComparer.Equals(first, second)); + Assert.NotEqual(ReadOnlyComparer.GetHashCode(first), ReadOnlyComparer.GetHashCode(second)); + } } From 8eb00d6398c39a5a0d6867aa8c60014cd513efb3 Mon Sep 17 00:00:00 2001 From: "a.kasyanov" Date: Thu, 14 May 2026 20:33:30 +0300 Subject: [PATCH 65/71] refactor: remove dead OrderedDictionary inline helpers from EquatableWriter OrderedDictionaryEquals and OrderedDictionaryHashCode were never reachable: the generator always emits an explicit OrderedReadOnlyDictionaryEqualityComparer expression for ordered-dictionary properties, so ComparerTypes.OrderedDictionary is never left on the property model when the writer is called. Dead code removed; no behaviour change. Co-Authored-By: Claude Sonnet 4.5 --- .../EquatableWriter.cs | 62 ------------------- 1 file changed, 62 deletions(-) diff --git a/src/Equatable.SourceGenerator/EquatableWriter.cs b/src/Equatable.SourceGenerator/EquatableWriter.cs index 682b80f..ee41e73 100644 --- a/src/Equatable.SourceGenerator/EquatableWriter.cs +++ b/src/Equatable.SourceGenerator/EquatableWriter.cs @@ -136,15 +136,6 @@ private static void GenerateEquatable(IndentedStringBuilder codeBuilder, Equatab .Append(entityProperty.PropertyName) .Append(")"); break; - case ComparerTypes.OrderedDictionary: - codeBuilder - .Append(" OrderedDictionaryEquals(") - .Append(entityProperty.PropertyName) - .Append(", other.") - .Append(entityProperty.PropertyName) - .Append(")"); - - break; case ComparerTypes.HashSet: codeBuilder .Append(" HashSetEquals(") @@ -308,27 +299,6 @@ private static void GenerateEquatableFunctions(IndentedStringBuilder codeBuilder .AppendLine(); } - if (entityClass.Properties.Any(p => p.ComparerType == ComparerTypes.OrderedDictionary)) - { - codeBuilder - .AppendLine("static bool OrderedDictionaryEquals(global::System.Collections.Generic.IEnumerable>? left, global::System.Collections.Generic.IEnumerable>? right)") - .AppendLine("{") - .IncrementIndent() - .AppendLine("if (global::System.Object.ReferenceEquals(left, right))") - .AppendLine(" return true;") - .AppendLine() - .AppendLine("if (left is null || right is null)") - .AppendLine(" return false;") - .AppendLine() - .AppendLine("return global::System.Linq.Enumerable.SequenceEqual(") - .AppendLine(" global::System.Linq.Enumerable.OrderBy(left, p => p.Key),") - .AppendLine(" global::System.Linq.Enumerable.OrderBy(right, p => p.Key),") - .AppendLine(" global::System.Collections.Generic.EqualityComparer>.Default);") - .DecrementIndent() - .AppendLine("}") - .AppendLine(); - } - if (entityClass.Properties.Any(p => p.ComparerType == ComparerTypes.HashSet)) { codeBuilder @@ -488,13 +458,6 @@ private static void GenerateHashCode(IndentedStringBuilder codeBuilder, Equatabl .Append(entityProperty.PropertyName) .AppendLine(");"); break; - case ComparerTypes.OrderedDictionary: - codeBuilder - .Append("hashCode = (hashCode * -1521134295) + ") - .Append("OrderedDictionaryHashCode(") - .Append(entityProperty.PropertyName) - .AppendLine(");"); - break; case ComparerTypes.HashSet: codeBuilder .Append("hashCode = (hashCode * -1521134295) + ") @@ -600,31 +563,6 @@ private static void GenerateHashCodeFunctions(IndentedStringBuilder codeBuilder, .AppendLine(); } - if (entityClass.Properties.Any(p => p.ComparerType == ComparerTypes.OrderedDictionary)) - { - codeBuilder - .AppendLine("static int OrderedDictionaryHashCode(global::System.Collections.Generic.IEnumerable>? items)") - .AppendLine("{") - .IncrementIndent() - .AppendLine("if (items is null)") - .AppendLine(" return 0;") - .AppendLine() - .AppendLine("var hashCode = new global::System.HashCode();") - .AppendLine() - .AppendLine("foreach (var item in global::System.Linq.Enumerable.OrderBy(items, p => p.Key))") - .AppendLine("{") - .IncrementIndent() - .AppendLine("hashCode.Add(global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(item.Key!));") - .AppendLine("hashCode.Add(global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(item.Value!));") - .DecrementIndent() - .AppendLine("}") - .AppendLine() - .AppendLine("return hashCode.ToHashCode();") - .DecrementIndent() - .AppendLine("}") - .AppendLine(); - } - if (entityClass.Properties.Any(p => p.ComparerType == ComparerTypes.HashSet)) { codeBuilder From 59fe9ae886511e4988be7023ebfdda0380c3b1f3 Mon Sep 17 00:00:00 2001 From: "a.kasyanov" Date: Thu, 14 May 2026 20:45:05 +0300 Subject: [PATCH 66/71] =?UTF-8?q?docs:=20clarify=20DictionaryEquality=20vs?= =?UTF-8?q?=20sequential=20=E2=80=94=20identical=20semantics,=20different?= =?UTF-8?q?=20algorithm?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Explains that [DictionaryEquality(sequential: true)] produces the same equality result as plain [DictionaryEquality] for all inputs. The O(n log n) cost is only justified when a custom keyComparer implements both IEqualityComparer and IComparer and you need deterministic sort order for snapshot testing. Co-Authored-By: Claude Sonnet 4.5 --- README.md | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index c8e9e1f..211898c 100644 --- a/README.md +++ b/README.md @@ -336,7 +336,28 @@ public Dictionary? Prices { get; set; } // DictionaryEquality b ### `[DictionaryEquality(sequential: true)]` — key-sorted comparison -Both sides are sorted by key before comparison. Insertion order is irrelevant, and the result is deterministic — useful for snapshot testing and diagnostic logging. +Both sides are sorted by key before comparison. The equality result is **identical** to plain `[DictionaryEquality]` for every possible input — `{a:1, b:2}` equals `{b:2, a:1}` under both. The difference is purely in the algorithm and its consequences: + +| | `[DictionaryEquality]` | `[DictionaryEquality(sequential: true)]` | +|---|---|---| +| Algorithm | `TryGetValue` per entry | `OrderBy(key)` then `SequenceEqual` | +| Cost | O(n) average | O(n log n) | +| Key type requirement | needs `IEqualityComparer` | needs `IComparer` for sort order | + +**When `sequential: true` is worth the cost:** + +The main practical reason is a custom `keyComparer` that implements **both** `IEqualityComparer` and `IComparer`. Plain `[DictionaryEquality]` only uses the equality side (`TryGetValue`). With `sequential: true`, the same comparer also drives the sort order — giving you consistent, deterministic iteration for snapshot testing and diagnostic output, with the sort order defined by your comparer rather than insertion order or `GetHashCode` randomness. + +Example: a locale-aware config dictionary where keys must sort predictably for snapshot tests: + +```csharp +// Keys sorted by StringComparer.Ordinal for deterministic snapshot output. +// StringComparer implements both IEqualityComparer and IComparer. +[DictionaryEquality(sequential: true)] +public Dictionary? LocaleOverrides { get; set; } +``` + +For plain `Dictionary` with default comparers, prefer `[DictionaryEquality]` — the O(n) `TryGetValue` path is faster and produces the same result. **Supported types:** same as `[DictionaryEquality]` — any `IReadOnlyDictionary`. From 4ab42a031c8a0903cdd73f7667303e41985f1a96 Mon Sep 17 00:00:00 2001 From: "a.kasyanov" Date: Thu, 14 May 2026 20:49:56 +0300 Subject: [PATCH 67/71] docs+test: demonstrate custom IEqualityComparer+IComparer with sequential dictionary equality README: concrete example showing StringComparer.OrdinalIgnoreCase driving both key equality and sort order in OrderedDictionaryEqualityComparer, with a note that the dictionary's own internal comparer is not visible to generated code. Tests: four cases covering case-insensitive equality, insertion-order-independent hash with custom comparer, case-variant hash equivalence, and the contrast with the default ordinal comparer where case variants are distinct keys. Co-Authored-By: Claude Sonnet 4.5 --- README.md | 38 +++++++++++--- .../OrderedDictionaryEqualityComparerTest.cs | 52 +++++++++++++++++++ 2 files changed, 84 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 211898c..e6988b4 100644 --- a/README.md +++ b/README.md @@ -346,17 +346,43 @@ Both sides are sorted by key before comparison. The equality result is **identic **When `sequential: true` is worth the cost:** -The main practical reason is a custom `keyComparer` that implements **both** `IEqualityComparer` and `IComparer`. Plain `[DictionaryEquality]` only uses the equality side (`TryGetValue`). With `sequential: true`, the same comparer also drives the sort order — giving you consistent, deterministic iteration for snapshot testing and diagnostic output, with the sort order defined by your comparer rather than insertion order or `GetHashCode` randomness. +The main practical reason is a custom `keyComparer` that implements **both** `IEqualityComparer` and `IComparer`. Plain `[DictionaryEquality]` only uses the equality side (`TryGetValue`). With `sequential: true`, the same comparer drives **both** equality and sort order. -Example: a locale-aware config dictionary where keys must sort predictably for snapshot tests: +`StringComparer` is the canonical example — it implements both interfaces. Using `StringComparer.OrdinalIgnoreCase` as a key comparer means `"region"` and `"REGION"` are the same key, and keys sort in a predictable case-insensitive order: ```csharp -// Keys sorted by StringComparer.Ordinal for deterministic snapshot output. -// StringComparer implements both IEqualityComparer and IComparer. -[DictionaryEquality(sequential: true)] -public Dictionary? LocaleOverrides { get; set; } +// Built with OrdinalIgnoreCase so "region" and "REGION" are the same key. +var scores = new Dictionary(StringComparer.OrdinalIgnoreCase) +{ + ["West"] = 42, + ["east"] = 17, + ["NORTH"] = 99, +}; ``` +With plain `[DictionaryEquality]`, `TryGetValue` uses the dictionary's own `OrdinalIgnoreCase` comparer for lookup — equality is correct. But `GetHashCode` iterates in arbitrary insertion order, so two equal dictionaries built in different insertion order may produce different hash codes. + +With `[DictionaryEquality(sequential: true)]`, `OrderedDictionaryEqualityComparer` receives `StringComparer.OrdinalIgnoreCase` as its `keyComparer`. Because `StringComparer` also implements `IComparer`, it drives the sort in both `Equals` and `GetHashCode` — the iteration order is always `east → NORTH → West` (case-insensitive ordinal), regardless of insertion order: + +```csharp +[Equatable] +public partial class RegionScores +{ + // Sequential so the OrdinalIgnoreCase comparer controls sort order in GetHashCode, + // making the hash insertion-order independent even with a custom key comparer. + [DictionaryEquality(sequential: true)] + public Dictionary? Scores { get; set; } +} + +var a = new RegionScores { Scores = new(StringComparer.OrdinalIgnoreCase) { ["West"] = 42, ["east"] = 17 } }; +var b = new RegionScores { Scores = new(StringComparer.OrdinalIgnoreCase) { ["east"] = 17, ["West"] = 42 } }; + +a.Equals(b); // true — same key/value pairs +a.GetHashCode() == b.GetHashCode(); // true — sort order is deterministic via OrdinalIgnoreCase +``` + +> **Note:** the comparer passed to `OrderedDictionaryEqualityComparer` must be the one you supply via `[EqualityComparer]` or the generator's inferred default. The dictionary's own internal comparer (set at construction time via `new Dictionary(myComparer)`) is not visible to the generated equality code — the generated code uses `EqualityComparer.Default` unless you override it explicitly. + For plain `Dictionary` with default comparers, prefer `[DictionaryEquality]` — the O(n) `TryGetValue` path is faster and produces the same result. **Supported types:** same as `[DictionaryEquality]` — any `IReadOnlyDictionary`. diff --git a/test/Equatable.Generator.Tests/Comparers/OrderedDictionaryEqualityComparerTest.cs b/test/Equatable.Generator.Tests/Comparers/OrderedDictionaryEqualityComparerTest.cs index 413f722..daa558e 100644 --- a/test/Equatable.Generator.Tests/Comparers/OrderedDictionaryEqualityComparerTest.cs +++ b/test/Equatable.Generator.Tests/Comparers/OrderedDictionaryEqualityComparerTest.cs @@ -79,6 +79,58 @@ public void Nested_OrderedOuter_UnorderedInner_InnerInsertionOrderDiffers_Return } + // ── custom IEqualityComparer+IComparer: StringComparer.OrdinalIgnoreCase ────────────────────── + // Demonstrates why sequential: true exists. StringComparer implements both interfaces, so + // the same comparer drives key equality AND sort order — hash is insertion-order independent + // even when the dictionary uses a non-default key comparer. + + private static OrderedDictionaryEqualityComparer CaseInsensitive() + => new(StringComparer.OrdinalIgnoreCase, EqualityComparer.Default); + + [Fact] + public void CustomComparer_CaseInsensitiveKeys_Equal() + { + // "West" and "WEST" are the same key under OrdinalIgnoreCase + var a = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["West"] = 42, ["east"] = 17 }; + var b = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["WEST"] = 42, ["EAST"] = 17 }; + + Assert.True(CaseInsensitive().Equals(a, b)); + } + + [Fact] + public void CustomComparer_DifferentInsertionOrder_SameHashCode() + { + // Same pairs, different insertion order — hash must match because OrdinalIgnoreCase + // drives both equality and sort order, making the result fully deterministic. + var a = new Dictionary { ["West"] = 42, ["east"] = 17, ["NORTH"] = 99 }; + var b = new Dictionary { ["NORTH"] = 99, ["West"] = 42, ["east"] = 17 }; + + Assert.Equal(CaseInsensitive().GetHashCode(a), CaseInsensitive().GetHashCode(b)); + } + + [Fact] + public void CustomComparer_GetHashCode_CaseVariantsProduceSameHash() + { + // "West"→42 and "WEST"→42 are the same entry under OrdinalIgnoreCase — same hash + var a = new Dictionary { ["West"] = 42 }; + var b = new Dictionary { ["WEST"] = 42 }; + + Assert.Equal(CaseInsensitive().GetHashCode(a), CaseInsensitive().GetHashCode(b)); + } + + [Fact] + public void DefaultComparer_CaseVariants_NotEqual() + { + // Contrast: with default (ordinal) comparer, "West" != "WEST" — different keys + var a = new Dictionary { ["West"] = 42 }; + var b = new Dictionary { ["WEST"] = 42 }; + + Assert.False(Comparer.Equals(a, b)); + Assert.NotEqual(Comparer.GetHashCode(a), Comparer.GetHashCode(b)); + } + + // ──────────────────────────────────────────────────────────────────────────────────────────── + private static readonly OrderedDictionaryEqualityComparer Comparer = OrderedDictionaryEqualityComparer.Default; From cee3fc033ed3e5ce040758100b24f05103221001 Mon Sep 17 00:00:00 2001 From: "a.kasyanov" Date: Thu, 14 May 2026 20:53:53 +0300 Subject: [PATCH 68/71] =?UTF-8?q?docs:=20generalise=20custom-comparer=20no?= =?UTF-8?q?te=20=E2=80=94=20applies=20to=20any=20domain=20rule,=20not=20ju?= =?UTF-8?q?st=20case?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.5 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e6988b4..bac14cd 100644 --- a/README.md +++ b/README.md @@ -381,7 +381,7 @@ a.Equals(b); // true — same key/value pairs a.GetHashCode() == b.GetHashCode(); // true — sort order is deterministic via OrdinalIgnoreCase ``` -> **Note:** the comparer passed to `OrderedDictionaryEqualityComparer` must be the one you supply via `[EqualityComparer]` or the generator's inferred default. The dictionary's own internal comparer (set at construction time via `new Dictionary(myComparer)`) is not visible to the generated equality code — the generated code uses `EqualityComparer.Default` unless you override it explicitly. +> **Note:** the comparer used by the generated code is `EqualityComparer.Default` unless you override it explicitly via `[EqualityComparer]`. The dictionary's own internal comparer (set at construction time via `new Dictionary(myComparer)`) is not visible to the generated equality code. This applies to any custom key comparer — case-insensitive strings are just one example; the same is true for any domain-specific comparison rule (version strings, culture-aware text, normalised identifiers, etc.). For plain `Dictionary` with default comparers, prefer `[DictionaryEquality]` — the O(n) `TryGetValue` path is faster and produces the same result. From 3c801a71ec80b375b968990843c90a3a2a675777 Mon Sep 17 00:00:00 2001 From: "a.kasyanov" Date: Thu, 14 May 2026 21:06:14 +0300 Subject: [PATCH 69/71] fix: DictionaryEqualityComparer.Equals must use KeyComparer, not dict's internal comparer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit y.TryGetValue uses the dictionary's own internal comparer, not this.KeyComparer. When a custom keyComparer is supplied, GetHashCode already uses it — so two entries equal under KeyComparer produced the same hash but Equals returned false, a direct hash contract violation. Fix: build a temporary Dictionary keyed by KeyComparer, then TryGetValue against that. O(n) cost preserved. Same fix applied to ReadOnlyDictionaryEqualityComparer. Property tests added to DictionaryComparerProperties and ReadOnlyDictionaryComparerProperties to permanently guard the invariant: for any custom keyComparer, Equals(x,y) implies GetHashCode(x) == GetHashCode(y). Co-Authored-By: Claude Sonnet 4.5 --- .../DictionaryEqualityComparer.cs | 7 ++++- .../ReadOnlyDictionaryEqualityComparer.cs | 7 ++++- .../DictionaryEqualityComparerTest.cs | 31 +++++++++++++++++++ .../DictionaryComparerProperties.cs | 23 ++++++++++++++ .../ReadOnlyDictionaryComparerProperties.cs | 18 +++++++++++ 5 files changed, 84 insertions(+), 2 deletions(-) diff --git a/src/Equatable.Comparers/DictionaryEqualityComparer.cs b/src/Equatable.Comparers/DictionaryEqualityComparer.cs index 07398b8..10aa8ed 100644 --- a/src/Equatable.Comparers/DictionaryEqualityComparer.cs +++ b/src/Equatable.Comparers/DictionaryEqualityComparer.cs @@ -53,9 +53,14 @@ public bool Equals(IDictionary? x, IDictionary? 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(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)) diff --git a/src/Equatable.Comparers/ReadOnlyDictionaryEqualityComparer.cs b/src/Equatable.Comparers/ReadOnlyDictionaryEqualityComparer.cs index fb5ec7c..7ea05bc 100644 --- a/src/Equatable.Comparers/ReadOnlyDictionaryEqualityComparer.cs +++ b/src/Equatable.Comparers/ReadOnlyDictionaryEqualityComparer.cs @@ -53,9 +53,14 @@ public bool Equals(IReadOnlyDictionary? x, IReadOnlyDictionary(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)) diff --git a/test/Equatable.Generator.Tests/Comparers/DictionaryEqualityComparerTest.cs b/test/Equatable.Generator.Tests/Comparers/DictionaryEqualityComparerTest.cs index c179262..efada87 100644 --- a/test/Equatable.Generator.Tests/Comparers/DictionaryEqualityComparerTest.cs +++ b/test/Equatable.Generator.Tests/Comparers/DictionaryEqualityComparerTest.cs @@ -170,4 +170,35 @@ public void GetHashCodeSameDifferentOrder() Assert.Equal(bHash, aHash); } + + [Fact] + public void ReadOnly_CustomKeyComparer_Equals_UsesKeyComparer_NotDictionaryInternalComparer() + { + IReadOnlyDictionary a = new Dictionary { ["West"] = 42 }; + IReadOnlyDictionary b = new Dictionary { ["WEST"] = 42 }; + + var cmp = new ReadOnlyDictionaryEqualityComparer( + StringComparer.OrdinalIgnoreCase, EqualityComparer.Default); + + Assert.Equal(cmp.GetHashCode(a), cmp.GetHashCode(b)); + Assert.True(cmp.Equals(a, b)); + } + + [Fact] + public void CustomKeyComparer_Equals_UsesKeyComparer_NotDictionaryInternalComparer() + { + // Dicts built with DEFAULT (ordinal, case-sensitive) comparer. + // The DictionaryEqualityComparer is given OrdinalIgnoreCase as keyComparer. + // Equals must use KeyComparer for lookup — not y's own internal comparer — + // otherwise "West" and "WEST" would be treated as different keys, violating + // the hash contract (GetHashCode already treats them as equal via OrdinalIgnoreCase). + var a = new Dictionary { ["West"] = 42 }; + var b = new Dictionary { ["WEST"] = 42 }; + + var cmp = new DictionaryEqualityComparer( + StringComparer.OrdinalIgnoreCase, EqualityComparer.Default); + + Assert.Equal(cmp.GetHashCode(a), cmp.GetHashCode(b)); // same hash + Assert.True(cmp.Equals(a, b)); // equal — contract holds + } } diff --git a/test/Equatable.Generator.Tests/Properties/DictionaryComparerProperties.cs b/test/Equatable.Generator.Tests/Properties/DictionaryComparerProperties.cs index 4751944..352b7b5 100644 --- a/test/Equatable.Generator.Tests/Properties/DictionaryComparerProperties.cs +++ b/test/Equatable.Generator.Tests/Properties/DictionaryComparerProperties.cs @@ -6,6 +6,29 @@ public class DictionaryComparerProperties { private static readonly DictionaryEqualityComparer Comparer = DictionaryEqualityComparer.Default; + // Hash contract with custom keyComparer: Equals(x,y) → GetHashCode(x) == GetHashCode(y). + // This catches the bug where Equals used y.TryGetValue (dict's internal comparer) while + // GetHashCode used KeyComparer — the two could disagree, violating the contract. + private static readonly DictionaryEqualityComparer CaseInsensitiveComparer = + new(StringComparer.OrdinalIgnoreCase, EqualityComparer.Default); + + [Property] + public Property CustomKeyComparer_HashContract_EqualImpliesSameHash(Dictionary dict) + { + // Build a copy with keys mapped to their upper-case equivalents — equal under OrdinalIgnoreCase + var upper = new Dictionary(); + foreach (var pair in dict) + { + var upperKey = pair.Key.ToUpperInvariant(); + upper[upperKey] = pair.Value; // last writer wins if two keys collide under upper-case + } + + // Only assert the contract when Equals says they are equal + return Prop.When( + CaseInsensitiveComparer.Equals(dict, upper), + CaseInsensitiveComparer.GetHashCode(dict) == CaseInsensitiveComparer.GetHashCode(upper)); + } + [Property] public Property Equals_Reflexivity_SameInstance_ReturnsTrue(Dictionary dict) { diff --git a/test/Equatable.Generator.Tests/Properties/ReadOnlyDictionaryComparerProperties.cs b/test/Equatable.Generator.Tests/Properties/ReadOnlyDictionaryComparerProperties.cs index b72a429..330977f 100644 --- a/test/Equatable.Generator.Tests/Properties/ReadOnlyDictionaryComparerProperties.cs +++ b/test/Equatable.Generator.Tests/Properties/ReadOnlyDictionaryComparerProperties.cs @@ -6,6 +6,24 @@ public class ReadOnlyDictionaryComparerProperties { private static readonly ReadOnlyDictionaryEqualityComparer Comparer = ReadOnlyDictionaryEqualityComparer.Default; + private static readonly ReadOnlyDictionaryEqualityComparer CaseInsensitiveComparer = + new(StringComparer.OrdinalIgnoreCase, EqualityComparer.Default); + + [Property] + public Property CustomKeyComparer_HashContract_EqualImpliesSameHash(Dictionary dict) + { + var upper = new Dictionary(); + foreach (var pair in dict) + upper[pair.Key.ToUpperInvariant()] = pair.Value; + + IReadOnlyDictionary a = dict; + IReadOnlyDictionary b = upper; + + return Prop.When( + CaseInsensitiveComparer.Equals(a, b), + CaseInsensitiveComparer.GetHashCode(a) == CaseInsensitiveComparer.GetHashCode(b)); + } + [Property] public Property Equals_Reflexivity_SameInstance_ReturnsTrue(Dictionary dict) { From a14351c866c3029f40d694c5eb2bfad355cebfce Mon Sep 17 00:00:00 2001 From: "a.kasyanov" Date: Thu, 14 May 2026 21:06:36 +0300 Subject: [PATCH 70/71] =?UTF-8?q?docs:=20update=20sequential=20note=20?= =?UTF-8?q?=E2=80=94=20both=20comparers=20now=20handle=20custom=20keyCompa?= =?UTF-8?q?rer=20correctly?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.5 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index bac14cd..fd2f6f5 100644 --- a/README.md +++ b/README.md @@ -383,7 +383,7 @@ a.GetHashCode() == b.GetHashCode(); // true — sort order is deterministic vi > **Note:** the comparer used by the generated code is `EqualityComparer.Default` unless you override it explicitly via `[EqualityComparer]`. The dictionary's own internal comparer (set at construction time via `new Dictionary(myComparer)`) is not visible to the generated equality code. This applies to any custom key comparer — case-insensitive strings are just one example; the same is true for any domain-specific comparison rule (version strings, culture-aware text, normalised identifiers, etc.). -For plain `Dictionary` with default comparers, prefer `[DictionaryEquality]` — the O(n) `TryGetValue` path is faster and produces the same result. +Both `[DictionaryEquality]` and `[DictionaryEquality(sequential: true)]` honour the supplied `keyComparer` correctly in both `Equals` and `GetHashCode`. Prefer `[DictionaryEquality]` — it is O(n) vs O(n log n) for `sequential: true`. Use `sequential: true` only when you need a deterministic, sorted iteration order for snapshot testing or diagnostic output. **Supported types:** same as `[DictionaryEquality]` — any `IReadOnlyDictionary`. From d0de340dfb0fb12fc19b41ede0d7298346d58323 Mon Sep 17 00:00:00 2001 From: "a.kasyanov" Date: Thu, 14 May 2026 21:26:55 +0300 Subject: [PATCH 71/71] =?UTF-8?q?refactor:=20remove=20[DictionaryEquality(?= =?UTF-8?q?sequential:=20true)]=20=E2=80=94=20never=20shipped=20to=20prod?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops OrderedDictionaryEqualityComparer, OrderedReadOnlyDictionaryEqualityComparer, the sequential: true parameter on DictionaryEqualityAttribute, all generator code paths that routed to ComparerTypes.OrderedDictionary, and all related tests, snapshot files, and README documentation. Co-Authored-By: Claude Sonnet 4.5 --- README.md | 81 ------- .../OrderedDictionaryEqualityComparer.cs | 88 ------- ...deredReadOnlyDictionaryEqualityComparer.cs | 88 ------- .../Attributes/DictionaryEqualityAttribute.cs | 14 +- .../EquatableGenerator.cs | 43 +--- .../Models/ComparerTypes.cs | 1 - .../SequentialDictionary.cs | 15 -- .../Comparers/ComparerGetHashCodeTest.cs | 39 --- .../OrderedDictionaryEqualityComparerTest.cs | 226 ------------------ .../DataContractGeneratorTest.cs | 51 +--- .../Entities/SequentialDictionaryTest.cs | 75 ------ .../EquatableGeneratorTest.cs | 64 +---- .../MessagePackGeneratorTest.cs | 51 +--- .../OrderedDictionaryComparerProperties.cs | 50 ---- ...WithOrderedDictionaryOverride.verified.txt | 45 ---- ...WithOrderedDictionaryOverride.verified.txt | 45 ---- ...ithDictionaryEqualityOverride.verified.txt | 105 ++++++++ ...DictionaryEqualityPropagation.verified.txt | 12 +- ...WithOrderedDictionaryOverride.verified.txt | 45 ---- ...quentialNestedDictPropagation.verified.txt | 45 ---- ...WithOrderedDictionaryOverride.verified.txt | 45 ---- ...pagatesIntoNestedDictionaries.verified.txt | 8 +- ...WithOrderedDictionaryOverride.verified.txt | 45 ---- ...eSequentialDictionaryEquality.verified.txt | 45 ---- ...DictPropagatesOrderedComparer.verified.txt | 43 ---- ...ithDictionaryEqualityOverride.verified.txt | 105 ++++++++ ...DictionaryEqualityPropagation.verified.txt | 12 +- ...WithOrderedDictionaryOverride.verified.txt | 45 ---- ...quentialNestedDictPropagation.verified.txt | 45 ---- 29 files changed, 236 insertions(+), 1340 deletions(-) delete mode 100644 src/Equatable.Comparers/OrderedDictionaryEqualityComparer.cs delete mode 100644 src/Equatable.Comparers/OrderedReadOnlyDictionaryEqualityComparer.cs delete mode 100644 test/Equatable.Entities/SequentialDictionary.cs delete mode 100644 test/Equatable.Generator.Tests/Comparers/OrderedDictionaryEqualityComparerTest.cs delete mode 100644 test/Equatable.Generator.Tests/Entities/SequentialDictionaryTest.cs delete mode 100644 test/Equatable.Generator.Tests/Properties/OrderedDictionaryComparerProperties.cs delete mode 100644 test/Equatable.Generator.Tests/Snapshots/AdapterGeneratorTest.GenerateDataContractEquatableWithOrderedDictionaryOverride.verified.txt delete mode 100644 test/Equatable.Generator.Tests/Snapshots/AdapterGeneratorTest.GenerateMessagePackEquatableWithOrderedDictionaryOverride.verified.txt create mode 100644 test/Equatable.Generator.Tests/Snapshots/DataContractGeneratorTest.GenerateDataContractEquatableWithDictionaryEqualityOverride.verified.txt delete mode 100644 test/Equatable.Generator.Tests/Snapshots/DataContractGeneratorTest.GenerateDataContractEquatableWithOrderedDictionaryOverride.verified.txt delete mode 100644 test/Equatable.Generator.Tests/Snapshots/DataContractGeneratorTest.GenerateDataContractEquatableWithSequentialNestedDictPropagation.verified.txt delete mode 100644 test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateDataContractEquatableWithOrderedDictionaryOverride.verified.txt delete mode 100644 test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateMessagePackEquatableWithOrderedDictionaryOverride.verified.txt delete mode 100644 test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateSequentialDictionaryEquality.verified.txt delete mode 100644 test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateSequentialDictionaryEquality_NestedDictPropagatesOrderedComparer.verified.txt create mode 100644 test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatableWithDictionaryEqualityOverride.verified.txt delete mode 100644 test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatableWithOrderedDictionaryOverride.verified.txt delete mode 100644 test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatableWithSequentialNestedDictPropagation.verified.txt diff --git a/README.md b/README.md index fd2f6f5..d92fdf3 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,6 @@ These attributes live in `Equatable.Attributes` and control how each property is | `[SequenceEquality]` | `SequenceEqualityComparer` — order-sensitive element comparison | `List`, `T[]`, `T[,]`, `T[,,]` | | `[HashSetEquality]` | `HashSetEqualityComparer` — order-insensitive element comparison | `HashSet` | | `[DictionaryEquality]` | `DictionaryEqualityComparer` — key-value equality, insertion order irrelevant | `Dictionary` | -| `[DictionaryEquality(sequential:true)]` | `OrderedDictionaryEqualityComparer` — key-sorted comparison | — | ### `Equatable.Generator.DataContract` — class-level trigger @@ -334,64 +333,6 @@ public Dictionary? Prices { get; set; } // DictionaryEquality b `{a:1.85, b:1.90}` equals `{b:1.90, a:1.85}` ✓ -### `[DictionaryEquality(sequential: true)]` — key-sorted comparison - -Both sides are sorted by key before comparison. The equality result is **identical** to plain `[DictionaryEquality]` for every possible input — `{a:1, b:2}` equals `{b:2, a:1}` under both. The difference is purely in the algorithm and its consequences: - -| | `[DictionaryEquality]` | `[DictionaryEquality(sequential: true)]` | -|---|---|---| -| Algorithm | `TryGetValue` per entry | `OrderBy(key)` then `SequenceEqual` | -| Cost | O(n) average | O(n log n) | -| Key type requirement | needs `IEqualityComparer` | needs `IComparer` for sort order | - -**When `sequential: true` is worth the cost:** - -The main practical reason is a custom `keyComparer` that implements **both** `IEqualityComparer` and `IComparer`. Plain `[DictionaryEquality]` only uses the equality side (`TryGetValue`). With `sequential: true`, the same comparer drives **both** equality and sort order. - -`StringComparer` is the canonical example — it implements both interfaces. Using `StringComparer.OrdinalIgnoreCase` as a key comparer means `"region"` and `"REGION"` are the same key, and keys sort in a predictable case-insensitive order: - -```csharp -// Built with OrdinalIgnoreCase so "region" and "REGION" are the same key. -var scores = new Dictionary(StringComparer.OrdinalIgnoreCase) -{ - ["West"] = 42, - ["east"] = 17, - ["NORTH"] = 99, -}; -``` - -With plain `[DictionaryEquality]`, `TryGetValue` uses the dictionary's own `OrdinalIgnoreCase` comparer for lookup — equality is correct. But `GetHashCode` iterates in arbitrary insertion order, so two equal dictionaries built in different insertion order may produce different hash codes. - -With `[DictionaryEquality(sequential: true)]`, `OrderedDictionaryEqualityComparer` receives `StringComparer.OrdinalIgnoreCase` as its `keyComparer`. Because `StringComparer` also implements `IComparer`, it drives the sort in both `Equals` and `GetHashCode` — the iteration order is always `east → NORTH → West` (case-insensitive ordinal), regardless of insertion order: - -```csharp -[Equatable] -public partial class RegionScores -{ - // Sequential so the OrdinalIgnoreCase comparer controls sort order in GetHashCode, - // making the hash insertion-order independent even with a custom key comparer. - [DictionaryEquality(sequential: true)] - public Dictionary? Scores { get; set; } -} - -var a = new RegionScores { Scores = new(StringComparer.OrdinalIgnoreCase) { ["West"] = 42, ["east"] = 17 } }; -var b = new RegionScores { Scores = new(StringComparer.OrdinalIgnoreCase) { ["east"] = 17, ["West"] = 42 } }; - -a.Equals(b); // true — same key/value pairs -a.GetHashCode() == b.GetHashCode(); // true — sort order is deterministic via OrdinalIgnoreCase -``` - -> **Note:** the comparer used by the generated code is `EqualityComparer.Default` unless you override it explicitly via `[EqualityComparer]`. The dictionary's own internal comparer (set at construction time via `new Dictionary(myComparer)`) is not visible to the generated equality code. This applies to any custom key comparer — case-insensitive strings are just one example; the same is true for any domain-specific comparison rule (version strings, culture-aware text, normalised identifiers, etc.). - -Both `[DictionaryEquality]` and `[DictionaryEquality(sequential: true)]` honour the supplied `keyComparer` correctly in both `Equals` and `GetHashCode`. Prefer `[DictionaryEquality]` — it is O(n) vs O(n log n) for `sequential: true`. Use `sequential: true` only when you need a deterministic, sorted iteration order for snapshot testing or diagnostic output. - -**Supported types:** same as `[DictionaryEquality]` — any `IReadOnlyDictionary`. - -```csharp -[DictionaryEquality(sequential: true)] -public Dictionary? RankByRegion { get; set; } -``` - --- ## Nested collections @@ -414,26 +355,6 @@ public Dictionary>? ScoresByRegion { get; set; } public Dictionary>? TagsByRegion { get; set; } ``` -### `[DictionaryEquality(sequential: true)]` - -Key-sorted comparison propagates into every nested dictionary level. - -```csharp -// outer: key-sorted dict -// inner Dictionary: key-sorted (propagated) -[DictionaryEquality(sequential: true)] -public Dictionary>? ByRegionAndTeam { get; set; } - -// outer: key-sorted dict -// inner List: SequenceEquality (default for List) -[DictionaryEquality(sequential: true)] -public Dictionary>? HistoryByRegion { get; set; } - -// three levels deep — propagation applies at every level -[DictionaryEquality(sequential: true)] -public Dictionary>>? ThreeLevelConfig { get; set; } -``` - ### `[SequenceEquality]` ```csharp @@ -666,7 +587,6 @@ The hash contract is critical for correct behaviour when instances are used as d ### New features -- **`[DictionaryEquality(sequential: true)]`** — key-sorted dictionary comparison. Both sides are sorted by key before comparison, producing deterministic equality regardless of insertion order. Useful for snapshot testing and diagnostic logging. Propagates into nested dictionary values. - **Direction overrides** — `[HashSetEquality]` on `List` or `T[]` produces order-insensitive comparison; `[SequenceEquality]` on `HashSet` enforces order-sensitive comparison. - **Nested collection comparer propagation** — a single annotation on the outer property propagates the chosen comparer kind into all nested levels. `Dictionary>`, `Dictionary>`, and three-level nesting are all handled with a single annotation. - **`MultiDimensionalArrayEqualityComparer`** — structural equality for `T[,]`, `T[,,]`, and higher-rank arrays, applied automatically as the default. Checks rank, dimension lengths, and element values in row-major order. @@ -684,7 +604,6 @@ The hash contract is critical for correct behaviour when instances are used as d - **Empty collections hash differently from null** — a sentinel value ensures `GetHashCode(empty) != GetHashCode(null)`, satisfying the hash contract. - **`HashSetEqualityComparer.GetHashCode` is order-independent** — uses a commutative accumulation strategy, consistent with `SetEquals`-based `Equals`. -- **`[DictionaryEquality(sequential: true)]` propagates correctly** — key-sorted mode is applied to nested dictionary values, not only the outermost level. - **Value types without `==`** — `EqualityComparer.Default` is used instead of direct operator comparison. ### Improvements diff --git a/src/Equatable.Comparers/OrderedDictionaryEqualityComparer.cs b/src/Equatable.Comparers/OrderedDictionaryEqualityComparer.cs deleted file mode 100644 index 0bce404..0000000 --- a/src/Equatable.Comparers/OrderedDictionaryEqualityComparer.cs +++ /dev/null @@ -1,88 +0,0 @@ -namespace Equatable.Comparers; - -/// -/// Order-sensitive equality comparer. -/// Two dictionaries are equal only if they contain the same key/value pairs in the same key-sorted order. -/// -public class OrderedDictionaryEqualityComparer : IEqualityComparer> -{ - /// Gets the default equality comparer for the specified generic arguments. - public static OrderedDictionaryEqualityComparer Default { get; } = new(); - - public OrderedDictionaryEqualityComparer() : this(EqualityComparer.Default, EqualityComparer.Default) - { - } - - public OrderedDictionaryEqualityComparer(IEqualityComparer keyComparer, IEqualityComparer valueComparer) - { - KeyComparer = keyComparer ?? throw new ArgumentNullException(nameof(keyComparer)); - ValueComparer = valueComparer ?? throw new ArgumentNullException(nameof(valueComparer)); - // Prefer IComparer from keyComparer for sort; fall back to a hash-tiebreaker - // to guarantee strict total order (dictionary keys are unique, so ties indicate - // a sort comparer inconsistent with key equality — hash tiebreaker fixes this). - KeySortComparer = keyComparer as IComparer - ?? new HashTiebreakerComparer(keyComparer); - } - - public IEqualityComparer KeyComparer { get; } - public IEqualityComparer ValueComparer { get; } - private IComparer KeySortComparer { get; } - - /// - public bool Equals(IDictionary? x, IDictionary? y) - { - if (ReferenceEquals(x, y)) - return true; - - if (x is null || y is null) - return false; - - return x.OrderBy(p => p.Key, KeySortComparer).SequenceEqual(y.OrderBy(p => p.Key, KeySortComparer), PairComparer); - } - - /// - public int GetHashCode(IDictionary obj) - { - if (obj == null) - return 0; - - var hashCode = new HashCode(); - - foreach (var pair in obj.OrderBy(p => p.Key, KeySortComparer)) - { - hashCode.Add(pair.Key, KeyComparer); - hashCode.Add(pair.Value, ValueComparer); - } - - return hashCode.ToHashCode(); - } - - private KeyValuePairEqualityComparer PairComparer => new(KeyComparer, ValueComparer); - - private sealed class KeyValuePairEqualityComparer(IEqualityComparer keyComparer, IEqualityComparer valueComparer) - : IEqualityComparer> - { - public bool Equals(KeyValuePair x, KeyValuePair y) => - keyComparer.Equals(x.Key, y.Key) && valueComparer.Equals(x.Value, y.Value); - - public int GetHashCode(KeyValuePair obj) => - HashCode.Combine(keyComparer.GetHashCode(obj.Key!), valueComparer.GetHashCode(obj.Value!)); - } - - // Provides a strict total order for keys that have no natural IComparer, - // using hash code as tiebreaker when the natural comparer returns 0 for distinct keys. - private sealed class HashTiebreakerComparer(IEqualityComparer equalityComparer) : IComparer - { - private static readonly IComparer _natural = Comparer.Default; - - public int Compare(TKey? x, TKey? y) - { - int cmp = _natural.Compare(x, y); - if (cmp != 0) return cmp; - // Natural comparer considers them equal; break tie by hash code - int hx = x is null ? 0 : equalityComparer.GetHashCode(x); - int hy = y is null ? 0 : equalityComparer.GetHashCode(y); - return hx.CompareTo(hy); - } - } -} diff --git a/src/Equatable.Comparers/OrderedReadOnlyDictionaryEqualityComparer.cs b/src/Equatable.Comparers/OrderedReadOnlyDictionaryEqualityComparer.cs deleted file mode 100644 index 758fd2c..0000000 --- a/src/Equatable.Comparers/OrderedReadOnlyDictionaryEqualityComparer.cs +++ /dev/null @@ -1,88 +0,0 @@ -namespace Equatable.Comparers; - -/// -/// Order-sensitive equality comparer. -/// Two dictionaries are equal only if they contain the same key/value pairs in the same key-sorted order. -/// -public class OrderedReadOnlyDictionaryEqualityComparer : IEqualityComparer> -{ - /// Gets the default equality comparer for the specified generic arguments. - public static OrderedReadOnlyDictionaryEqualityComparer Default { get; } = new(); - - public OrderedReadOnlyDictionaryEqualityComparer() : this(EqualityComparer.Default, EqualityComparer.Default) - { - } - - public OrderedReadOnlyDictionaryEqualityComparer(IEqualityComparer keyComparer, IEqualityComparer valueComparer) - { - KeyComparer = keyComparer ?? throw new ArgumentNullException(nameof(keyComparer)); - ValueComparer = valueComparer ?? throw new ArgumentNullException(nameof(valueComparer)); - // Prefer IComparer from keyComparer for sort; fall back to a hash-tiebreaker - // to guarantee strict total order (dictionary keys are unique, so ties indicate - // a sort comparer inconsistent with key equality — hash tiebreaker fixes this). - KeySortComparer = keyComparer as IComparer - ?? new HashTiebreakerComparer(keyComparer); - } - - public IEqualityComparer KeyComparer { get; } - public IEqualityComparer ValueComparer { get; } - private IComparer KeySortComparer { get; } - - /// - public bool Equals(IReadOnlyDictionary? x, IReadOnlyDictionary? y) - { - if (ReferenceEquals(x, y)) - return true; - - if (x is null || y is null) - return false; - - return x.OrderBy(p => p.Key, KeySortComparer).SequenceEqual(y.OrderBy(p => p.Key, KeySortComparer), PairComparer); - } - - /// - public int GetHashCode(IReadOnlyDictionary obj) - { - if (obj == null) - return 0; - - var hashCode = new HashCode(); - - foreach (var pair in obj.OrderBy(p => p.Key, KeySortComparer)) - { - hashCode.Add(pair.Key, KeyComparer); - hashCode.Add(pair.Value, ValueComparer); - } - - return hashCode.ToHashCode(); - } - - private KeyValuePairEqualityComparer PairComparer => new(KeyComparer, ValueComparer); - - private sealed class KeyValuePairEqualityComparer(IEqualityComparer keyComparer, IEqualityComparer valueComparer) - : IEqualityComparer> - { - public bool Equals(KeyValuePair x, KeyValuePair y) => - keyComparer.Equals(x.Key, y.Key) && valueComparer.Equals(x.Value, y.Value); - - public int GetHashCode(KeyValuePair obj) => - HashCode.Combine(keyComparer.GetHashCode(obj.Key!), valueComparer.GetHashCode(obj.Value!)); - } - - // Provides a strict total order for keys that have no natural IComparer, - // using hash code as tiebreaker when the natural comparer returns 0 for distinct keys. - private sealed class HashTiebreakerComparer(IEqualityComparer equalityComparer) : IComparer - { - private static readonly IComparer _natural = Comparer.Default; - - public int Compare(TKey? x, TKey? y) - { - int cmp = _natural.Compare(x, y); - if (cmp != 0) return cmp; - // Natural comparer considers them equal; break tie by hash code - int hx = x is null ? 0 : equalityComparer.GetHashCode(x); - int hy = y is null ? 0 : equalityComparer.GetHashCode(y); - return hx.CompareTo(hy); - } - } -} diff --git a/src/Equatable.Generator/Attributes/DictionaryEqualityAttribute.cs b/src/Equatable.Generator/Attributes/DictionaryEqualityAttribute.cs index 9ba3380..a8f30ee 100644 --- a/src/Equatable.Generator/Attributes/DictionaryEqualityAttribute.cs +++ b/src/Equatable.Generator/Attributes/DictionaryEqualityAttribute.cs @@ -4,20 +4,10 @@ namespace Equatable.Attributes; /// /// 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. /// -/// -/// When is false (default), equality and hash code are -/// insertion-order independent — two dictionaries with the same key/value pairs in any order -/// are considered equal and produce the same hash code. -/// -/// When is true, insertion order is part of the semantic: -/// equality uses SequenceEqual on the key-value pair sequence and hash code is computed -/// sequentially — two dictionaries with the same pairs in different order are NOT equal. -/// Use this only when dictionary ordering carries business meaning. -/// [Conditional("EQUATABLE_GENERATOR")] [AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)] -public class DictionaryEqualityAttribute(bool sequential = false) : Attribute +public class DictionaryEqualityAttribute : Attribute { - public bool Sequential { get; } = sequential; } diff --git a/src/Equatable.SourceGenerator/EquatableGenerator.cs b/src/Equatable.SourceGenerator/EquatableGenerator.cs index d47066d..54bf4f7 100644 --- a/src/Equatable.SourceGenerator/EquatableGenerator.cs +++ b/src/Equatable.SourceGenerator/EquatableGenerator.cs @@ -164,7 +164,7 @@ private static EquatableProperty CreateProperty(IPropertySymbol propertySymbol) // that need a composed comparer rather than EqualityComparer.Default. // BuildArrayComparerExpression always returns a non-null expression for arrays // (including multi-dimensional, which use MultiDimensionalArrayEqualityComparer). - if (comparerType is ComparerTypes.Dictionary or ComparerTypes.OrderedDictionary or ComparerTypes.HashSet or ComparerTypes.Sequence) + if (comparerType is ComparerTypes.Dictionary or ComparerTypes.HashSet or ComparerTypes.Sequence) { // enumKind is always propagated for explicit HashSet/Sequence annotations — the user's // explicit declaration signals intent for ALL nested collection levels, not just the @@ -297,7 +297,7 @@ INamedTypeSymbol namedType when IsDictionary(namedType) || namedType.AllInterfac INamedTypeSymbol? enumInterface = IsEnumerable(unwrapped) ? unwrapped : unwrapped.AllInterfaces.FirstOrDefault(IsEnumerable); - if ((kind == ComparerTypes.Dictionary || kind == ComparerTypes.OrderedDictionary) && dictInterface != null) + if (kind == ComparerTypes.Dictionary && dictInterface != null) { var keyType = dictInterface.TypeArguments[0]; var valueType = dictInterface.TypeArguments[1]; @@ -311,21 +311,7 @@ INamedTypeSymbol namedType when IsDictionary(namedType) || namedType.AllInterfac var isReadOnly = IsReadOnlyDictionary(unwrapped) || (IsDictionary(unwrapped) is false && unwrapped.AllInterfaces.Any(IsReadOnlyDictionary)); - if (kind == ComparerTypes.OrderedDictionary) - { - // ordered: always emit an explicit comparer so the ordered semantics are enforced, - // even when key/value types don't need composition themselves - keyExpr ??= $"global::System.Collections.Generic.EqualityComparer<{keyTypeFq}>.Default"; - valueExpr ??= $"global::System.Collections.Generic.EqualityComparer<{valueTypeFq}>.Default"; - - var orderedClass = isReadOnly - ? "global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer" - : "global::Equatable.Comparers.OrderedDictionaryEqualityComparer"; - - return $"new {orderedClass}<{keyTypeFq}, {valueTypeFq}>({keyExpr}, {valueExpr})"; - } - - // unordered: only compose when at least one argument needs a non-default comparer + // only compose when at least one argument needs a non-default comparer if (keyExpr == null && valueExpr == null) return null; @@ -440,14 +426,6 @@ private static string BuildDictComparerExpression(INamedTypeSymbol dictInterface var valueExpr = BuildElementComparerExpression(valueType, visited, dictKind) ?? $"global::System.Collections.Generic.EqualityComparer<{valueTypeFq}>.Default"; - if (dictKind == ComparerTypes.OrderedDictionary) - { - var orderedClass = isReadOnly - ? "global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer" - : "global::Equatable.Comparers.OrderedDictionaryEqualityComparer"; - return $"new {orderedClass}<{keyTypeFq}, {valueTypeFq}>({keyExpr}, {valueExpr})"; - } - var comparerClass = isReadOnly ? "global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer" : "global::Equatable.Comparers.DictionaryEqualityComparer"; @@ -524,7 +502,7 @@ private static bool ValidateComparer(IPropertySymbol propertySymbol, ComparerTyp if (comparerType == ComparerTypes.String) return IsString(propertySymbol.Type); - if (comparerType == ComparerTypes.Dictionary || comparerType == ComparerTypes.OrderedDictionary) + if (comparerType == ComparerTypes.Dictionary) return (propertySymbol.Type is INamedTypeSymbol nt && IsDictionary(nt)) || propertySymbol.Type.AllInterfaces.Any(IsDictionary); @@ -563,19 +541,6 @@ private static (ComparerTypes? comparerType, string? comparerName, string? compa private static (ComparerTypes? comparerType, string? comparerName, string? comparerInstance) GetDictionaryComparer(AttributeData? attribute) { - if (attribute == null) - return (ComparerTypes.Dictionary, null, null); - - // named arg: [DictionaryEquality(sequential: true)] - var namedArg = attribute.NamedArguments.FirstOrDefault(a => a.Key == "Sequential"); - if (namedArg.Key != null && namedArg.Value.Value is bool namedSequential && namedSequential) - return (ComparerTypes.OrderedDictionary, null, null); - - // positional arg: [DictionaryEquality(true)] - if (attribute.ConstructorArguments.Length > 0 && - attribute.ConstructorArguments[0].Value is bool positionalSequential && positionalSequential) - return (ComparerTypes.OrderedDictionary, null, null); - return (ComparerTypes.Dictionary, null, null); } diff --git a/src/Equatable.SourceGenerator/Models/ComparerTypes.cs b/src/Equatable.SourceGenerator/Models/ComparerTypes.cs index 07f2d2d..8839db2 100644 --- a/src/Equatable.SourceGenerator/Models/ComparerTypes.cs +++ b/src/Equatable.SourceGenerator/Models/ComparerTypes.cs @@ -4,7 +4,6 @@ public enum ComparerTypes { Default, Dictionary, - OrderedDictionary, HashSet, Reference, Sequence, diff --git a/test/Equatable.Entities/SequentialDictionary.cs b/test/Equatable.Entities/SequentialDictionary.cs deleted file mode 100644 index ca0afe7..0000000 --- a/test/Equatable.Entities/SequentialDictionary.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System.Collections.Generic; - -using Equatable.Attributes; - -namespace Equatable.Entities; - -[Equatable] -public partial class SequentialDictionary -{ - [DictionaryEquality(sequential: true)] - public Dictionary? Entries { get; set; } - - [DictionaryEquality(sequential: true)] - public IReadOnlyDictionary? ReadOnlyEntries { get; set; } -} diff --git a/test/Equatable.Generator.Tests/Comparers/ComparerGetHashCodeTest.cs b/test/Equatable.Generator.Tests/Comparers/ComparerGetHashCodeTest.cs index 4cedbe1..451e613 100644 --- a/test/Equatable.Generator.Tests/Comparers/ComparerGetHashCodeTest.cs +++ b/test/Equatable.Generator.Tests/Comparers/ComparerGetHashCodeTest.cs @@ -183,45 +183,6 @@ public void Sequence_DifferentOrder_DifferentHash() Assert.NotEqual(SeqComparer.GetHashCode(a), SeqComparer.GetHashCode(b)); } - // ── OrderedDictionaryEqualityComparer ──────────────────────────────────────────────────────── - - private static readonly OrderedDictionaryEqualityComparer OrderedDictComparer - = OrderedDictionaryEqualityComparer.Default; - - [Fact] - public void OrderedDictionary_Null_HashIsZero() - { - Assert.Equal(0, OrderedDictComparer.GetHashCode(null!)); - } - - [Fact] - public void OrderedDictionary_Empty_HashDiffersFromNull() - { - Assert.NotEqual( - OrderedDictComparer.GetHashCode(null!), - OrderedDictComparer.GetHashCode(new Dictionary())); - } - - [Fact] - public void OrderedDictionary_EqualCollections_SameHash() - { - // Ordered comparer is key-sorted — same content, different insertion order → equal - var a = new Dictionary { ["a"] = 1, ["b"] = 2, ["c"] = 3 }; - var b = new Dictionary { ["c"] = 3, ["a"] = 1, ["b"] = 2 }; - - Assert.True(OrderedDictComparer.Equals(a, b)); - Assert.Equal(OrderedDictComparer.GetHashCode(a), OrderedDictComparer.GetHashCode(b)); - } - - [Fact] - public void OrderedDictionary_DifferentValues_DifferentHash() - { - var a = new Dictionary { ["a"] = 1 }; - var b = new Dictionary { ["a"] = 2 }; - - Assert.NotEqual(OrderedDictComparer.GetHashCode(a), OrderedDictComparer.GetHashCode(b)); - } - // ── Cross-comparer: empty vs single-element ────────────────────────────────────────────────── [Fact] diff --git a/test/Equatable.Generator.Tests/Comparers/OrderedDictionaryEqualityComparerTest.cs b/test/Equatable.Generator.Tests/Comparers/OrderedDictionaryEqualityComparerTest.cs deleted file mode 100644 index daa558e..0000000 --- a/test/Equatable.Generator.Tests/Comparers/OrderedDictionaryEqualityComparerTest.cs +++ /dev/null @@ -1,226 +0,0 @@ -using System.Collections.Generic; -using Equatable.Comparers; - -namespace Equatable.Generator.Tests.Comparers; - -public class OrderedDictionaryEqualityComparerTest -{ - // ── nested: OrderedDictionaryEqualityComparer> ── - // Verifies that when the value comparer is itself an OrderedDictionaryEqualityComparer, - // inner-dict insertion order is also irrelevant (both levels sorted by key). - - private static OrderedDictionaryEqualityComparer> NestedOrdered() - => new(EqualityComparer.Default, - new OrderedDictionaryEqualityComparer()); - - [Fact] - public void Nested_OrderedBothLevels_InnerInsertionOrderDiffers_ReturnsTrue() - { - var inner1 = new Dictionary { ["x"] = 1, ["y"] = 2 }; - var inner2 = new Dictionary { ["y"] = 2, ["x"] = 1 }; - var a = new Dictionary> { ["k"] = inner1 }; - var b = new Dictionary> { ["k"] = inner2 }; - - Assert.True(NestedOrdered().Equals(a, b)); - } - - [Fact] - public void Nested_OrderedBothLevels_OuterInsertionOrderDiffers_ReturnsTrue() - { - var a = new Dictionary> - { - ["a"] = new Dictionary { ["x"] = 1 }, - ["b"] = new Dictionary { ["y"] = 2 }, - }; - var b = new Dictionary> - { - ["b"] = new Dictionary { ["y"] = 2 }, - ["a"] = new Dictionary { ["x"] = 1 }, - }; - - Assert.True(NestedOrdered().Equals(a, b)); - } - - [Fact] - public void Nested_OrderedBothLevels_InnerValueDiffers_ReturnsFalse() - { - var a = new Dictionary> { ["k"] = new Dictionary { ["x"] = 1 } }; - var b = new Dictionary> { ["k"] = new Dictionary { ["x"] = 99 } }; - - Assert.False(NestedOrdered().Equals(a, b)); - } - - [Fact] - public void Nested_OrderedBothLevels_SamePairs_GetHashCodesEqual() - { - var inner1 = new Dictionary { ["a"] = 1, ["b"] = 2 }; - var inner2 = new Dictionary { ["b"] = 2, ["a"] = 1 }; - var a = new Dictionary> { ["k"] = inner1 }; - var b = new Dictionary> { ["k"] = inner2 }; - - var cmp = NestedOrdered(); - Assert.Equal(cmp.GetHashCode(a), cmp.GetHashCode(b)); - } - - // ── nested with unordered inner: verify behaviour when inner is DictionaryEqualityComparer ── - - private static OrderedDictionaryEqualityComparer> NestedUnordered() - => new(EqualityComparer.Default, - new DictionaryEqualityComparer()); - - [Fact] - public void Nested_OrderedOuter_UnorderedInner_InnerInsertionOrderDiffers_ReturnsTrue() - { - // Inner uses DictionaryEqualityComparer (unordered) → inner insertion order irrelevant. - var a = new Dictionary> { ["k"] = new Dictionary { ["x"] = 1, ["y"] = 2 } }; - var b = new Dictionary> { ["k"] = new Dictionary { ["y"] = 2, ["x"] = 1 } }; - - Assert.True(NestedUnordered().Equals(a, b)); - } - - - // ── custom IEqualityComparer+IComparer: StringComparer.OrdinalIgnoreCase ────────────────────── - // Demonstrates why sequential: true exists. StringComparer implements both interfaces, so - // the same comparer drives key equality AND sort order — hash is insertion-order independent - // even when the dictionary uses a non-default key comparer. - - private static OrderedDictionaryEqualityComparer CaseInsensitive() - => new(StringComparer.OrdinalIgnoreCase, EqualityComparer.Default); - - [Fact] - public void CustomComparer_CaseInsensitiveKeys_Equal() - { - // "West" and "WEST" are the same key under OrdinalIgnoreCase - var a = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["West"] = 42, ["east"] = 17 }; - var b = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["WEST"] = 42, ["EAST"] = 17 }; - - Assert.True(CaseInsensitive().Equals(a, b)); - } - - [Fact] - public void CustomComparer_DifferentInsertionOrder_SameHashCode() - { - // Same pairs, different insertion order — hash must match because OrdinalIgnoreCase - // drives both equality and sort order, making the result fully deterministic. - var a = new Dictionary { ["West"] = 42, ["east"] = 17, ["NORTH"] = 99 }; - var b = new Dictionary { ["NORTH"] = 99, ["West"] = 42, ["east"] = 17 }; - - Assert.Equal(CaseInsensitive().GetHashCode(a), CaseInsensitive().GetHashCode(b)); - } - - [Fact] - public void CustomComparer_GetHashCode_CaseVariantsProduceSameHash() - { - // "West"→42 and "WEST"→42 are the same entry under OrdinalIgnoreCase — same hash - var a = new Dictionary { ["West"] = 42 }; - var b = new Dictionary { ["WEST"] = 42 }; - - Assert.Equal(CaseInsensitive().GetHashCode(a), CaseInsensitive().GetHashCode(b)); - } - - [Fact] - public void DefaultComparer_CaseVariants_NotEqual() - { - // Contrast: with default (ordinal) comparer, "West" != "WEST" — different keys - var a = new Dictionary { ["West"] = 42 }; - var b = new Dictionary { ["WEST"] = 42 }; - - Assert.False(Comparer.Equals(a, b)); - Assert.NotEqual(Comparer.GetHashCode(a), Comparer.GetHashCode(b)); - } - - // ──────────────────────────────────────────────────────────────────────────────────────────── - - private static readonly OrderedDictionaryEqualityComparer Comparer - = OrderedDictionaryEqualityComparer.Default; - - private static readonly OrderedReadOnlyDictionaryEqualityComparer ReadOnlyComparer - = OrderedReadOnlyDictionaryEqualityComparer.Default; - - [Fact] - public void Equals_SamePairs_DifferentInsertionOrder_ReturnsTrue() - { - var a = new Dictionary { ["a"] = 1, ["b"] = 2 }; - var b = new Dictionary { ["b"] = 2, ["a"] = 1 }; - - Assert.True(Comparer.Equals(a, b)); - } - - [Fact] - public void Equals_DifferentValues_ReturnsFalse() - { - var a = new Dictionary { ["a"] = 1, ["b"] = 2 }; - var b = new Dictionary { ["a"] = 1, ["b"] = 99 }; - - Assert.False(Comparer.Equals(a, b)); - } - - [Fact] - public void Equals_DifferentKeys_ReturnsFalse() - { - var a = new Dictionary { ["a"] = 1 }; - var b = new Dictionary { ["z"] = 1 }; - - Assert.False(Comparer.Equals(a, b)); - } - - [Fact] - public void Equals_NullBoth_ReturnsTrue() - { - Assert.True(Comparer.Equals(null, null)); - } - - [Fact] - public void Equals_NullOne_ReturnsFalse() - { - var a = new Dictionary { ["a"] = 1 }; - Assert.False(Comparer.Equals(a, null)); - Assert.False(Comparer.Equals(null, a)); - } - - [Fact] - public void GetHashCode_SamePairs_DifferentInsertionOrder_Equal() - { - var a = new Dictionary { ["a"] = 1, ["b"] = 2, ["c"] = 3 }; - var b = new Dictionary { ["c"] = 3, ["a"] = 1, ["b"] = 2 }; - - Assert.Equal(Comparer.GetHashCode(a), Comparer.GetHashCode(b)); - } - - [Fact] - public void GetHashCode_DifferentValues_NotEqual() - { - var a = new Dictionary { ["a"] = 1 }; - var b = new Dictionary { ["a"] = 2 }; - - Assert.NotEqual(Comparer.GetHashCode(a), Comparer.GetHashCode(b)); - } - - [Fact] - public void EqualDictionaries_HaveSameHashCode() - { - var a = new Dictionary { ["a"] = 1, ["b"] = 2 }; - var b = new Dictionary { ["b"] = 2, ["a"] = 1 }; - - Assert.True(Comparer.Equals(a, b)); - Assert.Equal(Comparer.GetHashCode(a), Comparer.GetHashCode(b)); - } - - [Fact] - public void ReadOnly_Equals_SamePairs_DifferentInsertionOrder_ReturnsTrue() - { - IReadOnlyDictionary a = new Dictionary { ["x"] = 10, ["y"] = 20 }; - IReadOnlyDictionary b = new Dictionary { ["y"] = 20, ["x"] = 10 }; - - Assert.True(ReadOnlyComparer.Equals(a, b)); - } - - [Fact] - public void ReadOnly_GetHashCode_SamePairs_DifferentInsertionOrder_Equal() - { - IReadOnlyDictionary a = new Dictionary { ["x"] = 10, ["y"] = 20 }; - IReadOnlyDictionary b = new Dictionary { ["y"] = 20, ["x"] = 10 }; - - Assert.Equal(ReadOnlyComparer.GetHashCode(a), ReadOnlyComparer.GetHashCode(b)); - } -} diff --git a/test/Equatable.Generator.Tests/DataContractGeneratorTest.cs b/test/Equatable.Generator.Tests/DataContractGeneratorTest.cs index c00db9f..15b8a8b 100644 --- a/test/Equatable.Generator.Tests/DataContractGeneratorTest.cs +++ b/test/Equatable.Generator.Tests/DataContractGeneratorTest.cs @@ -110,7 +110,7 @@ public abstract class UnannotatedBase // An explicit equality attribute on a [DataMember] property must override the inferred comparer. [Fact] - public Task GenerateDataContractEquatableWithOrderedDictionaryOverride() + public Task GenerateDataContractEquatableWithDictionaryEqualityOverride() { var source = @" using System.Collections.Generic; @@ -122,13 +122,13 @@ namespace Equatable.Entities; [DataContract] [DataContractEquatable] -public partial class OrderedContract +public partial class DictionaryOverrideContract { [DataMember(Order = 0)] public int Id { get; set; } [DataMember(Order = 1)] - [DictionaryEquality(sequential: true)] + [DictionaryEquality] public Dictionary? Tags { get; set; } } "; @@ -341,37 +341,8 @@ public partial class SequenceOverrideContract return Verifier.Verify(output).UseDirectory("Snapshots").ScrubLinesContaining("GeneratedCodeAttribute"); } - [Fact] - public Task GenerateDataContractEquatableWithSequentialNestedDictPropagation() - { - var source = @" -using System.Collections.Generic; -using System.Runtime.Serialization; -using Equatable.Attributes; -using Equatable.Attributes.DataContract; - -namespace Equatable.Entities; - -[DataContract] -[DataContractEquatable] -public partial class NestedOrderedContract -{ - [DataMember(Order = 0)] - public int Id { get; set; } - - [DataMember(Order = 1)] - [DictionaryEquality(sequential: true)] - public Dictionary>? NestedDicts { get; set; } -} -"; - var (diagnostics, output) = GetGeneratedOutput(source); - Assert.Empty(diagnostics); - return Verifier.Verify(output).UseDirectory("Snapshots").ScrubLinesContaining("GeneratedCodeAttribute"); - } - // ── dictKind propagation ────────────────────────────────────────────────────────────────────── - // Explicit [DictionaryEquality] kind propagates to ALL nested dictionary levels; nested - // enumerables keep their natural comparer. + // [DictionaryEquality] propagates to ALL nested dictionary levels; nested enumerables keep their natural comparer. [Fact] public Task GenerateDataContractEquatableWithDictionaryEqualityPropagation() @@ -392,20 +363,8 @@ public partial class DictPropagationContract public int Id { get; set; } [DataMember(Order = 1)] - [DictionaryEquality(sequential: true)] - public Dictionary>>? ThreeLevelOrdered { get; set; } - - [DataMember(Order = 2)] - [DictionaryEquality(sequential: true)] - public Dictionary>? OrderedDictOfList { get; set; } - - [DataMember(Order = 3)] - [DictionaryEquality(sequential: true)] - public Dictionary>>? OrderedDictOfDictOfList { get; set; } - - [DataMember(Order = 4)] [DictionaryEquality] - public Dictionary>? UnorderedNestedDict { get; set; } + public Dictionary>? NestedDicts { get; set; } } "; var (diagnostics, output) = GetGeneratedOutput(source); diff --git a/test/Equatable.Generator.Tests/Entities/SequentialDictionaryTest.cs b/test/Equatable.Generator.Tests/Entities/SequentialDictionaryTest.cs deleted file mode 100644 index c427859..0000000 --- a/test/Equatable.Generator.Tests/Entities/SequentialDictionaryTest.cs +++ /dev/null @@ -1,75 +0,0 @@ -using System.Collections.Generic; -using Equatable.Entities; - -namespace Equatable.Generator.Tests.Entities; - -/// -/// Tests for [DictionaryEquality(sequential: true)]. -/// Equality and hash code both sort by key before comparing, -/// so two dictionaries are equal iff they have the same key/value pairs regardless of insertion order. -/// -public class SequentialDictionaryTest -{ - [Fact] - public void Equals_SamePairs_DifferentInsertionOrder_ReturnsTrue() - { - var left = new SequentialDictionary { Entries = new Dictionary { ["a"] = 1, ["b"] = 2 } }; - var right = new SequentialDictionary { Entries = new Dictionary { ["b"] = 2, ["a"] = 1 } }; - - Assert.True(left.Equals(right)); - } - - [Fact] - public void Equals_DifferentValues_ReturnsFalse() - { - var left = new SequentialDictionary { Entries = new Dictionary { ["a"] = 1, ["b"] = 2 } }; - var right = new SequentialDictionary { Entries = new Dictionary { ["a"] = 1, ["b"] = 99 } }; - - Assert.False(left.Equals(right)); - } - - [Fact] - public void Equals_DifferentKeys_ReturnsFalse() - { - var left = new SequentialDictionary { Entries = new Dictionary { ["a"] = 1 } }; - var right = new SequentialDictionary { Entries = new Dictionary { ["z"] = 1 } }; - - Assert.False(left.Equals(right)); - } - - [Fact] - public void HashCode_SamePairs_DifferentInsertionOrder_Equal() - { - var left = new SequentialDictionary { Entries = new Dictionary { ["a"] = 1, ["b"] = 2, ["c"] = 3 } }; - var right = new SequentialDictionary { Entries = new Dictionary { ["c"] = 3, ["a"] = 1, ["b"] = 2 } }; - - Assert.Equal(left.GetHashCode(), right.GetHashCode()); - } - - [Fact] - public void HashCode_DifferentValues_NotEqual() - { - var left = new SequentialDictionary { Entries = new Dictionary { ["a"] = 1 } }; - var right = new SequentialDictionary { Entries = new Dictionary { ["a"] = 2 } }; - - Assert.NotEqual(left.GetHashCode(), right.GetHashCode()); - } - - [Fact] - public void ReadOnly_Equals_SamePairs_DifferentInsertionOrder_ReturnsTrue() - { - var left = new SequentialDictionary { ReadOnlyEntries = new Dictionary { ["x"] = 10, ["y"] = 20 } }; - var right = new SequentialDictionary { ReadOnlyEntries = new Dictionary { ["y"] = 20, ["x"] = 10 } }; - - Assert.True(left.Equals(right)); - } - - [Fact] - public void ReadOnly_HashCode_SamePairs_DifferentInsertionOrder_Equal() - { - var left = new SequentialDictionary { ReadOnlyEntries = new Dictionary { ["x"] = 10, ["y"] = 20 } }; - var right = new SequentialDictionary { ReadOnlyEntries = new Dictionary { ["y"] = 20, ["x"] = 10 } }; - - Assert.Equal(left.GetHashCode(), right.GetHashCode()); - } -} diff --git a/test/Equatable.Generator.Tests/EquatableGeneratorTest.cs b/test/Equatable.Generator.Tests/EquatableGeneratorTest.cs index 3345a1a..9a9acd7 100644 --- a/test/Equatable.Generator.Tests/EquatableGeneratorTest.cs +++ b/test/Equatable.Generator.Tests/EquatableGeneratorTest.cs @@ -862,30 +862,6 @@ public partial class Container return Verifier.Verify(output).UseDirectory("Snapshots").ScrubLinesContaining("GeneratedCodeAttribute"); } - [Fact] - public Task GenerateSequentialDictionaryEquality() - { - var source = @" -using System.Collections.Generic; -using Equatable.Attributes; - -namespace Equatable.Entities; - -[Equatable] -public partial class Container -{ - [DictionaryEquality(sequential: true)] - public Dictionary? Entries { get; set; } - - [DictionaryEquality(sequential: true)] - public IReadOnlyDictionary? ReadOnlyEntries { get; set; } -} -"; - var (diagnostics, output) = GetGeneratedOutput(source); - Assert.Empty(diagnostics); - return Verifier.Verify(output).UseDirectory("Snapshots").ScrubLinesContaining("GeneratedCodeAttribute"); - } - [Fact] public Task GenerateIEnumerableSequenceEquality() { @@ -1117,31 +1093,9 @@ public partial class Container return Verifier.Verify(output).UseDirectory("Snapshots").ScrubLinesContaining("GeneratedCodeAttribute"); } - [Fact] - public Task GenerateSequentialDictionaryEquality_NestedDictPropagatesOrderedComparer() - { - var source = @" -using System.Collections.Generic; -using Equatable.Attributes; - -namespace Equatable.Entities; - -[Equatable] -public partial class Container -{ - [DictionaryEquality(sequential: true)] - public Dictionary>? NestedDicts { get; set; } -} -"; - var (diagnostics, output) = GetGeneratedOutput(source); - Assert.Empty(diagnostics); - return Verifier.Verify(output).UseDirectory("Snapshots").ScrubLinesContaining("GeneratedCodeAttribute"); - } - // ── dictKind propagation ────────────────────────────────────────────────────────────────────── - // When [DictionaryEquality] / [DictionaryEquality(sequential:true)] is set explicitly, the - // annotated kind propagates into ALL nested dictionary levels. Nested enumerables (List, array, - // HashSet) keep their natural comparer regardless. + // [DictionaryEquality] propagates into ALL nested dictionary levels. + // Nested enumerables (List, array, HashSet) keep their natural comparer. [Fact] public Task GenerateDictionaryEqualityPropagatesIntoNestedDictionaries() @@ -1155,19 +1109,7 @@ namespace Equatable.Entities; [Equatable] public partial class Container { - /// [DictionaryEquality(sequential:true)] on 3-level nest: all dict levels use Ordered. - [DictionaryEquality(sequential: true)] - public Dictionary>>? ThreeLevelOrdered { get; set; } - - /// [DictionaryEquality(sequential:true)] on dict-of-list: dict is ordered, inner list is natural Sequence. - [DictionaryEquality(sequential: true)] - public Dictionary>? OrderedDictOfList { get; set; } - - /// [DictionaryEquality(sequential:true)] on dict-of-dict-of-list: both dict levels ordered, inner list natural. - [DictionaryEquality(sequential: true)] - public Dictionary>>? OrderedDictOfDictOfList { get; set; } - - /// [DictionaryEquality] (unordered) on dict-of-dict: both dict levels unordered. + /// [DictionaryEquality] on dict-of-dict: both dict levels unordered. [DictionaryEquality] public Dictionary>? UnorderedNestedDict { get; set; } } diff --git a/test/Equatable.Generator.Tests/MessagePackGeneratorTest.cs b/test/Equatable.Generator.Tests/MessagePackGeneratorTest.cs index 5ad91dc..a5b818a 100644 --- a/test/Equatable.Generator.Tests/MessagePackGeneratorTest.cs +++ b/test/Equatable.Generator.Tests/MessagePackGeneratorTest.cs @@ -110,7 +110,7 @@ public abstract class UnannotatedBase // An explicit equality attribute on a [Key] property must override the inferred comparer. [Fact] - public Task GenerateMessagePackEquatableWithOrderedDictionaryOverride() + public Task GenerateMessagePackEquatableWithDictionaryEqualityOverride() { var source = @" using System.Collections.Generic; @@ -122,13 +122,13 @@ namespace Equatable.Entities; [MessagePackObject] [MessagePackEquatable] -public partial class OrderedContract +public partial class DictionaryOverrideContract { [Key(0)] public int Id { get; set; } [Key(1)] - [DictionaryEquality(sequential: true)] + [DictionaryEquality] public Dictionary? Tags { get; set; } } "; @@ -341,37 +341,8 @@ public partial class SequenceOverrideContract return Verifier.Verify(output).UseDirectory("Snapshots").ScrubLinesContaining("GeneratedCodeAttribute"); } - [Fact] - public Task GenerateMessagePackEquatableWithSequentialNestedDictPropagation() - { - var source = @" -using System.Collections.Generic; -using MessagePack; -using Equatable.Attributes; -using Equatable.Attributes.MessagePack; - -namespace Equatable.Entities; - -[MessagePackObject] -[MessagePackEquatable] -public partial class NestedOrderedContract -{ - [Key(0)] - public int Id { get; set; } - - [Key(1)] - [DictionaryEquality(sequential: true)] - public Dictionary>? NestedDicts { get; set; } -} -"; - var (diagnostics, output) = GetGeneratedOutput(source); - Assert.Empty(diagnostics); - return Verifier.Verify(output).UseDirectory("Snapshots").ScrubLinesContaining("GeneratedCodeAttribute"); - } - // ── dictKind propagation ────────────────────────────────────────────────────────────────────── - // Explicit [DictionaryEquality] kind propagates to ALL nested dictionary levels; nested - // enumerables keep their natural comparer. + // [DictionaryEquality] propagates to ALL nested dictionary levels; nested enumerables keep their natural comparer. [Fact] public Task GenerateMessagePackEquatableWithDictionaryEqualityPropagation() @@ -392,20 +363,8 @@ public partial class DictPropagationContract public int Id { get; set; } [Key(1)] - [DictionaryEquality(sequential: true)] - public Dictionary>>? ThreeLevelOrdered { get; set; } - - [Key(2)] - [DictionaryEquality(sequential: true)] - public Dictionary>? OrderedDictOfList { get; set; } - - [Key(3)] - [DictionaryEquality(sequential: true)] - public Dictionary>>? OrderedDictOfDictOfList { get; set; } - - [Key(4)] [DictionaryEquality] - public Dictionary>? UnorderedNestedDict { get; set; } + public Dictionary>? NestedDicts { get; set; } } "; var (diagnostics, output) = GetGeneratedOutput(source); diff --git a/test/Equatable.Generator.Tests/Properties/OrderedDictionaryComparerProperties.cs b/test/Equatable.Generator.Tests/Properties/OrderedDictionaryComparerProperties.cs deleted file mode 100644 index 6727adf..0000000 --- a/test/Equatable.Generator.Tests/Properties/OrderedDictionaryComparerProperties.cs +++ /dev/null @@ -1,50 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using Equatable.Comparers; - -namespace Equatable.Generator.Tests.Properties; - -public class OrderedDictionaryComparerProperties -{ - private static readonly OrderedDictionaryEqualityComparer Comparer - = OrderedDictionaryEqualityComparer.Default; - - [Property] - public Property Equals_Reflexivity_SameInstance_ReturnsTrue(Dictionary dict) - { - return Prop.ToProperty(Comparer.Equals(dict, dict)); - } - - [Property] - public Property Equals_Symmetry_AEqualsB_ImpliesBEqualsA(Dictionary x, Dictionary y) - { - return Prop.ToProperty(Comparer.Equals(x, y) == Comparer.Equals(y, x)); - } - - [Property] - public Property HashCode_InsertionOrderIndependent(Dictionary dict) - { - var reversed = new Dictionary(dict.Reverse()); - return Prop.ToProperty(Comparer.GetHashCode(dict) == Comparer.GetHashCode(reversed)); - } - - [Property] - public Property HashCode_EqualDictionaries_HaveSameHash(Dictionary dict) - { - // equal → same hash (one direction only — hash collisions can make unequal produce same hash) - var reversed = new Dictionary(dict.Reverse()); - return Prop.When(Comparer.Equals(dict, reversed), Comparer.GetHashCode(dict) == Comparer.GetHashCode(reversed)); - } - - [Property] - public Property Equals_DifferentValue_ReturnsFalse(Dictionary dict, string key, int v1, int v2) - { - if (v1 == v2) - return Prop.When(true, true); - - var a = new Dictionary(dict) { [key] = v1 }; - var b = new Dictionary(dict) { [key] = v2 }; - - return Prop.ToProperty(!Comparer.Equals(a, b)); - } -} diff --git a/test/Equatable.Generator.Tests/Snapshots/AdapterGeneratorTest.GenerateDataContractEquatableWithOrderedDictionaryOverride.verified.txt b/test/Equatable.Generator.Tests/Snapshots/AdapterGeneratorTest.GenerateDataContractEquatableWithOrderedDictionaryOverride.verified.txt deleted file mode 100644 index 1c5c671..0000000 --- a/test/Equatable.Generator.Tests/Snapshots/AdapterGeneratorTest.GenerateDataContractEquatableWithOrderedDictionaryOverride.verified.txt +++ /dev/null @@ -1,45 +0,0 @@ -// -#nullable enable - -namespace Equatable.Entities -{ - partial class OrderedContract : global::System.IEquatable - { - /// - public bool Equals(global::Equatable.Entities.OrderedContract? other) - { - return !(other is null) - && Id == other.Id - && (new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default)).Equals(Tags, other.Tags); - - } - - /// - public override bool Equals(object? obj) - { - return Equals(obj as global::Equatable.Entities.OrderedContract); - } - - /// - public static bool operator ==(global::Equatable.Entities.OrderedContract? left, global::Equatable.Entities.OrderedContract? right) - { - return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); - } - - /// - public static bool operator !=(global::Equatable.Entities.OrderedContract? left, global::Equatable.Entities.OrderedContract? right) - { - return !(left == right); - } - - /// - public override int GetHashCode(){ - int hashCode = -193969728; - hashCode = (hashCode * -1521134295) + Id.GetHashCode(); - hashCode = (hashCode * -1521134295) + (new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default)).GetHashCode(Tags!); - return hashCode; - - } - - } -} diff --git a/test/Equatable.Generator.Tests/Snapshots/AdapterGeneratorTest.GenerateMessagePackEquatableWithOrderedDictionaryOverride.verified.txt b/test/Equatable.Generator.Tests/Snapshots/AdapterGeneratorTest.GenerateMessagePackEquatableWithOrderedDictionaryOverride.verified.txt deleted file mode 100644 index 4bc235b..0000000 --- a/test/Equatable.Generator.Tests/Snapshots/AdapterGeneratorTest.GenerateMessagePackEquatableWithOrderedDictionaryOverride.verified.txt +++ /dev/null @@ -1,45 +0,0 @@ -// -#nullable enable - -namespace Equatable.Entities -{ - partial class OrderedPackedContract : global::System.IEquatable - { - /// - public bool Equals(global::Equatable.Entities.OrderedPackedContract? other) - { - return !(other is null) - && Id == other.Id - && (new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default)).Equals(Tags, other.Tags); - - } - - /// - public override bool Equals(object? obj) - { - return Equals(obj as global::Equatable.Entities.OrderedPackedContract); - } - - /// - public static bool operator ==(global::Equatable.Entities.OrderedPackedContract? left, global::Equatable.Entities.OrderedPackedContract? right) - { - return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); - } - - /// - public static bool operator !=(global::Equatable.Entities.OrderedPackedContract? left, global::Equatable.Entities.OrderedPackedContract? right) - { - return !(left == right); - } - - /// - public override int GetHashCode(){ - int hashCode = -193969728; - hashCode = (hashCode * -1521134295) + Id.GetHashCode(); - hashCode = (hashCode * -1521134295) + (new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default)).GetHashCode(Tags!); - return hashCode; - - } - - } -} diff --git a/test/Equatable.Generator.Tests/Snapshots/DataContractGeneratorTest.GenerateDataContractEquatableWithDictionaryEqualityOverride.verified.txt b/test/Equatable.Generator.Tests/Snapshots/DataContractGeneratorTest.GenerateDataContractEquatableWithDictionaryEqualityOverride.verified.txt new file mode 100644 index 0000000..d125619 --- /dev/null +++ b/test/Equatable.Generator.Tests/Snapshots/DataContractGeneratorTest.GenerateDataContractEquatableWithDictionaryEqualityOverride.verified.txt @@ -0,0 +1,105 @@ +// +#nullable enable + +namespace Equatable.Entities +{ + partial class DictionaryOverrideContract : global::System.IEquatable + { + /// + public bool Equals(global::Equatable.Entities.DictionaryOverrideContract? other) + { + return !(other is null) + && Id == other.Id + && DictionaryEquals(Tags, other.Tags); + + static bool DictionaryEquals(global::System.Collections.Generic.IEnumerable>? left, global::System.Collections.Generic.IEnumerable>? right) + { + if (global::System.Object.ReferenceEquals(left, right)) + return true; + + if (left is null || right is null) + return false; + + if (left is global::System.Collections.Generic.IReadOnlyCollection> leftCollection && + right is global::System.Collections.Generic.IReadOnlyCollection> rightCollection && + leftCollection.Count != rightCollection.Count) + return false; + + if (right is global::System.Collections.Generic.IReadOnlyDictionary rightReadOnly) + { + foreach (var pair in left) + { + if (!rightReadOnly.TryGetValue(pair.Key, out var value)) + return false; + + if (!global::System.Collections.Generic.EqualityComparer.Default.Equals(pair.Value, value)) + return false; + } + return true; + } + + if (right is global::System.Collections.Generic.IDictionary rightDictionary) + { + foreach (var pair in left) + { + if (!rightDictionary.TryGetValue(pair.Key, out var value)) + return false; + + if (!global::System.Collections.Generic.EqualityComparer.Default.Equals(pair.Value, value)) + return false; + } + return true; + } + + return global::System.Linq.Enumerable.SequenceEqual( + global::System.Linq.Enumerable.OrderBy(left, p => p.Key), + global::System.Linq.Enumerable.OrderBy(right, p => p.Key), + global::System.Collections.Generic.EqualityComparer>.Default); + } + + } + + /// + public override bool Equals(object? obj) + { + return Equals(obj as global::Equatable.Entities.DictionaryOverrideContract); + } + + /// + public static bool operator ==(global::Equatable.Entities.DictionaryOverrideContract? left, global::Equatable.Entities.DictionaryOverrideContract? right) + { + return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); + } + + /// + public static bool operator !=(global::Equatable.Entities.DictionaryOverrideContract? left, global::Equatable.Entities.DictionaryOverrideContract? right) + { + return !(left == right); + } + + /// + public override int GetHashCode(){ + int hashCode = -193969728; + hashCode = (hashCode * -1521134295) + Id.GetHashCode(); + hashCode = (hashCode * -1521134295) + DictionaryHashCode(Tags); + return hashCode; + + static int DictionaryHashCode(global::System.Collections.Generic.IEnumerable>? items) + { + if (items is null) + return 0; + + int hashCode = 1; + + foreach (var item in items) + hashCode += global::System.HashCode.Combine( + global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(item.Key!), + global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(item.Value!)); + + return hashCode; + } + + } + + } +} diff --git a/test/Equatable.Generator.Tests/Snapshots/DataContractGeneratorTest.GenerateDataContractEquatableWithDictionaryEqualityPropagation.verified.txt b/test/Equatable.Generator.Tests/Snapshots/DataContractGeneratorTest.GenerateDataContractEquatableWithDictionaryEqualityPropagation.verified.txt index 0b1b752..f7e0faa 100644 --- a/test/Equatable.Generator.Tests/Snapshots/DataContractGeneratorTest.GenerateDataContractEquatableWithDictionaryEqualityPropagation.verified.txt +++ b/test/Equatable.Generator.Tests/Snapshots/DataContractGeneratorTest.GenerateDataContractEquatableWithDictionaryEqualityPropagation.verified.txt @@ -10,10 +10,7 @@ namespace Equatable.Entities { return !(other is null) && Id == other.Id - && (new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer>>(global::System.Collections.Generic.EqualityComparer.Default, new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer>(global::System.Collections.Generic.EqualityComparer.Default, new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default)))).Equals(ThreeLevelOrdered, other.ThreeLevelOrdered) - && (new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer>(global::System.Collections.Generic.EqualityComparer.Default, global::Equatable.Comparers.SequenceEqualityComparer.Default)).Equals(OrderedDictOfList, other.OrderedDictOfList) - && (new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer>>(global::System.Collections.Generic.EqualityComparer.Default, new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer>(global::System.Collections.Generic.EqualityComparer.Default, global::Equatable.Comparers.SequenceEqualityComparer.Default))).Equals(OrderedDictOfDictOfList, other.OrderedDictOfDictOfList) - && (new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer>(global::System.Collections.Generic.EqualityComparer.Default, new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default))).Equals(UnorderedNestedDict, other.UnorderedNestedDict); + && (new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer>(global::System.Collections.Generic.EqualityComparer.Default, new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default))).Equals(NestedDicts, other.NestedDicts); } @@ -37,12 +34,9 @@ namespace Equatable.Entities /// public override int GetHashCode(){ - int hashCode = -1477941023; + int hashCode = 1020457703; hashCode = (hashCode * -1521134295) + Id.GetHashCode(); - hashCode = (hashCode * -1521134295) + (new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer>>(global::System.Collections.Generic.EqualityComparer.Default, new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer>(global::System.Collections.Generic.EqualityComparer.Default, new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default)))).GetHashCode(ThreeLevelOrdered!); - hashCode = (hashCode * -1521134295) + (new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer>(global::System.Collections.Generic.EqualityComparer.Default, global::Equatable.Comparers.SequenceEqualityComparer.Default)).GetHashCode(OrderedDictOfList!); - hashCode = (hashCode * -1521134295) + (new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer>>(global::System.Collections.Generic.EqualityComparer.Default, new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer>(global::System.Collections.Generic.EqualityComparer.Default, global::Equatable.Comparers.SequenceEqualityComparer.Default))).GetHashCode(OrderedDictOfDictOfList!); - hashCode = (hashCode * -1521134295) + (new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer>(global::System.Collections.Generic.EqualityComparer.Default, new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default))).GetHashCode(UnorderedNestedDict!); + hashCode = (hashCode * -1521134295) + (new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer>(global::System.Collections.Generic.EqualityComparer.Default, new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default))).GetHashCode(NestedDicts!); return hashCode; } diff --git a/test/Equatable.Generator.Tests/Snapshots/DataContractGeneratorTest.GenerateDataContractEquatableWithOrderedDictionaryOverride.verified.txt b/test/Equatable.Generator.Tests/Snapshots/DataContractGeneratorTest.GenerateDataContractEquatableWithOrderedDictionaryOverride.verified.txt deleted file mode 100644 index 1c5c671..0000000 --- a/test/Equatable.Generator.Tests/Snapshots/DataContractGeneratorTest.GenerateDataContractEquatableWithOrderedDictionaryOverride.verified.txt +++ /dev/null @@ -1,45 +0,0 @@ -// -#nullable enable - -namespace Equatable.Entities -{ - partial class OrderedContract : global::System.IEquatable - { - /// - public bool Equals(global::Equatable.Entities.OrderedContract? other) - { - return !(other is null) - && Id == other.Id - && (new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default)).Equals(Tags, other.Tags); - - } - - /// - public override bool Equals(object? obj) - { - return Equals(obj as global::Equatable.Entities.OrderedContract); - } - - /// - public static bool operator ==(global::Equatable.Entities.OrderedContract? left, global::Equatable.Entities.OrderedContract? right) - { - return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); - } - - /// - public static bool operator !=(global::Equatable.Entities.OrderedContract? left, global::Equatable.Entities.OrderedContract? right) - { - return !(left == right); - } - - /// - public override int GetHashCode(){ - int hashCode = -193969728; - hashCode = (hashCode * -1521134295) + Id.GetHashCode(); - hashCode = (hashCode * -1521134295) + (new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default)).GetHashCode(Tags!); - return hashCode; - - } - - } -} diff --git a/test/Equatable.Generator.Tests/Snapshots/DataContractGeneratorTest.GenerateDataContractEquatableWithSequentialNestedDictPropagation.verified.txt b/test/Equatable.Generator.Tests/Snapshots/DataContractGeneratorTest.GenerateDataContractEquatableWithSequentialNestedDictPropagation.verified.txt deleted file mode 100644 index eeb199e..0000000 --- a/test/Equatable.Generator.Tests/Snapshots/DataContractGeneratorTest.GenerateDataContractEquatableWithSequentialNestedDictPropagation.verified.txt +++ /dev/null @@ -1,45 +0,0 @@ -// -#nullable enable - -namespace Equatable.Entities -{ - partial class NestedOrderedContract : global::System.IEquatable - { - /// - public bool Equals(global::Equatable.Entities.NestedOrderedContract? other) - { - return !(other is null) - && Id == other.Id - && (new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer>(global::System.Collections.Generic.EqualityComparer.Default, new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default))).Equals(NestedDicts, other.NestedDicts); - - } - - /// - public override bool Equals(object? obj) - { - return Equals(obj as global::Equatable.Entities.NestedOrderedContract); - } - - /// - public static bool operator ==(global::Equatable.Entities.NestedOrderedContract? left, global::Equatable.Entities.NestedOrderedContract? right) - { - return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); - } - - /// - public static bool operator !=(global::Equatable.Entities.NestedOrderedContract? left, global::Equatable.Entities.NestedOrderedContract? right) - { - return !(left == right); - } - - /// - public override int GetHashCode(){ - int hashCode = 1020457703; - hashCode = (hashCode * -1521134295) + Id.GetHashCode(); - hashCode = (hashCode * -1521134295) + (new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer>(global::System.Collections.Generic.EqualityComparer.Default, new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default))).GetHashCode(NestedDicts!); - return hashCode; - - } - - } -} diff --git a/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateDataContractEquatableWithOrderedDictionaryOverride.verified.txt b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateDataContractEquatableWithOrderedDictionaryOverride.verified.txt deleted file mode 100644 index 1c5c671..0000000 --- a/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateDataContractEquatableWithOrderedDictionaryOverride.verified.txt +++ /dev/null @@ -1,45 +0,0 @@ -// -#nullable enable - -namespace Equatable.Entities -{ - partial class OrderedContract : global::System.IEquatable - { - /// - public bool Equals(global::Equatable.Entities.OrderedContract? other) - { - return !(other is null) - && Id == other.Id - && (new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default)).Equals(Tags, other.Tags); - - } - - /// - public override bool Equals(object? obj) - { - return Equals(obj as global::Equatable.Entities.OrderedContract); - } - - /// - public static bool operator ==(global::Equatable.Entities.OrderedContract? left, global::Equatable.Entities.OrderedContract? right) - { - return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); - } - - /// - public static bool operator !=(global::Equatable.Entities.OrderedContract? left, global::Equatable.Entities.OrderedContract? right) - { - return !(left == right); - } - - /// - public override int GetHashCode(){ - int hashCode = -193969728; - hashCode = (hashCode * -1521134295) + Id.GetHashCode(); - hashCode = (hashCode * -1521134295) + (new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default)).GetHashCode(Tags!); - return hashCode; - - } - - } -} diff --git a/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateDictionaryEqualityPropagatesIntoNestedDictionaries.verified.txt b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateDictionaryEqualityPropagatesIntoNestedDictionaries.verified.txt index 9e04aa3..0b49afa 100644 --- a/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateDictionaryEqualityPropagatesIntoNestedDictionaries.verified.txt +++ b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateDictionaryEqualityPropagatesIntoNestedDictionaries.verified.txt @@ -9,9 +9,6 @@ namespace Equatable.Entities public bool Equals(global::Equatable.Entities.Container? other) { return !(other is null) - && (new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer>>(global::System.Collections.Generic.EqualityComparer.Default, new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer>(global::System.Collections.Generic.EqualityComparer.Default, new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default)))).Equals(ThreeLevelOrdered, other.ThreeLevelOrdered) - && (new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer>(global::System.Collections.Generic.EqualityComparer.Default, global::Equatable.Comparers.SequenceEqualityComparer.Default)).Equals(OrderedDictOfList, other.OrderedDictOfList) - && (new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer>>(global::System.Collections.Generic.EqualityComparer.Default, new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer>(global::System.Collections.Generic.EqualityComparer.Default, global::Equatable.Comparers.SequenceEqualityComparer.Default))).Equals(OrderedDictOfDictOfList, other.OrderedDictOfDictOfList) && (new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer>(global::System.Collections.Generic.EqualityComparer.Default, new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default))).Equals(UnorderedNestedDict, other.UnorderedNestedDict); } @@ -36,10 +33,7 @@ namespace Equatable.Entities /// public override int GetHashCode(){ - int hashCode = -1473867807; - hashCode = (hashCode * -1521134295) + (new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer>>(global::System.Collections.Generic.EqualityComparer.Default, new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer>(global::System.Collections.Generic.EqualityComparer.Default, new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default)))).GetHashCode(ThreeLevelOrdered!); - hashCode = (hashCode * -1521134295) + (new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer>(global::System.Collections.Generic.EqualityComparer.Default, global::Equatable.Comparers.SequenceEqualityComparer.Default)).GetHashCode(OrderedDictOfList!); - hashCode = (hashCode * -1521134295) + (new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer>>(global::System.Collections.Generic.EqualityComparer.Default, new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer>(global::System.Collections.Generic.EqualityComparer.Default, global::Equatable.Comparers.SequenceEqualityComparer.Default))).GetHashCode(OrderedDictOfDictOfList!); + int hashCode = -1092143752; hashCode = (hashCode * -1521134295) + (new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer>(global::System.Collections.Generic.EqualityComparer.Default, new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default))).GetHashCode(UnorderedNestedDict!); return hashCode; diff --git a/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateMessagePackEquatableWithOrderedDictionaryOverride.verified.txt b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateMessagePackEquatableWithOrderedDictionaryOverride.verified.txt deleted file mode 100644 index 4bc235b..0000000 --- a/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateMessagePackEquatableWithOrderedDictionaryOverride.verified.txt +++ /dev/null @@ -1,45 +0,0 @@ -// -#nullable enable - -namespace Equatable.Entities -{ - partial class OrderedPackedContract : global::System.IEquatable - { - /// - public bool Equals(global::Equatable.Entities.OrderedPackedContract? other) - { - return !(other is null) - && Id == other.Id - && (new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default)).Equals(Tags, other.Tags); - - } - - /// - public override bool Equals(object? obj) - { - return Equals(obj as global::Equatable.Entities.OrderedPackedContract); - } - - /// - public static bool operator ==(global::Equatable.Entities.OrderedPackedContract? left, global::Equatable.Entities.OrderedPackedContract? right) - { - return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); - } - - /// - public static bool operator !=(global::Equatable.Entities.OrderedPackedContract? left, global::Equatable.Entities.OrderedPackedContract? right) - { - return !(left == right); - } - - /// - public override int GetHashCode(){ - int hashCode = -193969728; - hashCode = (hashCode * -1521134295) + Id.GetHashCode(); - hashCode = (hashCode * -1521134295) + (new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default)).GetHashCode(Tags!); - return hashCode; - - } - - } -} diff --git a/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateSequentialDictionaryEquality.verified.txt b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateSequentialDictionaryEquality.verified.txt deleted file mode 100644 index 49f0ca2..0000000 --- a/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateSequentialDictionaryEquality.verified.txt +++ /dev/null @@ -1,45 +0,0 @@ -// -#nullable enable - -namespace Equatable.Entities -{ - partial class Container : global::System.IEquatable - { - /// - public bool Equals(global::Equatable.Entities.Container? other) - { - return !(other is null) - && (new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default)).Equals(Entries, other.Entries) - && (new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default)).Equals(ReadOnlyEntries, other.ReadOnlyEntries); - - } - - /// - public override bool Equals(object? obj) - { - return Equals(obj as global::Equatable.Entities.Container); - } - - /// - public static bool operator ==(global::Equatable.Entities.Container? left, global::Equatable.Entities.Container? right) - { - return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); - } - - /// - public static bool operator !=(global::Equatable.Entities.Container? left, global::Equatable.Entities.Container? right) - { - return !(left == right); - } - - /// - public override int GetHashCode(){ - int hashCode = 612607000; - hashCode = (hashCode * -1521134295) + (new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default)).GetHashCode(Entries!); - hashCode = (hashCode * -1521134295) + (new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default)).GetHashCode(ReadOnlyEntries!); - return hashCode; - - } - - } -} diff --git a/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateSequentialDictionaryEquality_NestedDictPropagatesOrderedComparer.verified.txt b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateSequentialDictionaryEquality_NestedDictPropagatesOrderedComparer.verified.txt deleted file mode 100644 index fd0cdd8..0000000 --- a/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateSequentialDictionaryEquality_NestedDictPropagatesOrderedComparer.verified.txt +++ /dev/null @@ -1,43 +0,0 @@ -// -#nullable enable - -namespace Equatable.Entities -{ - partial class Container : global::System.IEquatable - { - /// - public bool Equals(global::Equatable.Entities.Container? other) - { - return !(other is null) - && (new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer>(global::System.Collections.Generic.EqualityComparer.Default, new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default))).Equals(NestedDicts, other.NestedDicts); - - } - - /// - public override bool Equals(object? obj) - { - return Equals(obj as global::Equatable.Entities.Container); - } - - /// - public static bool operator ==(global::Equatable.Entities.Container? left, global::Equatable.Entities.Container? right) - { - return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); - } - - /// - public static bool operator !=(global::Equatable.Entities.Container? left, global::Equatable.Entities.Container? right) - { - return !(left == right); - } - - /// - public override int GetHashCode(){ - int hashCode = -1088400921; - hashCode = (hashCode * -1521134295) + (new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer>(global::System.Collections.Generic.EqualityComparer.Default, new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default))).GetHashCode(NestedDicts!); - return hashCode; - - } - - } -} diff --git a/test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatableWithDictionaryEqualityOverride.verified.txt b/test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatableWithDictionaryEqualityOverride.verified.txt new file mode 100644 index 0000000..d125619 --- /dev/null +++ b/test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatableWithDictionaryEqualityOverride.verified.txt @@ -0,0 +1,105 @@ +// +#nullable enable + +namespace Equatable.Entities +{ + partial class DictionaryOverrideContract : global::System.IEquatable + { + /// + public bool Equals(global::Equatable.Entities.DictionaryOverrideContract? other) + { + return !(other is null) + && Id == other.Id + && DictionaryEquals(Tags, other.Tags); + + static bool DictionaryEquals(global::System.Collections.Generic.IEnumerable>? left, global::System.Collections.Generic.IEnumerable>? right) + { + if (global::System.Object.ReferenceEquals(left, right)) + return true; + + if (left is null || right is null) + return false; + + if (left is global::System.Collections.Generic.IReadOnlyCollection> leftCollection && + right is global::System.Collections.Generic.IReadOnlyCollection> rightCollection && + leftCollection.Count != rightCollection.Count) + return false; + + if (right is global::System.Collections.Generic.IReadOnlyDictionary rightReadOnly) + { + foreach (var pair in left) + { + if (!rightReadOnly.TryGetValue(pair.Key, out var value)) + return false; + + if (!global::System.Collections.Generic.EqualityComparer.Default.Equals(pair.Value, value)) + return false; + } + return true; + } + + if (right is global::System.Collections.Generic.IDictionary rightDictionary) + { + foreach (var pair in left) + { + if (!rightDictionary.TryGetValue(pair.Key, out var value)) + return false; + + if (!global::System.Collections.Generic.EqualityComparer.Default.Equals(pair.Value, value)) + return false; + } + return true; + } + + return global::System.Linq.Enumerable.SequenceEqual( + global::System.Linq.Enumerable.OrderBy(left, p => p.Key), + global::System.Linq.Enumerable.OrderBy(right, p => p.Key), + global::System.Collections.Generic.EqualityComparer>.Default); + } + + } + + /// + public override bool Equals(object? obj) + { + return Equals(obj as global::Equatable.Entities.DictionaryOverrideContract); + } + + /// + public static bool operator ==(global::Equatable.Entities.DictionaryOverrideContract? left, global::Equatable.Entities.DictionaryOverrideContract? right) + { + return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); + } + + /// + public static bool operator !=(global::Equatable.Entities.DictionaryOverrideContract? left, global::Equatable.Entities.DictionaryOverrideContract? right) + { + return !(left == right); + } + + /// + public override int GetHashCode(){ + int hashCode = -193969728; + hashCode = (hashCode * -1521134295) + Id.GetHashCode(); + hashCode = (hashCode * -1521134295) + DictionaryHashCode(Tags); + return hashCode; + + static int DictionaryHashCode(global::System.Collections.Generic.IEnumerable>? items) + { + if (items is null) + return 0; + + int hashCode = 1; + + foreach (var item in items) + hashCode += global::System.HashCode.Combine( + global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(item.Key!), + global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(item.Value!)); + + return hashCode; + } + + } + + } +} diff --git a/test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatableWithDictionaryEqualityPropagation.verified.txt b/test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatableWithDictionaryEqualityPropagation.verified.txt index 0b1b752..f7e0faa 100644 --- a/test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatableWithDictionaryEqualityPropagation.verified.txt +++ b/test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatableWithDictionaryEqualityPropagation.verified.txt @@ -10,10 +10,7 @@ namespace Equatable.Entities { return !(other is null) && Id == other.Id - && (new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer>>(global::System.Collections.Generic.EqualityComparer.Default, new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer>(global::System.Collections.Generic.EqualityComparer.Default, new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default)))).Equals(ThreeLevelOrdered, other.ThreeLevelOrdered) - && (new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer>(global::System.Collections.Generic.EqualityComparer.Default, global::Equatable.Comparers.SequenceEqualityComparer.Default)).Equals(OrderedDictOfList, other.OrderedDictOfList) - && (new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer>>(global::System.Collections.Generic.EqualityComparer.Default, new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer>(global::System.Collections.Generic.EqualityComparer.Default, global::Equatable.Comparers.SequenceEqualityComparer.Default))).Equals(OrderedDictOfDictOfList, other.OrderedDictOfDictOfList) - && (new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer>(global::System.Collections.Generic.EqualityComparer.Default, new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default))).Equals(UnorderedNestedDict, other.UnorderedNestedDict); + && (new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer>(global::System.Collections.Generic.EqualityComparer.Default, new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default))).Equals(NestedDicts, other.NestedDicts); } @@ -37,12 +34,9 @@ namespace Equatable.Entities /// public override int GetHashCode(){ - int hashCode = -1477941023; + int hashCode = 1020457703; hashCode = (hashCode * -1521134295) + Id.GetHashCode(); - hashCode = (hashCode * -1521134295) + (new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer>>(global::System.Collections.Generic.EqualityComparer.Default, new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer>(global::System.Collections.Generic.EqualityComparer.Default, new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default)))).GetHashCode(ThreeLevelOrdered!); - hashCode = (hashCode * -1521134295) + (new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer>(global::System.Collections.Generic.EqualityComparer.Default, global::Equatable.Comparers.SequenceEqualityComparer.Default)).GetHashCode(OrderedDictOfList!); - hashCode = (hashCode * -1521134295) + (new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer>>(global::System.Collections.Generic.EqualityComparer.Default, new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer>(global::System.Collections.Generic.EqualityComparer.Default, global::Equatable.Comparers.SequenceEqualityComparer.Default))).GetHashCode(OrderedDictOfDictOfList!); - hashCode = (hashCode * -1521134295) + (new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer>(global::System.Collections.Generic.EqualityComparer.Default, new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default))).GetHashCode(UnorderedNestedDict!); + hashCode = (hashCode * -1521134295) + (new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer>(global::System.Collections.Generic.EqualityComparer.Default, new global::Equatable.Comparers.ReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default))).GetHashCode(NestedDicts!); return hashCode; } diff --git a/test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatableWithOrderedDictionaryOverride.verified.txt b/test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatableWithOrderedDictionaryOverride.verified.txt deleted file mode 100644 index 1c5c671..0000000 --- a/test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatableWithOrderedDictionaryOverride.verified.txt +++ /dev/null @@ -1,45 +0,0 @@ -// -#nullable enable - -namespace Equatable.Entities -{ - partial class OrderedContract : global::System.IEquatable - { - /// - public bool Equals(global::Equatable.Entities.OrderedContract? other) - { - return !(other is null) - && Id == other.Id - && (new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default)).Equals(Tags, other.Tags); - - } - - /// - public override bool Equals(object? obj) - { - return Equals(obj as global::Equatable.Entities.OrderedContract); - } - - /// - public static bool operator ==(global::Equatable.Entities.OrderedContract? left, global::Equatable.Entities.OrderedContract? right) - { - return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); - } - - /// - public static bool operator !=(global::Equatable.Entities.OrderedContract? left, global::Equatable.Entities.OrderedContract? right) - { - return !(left == right); - } - - /// - public override int GetHashCode(){ - int hashCode = -193969728; - hashCode = (hashCode * -1521134295) + Id.GetHashCode(); - hashCode = (hashCode * -1521134295) + (new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default)).GetHashCode(Tags!); - return hashCode; - - } - - } -} diff --git a/test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatableWithSequentialNestedDictPropagation.verified.txt b/test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatableWithSequentialNestedDictPropagation.verified.txt deleted file mode 100644 index eeb199e..0000000 --- a/test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatableWithSequentialNestedDictPropagation.verified.txt +++ /dev/null @@ -1,45 +0,0 @@ -// -#nullable enable - -namespace Equatable.Entities -{ - partial class NestedOrderedContract : global::System.IEquatable - { - /// - public bool Equals(global::Equatable.Entities.NestedOrderedContract? other) - { - return !(other is null) - && Id == other.Id - && (new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer>(global::System.Collections.Generic.EqualityComparer.Default, new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default))).Equals(NestedDicts, other.NestedDicts); - - } - - /// - public override bool Equals(object? obj) - { - return Equals(obj as global::Equatable.Entities.NestedOrderedContract); - } - - /// - public static bool operator ==(global::Equatable.Entities.NestedOrderedContract? left, global::Equatable.Entities.NestedOrderedContract? right) - { - return global::System.Collections.Generic.EqualityComparer.Default.Equals(left, right); - } - - /// - public static bool operator !=(global::Equatable.Entities.NestedOrderedContract? left, global::Equatable.Entities.NestedOrderedContract? right) - { - return !(left == right); - } - - /// - public override int GetHashCode(){ - int hashCode = 1020457703; - hashCode = (hashCode * -1521134295) + Id.GetHashCode(); - hashCode = (hashCode * -1521134295) + (new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer>(global::System.Collections.Generic.EqualityComparer.Default, new global::Equatable.Comparers.OrderedReadOnlyDictionaryEqualityComparer(global::System.Collections.Generic.EqualityComparer.Default, global::System.Collections.Generic.EqualityComparer.Default))).GetHashCode(NestedDicts!); - return hashCode; - - } - - } -}