diff --git a/src/Linq2GraphQL.Client/Exceptions/GraphQueryTranslationException.cs b/src/Linq2GraphQL.Client/Exceptions/GraphQueryTranslationException.cs
new file mode 100644
index 00000000..aaf409f7
--- /dev/null
+++ b/src/Linq2GraphQL.Client/Exceptions/GraphQueryTranslationException.cs
@@ -0,0 +1,35 @@
+using System.Linq.Expressions;
+
+namespace Linq2GraphQL.Client;
+
+///
+/// Thrown when an expression passed to Include or Select cannot be turned into a GraphQL
+/// selection, either because it uses a construct that has no GraphQL equivalent or because it reads
+/// something that is not part of the query.
+///
+///
+/// The query text does not exist yet when translation fails, so unlike
+/// and this
+/// exception carries the offending instead.
+/// It derives from , which is what the parser threw before this type
+/// existed, so callers that catch that keep working.
+///
+public class GraphQueryTranslationException : NotSupportedException
+{
+ public GraphQueryTranslationException(string message, Expression expression, string memberName = null)
+ : base(message)
+ {
+ Expression = expression;
+ MemberName = memberName;
+ }
+
+ ///
+ /// The part of the expression that could not be translated.
+ ///
+ public Expression Expression { get; }
+
+ ///
+ /// The LINQ operator or GraphQL member the failure is about, when it is about a single one.
+ ///
+ public string MemberName { get; }
+}
diff --git a/src/Linq2GraphQL.Client/Visitors/LinqOperator.cs b/src/Linq2GraphQL.Client/Visitors/LinqOperator.cs
index 5bedfbfc..22879a3a 100644
--- a/src/Linq2GraphQL.Client/Visitors/LinqOperator.cs
+++ b/src/Linq2GraphQL.Client/Visitors/LinqOperator.cs
@@ -15,15 +15,18 @@ internal enum LinqOperatorKind
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 operator keeps the source element type, or reduces the sequence to one of its elements, to a
+ /// scalar computed from them, or to another container holding them (Where, OrderBy,
+ /// First, Count, ToDictionary, GroupBy, ...).
/// 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.
+ /// A known LINQ operator that cannot be translated, because it combines several sequences
+ /// (Concat, Join, Zip, ...) or folds the elements into an accumulator that the
+ /// selection cannot follow (Aggregate).
///
Unsupported
}
@@ -40,12 +43,15 @@ internal static class LinqOperator
[
"All",
"Any",
+ "Append",
"AsEnumerable",
"AsQueryable",
"Average",
"Cast",
"Chunk",
+ "Contains",
"Count",
+ "CountBy",
"DefaultIfEmpty",
"Distinct",
"DistinctBy",
@@ -53,6 +59,8 @@ internal static class LinqOperator
"ElementAtOrDefault",
"First",
"FirstOrDefault",
+ "GroupBy",
+ "Index",
"Last",
"LastOrDefault",
"LongCount",
@@ -65,7 +73,9 @@ internal static class LinqOperator
"OrderBy",
"OrderByDescending",
"OrderDescending",
+ "Prepend",
"Reverse",
+ "Shuffle",
"Single",
"SingleOrDefault",
"Skip",
@@ -78,8 +88,11 @@ internal static class LinqOperator
"ThenBy",
"ThenByDescending",
"ToArray",
+ "ToDictionary",
"ToHashSet",
"ToList",
+ "ToLookup",
+ "TryGetNonEnumeratedCount",
"Where"
];
diff --git a/src/Linq2GraphQL.Client/Visitors/QueryExpressionVisitor.cs b/src/Linq2GraphQL.Client/Visitors/QueryExpressionVisitor.cs
index ef1c1bf9..08094570 100644
--- a/src/Linq2GraphQL.Client/Visitors/QueryExpressionVisitor.cs
+++ b/src/Linq2GraphQL.Client/Visitors/QueryExpressionVisitor.cs
@@ -131,7 +131,11 @@ private QueryNode ResolveCall(MethodCallExpression call)
case LinqOperatorKind.Unsupported:
throw Unsupported(call,
- $"the LINQ operator '{call.Method.Name}' cannot be translated to a GraphQL selection");
+ $"the LINQ operator '{call.Method.Name}' has no GraphQL equivalent and the selection cannot " +
+ "follow what it returns",
+ $"Select the fields the query needs and call '{call.Method.Name}' on the result of " +
+ "ExecuteAsync instead.",
+ call.Method.Name);
default:
// An ordinary method such as string.ToUpper(). It selects nothing itself, but its target and
@@ -155,7 +159,8 @@ private QueryNode ResolveGraphMethod(MethodCallExpression call)
if (parent == null)
{
throw Unsupported(call,
- $"the target of '{call.Method.Name}' is not part of the query");
+ $"the target of '{call.Method.Name}' is not part of the query",
+ memberName: call.Method.Name);
}
return parent.AddChildNode(new QueryNode(call.Method, arguments: GetArguments(call)));
@@ -181,9 +186,21 @@ private QueryNode ResolveLinqOperator(MethodCallExpression call, LinqOperatorKin
{
// Predicates and key selectors run on the client, so the members they touch must be fetched, but
// the selection itself stays on the sequence.
+ var elementType = GetElementType(call.Arguments[0].Type);
+
foreach (var lambda in lambdas)
{
Bind(lambda.Parameters[0], source);
+
+ // GroupBy's result selector takes (key, elements): the elements are the sequence itself.
+ for (var i = 1; i < lambda.Parameters.Count; i++)
+ {
+ if (elementType != null && GetElementType(lambda.Parameters[i].Type) == elementType)
+ {
+ Bind(lambda.Parameters[i], source);
+ }
+ }
+
Select(lambda.Body);
}
@@ -217,6 +234,21 @@ private QueryNode ResolveLinqOperator(MethodCallExpression call, LinqOperatorKin
return projected;
}
+ ///
+ /// The element type of a sequence type, or null when the type is not a sequence.
+ ///
+ private static Type GetElementType(Type type)
+ {
+ if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(IEnumerable<>))
+ {
+ return type.GetGenericArguments()[0];
+ }
+
+ return type.GetInterfaces()
+ .FirstOrDefault(e => e.IsGenericType && e.GetGenericTypeDefinition() == typeof(IEnumerable<>))
+ ?.GetGenericArguments()[0];
+ }
+
private static List GetLambdas(MethodCallExpression call)
{
var lambdas = new List();
@@ -298,9 +330,10 @@ private QueryNode Scope(ParameterExpression parameter)
return node;
}
- throw new NotSupportedException(
+ throw new GraphQueryTranslationException(
$"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.");
+ "the query. Only the parameters of the lambdas passed to Include and Select can be used to select fields.",
+ parameter);
}
private static Expression Unwrap(Expression expression)
@@ -325,8 +358,12 @@ private static Expression Unwrap(Expression expression)
return null;
}
- private static NotSupportedException Unsupported(Expression expression, string reason)
+ private static GraphQueryTranslationException Unsupported(Expression expression, string reason,
+ string hint = null, string memberName = null)
{
- return new NotSupportedException($"Cannot translate '{expression}' into a GraphQL selection: {reason}.");
+ var message = $"Cannot translate '{expression}' into a GraphQL selection: {reason}.";
+
+ return new GraphQueryTranslationException(hint == null ? message : message + " " + hint, expression,
+ memberName);
}
}
diff --git a/test/Linq2GraphQL.Tests/ExpressionParserTests.cs b/test/Linq2GraphQL.Tests/ExpressionParserTests.cs
index 08ef9cfb..7b532695 100644
--- a/test/Linq2GraphQL.Tests/ExpressionParserTests.cs
+++ b/test/Linq2GraphQL.Tests/ExpressionParserTests.cs
@@ -282,13 +282,67 @@ public void CapturedValue_SelectsNothing()
ShouldSelect((OrdersConnection e) => new { e.TotalCount, Captured = captured.OrderId }, "totalCount*");
}
+ [Fact]
+ public void MaterialisingOperator_FetchesWhatItReads()
+ {
+ ShouldSelect((OrdersConnection e) => e.Nodes.ToDictionary(n => n.OrderId, n => n.Customer),
+ """
+ nodes*
+ nodes.orderId*
+ nodes.customer*
+ """);
+ }
+
+ [Fact]
+ public void MaterialisingOperator_WithComparer_FetchesWhatItReads()
+ {
+ ShouldSelect(
+ (OrdersConnection e) => e.Nodes.ToDictionary(n => n.Customer.CustomerName, n => n,
+ StringComparer.OrdinalIgnoreCase),
+ """
+ nodes*
+ nodes.customer
+ nodes.customer.customerName*
+ """);
+ }
+
+ [Fact]
+ public void GroupBy_FetchesTheKeyFields()
+ {
+ ShouldSelect((OrdersConnection e) => e.Nodes.GroupBy(n => n.OrderId),
+ """
+ nodes*
+ nodes.orderId*
+ """);
+ }
+
+ [Fact]
+ public void GroupBy_WithResultSelector_BindsTheElements()
+ {
+ ShouldSelect(
+ (OrdersConnection e) => e.Nodes.GroupBy(n => n.Customer.CustomerName,
+ (name, orders) => orders.Select(o => o.OrderId)),
+ """
+ nodes*
+ nodes.customer
+ nodes.customer.customerName*
+ nodes.orderId*
+ """);
+ }
+
[Fact]
public void UnsupportedOperator_IsReported()
{
- var exception = Should.Throw(() =>
- Parse((OrdersConnection e) => e.Nodes.GroupBy(n => n.OrderId)));
+ var exception = Should.Throw(() =>
+ Parse((OrdersConnection e) => e.Nodes.Concat(e.Nodes)));
+
+ exception.Message.ShouldContain("Concat");
+ exception.Message.ShouldContain("ExecuteAsync");
+ exception.MemberName.ShouldBe("Concat");
+ exception.Expression.ShouldNotBeNull();
- exception.Message.ShouldContain("GroupBy");
+ // The parser threw NotSupportedException before the exception got a name of its own.
+ exception.ShouldBeAssignableTo();
}
[Fact]
@@ -302,8 +356,9 @@ public void UnboundParameter_IsReported()
var root = new QueryNode(typeof(OrdersConnection), "root", null, null, true);
- var exception = Should.Throw(() => Utilities.ParseExpression(lambda, root));
+ var exception = Should.Throw(() => Utilities.ParseExpression(lambda, root));
exception.Message.ShouldContain("stray");
+ exception.Expression.ShouldBe(stray);
}
}
diff --git a/test/Linq2GraphQL.Tests/QueryOperatorTests.cs b/test/Linq2GraphQL.Tests/QueryOperatorTests.cs
index 4d36d8e6..594acdcb 100644
--- a/test/Linq2GraphQL.Tests/QueryOperatorTests.cs
+++ b/test/Linq2GraphQL.Tests/QueryOperatorTests.cs
@@ -1,3 +1,4 @@
+using Linq2GraphQL.Client;
using Linq2GraphQL.TestClient;
using Shouldly;
@@ -115,14 +116,53 @@ public async Task SelectMany_WithResultSelector()
result.ShouldAllBe(e => e.CustomerName != null && e.OrderId != Guid.Empty);
}
+ [Fact]
+ public async Task ToDictionary_FetchesTheKeyAndElementFields()
+ {
+ var query = sampleClient
+ .Query
+ .Orders()
+ .Select(e => e.Nodes.ToDictionary(n => n.OrderId.ToString(), n => n.Customer.CustomerName,
+ StringComparer.OrdinalIgnoreCase));
+
+ var request = await query.GetRequestAsync();
+ request.Query.ShouldContain("customerName");
+ request.Query.ShouldContain("orderId");
+
+ var result = await query.ExecuteAsync();
+
+ result.ShouldNotBeEmpty();
+ result.Values.ShouldAllBe(e => e != null);
+ }
+
+ [Fact]
+ public async Task GroupBy_FetchesTheKeyFields()
+ {
+ var query = sampleClient
+ .Query
+ .Orders()
+ .Select(e => e.Nodes
+ .GroupBy(n => n.Customer.CustomerName, (name, orders) => orders.Select(o => o.OrderId)));
+
+ var request = await query.GetRequestAsync();
+ request.Query.ShouldContain("customerName");
+
+ var result = (await query.ExecuteAsync()).ToList();
+
+ result.ShouldNotBeEmpty();
+ result.SelectMany(e => e).ShouldAllBe(e => e != Guid.Empty);
+ }
+
[Fact]
public async Task UnsupportedOperator_IsReported()
{
- var exception = Should.Throw(() => sampleClient
+ var exception = Should.Throw(() => sampleClient
.Query
.Orders()
- .Select(e => e.Nodes.GroupBy(n => n.OrderId)));
+ .Select(e => e.Nodes.Concat(e.Nodes)));
- exception.Message.ShouldContain("GroupBy");
+ exception.Message.ShouldContain("Concat");
+ exception.Message.ShouldContain("ExecuteAsync");
+ exception.MemberName.ShouldBe("Concat");
}
}