From 578db5f4744137daa73d69a894a6779a9a8575d4 Mon Sep 17 00:00:00 2001 From: Zac Merritt Date: Thu, 24 Sep 2026 16:27:41 -0400 Subject: [PATCH 1/2] Don't apply the query root rewrite to non-entity projections _AddProjectableSelect appends Select, so it only composes when the node really is a sequence of TEntity. It is not, whenever the caller already projected away from the entity root, and appending the rewrite anyway throws instead of no-oping: ArgumentException: Expression of type 'IQueryable' cannot be used for parameter of type 'IQueryable' of method Select[TEntity,TEntity] _disableRootRewrite is meant to prevent this -- a Select sets it -- but #132 gave that same field a second job, the tracking decision. ExpressionVisitor walks outside-in, so in db.Set().AsNoTracking().Where(...).Select(x => new Dto { ... }).ToList() the AsNoTracking node is visited last and clears the flag the Select set, re-enabling the rewrite against an IQueryable. Any query that puts AsNoTracking before a projection to a DTO or an anonymous type throws, which is a common repository shape. Checking the node's own type is independent of visit order, so it holds whichever of the two flags wrote last. Deliberately an exact element-type match rather than an assignability check: IQueryable is covariant, so IQueryable is assignable from IQueryable, and rewriting there would re-project a derived entity as its base type. Tests: three new QueryRootTests cases covering AsNoTracking before a projection to an anonymous type, the same via the context's default tracking behaviour, and a projection that stays on the entity type (still rewritten). Co-Authored-By: Claude Opus 5.5 --- .../Services/ProjectableExpressionReplacer.cs | 58 +++++++++++++++++++ ...ueryRootExpression.DotNet10_0.verified.txt | 2 + ...QueryRootExpression.DotNet8_0.verified.txt | 2 + ...QueryRootExpression.DotNet9_0.verified.txt | 2 + ...ueryRootExpression.DotNet10_0.verified.txt | 2 + ...QueryRootExpression.DotNet8_0.verified.txt | 2 + ...QueryRootExpression.DotNet9_0.verified.txt | 2 + ...ueryRootExpression.DotNet10_0.verified.txt | 2 + ...QueryRootExpression.DotNet8_0.verified.txt | 2 + ...QueryRootExpression.DotNet9_0.verified.txt | 2 + .../QueryRootTests.cs | 44 ++++++++++++++ 11 files changed, 120 insertions(+) create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/QueryRootTests.AsNoTrackingThenProjectionToAnonymousTypeQueryRootExpression.DotNet10_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/QueryRootTests.AsNoTrackingThenProjectionToAnonymousTypeQueryRootExpression.DotNet8_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/QueryRootTests.AsNoTrackingThenProjectionToAnonymousTypeQueryRootExpression.DotNet9_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/QueryRootTests.AsNoTrackingThenProjectionToEntityTypeQueryRootExpression.DotNet10_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/QueryRootTests.AsNoTrackingThenProjectionToEntityTypeQueryRootExpression.DotNet8_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/QueryRootTests.AsNoTrackingThenProjectionToEntityTypeQueryRootExpression.DotNet9_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/QueryRootTests.NoTrackingByDefaultThenProjectionToAnonymousTypeQueryRootExpression.DotNet10_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/QueryRootTests.NoTrackingByDefaultThenProjectionToAnonymousTypeQueryRootExpression.DotNet8_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/QueryRootTests.NoTrackingByDefaultThenProjectionToAnonymousTypeQueryRootExpression.DotNet9_0.verified.txt diff --git a/src/EntityFrameworkCore.Projectables/Services/ProjectableExpressionReplacer.cs b/src/EntityFrameworkCore.Projectables/Services/ProjectableExpressionReplacer.cs index be3c96a4..5a8da274 100644 --- a/src/EntityFrameworkCore.Projectables/Services/ProjectableExpressionReplacer.cs +++ b/src/EntityFrameworkCore.Projectables/Services/ProjectableExpressionReplacer.cs @@ -37,6 +37,7 @@ public sealed class ProjectableExpressionReplacer : ExpressionVisitor private readonly static ConditionalWeakTable _projectablePropertiesCache = new(); private readonly static ConditionalWeakTable _closedSelectCache = new(); private readonly static ConditionalWeakTable _closedWhereCache = new(); + private readonly static ConditionalWeakTable> _queryableElementTypeCache = new(); public ProjectableExpressionReplacer(IProjectionExpressionResolver projectionExpressionResolver, bool trackByDefault = false) { @@ -330,6 +331,25 @@ protected override Expression VisitExtension(Expression node) private Expression _AddProjectableSelect(Expression node, IEntityType entityType) { + // This rewrite appends Select, so it only composes when the + // node really is a sequence of TEntity. It is not, whenever the caller already + // projected away from the entity root -- `.Select(x => new Dto { ... })`, or a + // projection to an anonymous type. There is no entity left to populate in that + // case, and appending the rewrite is a type error rather than a no-op: + // + // ArgumentException: Expression of type 'IQueryable' cannot be used for + // parameter of type 'IQueryable' of method Select[TEntity,TEntity] + // + // _disableRootRewrite is meant to catch this: a `Select` sets it. But #132 gave + // that same field a second job, the tracking decision, and an `AsNoTracking()` + // written before the `Select` clears it again -- ExpressionVisitor walks + // outside-in, so the `AsNoTracking` node nearest the query root is visited last + // and wins. Checking the node's own type is independent of visit order. + if (!_IsSequenceOfEntity(node.Type, entityType.ClrType)) + { + return node; + } + var projectableProperties = _projectablePropertiesCache.GetValue( entityType.ClrType, static t => t.GetProperties() @@ -373,6 +393,44 @@ private Expression _AddProjectableSelect(Expression node, IEntityType entityType ); } + // True when `node` is statically an IQueryable, which is the only shape + // Select can be appended to. Deliberately an exact element-type + // match rather than an assignability check: IQueryable is covariant, so + // IQueryable.IsAssignableFrom(IQueryable) is true, and rewriting + // there would re-project a derived entity as its base type. + private static bool _IsSequenceOfEntity(Type nodeType, Type entityClrType) + => _queryableElementTypeCache + .GetValue(nodeType, static t => new StrongBox(_FindQueryableElementType(t))) + .Value == entityClrType; + + private static Type? _FindQueryableElementType(Type type) + { + // Fast path: expression nodes built by Queryable.* and EF's query root are + // typed as IQueryable exactly. + if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(IQueryable<>)) + { + return type.GetGenericArguments()[0]; + } + + // Otherwise the node may be a concrete queryable such as DbSet. + Type? elementType = null; + foreach (var candidate in type.GetInterfaces()) + { + if (candidate.IsGenericType && candidate.GetGenericTypeDefinition() == typeof(IQueryable<>)) + { + if (elementType is not null) + { + // Implements IQueryable<> more than once; no single element type. + return null; + } + + elementType = candidate.GetGenericArguments()[0]; + } + } + + return elementType; + } + // Builds the member binding used to copy an EF-mapped member into the re-projected entity. // // EF's member metadata resolves auto-properties to their compiler-generated backing field. diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/QueryRootTests.AsNoTrackingThenProjectionToAnonymousTypeQueryRootExpression.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/QueryRootTests.AsNoTrackingThenProjectionToAnonymousTypeQueryRootExpression.DotNet10_0.verified.txt new file mode 100644 index 00000000..759fce32 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/QueryRootTests.AsNoTrackingThenProjectionToAnonymousTypeQueryRootExpression.DotNet10_0.verified.txt @@ -0,0 +1,2 @@ +SELECT [e].[Id] * 5 AS [ComputedWithBacking] +FROM [Entity] AS [e] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/QueryRootTests.AsNoTrackingThenProjectionToAnonymousTypeQueryRootExpression.DotNet8_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/QueryRootTests.AsNoTrackingThenProjectionToAnonymousTypeQueryRootExpression.DotNet8_0.verified.txt new file mode 100644 index 00000000..759fce32 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/QueryRootTests.AsNoTrackingThenProjectionToAnonymousTypeQueryRootExpression.DotNet8_0.verified.txt @@ -0,0 +1,2 @@ +SELECT [e].[Id] * 5 AS [ComputedWithBacking] +FROM [Entity] AS [e] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/QueryRootTests.AsNoTrackingThenProjectionToAnonymousTypeQueryRootExpression.DotNet9_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/QueryRootTests.AsNoTrackingThenProjectionToAnonymousTypeQueryRootExpression.DotNet9_0.verified.txt new file mode 100644 index 00000000..759fce32 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/QueryRootTests.AsNoTrackingThenProjectionToAnonymousTypeQueryRootExpression.DotNet9_0.verified.txt @@ -0,0 +1,2 @@ +SELECT [e].[Id] * 5 AS [ComputedWithBacking] +FROM [Entity] AS [e] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/QueryRootTests.AsNoTrackingThenProjectionToEntityTypeQueryRootExpression.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/QueryRootTests.AsNoTrackingThenProjectionToEntityTypeQueryRootExpression.DotNet10_0.verified.txt new file mode 100644 index 00000000..88b47d7c --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/QueryRootTests.AsNoTrackingThenProjectionToEntityTypeQueryRootExpression.DotNet10_0.verified.txt @@ -0,0 +1,2 @@ +SELECT [e].[Id], [e].[Id] * 5 AS [ComputedWithBacking] +FROM [Entity] AS [e] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/QueryRootTests.AsNoTrackingThenProjectionToEntityTypeQueryRootExpression.DotNet8_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/QueryRootTests.AsNoTrackingThenProjectionToEntityTypeQueryRootExpression.DotNet8_0.verified.txt new file mode 100644 index 00000000..88b47d7c --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/QueryRootTests.AsNoTrackingThenProjectionToEntityTypeQueryRootExpression.DotNet8_0.verified.txt @@ -0,0 +1,2 @@ +SELECT [e].[Id], [e].[Id] * 5 AS [ComputedWithBacking] +FROM [Entity] AS [e] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/QueryRootTests.AsNoTrackingThenProjectionToEntityTypeQueryRootExpression.DotNet9_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/QueryRootTests.AsNoTrackingThenProjectionToEntityTypeQueryRootExpression.DotNet9_0.verified.txt new file mode 100644 index 00000000..88b47d7c --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/QueryRootTests.AsNoTrackingThenProjectionToEntityTypeQueryRootExpression.DotNet9_0.verified.txt @@ -0,0 +1,2 @@ +SELECT [e].[Id], [e].[Id] * 5 AS [ComputedWithBacking] +FROM [Entity] AS [e] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/QueryRootTests.NoTrackingByDefaultThenProjectionToAnonymousTypeQueryRootExpression.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/QueryRootTests.NoTrackingByDefaultThenProjectionToAnonymousTypeQueryRootExpression.DotNet10_0.verified.txt new file mode 100644 index 00000000..759fce32 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/QueryRootTests.NoTrackingByDefaultThenProjectionToAnonymousTypeQueryRootExpression.DotNet10_0.verified.txt @@ -0,0 +1,2 @@ +SELECT [e].[Id] * 5 AS [ComputedWithBacking] +FROM [Entity] AS [e] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/QueryRootTests.NoTrackingByDefaultThenProjectionToAnonymousTypeQueryRootExpression.DotNet8_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/QueryRootTests.NoTrackingByDefaultThenProjectionToAnonymousTypeQueryRootExpression.DotNet8_0.verified.txt new file mode 100644 index 00000000..759fce32 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/QueryRootTests.NoTrackingByDefaultThenProjectionToAnonymousTypeQueryRootExpression.DotNet8_0.verified.txt @@ -0,0 +1,2 @@ +SELECT [e].[Id] * 5 AS [ComputedWithBacking] +FROM [Entity] AS [e] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/QueryRootTests.NoTrackingByDefaultThenProjectionToAnonymousTypeQueryRootExpression.DotNet9_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/QueryRootTests.NoTrackingByDefaultThenProjectionToAnonymousTypeQueryRootExpression.DotNet9_0.verified.txt new file mode 100644 index 00000000..759fce32 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/QueryRootTests.NoTrackingByDefaultThenProjectionToAnonymousTypeQueryRootExpression.DotNet9_0.verified.txt @@ -0,0 +1,2 @@ +SELECT [e].[Id] * 5 AS [ComputedWithBacking] +FROM [Entity] AS [e] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/QueryRootTests.cs b/tests/EntityFrameworkCore.Projectables.FunctionalTests/QueryRootTests.cs index eee05c8c..d613a8a6 100644 --- a/tests/EntityFrameworkCore.Projectables.FunctionalTests/QueryRootTests.cs +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/QueryRootTests.cs @@ -76,5 +76,49 @@ public Task AsNoTrackingQueryRootExpression() return Verifier.Verify(query.ToQueryString()); } + + // AsNoTracking() written *before* a projection to a non-entity type. The query root + // rewrite must not be applied: there is no entity left in the result to populate. + // Before the element-type guard this threw, because AsNoTracking is visited after + // the Select (ExpressionVisitor walks outside-in) and so re-enabled the rewrite, + // which then tried to append Select to an IQueryable. + [Fact] + public Task AsNoTrackingThenProjectionToAnonymousTypeQueryRootExpression() + { + using var dbContext = new SampleDbContext(queryTrackingBehavior: QueryTrackingBehavior.TrackAll); + + var query = dbContext.Set() + .AsNoTracking() + .Select(e => new { e.ComputedWithBacking }); + + return Verifier.Verify(query.ToQueryString()); + } + + // The same projection, reached through the context's default tracking behaviour + // rather than an explicit AsNoTracking() call. + [Fact] + public Task NoTrackingByDefaultThenProjectionToAnonymousTypeQueryRootExpression() + { + using var dbContext = new SampleDbContext(queryTrackingBehavior: QueryTrackingBehavior.NoTracking); + + var query = dbContext.Set() + .Select(e => new { e.ComputedWithBacking }); + + return Verifier.Verify(query.ToQueryString()); + } + + // A projection that stays on the entity type is still rewritten, so a writable + // projectable gets populated. + [Fact] + public Task AsNoTrackingThenProjectionToEntityTypeQueryRootExpression() + { + using var dbContext = new SampleDbContext(queryTrackingBehavior: QueryTrackingBehavior.TrackAll); + + var query = dbContext.Set() + .AsNoTracking() + .Select(e => e); + + return Verifier.Verify(query.ToQueryString()); + } } } From b20803b048f70a51118090867191b6ebace60cc5 Mon Sep 17 00:00:00 2001 From: Zac Merritt <89794264+zmerdev@users.noreply.github.com> Date: Fri, 25 Sep 2026 16:41:23 -0400 Subject: [PATCH 2/2] Les verbose comments --- .../Services/ProjectableExpressionReplacer.cs | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/src/EntityFrameworkCore.Projectables/Services/ProjectableExpressionReplacer.cs b/src/EntityFrameworkCore.Projectables/Services/ProjectableExpressionReplacer.cs index 5a8da274..83c20189 100644 --- a/src/EntityFrameworkCore.Projectables/Services/ProjectableExpressionReplacer.cs +++ b/src/EntityFrameworkCore.Projectables/Services/ProjectableExpressionReplacer.cs @@ -331,20 +331,10 @@ protected override Expression VisitExtension(Expression node) private Expression _AddProjectableSelect(Expression node, IEntityType entityType) { - // This rewrite appends Select, so it only composes when the - // node really is a sequence of TEntity. It is not, whenever the caller already - // projected away from the entity root -- `.Select(x => new Dto { ... })`, or a - // projection to an anonymous type. There is no entity left to populate in that - // case, and appending the rewrite is a type error rather than a no-op: - // - // ArgumentException: Expression of type 'IQueryable' cannot be used for - // parameter of type 'IQueryable' of method Select[TEntity,TEntity] - // - // _disableRootRewrite is meant to catch this: a `Select` sets it. But #132 gave - // that same field a second job, the tracking decision, and an `AsNoTracking()` - // written before the `Select` clears it again -- ExpressionVisitor walks - // outside-in, so the `AsNoTracking` node nearest the query root is visited last - // and wins. Checking the node's own type is independent of visit order. + // This appends Select, so it only composes when the node + // really is a sequence of TEntity not a DTO or anonymous type. Fixes this error + // ArgumentException: Expression of type 'IQueryable' cannot be used for + // parameter of type 'IQueryable' of method Select[TEntity,TEntity] if (!_IsSequenceOfEntity(node.Type, entityType.ClrType)) { return node;