Skip to content

Commit eb525c8

Browse files
committed
Implement partial property loading in relational query
Closes dotnet#37279
1 parent d34c72b commit eb525c8

12 files changed

Lines changed: 327 additions & 40 deletions

File tree

src/EFCore.Relational/Query/SqlExpressions/SelectExpression.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1420,6 +1420,11 @@ void ProcessType(StructuralTypeProjectionExpression typeProjection)
14201420
continue;
14211421
}
14221422

1423+
if (!property.IsAutoLoaded)
1424+
{
1425+
continue;
1426+
}
1427+
14231428
projections[property] = AddToProjection(typeProjection.BindProperty(property), alias: null);
14241429
}
14251430

src/EFCore/Infrastructure/ModelValidator.cs

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ public virtual void Validate(IModel model, IDiagnosticsLogger<DbLoggerCategory.M
4747
ValidateEntityType(entityType, logger);
4848
ValidateClrInheritance(entityType, validEntityTypes);
4949
ValidateData(entityType, identityMaps, sensitiveDataLogged, logger);
50-
50+
5151
var primaryKey = entityType.FindPrimaryKey();
5252
if (primaryKey == null)
5353
{
@@ -99,6 +99,7 @@ protected virtual void ValidateEntityType(
9999
ValidateInheritanceMapping(entityType, logger);
100100
ValidateFieldMapping(entityType, logger);
101101
ValidateQueryFilters(entityType, logger);
102+
ValidateConstructorBindingAutoLoaded(entityType);
102103

103104
foreach (var property in entityType.GetDeclaredProperties())
104105
{
@@ -138,6 +139,30 @@ protected virtual void ValidateEntityType(
138139
LogShadowProperties(entityType, logger);
139140
}
140141

142+
/// <summary>
143+
/// Validates that no constructor-bound property is configured as not auto-loaded.
144+
/// </summary>
145+
/// <param name="structuralType">The structural type to validate.</param>
146+
protected virtual void ValidateConstructorBindingAutoLoaded(ITypeBase structuralType)
147+
{
148+
if (structuralType.ConstructorBinding is null)
149+
{
150+
return;
151+
}
152+
153+
var typeName = structuralType.DisplayName();
154+
155+
foreach (var consumedProperty in structuralType.ConstructorBinding.ParameterBindings
156+
.SelectMany(p => p.ConsumedProperties))
157+
{
158+
if (consumedProperty is IProperty { IsAutoLoaded: false } property)
159+
{
160+
throw new InvalidOperationException(
161+
CoreStrings.AutoLoadedConstructorProperty(property.Name, typeName));
162+
}
163+
}
164+
}
165+
141166
/// <summary>
142167
/// Validates inheritance mapping for an entity type.
143168
/// </summary>
@@ -226,6 +251,7 @@ protected virtual void ValidateComplexProperty(
226251
var complexType = complexProperty.ComplexType;
227252

228253
ValidateChangeTrackingStrategy(complexType, logger);
254+
ValidateConstructorBindingAutoLoaded(complexType);
229255

230256
foreach (var property in complexType.GetDeclaredProperties())
231257
{

src/EFCore/Properties/CoreStrings.Designer.cs

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/EFCore/Properties/CoreStrings.resx

Lines changed: 30 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
11
<?xml version="1.0" encoding="utf-8"?>
22
<root>
3-
<!--
4-
Microsoft ResX Schema
5-
3+
<!--
4+
Microsoft ResX Schema
5+
66
Version 2.0
7-
8-
The primary goals of this format is to allow a simple XML format
9-
that is mostly human readable. The generation and parsing of the
10-
various data types are done through the TypeConverter classes
7+
8+
The primary goals of this format is to allow a simple XML format
9+
that is mostly human readable. The generation and parsing of the
10+
various data types are done through the TypeConverter classes
1111
associated with the data types.
12-
12+
1313
Example:
14-
14+
1515
... ado.net/XML headers & schema ...
1616
<resheader name="resmimetype">text/microsoft-resx</resheader>
1717
<resheader name="version">2.0</resheader>
@@ -26,36 +26,36 @@
2626
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
2727
<comment>This is a comment</comment>
2828
</data>
29-
30-
There are any number of "resheader" rows that contain simple
29+
30+
There are any number of "resheader" rows that contain simple
3131
name/value pairs.
32-
33-
Each data row contains a name, and value. The row also contains a
34-
type or mimetype. Type corresponds to a .NET class that support
35-
text/value conversion through the TypeConverter architecture.
36-
Classes that don't support this are serialized and stored with the
32+
33+
Each data row contains a name, and value. The row also contains a
34+
type or mimetype. Type corresponds to a .NET class that support
35+
text/value conversion through the TypeConverter architecture.
36+
Classes that don't support this are serialized and stored with the
3737
mimetype set.
38-
39-
The mimetype is used for serialized objects, and tells the
40-
ResXResourceReader how to depersist the object. This is currently not
38+
39+
The mimetype is used for serialized objects, and tells the
40+
ResXResourceReader how to depersist the object. This is currently not
4141
extensible. For a given mimetype the value must be set accordingly:
42-
43-
Note - application/x-microsoft.net.object.binary.base64 is the format
44-
that the ResXResourceWriter will generate, however the reader can
42+
43+
Note - application/x-microsoft.net.object.binary.base64 is the format
44+
that the ResXResourceWriter will generate, however the reader can
4545
read any of the formats listed below.
46-
46+
4747
mimetype: application/x-microsoft.net.object.binary.base64
48-
value : The object must be serialized with
48+
value : The object must be serialized with
4949
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
5050
: and then encoded with base64 encoding.
51-
51+
5252
mimetype: application/x-microsoft.net.object.soap.base64
53-
value : The object must be serialized with
53+
value : The object must be serialized with
5454
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
5555
: and then encoded with base64 encoding.
5656
5757
mimetype: application/x-microsoft.net.object.bytearray.base64
58-
value : The object must be serialized into a byte array
58+
value : The object must be serialized into a byte array
5959
: using a System.ComponentModel.TypeConverter
6060
: and then encoded with base64 encoding.
6161
-->
@@ -168,6 +168,9 @@
168168
<data name="AutoLoadedConcurrencyTokenProperty" xml:space="preserve">
169169
<value>The property '{property}' on type '{type}' is a concurrency token and cannot be configured as not auto-loaded. Concurrency tokens must always be loaded.</value>
170170
</data>
171+
<data name="AutoLoadedConstructorProperty" xml:space="preserve">
172+
<value>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.</value>
173+
</data>
171174
<data name="AutoLoadedDiscriminatorProperty" xml:space="preserve">
172175
<value>The property '{property}' on type '{type}' is a discriminator and cannot be configured as not auto-loaded. Discriminator properties must always be loaded.</value>
173176
</data>

src/EFCore/Query/Internal/StructuralTypeMaterializerSource.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ public Expression CreateMaterializeExpression(
7575
bindingInfo.ServiceInstances.Add(instanceVariable);
7676

7777
var properties = new HashSet<IPropertyBase>(
78-
structuralType.GetProperties().Cast<IPropertyBase>().Where(p => !p.IsShadowProperty())
78+
structuralType.GetProperties().Cast<IPropertyBase>().Where(p => !p.IsShadowProperty() && p is not IProperty { IsAutoLoaded: false })
7979
.Concat(structuralType.GetComplexProperties().Where(p => !p.IsShadowProperty())));
8080

8181
var blockExpressions = new List<Expression>();

src/EFCore/Query/ShapedQueryCompilingExpressionVisitor.cs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -754,8 +754,10 @@ private BlockExpression CreateFullMaterializeExpression(
754754
shadowProperties.Select(
755755
p =>
756756
Convert(
757-
valueBufferExpression.CreateValueBufferReadValueExpression(
758-
p.ClrType, p.GetIndex(), p), typeof(object)))))));
757+
p is IProperty { IsAutoLoaded: false }
758+
? (p.Sentinel is null ? Default(p.ClrType) : Constant(p.Sentinel, p.ClrType))
759+
: valueBufferExpression.CreateValueBufferReadValueExpression(
760+
p.ClrType, p.GetIndex(), p), typeof(object)))))));
759761
}
760762
}
761763

test/EFCore.Relational.Specification.Tests/Update/NonSharedModelUpdatesTestBase.cs

Lines changed: 148 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,16 @@ await ExecuteWithStrategyInTransactionAsync(
154154
{
155155
var blog = await context.Set<BlogWithDescription>().SingleAsync();
156156
Assert.Equal("Updated Blog", blog.Name);
157-
Assert.Equal("Original description", blog.Description);
157+
158+
// Description is not auto-loaded, so it should be null (sentinel) in the materialized entity
159+
Assert.Null(blog.Description);
160+
161+
// Verify the data is correct in the database by explicitly projecting the non-auto-loaded property
162+
var description = await context.Set<BlogWithDescription>()
163+
.Where(b => b.Description == "Original description")
164+
.Select(b => b.Description)
165+
.SingleAsync();
166+
Assert.Equal("Original description", description);
158167
});
159168
}
160169

@@ -198,7 +207,144 @@ await ExecuteWithStrategyInTransactionAsync(
198207
{
199208
var blog = await context.Set<BlogWithTags>().SingleAsync();
200209
Assert.Equal("Updated Blog", blog.Name);
201-
Assert.Equal(new[] { "efcore", "dotnet" }, blog.Tags);
210+
// Tags is not auto-loaded, so it should be the empty sentinel in the materialized entity
211+
Assert.Empty(blog.Tags);
212+
});
213+
}
214+
215+
[ConditionalTheory, MemberData(nameof(IsAsyncData))]
216+
public virtual async Task Query_with_not_auto_loaded_property_tracked(bool async)
217+
{
218+
var contextFactory = await InitializeNonSharedTest<DbContext>(
219+
onModelCreating: mb => mb.Entity<BlogWithDescription>(
220+
b => b.Property(e => e.Description).Metadata.IsAutoLoaded = false),
221+
seed: async context =>
222+
{
223+
context.Add(new BlogWithDescription { Name = "EF Blog", Description = "Some description" });
224+
await context.SaveChangesAsync();
225+
});
226+
227+
await ExecuteWithStrategyInTransactionAsync(
228+
contextFactory,
229+
async context =>
230+
{
231+
var blog = async
232+
? await context.Set<BlogWithDescription>().SingleAsync()
233+
: context.Set<BlogWithDescription>().Single();
234+
235+
// The non-auto-loaded property should not have been fetched
236+
Assert.Null(blog.Description);
237+
238+
// The change tracker should know the property is not loaded
239+
var entry = context.Entry(blog);
240+
Assert.False(entry.Property(e => e.Description).IsLoaded);
241+
});
242+
}
243+
244+
[ConditionalTheory, MemberData(nameof(IsAsyncData))]
245+
public virtual async Task Query_with_not_auto_loaded_property_no_tracking(bool async)
246+
{
247+
var contextFactory = await InitializeNonSharedTest<DbContext>(
248+
onModelCreating: mb => mb.Entity<BlogWithDescription>(
249+
b => b.Property(e => e.Description).Metadata.IsAutoLoaded = false),
250+
seed: async context =>
251+
{
252+
context.Add(new BlogWithDescription { Name = "EF Blog", Description = "Some description" });
253+
await context.SaveChangesAsync();
254+
});
255+
256+
await ExecuteWithStrategyInTransactionAsync(
257+
contextFactory,
258+
async context =>
259+
{
260+
var blog = async
261+
? await context.Set<BlogWithDescription>().AsNoTracking().SingleAsync()
262+
: context.Set<BlogWithDescription>().AsNoTracking().Single();
263+
264+
// The non-auto-loaded property should not have been fetched, and retains its default/sentinel value
265+
Assert.Null(blog.Description);
266+
});
267+
}
268+
269+
[ConditionalTheory, MemberData(nameof(IsAsyncData))]
270+
public virtual async Task Explicit_select_of_not_auto_loaded_property(bool async)
271+
{
272+
var contextFactory = await InitializeNonSharedTest<DbContext>(
273+
onModelCreating: mb => mb.Entity<BlogWithDescription>(
274+
b => b.Property(e => e.Description).Metadata.IsAutoLoaded = false),
275+
seed: async context =>
276+
{
277+
context.Add(new BlogWithDescription { Name = "EF Blog", Description = "Some description" });
278+
await context.SaveChangesAsync();
279+
});
280+
281+
await ExecuteWithStrategyInTransactionAsync(
282+
contextFactory,
283+
async context =>
284+
{
285+
// Explicitly projecting the non-auto-loaded property should still work
286+
var description = async
287+
? await context.Set<BlogWithDescription>().Select(b => b.Description).SingleAsync()
288+
: context.Set<BlogWithDescription>().Select(b => b.Description).Single();
289+
290+
Assert.Equal("Some description", description);
291+
});
292+
}
293+
294+
[ConditionalTheory, MemberData(nameof(IsAsyncData))]
295+
public virtual async Task Where_on_not_auto_loaded_property(bool async)
296+
{
297+
var contextFactory = await InitializeNonSharedTest<DbContext>(
298+
onModelCreating: mb => mb.Entity<BlogWithDescription>(
299+
b => b.Property(e => e.Description).Metadata.IsAutoLoaded = false),
300+
seed: async context =>
301+
{
302+
context.Add(new BlogWithDescription { Name = "EF Blog", Description = "Some description" });
303+
await context.SaveChangesAsync();
304+
});
305+
306+
await ExecuteWithStrategyInTransactionAsync(
307+
contextFactory,
308+
async context =>
309+
{
310+
// Filtering on a non-auto-loaded property should work; the property must be available in the subquery
311+
var blog = async
312+
? await context.Set<BlogWithDescription>().Where(b => b.Description == "Some description").SingleAsync()
313+
: context.Set<BlogWithDescription>().Where(b => b.Description == "Some description").Single();
314+
315+
Assert.Equal("EF Blog", blog.Name);
316+
317+
// The non-auto-loaded property should still not be in the entity projection
318+
Assert.Null(blog.Description);
319+
});
320+
}
321+
322+
[ConditionalTheory, MemberData(nameof(IsAsyncData))]
323+
public virtual async Task Query_with_not_auto_loaded_primitive_collection(bool async)
324+
{
325+
var contextFactory = await InitializeNonSharedTest<DbContext>(
326+
onModelCreating: mb => mb.Entity<BlogWithTags>(
327+
b =>
328+
{
329+
b.Property(e => e.Tags).Metadata.IsAutoLoaded = false;
330+
b.Property(e => e.Tags).Metadata.Sentinel = new List<string>();
331+
}),
332+
seed: async context =>
333+
{
334+
context.Add(new BlogWithTags { Name = "EF Blog", Tags = ["efcore", "dotnet"] });
335+
await context.SaveChangesAsync();
336+
});
337+
338+
await ExecuteWithStrategyInTransactionAsync(
339+
contextFactory,
340+
async context =>
341+
{
342+
var blog = async
343+
? await context.Set<BlogWithTags>().SingleAsync()
344+
: context.Set<BlogWithTags>().Single();
345+
346+
// The non-auto-loaded primitive collection should not have been fetched
347+
Assert.Empty(blog.Tags);
202348
});
203349
}
204350

test/EFCore.SqlServer.FunctionalTests/Query/Translations/VectorTranslationsSqlServerTest.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ public async Task VectorDistance_with_parameter()
3737
@p='1'
3838
@vector='Microsoft.Data.SqlTypes.SqlVector`1[System.Single]' (Size = 20) (DbType = Binary)
3939
40-
SELECT TOP(@p) [v].[Id], [v].[Vector]
40+
SELECT TOP(@p) [v].[Id]
4141
FROM [VectorEntities] AS [v]
4242
ORDER BY VECTOR_DISTANCE('cosine', [v].[Vector], @vector)
4343
""");
@@ -59,7 +59,7 @@ public async Task VectorDistance_with_constant()
5959
"""
6060
@p='1'
6161
62-
SELECT TOP(@p) [v].[Id], [v].[Vector]
62+
SELECT TOP(@p) [v].[Id]
6363
FROM [VectorEntities] AS [v]
6464
ORDER BY VECTOR_DISTANCE('cosine', [v].[Vector], CAST('[1,2,100]' AS VECTOR(3)))
6565
""");
@@ -84,7 +84,7 @@ public async Task VectorSearch_project_entity_and_distance()
8484
@p='Microsoft.Data.SqlTypes.SqlVector`1[System.Single]' (Size = 20) (DbType = Binary)
8585
@p1='1'
8686
87-
SELECT [v].[Id], [v].[Vector], [v0].[Distance]
87+
SELECT [v].[Id], [v0].[Distance]
8888
FROM VECTOR_SEARCH(
8989
TABLE = [VectorEntities] AS [v],
9090
COLUMN = [Vector],
@@ -120,7 +120,7 @@ public async Task VectorSearch_project_entity_only_with_distance_filter_and_orde
120120
@p='Microsoft.Data.SqlTypes.SqlVector`1[System.Single]' (Size = 20) (DbType = Binary)
121121
@p1='3'
122122
123-
SELECT [v].[Id], [v].[Vector]
123+
SELECT [v].[Id]
124124
FROM VECTOR_SEARCH(
125125
TABLE = [VectorEntities] AS [v],
126126
COLUMN = [Vector],

0 commit comments

Comments
 (0)