From 235576971995648e64f7e4c61fc32389f1df4ab6 Mon Sep 17 00:00:00 2001 From: Zac Merritt Date: Thu, 24 Sep 2026 16:29:24 -0400 Subject: [PATCH] Add UseProjectables(o => o.PopulateSettableProperties(false)) opt-out Filling in settable projectables when whole entities are loaded (#84) is a behaviour change for anyone upgrading from before it: a materialized entity now carries database-computed values for those properties, and the query rewrite behind it also materializes navigations that were never Include-ed. Callers who only ever project explicitly can now opt out and keep the older semantics. Projectables are still expanded wherever a query reads them; only the assignment onto materialized entities goes away. Enabled by default, so nothing changes unless a caller opts out. Applies to CompatibilityMode.Full. The Limited path builds its replacer through ExpressionExtensions.ExpandProjectables, which has no access to context options, so it is unaffected -- as it already is by the #132 tracking check, which it also does not see. Tests: two new QueryRootTests cases covering both sides of PopulateSettableProperties(false): a plain entity query is not rewritten, and a projectable read in a Select is still expanded. Co-Authored-By: Claude Opus 5.5 --- .../Internal/CustomQueryCompiler.cs | 4 ++- .../Internal/ProjectionOptionsExtension.cs | 18 ++++++++++++ .../ProjectableOptionsBuilder.cs | 14 ++++++++++ .../Services/ProjectableExpressionReplacer.cs | 12 +++++++- .../Helpers/SampleDbContext.cs | 5 +++- ...ueryRootExpression.DotNet10_0.verified.txt | 2 ++ ...QueryRootExpression.DotNet8_0.verified.txt | 2 ++ ...QueryRootExpression.DotNet9_0.verified.txt | 2 ++ ...xpandsInProjection.DotNet10_0.verified.txt | 2 ++ ...ExpandsInProjection.DotNet8_0.verified.txt | 2 ++ ...ExpandsInProjection.DotNet9_0.verified.txt | 2 ++ .../QueryRootTests.cs | 28 +++++++++++++++++++ 12 files changed, 90 insertions(+), 3 deletions(-) create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/QueryRootTests.PopulateSettablePropertiesDisabledQueryRootExpression.DotNet10_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/QueryRootTests.PopulateSettablePropertiesDisabledQueryRootExpression.DotNet8_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/QueryRootTests.PopulateSettablePropertiesDisabledQueryRootExpression.DotNet9_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/QueryRootTests.PopulateSettablePropertiesDisabledStillExpandsInProjection.DotNet10_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/QueryRootTests.PopulateSettablePropertiesDisabledStillExpandsInProjection.DotNet8_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/QueryRootTests.PopulateSettablePropertiesDisabledStillExpandsInProjection.DotNet9_0.verified.txt diff --git a/src/EntityFrameworkCore.Projectables/Infrastructure/Internal/CustomQueryCompiler.cs b/src/EntityFrameworkCore.Projectables/Infrastructure/Internal/CustomQueryCompiler.cs index f8a206e9..87547e6a 100644 --- a/src/EntityFrameworkCore.Projectables/Infrastructure/Internal/CustomQueryCompiler.cs +++ b/src/EntityFrameworkCore.Projectables/Infrastructure/Internal/CustomQueryCompiler.cs @@ -61,7 +61,9 @@ public CustomQueryCompiler(IQueryCompiler decoratedQueryCompiler, var trackingByDefault = (contextOptions.FindExtension()?.QueryTrackingBehavior ?? QueryTrackingBehavior.TrackAll) == QueryTrackingBehavior.TrackAll; - _projectableExpressionReplacer = new ProjectableExpressionReplacer(new ProjectionExpressionResolver(), trackingByDefault); + var populateSettableProperties = contextOptions.FindExtension()?.PopulateSettableProperties ?? true; + + _projectableExpressionReplacer = new ProjectableExpressionReplacer(new ProjectionExpressionResolver(), trackingByDefault, populateSettableProperties); } public override Func CreateCompiledAsyncQuery(Expression query) diff --git a/src/EntityFrameworkCore.Projectables/Infrastructure/Internal/ProjectionOptionsExtension.cs b/src/EntityFrameworkCore.Projectables/Infrastructure/Internal/ProjectionOptionsExtension.cs index fbfa4be9..0261a16c 100644 --- a/src/EntityFrameworkCore.Projectables/Infrastructure/Internal/ProjectionOptionsExtension.cs +++ b/src/EntityFrameworkCore.Projectables/Infrastructure/Internal/ProjectionOptionsExtension.cs @@ -19,6 +19,7 @@ namespace EntityFrameworkCore.Projectables.Infrastructure.Internal public class ProjectionOptionsExtension : IDbContextOptionsExtension { CompatibilityMode _compatibilityMode = CompatibilityMode.Full; + bool _populateSettableProperties = true; public ProjectionOptionsExtension() { @@ -91,6 +92,21 @@ static object CreateTargetInstance(IServiceProvider services, ServiceDescriptor } } + /// + /// Whether projectable properties that have a setter are filled in with their + /// database-computed value when a query loads whole entities. Enabled by default. + /// + public bool PopulateSettableProperties => _populateSettableProperties; + + public ProjectionOptionsExtension WithPopulateSettableProperties(bool enabled) + { + var clone = Clone(); + + clone._populateSettableProperties = enabled; + + return clone; + } + public ProjectionOptionsExtension WithCompatibilityMode(CompatibilityMode compatibilityMode) { var clone = Clone(); @@ -126,6 +142,7 @@ public override void PopulateDebugInfo(IDictionary debugInfo) } debugInfo["Projectables:CompatibilityMode"] = Extension._compatibilityMode.ToString(); + debugInfo["Projectables:PopulateSettableProperties"] = Extension._populateSettableProperties.ToString(); } public override int GetServiceProviderHashCode() @@ -133,6 +150,7 @@ public override int GetServiceProviderHashCode() var hashCode = new HashCode(); hashCode.Add(Extension._compatibilityMode); + hashCode.Add(Extension._populateSettableProperties); return hashCode.ToHashCode(); } diff --git a/src/EntityFrameworkCore.Projectables/Infrastructure/ProjectableOptionsBuilder.cs b/src/EntityFrameworkCore.Projectables/Infrastructure/ProjectableOptionsBuilder.cs index 697a6e5d..48ccf948 100644 --- a/src/EntityFrameworkCore.Projectables/Infrastructure/ProjectableOptionsBuilder.cs +++ b/src/EntityFrameworkCore.Projectables/Infrastructure/ProjectableOptionsBuilder.cs @@ -24,6 +24,20 @@ public ProjectableOptionsBuilder(DbContextOptionsBuilder optionsBuilder) public ProjectableOptionsBuilder CompatibilityMode(CompatibilityMode mode) => WithOption(x => x.WithCompatibilityMode(mode)); + /// + /// Controls whether projectable properties that have a setter are filled in with their + /// database-computed value when a query loads whole entities (issue #84). Enabled by + /// default. + /// + /// Disabling it leaves projectables usable inside queries -- in Select, Where, + /// OrderBy and so on -- while a loaded entity keeps whatever its CLR member + /// returns. That is the behaviour of versions from before #84, and is what you want + /// when entities are only ever projected explicitly. + /// + /// + public ProjectableOptionsBuilder PopulateSettableProperties(bool enabled) + => WithOption(x => x.WithPopulateSettableProperties(enabled)); + /// /// Sets an option by cloning the extension used to store the settings. This ensures the builder /// does not modify options that are already in use elsewhere. diff --git a/src/EntityFrameworkCore.Projectables/Services/ProjectableExpressionReplacer.cs b/src/EntityFrameworkCore.Projectables/Services/ProjectableExpressionReplacer.cs index be3c96a4..874c5b9a 100644 --- a/src/EntityFrameworkCore.Projectables/Services/ProjectableExpressionReplacer.cs +++ b/src/EntityFrameworkCore.Projectables/Services/ProjectableExpressionReplacer.cs @@ -19,6 +19,7 @@ public sealed class ProjectableExpressionReplacer : ExpressionVisitor private IQueryProvider? _currentQueryProvider; private bool _disableRootRewrite = false; private readonly bool _trackingByDefault; + private readonly bool _populateSettableProperties; private IEntityType? _entityType; // Extract MethodInfo via expression trees (trim-safe; computed once per AppDomain) @@ -38,9 +39,10 @@ public sealed class ProjectableExpressionReplacer : ExpressionVisitor private readonly static ConditionalWeakTable _closedSelectCache = new(); private readonly static ConditionalWeakTable _closedWhereCache = new(); - public ProjectableExpressionReplacer(IProjectionExpressionResolver projectionExpressionResolver, bool trackByDefault = false) + public ProjectableExpressionReplacer(IProjectionExpressionResolver projectionExpressionResolver, bool trackByDefault = false, bool populateSettableProperties = true) { _trackingByDefault = trackByDefault; + _populateSettableProperties = populateSettableProperties; _resolver = projectionExpressionResolver; } @@ -69,6 +71,14 @@ bool TryGetReflectedExpression(MemberInfo memberInfo, [NotNullWhen(true)] out La var ret = Visit(node); + if (!_populateSettableProperties) + { + // Opted out via UseProjectables(o => o.PopulateSettableProperties(false)). + // Projectables are still expanded wherever a query reads them; they are just + // not assigned onto materialized entities. + return ret; + } + if (_disableRootRewrite) { // This boolean is enabled when a "Select" is encountered diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/Helpers/SampleDbContext.cs b/tests/EntityFrameworkCore.Projectables.FunctionalTests/Helpers/SampleDbContext.cs index f17e81b9..89843182 100644 --- a/tests/EntityFrameworkCore.Projectables.FunctionalTests/Helpers/SampleDbContext.cs +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/Helpers/SampleDbContext.cs @@ -15,11 +15,13 @@ public class SampleDbContext : DbContext { readonly CompatibilityMode _compatibilityMode; readonly QueryTrackingBehavior _queryTrackingBehavior; + readonly bool _populateSettableProperties; - public SampleDbContext(CompatibilityMode compatibilityMode = CompatibilityMode.Full, QueryTrackingBehavior queryTrackingBehavior = QueryTrackingBehavior.TrackAll) + public SampleDbContext(CompatibilityMode compatibilityMode = CompatibilityMode.Full, QueryTrackingBehavior queryTrackingBehavior = QueryTrackingBehavior.TrackAll, bool populateSettableProperties = true) { _compatibilityMode = compatibilityMode; _queryTrackingBehavior = queryTrackingBehavior; + _populateSettableProperties = populateSettableProperties; } protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) @@ -27,6 +29,7 @@ protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) optionsBuilder.UseSqlServer("Server=(localdb)\\v11.0;Integrated Security=true"); // Fake connection string as we're actually never connecting optionsBuilder.UseProjectables(options => { options.CompatibilityMode(_compatibilityMode); // Needed by our ComplexModelTests + options.PopulateSettableProperties(_populateSettableProperties); }); optionsBuilder.UseQueryTrackingBehavior(_queryTrackingBehavior); } diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/QueryRootTests.PopulateSettablePropertiesDisabledQueryRootExpression.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/QueryRootTests.PopulateSettablePropertiesDisabledQueryRootExpression.DotNet10_0.verified.txt new file mode 100644 index 00000000..b1c3b32b --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/QueryRootTests.PopulateSettablePropertiesDisabledQueryRootExpression.DotNet10_0.verified.txt @@ -0,0 +1,2 @@ +SELECT [e].[Id] +FROM [Entity] AS [e] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/QueryRootTests.PopulateSettablePropertiesDisabledQueryRootExpression.DotNet8_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/QueryRootTests.PopulateSettablePropertiesDisabledQueryRootExpression.DotNet8_0.verified.txt new file mode 100644 index 00000000..b1c3b32b --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/QueryRootTests.PopulateSettablePropertiesDisabledQueryRootExpression.DotNet8_0.verified.txt @@ -0,0 +1,2 @@ +SELECT [e].[Id] +FROM [Entity] AS [e] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/QueryRootTests.PopulateSettablePropertiesDisabledQueryRootExpression.DotNet9_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/QueryRootTests.PopulateSettablePropertiesDisabledQueryRootExpression.DotNet9_0.verified.txt new file mode 100644 index 00000000..b1c3b32b --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/QueryRootTests.PopulateSettablePropertiesDisabledQueryRootExpression.DotNet9_0.verified.txt @@ -0,0 +1,2 @@ +SELECT [e].[Id] +FROM [Entity] AS [e] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/QueryRootTests.PopulateSettablePropertiesDisabledStillExpandsInProjection.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/QueryRootTests.PopulateSettablePropertiesDisabledStillExpandsInProjection.DotNet10_0.verified.txt new file mode 100644 index 00000000..759fce32 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/QueryRootTests.PopulateSettablePropertiesDisabledStillExpandsInProjection.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.PopulateSettablePropertiesDisabledStillExpandsInProjection.DotNet8_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/QueryRootTests.PopulateSettablePropertiesDisabledStillExpandsInProjection.DotNet8_0.verified.txt new file mode 100644 index 00000000..759fce32 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/QueryRootTests.PopulateSettablePropertiesDisabledStillExpandsInProjection.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.PopulateSettablePropertiesDisabledStillExpandsInProjection.DotNet9_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/QueryRootTests.PopulateSettablePropertiesDisabledStillExpandsInProjection.DotNet9_0.verified.txt new file mode 100644 index 00000000..759fce32 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/QueryRootTests.PopulateSettablePropertiesDisabledStillExpandsInProjection.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..d8b099e8 100644 --- a/tests/EntityFrameworkCore.Projectables.FunctionalTests/QueryRootTests.cs +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/QueryRootTests.cs @@ -76,5 +76,33 @@ public Task AsNoTrackingQueryRootExpression() return Verifier.Verify(query.ToQueryString()); } + + // PopulateSettableProperties(false) skips the query root rewrite entirely, so a + // materialized entity keeps whatever its CLR member returns. Projectables are still + // expanded wherever a query reads them; see + // PopulateSettablePropertiesDisabledStillExpandsInProjection below. + [Fact] + public Task PopulateSettablePropertiesDisabledQueryRootExpression() + { + using var dbContext = new SampleDbContext( + queryTrackingBehavior: QueryTrackingBehavior.NoTracking, + populateSettableProperties: false); + + var query = dbContext.Set(); + + return Verifier.Verify(query.ToQueryString()); + } + + [Fact] + public Task PopulateSettablePropertiesDisabledStillExpandsInProjection() + { + using var dbContext = new SampleDbContext( + queryTrackingBehavior: QueryTrackingBehavior.NoTracking, + populateSettableProperties: false); + + var query = dbContext.Set().Select(e => new { e.ComputedWithBacking }); + + return Verifier.Verify(query.ToQueryString()); + } } }