From f768fbc2f325779c4587b482e975bfc49d905f4b Mon Sep 17 00:00:00 2001 From: Shay Rojansky Date: Thu, 5 Mar 2026 12:30:29 +0200 Subject: [PATCH] Implement partial property loading in relational query Closes #37279 --- .../Query/SqlExpressions/SelectExpression.cs | 5 + .../SqlServerAutoLoadConvention.cs | 2 +- src/EFCore/Infrastructure/ModelValidator.cs | 28 +++- src/EFCore/Properties/CoreStrings.Designer.cs | 8 + src/EFCore/Properties/CoreStrings.resx | 57 +++---- .../StructuralTypeMaterializerSource.cs | 2 +- .../ShapedQueryCompilingExpressionVisitor.cs | 6 +- .../Update/NonSharedModelUpdatesTestBase.cs | 150 +++++++++++++++++- .../VectorTranslationsSqlServerTest.cs | 8 +- .../VectorIndexEntityEntityType.cs | 3 +- .../Scaffolding/CompiledModelSqlServerTest.cs | 3 + .../NonSharedModelUpdatesSqlServerTest.cs | 66 +++++++- .../Infrastructure/ModelValidatorTest.cs | 31 ++++ 13 files changed, 328 insertions(+), 41 deletions(-) diff --git a/src/EFCore.Relational/Query/SqlExpressions/SelectExpression.cs b/src/EFCore.Relational/Query/SqlExpressions/SelectExpression.cs index 2efcd67ecf8..822fac8a8ce 100644 --- a/src/EFCore.Relational/Query/SqlExpressions/SelectExpression.cs +++ b/src/EFCore.Relational/Query/SqlExpressions/SelectExpression.cs @@ -1420,6 +1420,11 @@ void ProcessType(StructuralTypeProjectionExpression typeProjection) continue; } + if (!property.IsAutoLoaded) + { + continue; + } + projections[property] = AddToProjection(typeProjection.BindProperty(property), alias: null); } diff --git a/src/EFCore.SqlServer/Metadata/Conventions/SqlServerAutoLoadConvention.cs b/src/EFCore.SqlServer/Metadata/Conventions/SqlServerAutoLoadConvention.cs index 2ccbeb8f21b..de632981123 100644 --- a/src/EFCore.SqlServer/Metadata/Conventions/SqlServerAutoLoadConvention.cs +++ b/src/EFCore.SqlServer/Metadata/Conventions/SqlServerAutoLoadConvention.cs @@ -29,6 +29,6 @@ protected override bool ShouldBeAutoLoaded(IConventionProperty property) // If there's a value converter, the CLR type may not reflect the store type, // so we can only check for SqlVector<> when there's no converter. return property.GetValueConverter() is not null - || property.ClrType.TryGetElementType(typeof(SqlVector<>)) is null; + || property.ClrType.UnwrapNullableType().TryGetElementType(typeof(SqlVector<>)) is null; } } diff --git a/src/EFCore/Infrastructure/ModelValidator.cs b/src/EFCore/Infrastructure/ModelValidator.cs index ceddc40f67a..a61e3593523 100644 --- a/src/EFCore/Infrastructure/ModelValidator.cs +++ b/src/EFCore/Infrastructure/ModelValidator.cs @@ -47,7 +47,7 @@ public virtual void Validate(IModel model, IDiagnosticsLogger + /// Validates that no constructor-bound property is configured as not auto-loaded. + /// + /// The structural type to validate. + protected virtual void ValidateConstructorBindingAutoLoaded(ITypeBase structuralType) + { + if (structuralType.ConstructorBinding is null) + { + return; + } + + var typeName = structuralType.DisplayName(); + + foreach (var consumedProperty in structuralType.ConstructorBinding.ParameterBindings + .SelectMany(p => p.ConsumedProperties)) + { + if (consumedProperty is IProperty { IsAutoLoaded: false } property) + { + throw new InvalidOperationException( + CoreStrings.AutoLoadedConstructorProperty(property.Name, typeName)); + } + } + } + /// /// Validates inheritance mapping for an entity type. /// @@ -226,6 +251,7 @@ protected virtual void ValidateComplexProperty( var complexType = complexProperty.ComplexType; ValidateChangeTrackingStrategy(complexType, logger); + ValidateConstructorBindingAutoLoaded(complexType); foreach (var property in complexType.GetDeclaredProperties()) { diff --git a/src/EFCore/Properties/CoreStrings.Designer.cs b/src/EFCore/Properties/CoreStrings.Designer.cs index 60ba4290e46..ed16488cc5a 100644 --- a/src/EFCore/Properties/CoreStrings.Designer.cs +++ b/src/EFCore/Properties/CoreStrings.Designer.cs @@ -160,6 +160,14 @@ public static string AutoLoadedConcurrencyTokenProperty(object? property, object GetString("AutoLoadedConcurrencyTokenProperty", nameof(property), nameof(type)), property, type); + /// + /// The property '{property}' on type '{type}' is used in a constructor binding and cannot be configured as not auto-loaded. Constructor-bound properties must always be loaded. + /// + public static string AutoLoadedConstructorProperty(object? property, object? type) + => string.Format( + GetString("AutoLoadedConstructorProperty", nameof(property), nameof(type)), + property, type); + /// /// The property '{property}' on type '{type}' is a discriminator and cannot be configured as not auto-loaded. Discriminator properties must always be loaded. /// diff --git a/src/EFCore/Properties/CoreStrings.resx b/src/EFCore/Properties/CoreStrings.resx index edd710d181f..718faba820a 100644 --- a/src/EFCore/Properties/CoreStrings.resx +++ b/src/EFCore/Properties/CoreStrings.resx @@ -1,17 +1,17 @@  - @@ -168,6 +168,9 @@ The property '{property}' on type '{type}' is a concurrency token and cannot be configured as not auto-loaded. Concurrency tokens must always be loaded. + + The property '{property}' on type '{type}' is used in a constructor binding and cannot be configured as not auto-loaded. Constructor-bound properties must always be loaded. + The property '{property}' on type '{type}' is a discriminator and cannot be configured as not auto-loaded. Discriminator properties must always be loaded. diff --git a/src/EFCore/Query/Internal/StructuralTypeMaterializerSource.cs b/src/EFCore/Query/Internal/StructuralTypeMaterializerSource.cs index 54808a5057e..93a6e1b7304 100644 --- a/src/EFCore/Query/Internal/StructuralTypeMaterializerSource.cs +++ b/src/EFCore/Query/Internal/StructuralTypeMaterializerSource.cs @@ -75,7 +75,7 @@ public Expression CreateMaterializeExpression( bindingInfo.ServiceInstances.Add(instanceVariable); var properties = new HashSet( - structuralType.GetProperties().Cast().Where(p => !p.IsShadowProperty()) + structuralType.GetProperties().Cast().Where(p => !p.IsShadowProperty() && p is not IProperty { IsAutoLoaded: false }) .Concat(structuralType.GetComplexProperties().Where(p => !p.IsShadowProperty()))); var blockExpressions = new List(); diff --git a/src/EFCore/Query/ShapedQueryCompilingExpressionVisitor.cs b/src/EFCore/Query/ShapedQueryCompilingExpressionVisitor.cs index 7e071275b9e..ec065b48c4e 100644 --- a/src/EFCore/Query/ShapedQueryCompilingExpressionVisitor.cs +++ b/src/EFCore/Query/ShapedQueryCompilingExpressionVisitor.cs @@ -754,8 +754,10 @@ private BlockExpression CreateFullMaterializeExpression( shadowProperties.Select( p => Convert( - valueBufferExpression.CreateValueBufferReadValueExpression( - p.ClrType, p.GetIndex(), p), typeof(object))))))); + p is IProperty { IsAutoLoaded: false } + ? (p.Sentinel is null ? Default(p.ClrType) : Constant(p.Sentinel, p.ClrType)) + : valueBufferExpression.CreateValueBufferReadValueExpression( + p.ClrType, p.GetIndex(), p), typeof(object))))))); } } diff --git a/test/EFCore.Relational.Specification.Tests/Update/NonSharedModelUpdatesTestBase.cs b/test/EFCore.Relational.Specification.Tests/Update/NonSharedModelUpdatesTestBase.cs index 71538537d02..aca5b7f2231 100644 --- a/test/EFCore.Relational.Specification.Tests/Update/NonSharedModelUpdatesTestBase.cs +++ b/test/EFCore.Relational.Specification.Tests/Update/NonSharedModelUpdatesTestBase.cs @@ -154,7 +154,16 @@ await ExecuteWithStrategyInTransactionAsync( { var blog = await context.Set().SingleAsync(); Assert.Equal("Updated Blog", blog.Name); - Assert.Equal("Original description", blog.Description); + + // Description is not auto-loaded, so it should be null (sentinel) in the materialized entity + Assert.Null(blog.Description); + + // Verify the data is correct in the database by explicitly projecting the non-auto-loaded property + var description = await context.Set() + .Where(b => b.Description == "Original description") + .Select(b => b.Description) + .SingleAsync(); + Assert.Equal("Original description", description); }); } @@ -198,7 +207,144 @@ await ExecuteWithStrategyInTransactionAsync( { var blog = await context.Set().SingleAsync(); Assert.Equal("Updated Blog", blog.Name); - Assert.Equal(new[] { "efcore", "dotnet" }, blog.Tags); + // Tags is not auto-loaded, so it should be the empty sentinel in the materialized entity + Assert.Empty(blog.Tags); + }); + } + + [ConditionalTheory, MemberData(nameof(IsAsyncData))] + public virtual async Task Query_with_not_auto_loaded_property_tracked(bool async) + { + var contextFactory = await InitializeNonSharedTest( + onModelCreating: mb => mb.Entity( + b => b.Property(e => e.Description).Metadata.IsAutoLoaded = false), + seed: async context => + { + context.Add(new BlogWithDescription { Name = "EF Blog", Description = "Some description" }); + await context.SaveChangesAsync(); + }); + + await ExecuteWithStrategyInTransactionAsync( + contextFactory, + async context => + { + var blog = async + ? await context.Set().SingleAsync() + : context.Set().Single(); + + // The non-auto-loaded property should not have been fetched + Assert.Null(blog.Description); + + // The change tracker should know the property is not loaded + var entry = context.Entry(blog); + Assert.False(entry.Property(e => e.Description).IsLoaded); + }); + } + + [ConditionalTheory, MemberData(nameof(IsAsyncData))] + public virtual async Task Query_with_not_auto_loaded_property_no_tracking(bool async) + { + var contextFactory = await InitializeNonSharedTest( + onModelCreating: mb => mb.Entity( + b => b.Property(e => e.Description).Metadata.IsAutoLoaded = false), + seed: async context => + { + context.Add(new BlogWithDescription { Name = "EF Blog", Description = "Some description" }); + await context.SaveChangesAsync(); + }); + + await ExecuteWithStrategyInTransactionAsync( + contextFactory, + async context => + { + var blog = async + ? await context.Set().AsNoTracking().SingleAsync() + : context.Set().AsNoTracking().Single(); + + // The non-auto-loaded property should not have been fetched, and retains its default/sentinel value + Assert.Null(blog.Description); + }); + } + + [ConditionalTheory, MemberData(nameof(IsAsyncData))] + public virtual async Task Explicit_select_of_not_auto_loaded_property(bool async) + { + var contextFactory = await InitializeNonSharedTest( + onModelCreating: mb => mb.Entity( + b => b.Property(e => e.Description).Metadata.IsAutoLoaded = false), + seed: async context => + { + context.Add(new BlogWithDescription { Name = "EF Blog", Description = "Some description" }); + await context.SaveChangesAsync(); + }); + + await ExecuteWithStrategyInTransactionAsync( + contextFactory, + async context => + { + // Explicitly projecting the non-auto-loaded property should still work + var description = async + ? await context.Set().Select(b => b.Description).SingleAsync() + : context.Set().Select(b => b.Description).Single(); + + Assert.Equal("Some description", description); + }); + } + + [ConditionalTheory, MemberData(nameof(IsAsyncData))] + public virtual async Task Where_on_not_auto_loaded_property(bool async) + { + var contextFactory = await InitializeNonSharedTest( + onModelCreating: mb => mb.Entity( + b => b.Property(e => e.Description).Metadata.IsAutoLoaded = false), + seed: async context => + { + context.Add(new BlogWithDescription { Name = "EF Blog", Description = "Some description" }); + await context.SaveChangesAsync(); + }); + + await ExecuteWithStrategyInTransactionAsync( + contextFactory, + async context => + { + // Filtering on a non-auto-loaded property should work; the property must be available in the subquery + var blog = async + ? await context.Set().Where(b => b.Description == "Some description").SingleAsync() + : context.Set().Where(b => b.Description == "Some description").Single(); + + Assert.Equal("EF Blog", blog.Name); + + // The non-auto-loaded property should still not be in the entity projection + Assert.Null(blog.Description); + }); + } + + [ConditionalTheory, MemberData(nameof(IsAsyncData))] + public virtual async Task Query_with_not_auto_loaded_primitive_collection(bool async) + { + var contextFactory = await InitializeNonSharedTest( + onModelCreating: mb => mb.Entity( + b => + { + b.Property(e => e.Tags).Metadata.IsAutoLoaded = false; + b.Property(e => e.Tags).Metadata.Sentinel = new List(); + }), + seed: async context => + { + context.Add(new BlogWithTags { Name = "EF Blog", Tags = ["efcore", "dotnet"] }); + await context.SaveChangesAsync(); + }); + + await ExecuteWithStrategyInTransactionAsync( + contextFactory, + async context => + { + var blog = async + ? await context.Set().SingleAsync() + : context.Set().Single(); + + // The non-auto-loaded primitive collection should not have been fetched + Assert.Empty(blog.Tags); }); } diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/Translations/VectorTranslationsSqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Query/Translations/VectorTranslationsSqlServerTest.cs index d4e12bf149e..8e7ffd052be 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Query/Translations/VectorTranslationsSqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Query/Translations/VectorTranslationsSqlServerTest.cs @@ -37,7 +37,7 @@ public async Task VectorDistance_with_parameter() @p='1' @vector='Microsoft.Data.SqlTypes.SqlVector`1[System.Single]' (Size = 20) (DbType = Binary) -SELECT TOP(@p) [v].[Id], [v].[Vector] +SELECT TOP(@p) [v].[Id] FROM [VectorEntities] AS [v] ORDER BY VECTOR_DISTANCE('cosine', [v].[Vector], @vector) """); @@ -59,7 +59,7 @@ public async Task VectorDistance_with_constant() """ @p='1' -SELECT TOP(@p) [v].[Id], [v].[Vector] +SELECT TOP(@p) [v].[Id] FROM [VectorEntities] AS [v] ORDER BY VECTOR_DISTANCE('cosine', [v].[Vector], CAST('[1,2,100]' AS VECTOR(3))) """); @@ -84,7 +84,7 @@ public async Task VectorSearch_project_entity_and_distance() @p='Microsoft.Data.SqlTypes.SqlVector`1[System.Single]' (Size = 20) (DbType = Binary) @p1='1' -SELECT [v].[Id], [v].[Vector], [v0].[Distance] +SELECT [v].[Id], [v0].[Distance] FROM VECTOR_SEARCH( TABLE = [VectorEntities] AS [v], COLUMN = [Vector], @@ -120,7 +120,7 @@ public async Task VectorSearch_project_entity_only_with_distance_filter_and_orde @p='Microsoft.Data.SqlTypes.SqlVector`1[System.Single]' (Size = 20) (DbType = Binary) @p1='3' -SELECT [v].[Id], [v].[Vector] +SELECT [v].[Id] FROM VECTOR_SEARCH( TABLE = [VectorEntities] AS [v], COLUMN = [Vector], diff --git a/test/EFCore.SqlServer.FunctionalTests/Scaffolding/Baselines/Vector_index/VectorIndexEntityEntityType.cs b/test/EFCore.SqlServer.FunctionalTests/Scaffolding/Baselines/Vector_index/VectorIndexEntityEntityType.cs index cbd90976948..892e0b4329f 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Scaffolding/Baselines/Vector_index/VectorIndexEntityEntityType.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Scaffolding/Baselines/Vector_index/VectorIndexEntityEntityType.cs @@ -85,7 +85,8 @@ public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType bas typeof(SqlVector?), propertyInfo: typeof(CompiledModelSqlServerTest.VectorIndexEntity).GetProperty("Vector", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), fieldInfo: typeof(CompiledModelSqlServerTest.VectorIndexEntity).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), - nullable: true); + nullable: true, + autoLoaded: false); vector.SetGetter( SqlVector? (CompiledModelSqlServerTest.VectorIndexEntity instance) => VectorIndexEntityUnsafeAccessors.Vector(instance), bool (CompiledModelSqlServerTest.VectorIndexEntity instance) => !(VectorIndexEntityUnsafeAccessors.Vector(instance).HasValue)); diff --git a/test/EFCore.SqlServer.FunctionalTests/Scaffolding/CompiledModelSqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Scaffolding/CompiledModelSqlServerTest.cs index 227e09d30c9..05f437a9f80 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Scaffolding/CompiledModelSqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Scaffolding/CompiledModelSqlServerTest.cs @@ -210,6 +210,9 @@ public virtual Task Vector_index() model => { var entityType = model.FindEntityType(typeof(VectorIndexEntity))!; + var vectorProperty = entityType.FindProperty(nameof(VectorIndexEntity.Vector))!; + Assert.False(vectorProperty.IsAutoLoaded); + var index = entityType.GetIndexes().Single(); // Vector index annotations are not used at runtime, so they are not included in the compiled model Assert.Null(index[SqlServerAnnotationNames.VectorIndexMetric]); diff --git a/test/EFCore.SqlServer.FunctionalTests/Update/NonSharedModelUpdatesSqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Update/NonSharedModelUpdatesSqlServerTest.cs index 4c694f86785..3cb888868d6 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Update/NonSharedModelUpdatesSqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Update/NonSharedModelUpdatesSqlServerTest.cs @@ -212,8 +212,14 @@ OUTPUT 1 """, // """ -SELECT TOP(2) [b].[Id], [b].[Description], [b].[Name] +SELECT TOP(2) [b].[Id], [b].[Name] FROM [BlogWithDescription] AS [b] +""", + // + """ +SELECT TOP(2) [b].[Description] +FROM [BlogWithDescription] AS [b] +WHERE [b].[Description] = N'Original description' """); } @@ -234,7 +240,63 @@ OUTPUT 1 """, // """ -SELECT TOP(2) [b].[Id], [b].[Name], [b].[Tags] +SELECT TOP(2) [b].[Id], [b].[Name] +FROM [BlogWithTags] AS [b] +"""); + } + + public override async Task Query_with_not_auto_loaded_property_tracked(bool async) + { + await base.Query_with_not_auto_loaded_property_tracked(async); + + AssertSql( + """ +SELECT TOP(2) [b].[Id], [b].[Name] +FROM [BlogWithDescription] AS [b] +"""); + } + + public override async Task Query_with_not_auto_loaded_property_no_tracking(bool async) + { + await base.Query_with_not_auto_loaded_property_no_tracking(async); + + AssertSql( + """ +SELECT TOP(2) [b].[Id], [b].[Name] +FROM [BlogWithDescription] AS [b] +"""); + } + + public override async Task Explicit_select_of_not_auto_loaded_property(bool async) + { + await base.Explicit_select_of_not_auto_loaded_property(async); + + AssertSql( + """ +SELECT TOP(2) [b].[Description] +FROM [BlogWithDescription] AS [b] +"""); + } + + public override async Task Where_on_not_auto_loaded_property(bool async) + { + await base.Where_on_not_auto_loaded_property(async); + + AssertSql( + """ +SELECT TOP(2) [b].[Id], [b].[Name] +FROM [BlogWithDescription] AS [b] +WHERE [b].[Description] = N'Some description' +"""); + } + + public override async Task Query_with_not_auto_loaded_primitive_collection(bool async) + { + await base.Query_with_not_auto_loaded_primitive_collection(async); + + AssertSql( + """ +SELECT TOP(2) [b].[Id], [b].[Name] FROM [BlogWithTags] AS [b] """); } diff --git a/test/EFCore.Tests/Infrastructure/ModelValidatorTest.cs b/test/EFCore.Tests/Infrastructure/ModelValidatorTest.cs index bf42921f9bd..f91c1a1297d 100644 --- a/test/EFCore.Tests/Infrastructure/ModelValidatorTest.cs +++ b/test/EFCore.Tests/Infrastructure/ModelValidatorTest.cs @@ -2372,12 +2372,43 @@ public virtual void Allows_non_key_property_not_auto_loaded() Validate(modelBuilder); } + [ConditionalFact] + public virtual void Detects_constructor_bound_property_not_auto_loaded() + { + var modelBuilder = CreateConventionModelBuilder(); + modelBuilder.Entity( + eb => + { + eb.Property(e => e.Id); + eb.Property(e => e.Name); + }); + + var model = modelBuilder.Model; + var property = model.FindEntityType(typeof(AutoLoadEntityWithConstructor))!.FindProperty(nameof(AutoLoadEntityWithConstructor.Name))!; + property.IsAutoLoaded = false; + + VerifyError( + CoreStrings.AutoLoadedConstructorProperty(nameof(AutoLoadEntityWithConstructor.Name), nameof(AutoLoadEntityWithConstructor)), + modelBuilder); + } + protected class AutoLoadEntity { public int Id { get; set; } public string Name { get; set; } = null!; } + protected class AutoLoadEntityWithConstructor + { + public AutoLoadEntityWithConstructor(string name) + { + Name = name; + } + + public int Id { get; set; } + public string Name { get; set; } + } + protected class AutoLoadPrincipal { public int Id { get; set; }