Skip to content
Open
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
Expand Up @@ -37,6 +37,7 @@ public sealed class ProjectableExpressionReplacer : ExpressionVisitor
private readonly static ConditionalWeakTable<Type, PropertyInfo[]> _projectablePropertiesCache = new();
private readonly static ConditionalWeakTable<Type, MethodInfo> _closedSelectCache = new();
private readonly static ConditionalWeakTable<Type, MethodInfo> _closedWhereCache = new();
private readonly static ConditionalWeakTable<Type, StrongBox<Type?>> _queryableElementTypeCache = new();

public ProjectableExpressionReplacer(IProjectionExpressionResolver projectionExpressionResolver, bool trackByDefault = false)
{
Expand Down Expand Up @@ -330,6 +331,15 @@ protected override Expression VisitExtension(Expression node)

private Expression _AddProjectableSelect(Expression node, IEntityType entityType)
{
// This appends Select<TEntity, TEntity>, 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<Dto>' cannot be used for
// parameter of type 'IQueryable<TEntity>' of method Select[TEntity,TEntity]
if (!_IsSequenceOfEntity(node.Type, entityType.ClrType))
{
return node;
}

var projectableProperties = _projectablePropertiesCache.GetValue(
entityType.ClrType,
static t => t.GetProperties()
Expand Down Expand Up @@ -373,6 +383,44 @@ private Expression _AddProjectableSelect(Expression node, IEntityType entityType
);
}

// True when `node` is statically an IQueryable<entityClrType>, which is the only shape
// Select<TEntity, TEntity> can be appended to. Deliberately an exact element-type
// match rather than an assignability check: IQueryable<out T> is covariant, so
// IQueryable<TEntity>.IsAssignableFrom(IQueryable<TDerived>) 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<Type?>(_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<T> exactly.
if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(IQueryable<>))
{
return type.GetGenericArguments()[0];
}

// Otherwise the node may be a concrete queryable such as DbSet<T>.
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.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
SELECT [e].[Id] * 5 AS [ComputedWithBacking]
FROM [Entity] AS [e]
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
SELECT [e].[Id] * 5 AS [ComputedWithBacking]
FROM [Entity] AS [e]
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
SELECT [e].[Id] * 5 AS [ComputedWithBacking]
FROM [Entity] AS [e]
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
SELECT [e].[Id], [e].[Id] * 5 AS [ComputedWithBacking]
FROM [Entity] AS [e]
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
SELECT [e].[Id], [e].[Id] * 5 AS [ComputedWithBacking]
FROM [Entity] AS [e]
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
SELECT [e].[Id], [e].[Id] * 5 AS [ComputedWithBacking]
FROM [Entity] AS [e]
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
SELECT [e].[Id] * 5 AS [ComputedWithBacking]
FROM [Entity] AS [e]
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
SELECT [e].[Id] * 5 AS [ComputedWithBacking]
FROM [Entity] AS [e]
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
SELECT [e].[Id] * 5 AS [ComputedWithBacking]
FROM [Entity] AS [e]
Original file line number Diff line number Diff line change
Expand Up @@ -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<Entity, Entity> to an IQueryable<anonymous>.
[Fact]
public Task AsNoTrackingThenProjectionToAnonymousTypeQueryRootExpression()
{
using var dbContext = new SampleDbContext<Entity>(queryTrackingBehavior: QueryTrackingBehavior.TrackAll);

var query = dbContext.Set<Entity>()
.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<Entity>(queryTrackingBehavior: QueryTrackingBehavior.NoTracking);

var query = dbContext.Set<Entity>()
.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<Entity>(queryTrackingBehavior: QueryTrackingBehavior.TrackAll);

var query = dbContext.Set<Entity>()
.AsNoTracking()
.Select(e => e);

return Verifier.Verify(query.ToQueryString());
}
}
}