diff --git a/Directory.Packages.props b/Directory.Packages.props index 5e49605..53801b2 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -10,6 +10,13 @@ + + + + + + + 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 @@ + + + + diff --git a/README.md b/README.md index 7065546..d92fdf3 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,613 @@ 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 +## Overview -- 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. +By default, C# classes inherit `Equals` from `object`, which compares object references rather than values: -### Usage +```csharp +var a = new Product { Id = 1, Name = "Widget" }; +var b = new Product { Id = 1, Name = "Widget" }; +Console.WriteLine(a == b); // false — distinct instances, even though all values are identical +``` + +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 +``` + +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. + +> `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. -#### Add package +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: -Add the nuget package to your projects. +- **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. -`dotnet add package Equatable.Generator` +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. -Prevent including Equatable.Generator as a dependency +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 + +| 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(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 | + +## Getting started ```xml + ``` -### Requirements +When using the adapter packages, add the corresponding adapter generator the same way: -This library requires: +```xml + + -- Target framework .NET Standard 2.0 or greater -- Project C# `LangVersion` 8.0 or higher + + +``` -### Equatable Attributes +`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. -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. +Mark your class as `partial` and add `[Equatable]`: -- `[Equatable]` Marks the class to generate overrides for `Equals` and `GetHashCode` +```csharp +[Equatable] +public partial class Product +{ + public int Id { get; set; } + public string? Name { get; set; } + public decimal Price { get; set; } +} +``` - The default comparer used in the implementation of `Equals` and `GetHashCode` is `EqualityComparer.Default`. Customize the comparer used with the following attributes. +The generator produces `Equals` and `GetHashCode` implementations covering every public property. Supported type declarations: `class`, `record`, `struct`, and `readonly struct`. -- `[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` +## All attributes at a glance -### Example Usage +### `Equatable.Generator` — class-level trigger -Example of using the attributes to customize the source generation of `Equals` and `GetHashCode` +| Attribute | What it does | +|---|---| +| `[Equatable]` | Triggers generation; includes all public properties | -``` c# -[Equatable] -public partial class UserImport +### `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 | +|---|---|---| +| `[IgnoreEquality]` | Exclude this property from equality | — | +| `[StringEquality(StringComparison.X)]` | `StringComparer.X.Equals(a, b)` | — | +| `[EqualityComparer(typeof(T))]` | `T.Default.Equals(a, b)` — any custom `IEqualityComparer` | — | +| `[ReferenceEquality]` | `Object.ReferenceEquals(a, b)` | — | +| `[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` | + +### `Equatable.Generator.DataContract` — class-level trigger + +| Attribute | Package | What it does | +|---|---|---| +| `[DataContractEquatable]` | `Equatable.Generator.DataContract` | Triggers generation; reads `[DataMember]` to select properties | + +Property selection is driven by `System.Runtime.Serialization` attributes, which come from the BCL or the serialisation library already in 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]`) 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 + +| Attribute | Package | What it does | +|---|---|---| +| `[MessagePackEquatable]` | `Equatable.Generator.MessagePack` | Triggers generation; reads `[Key(n)]` to select properties | + +Property selection is driven by MessagePack attributes from the `MessagePack` package already in 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` 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 the type is already annotated for serialisation. The adapter derives property selection from the existing serialisation attributes, requiring no duplication of intent. + +### 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 | + +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 + +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] +[DataContractEquatable] +public partial class EventContract +{ + [DataMember(Order = 0)] public int EventId { get; set; } + + // List → SequenceEqualityComparer inferred — no attribute required + [DataMember(Order = 1)] public List? Tags { get; set; } + + // Dictionary → DictionaryEqualityComparer inferred — no attribute required + [DataMember(Order = 2)] public Dictionary? Scores { get; set; } + + [IgnoreDataMember] + public DateTime LastSeen { get; set; } // excluded from equality +} +``` + +```csharp +[MessagePackObject] +[MessagePackEquatable] +public partial class LiveScore { + [Key(0)] public int MatchId { get; set; } + [Key(1)] public int HomeScore { get; set; } + + // HashSet → HashSetEqualityComparer inferred — no attribute required + [Key(2)] public HashSet? Tags { get; set; } + + [IgnoreMember] + public DateTime ReceivedAt { get; set; } // excluded +} +``` + +### Overriding the inferred comparer + +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 — element order is irrelevant + [DataMember(Order = 0)] + [HashSetEquality] + public List? PermissionCodes { get; set; } + + // Override: case-insensitive string comparison + [DataMember(Order = 1)] [StringEquality(StringComparison.OrdinalIgnoreCase)] - public string EmailAddress { get; set; } = null!; + public string? Region { get; set; } - public string? DisplayName { get; set; } + // Override: fully custom comparer + [DataMember(Order = 2)] + [EqualityComparer(typeof(CountOnlyComparer))] + public Dictionary? Weights { get; set; } +} +``` + +### Serialised but excluded from equality - public string? FirstName { get; set; } +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. - public string? LastName { get; set; } +Combine `[DataMember]` or `[Key(n)]` with `[IgnoreEquality]`: - public DateTimeOffset? LockoutEnd { get; set; } +```csharp +[DataContract] +[DataContractEquatable] +public partial class OrderDataContract +{ + [DataMember(Order = 0)] + public int Id { get; set; } - public DateTimeOffset? LastLogin { 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 string FullName => $"{FirstName} {LastName}"; + public DateTime LastModified { get; set; } +} +``` - [HashSetEquality] - public HashSet? Roles { 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 + +**Default for:** `List`, `T[]` — no attribute required 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 +``` + +`["A","B","C"]` equals `["A","B","C"]` ✓ +`["A","B","C"]` does NOT equal `["C","B","A"]` ✓ + +**Direction override:** apply to `HashSet` to enforce order-sensitive comparison on a normally unordered type. + +```csharp +[SequenceEquality] +public HashSet? OrderedTags { get; set; } // override: element order now matters +``` + +--- + +### `[HashSetEquality]` — order-insensitive comparison + +**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. + +```csharp +public HashSet? Roles { get; set; } // HashSetEquality by default +``` + +`{"admin","editor"}` equals `{"editor","admin"}` ✓ + +**Direction override:** apply to `List` or `T[]` to make the comparison order-insensitive. + +```csharp +[HashSetEquality] +public List? PermissionCodes { get; set; } // override: element order no longer matters +``` + +--- + +### `[DictionaryEquality]` — key-value comparison, insertion order irrelevant - [DictionaryEquality] - public Dictionary? Permissions { get; set; } +**Default for:** `Dictionary` — no attribute required on plain dictionaries. - [SequenceEquality] - public List? History { get; set; } +**Supported types:** any `IReadOnlyDictionary` — `Dictionary`, `IReadOnlyDictionary`, `SortedDictionary`, `ConcurrentDictionary`, and more. + +```csharp +public Dictionary? Prices { get; set; } // DictionaryEquality by default +``` + +`{a:1.85, b:1.90}` equals `{b:1.90, a:1.85}` ✓ + +--- + +## Nested collections + +Annotate the **outer property once** — the generator selects the appropriate comparer for every nested level automatically. + +### `[DictionaryEquality]` + +```csharp +// outer: DictionaryEquality +// inner Dictionary: DictionaryEquality (propagated) +public Dictionary>? ByRegion { get; set; } + +// outer: DictionaryEquality +// inner List: SequenceEquality (default for List) +public Dictionary>? ScoresByRegion { get; set; } + +// outer: DictionaryEquality +// inner HashSet: HashSetEquality (default for HashSet) +public Dictionary>? TagsByRegion { get; set; } +``` + +### `[SequenceEquality]` + +```csharp +// outer: SequenceEquality (element 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 propagate transparently + +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) + +[HashSetEquality] +public List? Permissions { get; set; } // HashSetEquality (explicit override) + +[SequenceEquality] +public HashSet? OrderedSet { get; set; } // SequenceEquality (explicit override) +``` + +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) +// inner HashSet → HashSetEquality (default for HashSet) +public List>? Groups { get; set; } + +// 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 — element order now matters) +// inner List → SequenceEquality (propagated from outer override) +[SequenceEquality] +public HashSet>? OrderedGroups { get; set; } +``` + +--- + +## Multi-dimensional arrays + +`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 applied by default +public int[,] Grid { get; set; } + +// 3D array — rank is detected at compile time; same default applies +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** (position matters) + +```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 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,...) + +var d = new int[,,] { { { 1, 2 }, { 3, 4 } } }; +// a != d ✓ (rank 2 vs rank 3 — always unequal regardless of content) +``` + +### Comparer overrides for multi-dimensional arrays + +`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[]` supports the full range of 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]` — custom comparer + +When no built-in attribute is appropriate, supply a custom `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; } ``` -Works for `record` types too +--- -```c# -[Equatable] -public partial record StatusRecord( - int Id, - [property: StringEquality(StringComparison.OrdinalIgnoreCase)] string Name, - string? Description, - int DisplayOrder, - bool IsActive, - [property: SequenceEquality] List Versions -); +## Build-time diagnostics + +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 + +| 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** 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 applied as the default — no annotation is required or accepted. + +**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** 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] +[DataContractEquatable] +public partial class EventContract +{ + [DataMember(Order = 0)] public int EventId { get; set; } // included ✓ + + // EQ0022 — excluded without annotation; intent is ambiguous + 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 + +| Diagnostic | Condition | +|---|---| +| `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 — equality attributes have no effect on multi-dimensional arrays + +`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 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 converts silent, incorrect behaviour into a visible compile-time warning: + +```csharp +// EQ0014 — attribute has no effect on a rank-2 array +[SequenceEquality] +public int[,]? Grid { get; set; } + +// Correct — no attribute required; MultiDimensionalArrayEqualityComparer is the default +public int[,]? Grid { get; set; } ``` + +### EQ0015 — enumerable attributes are not applicable to dictionary types + +`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) +[SequenceEquality] +public Dictionary? Scores { get; set; } + +// EQ0015 — treats Dictionary as a set of KeyValuePair entries +[HashSetEquality] +public Dictionary? Scores { get; set; } + +// Correct — key-value equality, insertion order irrelevant +[DictionaryEquality] +public Dictionary? Scores { get; set; } +``` + +--- + +## Equality invariants + +Every generated implementation satisfies the following properties: + +| 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 correct behaviour when instances are used as dictionary keys or hash set members. + +--- + +## 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. 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 + +- **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` — 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 ensures `GetHashCode(empty) != GetHashCode(null)`, satisfying the hash contract. +- **`HashSetEqualityComparer.GetHashCode` is order-independent** — uses a commutative accumulation strategy, consistent with `SetEquals`-based `Equals`. +- **Value types without `==`** — `EqualityComparer.Default` is used instead of direct operator comparison. + +### Improvements + +- `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 later +- C# language version: 8.0 or higher diff --git a/src/Equatable.Comparers/DictionaryEqualityComparer.cs b/src/Equatable.Comparers/DictionaryEqualityComparer.cs index 61ee694..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)) @@ -71,15 +76,21 @@ public int GetHashCode(IDictionary obj) if (obj == null) return 0; - var hash = new HashCode(); + // Start at 1, not 0: an empty dictionary must not hash the same as null. + // Equals correctly returns false for (null, empty), so GetHashCode must also + // differ — otherwise a hash table would bucket them together and force an Equals + // call that returns false, producing unnecessary collisions. null returns 0 above; + // 1 here ensures an empty collection is always distinguishable. + int hashCode = 1; - // sort by key to ensure dictionary with different order are the same - foreach (var pair in obj.OrderBy(d => d.Key)) - { - hash.Add(pair.Key, KeyComparer); - hash.Add(pair.Value, ValueComparer); - } + // Commutative SUM ensures hash is insertion-order independent, consistent with + // Equals which uses TryGetValue (also order-independent). Previously GetHashCode + // used OrderBy + sequential HashCode.Add, which was order-dependent and violated + // the contract: two equal dictionaries (same keys/values, different insertion order) + // would produce different hash codes. + foreach (var pair in obj) + hashCode += HashCode.Combine(KeyComparer.GetHashCode(pair.Key!), ValueComparer.GetHashCode(pair.Value!)); - return hash.ToHashCode(); + return hashCode; } } diff --git a/src/Equatable.Comparers/HashSetEqualityComparer.cs b/src/Equatable.Comparers/HashSetEqualityComparer.cs index cb02689..8ea56df 100644 --- a/src/Equatable.Comparers/HashSetEqualityComparer.cs +++ b/src/Equatable.Comparers/HashSetEqualityComparer.cs @@ -56,12 +56,21 @@ public int GetHashCode(IEnumerable obj) if (obj == null) return 0; - var hashCode = new HashCode(); + // Start at 1, not 0: an empty set must not hash the same as null. + // Equals correctly returns false for (null, empty), so GetHashCode must also + // differ — otherwise a hash table would bucket them together and force an Equals + // call that returns false, producing unnecessary collisions. null returns 0 above; + // 1 here ensures an empty collection is always distinguishable. + int hashCode = 1; - // sort to ensure set with different order are the same - foreach (var item in obj.OrderBy(s => s)) - hashCode.Add(item, Comparer); + // Commutative SUM ensures hash is iteration-order independent, consistent with + // Equals which uses SetEquals (also order-independent). Previously GetHashCode + // used OrderBy + sequential HashCode.Add, which was order-dependent and violated + // the contract: two equal sets (same elements, different insertion order) could + // produce different hash codes. + foreach (var item in obj) + hashCode += Comparer.GetHashCode(item!); - return hashCode.ToHashCode(); + return hashCode; } } 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.Comparers/ReadOnlyDictionaryEqualityComparer.cs b/src/Equatable.Comparers/ReadOnlyDictionaryEqualityComparer.cs new file mode 100644 index 0000000..7ea05bc --- /dev/null +++ b/src/Equatable.Comparers/ReadOnlyDictionaryEqualityComparer.cs @@ -0,0 +1,91 @@ +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; + + // 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 (!yLookup.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; + + // Start at 1, not 0: an empty dictionary must not hash the same as null. + // Equals correctly returns false for (null, empty), so GetHashCode must also + // differ — otherwise a hash table would bucket them together and force an Equals + // call that returns false, producing unnecessary collisions. null returns 0 above; + // 1 here ensures an empty collection is always distinguishable. + int hashCode = 1; + + foreach (var pair in obj) + hashCode += HashCode.Combine(KeyComparer.GetHashCode(pair.Key!), ValueComparer.GetHashCode(pair.Value!)); + + return hashCode; + } +} diff --git a/src/Equatable.Generator.DataContract/Attributes/DataContractEquatableAttribute.cs b/src/Equatable.Generator.DataContract/Attributes/DataContractEquatableAttribute.cs new file mode 100644 index 0000000..6c949d8 --- /dev/null +++ b/src/Equatable.Generator.DataContract/Attributes/DataContractEquatableAttribute.cs @@ -0,0 +1,12 @@ +using System.Diagnostics; + +namespace Equatable.Attributes.DataContract; + +/// +/// 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.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.MessagePack/Attributes/MessagePackEquatableAttribute.cs b/src/Equatable.Generator.MessagePack/Attributes/MessagePackEquatableAttribute.cs new file mode 100644 index 0000000..5c67097 --- /dev/null +++ b/src/Equatable.Generator.MessagePack/Attributes/MessagePackEquatableAttribute.cs @@ -0,0 +1,12 @@ +using System.Diagnostics; + +namespace Equatable.Attributes.MessagePack; + +/// +/// 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.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.Generator/Attributes/DictionaryEqualityAttribute.cs b/src/Equatable.Generator/Attributes/DictionaryEqualityAttribute.cs index c7bf7b9..a8f30ee 100644 --- a/src/Equatable.Generator/Attributes/DictionaryEqualityAttribute.cs +++ b/src/Equatable.Generator/Attributes/DictionaryEqualityAttribute.cs @@ -3,8 +3,11 @@ 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. +/// Two dictionaries are equal when they contain the same key/value pairs, regardless of insertion order. /// [Conditional("EQUATABLE_GENERATOR")] [AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)] -public class DictionaryEqualityAttribute : Attribute; +public class DictionaryEqualityAttribute : Attribute +{ +} 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.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..9a06433 --- /dev/null +++ b/src/Equatable.SourceGenerator.DataContract/DataContractEquatableAnalyzer.cs @@ -0,0 +1,105 @@ +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 + ); + + 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, UnannotatedPropertyOnDataContractEquatable); + + 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)) + { + 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 propertyLocation = property.Locations.FirstOrDefault(); + context.ReportDiagnostic(Diagnostic.Create( + UnannotatedPropertyOnDataContractEquatable, + propertyLocation, + property.Name, + typeSymbol.Name)); + } + } + + private static bool HasDataContractEquatableAttribute(INamedTypeSymbol typeSymbol) => + typeSymbol.GetAttributes().Any(a => a.AttributeClass is + { + Name: "DataContractEquatableAttribute", + ContainingNamespace: { Name: "DataContract", 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" } } + }); + + 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.DataContract/DataContractEquatableGenerator.cs b/src/Equatable.SourceGenerator.DataContract/DataContractEquatableGenerator.cs new file mode 100644 index 0000000..ca06cc4 --- /dev/null +++ b/src/Equatable.SourceGenerator.DataContract/DataContractEquatableGenerator.cs @@ -0,0 +1,46 @@ +using Microsoft.CodeAnalysis; + +namespace Equatable.SourceGenerator.DataContract; + +[Generator] +public class DataContractEquatableGenerator : IIncrementalGenerator +{ + public void Initialize(IncrementalGeneratorInitializationContext context) + { + EquatableGenerator.RegisterProvider(context, + fullyQualifiedMetadataName: "Equatable.Attributes.DataContract.DataContractEquatableAttribute", + trackingName: "DataContractEquatableAttribute", + propertyFilter: IsIncludedDataContract, + postProcessProperty: EquatableGenerator.InferCollectionComparer); + } + + private static bool IsIncludedDataContract(IPropertySymbol propertySymbol) + { + if (!EquatableGenerator.IsPublicInstanceProperty(propertySymbol)) + 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; + + 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", + 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/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/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/MessagePackEquatableAnalyzer.cs b/src/Equatable.SourceGenerator.MessagePack/MessagePackEquatableAnalyzer.cs new file mode 100644 index 0000000..0a7bdae --- /dev/null +++ b/src/Equatable.SourceGenerator.MessagePack/MessagePackEquatableAnalyzer.cs @@ -0,0 +1,97 @@ +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 + ); + + 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, UnannotatedPropertyOnMessagePackEquatable); + + 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)) + { + 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(); + + 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) => + typeSymbol.GetAttributes().Any(a => a.AttributeClass is + { + Name: "MessagePackEquatableAttribute", + ContainingNamespace: { Name: "MessagePack", ContainingNamespace: { Name: "Attributes", ContainingNamespace.Name: "Equatable" } } + }); + + private static bool HasMessagePackObjectAttribute(INamedTypeSymbol typeSymbol) => + typeSymbol.GetAttributes().Any(a => a.AttributeClass is + { + 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/src/Equatable.SourceGenerator.MessagePack/MessagePackEquatableGenerator.cs b/src/Equatable.SourceGenerator.MessagePack/MessagePackEquatableGenerator.cs new file mode 100644 index 0000000..6ca4017 --- /dev/null +++ b/src/Equatable.SourceGenerator.MessagePack/MessagePackEquatableGenerator.cs @@ -0,0 +1,38 @@ +using Microsoft.CodeAnalysis; + +namespace Equatable.SourceGenerator.MessagePack; + +[Generator] +public class MessagePackEquatableGenerator : IIncrementalGenerator +{ + public void Initialize(IncrementalGeneratorInitializationContext context) + { + EquatableGenerator.RegisterProvider(context, + fullyQualifiedMetadataName: "Equatable.Attributes.MessagePack.MessagePackEquatableAttribute", + trackingName: "MessagePackEquatableAttribute", + propertyFilter: IsIncludedMessagePack, + postProcessProperty: EquatableGenerator.InferCollectionComparer); + } + + private static bool IsIncludedMessagePack(IPropertySymbol propertySymbol) + { + if (!EquatableGenerator.IsPublicInstanceProperty(propertySymbol)) + 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; + + 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/src/Equatable.SourceGenerator/DiagnosticDescriptors.cs b/src/Equatable.SourceGenerator/DiagnosticDescriptors.cs index 21757d7..1027128 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 @@ -58,4 +58,22 @@ 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 + ); + + 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 86c2d04..a8f2f92 100644 --- a/src/Equatable.SourceGenerator/EquatableAnalyzer.cs +++ b/src/Equatable.SourceGenerator/EquatableAnalyzer.cs @@ -16,7 +16,9 @@ public class EquatableAnalyzer : DiagnosticAnalyzer DiagnosticDescriptors.InvalidStringEqualityAttributeUsage, DiagnosticDescriptors.InvalidDictionaryEqualityAttributeUsage, DiagnosticDescriptors.InvalidHashSetEqualityAttributeUsage, - DiagnosticDescriptors.InvalidSequenceEqualityAttributeUsage + DiagnosticDescriptors.InvalidSequenceEqualityAttributeUsage, + DiagnosticDescriptors.InvalidAttributeOnMultiDimensionalArray, + DiagnosticDescriptors.InvalidEnumerableAttributeOnDictionary ); public override void Initialize(AnalysisContext context) @@ -51,7 +53,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; @@ -72,6 +74,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 +88,27 @@ 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)); + } + + // [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( @@ -118,8 +142,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 +166,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( @@ -193,6 +227,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 ff54a97..54bf4f7 100644 --- a/src/Equatable.SourceGenerator/EquatableGenerator.cs +++ b/src/Equatable.SourceGenerator/EquatableGenerator.cs @@ -13,25 +13,34 @@ 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); + } + + public static void RegisterProvider( + IncrementalGeneratorInitializationContext context, + string fullyQualifiedMetadataName, + string trackingName, + Func propertyFilter, + Func? postProcessProperty = null) { var provider = context.SyntaxProvider .ForAttributeWithMetadataName( - fullyQualifiedMetadataName: "Equatable.Attributes.EquatableAttribute", + fullyQualifiedMetadataName: fullyQualifiedMetadataName, predicate: SyntacticPredicate, - transform: SemanticTransform + transform: (ctx, ct) => SemanticTransform(ctx, ct, propertyFilter, postProcessProperty) ) - .Where(static context => context is not null) - .WithTrackingName("EquatableAttribute"); + .Where(static item => item is not null) + .WithTrackingName(trackingName); - // output code - var entityClasses = provider - .Where(static item => item is not null); - - context.RegisterSourceOutput(entityClasses, Execute); + context.RegisterSourceOutput(provider, Execute); } - private static void Execute(SourceProductionContext context, EquatableClass? entityClass) + public static void Execute(SourceProductionContext context, EquatableClass? entityClass) { if (entityClass == null) return; @@ -49,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) + private static EquatableClass? SemanticTransform(GeneratorAttributeSyntaxContext context, CancellationToken cancellationToken, Func propertyFilter, Func? postProcessProperty = null) { if (context.TargetSymbol is not INamedTypeSymbol targetSymbol) return null; @@ -67,10 +76,10 @@ 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) + .Select(p => postProcessProperty != null ? postProcessProperty(p, CreateProperty(p)) : CreateProperty(p)) .ToArray() ?? []; // the seed value of the hash code method @@ -103,7 +112,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 +125,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) @@ -136,19 +145,10 @@ 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 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); @@ -158,44 +158,363 @@ 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); + // 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, enumKind), + IArrayTypeSymbol arrayType when comparerType == ComparerTypes.HashSet + => BuildHashSetArrayComparerExpression(arrayType, enumKind), + IArrayTypeSymbol arrayType => BuildArrayComparerExpression(arrayType, enumKind), + _ => 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); + 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). + // 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, ComparerTypes? enumKind = null) + { + // 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, dictKind: kind); + var valueExpr = BuildElementComparerExpression(valueType, dictKind: kind); + + var keyTypeFq = keyType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + var valueTypeFq = valueType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + + var isReadOnly = IsReadOnlyDictionary(unwrapped) + || (IsDictionary(unwrapped) is false && unwrapped.AllInterfaces.Any(IsReadOnlyDictionary)); + + // only compose when at least one argument needs a non-default comparer + if (keyExpr == null && valueExpr == null) + return null; + + keyExpr ??= $"global::System.Collections.Generic.EqualityComparer<{keyTypeFq}>.Default"; + valueExpr ??= $"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})"; + } + + if ((kind == ComparerTypes.HashSet || kind == ComparerTypes.Sequence) && enumInterface != null) + { + var elementType = enumInterface.TypeArguments[0]; + var elementExpr = BuildElementComparerExpression(elementType, enumKind: enumKind); + + 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 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). + // 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 enumKind == ComparerTypes.HashSet + ? BuildHashSetArrayComparerExpression(arrayType, enumKind) + : BuildArrayComparerExpression(arrayType, enumKind); + + 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) + || (IsDictionary(named) is false && named.AllInterfaces.Any(IsReadOnlyDictionary)); + return BuildDictComparerExpression(asDictInterface, isReadOnly, visited, dictKind); + } + + var asEnumInterface = IsEnumerable(named) ? named + : named.AllInterfaces.FirstOrDefault(IsEnumerable); + + if (asEnumInterface != null) + { + var innerType = asEnumInterface.TypeArguments[0]; + var innerExpr = BuildElementComparerExpression(innerType, visited, dictKind, enumKind); + var innerTypeFq = innerType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + + // 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})"; + + return $"{comparerClass}<{innerTypeFq}>.Default"; + } + + return 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, dictKind) + ?? $"global::System.Collections.Generic.EqualityComparer<{keyTypeFq}>.Default"; + var valueExpr = BuildElementComparerExpression(valueType, visited, dictKind) + ?? $"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, ComparerTypes? enumKind = null) + { + var elementType = arrayType.ElementType; + var elementTypeFq = elementType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + var innerExpr = BuildElementComparerExpression(elementType, enumKind: enumKind); + + 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"; + } + + // 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 {comparerClass}<{elementTypeFq}>({innerExpr})"; + return $"{comparerClass}<{elementTypeFq}>.Default"; + } + + private static string BuildHashSetArrayComparerExpression(IArrayTypeSymbol arrayType, ComparerTypes? enumKind = null) + { + var elementType = arrayType.ElementType; + var elementTypeFq = elementType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + 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 {comparerClass}<{elementTypeFq}>({innerExpr})"; + return $"{comparerClass}<{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 IArrayTypeSymbol { Rank: 1 } + || (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; } @@ -210,7 +529,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), @@ -220,6 +539,11 @@ private static (ComparerTypes? comparerType, string? comparerName, string? compa }; } + private static (ComparerTypes? comparerType, string? comparerName, string? comparerInstance) GetDictionaryComparer(AttributeData? attribute) + { + return (ComparerTypes.Dictionary, null, null); + } + private static (ComparerTypes? comparerType, string? comparerName, string? comparerInstance) GetStringComparer(AttributeData? attribute) { if (attribute == null || attribute.ConstructorArguments.Length != 1) @@ -262,6 +586,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(); @@ -295,7 +622,37 @@ 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) + { + // 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) @@ -321,10 +678,7 @@ private static bool IsEnumerable(INamedTypeSymbol targetSymbol) ContainingNamespace: { Name: "Collections", - ContainingNamespace: - { - Name: "System" - } + ContainingNamespace.Name: "System" } } }; @@ -334,7 +688,7 @@ private static bool IsDictionary(INamedTypeSymbol targetSymbol) { return targetSymbol is { - Name: "IDictionary", + Name: "IDictionary" or "IReadOnlyDictionary", IsGenericType: true, TypeArguments.Length: 2, TypeParameters.Length: 2, @@ -344,10 +698,7 @@ private static bool IsDictionary(INamedTypeSymbol targetSymbol) ContainingNamespace: { Name: "Collections", - ContainingNamespace: - { - Name: "System" - } + ContainingNamespace.Name: "System" } } }; @@ -475,7 +826,7 @@ 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(IsEquatableGeneratorAttribute)) { return currentSymbol; } diff --git a/src/Equatable.SourceGenerator/EquatableWriter.cs b/src/Equatable.SourceGenerator/EquatableWriter.cs index 8179dc5..ee41e73 100644 --- a/src/Equatable.SourceGenerator/EquatableWriter.cs +++ b/src/Equatable.SourceGenerator/EquatableWriter.cs @@ -135,7 +135,6 @@ private static void GenerateEquatable(IndentedStringBuilder codeBuilder, Equatab .Append(", other.") .Append(entityProperty.PropertyName) .Append(")"); - break; case ComparerTypes.HashSet: codeBuilder @@ -187,6 +186,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 @@ -232,7 +242,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 +251,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(); } @@ -465,6 +498,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) + ") @@ -503,28 +544,18 @@ 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 = 1;") + .AppendLine() + .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 +572,10 @@ 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 = 1;") .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("foreach (var item in items)") + .AppendLine(" hashCode += global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(item!);") .AppendLine() .AppendLine("return hashCode;") .DecrementIndent() 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..31ede8b 100644 --- a/test/Equatable.Entities/Equatable.Entities.csproj +++ b/test/Equatable.Entities/Equatable.Entities.csproj @@ -12,15 +12,27 @@ + + + + Analyzer false + + Analyzer + false + + + Analyzer + false + 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/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/OrderDataContract.cs b/test/Equatable.Entities/OrderDataContract.cs new file mode 100644 index 0000000..5cfdc87 --- /dev/null +++ b/test/Equatable.Entities/OrderDataContract.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; +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; } + + // not included — no [DataMember] + public string? InternalNote { get; set; } + + [IgnoreDataMember] + public string? IgnoredField { get; set; } +} 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 new file mode 100644 index 0000000..9ed7503 --- /dev/null +++ b/test/Equatable.Entities/SerializedRecord.cs @@ -0,0 +1,21 @@ +using Equatable.Attributes.MessagePack; +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.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; } +} 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..02f3724 --- /dev/null +++ b/test/Equatable.Generator.Properties.Tests/Equatable.Generator.Properties.Tests.csproj @@ -0,0 +1,35 @@ + + + + net10.0 + Exe + enable + enable + latest + false + true + + + + + + + + + + + + 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..3e56ccf --- /dev/null +++ b/test/Equatable.Generator.Properties.Tests/Properties/NestedCollectionsProperties.cs @@ -0,0 +1,787 @@ +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_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() }; + // 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] + 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(); + } + + // ══════════════════════════════════════════════════════════════════════ + // 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.Properties.Tests/Properties/OrderDataContractProperties.cs b/test/Equatable.Generator.Properties.Tests/Properties/OrderDataContractProperties.cs new file mode 100644 index 0000000..188e2b9 --- /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(false); + + 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(false); + + 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..8653e62 --- /dev/null +++ b/test/Equatable.Generator.Properties.Tests/Properties/SerializedRecordProperties.cs @@ -0,0 +1,78 @@ +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 || double.IsNaN(score)) + return true.ToProperty().When(false); + + 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) + { + // 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 }; + 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/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/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/Comparers/ComparerGetHashCodeTest.cs b/test/Equatable.Generator.Tests/Comparers/ComparerGetHashCodeTest.cs new file mode 100644 index 0000000..451e613 --- /dev/null +++ b/test/Equatable.Generator.Tests/Comparers/ComparerGetHashCodeTest.cs @@ -0,0 +1,205 @@ +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)); + } + + // ── 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/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/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"])); + } } diff --git a/test/Equatable.Generator.Tests/DataContractAnalyzerTest.cs b/test/Equatable.Generator.Tests/DataContractAnalyzerTest.cs new file mode 100644 index 0000000..0546a9a --- /dev/null +++ b/test/Equatable.Generator.Tests/DataContractAnalyzerTest.cs @@ -0,0 +1,226 @@ +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); + } + + // ── 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/DataContractGeneratorTest.cs b/test/Equatable.Generator.Tests/DataContractGeneratorTest.cs new file mode 100644 index 0000000..15b8a8b --- /dev/null +++ b/test/Equatable.Generator.Tests/DataContractGeneratorTest.cs @@ -0,0 +1,374 @@ +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 GenerateDataContractEquatableWithDictionaryEqualityOverride() + { + 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 DictionaryOverrideContract +{ + [DataMember(Order = 0)] + public int Id { get; set; } + + [DataMember(Order = 1)] + [DictionaryEquality] + 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"); + } + + // ── [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() + { + 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"); + } + + // ── dictKind propagation ────────────────────────────────────────────────────────────────────── + // [DictionaryEquality] 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] + public Dictionary>? NestedDicts { 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/DictionaryHashCodeTest.cs b/test/Equatable.Generator.Tests/DictionaryHashCodeTest.cs new file mode 100644 index 0000000..0ad4602 --- /dev/null +++ b/test/Equatable.Generator.Tests/DictionaryHashCodeTest.cs @@ -0,0 +1,127 @@ +using Equatable.Comparers; + +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: +/// +/// 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)); + } + + /// + /// 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)); + } +} 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/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/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/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()); + } +} diff --git a/test/Equatable.Generator.Tests/Equatable.Generator.Tests.csproj b/test/Equatable.Generator.Tests/Equatable.Generator.Tests.csproj index 52a4534..683c9ad 100644 --- a/test/Equatable.Generator.Tests/Equatable.Generator.Tests.csproj +++ b/test/Equatable.Generator.Tests/Equatable.Generator.Tests.csproj @@ -16,6 +16,7 @@ + @@ -24,15 +25,28 @@ + + Analyzer true + + Analyzer + true + + + Analyzer + true + + + + diff --git a/test/Equatable.Generator.Tests/EquatableAnalyzerTest.cs b/test/Equatable.Generator.Tests/EquatableAnalyzerTest.cs index ba33aab..00441cf 100644 --- a/test/Equatable.Generator.Tests/EquatableAnalyzerTest.cs +++ b/test/Equatable.Generator.Tests/EquatableAnalyzerTest.cs @@ -1,12 +1,3 @@ -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; public class EquatableAnalyzerTest @@ -40,7 +31,7 @@ public partial class UserImport } "; - var diagnostics = await GetAnalyzerDiagnosticsAsync(source); + var diagnostics = await AnalyzerTestHelper.GetAnalyzerDiagnosticsAsync(source); Assert.Empty(diagnostics); } @@ -65,7 +56,7 @@ public partial class UserImport } "; - var diagnostics = await GetAnalyzerDiagnosticsAsync(source); + var diagnostics = await AnalyzerTestHelper.GetAnalyzerDiagnosticsAsync(source); Assert.Empty(diagnostics); } @@ -88,7 +79,7 @@ public partial class Audit } "; - var diagnostics = await GetAnalyzerDiagnosticsAsync(source); + var diagnostics = await AnalyzerTestHelper.GetAnalyzerDiagnosticsAsync(source); Assert.Empty(diagnostics); } @@ -121,7 +112,7 @@ public class LengthEqualityComparer : IEqualityComparer } "; - var diagnostics = await GetAnalyzerDiagnosticsAsync(source); + var diagnostics = await AnalyzerTestHelper.GetAnalyzerDiagnosticsAsync(source); Assert.Empty(diagnostics); } @@ -141,7 +132,7 @@ public class NotEquatable } "; - var diagnostics = await GetAnalyzerDiagnosticsAsync(source); + var diagnostics = await AnalyzerTestHelper.GetAnalyzerDiagnosticsAsync(source); Assert.Empty(diagnostics); } @@ -165,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); @@ -191,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); @@ -217,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); @@ -243,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); @@ -268,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); @@ -293,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); @@ -318,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); @@ -341,7 +332,7 @@ public partial class UserImport } "; - var diagnostics = await GetAnalyzerDiagnosticsAsync(source); + var diagnostics = await AnalyzerTestHelper.GetAnalyzerDiagnosticsAsync(source); Assert.Empty(diagnostics); } @@ -363,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); @@ -387,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")); @@ -412,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); @@ -437,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); @@ -462,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); @@ -487,7 +478,7 @@ public partial class UserImport } "; - var diagnostics = await GetAnalyzerDiagnosticsAsync(source); + var diagnostics = await AnalyzerTestHelper.GetAnalyzerDiagnosticsAsync(source); Assert.Empty(diagnostics); } @@ -510,7 +501,7 @@ public partial class UserImport } "; - var diagnostics = await GetAnalyzerDiagnosticsAsync(source); + var diagnostics = await AnalyzerTestHelper.GetAnalyzerDiagnosticsAsync(source); Assert.Empty(diagnostics); } @@ -537,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); @@ -568,31 +559,431 @@ 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 AnalyzeMissingAttributeForISet() + { + // ISet implements IEnumerable → EQ0002 fires when no attribute is present + const string source = @" +using System.Collections.Generic; +using Equatable.Attributes; + +namespace Equatable.Entities; + +[Equatable] +public partial class UserImport +{ + public ISet? Tags { get; set; } +} +"; + var diagnostics = await AnalyzerTestHelper.GetAnalyzerDiagnosticsAsync(source); + + var diagnostic = Assert.Single(diagnostics); + Assert.Equal("EQ0002", diagnostic.Id); + Assert.Contains("Tags", diagnostic.GetMessage()); + } + + [Fact] + public async Task AnalyzeMissingAttributeForIReadOnlySet() + { + // IReadOnlySet implements IEnumerable → EQ0002 fires when no attribute is present + const string source = @" +using System.Collections.Generic; +using Equatable.Attributes; + +namespace Equatable.Entities; + +[Equatable] +public partial class UserImport +{ + public IReadOnlySet? Roles { get; set; } +} +"; + var diagnostics = await AnalyzerTestHelper.GetAnalyzerDiagnosticsAsync(source); + + var diagnostic = Assert.Single(diagnostics); + Assert.Equal("EQ0002", diagnostic.Id); + Assert.Contains("Roles", diagnostic.GetMessage()); + } + + [Fact] + public async Task AnalyzeMissingAttributeForArray() + { + // int[] without [SequenceEquality] → EQ0002 + const string source = @" +using Equatable.Attributes; + +namespace Equatable.Entities; + +[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 AnalyzeMultiDimensionalArrayNoAttributeNoWarning() + { + // int[,] without any attribute → no diagnostic (MultiDimensionalArrayEqualityComparer is the default) + const string source = @" +using Equatable.Attributes; + +namespace Equatable.Entities; + +[Equatable] +public partial class Grid +{ + public int[,]? Cells { get; set; } +} +"; + var diagnostics = await AnalyzerTestHelper.GetAnalyzerDiagnosticsAsync(source); + + Assert.Empty(diagnostics); + } + + [Fact] + public async Task AnalyzeHashSetEqualityOnISetIsValid() + { + // [HashSetEquality] on ISet must NOT emit EQ0012 + const string source = @" +using System.Collections.Generic; +using Equatable.Attributes; + +namespace Equatable.Entities; + +[Equatable] +public partial class UserImport +{ + [HashSetEquality] + public ISet? Tags { get; set; } +} +"; + var diagnostics = await AnalyzerTestHelper.GetAnalyzerDiagnosticsAsync(source); + + Assert.Empty(diagnostics); + } + + [Fact] + public async Task AnalyzeHashSetEqualityOnIReadOnlySetIsValid() + { + // [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); } - private static async Task> GetAnalyzerDiagnosticsAsync(string source) + // ── 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() { - 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), - ]); + // [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; - var compilation = CSharpCompilation.Create( - "Test.Analyzer", - [syntaxTree], - references, - new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); +[Equatable] +public partial class Grid +{ + [HashSetEquality] + public int[,]? Cells { get; set; } +} +"; + var diagnostics = await AnalyzerTestHelper.GetAnalyzerDiagnosticsAsync(source); - var analyzer = new EquatableAnalyzer(); - var compilationWithAnalyzers = compilation.WithAnalyzers([analyzer]); + var diagnostic = Assert.Single(diagnostics); + Assert.Equal("EQ0014", diagnostic.Id); + Assert.Contains("Cells", diagnostic.GetMessage()); + } - return await compilationWithAnalyzers.GetAnalyzerDiagnosticsAsync(); + [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); + } + + // ── 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); } } diff --git a/test/Equatable.Generator.Tests/EquatableGeneratorTest.cs b/test/Equatable.Generator.Tests/EquatableGeneratorTest.cs index 281a2c8..9a9acd7 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; @@ -540,23 +542,616 @@ public partial class UserImport .ScrubLinesContaining("GeneratedCodeAttribute"); } - private static (ImmutableArray Diagnostics, string Output) GetGeneratedOutput(string source) - where T : IIncrementalGenerator, new() + [Fact] + public Task GenerateSequenceEqualityMultiDimensionalArray() { - var syntaxTree = CSharpSyntaxTree.ParseText(source); - var references = AppDomain.CurrentDomain.GetAssemblies() + 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"); + } + + /// + /// 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() + { + var source = @" +using System.Collections.Generic; +using Equatable.Attributes; + +namespace Equatable.Entities; + +[Equatable] +public partial class LookupTable +{ + [DictionaryEquality] + public IReadOnlyDictionary? FlatEntries { get; set; } +} +"; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + + return Verifier + .Verify(output) + .UseDirectory("Snapshots") + .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"); + } + + // ── 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 source = @" +using System.Runtime.Serialization; +using Equatable.Attributes; +using Equatable.Attributes.DataContract; + +namespace Equatable.Entities; + +[Equatable] +public partial class ConcreteRecord : ContractBase +{ + public int Rank { get; set; } +} + +[DataContract] +[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; +using Equatable.Attributes.MessagePack; + +namespace Equatable.Entities; + +[Equatable] +public partial class ConcreteRecord : PackedBase +{ + public string? Label { get; set; } +} + +[MessagePackObject] +[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"); + } + + // ── 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"); + } + + // ── 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"); + } + + [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 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"); + } + + // ── dictKind propagation ────────────────────────────────────────────────────────────────────── + // [DictionaryEquality] propagates into ALL nested dictionary levels. + // Nested enumerables (List, array, HashSet) keep their natural comparer. + + [Fact] + public Task GenerateDictionaryEqualityPropagatesIntoNestedDictionaries() + { + var source = @" +using System.Collections.Generic; +using Equatable.Attributes; + +namespace Equatable.Entities; + +[Equatable] +public partial class Container +{ + /// [DictionaryEquality] 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 = + [ + 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), + ]; + + 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), - ]); + MetadataReference.CreateFromFile(typeof(DataContractEquatableGenerator).Assembly.Location), + MetadataReference.CreateFromFile(typeof(MessagePackEquatableGenerator).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; @@ -569,4 +1164,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/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/MessagePackAnalyzerTest.cs b/test/Equatable.Generator.Tests/MessagePackAnalyzerTest.cs new file mode 100644 index 0000000..74cb263 --- /dev/null +++ b/test/Equatable.Generator.Tests/MessagePackAnalyzerTest.cs @@ -0,0 +1,226 @@ +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); + } + + // ── 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")); + } +} diff --git a/test/Equatable.Generator.Tests/MessagePackGeneratorTest.cs b/test/Equatable.Generator.Tests/MessagePackGeneratorTest.cs new file mode 100644 index 0000000..a5b818a --- /dev/null +++ b/test/Equatable.Generator.Tests/MessagePackGeneratorTest.cs @@ -0,0 +1,374 @@ +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 OrderDataContract +{ + [Key(0)] + public int Id { get; set; } + + [Key(1)] + public string? Name { get; set; } + + public string? InternalNote { get; set; } + + [IgnoreMember] + 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 [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 DerivedContract : BaseContract +{ + [Key(2)] + public int Rank { get; set; } +} + +[MessagePackObject] +[MessagePackEquatable] +public partial class BaseContract +{ + [Key(0)] + public int Id { get; set; } + + [Key(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 [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 int Rank { get; set; } +} + +public abstract class UnannotatedBase +{ + [Key(0)] + public int Id { get; set; } + + [Key(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 [Key] property must override the inferred comparer. + + [Fact] + public Task GenerateMessagePackEquatableWithDictionaryEqualityOverride() + { + var source = @" +using System.Collections.Generic; +using MessagePack; +using Equatable.Attributes; +using Equatable.Attributes.MessagePack; + +namespace Equatable.Entities; + +[MessagePackObject] +[MessagePackEquatable] +public partial class DictionaryOverrideContract +{ + [Key(0)] + public int Id { get; set; } + + [Key(1)] + [DictionaryEquality] + 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 ContractWithCollections +{ + [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; } + + 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 GenerateMessagePackEquatableWithNestedCollectionComparers() + { + var source = @" +using System.Collections.Generic; +using MessagePack; +using Equatable.Attributes.MessagePack; + +namespace Equatable.Entities; + +[MessagePackObject] +[MessagePackEquatable] +public partial class ContractWithNestedCollections +{ + [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? InternalNote { get; set; } +} +"; + var (diagnostics, output) = GetGeneratedOutput(source); + Assert.Empty(diagnostics); + 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() + { + 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"); + } + + // ── dictKind propagation ────────────────────────────────────────────────────────────────────── + // [DictionaryEquality] 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] + public Dictionary>? NestedDicts { 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/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 new file mode 100644 index 0000000..352b7b5 --- /dev/null +++ b/test/Equatable.Generator.Tests/Properties/DictionaryComparerProperties.cs @@ -0,0 +1,70 @@ +using Equatable.Comparers; + +namespace Equatable.Generator.Tests.Properties; + +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) + { + 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) + { + var copy = new Dictionary(dict); + return Prop.ToProperty(Comparer.Equals(dict, copy) && Comparer.GetHashCode(dict) == Comparer.GetHashCode(copy)); + } + + [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 }; + + // different values must NOT be equal + 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 new file mode 100644 index 0000000..991feed --- /dev/null +++ b/test/Equatable.Generator.Tests/Properties/HashSetComparerProperties.cs @@ -0,0 +1,53 @@ +using Equatable.Comparers; + +namespace Equatable.Generator.Tests.Properties; + +[Properties(Arbitrary = new[] { typeof(Arbitraries) })] +public class HashSetComparerProperties +{ + private static readonly HashSetEqualityComparer Comparer = HashSetEqualityComparer.Default; + + [Property] + public Property Equals_Reflexivity_SameInstance_ReturnsTrue(HashSet set) + { + return Prop.ToProperty(Comparer.Equals(set, set)); + } + + [Property] + public Property Equals_Symmetry_AEqualsB_ImpliesBEqualsA(HashSet x, HashSet y) + { + return Prop.ToProperty(Comparer.Equals(x, y) == Comparer.Equals(y, x)); + } + + [Property] + 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 + var reversed = new HashSet(set.Reverse()); + return Prop.ToProperty(Comparer.GetHashCode(set) == Comparer.GetHashCode(reversed)); + } + + [Property] + 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 Equals_SupersetOfElements_ReturnsFalse(HashSet set, string extra) + { + if (set.Contains(extra)) + return Prop.When(true, true); + + var bigger = new HashSet(set) { extra }; + return Prop.ToProperty(!Comparer.Equals(set, bigger)); + } + + [Property] + public Property Equals_BothNull_ReturnsTrue() + { + 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 new file mode 100644 index 0000000..13daea4 --- /dev/null +++ b/test/Equatable.Generator.Tests/Properties/NestedCollectionsProperties.cs @@ -0,0 +1,779 @@ +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 +/// +[Properties(Arbitrary = new[] { typeof(Arbitraries) })] +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 Prop.ToProperty(a.Equals(b)); + } + + [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 Prop.ToProperty(a.Equals(b)); + } + + [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 Prop.ToProperty((a.GetHashCode() == b.GetHashCode())); + } + + [Property] + public Property DictOfLists_InnerOrderMatters(string key, int v1, int v2) + { + 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 Prop.ToProperty((!a.Equals(b))); + } + + [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 Prop.ToProperty((a.Equals(b) && a.GetHashCode() == b.GetHashCode())); + } + + // ══════════════════════════════════════════════════════════════════════ + // 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 Prop.ToProperty(a.Equals(b)); + } + + [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 Prop.ToProperty(a.Equals(b)); + } + + [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 Prop.ToProperty(a.Equals(b)); + } + + [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 Prop.ToProperty((a.GetHashCode() == b.GetHashCode())); + } + + // ══════════════════════════════════════════════════════════════════════ + // 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 Prop.ToProperty(a.Equals(b)); + } + + [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 Prop.ToProperty(a.Equals(b)); + } + + [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 Prop.ToProperty(a.Equals(b)); + } + + [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 Prop.ToProperty((a.Equals(b) && a.GetHashCode() == b.GetHashCode())); + } + + // ══════════════════════════════════════════════════════════════════════ + // 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 Prop.ToProperty(a.Equals(b)); + } + + [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 Prop.ToProperty(a.Equals(b)); + } + + [Property] + public Property ListOfDicts_OuterOrderMatters(Dictionary d1, Dictionary d2) + { + if (d1.SequenceEqual(d2)) return Prop.When(true, true); + var a = new NestedCollections { ListOfDicts = [d1, d2] }; + var b = new NestedCollections { ListOfDicts = [d2, d1] }; + return Prop.ToProperty((!a.Equals(b))); + } + + [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 Prop.ToProperty((a.Equals(b) && a.GetHashCode() == b.GetHashCode())); + } + + // ══════════════════════════════════════════════════════════════════════ + // 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 Prop.ToProperty(a.Equals(b)); + } + + [Property] + 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() }; + // 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] + public Property ListOfSets_OuterOrderMatters(HashSet s1, HashSet s2) + { + // Two distinct non-equal sets — swapping them must break equality + if (s1.SetEquals(s2)) return Prop.When(true, true); + var a = new NestedCollections { ListOfSets = [s1, s2] }; + var b = new NestedCollections { ListOfSets = [s2, s1] }; + return Prop.ToProperty((!a.Equals(b))); + } + + // ══════════════════════════════════════════════════════════════════════ + // 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 Prop.ToProperty(a.Equals(b)); + } + + [Property] + public Property ListOfLists_OuterOrderMatters(List l1, List l2) + { + if (l1.SequenceEqual(l2)) return Prop.When(true, true); + var a = new NestedCollections { ListOfLists = [l1, l2] }; + var b = new NestedCollections { ListOfLists = [l2, l1] }; + 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 Prop.When(true, true); + var a = new NestedCollections { ListOfLists = [[v1, v2]] }; + var b = new NestedCollections { ListOfLists = [[v2, v1]] }; + return Prop.ToProperty((!a.Equals(b))); + } + + [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 Prop.ToProperty((a.Equals(b) && a.GetHashCode() == b.GetHashCode())); + } + + // ══════════════════════════════════════════════════════════════════════ + // 2-level: HashSet> + // ══════════════════════════════════════════════════════════════════════ + + [Property] + public Property SetOfLists_EqualWhenSameReferences(List> items) + { + // 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>(((IEnumerable>)items).Reverse()) }; + return Prop.ToProperty(a.Equals(b)); + } + + // ══════════════════════════════════════════════════════════════════════ + // 2-level: HashSet> + // ══════════════════════════════════════════════════════════════════════ + + [Property] + 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>(((IEnumerable>)items).Reverse()) }; + return Prop.ToProperty(a.Equals(b)); + } + + // ══════════════════════════════════════════════════════════════════════ + // 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 Prop.ToProperty(a.Equals(b)); + } + + [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 Prop.ToProperty(a.Equals(b)); + } + + [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 Prop.ToProperty(a.Equals(b)); + } + + [Property] + public Property ThreeLevelNested_InnermostOrderMatters(string outerKey, string innerKey, int v1, int v2) + { + if (v1 == v2) return Prop.When(true, 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 Prop.ToProperty((!a.Equals(b))); + } + + [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 Prop.ToProperty((a.Equals(b) && a.GetHashCode() == b.GetHashCode())); + } + + // ══════════════════════════════════════════════════════════════════════ + // 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 Prop.ToProperty(a.Equals(b)); + } + + [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 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 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 Prop.ToProperty((!a.Equals(b))); + } + + [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 Prop.ToProperty(a.Equals(b)); + } + + // ══════════════════════════════════════════════════════════════════════ + // 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 Prop.ToProperty(a.Equals(b)); + } + + [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 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 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 Prop.ToProperty((!a.Equals(b))); + } + + [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 Prop.ToProperty(a.Equals(b)); + } + + // ══════════════════════════════════════════════════════════════════════ + // 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 Prop.ToProperty(a.Equals(b)); + } + + [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 Prop.When(true, true); + var a = new NestedCollections { ListOfDictOfLists = [d1, d2] }; + var b = new NestedCollections { ListOfDictOfLists = [d2, d1] }; + return Prop.ToProperty((!a.Equals(b))); + } + + [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 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 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 Prop.ToProperty((!a.Equals(b))); + } + + // ══════════════════════════════════════════════════════════════════════ + // 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 Prop.ToProperty(a.Equals(b)); + } + + [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 Prop.When(true, true); + var a = new NestedCollections { ListOfDictOfSets = [d1, d2] }; + var b = new NestedCollections { ListOfDictOfSets = [d2, d1] }; + return Prop.ToProperty((!a.Equals(b))); + } + + [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 Prop.ToProperty(a.Equals(b)); + } + + [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 Prop.ToProperty(a.Equals(b)); + } + + // ══════════════════════════════════════════════════════════════════════ + // 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 Prop.ToProperty(a.Equals(b)); + } + + [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 Prop.When(true, true); + var a = new NestedCollections { ListOfListOfDicts = [l1, l2] }; + var b = new NestedCollections { ListOfListOfDicts = [l2, l1] }; + 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 Prop.When(true, true); + var a = new NestedCollections { ListOfListOfDicts = [[d1, d2]] }; + var b = new NestedCollections { ListOfListOfDicts = [[d2, d1]] }; + return Prop.ToProperty((!a.Equals(b))); + } + + [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 Prop.ToProperty(a.Equals(b)); + } + + [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 Prop.ToProperty((a.Equals(b) && a.GetHashCode() == b.GetHashCode())); + } + + // ══════════════════════════════════════════════════════════════════════ + // Arrays: int[] + // ══════════════════════════════════════════════════════════════════════ + + [Property] + public Property FlatArray_EqualWhenSameContent(int[] arr) + { + var a = new NestedCollections { FlatArray = arr }; + var b = new NestedCollections { FlatArray = (int[])arr.Clone() }; + return Prop.ToProperty(a.Equals(b)); + } + + [Property] + public Property FlatArray_OrderMatters(int v1, int v2) + { + if (v1 == v2) return Prop.When(true, true); + var a = new NestedCollections { FlatArray = [v1, v2] }; + var b = new NestedCollections { FlatArray = [v2, v1] }; + return Prop.ToProperty((!a.Equals(b))); + } + + [Property] + public Property FlatArray_EqualImpliesSameHash(int[] arr) + { + var a = new NestedCollections { FlatArray = arr }; + var b = new NestedCollections { FlatArray = (int[])arr.Clone() }; + return Prop.ToProperty((a.Equals(b) && a.GetHashCode() == b.GetHashCode())); + } + + // ══════════════════════════════════════════════════════════════════════ + // 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 Prop.ToProperty(a.Equals(b)); + } + + [Property] + public Property ArrayOfArrays_OuterOrderMatters(int[] inner1, int[] inner2) + { + if (inner1.SequenceEqual(inner2)) return Prop.When(true, true); + var a = new NestedCollections { ArrayOfArrays = [inner1, inner2] }; + var b = new NestedCollections { ArrayOfArrays = [inner2, inner1] }; + return Prop.ToProperty((!a.Equals(b))); + } + + [Property] + public Property ArrayOfArrays_InnerOrderMatters(int v1, int v2) + { + if (v1 == v2) return Prop.When(true, true); + var a = new NestedCollections { ArrayOfArrays = [[v1, v2]] }; + var b = new NestedCollections { ArrayOfArrays = [[v2, v1]] }; + return Prop.ToProperty((!a.Equals(b))); + } + + // ══════════════════════════════════════════════════════════════════════ + // 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 Prop.ToProperty(a.Equals(b)); + } + + [Property] + public Property ArrayOfDicts_OuterOrderMatters(Dictionary d1, Dictionary d2) + { + if (d1.SequenceEqual(d2)) return Prop.When(true, true); + var a = new NestedCollections { ArrayOfDicts = [d1, d2] }; + var b = new NestedCollections { ArrayOfDicts = [d2, d1] }; + return Prop.ToProperty((!a.Equals(b))); + } + + [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 Prop.ToProperty(a.Equals(b)); + } + + // ══════════════════════════════════════════════════════════════════════ + // 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 Prop.ToProperty(a.Equals(b)); + } + + [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 Prop.ToProperty(a.Equals(b)); + } + + [Property] + public Property DictOfArrays_InnerOrderMatters(string key, int v1, int v2) + { + 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 Prop.ToProperty((!a.Equals(b))); + } + + [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 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)); + } +} diff --git a/test/Equatable.Generator.Tests/Properties/OrderDataContractProperties.cs b/test/Equatable.Generator.Tests/Properties/OrderDataContractProperties.cs new file mode 100644 index 0000000..aef1d60 --- /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 Prop.ToProperty(o.Equals(o)); + } + + [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 Prop.ToProperty(a.Equals(b) == b.Equals(a)); + } + + [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 Prop.ToProperty(a.Equals(b) && a.GetHashCode() == b.GetHashCode()); + } + + [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 Prop.ToProperty(a.Equals(b)); + } + + [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 Prop.ToProperty(a.GetHashCode() == b.GetHashCode()); + } + + [Property] + public Property DifferentIdNotEqual(string? name, int id1, int id2) + { + if (id1 == id2) + return Prop.When(true, true); + + var a = new OrderDataContract { Id = id1, Name = name }; + var b = new OrderDataContract { Id = id2, Name = name }; + return Prop.ToProperty(!a.Equals(b)); + } + + [Property] + public Property DifferentNameNotEqual(int id, string name1, string name2) + { + if (name1 == name2) + return Prop.When(true, true); + + var a = new OrderDataContract { Id = id, Name = name1 }; + var b = new OrderDataContract { Id = id, Name = name2 }; + return Prop.ToProperty(!a.Equals(b)); + } +} diff --git a/test/Equatable.Generator.Tests/Properties/ReadOnlyDictionaryComparerProperties.cs b/test/Equatable.Generator.Tests/Properties/ReadOnlyDictionaryComparerProperties.cs new file mode 100644 index 0000000..330977f --- /dev/null +++ b/test/Equatable.Generator.Tests/Properties/ReadOnlyDictionaryComparerProperties.cs @@ -0,0 +1,85 @@ +using Equatable.Comparers; + +namespace Equatable.Generator.Tests.Properties; + +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) + { + IReadOnlyDictionary d = dict; + return Prop.ToProperty(Comparer.Equals(d, d)); + } + + [Property] + public Property Equals_Symmetry_AEqualsB_ImpliesBEqualsA(Dictionary x, Dictionary y) + { + IReadOnlyDictionary a = x; + IReadOnlyDictionary b = y; + return Prop.ToProperty(Comparer.Equals(a, b) == Comparer.Equals(b, a)); + } + + [Property] + public Property HashCode_EqualDictionaries_HaveSameHash(Dictionary dict) + { + // 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.When(Comparer.Equals(a, b), Comparer.GetHashCode(a) == Comparer.GetHashCode(b)); + } + + [Property] + public Property HashCode_InsertionOrderIndependent(Dictionary dict) + { + IReadOnlyDictionary a = dict; + IReadOnlyDictionary b = new Dictionary(dict.Reverse()); + return Prop.ToProperty(Comparer.GetHashCode(a) == Comparer.GetHashCode(b)); + } + + [Property] + public Property Equals_BothNull_ReturnsTrue() + { + return Prop.ToProperty(Comparer.Equals(null, null)); + } + + [Property] + public Property Equals_OneNull_ReturnsFalse(Dictionary dict) + { + IReadOnlyDictionary d = dict; + return Prop.ToProperty(!Comparer.Equals(null, d) && !Comparer.Equals(d, null)); + } + + [Property] + public Property Equals_DictionaryWithExtraKey_ReturnsFalse(Dictionary dict, string key, int value) + { + // guard: key must not already be in dict + if (dict.ContainsKey(key)) + return Prop.When(true, true); // vacuously true — skip this input + + IReadOnlyDictionary a = dict; + var bigger = new Dictionary(dict) { [key] = value }; + IReadOnlyDictionary b = bigger; + + return Prop.ToProperty(!Comparer.Equals(a, b)); + } +} 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.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/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.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/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 new file mode 100644 index 0000000..f7e0faa --- /dev/null +++ b/test/Equatable.Generator.Tests/Snapshots/DataContractGeneratorTest.GenerateDataContractEquatableWithDictionaryEqualityPropagation.verified.txt @@ -0,0 +1,45 @@ +// +#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.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); + + } + + /// + 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 = 1020457703; + hashCode = (hashCode * -1521134295) + Id.GetHashCode(); + 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.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.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.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.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.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.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.GenerateDictionaryEqualityPropagatesIntoNestedDictionaries.verified.txt b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateDictionaryEqualityPropagatesIntoNestedDictionaries.verified.txt new file mode 100644 index 0000000..0b49afa --- /dev/null +++ b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateDictionaryEqualityPropagatesIntoNestedDictionaries.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.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 = -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.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.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.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.GenerateIDictionaryEquality.verified.txt b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateIDictionaryEquality.verified.txt new file mode 100644 index 0000000..ea79ec8 --- /dev/null +++ b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateIDictionaryEquality.verified.txt @@ -0,0 +1,103 @@ +// +#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 = 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/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..2d7356a --- /dev/null +++ b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateISetHashSetEquality.verified.txt @@ -0,0 +1,74 @@ +// +#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 = 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.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.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.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/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; + + } + + } +} 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..e7d6295 --- /dev/null +++ b/test/Equatable.Generator.Tests/Snapshots/EquatableGeneratorTest.GenerateReadOnlyDictionary.verified.txt @@ -0,0 +1,103 @@ +// +#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 = 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/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.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/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/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..853bdc5 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,17 @@ 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 = 1; - // 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!); - } + 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 +147,10 @@ namespace Equatable.Entities if (items is null) return 0; - int hashCode = 275986352; + int hashCode = 1; - // 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!); + 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.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 d271508..67d2ecf 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,17 @@ 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 = 1; - // 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!); - } + 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 +134,10 @@ namespace Equatable.Entities if (items is null) return 0; - int hashCode = -1758092530; + int hashCode = 1; - // 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!); + 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.GenerateMessagePackEquatable.verified.txt b/test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatable.verified.txt new file mode 100644 index 0000000..d7fe908 --- /dev/null +++ b/test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatable.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/MessagePackGeneratorTest.GenerateMessagePackEquatableDerivedFromMessagePackEquatableBase.verified.txt b/test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatableDerivedFromMessagePackEquatableBase.verified.txt new file mode 100644 index 0000000..c07dab0 --- /dev/null +++ b/test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatableDerivedFromMessagePackEquatableBase.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/MessagePackGeneratorTest.GenerateMessagePackEquatableDerivedIncludesUnannotatedBase.verified.txt b/test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatableDerivedIncludesUnannotatedBase.verified.txt new file mode 100644 index 0000000..43258cd --- /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) + && 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/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; + + } + + } +} 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 new file mode 100644 index 0000000..f7e0faa --- /dev/null +++ b/test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatableWithDictionaryEqualityPropagation.verified.txt @@ -0,0 +1,45 @@ +// +#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.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); + + } + + /// + 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 = 1020457703; + hashCode = (hashCode * -1521134295) + Id.GetHashCode(); + 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.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.GenerateMessagePackEquatableWithInferredCollectionComparers.verified.txt b/test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatableWithInferredCollectionComparers.verified.txt new file mode 100644 index 0000000..034c428 --- /dev/null +++ b/test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatableWithInferredCollectionComparers.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/MessagePackGeneratorTest.GenerateMessagePackEquatableWithNestedCollectionComparers.verified.txt b/test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatableWithNestedCollectionComparers.verified.txt new file mode 100644 index 0000000..e39793b --- /dev/null +++ b/test/Equatable.Generator.Tests/Snapshots/MessagePackGeneratorTest.GenerateMessagePackEquatableWithNestedCollectionComparers.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/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.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; + } + + } + + } +}