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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
## Fixes

- #562 - Fixed invalid SDL for argument default values of value types. `Guid`, `DateTime`, `DateTimeOffset`, `DateOnly`, `TimeOnly`, `TimeSpan` and `char` defaults were printed bare (`runId: ID! = 00000000-0000-0000-0000-000000000000`), which tools like GraphQL Code Generator reject. They are now quoted string literals (dates in ISO format). Numeric defaults are printed with the invariant culture (`3.14`, not `3,14` under `de-DE`) and string defaults are escaped (`"`, `\`, newlines).
- Fixed `UseOffsetPaging()` on a field that is not on the root query type when its collection comes from EF - e.g. `schema.Type<Actor>().ReplaceField("movies", new { }, (a, _) => a.Movies.OrderBy(m => m.Released)).UseOffsetPaging()` - failing with `The LINQ expression 'p_Movie => new Dynamic_items...' could not be translated`. The collection is part of the parent's projection, so EF has to translate the paging, and `items` was paged with EntityGraphQL's own `Skip`/`Take(int?)` helpers, which EF does not know. A collection that is not an `IQueryable` is now paged with `System.Linq`'s `Skip`/`Take` (a null `skip` is `0`, a null `take` is the rest of the collection - `int.MaxValue - skip`, as EF pages a nested collection with `row <= skip + take`, which would overflow an `int` on SQL Server and Postgres). `hasNextPage` on such a field is translated too: EF evaluated EntityGraphQL's `PageHasNext` helper in memory, loading the whole collection for every parent, and it is now an `EXISTS` query. Paging on root query fields was not affected.

# 6.2.4

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -152,17 +152,27 @@ private MemberInitExpression BuildTotalCountExpression(BaseGraphQLField? fieldNo

if (needsHasNext)
{
var hasNextExp = Expression.Call(
isQueryable ? typeof(QueryableExtensions) : typeof(EnumerableExtensions),
nameof(EnumerableExtensions.PageHasNext),
[listType!],
resolve,
skipExp,
takeExp
);
return Expression.MemberInit(
Expression.New(returnType.GetConstructor([typeof(int?), typeof(int?), typeof(bool?)])!, skipExp, takeExp, Expression.Convert(hasNextExp, typeof(bool?)))
);
Expression hasNextExp = isQueryable
? Expression.Call(typeof(QueryableExtensions), nameof(QueryableExtensions.PageHasNext), [listType!], resolve, skipExp, takeExp)
// A collection on a parent object (e.g. a navigation property) is part of the parent's projection, so EF has
// to translate this. It does not know PageHasNext and would load the whole collection to call it, so spell
// it out with System.Linq: take.HasValue && source.Skip((skip ?? 0) + take.Value).Any()
: Expression.AndAlso(
Expression.Property(takeExp, nameof(Nullable<int>.HasValue)),
Expression.Call(
typeof(Enumerable),
nameof(Enumerable.Any),
[listType!],
Expression.Call(
typeof(Enumerable),
nameof(Enumerable.Skip),
[listType!],
resolve,
Expression.Add(Expression.Coalesce(skipExp, Expression.Constant(0)), Expression.Property(takeExp, nameof(Nullable<int>.Value)))
)
)
);
return Expression.MemberInit(Expression.New(returnType.GetConstructor([typeof(int?), typeof(int?), typeof(bool?)])!, skipExp, takeExp, Expression.Convert(hasNextExp, typeof(bool?))));
}

var bindings = new List<MemberBinding>();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System.Linq;
using System.Linq.Expressions;
using EntityGraphQL.Compiler;
using EntityGraphQL.Compiler.Util;
Expand Down Expand Up @@ -112,19 +113,33 @@ FieldExtensionExpressionContext context
throw new EntityGraphQLException(GraphQLErrorCategory.ExecutionError, "OffsetPagingItemsExtension requires an argument parameter to be passed in");

// Build our items expression with the paging
newItemsExp = Expression.Call(
isQueryable ? typeof(QueryableExtensions) : typeof(EnumerableExtensions),
nameof(EnumerableExtensions.Take),
[listType],
Expression.Call(
isQueryable ? typeof(QueryableExtensions) : typeof(EnumerableExtensions),
nameof(EnumerableExtensions.Skip),
var skipExp = Expression.PropertyOrField(argumentParam, "skip");
var takeExp = Expression.PropertyOrField(argumentParam, "take");
if (isQueryable)
{
newItemsExp = Expression.Call(
typeof(QueryableExtensions),
nameof(QueryableExtensions.Take),
[listType],
Expression.Call(typeof(QueryableExtensions), nameof(QueryableExtensions.Skip), [listType], newItemsExp, skipExp),
takeExp
);
}
else
{
// A collection on a parent object (e.g. a navigation property) is part of the parent's projection, so a LINQ
// provider like EF has to translate it. It knows System.Linq's Skip/Take but not our int? overloads.
// No take means the rest of the collection: int.MaxValue - skip rather than int.MaxValue, as EF pages a nested
// collection with ROW_NUMBER() and `row <= skip + take`, which overflows an int on SQL Server/Postgres
var skipOrZero = Expression.Coalesce(skipExp, Expression.Constant(0));
newItemsExp = Expression.Call(
typeof(Enumerable),
nameof(Enumerable.Take),
[listType],
newItemsExp,
Expression.PropertyOrField(argumentParam, "skip")
),
Expression.PropertyOrField(argumentParam, "take")
);
Expression.Call(typeof(Enumerable), nameof(Enumerable.Skip), [listType], newItemsExp, skipOrZero),
Expression.Coalesce(takeExp, Expression.Subtract(Expression.Constant(int.MaxValue), skipOrZero))
);
}

// we have moved the expression from the parent node to here. We need to call the before callback
if (fieldNode.ParentNode?.IsRootField == true)
Expand Down
58 changes: 58 additions & 0 deletions src/tests/EntityGraphQL.EF.Tests/PagingSqlTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,64 @@ public void ConnectionPagingForwardHasNextPageOnlyUsesExistsNotCount()
Assert.Contains(Commands(sql), c => c.Contains("COUNT"));
}

private static (TestDbContextFactory factory, TestDbContext data, List<string> sql, SchemaProvider<TestDbContext> schema) MakeNestedOffsetPaging()
{
var schema = SchemaBuilder.FromObject<TestDbContext>();
schema.Type<Actor>().ReplaceField("movies", new { }, (a, _) => a.Movies.OrderBy(m => m.Id), "Movies").UseOffsetPaging();

var sql = new List<string>();
var factory = new TestDbContextFactory();
var data = factory.CreateContext(b => (DbContextOptionsBuilder<TestDbContext>)b.LogTo(m => sql.Add(m), new[] { RelationalEventId.CommandExecuted }));
data.Actors.Add(new Actor("Actor") { Id = 1, Movies = Enumerable.Range(1, 10).Select(i => new Movie($"Movie{i}") { Id = i }).ToList() });
data.SaveChanges();
sql.Clear();
return (factory, data, sql, schema);
}

[Fact]
public void NestedOffsetPagingHasNextPageIsTranslated()
{
var (factory, data, sql, schema) = MakeNestedOffsetPaging();
using var _ = factory;

var result = schema.ExecuteRequestWithContext(new QueryRequest { Query = "{ actors { movies(take: 2, skip: 4) { hasNextPage items { name } } } }" }, data, null, null);

Assert.Null(result.Errors);
dynamic movies = ((dynamic)result.Data!["actors"]!)[0].movies;
Assert.True((bool)movies.hasNextPage);
Assert.Equal("Movie5", (string)movies.items[0].name);
var page = Assert.Single(Commands(sql));
Assert.Contains("EXISTS", page);
// hasNextPage evaluated in memory would load every column of every movie of every actor
Assert.DoesNotContain("\"Released\"", page);

sql.Clear();
var last = schema.ExecuteRequestWithContext(new QueryRequest { Query = "{ actors { movies(take: 2, skip: 8) { hasNextPage } } }" }, data, null, null);
Assert.Null(last.Errors);
Assert.False((bool)((dynamic)last.Data!["actors"]!)[0].movies.hasNextPage);

// no take - the page is the rest of the collection
var noTake = schema.ExecuteRequestWithContext(new QueryRequest { Query = "{ actors { movies(skip: 2) { hasNextPage } } }" }, data, null, null);
Assert.Null(noTake.Errors);
Assert.False((bool)((dynamic)noTake.Data!["actors"]!)[0].movies.hasNextPage);
}

[Fact]
public void NestedOffsetPagingSkipWithoutTakeDoesNotOverflow()
{
var (factory, data, sql, schema) = MakeNestedOffsetPaging();
using var _ = factory;

var result = schema.ExecuteRequestWithContext(new QueryRequest { Query = "{ actors { movies(skip: 7) { items { name } } } }" }, data, null, null);

Assert.Null(result.Errors);
var items = ((IEnumerable<dynamic>)((dynamic)result.Data!["actors"]!)[0].movies.items).Select(i => (string)i.name).ToList();
Assert.Equal(["Movie8", "Movie9", "Movie10"], items);
// EF pages a nested collection with `row <= skip + take`. A take of int.MaxValue overflows an int on SQL Server and
// Postgres for any skip > 0 (SQLite is 64-bit so would not show it)
Assert.DoesNotContain("2147483647", Assert.Single(Commands(sql)));
}

[Fact]
public void ConnectionPagingWithServiceFieldSharesThePageQuery()
{
Expand Down
74 changes: 74 additions & 0 deletions src/tests/EntityGraphQL.EF.Tests/PagingTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -281,4 +281,78 @@ public void Test1ToManySelfReferenceOffset()
Assert.Equal("Child", actors.items[1].name);
Assert.Equal("Parent", actors.items[1].children[0].name);
}

// A paged collection selected on a parent object is part of the parent's projection, so EF has to translate
// the paging - "The LINQ expression 'p_Movie => new Dynamic_items...' could not be translated" before.
[Fact]
public void TestNestedOffsetPagingOnNavigation()
{
var schema = SchemaBuilder.FromObject<TestDbContext>();
using var factory = new TestDbContextFactory();
var data = CreateActorWithMovies(factory);

schema.Type<Actor>().ReplaceField("movies", new { }, (a, _) => a.Movies.OrderByDescending(m => m.Released), "Movies, newest first").UseOffsetPaging();
var gql = new QueryRequest
{
Query =
@"{
actor(id: 1) {
movies(take: 1) {
totalItems
hasNextPage
items { name }
}
}
}",
};

var result = schema.ExecuteRequestWithContext(gql, data, null, null);
Assert.Null(result.Errors);

dynamic movies = ((dynamic)result.Data!["actor"]!).movies;
Assert.Equal(2, movies.totalItems);
Assert.True(movies.hasNextPage);
Assert.Equal(1, Enumerable.Count(movies.items));
Assert.Equal("Newer", movies.items[0].name);
}

[Fact]
public void TestNestedOffsetPagingOnNavigationHasNextPageOnly()
{
var schema = SchemaBuilder.FromObject<TestDbContext>();
using var factory = new TestDbContextFactory();
var data = CreateActorWithMovies(factory);

schema.Type<Actor>().ReplaceField("movies", new { }, (a, _) => a.Movies.OrderByDescending(m => m.Released), "Movies, newest first").UseOffsetPaging();
var gql = new QueryRequest
{
Query =
@"{
actors {
movies(skip: 1, take: 1) {
hasNextPage
items { name }
}
}
}",
};

var result = schema.ExecuteRequestWithContext(gql, data, null, null);
Assert.Null(result.Errors);

dynamic movies = ((dynamic)result.Data!["actors"]!)[0].movies;
Assert.False(movies.hasNextPage);
Assert.Equal(1, Enumerable.Count(movies.items));
Assert.Equal("Older", movies.items[0].name);
}

private static TestDbContext CreateActorWithMovies(TestDbContextFactory factory)
{
var data = factory.CreateContext();
data.Actors.Add(
new Actor("Actor") { Id = 1, Movies = [new Movie("Older") { Id = 1, Released = new DateTime(2020, 1, 1) }, new Movie("Newer") { Id = 2, Released = new DateTime(2021, 1, 1) }] }
);
data.SaveChanges();
return data;
}
}
Loading