Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
using System.Linq.Expressions;

namespace Linq2GraphQL.Client;

/// <summary>
/// Thrown when an expression passed to <c>Include</c> or <c>Select</c> 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.
/// </summary>
/// <remarks>
/// The query text does not exist yet when translation fails, so unlike
/// <see cref="GraphQueryRequestException" /> and <see cref="GraphQueryExecutionException" /> this
/// exception carries the offending <see cref="Expression" /> instead.
/// It derives from <see cref="NotSupportedException" />, which is what the parser threw before this type
/// existed, so callers that catch that keep working.
/// </remarks>
public class GraphQueryTranslationException : NotSupportedException
{
public GraphQueryTranslationException(string message, Expression expression, string memberName = null)
: base(message)
{
Expression = expression;
MemberName = memberName;
}

/// <summary>
/// The part of the expression that could not be translated.
/// </summary>
public Expression Expression { get; }

/// <summary>
/// The LINQ operator or GraphQL member the failure is about, when it is about a single one.
/// </summary>
public string MemberName { get; }
}
19 changes: 16 additions & 3 deletions src/Linq2GraphQL.Client/Visitors/LinqOperator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,18 @@ internal enum LinqOperatorKind
Projection,

/// <summary>
/// The operator keeps the source element type, or reduces the sequence to one of its elements or to a
/// scalar computed from them (<c>Where</c>, <c>OrderBy</c>, <c>First</c>, <c>Count</c>, ...).
/// 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 (<c>Where</c>, <c>OrderBy</c>,
/// <c>First</c>, <c>Count</c>, <c>ToDictionary</c>, <c>GroupBy</c>, ...).
/// 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.
/// </summary>
PassThrough,

/// <summary>
/// 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
/// (<c>Concat</c>, <c>Join</c>, <c>Zip</c>, ...) or folds the elements into an accumulator that the
/// selection cannot follow (<c>Aggregate</c>).
/// </summary>
Unsupported
}
Expand All @@ -40,19 +43,24 @@ internal static class LinqOperator
[
"All",
"Any",
"Append",
"AsEnumerable",
"AsQueryable",
"Average",
"Cast",
"Chunk",
"Contains",
"Count",
"CountBy",
"DefaultIfEmpty",
"Distinct",
"DistinctBy",
"ElementAt",
"ElementAtOrDefault",
"First",
"FirstOrDefault",
"GroupBy",
"Index",
"Last",
"LastOrDefault",
"LongCount",
Expand All @@ -65,7 +73,9 @@ internal static class LinqOperator
"OrderBy",
"OrderByDescending",
"OrderDescending",
"Prepend",
"Reverse",
"Shuffle",
"Single",
"SingleOrDefault",
"Skip",
Expand All @@ -78,8 +88,11 @@ internal static class LinqOperator
"ThenBy",
"ThenByDescending",
"ToArray",
"ToDictionary",
"ToHashSet",
"ToList",
"ToLookup",
"TryGetNonEnumeratedCount",
"Where"
];

Expand Down
49 changes: 43 additions & 6 deletions src/Linq2GraphQL.Client/Visitors/QueryExpressionVisitor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)));
Expand All @@ -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);
}

Expand Down Expand Up @@ -217,6 +234,21 @@ private QueryNode ResolveLinqOperator(MethodCallExpression call, LinqOperatorKin
return projected;
}

/// <summary>
/// The element type of a sequence type, or null when the type is not a sequence.
/// </summary>
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<LambdaExpression> GetLambdas(MethodCallExpression call)
{
var lambdas = new List<LambdaExpression>();
Expand Down Expand Up @@ -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)
Expand All @@ -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);
}
}
63 changes: 59 additions & 4 deletions test/Linq2GraphQL.Tests/ExpressionParserTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<NotSupportedException>(() =>
Parse((OrdersConnection e) => e.Nodes.GroupBy(n => n.OrderId)));
var exception = Should.Throw<GraphQueryTranslationException>(() =>
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<NotSupportedException>();
}

[Fact]
Expand All @@ -302,8 +356,9 @@ public void UnboundParameter_IsReported()

var root = new QueryNode(typeof(OrdersConnection), "root", null, null, true);

var exception = Should.Throw<NotSupportedException>(() => Utilities.ParseExpression(lambda, root));
var exception = Should.Throw<GraphQueryTranslationException>(() => Utilities.ParseExpression(lambda, root));
exception.Message.ShouldContain("stray");
exception.Expression.ShouldBe(stray);
}
}

Expand Down
46 changes: 43 additions & 3 deletions test/Linq2GraphQL.Tests/QueryOperatorTests.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using Linq2GraphQL.Client;
using Linq2GraphQL.TestClient;
using Shouldly;

Expand Down Expand Up @@ -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<NotSupportedException>(() => sampleClient
var exception = Should.Throw<GraphQueryTranslationException>(() => 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");
}
}
Loading