diff --git a/src/EntityFrameworkCore.Projectables/Services/ProjectableExpressionReplacer.cs b/src/EntityFrameworkCore.Projectables/Services/ProjectableExpressionReplacer.cs index be3c96a..83c2018 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,15 @@ protected override Expression VisitExtension(Expression node) private Expression _AddProjectableSelect(Expression node, IEntityType entityType) { + // 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; + } + var projectableProperties = _projectablePropertiesCache.GetValue( entityType.ClrType, static t => t.GetProperties() @@ -373,6 +383,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 0000000..759fce3 --- /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 0000000..759fce3 --- /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 0000000..759fce3 --- /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 0000000..88b47d7 --- /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 0000000..88b47d7 --- /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 0000000..88b47d7 --- /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 0000000..759fce3 --- /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 0000000..759fce3 --- /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 0000000..759fce3 --- /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 eee05c8..d613a8a 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()); + } } }