From 46a4cb61a17ab5fc11e683e056a8faecf9804548 Mon Sep 17 00:00:00 2001 From: Michael Staib Date: Tue, 24 Feb 2026 14:27:34 +0000 Subject: [PATCH 1/3] Fix TPH projections with ToPageAsync --- .../Extensions/PagingQueryableExtensions.cs | 21 ++++++-- .../PagingInheritanceTests.cs | 51 +++++++++++++++++++ 2 files changed, 69 insertions(+), 3 deletions(-) diff --git a/src/GreenDonut/src/GreenDonut.Data.EntityFramework/Extensions/PagingQueryableExtensions.cs b/src/GreenDonut/src/GreenDonut.Data.EntityFramework/Extensions/PagingQueryableExtensions.cs index 9006737d08f..9865b04426e 100644 --- a/src/GreenDonut/src/GreenDonut.Data.EntityFramework/Extensions/PagingQueryableExtensions.cs +++ b/src/GreenDonut/src/GreenDonut.Data.EntityFramework/Extensions/PagingQueryableExtensions.cs @@ -78,6 +78,18 @@ public static async ValueTask> ToPageAsync( ArgumentNullException.ThrowIfNull(source); source = QueryHelpers.EnsureOrderPropsAreSelected(source); + Expression>? selector = null; + var applySelectorAfterPaging = arguments.After is not null || arguments.Before is not null; + + if (applySelectorAfterPaging) + { + selector = QueryHelpers.ExtractCurrentSelector(source); + + if (selector is not null) + { + source = QueryHelpers.RemoveSelector(source); + } + } var keys = ParseDataSetKeys(source); @@ -181,13 +193,16 @@ public static async ValueTask> ToPageAsync( } source = source.Take(requestedCount + 1); + var pageQuery = selector is null + ? source + : source.Select(selector); var builder = ImmutableArray.CreateBuilder(); var fetchCount = 0; if (includeTotalCount) { - var combinedQuery = source.Select(t => new { TotalCount = originalQuery.Count(), Item = t }); + var combinedQuery = pageQuery.Select(t => new { TotalCount = originalQuery.Count(), Item = t }); TryGetQueryInterceptor()?.OnBeforeExecute(combinedQuery); @@ -207,9 +222,9 @@ public static async ValueTask> ToPageAsync( } else { - TryGetQueryInterceptor()?.OnBeforeExecute(source); + TryGetQueryInterceptor()?.OnBeforeExecute(pageQuery); - await foreach (var item in source.AsAsyncEnumerable() + await foreach (var item in pageQuery.AsAsyncEnumerable() .WithCancellation(cancellationToken).ConfigureAwait(false)) { fetchCount++; diff --git a/src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/PagingInheritanceTests.cs b/src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/PagingInheritanceTests.cs index 33e821b2b0a..44c2d3b812d 100644 --- a/src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/PagingInheritanceTests.cs +++ b/src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/PagingInheritanceTests.cs @@ -72,6 +72,39 @@ public async Task BatchPaging_With_TPC_Selector_And_Scalar_Property() Assert.Equal(2, result.Count); } + [Fact] + public async Task Paging_With_TPH_Selector_After_Cursor() + { + // arrange + var connectionString = CreateConnectionString(); + await SeedAnimalsAsync(connectionString); + + await using var context = new AnimalContext(connectionString); + + var query = new QueryContext( + Selector: e => + e is Dog + ? new Dog { Id = ((Dog)e).Id, Name = ((Dog)e).Name } + : e is Cat + ? (Animal)new Cat { Id = ((Cat)e).Id, Name = ((Cat)e).Name } + : null!); + + var arguments = new PagingArguments(2); + + // act + var firstPage = await context.Pets + .With(query, sort => sort.AddDescending(e => e.Name)) + .ToPageAsync(arguments); + + var secondPage = await context.Pets + .With(query, sort => sort.AddDescending(e => e.Name)) + .ToPageAsync(arguments with { After = firstPage.CreateCursor(firstPage.Last!) }); + + // assert + Assert.NotNull(secondPage); + Assert.Equal(2, secondPage.Items.Length); + } + private static async Task SeedFileSystemAsync(string connectionString) { await using var context = new FileSystemContext(connectionString); @@ -102,4 +135,22 @@ private static async Task SeedFileSystemAsync(string connectionString) await context.SaveChangesAsync(); } + + private static async Task SeedAnimalsAsync(string connectionString) + { + await using var context = new AnimalContext(connectionString); + await context.Database.EnsureCreatedAsync(); + + var owner = new Owner { Id = 1, Name = "owner-1" }; + + context.Owners.Add(owner); + context.Pets.AddRange( + new Dog { Id = 1, Name = "zeta", OwnerId = owner.Id, IsBarking = true }, + new Cat { Id = 2, Name = "epsilon", OwnerId = owner.Id, IsPurring = true }, + new Dog { Id = 3, Name = "delta", OwnerId = owner.Id, IsBarking = false }, + new Cat { Id = 4, Name = "gamma", OwnerId = owner.Id, IsPurring = false }, + new Dog { Id = 5, Name = "beta", OwnerId = owner.Id, IsBarking = true }); + + await context.SaveChangesAsync(); + } } From 7ccfb33cdc291869342ffa82e3776209e1c90406 Mon Sep 17 00:00:00 2001 From: Michael Staib Date: Tue, 24 Feb 2026 18:54:01 +0000 Subject: [PATCH 2/3] Make it work for ToBatchPageAsync --- .../Expressions/ExpressionHelpers.cs | 23 +++++------ .../Extensions/PagingQueryableExtensions.cs | 26 ++++++------- .../PagingInheritanceTests.cs | 39 +++++++++++++++++++ 3 files changed, 64 insertions(+), 24 deletions(-) diff --git a/src/GreenDonut/src/GreenDonut.Data.EntityFramework/Expressions/ExpressionHelpers.cs b/src/GreenDonut/src/GreenDonut.Data.EntityFramework/Expressions/ExpressionHelpers.cs index 01e754744d2..588b2ec35f2 100644 --- a/src/GreenDonut/src/GreenDonut.Data.EntityFramework/Expressions/ExpressionHelpers.cs +++ b/src/GreenDonut/src/GreenDonut.Data.EntityFramework/Expressions/ExpressionHelpers.cs @@ -181,17 +181,6 @@ public static BatchExpression BuildBatchExpression( typedOrderExpression); } - // apply the selector to each item in the grouping after ordering - if (selector is not null) - { - var selectMethod = typeof(Enumerable) - .GetMethods(BindingFlags.Static | BindingFlags.Public) - .First(m => m.Name == nameof(Enumerable.Select) && m.GetParameters().Length == 2) - .MakeGenericMethod(typeof(TV), typeof(TV)); - - source = Expression.Call(selectMethod, source, selector); - } - var offset = 0; var usesRelativeCursors = false; Cursor? cursor = null; @@ -276,6 +265,18 @@ public static BatchExpression BuildBatchExpression( Expression.Constant(arguments.Last.Value + 1)); } + // apply the selector after cursor filtering and paging so cursor predicates + // run against the unprojected source when the selector shape is not SQL-translatable. + if (selector is not null) + { + var selectMethod = typeof(Enumerable) + .GetMethods(BindingFlags.Static | BindingFlags.Public) + .First(m => m.Name == nameof(Enumerable.Select) && m.GetParameters().Length == 2) + .MakeGenericMethod(typeof(TV), typeof(TV)); + + source = Expression.Call(selectMethod, source, selector); + } + source = Expression.Call( typeof(Enumerable), "ToList", diff --git a/src/GreenDonut/src/GreenDonut.Data.EntityFramework/Extensions/PagingQueryableExtensions.cs b/src/GreenDonut/src/GreenDonut.Data.EntityFramework/Extensions/PagingQueryableExtensions.cs index 9865b04426e..61593a89dba 100644 --- a/src/GreenDonut/src/GreenDonut.Data.EntityFramework/Extensions/PagingQueryableExtensions.cs +++ b/src/GreenDonut/src/GreenDonut.Data.EntityFramework/Extensions/PagingQueryableExtensions.cs @@ -427,6 +427,19 @@ public static async ValueTask>> ToBatchPageAsync>> ToBatchPageAsync( + Selector: e => + e is Dog + ? new Dog { Id = ((Dog)e).Id, Name = ((Dog)e).Name } + : e is Cat + ? (Animal)new Cat { Id = ((Cat)e).Id, Name = ((Cat)e).Name } + : null!); + + var arguments = new PagingArguments(2); + + // act + var firstMap = await context.Pets + .With(query, sort => sort.AddDescending(e => e.Name)) + .ToBatchPageAsync(e => e.OwnerId, arguments); + + var firstPage = Assert.Single(firstMap).Value; + + var secondMap = await context.Pets + .With(query, sort => sort.AddDescending(e => e.Name)) + .ToBatchPageAsync( + e => e.OwnerId, + arguments with { After = firstPage.CreateCursor(firstPage.Last!) }); + + var secondPage = Assert.Single(secondMap).Value; + + // assert + Assert.NotNull(secondPage); + Assert.Equal(2, secondPage.Items.Length); + } + private static async Task SeedFileSystemAsync(string connectionString) { await using var context = new FileSystemContext(connectionString); From 018b62c0146a16735660157a6e7a947f442466a5 Mon Sep 17 00:00:00 2001 From: Michael Staib Date: Tue, 24 Feb 2026 20:06:05 +0000 Subject: [PATCH 3/3] Fixed expression issue --- .../Expressions/ExpressionHelpers.cs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/GreenDonut/src/GreenDonut.Data.EntityFramework/Expressions/ExpressionHelpers.cs b/src/GreenDonut/src/GreenDonut.Data.EntityFramework/Expressions/ExpressionHelpers.cs index 588b2ec35f2..b12b008e8a7 100644 --- a/src/GreenDonut/src/GreenDonut.Data.EntityFramework/Expressions/ExpressionHelpers.cs +++ b/src/GreenDonut/src/GreenDonut.Data.EntityFramework/Expressions/ExpressionHelpers.cs @@ -164,6 +164,7 @@ public static BatchExpression BuildBatchExpression( var group = Expression.Parameter(typeof(IGrouping), "g"); var groupKey = Expression.Property(group, "Key"); Expression source = group; + var applySelectorAfterPaging = arguments.After is not null || arguments.Before is not null; for (var i = 0; i < orderExpressions.Length; i++) { @@ -181,6 +182,17 @@ public static BatchExpression BuildBatchExpression( typedOrderExpression); } + // keep the historical query shape unless cursor filtering is active. + if (!applySelectorAfterPaging && selector is not null) + { + var selectMethod = typeof(Enumerable) + .GetMethods(BindingFlags.Static | BindingFlags.Public) + .First(m => m.Name == nameof(Enumerable.Select) && m.GetParameters().Length == 2) + .MakeGenericMethod(typeof(TV), typeof(TV)); + + source = Expression.Call(selectMethod, source, selector); + } + var offset = 0; var usesRelativeCursors = false; Cursor? cursor = null; @@ -267,7 +279,7 @@ public static BatchExpression BuildBatchExpression( // apply the selector after cursor filtering and paging so cursor predicates // run against the unprojected source when the selector shape is not SQL-translatable. - if (selector is not null) + if (applySelectorAfterPaging && selector is not null) { var selectMethod = typeof(Enumerable) .GetMethods(BindingFlags.Static | BindingFlags.Public)