Skip to content
Draft
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 @@ -7,4 +7,29 @@ public static class QueryableExtensions
/// </summary>
public static IQueryable<TModel> ExpandProjectables<TModel>(this IQueryable<TModel> query)
=> query.Provider.CreateQuery<TModel>(query.Expression.ExpandProjectables());

/// <summary>
/// Ensures that all writable <c>[Projectable]</c> properties on <typeparamref name="TModel"/> are populated
/// in query results by automatically generating a SELECT projection that merges all EF-mapped properties and navigations with
/// the expression-expanded value of every writable projectable property.
/// <para>
/// This method is particularly useful when fetching full entities (e.g. <c>FirstAsync()</c>,
/// <c>ToListAsync()</c>) on queries that are tracked by default, where the automatic select injection is
/// otherwise suppressed to preserve change-tracking semantics. Call this method after any <c>Where</c>
/// or <c>OrderBy</c> clauses and before terminal operators.
/// </para>
/// <para>
/// This method currently delegates to <see cref="ExpandProjectables{TModel}(IQueryable{TModel})"/> and does
/// not override the root-rewrite guard for queries that already contain an explicit <c>AsTracking()</c> call
/// in the expression tree. As a result, <c>query.AsTracking().AsExpandedProperties()</c> does not currently
/// force projection injection.
/// </para>
/// <para>
/// Read-only projectable properties (those without a setter) are not included in the generated projection
/// because EF Core's materializer cannot set them on the resulting entity.
/// </para>
/// </summary>
public static IQueryable<TModel> AsExpandedProperties<TModel>(this IQueryable<TModel> query)
where TModel : class
=> query.ExpandProjectables();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
SELECT [e].[Id], [e].[Id] * 3
FROM [Entity] AS [e]
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
SELECT [e].[Id], [e].[Id] * 3
FROM [Entity] AS [e]
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
SELECT [e].[Id], [e].[Id] * 3
FROM [Entity] AS [e]
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
SELECT [e].[Id], [e].[Id] * 3
FROM [Entity] AS [e]
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
SELECT [e].[Id], [e].[Id] * 3
FROM [Entity] AS [e]
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
SELECT [e].[Id], [e].[Id] * 3
FROM [Entity] AS [e]
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
SELECT [e].[Id], [e].[Id] * 3
FROM [Entity] AS [e]
WHERE [e].[Id] > 0
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
SELECT [e].[Id], [e].[Id] * 3
FROM [Entity] AS [e]
WHERE [e].[Id] > 0
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
SELECT [e].[Id], [e].[Id] * 3
FROM [Entity] AS [e]
WHERE [e].[Id] > 0
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
using EntityFrameworkCore.Projectables.Extensions;
using EntityFrameworkCore.Projectables.FunctionalTests.Helpers;
using Microsoft.EntityFrameworkCore;

namespace EntityFrameworkCore.Projectables.FunctionalTests;

public class AsExpandedPropertiesTests
{
/// <summary>
/// Entity with both a read-only and a writable projectable property.
/// <list type="bullet">
/// <item><c>ReadOnlyComputed</c> — expression-bodied (no setter); excluded from auto-inject.</item>
/// <item><c>WritableComputed</c> — has a setter; included in auto-inject.</item>
/// </list>
/// </summary>
public class Entity
{
public int Id { get; set; }

/// <summary>Read-only projectable: NOT included in the auto-generated SELECT (no setter).</summary>
[Projectable]
public int ReadOnlyComputed => Id * 2;

/// <summary>Writable projectable: IS included in the auto-generated SELECT (has setter).</summary>
[Projectable]
public int WritableComputed
{
get => Id * 3;
set { }
}
}

[Fact]
public Task TrackingContext_QueryRoot_InjectsWritableProjectableProperties()
{
// Tracking context: without AsExpandedProperties() the auto-inject is suppressed.
// AsExpandedProperties() forces the Select injection regardless of tracking mode.
// ReadOnlyComputed is absent from SELECT (no setter); WritableComputed is present.
using var dbContext = new SampleDbContext<Entity>(queryTrackingBehavior: QueryTrackingBehavior.TrackAll);

var query = dbContext.Set<Entity>().AsExpandedProperties();

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

[Fact]
public Task TrackingContext_WithWhere_InjectsProjectablePropertiesAfterFilter()
{
// Verifies that AsExpandedProperties() wraps the Where clause (not the raw root),
// so the generated SQL contains WHERE and SELECT in the correct order.
using var dbContext = new SampleDbContext<Entity>(queryTrackingBehavior: QueryTrackingBehavior.TrackAll);

var query = dbContext.Set<Entity>()
.Where(e => e.Id > 0)
.AsExpandedProperties();

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

[Fact]
public Task NoTrackingContext_QueryRoot_InjectsProjectableProperties()
{
// NoTracking context: auto-inject already happens automatically via UseProjectables();
// calling AsExpandedProperties() explicitly should produce the same result.
using var dbContext = new SampleDbContext<Entity>(queryTrackingBehavior: QueryTrackingBehavior.NoTracking);

var query = dbContext.Set<Entity>().AsExpandedProperties();

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