diff --git a/src/EntityFrameworkCore.Projectables/Extensions/QueryableExtensions.cs b/src/EntityFrameworkCore.Projectables/Extensions/QueryableExtensions.cs index e3e2fa4d..0a0b273a 100644 --- a/src/EntityFrameworkCore.Projectables/Extensions/QueryableExtensions.cs +++ b/src/EntityFrameworkCore.Projectables/Extensions/QueryableExtensions.cs @@ -7,4 +7,29 @@ public static class QueryableExtensions /// public static IQueryable ExpandProjectables(this IQueryable query) => query.Provider.CreateQuery(query.Expression.ExpandProjectables()); + + /// + /// Ensures that all writable [Projectable] properties on 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. + /// + /// This method is particularly useful when fetching full entities (e.g. FirstAsync(), + /// ToListAsync()) 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 Where + /// or OrderBy clauses and before terminal operators. + /// + /// + /// This method currently delegates to and does + /// not override the root-rewrite guard for queries that already contain an explicit AsTracking() call + /// in the expression tree. As a result, query.AsTracking().AsExpandedProperties() does not currently + /// force projection injection. + /// + /// + /// 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. + /// + /// + public static IQueryable AsExpandedProperties(this IQueryable query) + where TModel : class + => query.ExpandProjectables(); } \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/AsExpandedPropertiesTests.NoTrackingContext_QueryRoot_InjectsProjectableProperties.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/AsExpandedPropertiesTests.NoTrackingContext_QueryRoot_InjectsProjectableProperties.DotNet10_0.verified.txt new file mode 100644 index 00000000..8a30188c --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/AsExpandedPropertiesTests.NoTrackingContext_QueryRoot_InjectsProjectableProperties.DotNet10_0.verified.txt @@ -0,0 +1,2 @@ +SELECT [e].[Id], [e].[Id] * 3 +FROM [Entity] AS [e] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/AsExpandedPropertiesTests.NoTrackingContext_QueryRoot_InjectsProjectableProperties.DotNet8_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/AsExpandedPropertiesTests.NoTrackingContext_QueryRoot_InjectsProjectableProperties.DotNet8_0.verified.txt new file mode 100644 index 00000000..8a30188c --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/AsExpandedPropertiesTests.NoTrackingContext_QueryRoot_InjectsProjectableProperties.DotNet8_0.verified.txt @@ -0,0 +1,2 @@ +SELECT [e].[Id], [e].[Id] * 3 +FROM [Entity] AS [e] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/AsExpandedPropertiesTests.NoTrackingContext_QueryRoot_InjectsProjectableProperties.DotNet9_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/AsExpandedPropertiesTests.NoTrackingContext_QueryRoot_InjectsProjectableProperties.DotNet9_0.verified.txt new file mode 100644 index 00000000..8a30188c --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/AsExpandedPropertiesTests.NoTrackingContext_QueryRoot_InjectsProjectableProperties.DotNet9_0.verified.txt @@ -0,0 +1,2 @@ +SELECT [e].[Id], [e].[Id] * 3 +FROM [Entity] AS [e] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/AsExpandedPropertiesTests.TrackingContext_QueryRoot_InjectsWritableProjectableProperties.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/AsExpandedPropertiesTests.TrackingContext_QueryRoot_InjectsWritableProjectableProperties.DotNet10_0.verified.txt new file mode 100644 index 00000000..8a30188c --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/AsExpandedPropertiesTests.TrackingContext_QueryRoot_InjectsWritableProjectableProperties.DotNet10_0.verified.txt @@ -0,0 +1,2 @@ +SELECT [e].[Id], [e].[Id] * 3 +FROM [Entity] AS [e] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/AsExpandedPropertiesTests.TrackingContext_QueryRoot_InjectsWritableProjectableProperties.DotNet8_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/AsExpandedPropertiesTests.TrackingContext_QueryRoot_InjectsWritableProjectableProperties.DotNet8_0.verified.txt new file mode 100644 index 00000000..8a30188c --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/AsExpandedPropertiesTests.TrackingContext_QueryRoot_InjectsWritableProjectableProperties.DotNet8_0.verified.txt @@ -0,0 +1,2 @@ +SELECT [e].[Id], [e].[Id] * 3 +FROM [Entity] AS [e] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/AsExpandedPropertiesTests.TrackingContext_QueryRoot_InjectsWritableProjectableProperties.DotNet9_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/AsExpandedPropertiesTests.TrackingContext_QueryRoot_InjectsWritableProjectableProperties.DotNet9_0.verified.txt new file mode 100644 index 00000000..8a30188c --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/AsExpandedPropertiesTests.TrackingContext_QueryRoot_InjectsWritableProjectableProperties.DotNet9_0.verified.txt @@ -0,0 +1,2 @@ +SELECT [e].[Id], [e].[Id] * 3 +FROM [Entity] AS [e] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/AsExpandedPropertiesTests.TrackingContext_WithWhere_InjectsProjectablePropertiesAfterFilter.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/AsExpandedPropertiesTests.TrackingContext_WithWhere_InjectsProjectablePropertiesAfterFilter.DotNet10_0.verified.txt new file mode 100644 index 00000000..ef213415 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/AsExpandedPropertiesTests.TrackingContext_WithWhere_InjectsProjectablePropertiesAfterFilter.DotNet10_0.verified.txt @@ -0,0 +1,3 @@ +SELECT [e].[Id], [e].[Id] * 3 +FROM [Entity] AS [e] +WHERE [e].[Id] > 0 \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/AsExpandedPropertiesTests.TrackingContext_WithWhere_InjectsProjectablePropertiesAfterFilter.DotNet8_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/AsExpandedPropertiesTests.TrackingContext_WithWhere_InjectsProjectablePropertiesAfterFilter.DotNet8_0.verified.txt new file mode 100644 index 00000000..ef213415 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/AsExpandedPropertiesTests.TrackingContext_WithWhere_InjectsProjectablePropertiesAfterFilter.DotNet8_0.verified.txt @@ -0,0 +1,3 @@ +SELECT [e].[Id], [e].[Id] * 3 +FROM [Entity] AS [e] +WHERE [e].[Id] > 0 \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/AsExpandedPropertiesTests.TrackingContext_WithWhere_InjectsProjectablePropertiesAfterFilter.DotNet9_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/AsExpandedPropertiesTests.TrackingContext_WithWhere_InjectsProjectablePropertiesAfterFilter.DotNet9_0.verified.txt new file mode 100644 index 00000000..ef213415 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/AsExpandedPropertiesTests.TrackingContext_WithWhere_InjectsProjectablePropertiesAfterFilter.DotNet9_0.verified.txt @@ -0,0 +1,3 @@ +SELECT [e].[Id], [e].[Id] * 3 +FROM [Entity] AS [e] +WHERE [e].[Id] > 0 \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/AsExpandedPropertiesTests.cs b/tests/EntityFrameworkCore.Projectables.FunctionalTests/AsExpandedPropertiesTests.cs new file mode 100644 index 00000000..52e05866 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/AsExpandedPropertiesTests.cs @@ -0,0 +1,71 @@ +using EntityFrameworkCore.Projectables.Extensions; +using EntityFrameworkCore.Projectables.FunctionalTests.Helpers; +using Microsoft.EntityFrameworkCore; + +namespace EntityFrameworkCore.Projectables.FunctionalTests; + +public class AsExpandedPropertiesTests +{ + /// + /// Entity with both a read-only and a writable projectable property. + /// + /// ReadOnlyComputed — expression-bodied (no setter); excluded from auto-inject. + /// WritableComputed — has a setter; included in auto-inject. + /// + /// + public class Entity + { + public int Id { get; set; } + + /// Read-only projectable: NOT included in the auto-generated SELECT (no setter). + [Projectable] + public int ReadOnlyComputed => Id * 2; + + /// Writable projectable: IS included in the auto-generated SELECT (has setter). + [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(queryTrackingBehavior: QueryTrackingBehavior.TrackAll); + + var query = dbContext.Set().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(queryTrackingBehavior: QueryTrackingBehavior.TrackAll); + + var query = dbContext.Set() + .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(queryTrackingBehavior: QueryTrackingBehavior.NoTracking); + + var query = dbContext.Set().AsExpandedProperties(); + + return Verifier.Verify(query.ToQueryString()); + } +}