From 7e7b4aac29d551b8a601ef1ad2300290198e5659 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20Dang=C3=A5rden?= Date: Mon, 7 Sep 2026 18:19:37 +0200 Subject: [PATCH] Rewrite the expression parser The parser built a MemberNode tree only to convert it into the QueryNode tree, bound lambda parameters by walking up parents and falling back to the root when no match was found, and looked at only Arguments[0] of a LINQ operator. That left three failures: - e.Nodes.First(), .Count() and .ToList() threw ArgumentOutOfRangeException, because Arguments[1] was read whenever Arguments[0] was an attributed member, whether a lambda was passed or not. - A chained operator produced an invalid query. In e.Nodes.Where(n => n.Customer.CustomerName != null).Select(n => n.OrderId) the outer Select fell through to the base visitor, so its parameter was unknown and orderId was attached to the root: "The field `orderId` does not exist on the type `OrdersConnection`". - SelectMany's result selector bound both of its parameters to the outer element: "The field `orderId` does not exist on the type `Customer`". QueryExpressionVisitor now builds QueryNode directly, so merging happens once in AddChildNode. ResolvePath handles the expressions that name a field and returns the node they select; anything else falls through to the inherited ExpressionVisitor, so fields mentioned inside initialisers, comparisons and ordinary method calls are still fetched. Lambda parameters are bound in an explicit reference keyed scope map, and a parameter that was never bound is reported instead of silently selecting from the root. LinqOperator classifies an operator as Projection, where the selection moves to the lambda result, or PassThrough, where it stays on the source and the lambda is a client side predicate or key selector whose members still have to be fetched. Where, OrderBy, Take, First, Count, Cast and the rest are supported and can be chained; the operators that combine several sequences throw a NotSupportedException naming the operator rather than quietly dropping fields. Convert, TypeAs, Quote and Unbox are unwrapped, so a cast or a Nullable lift no longer truncates a path. ExpressionEvaluator reads constants, captured locals and transparent conversions directly and compiles only as a fallback. Arguments were previously evaluated twice per call. GetArgumentsId has to stay a pure function of the argument values, because GetMethodValue recomputes it to find the field in the response. It now renders values structurally and hashes with FNV-1a/64 instead of object.GetHashCode(), which was reference based: aliases were not the same in two processes, so the query text could not be cached, and a collision silently merged two distinct argument sets into one field. Co-Authored-By: Claude Opus 5 (1M context) --- src/Linq2GraphQL.Client/Utilities.cs | 185 ++++++++- .../Visitors/ExpressionEvaluator.cs | 102 +++++ .../Visitors/LinqOperator.cs | 112 ++++++ .../Visitors/MemberNode.cs | 123 ------ .../Visitors/ParameterVisitor.cs | 164 -------- .../Visitors/QueryExpressionVisitor.cs | 332 ++++++++++++++++ .../ExpressionParserTests.cs | 364 ++++++++++++++++++ test/Linq2GraphQL.Tests/QueryOperatorTests.cs | 128 ++++++ 8 files changed, 1206 insertions(+), 304 deletions(-) create mode 100644 src/Linq2GraphQL.Client/Visitors/ExpressionEvaluator.cs create mode 100644 src/Linq2GraphQL.Client/Visitors/LinqOperator.cs delete mode 100644 src/Linq2GraphQL.Client/Visitors/MemberNode.cs delete mode 100644 src/Linq2GraphQL.Client/Visitors/ParameterVisitor.cs create mode 100644 src/Linq2GraphQL.Client/Visitors/QueryExpressionVisitor.cs create mode 100644 test/Linq2GraphQL.Tests/ExpressionParserTests.cs create mode 100644 test/Linq2GraphQL.Tests/QueryOperatorTests.cs diff --git a/src/Linq2GraphQL.Client/Utilities.cs b/src/Linq2GraphQL.Client/Utilities.cs index dd9741af..6e79df5b 100644 --- a/src/Linq2GraphQL.Client/Utilities.cs +++ b/src/Linq2GraphQL.Client/Utilities.cs @@ -1,34 +1,185 @@ -using Linq2GraphQL.Client.Visitors; +using System.Collections; +using System.Globalization; using System.Linq.Expressions; +using System.Reflection; +using System.Text; +using Linq2GraphQL.Client.Visitors; + namespace Linq2GraphQL.Client; public static class Utilities { + private const int MaxArgumentDepth = 8; + + /// Separates one argument from the next so that adjacent values cannot run together. + private const char Separator = (char)31; + + /// + /// Identifies a set of field arguments so that the same field requested with different arguments becomes + /// several aliased selections. The result is derived from the argument values only, because the alias has + /// to be recomputed from the same values when the response is read back. + /// + /// An alias suffix, or null when there are no arguments to distinguish. public static string GetArgumentsId(IEnumerable objects) { - if (objects == null) return null; - var objs = objects.Where(o => o != null); - if (!objs.Any()) return null; + if (objects == null) + { + return null; + } - unchecked + var values = objects.Where(o => o != null).ToList(); + if (values.Count == 0) { - var hash = 19; - foreach (var obj in objs) - { - hash = hash * 31 + obj.GetHashCode(); - } + return null; + } - return hash.ToString().Replace("-", "_"); + var builder = new StringBuilder(); + foreach (var value in values) + { + AppendValue(builder, value, 0); + builder.Append(Separator); } + + return "_" + Hash(builder).ToString("x16"); } public static void ParseExpression(Expression body, QueryNode parent) { - var parameterVisitor = new ParameterVisitor(new MemberNode(null, null)); - var topNode = parameterVisitor.ParseExpression(body); - - topNode.PopulateChildQueryNodes(parent); - + QueryExpressionVisitor.Parse(body, parent); } -} \ No newline at end of file + /// + /// Writes a value in a form that is identical for equal values and stable between processes, which + /// is not for reference types. + /// + private static void AppendValue(StringBuilder builder, object value, int depth) + { + switch (value) + { + case null: + builder.Append("null"); + return; + + case string text: + builder.Append('"').Append(text).Append('"'); + return; + + case bool flag: + builder.Append(flag ? "true" : "false"); + return; + + case Enum enumValue: + builder.Append(enumValue.GetType().FullName).Append('.').Append(enumValue.ToString("D")); + return; + + case DateTime dateTime: + builder.Append(dateTime.ToString("O", CultureInfo.InvariantCulture)); + return; + + case DateTimeOffset dateTimeOffset: + builder.Append(dateTimeOffset.ToString("O", CultureInfo.InvariantCulture)); + return; + + case TimeSpan timeSpan: + builder.Append(timeSpan.ToString("c", CultureInfo.InvariantCulture)); + return; + + case CustomScalar scalar: + AppendValue(builder, scalar.Value, depth + 1); + return; + + case IFormattable formattable: + // Numbers, Guid, DateOnly, TimeOnly. + builder.Append(formattable.ToString(null, CultureInfo.InvariantCulture)); + return; + + case IEnumerable items: + AppendItems(builder, items, depth); + return; + + default: + AppendProperties(builder, value, depth); + return; + } + } + + private static void AppendItems(StringBuilder builder, IEnumerable items, int depth) + { + if (depth >= MaxArgumentDepth) + { + builder.Append("[...]"); + return; + } + + builder.Append('['); + foreach (var item in items) + { + AppendValue(builder, item, depth + 1); + builder.Append(','); + } + + builder.Append(']'); + } + + /// Input objects are compared by their contents, in a fixed property order. + private static void AppendProperties(StringBuilder builder, object value, int depth) + { + var type = value.GetType(); + builder.Append(type.FullName); + + if (depth >= MaxArgumentDepth) + { + builder.Append("{...}"); + return; + } + + builder.Append('{'); + + var properties = type + .GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(e => e.CanRead && e.GetIndexParameters().Length == 0) + .OrderBy(e => e.Name, StringComparer.Ordinal); + + foreach (var property in properties) + { + builder.Append(property.Name).Append('='); + + object propertyValue; + try + { + propertyValue = property.GetValue(value); + } + catch (Exception) + { + // A property that throws cannot contribute to the identity of the arguments. + builder.Append(";"); + continue; + } + + AppendValue(builder, propertyValue, depth + 1); + builder.Append(';'); + } + + builder.Append('}'); + } + + /// FNV-1a, for a 64 bit value that is the same in every process. + private static ulong Hash(StringBuilder builder) + { + const ulong offsetBasis = 14695981039346656037; + const ulong prime = 1099511628211; + + var hash = offsetBasis; + + foreach (var chunk in builder.GetChunks()) + { + foreach (var character in chunk.Span) + { + hash = (hash ^ (byte)character) * prime; + hash = (hash ^ (byte)(character >> 8)) * prime; + } + } + + return hash; + } +} diff --git a/src/Linq2GraphQL.Client/Visitors/ExpressionEvaluator.cs b/src/Linq2GraphQL.Client/Visitors/ExpressionEvaluator.cs new file mode 100644 index 00000000..f5c07ef1 --- /dev/null +++ b/src/Linq2GraphQL.Client/Visitors/ExpressionEvaluator.cs @@ -0,0 +1,102 @@ +using System.Linq.Expressions; +using System.Reflection; + +namespace Linq2GraphQL.Client.Visitors; + +/// +/// Turns the argument expressions of a GraphQL field into the values that are sent as query variables. +/// Constants and captured locals are read directly; everything else falls back to compiling the +/// expression, which is comparatively expensive. +/// +internal static class ExpressionEvaluator +{ + internal static object Evaluate(Expression expression) + { + return TryEvaluateFast(expression, out var value) ? value : Compile(expression); + } + + private static bool TryEvaluateFast(Expression expression, out object value) + { + switch (expression) + { + case null: + value = null; + return true; + + case ConstantExpression constant: + value = constant.Value; + return true; + + case MemberExpression member: + return TryEvaluateMember(member, out value); + + case UnaryExpression unary when IsTransparentConversion(unary): + return TryEvaluateFast(unary.Operand, out value); + + default: + value = null; + return false; + } + } + + private static bool TryEvaluateMember(MemberExpression member, out object value) + { + // A static member, or a captured local, which the compiler turns into a field on a closure object + // that is held by a ConstantExpression. + object target = null; + + if (member.Expression != null && !TryEvaluateFast(member.Expression, out target)) + { + value = null; + return false; + } + + switch (member.Member) + { + case FieldInfo field: + value = field.GetValue(target); + return true; + + case PropertyInfo property when property.CanRead && property.GetIndexParameters().Length == 0: + value = property.GetValue(target); + return true; + + default: + value = null; + return false; + } + } + + /// + /// True for a conversion that cannot change the value, so the operand can be read instead: boxing, + /// an upcast, or a lift into . + /// + private static bool IsTransparentConversion(UnaryExpression unary) + { + if (unary.NodeType != ExpressionType.Convert && + unary.NodeType != ExpressionType.ConvertChecked && + unary.NodeType != ExpressionType.TypeAs) + { + return false; + } + + if (unary.Method != null) + { + return false; + } + + var from = unary.Operand.Type; + var to = Nullable.GetUnderlyingType(unary.Type) ?? unary.Type; + + return to.IsAssignableFrom(from); + } + + private static object Compile(Expression expression) + { + var body = expression.Type == typeof(object) + ? expression + : Expression.Convert(expression, typeof(object)); + + return Expression.Lambda>(body).Compile()(); + } +} diff --git a/src/Linq2GraphQL.Client/Visitors/LinqOperator.cs b/src/Linq2GraphQL.Client/Visitors/LinqOperator.cs new file mode 100644 index 00000000..5bedfbfc --- /dev/null +++ b/src/Linq2GraphQL.Client/Visitors/LinqOperator.cs @@ -0,0 +1,112 @@ +using System.Reflection; +using System.Runtime.CompilerServices; + +namespace Linq2GraphQL.Client.Visitors; + +internal enum LinqOperatorKind +{ + /// Not a LINQ operator at all. + None, + + /// + /// The operator replaces the element with something else (Select, SelectMany). + /// The selection continues at whatever the projection lambda returns. + /// + Projection, + + /// + /// The operator keeps the source element type, or reduces the sequence to one of its elements or to a + /// scalar computed from them (Where, OrderBy, First, Count, ...). + /// The selection stays on the source node; any lambda is a client side predicate or key selector whose + /// members still have to be fetched for it to work. + /// + PassThrough, + + /// + /// A known LINQ operator that cannot be translated, typically because it combines several sequences. + /// + Unsupported +} + +internal static class LinqOperator +{ + private static readonly HashSet ProjectionOperators = + [ + "Select", + "SelectMany" + ]; + + private static readonly HashSet PassThroughOperators = + [ + "All", + "Any", + "AsEnumerable", + "AsQueryable", + "Average", + "Cast", + "Chunk", + "Count", + "DefaultIfEmpty", + "Distinct", + "DistinctBy", + "ElementAt", + "ElementAtOrDefault", + "First", + "FirstOrDefault", + "Last", + "LastOrDefault", + "LongCount", + "Max", + "MaxBy", + "Min", + "MinBy", + "OfType", + "Order", + "OrderBy", + "OrderByDescending", + "OrderDescending", + "Reverse", + "Single", + "SingleOrDefault", + "Skip", + "SkipLast", + "SkipWhile", + "Sum", + "Take", + "TakeLast", + "TakeWhile", + "ThenBy", + "ThenByDescending", + "ToArray", + "ToHashSet", + "ToList", + "Where" + ]; + + internal static LinqOperatorKind Classify(MethodInfo method) + { + if (method.DeclaringType != typeof(Queryable) && method.DeclaringType != typeof(Enumerable)) + { + return LinqOperatorKind.None; + } + + if (Attribute.GetCustomAttribute(method, typeof(ExtensionAttribute)) == null) + { + return LinqOperatorKind.None; + } + + if (ProjectionOperators.Contains(method.Name)) + { + return LinqOperatorKind.Projection; + } + + return PassThroughOperators.Contains(method.Name) + ? LinqOperatorKind.PassThrough + : LinqOperatorKind.Unsupported; + } + + internal static bool IsOperator(MethodInfo method) + { + return Classify(method) != LinqOperatorKind.None; + } +} diff --git a/src/Linq2GraphQL.Client/Visitors/MemberNode.cs b/src/Linq2GraphQL.Client/Visitors/MemberNode.cs deleted file mode 100644 index 3d5b69f3..00000000 --- a/src/Linq2GraphQL.Client/Visitors/MemberNode.cs +++ /dev/null @@ -1,123 +0,0 @@ -using Linq2GraphQL.Client; -using System.Linq.Expressions; -using System.Reflection; -using System.Text; - -namespace Linq2GraphQL.Client.Visitors -{ - internal class MemberNode(MemberInfo memberInfo, List arguments = null, ParameterExpression parameterExpression = null) - { - public string ParameterName => parameterExpression?.Name; - - public MemberInfo MemberInfo => memberInfo; - public MemberNode Parent { get; set; } - public List Children { get; set; } = []; - public List Arguments => arguments; - - public void SetParameterExpression(ParameterExpression param) - { - parameterExpression = param; - } - - - public bool HasArguments => arguments != null && arguments.Count > 0; - - public MemberNode GetMemberNodeFromParameterExpression(ParameterExpression expression) - { - if (parameterExpression == expression || Parent == null) return this; - return Parent.GetMemberNodeFromParameterExpression(expression); - } - - - public void AddChild(MemberNode memberNode) - { - memberNode.Parent = this; - Children.Add(memberNode); - } - - public MemberNode AddMembers(Expression node) - { - var members = GetMembers(node); - members.Reverse(); - - if (members.Count == 0) { return null; } - - var topNode = new MemberNode(members[0]); - var currentNode = topNode; - foreach (var member in members.Skip(1)) - { - var newNode = new MemberNode(member); - currentNode.AddChild(newNode); - currentNode = newNode; - } - AddChild(topNode); - - return currentNode; - - } - - private static List GetMembers(Expression expression) - { - var members = new List(); - - if (expression.NodeType == ExpressionType.MemberAccess) - { - var memberExpression = (MemberExpression)expression; - members.Add(memberExpression.Member); - members.AddRange(GetMembers(memberExpression.Expression)); - } - - return members; - } - - public void PopulateChildQueryNodes(QueryNode queryNode) - { - foreach (var child in Children) - { - var childNode = new QueryNode(child.MemberInfo, null, child.Arguments); - childNode.IncludePrimitive = child.Children.Count == 0; - var addedNode = queryNode.AddChildNode(childNode); - child.PopulateChildQueryNodes(addedNode); - } - } - - - public string PrintMemberTree(int level = 0) - { - var sb = new StringBuilder(); - sb.AppendLine($"Level: {level}"); - - level++; - - if (HasArguments) - { - var argumentList = $"({string.Join(", ", Arguments.Select(e => e.Value))})"; - - sb.AppendLine($"Member: {MemberInfo?.Name}{argumentList}"); - } - else - { - sb.AppendLine($"Member: {MemberInfo?.Name} "); - } - - sb.AppendLine($"Parent: {Parent?.MemberInfo?.Name}"); - sb.AppendLine($"Parameter: {parameterExpression?.Name}"); - - sb.AppendLine(""); - - foreach (var child in Children) - { - sb.Append(child.PrintMemberTree(level)); - } - - sb.AppendLine("".PadLeft(level, '-')); - - return sb.ToString(); - - } - - - } - - -} diff --git a/src/Linq2GraphQL.Client/Visitors/ParameterVisitor.cs b/src/Linq2GraphQL.Client/Visitors/ParameterVisitor.cs deleted file mode 100644 index 9b968863..00000000 --- a/src/Linq2GraphQL.Client/Visitors/ParameterVisitor.cs +++ /dev/null @@ -1,164 +0,0 @@ -using System.Linq.Expressions; -using System.Reflection; -using System.Runtime.CompilerServices; - -namespace Linq2GraphQL.Client.Visitors; - -internal class ParameterVisitor(MemberNode memberNode) : ExpressionVisitor -{ - public MemberNode ParseExpression(Expression expression) - { - Visit(expression); - return memberNode; - } - - protected override Expression VisitMember(MemberExpression node) - { - var attribute = node.Member.GetCustomAttribute(); - - if (attribute != null) - { - var parameter = GetParameterExpression(node); - AddMemberNodes(parameter, node); - } - - return node; - } - - public MemberNode AddMemberNodes(ParameterExpression targetParameter, Expression expression) - { - var targetNode = memberNode.GetMemberNodeFromParameterExpression(targetParameter); - var newNode = targetNode.AddMembers(expression); - return newNode; - } - - protected override Expression VisitMethodCall(MethodCallExpression expression) - { - var attribute = expression.Method.GetCustomAttribute(); - - if (attribute != null) - { - var parExp = GetParameterExpression(expression); - var i = 0; - var argumentValues = new List(); - foreach (var parameter in expression.Method.GetParameters()) - { - var graphQLArgumentAttribute = parameter.GetCustomAttribute(); - if (graphQLArgumentAttribute != null) - { - var arg = expression.Arguments[i]; - var v = GetArgumentValue(arg); - argumentValues.Add(new(graphQLArgumentAttribute.GraphQLName, graphQLArgumentAttribute.GraphQLType, - GetArgumentValue(arg))); - } - - i++; - } - - var targetNode = memberNode.GetMemberNodeFromParameterExpression(parExp); - targetNode.AddChild(new(expression.Method, argumentValues)); - - - return expression; - } - - if (IsLinqOperator(expression.Method)) - { - var memberExp = expression.Arguments[0] as MemberExpression; - var attr = memberExp?.Member.GetCustomAttribute(); - - if (attr != null) - { - var parameter = GetParameterExpression(expression.Arguments[1]); - var child = memberNode.AddMembers(memberExp); - child.SetParameterExpression(parameter); - - var visitor = new ParameterVisitor(child); - visitor.ParseExpression(expression.Arguments[1]); - return expression; - } - } - - return base.VisitMethodCall(expression); - } - - - protected override Expression VisitLambda(Expression node) - { - var newExp = node.Body as NewExpression; - return base.VisitLambda(node); - } - - //protected override Expression VisitNew(NewExpression node) - //{ - - // if (memberNode.ParameterName != null && node.Arguments.Any(e=> e.NodeType == ExpressionType.Parameter)) - // { - - - // } - - - // return base.VisitNew(node); - //} - - - private static object GetArgumentValue(Expression element) - { - if (element is ConstantExpression) - { - return (element as ConstantExpression).Value; - } - - var l = Expression.Lambda(Expression.Convert(element, element.Type)); - return l.Compile().DynamicInvoke(); - } - - - private ParameterExpression GetParameterExpression(Expression expression) - { - if (expression == null) - { - return null; - } - - if (expression.NodeType == ExpressionType.Parameter) - { - return expression as ParameterExpression; - } - - if (expression.NodeType == ExpressionType.MemberAccess) - { - var member = (MemberExpression)expression; - return GetParameterExpression(member.Expression); - } - - if (expression.NodeType == ExpressionType.Lambda) - { - var member = (LambdaExpression)expression; - return GetParameterExpression(member.Parameters[0]); - } - - if (expression.NodeType == ExpressionType.Call) - { - var member = (MethodCallExpression)expression; - - if (member.Object != null) - { - return GetParameterExpression(member.Object); - } - - return GetParameterExpression(member.Arguments[0]); - } - - - return null; - } - - private static bool IsLinqOperator(MethodInfo method) - { - if (method.DeclaringType != typeof(Queryable) && method.DeclaringType != typeof(Enumerable)) - return false; - return Attribute.GetCustomAttribute(method, typeof(ExtensionAttribute)) != null; - } -} \ No newline at end of file diff --git a/src/Linq2GraphQL.Client/Visitors/QueryExpressionVisitor.cs b/src/Linq2GraphQL.Client/Visitors/QueryExpressionVisitor.cs new file mode 100644 index 00000000..ef1c1bf9 --- /dev/null +++ b/src/Linq2GraphQL.Client/Visitors/QueryExpressionVisitor.cs @@ -0,0 +1,332 @@ +using System.Linq.Expressions; +using System.Reflection; + +namespace Linq2GraphQL.Client.Visitors; + +/// +/// Translates the LINQ expressions passed to Include and Select into the +/// tree that the GraphQL query text is generated from. +/// +/// +/// +/// The visitor works on two levels. handles expressions that name a +/// field — a chain of members, a GraphQL method call, or a LINQ operator over one of those — and returns +/// the node that expression selects, creating it if needed. Everything else (object initialisers, +/// comparisons, string calls, ...) is walked by the inherited so that +/// every field mentioned anywhere inside it still ends up in the query. +/// +/// +/// Lambda parameters are bound to the node they iterate in , keyed by reference, so +/// nested lambdas that reuse a parameter name stay distinct and a parameter that was never bound is +/// reported instead of silently attaching its fields to the root. +/// +/// +internal class QueryExpressionVisitor : ExpressionVisitor +{ + private readonly QueryNode root; + private readonly Dictionary scopes = new(); + + private QueryExpressionVisitor(QueryNode root) + { + this.root = root; + } + + internal static void Parse(Expression expression, QueryNode root) + { + ArgumentNullException.ThrowIfNull(expression); + ArgumentNullException.ThrowIfNull(root); + + new QueryExpressionVisitor(root).ParseRoot(expression); + } + + private void ParseRoot(Expression expression) + { + var body = Unwrap(expression); + + if (body is LambdaExpression lambda) + { + if (lambda.Parameters.Count > 0) + { + Bind(lambda.Parameters[0], root); + } + + body = Unwrap(lambda.Body); + } + + Select(body); + } + + /// + /// Adds everything selects to the query. When the expression names a + /// single field, that field is the end of the path the caller wrote and therefore also gets its + /// primitive properties. + /// + private void Select(Expression expression) + { + var node = ResolvePath(expression); + if (node != null) + { + node.IncludePrimitive = true; + } + } + + /// + /// Resolves an expression that names a field to its node, or returns null when the expression is not a + /// single path — in which case any fields inside it have been added as a side effect. + /// + private QueryNode ResolvePath(Expression expression) + { + switch (Unwrap(expression)) + { + case null: + return null; + + case ParameterExpression parameter: + return Scope(parameter); + + case MemberExpression member: + return ResolveMember(member); + + case MethodCallExpression call: + return ResolveCall(call); + + case var other: + // Not a path: an initialiser, a comparison, a literal. Walk it so the fields it mentions are + // still fetched, but there is no single node to hand back. + Visit(other); + return null; + } + } + + private QueryNode ResolveMember(MemberExpression member) + { + if (member.Member.GetCustomAttribute() == null) + { + // Not a GraphQL field: Nullable.Value, a plain CLR property, a captured local. The selection is + // whatever it was read from. + return ResolvePath(member.Expression); + } + + var parent = ResolvePath(member.Expression); + + // A GraphQL field read off something that is not part of the query - a captured entity, for instance - + // selects nothing. + return parent?.AddChildNode(new QueryNode(member.Member)); + } + + private QueryNode ResolveCall(MethodCallExpression call) + { + if (call.Method.GetCustomAttribute() != null) + { + return ResolveGraphMethod(call); + } + + var kind = LinqOperator.Classify(call.Method); + + switch (kind) + { + case LinqOperatorKind.Projection: + case LinqOperatorKind.PassThrough: + return ResolveLinqOperator(call, kind); + + case LinqOperatorKind.Unsupported: + throw Unsupported(call, + $"the LINQ operator '{call.Method.Name}' cannot be translated to a GraphQL selection"); + + default: + // An ordinary method such as string.ToUpper(). It selects nothing itself, but its target and + // arguments may mention fields that have to be fetched for it to run on the result. + Visit(call.Object); + foreach (var argument in call.Arguments) + { + Visit(argument); + } + + return null; + } + } + + private QueryNode ResolveGraphMethod(MethodCallExpression call) + { + // Extension methods carry their target as the first argument; instance methods have it as Object. + var target = call.Object ?? call.Arguments.FirstOrDefault(); + var parent = ResolvePath(target); + + if (parent == null) + { + throw Unsupported(call, + $"the target of '{call.Method.Name}' is not part of the query"); + } + + return parent.AddChildNode(new QueryNode(call.Method, arguments: GetArguments(call))); + } + + private QueryNode ResolveLinqOperator(MethodCallExpression call, LinqOperatorKind kind) + { + var source = ResolvePath(call.Arguments[0]); + var lambdas = GetLambdas(call); + + if (source == null) + { + // The sequence is not part of the query, so there is nothing to bind the lambdas to. + foreach (var argument in call.Arguments) + { + Visit(argument); + } + + return null; + } + + if (kind == LinqOperatorKind.PassThrough) + { + // Predicates and key selectors run on the client, so the members they touch must be fetched, but + // the selection itself stays on the sequence. + foreach (var lambda in lambdas) + { + Bind(lambda.Parameters[0], source); + Select(lambda.Body); + } + + return source; + } + + // Select / SelectMany: the selection moves to whatever the projection returns. + QueryNode collection = null; + QueryNode projected = null; + + for (var i = 0; i < lambdas.Count; i++) + { + var lambda = lambdas[i]; + + Bind(lambda.Parameters[0], source); + + // SelectMany's result selector takes (element, collectionElement). + if (i > 0 && lambda.Parameters.Count > 1 && collection != null) + { + Bind(lambda.Parameters[1], collection); + } + + projected = ResolvePath(lambda.Body); + + if (i == 0) + { + collection = projected; + } + } + + return projected; + } + + private static List GetLambdas(MethodCallExpression call) + { + var lambdas = new List(); + + for (var i = 1; i < call.Arguments.Count; i++) + { + if (Unwrap(call.Arguments[i]) is LambdaExpression { Parameters.Count: > 0 } lambda) + { + lambdas.Add(lambda); + } + } + + return lambdas; + } + + private static List GetArguments(MethodCallExpression call) + { + var parameters = call.Method.GetParameters(); + var arguments = new List(); + + for (var i = 0; i < parameters.Length && i < call.Arguments.Count; i++) + { + var attribute = parameters[i].GetCustomAttribute(); + if (attribute == null) + { + continue; + } + + arguments.Add(new ArgumentValue(attribute.GraphQLName, attribute.GraphQLType, + ExpressionEvaluator.Evaluate(call.Arguments[i]))); + } + + return arguments; + } + + protected override Expression VisitMember(MemberExpression node) + { + if (node.Member.GetCustomAttribute() != null) + { + Select(node); + return node; + } + + return base.VisitMember(node); + } + + protected override Expression VisitMethodCall(MethodCallExpression node) + { + if (node.Method.GetCustomAttribute() != null || + LinqOperator.IsOperator(node.Method)) + { + Select(node); + return node; + } + + return base.VisitMethodCall(node); + } + + protected override Expression VisitParameter(ParameterExpression node) + { + // The whole element is projected, so take all of its primitive fields. + if (scopes.TryGetValue(node, out var scopeNode)) + { + scopeNode.IncludePrimitive = true; + } + + return node; + } + + private void Bind(ParameterExpression parameter, QueryNode node) + { + scopes[parameter] = node; + } + + private QueryNode Scope(ParameterExpression parameter) + { + if (scopes.TryGetValue(parameter, out var node)) + { + return node; + } + + throw new NotSupportedException( + $"Cannot translate '{parameter.Name}' of type '{parameter.Type.Name}': it is not bound to a field of " + + "the query. Only the parameters of the lambdas passed to Include and Select can be used to select fields."); + } + + private static Expression Unwrap(Expression expression) + { + while (expression != null) + { + switch (expression.NodeType) + { + case ExpressionType.Quote: + case ExpressionType.Convert: + case ExpressionType.ConvertChecked: + case ExpressionType.TypeAs: + case ExpressionType.Unbox: + expression = ((UnaryExpression)expression).Operand; + continue; + + default: + return expression; + } + } + + return null; + } + + private static NotSupportedException Unsupported(Expression expression, string reason) + { + return new NotSupportedException($"Cannot translate '{expression}' into a GraphQL selection: {reason}."); + } +} diff --git a/test/Linq2GraphQL.Tests/ExpressionParserTests.cs b/test/Linq2GraphQL.Tests/ExpressionParserTests.cs new file mode 100644 index 00000000..08ef9cfb --- /dev/null +++ b/test/Linq2GraphQL.Tests/ExpressionParserTests.cs @@ -0,0 +1,364 @@ +using System.Linq.Expressions; +using System.Text; +using Linq2GraphQL.Client; +using Linq2GraphQL.TestClient; +using Shouldly; + +namespace Linq2GraphQL.Tests; + +/// +/// Covers the expression parser on its own: an expression goes in, the selection tree that the query text is +/// generated from comes out. No server and no schema is involved. +/// +public class ExpressionParserTests +{ + /// + /// Parses against a root node of and asserts the + /// fields it selects, one dotted path per line, with a trailing * on the fields whose primitive + /// properties are also selected. + /// + private static void ShouldSelect(Expression> path, string expected) + { + Parse(path).ShouldBe(expected.ReplaceLineEndings("\n").Trim()); + } + + private static string Parse(Expression> path) + { + var root = new QueryNode(typeof(T), "root", null, null, true); + Utilities.ParseExpression(path, root); + + var builder = new StringBuilder(); + Describe(root, "", builder); + return builder.ToString().TrimEnd('\n'); + } + + private static void Describe(QueryNode node, string prefix, StringBuilder builder) + { + foreach (var child in node.ChildNodes) + { + var path = prefix + (child.Alias ?? child.Name); + builder.Append(path).Append(child.IncludePrimitive ? "*\n" : "\n"); + Describe(child, path + ".", builder); + } + } + + [Fact] + public void MemberPath() + { + ShouldSelect((OrdersConnection e) => e.Nodes, "nodes*"); + } + + [Fact] + public void MemberChain_OnlyTheLastFieldTakesPrimitives() + { + ShouldSelect((Order e) => e.Customer.Orders, + """ + customer + customer.orders* + """); + } + + [Fact] + public void Select_ProjectionMovesTheSelection() + { + ShouldSelect((OrdersConnection e) => e.Nodes.Select(n => n.Customer), + """ + nodes + nodes.customer* + """); + } + + [Fact] + public void Select_Nested() + { + ShouldSelect((OrdersConnection e) => e.Nodes.Select(n => n.Customer.Orders.Select(o => o.Address)), + """ + nodes + nodes.customer + nodes.customer.orders + nodes.customer.orders.address* + """); + } + + [Fact] + public void Select_AnonymousType() + { + ShouldSelect((OrdersConnection e) => e.Nodes.Select(n => new { n.OrderId, n.Address }), + """ + nodes + nodes.orderId* + nodes.address* + """); + } + + [Fact] + public void Select_MemberInit() + { + ShouldSelect((OrdersConnection e) => e.Nodes.Select(n => new OrderIdAddress + { + OrderId = n.OrderId, + Address = n.Address + }), + """ + nodes + nodes.orderId* + nodes.address* + """); + } + + [Fact] + public void Select_NestedAnonymousTypes() + { + ShouldSelect((OrdersConnection e) => e.Nodes.Select(n => new + { + n.OrderId, + Cust = new { n.Customer.CustomerName, n.Customer.Orders } + }), + """ + nodes + nodes.orderId* + nodes.customer + nodes.customer.customerName* + nodes.customer.orders* + """); + } + + [Fact] + public void Parameter_SelectsTheWholeElement() + { + // "e" on its own means every primitive field of the root. + ShouldSelect((OrdersConnection e) => new { All = e, Nodes = e.Nodes }, "nodes*"); + } + + [Fact] + public void SelectMany() + { + ShouldSelect((List e) => e.SelectMany(c => c.Orders), "orders*"); + } + + [Fact] + public void SelectMany_WithResultSelector() + { + ShouldSelect((List e) => e.SelectMany(c => c.Orders, (c, o) => new { c.CustomerName, o.OrderId }), + """ + orders + orders.orderId* + customerName* + """); + } + + [Fact] + public void Duplicated_PathsMerge() + { + ShouldSelect((OrdersConnection e) => new { e.Nodes, Ids = e.Nodes.Select(n => n.OrderId) }, + """ + nodes* + nodes.orderId* + """); + } + + [Fact] + public void GraphMethod_WithArgumentsIsAliased() + { + var tree = Parse((OrdersConnection e) => e.Nodes.Select(n => n.OrderAddress(AddressType.Delivery))); + + tree.ShouldStartWith("nodes\nnodes.orderAddress_"); + tree.ShouldEndWith("*"); + } + + [Fact] + public void GraphMethod_SameFieldWithDifferentArgumentsBecomesTwoSelections() + { + var tree = Parse((OrdersConnection e) => e.Nodes.Select(n => new + { + Delivery = n.OrderAddress(AddressType.Delivery), + Invoice = n.OrderAddress(AddressType.Invoice) + })); + + tree.Split('\n').Length.ShouldBe(3); + tree.ShouldNotContain("orderAddress*"); + } + + [Fact] + public void GraphMethod_ArgumentsAreEvaluated() + { + var name = "Peter"; + var root = new QueryNode(typeof(OrdersConnection), "root", null, null, true); + + Utilities.ParseExpression((Expression>)(e => + e.Nodes.Select(n => n.OrderHello(name, 40 + 2))), root); + + var arguments = root.ChildNodes.Single().ChildNodes.Single().Arguments; + + arguments.Select(e => e.GraphName).ShouldBe(["name", "first"]); + arguments.Select(e => e.Value).ShouldBe(["Peter", 42]); + } + + // The shapes below were either silently dropped or threw before the parser was rewritten. + + [Fact] + public void PassThroughOperator_WithoutLambda() + { + ShouldSelect((OrdersConnection e) => e.Nodes.First(), "nodes*"); + ShouldSelect((OrdersConnection e) => e.Nodes.Count(), "nodes*"); + ShouldSelect((OrdersConnection e) => e.Nodes.ToList(), "nodes*"); + } + + [Fact] + public void PassThroughOperator_KeepsTheSelectionOnItsSource() + { + ShouldSelect((OrdersConnection e) => e.Nodes.Take(2).Select(n => n.OrderId), + """ + nodes + nodes.orderId* + """); + } + + [Fact] + public void PassThroughOperator_FetchesTheFieldsItsKeySelectorReads() + { + // orderDate has to be in the response for the client side ordering to work. + ShouldSelect((OrdersConnection e) => e.Nodes.OrderBy(n => n.OrderDate).Select(n => n.OrderId), + """ + nodes + nodes.orderDate* + nodes.orderId* + """); + } + + [Fact] + public void PassThroughOperator_FetchesTheFieldsItsPredicateReads() + { + ShouldSelect( + (OrdersConnection e) => e.Nodes.Where(n => n.Customer.CustomerName == "Kalle").Select(n => n.OrderId), + """ + nodes + nodes.customer + nodes.customer.customerName* + nodes.orderId* + """); + } + + [Fact] + public void ChainedOperators() + { + ShouldSelect((OrdersConnection e) => e.Nodes + .Where(n => n.OrderId != Guid.Empty) + .OrderByDescending(n => n.OrderDate) + .Select(n => n.Customer) + .Select(c => c.CustomerName), + """ + nodes + nodes.orderId* + nodes.orderDate* + nodes.customer + nodes.customer.customerName* + """); + } + + [Fact] + public void Cast_IsTransparent() + { + ShouldSelect((Order e) => (object)e.Customer, "customer*"); + ShouldSelect(e => e.CustomerId, "customerId*"); + } + + [Fact] + public void NonGraphMemberOnAGraphField_SelectsTheField() + { + ShouldSelect((Order e) => e.EntryTime!.Value.Ticks, "entryTime*"); + } + + [Fact] + public void OrdinaryMethodCall_FetchesWhatItReads() + { + ShouldSelect((Customer e) => e.CustomerName.ToUpper(), "customerName*"); + } + + [Fact] + public void CapturedValue_SelectsNothing() + { + var captured = new Order { OrderId = Guid.NewGuid() }; + ShouldSelect((OrdersConnection e) => new { e.TotalCount, Captured = captured.OrderId }, "totalCount*"); + } + + [Fact] + public void UnsupportedOperator_IsReported() + { + var exception = Should.Throw(() => + Parse((OrdersConnection e) => e.Nodes.GroupBy(n => n.OrderId))); + + exception.Message.ShouldContain("GroupBy"); + } + + [Fact] + public void UnboundParameter_IsReported() + { + var stray = Expression.Parameter(typeof(Order), "stray"); + var lambda = Expression.Lambda>( + Expression.Lambda>( + Expression.Property(stray, nameof(Order.Address)), stray), + Expression.Parameter(typeof(OrdersConnection), "e")); + + var root = new QueryNode(typeof(OrdersConnection), "root", null, null, true); + + var exception = Should.Throw(() => Utilities.ParseExpression(lambda, root)); + exception.Message.ShouldContain("stray"); + } +} + +public class ArgumentIdTests +{ + [Fact] + public void EqualValuesGiveEqualIds() + { + // The alias is recomputed from the argument values when the response is read back, so it has to be a + // pure function of those values. + Utilities.GetArgumentsId(["Peter", 1234]) + .ShouldBe(Utilities.GetArgumentsId([new string("Peter".ToCharArray()), 1234])); + } + + [Fact] + public void DifferentValuesGiveDifferentIds() + { + Utilities.GetArgumentsId(["Peter", 1234]) + .ShouldNotBe(Utilities.GetArgumentsId(["Peter", 1235])); + + Utilities.GetArgumentsId([AddressType.Delivery]) + .ShouldNotBe(Utilities.GetArgumentsId([AddressType.Invoice])); + } + + [Fact] + public void ValuesAreSeparated() + { + Utilities.GetArgumentsId(["a", "bc"]) + .ShouldNotBe(Utilities.GetArgumentsId(["ab", "c"])); + } + + [Fact] + public void NoArgumentsGivesNoId() + { + Utilities.GetArgumentsId(null).ShouldBeNull(); + Utilities.GetArgumentsId([]).ShouldBeNull(); + Utilities.GetArgumentsId([null, null]).ShouldBeNull(); + } + + [Fact] + public void IdIsStableBetweenProcesses() + { + // Hard coded on purpose: a reference based hash would make the generated query text differ from run to + // run, which stops it from being cached. + Utilities.GetArgumentsId(["Peter", 1234]).ShouldBe("_428b1fe100eabf57"); + } + + [Fact] + public void ObjectsAreComparedByTheirContents() + { + var one = new AddressInput { Street = "Main", Name = "Gothenburg" }; + var other = new AddressInput { Street = "Main", Name = "Gothenburg" }; + var different = new AddressInput { Street = "Main", Name = "Stockholm" }; + + Utilities.GetArgumentsId([one]).ShouldBe(Utilities.GetArgumentsId([other])); + Utilities.GetArgumentsId([one]).ShouldNotBe(Utilities.GetArgumentsId([different])); + } +} diff --git a/test/Linq2GraphQL.Tests/QueryOperatorTests.cs b/test/Linq2GraphQL.Tests/QueryOperatorTests.cs new file mode 100644 index 00000000..4d36d8e6 --- /dev/null +++ b/test/Linq2GraphQL.Tests/QueryOperatorTests.cs @@ -0,0 +1,128 @@ +using Linq2GraphQL.TestClient; +using Shouldly; + +namespace Linq2GraphQL.Tests; + +/// +/// LINQ operators other than Select inside a query expression. These run on the client, so the query has to +/// fetch the fields they read. Before the expression parser was rewritten they either threw or produced a +/// query that was missing fields. +/// +public class QueryOperatorTests : IClassFixture +{ + private readonly SampleClient sampleClient; + + public QueryOperatorTests(SampleClientFixture safeModeClient) + { + sampleClient = safeModeClient.sampleClient; + } + + [Fact] + public async Task First_WithoutLambda() + { + var query = sampleClient + .Query + .Orders() + .Select(e => e.Nodes.First().OrderId); + + var request = await query.GetRequestAsync(); + request.Query.ShouldContain("orderId"); + + (await query.ExecuteAsync()).ShouldNotBe(Guid.Empty); + } + + [Fact] + public async Task Count_WithoutLambda() + { + var count = await sampleClient + .Query + .Orders() + .Select(e => e.Nodes.Count()) + .ExecuteAsync(); + + count.ShouldBeGreaterThan(0); + } + + [Fact] + public async Task Where_FetchesThePredicateFields() + { + var query = sampleClient + .Query + .Orders() + .Select(e => e.Nodes + .Where(n => n.Customer.CustomerName != null) + .Select(n => n.OrderId)); + + var request = await query.GetRequestAsync(); + request.Query.ShouldContain("customerName"); + + var result = await query.ExecuteAsync(); + result.ShouldAllBe(e => e != Guid.Empty); + + // The predicate fields are fetched, the rest of the order is not. + query.BaseResult.Nodes.First().OrderDate.ShouldBe(default); + } + + [Fact] + public async Task OrderBy_FetchesTheKeyFields() + { + var query = sampleClient + .Query + .Orders() + .Select(e => e.Nodes + .OrderByDescending(n => n.OrderDate) + .Select(n => n.OrderId)); + + var request = await query.GetRequestAsync(); + request.Query.ShouldContain("orderDate"); + + var result = (await query.ExecuteAsync()).ToList(); + var ordered = query.BaseResult.Nodes.OrderByDescending(e => e.OrderDate).Select(e => e.OrderId); + + result.ShouldBe(ordered); + } + + [Fact] + public async Task ChainedOperators() + { + var query = sampleClient + .Query + .Orders() + .Select(e => e.Nodes + .Where(n => n.OrderId != Guid.Empty) + .OrderBy(n => n.OrderDate) + .Take(1) + .Select(n => new { n.OrderId, n.Customer.CustomerName })); + + var result = (await query.ExecuteAsync()).ToList(); + + result.Count.ShouldBe(1); + result[0].OrderId.ShouldNotBe(Guid.Empty); + result[0].CustomerName.ShouldNotBeNull(); + } + + [Fact] + public async Task SelectMany_WithResultSelector() + { + var query = sampleClient + .Query + .Customers() + .Select(e => e.SelectMany(c => c.Orders, (c, o) => new { c.CustomerName, o.OrderId })); + + var result = (await query.ExecuteAsync()).ToList(); + + result.ShouldNotBeEmpty(); + result.ShouldAllBe(e => e.CustomerName != null && e.OrderId != Guid.Empty); + } + + [Fact] + public async Task UnsupportedOperator_IsReported() + { + var exception = Should.Throw(() => sampleClient + .Query + .Orders() + .Select(e => e.Nodes.GroupBy(n => n.OrderId))); + + exception.Message.ShouldContain("GroupBy"); + } +}