From 45f0e9f6560ec227ff34b4dfc915b982bc9a2376 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C4=B0lker=20amca?= Date: Mon, 3 Aug 2026 13:55:02 +0300 Subject: [PATCH] Compare constant collection values symmetrically in ExpressionEqualityComparer - CompareConstant dispatched on the first value only, so a list and an array containing the same elements compared equal in one direction and unequal in the other, and the two were hashed differently (structurally vs element-wise), breaking the Equals/GetHashCode contract for the dictionaries and sets that use this comparer - Dispatch on both values, and hash enumerables element-wise regardless of their concrete type - Queryables are excluded from the hash code, so also exclude them from the element-wise comparison: they are only equal by reference, and enumerating them can execute a query - Add tests for cross-type collection symmetry and for queryable constants --- .../Query/ExpressionEqualityComparer.cs | 27 +++++++++++++------ .../Query/ExpressionEqualityComparerTest.cs | 25 +++++++++++++++++ 2 files changed, 44 insertions(+), 8 deletions(-) diff --git a/src/EFCore/Query/ExpressionEqualityComparer.cs b/src/EFCore/Query/ExpressionEqualityComparer.cs index 6564853ad02..cf58508c62c 100644 --- a/src/EFCore/Query/ExpressionEqualityComparer.cs +++ b/src/EFCore/Query/ExpressionEqualityComparer.cs @@ -72,10 +72,8 @@ public int GetHashCode(Expression obj) case null: break; - case IStructuralEquatable structuralEquatable: - hash.Add(structuralEquatable.GetHashCode(StructuralComparisons.StructuralEqualityComparer)); - break; - + // Enumerables are compared element-wise, so they must also be hashed element-wise, regardless + // of their concrete type (e.g. an array and a list containing the same elements). case IEnumerable enumerable: foreach (var item in enumerable) { @@ -83,6 +81,10 @@ public int GetHashCode(Expression obj) } break; + case IStructuralEquatable structuralEquatable: + hash.Add(structuralEquatable.GetHashCode(StructuralComparisons.StructuralEqualityComparer)); + break; + default: hash.Add(constantExpression.Value); break; @@ -380,14 +382,23 @@ private static bool CompareConstant(ConstantExpression a, ConstantExpression b) return true; } - return v1 switch + // Queryables are excluded from the hash code, so they can only be equal by reference (checked above). + // They must also not be compared element-wise here, since enumerating them can execute a query. + if (v1 is IQueryable || v2 is IQueryable) { - IStructuralEquatable structuralEquatable1 - => structuralEquatable1.Equals(v2, StructuralComparisons.StructuralEqualityComparer), + return false; + } - IEnumerable enumerable1 when v2 is IEnumerable enumerable2 + // Both values are dispatched on, so that the comparison stays symmetric for values of different types + // (e.g. an array and a list containing the same elements). + return (v1, v2) switch + { + (IEnumerable enumerable1, IEnumerable enumerable2) => enumerable1.Cast().SequenceEqual(enumerable2.Cast()), + (IStructuralEquatable structuralEquatable1, not null) + => structuralEquatable1.Equals(v2, StructuralComparisons.StructuralEqualityComparer), + _ => false }; } diff --git a/test/EFCore.Tests/Query/ExpressionEqualityComparerTest.cs b/test/EFCore.Tests/Query/ExpressionEqualityComparerTest.cs index 129f6945dc2..13baccd5552 100644 --- a/test/EFCore.Tests/Query/ExpressionEqualityComparerTest.cs +++ b/test/EFCore.Tests/Query/ExpressionEqualityComparerTest.cs @@ -107,6 +107,31 @@ public void Array_constant_expressions_are_compared_correctly() Assert.NotEqual(expressionComparer.GetHashCode(e1), expressionComparer.GetHashCode(e3)); } + [Fact] + public void Collection_constant_expressions_of_different_types_are_compared_symmetrically() + { + var expressionComparer = ExpressionEqualityComparer.Instance; + + var array = Constant(new[] { 1, 2, 3 }, typeof(IEnumerable)); + var list = Constant(new List { 1, 2, 3 }, typeof(IEnumerable)); + + Assert.True(expressionComparer.Equals(array, list)); + Assert.True(expressionComparer.Equals(list, array)); + + Assert.Equal(expressionComparer.GetHashCode(array), expressionComparer.GetHashCode(list)); + } + + [Fact] + public void Queryable_constant_expressions_are_not_compared_by_their_elements() + { + var expressionComparer = ExpressionEqualityComparer.Instance; + + var queryable = new[] { 1, 2, 3 }.AsQueryable(); + + Assert.True(expressionComparer.Equals(Constant(queryable), Constant(queryable))); + Assert.False(expressionComparer.Equals(Constant(queryable), Constant(new[] { 1, 2, 3 }.AsQueryable()))); + } + [Fact] // #30697 public void Lambda_parameters_names_are_taken_into_account() {