diff --git a/src/GreenDonut/src/GreenDonut.Data/Internal/ExpressionHasher.cs b/src/GreenDonut/src/GreenDonut.Data/Internal/ExpressionHasher.cs index 0fad6a61108..c538b06594a 100644 --- a/src/GreenDonut/src/GreenDonut.Data/Internal/ExpressionHasher.cs +++ b/src/GreenDonut/src/GreenDonut.Data/Internal/ExpressionHasher.cs @@ -2,6 +2,8 @@ using System.Buffers.Text; using System.Linq.Expressions; using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; using System.Security.Cryptography; using System.Text; @@ -18,6 +20,29 @@ internal sealed class ExpressionHasher : ExpressionVisitor private ReadOnlySpan New => "New:"u8; private ReadOnlySpan MemberInit => "MemberInit|"u8; private ReadOnlySpan Binding => "Binding:"u8; + private ReadOnlySpan TypeBinary => "TypeBinary:"u8; + private ReadOnlySpan Conditional => "Conditional|"u8; + private ReadOnlySpan NewArray => "NewArray:"u8; + private ReadOnlySpan Index => "Index:"u8; + private ReadOnlySpan ListInit => "ListInit|"u8; + private ReadOnlySpan Default => "Default:"u8; + private ReadOnlySpan Invocation => "Invocation|"u8; + private ReadOnlySpan Constant => "K:"u8; + private ReadOnlySpan ConstantRoot => "CR:"u8; + private ReadOnlySpan Value => "V:"u8; + private ReadOnlySpan Null => "Z:"u8; + private ReadOnlySpan Str => "S:"u8; + private ReadOnlySpan Bool => "B:"u8; + private ReadOnlySpan Num => "N:"u8; + private ReadOnlySpan EnumTag => "E:"u8; + private ReadOnlySpan Temporal => "T:"u8; + private ReadOnlySpan GuidTag => "G:"u8; + private ReadOnlySpan CollectionStart => "C[:"u8; + private ReadOnlySpan CollectionEnd => "C]:"u8; + private ReadOnlySpan Struct => "W:"u8; + private ReadOnlySpan TypeFallback => "TF:"u8; + + private const int MaxCapturedDepth = 8; private byte[] _buffer; private readonly int _initialSize; @@ -119,10 +144,49 @@ protected override Expression VisitMember(MemberExpression node) Append(Member); Append(node.Member); Append('|'); + + if (node.Expression is ConstantExpression constant) + { + Append(ConstantRoot); + AppendType(constant.Type); + Append('|'); + + // Reference-type property getters can execute user code. Captured closures use fields, + // and value-type holders cover hoisted ExpressionParameter values. + switch (node.Member) + { + case FieldInfo field: + Append(Value); + AppendCapturedValue(field.GetValue(constant.Value), 0); + break; + + case PropertyInfo property + when property.GetIndexParameters().Length == 0 && constant.Value is ValueType: + Append(Value); + AppendCapturedValue(property.GetValue(constant.Value), 0); + break; + + default: + AppendMemberTypeFallback(node.Member, constant.Type); + break; + } + + return node; + } + Visit(node.Expression); return node; } + protected override Expression VisitConstant(ConstantExpression node) + { + Append(Constant); + AppendType(node.Type); + Append('|'); + AppendCapturedValue(node.Value, 0); + return node; + } + protected override Expression VisitParameter(ParameterExpression node) { Append(Parameter); @@ -145,6 +209,16 @@ protected override Expression VisitMethodCall(MethodCallExpression node) Append(MethodCall); Append(node.Method); Append('|'); + + if (node.Method.IsGenericMethod) + { + foreach (var typeArgument in node.Method.GetGenericArguments()) + { + AppendType(typeArgument); + Append('|'); + } + } + Visit(node.Object); foreach (var argument in node.Arguments) { @@ -176,6 +250,88 @@ protected override Expression VisitMemberInit(MemberInitExpression node) return node; } + protected override Expression VisitTypeBinary(TypeBinaryExpression node) + { + Append(TypeBinary); + Append((int)node.NodeType); + Append('|'); + AppendType(node.TypeOperand); + Append('|'); + Visit(node.Expression); + return node; + } + + protected override Expression VisitConditional(ConditionalExpression node) + { + Append(Conditional); + Visit(node.Test); + Visit(node.IfTrue); + Visit(node.IfFalse); + return node; + } + + protected override Expression VisitNewArray(NewArrayExpression node) + { + Append(NewArray); + Append((int)node.NodeType); + Append('|'); + AppendType(node.Type); + Append('|'); + foreach (var expression in node.Expressions) + { + Visit(expression); + } + return node; + } + + protected override Expression VisitIndex(IndexExpression node) + { + Append(Index); + Append(node.Indexer); + Append('|'); + Visit(node.Object); + foreach (var argument in node.Arguments) + { + Visit(argument); + } + return node; + } + + protected override Expression VisitListInit(ListInitExpression node) + { + Append(ListInit); + Visit(node.NewExpression); + foreach (var initializer in node.Initializers) + { + Append(initializer.AddMethod); + Append('|'); + foreach (var argument in initializer.Arguments) + { + Visit(argument); + } + } + return node; + } + + protected override Expression VisitDefault(DefaultExpression node) + { + Append(Default); + AppendType(node.Type); + Append('|'); + return node; + } + + protected override Expression VisitInvocation(InvocationExpression node) + { + Append(Invocation); + Visit(node.Expression); + foreach (var argument in node.Arguments) + { + Visit(argument); + } + return node; + } + private void VisitMemberBindingInternal(MemberBinding binding) { Append(Binding); @@ -209,6 +365,286 @@ private void VisitMemberBindingInternal(MemberBinding binding) } } + private void AppendCapturedValue(object? value, int depth) + { + if (depth > MaxCapturedDepth) + { + throw new NotSupportedException( + "The captured value graph is too deep or cyclic and cannot be hashed into " + + $"a DataLoader branch key (value type: {value?.GetType().FullName ?? "null"})."); + } + + switch (value) + { + case null: + Append(Null); + break; + + case string s: + AppendStringValue(s); + break; + + case bool b: + Append(Bool); + Append(b ? (byte)1 : (byte)0); + break; + + case char c: + AppendNumber(ValueKind.Char, c); + break; + + case Enum e: + AppendEnumValue(e); + break; + + case sbyte i: + AppendNumber(ValueKind.SByte, i); + break; + + case byte i: + AppendNumber(ValueKind.Byte, i); + break; + + case short i: + AppendNumber(ValueKind.Int16, i); + break; + + case ushort i: + AppendNumber(ValueKind.UInt16, i); + break; + + case int i: + AppendNumber(ValueKind.Int32, i); + break; + + case uint i: + AppendNumber(ValueKind.UInt32, i); + break; + + case long i: + AppendNumber(ValueKind.Int64, i); + break; + + case ulong i: + AppendNumber(ValueKind.UInt64, i); + break; + + case float f: + AppendNumber(ValueKind.Single, f); + break; + + case double d: + AppendNumber(ValueKind.Double, d); + break; + + case decimal d: + AppendNumber(ValueKind.Decimal, d); + break; + + case DateTime d: + AppendNumber(ValueKind.DateTime, d.Ticks); + Append((byte)d.Kind); + break; + + case DateTimeOffset d: + AppendNumber(ValueKind.DateTimeOffset, d.Ticks); + AppendRaw(d.Offset.Ticks); + break; + + case TimeSpan t: + AppendNumber(ValueKind.TimeSpan, t.Ticks); + break; + + case DateOnly d: + AppendNumber(ValueKind.DateOnly, d.DayNumber); + break; + + case TimeOnly t: + AppendNumber(ValueKind.TimeOnly, t.Ticks); + break; + + case Guid g: + Append(GuidTag); + AppendRaw(g); + break; + + default: + AppendComplexValue(value, depth); + break; + } + } + + private void AppendComplexValue(object value, int depth) + { + var type = value.GetType(); + + if (type.IsArray) + { + if (value is Array { Rank: 1 } array && array.GetLowerBound(0) == 0) + { + AppendCollection(array, type, array.Length, depth); + } + else + { + AppendTypeFallback(type); + } + return; + } + + if (IsList(type)) + { + var list = (System.Collections.IList)value; + AppendCollection(list, type, list.Count, depth); + return; + } + + if (value is System.Collections.IEnumerable) + { + // Sequences other than arrays and List may be lazy and are never enumerated; + // they contribute their type identity only. + AppendTypeFallback(type); + return; + } + + if (type.IsValueType) + { + AppendStructValue(value, type, depth); + return; + } + + // Reference types are never evaluated beyond their type identity so that hashing + // cannot execute user code. + AppendTypeFallback(type); + } + + private void AppendStringValue(string value) + { + Append(Str); + Append(value.Length); + Append(':'); + + // The length prefix keeps string payloads from aliasing element boundaries. The + // UTF-16 payload is hashed directly, which is lossless for any content, including + // unpaired surrogates. + Append(MemoryMarshal.AsBytes(value.AsSpan())); + } + + private void AppendEnumValue(Enum value) + { + Append(EnumTag); + AppendType(value.GetType()); + Append('|'); + Append(value.ToString("D")); + } + + private void AppendNumber(ValueKind kind, T value) + where T : unmanaged + { + Append(Num); + Append((byte)kind); + AppendRaw(value); + } + + private void AppendRaw(T value) + where T : unmanaged + { + Span bytes = stackalloc byte[16]; + MemoryMarshal.Write(bytes, in value); + Append(bytes[..Unsafe.SizeOf()]); + } + + private void AppendCollection(System.Collections.IList items, Type type, int count, int depth) + { + Append(CollectionStart); + AppendType(type); + Append('|'); + Append(count); + Append(':'); + + for (var i = 0; i < count; i++) + { + AppendCapturedValue(items[i], depth + 1); + } + + Append(CollectionEnd); + } + + private void AppendStructValue(object value, Type type, int depth) + { + Append(Struct); + AppendType(type); + Append('|'); + + // Struct field reads run no user code, so captured value types like record structs, + // tuples, and composite keys can be folded safely. + var fields = type.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + Array.Sort(fields, static (a, b) => string.CompareOrdinal(a.Name, b.Name)); + + foreach (var field in fields) + { + Append(field.Name); + Append('='); + AppendCapturedValue(field.GetValue(value), depth + 1); + } + + Append('|'); + } + + private void AppendMemberTypeFallback(MemberInfo member, Type holderType) + { + Append(TypeFallback); + AppendType(holderType); + Append('|'); + + switch (member) + { + case FieldInfo field: + AppendType(field.FieldType); + break; + + case PropertyInfo property: + AppendType(property.PropertyType); + break; + + default: + AppendType(member.DeclaringType ?? holderType); + break; + } + + Append('|'); + } + + private void AppendTypeFallback(Type type) + { + Append(TypeFallback); + AppendType(type); + Append('|'); + } + + private static bool IsList(Type type) + => type.IsGenericType && type.GetGenericTypeDefinition() == typeof(List<>); + + private enum ValueKind : byte + { + SByte = 1, + Byte, + Int16, + UInt16, + Int32, + UInt32, + Int64, + UInt64, + Single, + Double, + Decimal, + Char, + DateTime, + DateTimeOffset, + TimeSpan, + DateOnly, + TimeOnly + } + private void Append(int i) { int written; @@ -258,6 +694,9 @@ private void Append(MemberInfo? member) Append(member.Name); } + private void AppendType(Type type) + => Append(type.FullName ?? type.Name); + private void Append(char s) { if (_start == _buffer.Length) @@ -332,7 +771,7 @@ private void ExpandBufferCapacity() { var newBuffer = ArrayPool.Shared.Rent(_buffer.Length * 2); _buffer.AsSpan().CopyTo(newBuffer); - ArrayPool.Shared.Return(_buffer); + ReturnCleared(_buffer); _buffer = newBuffer; } @@ -340,8 +779,16 @@ private Span ExpandRollingBufferCapacity(int bufferIndex) { var newBuffer = ArrayPool.Shared.Rent(_buffer.Length * 2); _buffer.AsSpan()[..bufferIndex].CopyTo(newBuffer); - ArrayPool.Shared.Return(_buffer); + ReturnCleared(_buffer); _buffer = newBuffer; return _buffer.AsSpan()[bufferIndex..]; } + + private static void ReturnCleared(byte[] buffer) + { + // The buffer holds captured predicate values, so scrub it before handing it back + // to the shared pool, mirroring the clear in Compute. + buffer.AsSpan().Clear(); + ArrayPool.Shared.Return(buffer); + } } diff --git a/src/GreenDonut/test/GreenDonut.Data.Tests/Internal/ExpressionHasherTests.cs b/src/GreenDonut/test/GreenDonut.Data.Tests/Internal/ExpressionHasherTests.cs index 8cc1643fe25..6e6a2337d01 100644 --- a/src/GreenDonut/test/GreenDonut.Data.Tests/Internal/ExpressionHasherTests.cs +++ b/src/GreenDonut/test/GreenDonut.Data.Tests/Internal/ExpressionHasherTests.cs @@ -79,7 +79,7 @@ public static void Selector_With_List() var hash = hasher.Add(selector).Compute(); // assert - Assert.Equal("78155299ff3ce114f4f60f97b02a00b9", hash); + Assert.Equal("9d85f008b51c17f727b1ccf836d28d02", hash); } [Fact] @@ -93,7 +93,7 @@ public static void Simple_Predicate() var hash = hasher.Add(selector).Compute(); // assert - Assert.Equal("76892c282824bfdfe59e135a298c926e", hash); + Assert.Equal("a6291badbb36d78585568bddc5fc334f", hash); } [Fact] @@ -150,18 +150,16 @@ public static void BufferResize_Add_Expression() // arrange var hasher = new ExpressionHasher(); var initialBufferSize = hasher.InitialBufferSize; - var expression = Expression.Lambda>(Expression.Constant(new Entity1())); // translated to 8 bytes + var expression = Expression.Lambda>(Expression.Constant(new Entity1())); // act - var length = 0; - while (length < initialBufferSize + 1) + while (hasher.BufferSize == initialBufferSize) { hasher.Add(expression); - length += 8; } // assert - Assert.Equal(initialBufferSize * 2, hasher.BufferSize); + Assert.True(hasher.BufferSize > initialBufferSize); } [Fact] @@ -204,12 +202,498 @@ public static void BufferResize_Add_SortDefinition() Assert.Equal(initialBufferSize * 2, hasher.BufferSize); } + [Fact] + public static void Hoisted_Eq_Different_Value_Should_Produce_Different_Hash() + { + // arrange + var hasher = new ExpressionHasher(); + var param = Expression.Parameter(typeof(Entity1), "x"); + var expression1 = Expression.Lambda>( + Expression.Equal( + Expression.Property(param, nameof(Entity1.Name)), + Expression.Property( + Expression.Constant(new ExpressionParameterMirror("abc")), + "p")), + param); + var expression2 = Expression.Lambda>( + Expression.Equal( + Expression.Property(param, nameof(Entity1.Name)), + Expression.Property( + Expression.Constant(new ExpressionParameterMirror("xyz")), + "p")), + param); + + // act + var hash1 = hasher.Add(expression1).Compute(); + var hash2 = hasher.Add(expression2).Compute(); + + // assert + Assert.NotEqual(hash1, hash2); + } + + [Fact] + public static void Hoisted_Eq_Same_Value_Should_Produce_Same_Hash() + { + // arrange + var hasher = new ExpressionHasher(); + var param = Expression.Parameter(typeof(Entity1), "x"); + var expression1 = Expression.Lambda>( + Expression.Equal( + Expression.Property(param, nameof(Entity1.Name)), + Expression.Property( + Expression.Constant(new ExpressionParameterMirror("abc")), + "p")), + param); + var expression2 = Expression.Lambda>( + Expression.Equal( + Expression.Property(param, nameof(Entity1.Name)), + Expression.Property( + Expression.Constant(new ExpressionParameterMirror("abc")), + "p")), + param); + + // act + var hash1 = hasher.Add(expression1).Compute(); + var hash2 = hasher.Add(expression2).Compute(); + + // assert + Assert.Equal(hash1, hash2); + } + + [Fact] + public static void Closure_Captured_Different_Value_Should_Produce_Different_Hash() + { + // arrange + var hasher = new ExpressionHasher(); + var expression1 = Build("abc"); + var expression2 = Build("xyz"); + + // act + var hash1 = hasher.Add(expression1).Compute(); + var hash2 = hasher.Add(expression2).Compute(); + + // assert + Assert.NotEqual(hash1, hash2); + + static Expression> Build(string v) => x => x.Name == v; + } + + [Fact] + public static void Bare_Constant_Different_Value_Should_Produce_Different_Hash() + { + // arrange + var hasher = new ExpressionHasher(); + var param = Expression.Parameter(typeof(Entity1), "x"); + var expression1 = Expression.Lambda>( + Expression.Equal( + Expression.Property(param, nameof(Entity1.Name)), + Expression.Constant("abc")), + param); + var expression2 = Expression.Lambda>( + Expression.Equal( + Expression.Property(param, nameof(Entity1.Name)), + Expression.Constant("xyz")), + param); + + // act + var hash1 = hasher.Add(expression1).Compute(); + var hash2 = hasher.Add(expression2).Compute(); + + // assert + Assert.NotEqual(hash1, hash2); + } + + [Fact] + public static void Hoisted_In_List_Different_Elements_Should_Produce_Different_Hash() + { + // arrange + var hasher = new ExpressionHasher(); + var param = Expression.Parameter(typeof(Entity1), "x"); + var containsMethod = typeof(Enumerable) + .GetMethods() + .Single(m => m.Name == "Contains" && m.GetParameters().Length == 2) + .MakeGenericMethod(typeof(string)); + var expression1 = Expression.Lambda>( + Expression.Call( + containsMethod, + Expression.Property( + Expression.Constant( + new ExpressionParameterMirror>(new[] { "a", "b" })), + "p"), + Expression.Property(param, nameof(Entity1.Name))), + param); + var expression2 = Expression.Lambda>( + Expression.Call( + containsMethod, + Expression.Property( + Expression.Constant( + new ExpressionParameterMirror>(new[] { "a", "c" })), + "p"), + Expression.Property(param, nameof(Entity1.Name))), + param); + + // act + var hash1 = hasher.Add(expression1).Compute(); + var hash2 = hasher.Add(expression2).Compute(); + + // assert + Assert.NotEqual(hash1, hash2); + } + + [Fact] + public static void Hoisted_Bool_Different_Value_Should_Produce_Different_Hash() + { + // arrange + var hasher = new ExpressionHasher(); + var param = Expression.Parameter(typeof(Entity1), "x"); + var expression1 = Expression.Lambda>( + Expression.Equal( + Expression.Property(param, nameof(Entity1.IsActive)), + Expression.Property( + Expression.Constant(new ExpressionParameterMirror(true)), + "p")), + param); + var expression2 = Expression.Lambda>( + Expression.Equal( + Expression.Property(param, nameof(Entity1.IsActive)), + Expression.Property( + Expression.Constant(new ExpressionParameterMirror(false)), + "p")), + param); + + // act + var hash1 = hasher.Add(expression1).Compute(); + var hash2 = hasher.Add(expression2).Compute(); + + // assert + Assert.NotEqual(hash1, hash2); + } + + [Fact] + public static void Throwing_Property_Getter_On_Reference_Holder_Should_Not_Be_Invoked() + { + // arrange + var holder = new ThrowingPropertyHolder(); + var expression = Expression.Property( + Expression.Constant(holder), + nameof(ThrowingPropertyHolder.Value)); + + // act + var exception = Record.Exception(() => new ExpressionHasher().Add(expression).Compute()); + + // assert + Assert.Null(exception); + Assert.False(holder.WasInvoked); + } + + [Fact] + public static void Lazy_Enumerable_Should_Not_Be_Enumerated() + { + // arrange + var enumerable = new ThrowingEnumerable(); + var expression = Expression.Constant(enumerable); + + // act + var exception = Record.Exception(() => new ExpressionHasher().Add(expression).Compute()); + + // assert + Assert.Null(exception); + Assert.False(enumerable.Enumerated); + } + + [Fact] + public static void Queryable_Should_Not_Be_Enumerated() + { + // arrange + var queryable = new ThrowingQueryable(); + var expression = Expression.Constant(queryable); + + // act + var exception = Record.Exception(() => new ExpressionHasher().Add(expression).Compute()); + + // assert + Assert.Null(exception); + Assert.False(queryable.Enumerated); + } + + [Fact] + public static void Self_Referential_Collection_Should_Throw() + { + // arrange + // a cyclic captured value has no hashable identity and must fail loudly + var self = new object[2]; + self[0] = self; + self[1] = "x"; + var expression = Expression.Constant(self); + + // act + var exception = Record.Exception(() => new ExpressionHasher().Add(expression).Compute()); + + // assert + Assert.IsType(exception); + } + + [Fact] + public static void Captured_DateTime_Sub_Second_Difference_Should_Produce_Different_Hash() + { + // arrange + var value1 = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc).AddTicks(1_230_000); + var value2 = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc).AddTicks(4_560_000); + + // act + var hash1 = HashConstant(value1); + var hash2 = HashConstant(value2); + + // assert + Assert.NotEqual(hash1, hash2); + } + + [Fact] + public static void Captured_List_Element_Boundaries_Should_Produce_Different_Hash() + { + // arrange + var value1 = new[] { "a,b" }; + var value2 = new[] { "a", "b" }; + + // act + var hash1 = HashConstant(value1); + var hash2 = HashConstant(value2); + + // assert + Assert.NotEqual(hash1, hash2); + } + + [Fact] + public static void Same_List_Values_Different_Instances_Should_Produce_Same_Hash() + { + // arrange + var value1 = new[] { "a", "b" }; + var value2 = new[] { "a", "b" }; + + // act + var hash1 = HashConstant(value1); + var hash2 = HashConstant(value2); + + // assert + Assert.Equal(hash1, hash2); + } + + [Fact] + public static void Constant_Int_And_String_As_Object_Should_Produce_Different_Hash() + { + // arrange + var expression1 = Expression.Constant(1, typeof(object)); + var expression2 = Expression.Constant("1", typeof(object)); + + // act + var hash1 = new ExpressionHasher().Add(expression1).Compute(); + var hash2 = new ExpressionHasher().Add(expression2).Compute(); + + // assert + Assert.NotEqual(hash1, hash2); + } + + [Fact] + public static void Long_String_Should_Hash_Deterministically_And_Distinguish_Prefix() + { + // arrange + var longValue = new string('a', 5000); + var prefixed1 = "A" + new string('a', 5000); + var prefixed2 = "B" + new string('a', 5000); + + // act + var hash1 = HashConstant(longValue); + var hash2 = HashConstant(longValue); + var prefixedHash1 = HashConstant(prefixed1); + var prefixedHash2 = HashConstant(prefixed2); + + // assert + Assert.Equal(hash1, hash2); + Assert.NotEqual(prefixedHash1, prefixedHash2); + } + + [Fact] + public static void Bare_Bool_Constants_Should_Produce_Different_Hash() + { + // arrange + var expression1 = Expression.Constant(true); + var expression2 = Expression.Constant(false); + + // act + var hash1 = new ExpressionHasher().Add(expression1).Compute(); + var hash2 = new ExpressionHasher().Add(expression2).Compute(); + + // assert + Assert.NotEqual(hash1, hash2); + } + + [Fact] + public static void Captured_Record_Struct_Different_Value_Should_Produce_Different_Hash() + { + // arrange + var value1 = new StructKey(1, "a"); + var value2 = new StructKey(2, "a"); + + // act + var hash1 = HashConstant(value1); + var hash2 = HashConstant(value2); + var hash3 = HashConstant(new StructKey(1, "a")); + + // assert + Assert.NotEqual(hash1, hash2); + Assert.Equal(hash1, hash3); + } + + [Fact] + public static void Captured_Tuple_Member_Chain_Should_Produce_Different_Hash() + { + // arrange + var expression1 = BuildTuplePredicate(("a", "b")); + var expression2 = BuildTuplePredicate(("c", "d")); + + // act + var hash1 = new ExpressionHasher().Add(expression1).Compute(); + var hash2 = new ExpressionHasher().Add(expression2).Compute(); + + // assert + Assert.NotEqual(hash1, hash2); + + static Expression> BuildTuplePredicate((string A, string B) pair) + => x => x.Name == pair.A; + } + + [Fact] + public static void Struct_List_Element_Boundaries_Should_Produce_Different_Hash() + { + // arrange + // element content must not shift across element boundaries + var value1 = new[] { new AmbiguousFormattable("1"), new AmbiguousFormattable("23") }; + var value2 = new[] { new AmbiguousFormattable("12"), new AmbiguousFormattable("3") }; + + // act + var hash1 = HashConstant(value1); + var hash2 = HashConstant(value2); + + // assert + Assert.NotEqual(hash1, hash2); + } + + [Fact] + public static void Type_Check_Predicate_Different_Type_Should_Produce_Different_Hash() + { + // arrange + Expression> expression1 = x => x.Entity is Entity2; + Expression> expression2 = x => x.Entity is Entity3; + + // act + var hash1 = new ExpressionHasher().Add(expression1).Compute(); + var hash2 = new ExpressionHasher().Add(expression2).Compute(); + + // assert + Assert.NotEqual(hash1, hash2); + } + + [Fact] + public static void Generic_Method_Argument_Should_Produce_Different_Hash() + { + // arrange + Expression> expression1 = x => x.Entities.OfType().Any(); + Expression> expression2 = x => x.Entities.OfType().Any(); + + // act + var hash1 = new ExpressionHasher().Add(expression1).Compute(); + var hash2 = new ExpressionHasher().Add(expression2).Compute(); + + // assert + Assert.NotEqual(hash1, hash2); + } + + [Fact] + public static void Large_Captured_List_Should_Be_Deterministic_And_Value_Sensitive() + { + // arrange + var value1 = new int[100_000]; + var value2 = new int[100_000]; + value2[^1] = 1; + + // act + var hash1 = HashConstant(value1); + var hash2 = HashConstant(value1); + var hash3 = HashConstant(value2); + + // assert + Assert.Equal(hash1, hash2); + Assert.NotEqual(hash1, hash3); + } + + private static string HashConstant(object value) + => new ExpressionHasher().Add(Expression.Constant(value)).Compute(); + + private readonly record struct ExpressionParameterMirror(T p); + + private readonly record struct StructKey(int Id, string Name); + + private readonly struct AmbiguousFormattable(string text) : IFormattable + { + public string ToString(string? format, IFormatProvider? formatProvider) => text; + } + + private sealed class ThrowingPropertyHolder + { + public bool WasInvoked { get; private set; } + + public string Value + { + get + { + WasInvoked = true; + throw new InvalidOperationException(); + } + } + } + + private sealed class ThrowingEnumerable : IEnumerable + { + public bool Enumerated { get; private set; } + + public IEnumerator GetEnumerator() + { + Enumerated = true; + throw new InvalidOperationException(); + } + + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() + => GetEnumerator(); + } + + private sealed class ThrowingQueryable : IQueryable + { + public bool Enumerated { get; private set; } + + public Type ElementType => typeof(string); + + public Expression Expression => System.Linq.Expressions.Expression.Constant(this); + + public IQueryProvider Provider => throw new InvalidOperationException(); + + public IEnumerator GetEnumerator() + { + Enumerated = true; + throw new InvalidOperationException(); + } + + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() + => GetEnumerator(); + } + public class Entity1 { public string Name { get; set; } = null!; public string? Description { get; set; } + public bool IsActive { get; set; } + public IEntity Entity { get; set; } = null!; public List Entities { get; set; } = null!; diff --git a/src/HotChocolate/Data/test/Data.Filters.SqlServer.Tests/DataLoaderTests.cs b/src/HotChocolate/Data/test/Data.Filters.SqlServer.Tests/DataLoaderTests.cs index 2d7bd95496e..6df64219052 100644 --- a/src/HotChocolate/Data/test/Data.Filters.SqlServer.Tests/DataLoaderTests.cs +++ b/src/HotChocolate/Data/test/Data.Filters.SqlServer.Tests/DataLoaderTests.cs @@ -1,4 +1,5 @@ using System.Linq.Expressions; +using System.Text.Json; using GreenDonut; using GreenDonut.Data; using HotChocolate.Execution; @@ -185,6 +186,94 @@ public async Task Filter_With_Expression_Null() .MatchMarkdownSnapshot(); } + [Fact] + public async Task Filter_With_Aliased_Filtering_Same_Operator_Different_Value() + { + // Arrange + var context = new CatalogContext(); + await context.SeedAsync(); + + // Act + var result = await new ServiceCollection() + .AddTransient(_ => context) + .AddGraphQL() + .AddFiltering() + .AddQueryType() + .AddPagingArguments() + .ModifyRequestOptions(o => o.IncludeExceptionDetails = true) + .ExecuteRequestAsync( + """ + { + a: productsByBrand(brandId: 1, where: { name: { eq: "Product 0-0" } }) { + name + } + b: productsByBrand(brandId: 1, where: { name: { eq: "Product 0-1" } }) { + name + } + } + """, + cancellationToken: Xunit.TestContext.Current.CancellationToken); + using var doc = JsonDocument.Parse(result.ToJson()); + + // Assert + var data = doc.RootElement.GetProperty("data"); + var aNames = data.GetProperty("a") + .EnumerateArray() + .Select(e => e.GetProperty("name").GetString()) + .ToArray(); + var bNames = data.GetProperty("b") + .EnumerateArray() + .Select(e => e.GetProperty("name").GetString()) + .ToArray(); + + Assert.Equal(new[] { "Product 0-0" }, aNames); + Assert.Equal(new[] { "Product 0-1" }, bNames); + } + + [Fact] + public async Task Filter_With_Aliased_Filtering_Same_Operator_In_Different_Value() + { + // Arrange + var context = new CatalogContext(); + await context.SeedAsync(); + + // Act + var result = await new ServiceCollection() + .AddTransient(_ => context) + .AddGraphQL() + .AddFiltering() + .AddQueryType() + .AddPagingArguments() + .ModifyRequestOptions(o => o.IncludeExceptionDetails = true) + .ExecuteRequestAsync( + """ + { + a: productsByBrand(brandId: 1, where: { name: { in: ["Product 0-0"] } }) { + name + } + b: productsByBrand(brandId: 1, where: { name: { in: ["Product 0-1"] } }) { + name + } + } + """, + cancellationToken: Xunit.TestContext.Current.CancellationToken); + using var doc = JsonDocument.Parse(result.ToJson()); + + // Assert + var data = doc.RootElement.GetProperty("data"); + var aNames = data.GetProperty("a") + .EnumerateArray() + .Select(e => e.GetProperty("name").GetString()) + .ToArray(); + var bNames = data.GetProperty("b") + .EnumerateArray() + .Select(e => e.GetProperty("name").GetString()) + .ToArray(); + + Assert.Equal(new[] { "Product 0-0" }, aNames); + Assert.Equal(new[] { "Product 0-1" }, bNames); + } + public class Query { public async Task FilterExpression( @@ -306,6 +395,47 @@ protected override async Task> LoadBatchAsyn return x.GroupBy(t => t.Id).ToDictionary(t => t.Key, t => t.ToArray()); } } + + public class AliasFilterQuery + { + [UseFiltering] + public async Task ProductsByBrand( + int brandId, + IFilterContext context, + AliasProductsByBrandIdDataLoader productsByBrandId, + CancellationToken cancellationToken) + => await productsByBrandId.Where(context).LoadAsync(brandId, cancellationToken); + } + + public class AliasProductsByBrandIdDataLoader : StatefulBatchDataLoader + { + private readonly IServiceProvider _services; + + public AliasProductsByBrandIdDataLoader( + IServiceProvider services, + IBatchScheduler batchScheduler, + DataLoaderOptions options) : base(batchScheduler, options) + { + _services = services; + } + + protected override async Task> LoadBatchAsync( + IReadOnlyList keys, + DataLoaderFetchContext context, + CancellationToken cancellationToken) + { + var catalogContext = _services.GetRequiredService(); + + var query = catalogContext.Products + .Where(t => keys.Contains(t.BrandId)) + .Where(context.GetPredicate()); + var list = await query.ToListAsync(cancellationToken); + + return list + .GroupBy(t => t.BrandId) + .ToDictionary(t => t.Key, t => t.ToArray()); + } + } } file static class Extensions diff --git a/src/HotChocolate/Data/test/Data.PostgreSQL.Tests/IntegrationTests.cs b/src/HotChocolate/Data/test/Data.PostgreSQL.Tests/IntegrationTests.cs index d3d3687f2a5..a5168eb02fb 100644 --- a/src/HotChocolate/Data/test/Data.PostgreSQL.Tests/IntegrationTests.cs +++ b/src/HotChocolate/Data/test/Data.PostgreSQL.Tests/IntegrationTests.cs @@ -14,6 +14,7 @@ using Squadron; using System.Reflection; using System.Runtime.CompilerServices; +using System.Text.Json; using System.Text.RegularExpressions; using static CookieCrumble.TestEnvironment; @@ -731,6 +732,61 @@ public async Task MaxPageSize_Should_Be_Enforced_On_Connection_Without_UseConnec """); } + [Fact] + public async Task Query_Brand_Products_Aliased_Same_Operator_Different_Value() + { + // arrange + // Two distinct queries must be issued, one per predicate value; a single query + // means the aliases collapsed onto one DataLoader branch. + using var interceptor = new TestQueryInterceptor(); + + // act + var result = await ExecuteAsync( + """ + { + brands(where: { name: { eq: "Daybird" } }) { + nodes { + name + a: products(where: { name: { eq: "Wanderer Black Hiking Boots" } }) { + nodes { + name + } + } + b: products(where: { name: { eq: "Trailblaze hiking backpack" } }) { + nodes { + name + } + } + } + } + } + """); + using var doc = JsonDocument.Parse(result.ToJson()); + + // assert + var node = doc.RootElement + .GetProperty("data") + .GetProperty("brands") + .GetProperty("nodes")[0]; + var aNames = node.GetProperty("a") + .GetProperty("nodes") + .EnumerateArray() + .Select(e => e.GetProperty("name").GetString()) + .ToArray(); + var bNames = node.GetProperty("b") + .GetProperty("nodes") + .EnumerateArray() + .Select(e => e.GetProperty("name").GetString()) + .ToArray(); + + Assert.Equal(new[] { "Wanderer Black Hiking Boots" }, aNames); + Assert.Equal(new[] { "Trailblaze hiking backpack" }, bNames); + + // One query for the brands page and one per product predicate value. A count of + // two means the aliases collapsed onto a single DataLoader branch. + Assert.Equal(3, interceptor.Queries.Count); + } + private static ServiceProvider CreateServer(string connectionString, int? maxPageSize = null) { var services = new ServiceCollection();