Skip to content

Feature/readonly collections and adapters#140

Open
kasyanovandrii wants to merge 9 commits into
loresoft:mainfrom
kasyanovandrii:feature/readonly-collections-and-adapters
Open

Feature/readonly collections and adapters#140
kasyanovandrii wants to merge 9 commits into
loresoft:mainfrom
kasyanovandrii:feature/readonly-collections-and-adapters

Conversation

@kasyanovandrii
Copy link
Copy Markdown

@kasyanovandrii kasyanovandrii commented May 11, 2026

Problem: Equality Contract Violation in Dictionary and HashSet Comparers

The original DictionaryEqualityComparer, ReadOnlyDictionaryEqualityComparer, and HashSetEqualityComparer violated the
fundamental .NET equality contract:

▎ If Equals(x, y) == true then GetHashCode(x) == GetHashCode(y)

Equals was order-independent (TryGetValue / SetEquals) but GetHashCode was order-dependent (OrderBy + sequential
HashCode.Add). Two logically equal collections in different insertion order would produce different hash codes,
causing incorrect behavior in Dictionary, HashSet, and any equality-based data structure.


Changes

DictionaryEqualityComparer<K,V> and ReadOnlyDictionaryEqualityComparer<K,V>

Equals — unchanged, uses TryGetValue (order-independent).

GetHashCode — was OrderBy(key) + sequential HashCode.Add (order-dependent, inconsistent with Equals). Now uses
commutative SUM of HashCode.Combine(key, value) (order-independent, consistent with Equals).

HashSetEqualityComparer

Equals — unchanged, uses SetEquals (order-independent).

GetHashCode — was OrderBy + sequential HashCode.Add (order-dependent, inconsistent with Equals). Now uses commutative
SUM of element hash codes (order-independent, consistent with Equals).

New: [DictionaryEquality(sequential: true)]

Opt-in key-sorted comparison mode where both Equals and GetHashCode use OrderBy(key) — consistent with each other and
deterministic regardless of Dictionary<K,V> internal iteration order:

[DictionaryEquality(sequential: true)]
public Dictionary<string, int>? Entries { get; set; }

Backed by two new comparers:

  • OrderedDictionaryEqualityComparer<K,V> — for IDictionary<K,V>
  • OrderedReadOnlyDictionaryEqualityComparer<K,V> — for IReadOnlyDictionary<K,V>

Both use OrderBy(key) with a sort comparer that guarantees strict total order: prefers IComparer if available,
falls back to a hash-code tiebreaker to handle cases where Comparer.Default returns 0 for distinct keys (e.g.
culture-sensitive string.CompareTo treating "" and "\x02" as equal).

Nested collections and multidimensional arrays (unchanged)

The generator composes comparers automatically for nested collection types. These were not affected by the bug.
Examples of what the generator produces:

  • Dict<K, List> → ReadOnlyDictionaryEqualityComparer<K, SequenceEqualityComparer> — outer unordered, inner
    ordered
  • List<Dict<K,V>> → SequenceEqualityComparer<ReadOnlyDictionaryEqualityComparer<K,V>> — outer ordered, inner unordered
  • Dict<K, Dict<K2, List>> → three-level nesting with correct semantics per level
  • int[,] / T[,,] → MultiDimensionalArrayEqualityComparer — row-major order, no LINQ allocations

SequenceEqualityComparer and MultiDimensionalArrayEqualityComparer were always consistent (both order-dependent,
matching their respective Equals semantics) and are unchanged.


Property-Based Tests (FsCheck.Xunit.v3)

  • Migrated from FsCheck.Xunit (xUnit v2 only, previously excluded from compilation) to FsCheck.Xunit.v3 (xUnit v3
    compatible)
  • All property test files rewritten from v2 API (value.ToProperty()) to v3 API (Prop.ToProperty(value))
  • Added Arbitraries class with custom generators for HashSet (not auto-derived in FsCheck v3)
  • Property tests now run as part of the normal test suite (238 tests total, 0 failures)

a.kasyanov and others added 9 commits May 11, 2026 21:51
…attributes

- Support IReadOnlyDictionary<TKey,TValue> in [DictionaryEquality] — generator
  now recognises both IDictionary and IReadOnlyDictionary via AllInterfaces check;
  generated DictionaryEquals helper uses IEnumerable<KeyValuePair<TKey,TValue>>
  as the common base and pattern-matches to IReadOnlyDictionary/IDictionary for
  O(1) TryGetValue lookup without casting
- Add ReadOnlyDictionaryEqualityComparer<TKey,TValue> to Equatable.Comparers for
  use with [EqualityComparer] on nested IReadOnlyDictionary properties
- Replace OrderBy in DictionaryHashCode and HashSetHashCode with commutative
  sum-of-HashCode.Combine approach — order-independent and allocation-free;
  same fix applied to DictionaryEqualityComparer and HashSetEqualityComparer
- Add [DataContractEquatable] adapter attribute — reacts to [DataMember]/
  [IgnoreDataMember] instead of [IgnoreEquality]; feeds into the same
  EquatableWriter pipeline
- Add [MessagePackEquatable] adapter attribute — reacts to MessagePack
  [Key]/[IgnoreMember] attributes
- Add entity and generator snapshot tests for all new scenarios

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
…d collection composition

- Add MultiDimensionalArrayEqualityComparer<T> for int[,]/T[,] with zero-allocation Array.GetEnumerator() iteration
- Extend GetBaseEquatableType and HasEquatableAttribute to recognise DataContractEquatable and MessagePackEquatable bases so derived classes emit base.Equals()/base.GetHashCode()
- Fix IsIncludedDataContract namespace check to require full System.Runtime.Serialization chain
- Auto-compose nested collection comparers (SequenceEqualityComparer, DictionaryEqualityComparer, HashSetEqualityComparer) recursively from a single top-level attribute; explicit [EqualityComparer] overrides at any level
- Add ComparerTypes.Expression path in writer for fully-composed comparer instance expressions
- Extend analyzer to cover DataContractEquatable/MessagePackEquatable types and accept arrays for [SequenceEquality]
- Add pinned MetadataReferences for DataMemberAttribute and KeyAttribute assemblies to fix load-order fragility in tests
- Add property-based tests (FsCheck) and snapshot tests for all new scenarios

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Core EquatableGenerator no longer knows about DataContract or MessagePack.
RegisterProvider is now public static so adapter generators can call it directly.
GetBaseEquatableType detects any *EquatableAttribute in Equatable.Attributes
namespace instead of hard-coding adapter names.

New packages:
- Equatable.SourceGenerator.DataContract — DataContractEquatableGenerator
- Equatable.SourceGenerator.MessagePack — MessagePackEquatableGenerator
- Equatable.Generator.DataContract — NuGet wrapper + DataContractEquatableAttribute
- Equatable.Generator.MessagePack — NuGet wrapper + MessagePackEquatableAttribute

DataContractEquatableAttribute and MessagePackEquatableAttribute moved out of
Equatable.Generator into their respective adapter packages.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
…kObject

DataContractEquatableAnalyzer (EQ0020): warns when [DataContractEquatable] is
present on a class without [DataContract] — DataMember attributes are silently
ignored by DataContractSerializer without it.

MessagePackEquatableAnalyzer (EQ0021): warns when [MessagePackEquatable] is
present on a class without [MessagePackObject] — Key attributes are ignored by
the MessagePack serializer without it.

Also add [DataContract]/[MessagePackObject] to all test entities and generator
test sources to reflect real-world usage accurately.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
…r duplication

Both adapter generators repeated the same indexer/accessibility guard.
Moved to EquatableGenerator.IsPublicInstanceProperty (public static) so
adapters call the shared helper instead of copying the logic.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Structs that don't define op_Equality (e.g. Nullable<T> wrapping a plain
struct) would cause a CS0019 compile error in generated code because the
generator unconditionally emitted == for all value types.

HasEqualityOperator() now checks the underlying type for op_Equality before
choosing ComparerTypes.ValueType, falling back to EqualityComparer<T>.Default.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
[DictionaryEquality] defaults to order-independent equality (key-lookup
Equals, sum-of-pairs hash). [DictionaryEquality(ordered: true)] opts into
order-sensitive equality: SequenceEqual on the key-value pair sequence for
Equals, sequential HashCode.Add per pair for GetHashCode.

Both modes are guaranteed in sync:
- Plain types: single ComparerTypes.OrderedDictionary enum value drives
  both the Equals and GetHashCode switch arms in the writer.
- Nested types: BuildCollectionComparerExpression now always emits an
  OrderedDictionaryEqualityComparer / OrderedReadOnlyDictionaryEqualityComparer
  instance for ordered properties, so both Equals and GetHashCode go through
  the same comparer object and can never diverge.

Adds OrderedDictionaryEqualityComparer<TKey,TValue> and
OrderedReadOnlyDictionaryEqualityComparer<TKey,TValue> to Equatable.Comparers.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
…ode, enable property tests

- Fix equality contract violation: GetHashCode used order-dependent logic while
  Equals was order-independent for dictionaries and hashsets
- DictionaryEqualityComparer/ReadOnlyDictionaryEqualityComparer: GetHashCode now
  uses commutative SUM (insertion-order independent, consistent with TryGetValue Equals)
- HashSetEqualityComparer: GetHashCode now uses commutative SUM (consistent with SetEquals)
- Add [DictionaryEquality(sequential: true)] mode: both Equals and GetHashCode use
  OrderBy(key) for deterministic key-sorted comparison
- OrderedDictionary comparers: fix OrderBy sort comparer to use hash tiebreaker
  when IComparer<TKey> treats distinct keys as equal (e.g. culture-sensitive string sort)
- Migrate property-based tests from FsCheck v2 to FsCheck.Xunit.v3 (xUnit v3 compatible)
- Add Arbitraries class with custom generators for HashSet<T> (not auto-generated in v3)
- Fix property test semantics for SetOfLists/SetOfDicts (reference equality for inner elements)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
… hashset comparers

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant