From 82c96a3bc99ecde5ea311e924448c1d913713372 Mon Sep 17 00:00:00 2001 From: Andriy Svyryd Date: Mon, 2 Mar 2026 18:05:18 -0800 Subject: [PATCH 1/3] Fixes #37278 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds infrastructure for **partial property loading** — the ability to mark entity properties as "not auto-loaded" so they can be excluded from queries and skipped during change tracking. This is the foundation for lazy-loading individual properties (e.g., large BLOBs, vectors) without loading the entire entity. After this is checked in query should provide sentinel values for not loaded properties. Cosmos support not implemented. Vector-specific model building API will be added as part of #36350 Model building Fluent API, explicit loading API and query overrides (Include) will be added in #1387 --- .../Internal/CosmosModelValidator.cs | 20 + .../Properties/CosmosStrings.Designer.cs | 8 + .../Properties/CosmosStrings.resx | 3 + .../CSharpRuntimeModelCodeGenerator.cs | 7 + .../RelationalModelValidator.cs | 16 + .../Properties/RelationalStrings.Designer.cs | 8 + .../Properties/RelationalStrings.resx | 3 + .../Update/ModificationCommand.cs | 10 +- .../Internal/SqlServerModelValidator.cs | 3 +- .../SqlServerAutoLoadConvention.cs | 40 ++ .../SqlServerConventionSetBuilder.cs | 1 + .../ChangeTracking/Internal/ChangeDetector.cs | 21 +- .../ChangeTracking/Internal/IInternalEntry.cs | 16 + .../Internal/InternalEntryBase.StateData.cs | 31 +- .../Internal/InternalEntryBase.cs | 53 ++- src/EFCore/ChangeTracking/PropertyEntry.cs | 15 + src/EFCore/Infrastructure/ModelValidator.cs | 45 +++ .../Builders/IConventionPropertyBuilder.cs | 23 ++ .../Conventions/AutoLoadConvention.cs | 69 ++++ .../Metadata/Conventions/ConventionSet.cs | 21 + .../IPropertyAutoLoadChangedConvention.cs | 23 ++ .../ProviderConventionSetBuilder.cs | 1 + .../ConventionDispatcher.ConventionScope.cs | 3 + ...entionDispatcher.DelayedConventionScope.cs | 14 + ...tionDispatcher.ImmediateConventionScope.cs | 35 ++ .../Internal/ConventionDispatcher.cs | 9 + .../Conventions/RuntimeModelConvention.cs | 6 +- src/EFCore/Metadata/IConventionProperty.cs | 17 + src/EFCore/Metadata/IMutableProperty.cs | 7 + src/EFCore/Metadata/IReadOnlyProperty.cs | 13 + .../Internal/InternalPropertyBuilder.cs | 55 +++ src/EFCore/Metadata/Internal/Property.cs | 62 +++ src/EFCore/Metadata/RuntimeProperty.cs | 12 +- src/EFCore/Metadata/RuntimeTypeBase.cs | 7 +- src/EFCore/Properties/CoreStrings.Designer.cs | 32 ++ src/EFCore/Properties/CoreStrings.resx | 12 + src/EFCore/Update/IUpdateEntry.cs | 8 + .../Scaffolding/CompiledModelCosmosTest.cs | 7 + .../TestUtilities/CosmosTestStore.cs | 3 + .../CosmosModelValidatorTest.cs | 21 + .../Baselines/BigModel/ManyTypesEntityType.cs | 3 +- .../No_NativeAOT/ManyTypesEntityType.cs | 3 +- .../CompiledModelRelationalTestBase.cs | 1 + .../Update/NonSharedModelUpdatesTestBase.cs | 106 +++++ .../RelationalModelValidatorTest.cs | 37 ++ .../Scaffolding/CompiledModelTestBase.cs | 5 + .../Baselines/BigModel/ManyTypesEntityType.cs | 3 +- .../ManyTypesEntityType.cs | 3 +- .../No_NativeAOT/ManyTypesEntityType.cs | 3 +- .../NonSharedModelUpdatesSqlServerTest.cs | 44 ++ .../SqlServerAutoLoadConventionTest.cs | 90 +++++ .../Baselines/BigModel/ManyTypesEntityType.cs | 3 +- .../ManyTypesEntityType.cs | 3 +- .../No_NativeAOT/ManyTypesEntityType.cs | 3 +- .../ChangeTracking/Internal/StateDataTest.cs | 48 ++- .../ChangeTracking/PropertyEntryTest.cs | 377 ++++++++++++++++++ test/EFCore.Tests/ExceptionTest.cs | 3 + .../Infrastructure/ModelValidatorTest.cs | 145 +++++++ .../Conventions/ConventionDispatcherTest.cs | 131 ++++++ .../Internal/InternalPropertyBuilderTest.cs | 33 ++ .../Metadata/Internal/PropertyTest.cs | 22 + 61 files changed, 1795 insertions(+), 31 deletions(-) create mode 100644 src/EFCore.SqlServer/Metadata/Conventions/SqlServerAutoLoadConvention.cs create mode 100644 src/EFCore/Metadata/Conventions/AutoLoadConvention.cs create mode 100644 src/EFCore/Metadata/Conventions/IPropertyAutoLoadChangedConvention.cs create mode 100644 test/EFCore.SqlServer.Tests/Metadata/Conventions/SqlServerAutoLoadConventionTest.cs diff --git a/src/EFCore.Cosmos/Infrastructure/Internal/CosmosModelValidator.cs b/src/EFCore.Cosmos/Infrastructure/Internal/CosmosModelValidator.cs index e4b0d5fecec..53d434431ad 100644 --- a/src/EFCore.Cosmos/Infrastructure/Internal/CosmosModelValidator.cs +++ b/src/EFCore.Cosmos/Infrastructure/Internal/CosmosModelValidator.cs @@ -45,6 +45,26 @@ protected override void ValidateEntityType( ValidateDiscriminatorMappings(entityType, logger); } + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + protected override void ValidateAutoLoaded( + IProperty property, + ITypeBase structuralType, + IDiagnosticsLogger logger) + { + base.ValidateAutoLoaded(property, structuralType, logger); + + if (!property.IsAutoLoaded) + { + throw new InvalidOperationException( + CosmosStrings.AutoLoadedCosmosProperty(property.Name, structuralType.DisplayName())); + } + } + /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to /// the same compatibility standards as public APIs. It may be changed or removed without notice in diff --git a/src/EFCore.Cosmos/Properties/CosmosStrings.Designer.cs b/src/EFCore.Cosmos/Properties/CosmosStrings.Designer.cs index a93e93037ef..995c5cf7e94 100644 --- a/src/EFCore.Cosmos/Properties/CosmosStrings.Designer.cs +++ b/src/EFCore.Cosmos/Properties/CosmosStrings.Designer.cs @@ -31,6 +31,14 @@ public static string AnalyticalTTLMismatch(object? ttl1, object? entityType1, ob GetString("AnalyticalTTLMismatch", nameof(ttl1), nameof(entityType1), nameof(entityType2), nameof(ttl2), nameof(container)), ttl1, entityType1, entityType2, ttl2, container); + /// + /// The property '{property}' on type '{type}' cannot be configured as not auto-loaded. The Cosmos provider stores entire documents as JSON, so partial property loading is not supported. + /// + public static string AutoLoadedCosmosProperty(object? property, object? type) + => string.Format( + GetString("AutoLoadedCosmosProperty", nameof(property), nameof(type)), + property, type); + /// /// The type '{givenType}' cannot be mapped as a dictionary because it does not implement '{dictionaryType}'. /// diff --git a/src/EFCore.Cosmos/Properties/CosmosStrings.resx b/src/EFCore.Cosmos/Properties/CosmosStrings.resx index 3e74e0e56e8..b3ec0d9d9b0 100644 --- a/src/EFCore.Cosmos/Properties/CosmosStrings.resx +++ b/src/EFCore.Cosmos/Properties/CosmosStrings.resx @@ -120,6 +120,9 @@ The time to live for analytical store was configured to '{ttl1}' on '{entityType1}', but on '{entityType2}' it was configured to '{ttl2}'. All entity types mapped to the same container '{container}' must be configured with the same time to live for analytical store. + + The property '{property}' on type '{type}' cannot be configured as not auto-loaded. The Cosmos provider doesn't support partial property loading. + The type '{givenType}' cannot be mapped as a dictionary because it does not implement '{dictionaryType}'. diff --git a/src/EFCore.Design/Scaffolding/Internal/CSharpRuntimeModelCodeGenerator.cs b/src/EFCore.Design/Scaffolding/Internal/CSharpRuntimeModelCodeGenerator.cs index 258ebc52c3e..b02f95d8df4 100644 --- a/src/EFCore.Design/Scaffolding/Internal/CSharpRuntimeModelCodeGenerator.cs +++ b/src/EFCore.Design/Scaffolding/Internal/CSharpRuntimeModelCodeGenerator.cs @@ -1199,6 +1199,13 @@ private void Create( .Append(_code.UnknownLiteral(sentinel)); } + if (!property.IsAutoLoaded) + { + mainBuilder.AppendLine(",") + .Append("autoLoaded: ") + .Append(_code.Literal(false)); + } + var jsonValueReaderWriterType = (Type?)property[CoreAnnotationNames.JsonValueReaderWriterType]; if (jsonValueReaderWriterType != null) { diff --git a/src/EFCore.Relational/Infrastructure/RelationalModelValidator.cs b/src/EFCore.Relational/Infrastructure/RelationalModelValidator.cs index a4b56cd2ae3..10168e4dd08 100644 --- a/src/EFCore.Relational/Infrastructure/RelationalModelValidator.cs +++ b/src/EFCore.Relational/Infrastructure/RelationalModelValidator.cs @@ -179,6 +179,22 @@ protected override void ValidateProperty( ValidateBoolWithDefaults(property, logger); } + /// + protected override void ValidateAutoLoaded( + IProperty property, + ITypeBase structuralType, + IDiagnosticsLogger logger) + { + base.ValidateAutoLoaded(property, structuralType, logger); + + if (!property.IsAutoLoaded + && structuralType.IsMappedToJson()) + { + throw new InvalidOperationException( + RelationalStrings.AutoLoadedJsonProperty(property.Name, structuralType.DisplayName())); + } + } + /// protected override void ValidateKey( IKey key, diff --git a/src/EFCore.Relational/Properties/RelationalStrings.Designer.cs b/src/EFCore.Relational/Properties/RelationalStrings.Designer.cs index 879e4b3c93f..d1324086fae 100644 --- a/src/EFCore.Relational/Properties/RelationalStrings.Designer.cs +++ b/src/EFCore.Relational/Properties/RelationalStrings.Designer.cs @@ -34,6 +34,14 @@ public static string AbstractTpc(object? entityType, object? storeObject) GetString("AbstractTpc", nameof(entityType), nameof(storeObject)), entityType, storeObject); + /// + /// The property '{property}' on type '{type}' is mapped to a JSON entity and cannot be configured as not auto-loaded. JSON-mapped entities are always loaded as a unit. + /// + public static string AutoLoadedJsonProperty(object? property, object? type) + => string.Format( + GetString("AutoLoadedJsonProperty", nameof(property), nameof(type)), + property, type); + /// /// Unable to deserialize a sequence from model metadata. See inner exception for details. /// diff --git a/src/EFCore.Relational/Properties/RelationalStrings.resx b/src/EFCore.Relational/Properties/RelationalStrings.resx index 1dbddd15833..37b6701ce00 100644 --- a/src/EFCore.Relational/Properties/RelationalStrings.resx +++ b/src/EFCore.Relational/Properties/RelationalStrings.resx @@ -120,6 +120,9 @@ The entity type '{entityType}' cannot be instantiated because its corresponding CLR type is abstract, but the entity type was mapped to '{storeObject}' using the 'TPC' mapping strategy. Only instantiable types should be mapped. See https://go.microsoft.com/fwlink/?linkid=2130430 for more information. + + The property '{property}' on type '{type}' is mapped to a JSON entity and cannot be configured as not auto-loaded. JSON-mapped entities are always loaded as a unit. + Unable to deserialize a sequence from model metadata. See inner exception for details. Obsolete diff --git a/src/EFCore.Relational/Update/ModificationCommand.cs b/src/EFCore.Relational/Update/ModificationCommand.cs index 8361516677a..184b931d677 100644 --- a/src/EFCore.Relational/Update/ModificationCommand.cs +++ b/src/EFCore.Relational/Update/ModificationCommand.cs @@ -408,10 +408,11 @@ void HandleColumn( // Note that for stored procedures we always need to send all parameters, regardless of whether the property // actually changed. writeValue = !columnPropagator?.TryPropagate(columnMapping, entry) - ?? (entry.EntityState == EntityState.Added - || entry.EntityState == EntityState.Deleted - || ColumnModification.IsModified(entry, property) - || StoreStoredProcedure is not null); + ?? (entry.IsLoaded(property) + && (entry.EntityState == EntityState.Added + || entry.EntityState == EntityState.Deleted + || ColumnModification.IsModified(entry, property) + || StoreStoredProcedure is not null)); } } @@ -1207,6 +1208,7 @@ public void RecordValue(IColumnMapping mapping, IUpdateEntry entry) { case EntityState.Modified: if (!_write + && entry.IsLoaded(property) && Update.ColumnModification.IsModified(entry, property)) { _write = true; diff --git a/src/EFCore.SqlServer/Infrastructure/Internal/SqlServerModelValidator.cs b/src/EFCore.SqlServer/Infrastructure/Internal/SqlServerModelValidator.cs index 74b15342df1..9396be1c3f0 100644 --- a/src/EFCore.SqlServer/Infrastructure/Internal/SqlServerModelValidator.cs +++ b/src/EFCore.SqlServer/Infrastructure/Internal/SqlServerModelValidator.cs @@ -50,10 +50,11 @@ protected override void ValidateProperty( ITypeBase structuralType, IDiagnosticsLogger logger) { + ValidateVectorProperty(property, logger); + base.ValidateProperty(property, structuralType, logger); ValidateDecimalColumn(property, logger); - ValidateVectorProperty(property, logger); } /// diff --git a/src/EFCore.SqlServer/Metadata/Conventions/SqlServerAutoLoadConvention.cs b/src/EFCore.SqlServer/Metadata/Conventions/SqlServerAutoLoadConvention.cs new file mode 100644 index 00000000000..381f20d035c --- /dev/null +++ b/src/EFCore.SqlServer/Metadata/Conventions/SqlServerAutoLoadConvention.cs @@ -0,0 +1,40 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Data.SqlTypes; +using Microsoft.EntityFrameworkCore.SqlServer.Storage.Internal; + +// ReSharper disable once CheckNamespace +namespace Microsoft.EntityFrameworkCore.Metadata.Conventions; + +/// +/// A convention that configures SQL Server vector properties as not auto-loaded. +/// +/// +/// See Model building conventions for more information and examples. +/// +public class SqlServerAutoLoadConvention : AutoLoadConvention +{ + /// + /// Creates a new instance of . + /// + /// Parameter object containing dependencies for this convention. + public SqlServerAutoLoadConvention(ProviderConventionSetBuilderDependencies dependencies) + : base(dependencies) + { + } + + /// + protected override bool ShouldBeAutoLoaded(IConventionProperty property) + { + var typeMapping = property.FindTypeMapping(); + if (typeMapping is not null) + { + return typeMapping is not SqlServerVectorTypeMapping; + } + + // Fall back to CLR type check when type mapping hasn't been resolved yet + return property.GetValueConverter() == null + && property.ClrType.TryGetElementType(typeof(SqlVector<>)) is null; + } +} diff --git a/src/EFCore.SqlServer/Metadata/Conventions/SqlServerConventionSetBuilder.cs b/src/EFCore.SqlServer/Metadata/Conventions/SqlServerConventionSetBuilder.cs index 7cc34385776..753bc9f2090 100644 --- a/src/EFCore.SqlServer/Metadata/Conventions/SqlServerConventionSetBuilder.cs +++ b/src/EFCore.SqlServer/Metadata/Conventions/SqlServerConventionSetBuilder.cs @@ -63,6 +63,7 @@ public override ConventionSet CreateConventionSet() conventionSet.Replace(new SqlServerRuntimeModelConvention(Dependencies, RelationalDependencies)); conventionSet.Replace( new SqlServerSharedTableConvention(Dependencies, RelationalDependencies)); + conventionSet.Replace(new SqlServerAutoLoadConvention(Dependencies)); var sqlServerTemporalConvention = new SqlServerTemporalConvention(Dependencies, RelationalDependencies); ConventionSet.AddBefore( diff --git a/src/EFCore/ChangeTracking/Internal/ChangeDetector.cs b/src/EFCore/ChangeTracking/Internal/ChangeDetector.cs index 9fa75bc42c8..af2b1cd339b 100644 --- a/src/EFCore/ChangeTracking/Internal/ChangeDetector.cs +++ b/src/EFCore/ChangeTracking/Internal/ChangeDetector.cs @@ -281,6 +281,17 @@ private bool LocalDetectChanges(InternalEntryBase entry) var changesFound = false; foreach (var property in entry.StructuralType.GetFlattenedProperties()) { + if (!entry.IsLoaded(property)) + { + if (!property.GetValueComparer().Equals(entry[property], property.Sentinel)) + { + entry.SetPropertyModified(property); + changesFound = true; + } + + continue; + } + if (property.GetOriginalValueIndex() >= 0 && !entry.IsModified(property) && !entry.IsConceptualNull(property)) @@ -339,9 +350,10 @@ public virtual bool DetectComplexPropertyChange(InternalEntryBase entry, IComple { foreach (var innerProperty in complexProperty.ComplexType.GetFlattenedProperties()) { - // Only mark properties that are tracked and can be modified + // Only mark properties that are tracked, can be modified, and are loaded if (innerProperty.GetOriginalValueIndex() >= 0 - && innerProperty.GetAfterSaveBehavior() == PropertySaveBehavior.Save) + && innerProperty.GetAfterSaveBehavior() == PropertySaveBehavior.Save + && entry.IsLoaded(innerProperty)) { entry.SetPropertyModified(innerProperty); } @@ -792,6 +804,11 @@ public bool DetectComplexCollectionChanges(InternalEntryBase entry, IComplexProp /// public bool DetectValueChange(IInternalEntry entry, IProperty property) { + if (!entry.IsLoaded(property)) + { + return false; + } + var current = entry[property]; var original = entry.GetOriginalValue(property); diff --git a/src/EFCore/ChangeTracking/Internal/IInternalEntry.cs b/src/EFCore/ChangeTracking/Internal/IInternalEntry.cs index 0acc3977fc0..eac3ccbdd97 100644 --- a/src/EFCore/ChangeTracking/Internal/IInternalEntry.cs +++ b/src/EFCore/ChangeTracking/Internal/IInternalEntry.cs @@ -206,6 +206,22 @@ public object Entity /// bool IsModified(IProperty property); + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + bool IsLoaded(IProperty property); + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + void SetIsLoaded(IProperty property, bool loaded); + /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to /// the same compatibility standards as public APIs. It may be changed or removed without notice in diff --git a/src/EFCore/ChangeTracking/Internal/InternalEntryBase.StateData.cs b/src/EFCore/ChangeTracking/Internal/InternalEntryBase.StateData.cs index 1a1eed21824..01320bd8ff4 100644 --- a/src/EFCore/ChangeTracking/Internal/InternalEntryBase.StateData.cs +++ b/src/EFCore/ChangeTracking/Internal/InternalEntryBase.StateData.cs @@ -59,7 +59,20 @@ protected internal enum PropertyFlag /// any release. You should only use it directly in your code with extreme caution and knowing that /// doing so can result in application failures when updating to a new Entity Framework Core release. /// - IsStoreGenerated = 5 + IsStoreGenerated = 5, + + /// + /// Tracks whether a property value has NOT been loaded (for properties with equal + /// to ). The default (false) means loaded; set to true for not-auto-loaded properties. + /// Distinct from which tracks navigation loaded state. + /// + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + IsPropertyNotLoaded = 6 } /// @@ -68,6 +81,20 @@ protected internal enum PropertyFlag /// any release. You should only use it directly in your code with extreme caution and knowing that /// doing so can result in application failures when updating to a new Entity Framework Core release. /// + /// + /// + /// Stores per-slot flags in a bit array. Each slot gets 8 bits. + /// The total number of slots is max(propertyCount, navigationCount). + /// + /// + /// Property flags use bits: Modified (0), Null (1), Unknown (2), IsTemporary (4), + /// IsStoreGenerated (5), IsPropertyNotLoaded (6). Bit 7 is unused/reserved. + /// + /// + /// Navigation flags use bit: IsLoaded (3). Since property flags and navigation flags occupy + /// distinct bits within the same 8-bit slot, they share the same slot array without conflict. + /// + /// protected internal readonly struct StateData { private const int BitsPerInt = 32; @@ -90,7 +117,7 @@ protected internal readonly struct StateData /// public StateData(int propertyCount, int navigationCount) { - // Properties and navigations use different flags + // Properties and navigations share the same bit array, but use different bits within each slot var bitsNumber = Math.Max(propertyCount, navigationCount) * BitsForPropertyFlags + BitsForAdditionalState - 1; _bits = new int[(bitsNumber / BitsPerInt) + 1]; } diff --git a/src/EFCore/ChangeTracking/Internal/InternalEntryBase.cs b/src/EFCore/ChangeTracking/Internal/InternalEntryBase.cs index 99dd4e0010e..05fb2eb8f5e 100644 --- a/src/EFCore/ChangeTracking/Internal/InternalEntryBase.cs +++ b/src/EFCore/ChangeTracking/Internal/InternalEntryBase.cs @@ -207,7 +207,7 @@ protected virtual bool PrepareForAdd(EntityState newState) { return false; } - + if (EntityState == EntityState.Modified) { _stateData.FlagAllProperties( @@ -232,6 +232,20 @@ protected virtual void SetEntityState(EntityState oldState, EntityState newState { var structuralType = StructuralType; + // When transitioning from Detached, check not-auto-loaded properties: + // if their current value equals the sentinel, mark them as not-loaded. + if (oldState == EntityState.Detached) + { + foreach (var property in structuralType.GetFlattenedProperties()) + { + if (!property.IsAutoLoaded) + { + _stateData.FlagProperty( + property.GetIndex(), PropertyFlag.IsPropertyNotLoaded, HasSentinelValue(property)); + } + } + } + // Prevent temp values from becoming permanent values if (oldState == EntityState.Added && newState != EntityState.Added @@ -264,6 +278,13 @@ protected virtual void SetEntityState(EntityState oldState, EntityState newState { _stateData.FlagProperty(property.GetIndex(), PropertyFlag.Modified, isFlagged: false); } + + // Properties that are not loaded (IsAutoLoaded = false and not yet loaded) should + // not be marked as modified when the entity state is set to Modified. + if (_stateData.IsPropertyFlagged(property.GetIndex(), PropertyFlag.IsPropertyNotLoaded)) + { + _stateData.FlagProperty(property.GetIndex(), PropertyFlag.Modified, isFlagged: false); + } } foreach (var complexCollection in structuralType.GetFlattenedComplexProperties()) @@ -407,7 +428,8 @@ public bool IsModified(IProperty property) return _stateData.EntityState == EntityState.Modified && _stateData.IsPropertyFlagged(propertyIndex, PropertyFlag.Modified) - && !_stateData.IsPropertyFlagged(propertyIndex, PropertyFlag.Unknown); + && !_stateData.IsPropertyFlagged(propertyIndex, PropertyFlag.Unknown) + && !_stateData.IsPropertyFlagged(propertyIndex, PropertyFlag.IsPropertyNotLoaded); } /// @@ -423,6 +445,32 @@ public bool IsUnknown(IProperty property) return _stateData.IsPropertyFlagged(property.GetIndex(), PropertyFlag.Unknown); } + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public bool IsLoaded(IProperty property) + { + StructuralType.CheckContains(property); + + return !_stateData.IsPropertyFlagged(property.GetIndex(), PropertyFlag.IsPropertyNotLoaded); + } + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public void SetIsLoaded(IProperty property, bool loaded) + { + StructuralType.CheckContains(property); + + _stateData.FlagProperty(property.GetIndex(), PropertyFlag.IsPropertyNotLoaded, !loaded); + } + /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to /// the same compatibility standards as public APIs. It may be changed or removed without notice in @@ -440,6 +488,7 @@ public void SetPropertyModified( var propertyIndex = property.GetIndex(); _stateData.FlagProperty(propertyIndex, PropertyFlag.Unknown, false); + _stateData.FlagProperty(propertyIndex, PropertyFlag.IsPropertyNotLoaded, false); var currentState = _stateData.EntityState; diff --git a/src/EFCore/ChangeTracking/PropertyEntry.cs b/src/EFCore/ChangeTracking/PropertyEntry.cs index ea2bcd73f25..230d0d8a57e 100644 --- a/src/EFCore/ChangeTracking/PropertyEntry.cs +++ b/src/EFCore/ChangeTracking/PropertyEntry.cs @@ -66,6 +66,21 @@ public virtual bool IsTemporary } } + /// + /// Gets or sets a value indicating whether the value of this property has been loaded + /// from the database. When , the property value is considered + /// not present and will be excluded from update operations. + /// + /// + /// See Accessing tracked entities in EF Core for more information and + /// examples. + /// + public virtual bool IsLoaded + { + get => InternalEntry.IsLoaded(Metadata); + set => InternalEntry.SetIsLoaded(Metadata, value); + } + /// /// Gets the metadata that describes the facets of this property and how it maps to the database. /// diff --git a/src/EFCore/Infrastructure/ModelValidator.cs b/src/EFCore/Infrastructure/ModelValidator.cs index d29918dce95..ceddc40f67a 100644 --- a/src/EFCore/Infrastructure/ModelValidator.cs +++ b/src/EFCore/Infrastructure/ModelValidator.cs @@ -167,6 +167,51 @@ protected virtual void ValidateProperty( { ValidateTypeMapping(property, logger); ValidatePrimitiveCollection(property, logger); + ValidateAutoLoaded(property, structuralType, logger); + } + + /// + /// Validates that a property configured as not auto-loaded is not a key, foreign key, concurrency token or discriminator. + /// + /// The property to validate. + /// The structural type containing the property. + /// The logger to use. + protected virtual void ValidateAutoLoaded( + IProperty property, + ITypeBase structuralType, + IDiagnosticsLogger logger) + { + if (property.IsAutoLoaded) + { + return; + } + + var typeName = structuralType.DisplayName(); + + if (property.IsKey()) + { + throw new InvalidOperationException( + CoreStrings.AutoLoadedKeyProperty(property.Name, typeName)); + } + + if (property.IsForeignKey()) + { + throw new InvalidOperationException( + CoreStrings.AutoLoadedForeignKeyProperty(property.Name, typeName)); + } + + if (property.IsConcurrencyToken) + { + throw new InvalidOperationException( + CoreStrings.AutoLoadedConcurrencyTokenProperty(property.Name, typeName)); + } + + if (structuralType is IEntityType entityType + && entityType.FindDiscriminatorProperty() == property) + { + throw new InvalidOperationException( + CoreStrings.AutoLoadedDiscriminatorProperty(property.Name, typeName)); + } } /// diff --git a/src/EFCore/Metadata/Builders/IConventionPropertyBuilder.cs b/src/EFCore/Metadata/Builders/IConventionPropertyBuilder.cs index 45bd923102d..1a24761eac9 100644 --- a/src/EFCore/Metadata/Builders/IConventionPropertyBuilder.cs +++ b/src/EFCore/Metadata/Builders/IConventionPropertyBuilder.cs @@ -104,6 +104,29 @@ public interface IConventionPropertyBuilder : IConventionPropertyBaseBuilder if the property can be configured as a concurrency token. bool CanSetIsConcurrencyToken(bool? concurrencyToken, bool fromDataAnnotation = false); + /// + /// Configures whether this property is automatically loaded when the entity is queried from the database. + /// + /// + /// A value indicating whether this property is automatically loaded. + /// to reset to default. + /// + /// Indicates whether the configuration was specified using a data annotation. + /// + /// The same builder instance if the configuration was applied, + /// otherwise. + /// + IConventionPropertyBuilder? IsAutoLoaded(bool? autoLoaded, bool fromDataAnnotation = false); + + /// + /// Returns a value indicating whether the property can be configured as auto-loaded + /// from the current configuration source. + /// + /// A value indicating whether this property is auto-loaded. + /// Indicates whether the configuration was specified using a data annotation. + /// if the auto-loaded can be configured for this property. + bool CanSetIsAutoLoaded(bool? autoLoaded, bool fromDataAnnotation = false); + /// /// Configures the value that will be used to determine if the property has been set or not. If the property is set to the /// sentinel value, then it is considered not set. By default, the sentinel value is the CLR default value for the type of diff --git a/src/EFCore/Metadata/Conventions/AutoLoadConvention.cs b/src/EFCore/Metadata/Conventions/AutoLoadConvention.cs new file mode 100644 index 00000000000..87be9cd2742 --- /dev/null +++ b/src/EFCore/Metadata/Conventions/AutoLoadConvention.cs @@ -0,0 +1,69 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.EntityFrameworkCore.Metadata.Conventions; + +/// +/// A convention that configures properties based on provider-specific heuristics to determine +/// whether they should be auto-loaded. +/// Override to customize which properties are excluded from automatic loading. +/// +/// +/// See Model building conventions for more information and examples. +/// +public class AutoLoadConvention : IModelFinalizingConvention +{ + /// + /// Creates a new instance of . + /// + /// Parameter object containing dependencies for this convention. + public AutoLoadConvention(ProviderConventionSetBuilderDependencies dependencies) + { + Dependencies = dependencies; + } + + /// + /// Dependencies for this service. + /// + protected virtual ProviderConventionSetBuilderDependencies Dependencies { get; } + + /// + public virtual void ProcessModelFinalizing( + IConventionModelBuilder modelBuilder, + IConventionContext context) + { + foreach (var entityType in modelBuilder.Metadata.GetEntityTypes()) + { + ProcessType(entityType); + } + } + + private void ProcessType(IConventionTypeBase typeBase) + { + foreach (var property in typeBase.GetDeclaredProperties()) + { + if (property.GetIsAutoLoadedConfigurationSource() == null + && !ShouldBeAutoLoaded(property)) + { + property.Builder.IsAutoLoaded(false, fromDataAnnotation: false); + } + } + + foreach (var complexProperty in typeBase.GetDeclaredComplexProperties()) + { + // Properties on complex collections are always auto-loaded + if (!complexProperty.IsCollection) + { + ProcessType(complexProperty.ComplexType); + } + } + } + + /// + /// Returns a value indicating whether the given property should be auto-loaded. + /// + /// The property to check. + /// if the property should be auto-loaded; otherwise. + protected virtual bool ShouldBeAutoLoaded(IConventionProperty property) + => true; +} diff --git a/src/EFCore/Metadata/Conventions/ConventionSet.cs b/src/EFCore/Metadata/Conventions/ConventionSet.cs index 497c9faf075..ca8782b5d95 100644 --- a/src/EFCore/Metadata/Conventions/ConventionSet.cs +++ b/src/EFCore/Metadata/Conventions/ConventionSet.cs @@ -262,6 +262,11 @@ public class ConventionSet /// public virtual List PropertyNullabilityChangedConventions { get; } = []; + /// + /// Conventions to run when the auto-load value of a property is changed. + /// + public virtual List PropertyAutoLoadChangedConventions { get; } = []; + /// /// Conventions to run when the field of a property is changed. /// @@ -603,6 +608,12 @@ public virtual void Replace(TImplementation newConvention) PropertyNullabilityChangedConventions.Add(propertyNullabilityChangedConvention); } + if (newConvention is IPropertyAutoLoadChangedConvention propertyAutoLoadChangedConvention + && !Replace(PropertyAutoLoadChangedConventions, propertyAutoLoadChangedConvention, oldConventionType)) + { + PropertyAutoLoadChangedConventions.Add(propertyAutoLoadChangedConvention); + } + if (newConvention is IPropertyFieldChangedConvention propertyFieldChangedConvention && !Replace(PropertyFieldChangedConventions, propertyFieldChangedConvention, oldConventionType)) { @@ -937,6 +948,11 @@ public virtual void Add(IConvention convention) PropertyNullabilityChangedConventions.Add(propertyNullabilityChangedConvention); } + if (convention is IPropertyAutoLoadChangedConvention propertyAutoLoadChangedConvention) + { + PropertyAutoLoadChangedConventions.Add(propertyAutoLoadChangedConvention); + } + if (convention is IPropertyFieldChangedConvention propertyFieldChangedConvention) { PropertyFieldChangedConventions.Add(propertyFieldChangedConvention); @@ -1280,6 +1296,11 @@ public virtual void Remove(Type conventionType) Remove(PropertyNullabilityChangedConventions, conventionType); } + if (typeof(IPropertyAutoLoadChangedConvention).IsAssignableFrom(conventionType)) + { + Remove(PropertyAutoLoadChangedConventions, conventionType); + } + if (typeof(IPropertyFieldChangedConvention).IsAssignableFrom(conventionType)) { Remove(PropertyFieldChangedConventions, conventionType); diff --git a/src/EFCore/Metadata/Conventions/IPropertyAutoLoadChangedConvention.cs b/src/EFCore/Metadata/Conventions/IPropertyAutoLoadChangedConvention.cs new file mode 100644 index 00000000000..6b5555994ab --- /dev/null +++ b/src/EFCore/Metadata/Conventions/IPropertyAutoLoadChangedConvention.cs @@ -0,0 +1,23 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.EntityFrameworkCore.Metadata.Conventions; + +/// +/// Represents an operation that should be performed when the +/// value for a property is changed. +/// +/// +/// See Model building conventions for more information and examples. +/// +public interface IPropertyAutoLoadChangedConvention : IConvention +{ + /// + /// Called after the value for a property is changed. + /// + /// The builder for the property. + /// Additional information associated with convention execution. + void ProcessPropertyAutoLoadChanged( + IConventionPropertyBuilder propertyBuilder, + IConventionContext context); +} diff --git a/src/EFCore/Metadata/Conventions/Infrastructure/ProviderConventionSetBuilder.cs b/src/EFCore/Metadata/Conventions/Infrastructure/ProviderConventionSetBuilder.cs index 10175ebd642..298eb968efb 100644 --- a/src/EFCore/Metadata/Conventions/Infrastructure/ProviderConventionSetBuilder.cs +++ b/src/EFCore/Metadata/Conventions/Infrastructure/ProviderConventionSetBuilder.cs @@ -97,6 +97,7 @@ public virtual ConventionSet CreateConventionSet() conventionSet.Add(new NonNullableNavigationConvention(Dependencies)); conventionSet.Add(new BackingFieldConvention(Dependencies)); conventionSet.Add(new QueryFilterRewritingConvention(Dependencies)); + conventionSet.Add(new AutoLoadConvention(Dependencies)); conventionSet.Add(new RuntimeModelConvention(Dependencies)); conventionSet.Add(new ElementMappingConvention(Dependencies)); conventionSet.Add(new ElementTypeChangedConvention(Dependencies)); diff --git a/src/EFCore/Metadata/Conventions/Internal/ConventionDispatcher.ConventionScope.cs b/src/EFCore/Metadata/Conventions/Internal/ConventionDispatcher.ConventionScope.cs index 5a179129c29..58613ed8196 100644 --- a/src/EFCore/Metadata/Conventions/Internal/ConventionDispatcher.ConventionScope.cs +++ b/src/EFCore/Metadata/Conventions/Internal/ConventionDispatcher.ConventionScope.cs @@ -242,6 +242,9 @@ public int GetLeafCount() public abstract bool? OnPropertyNullabilityChanged( IConventionPropertyBuilder propertyBuilder); + public abstract bool? OnPropertyAutoLoadChanged( + IConventionPropertyBuilder propertyBuilder); + public abstract IConventionProperty? OnPropertyRemoved( IConventionTypeBaseBuilder typeBaseBuilder, IConventionProperty property); diff --git a/src/EFCore/Metadata/Conventions/Internal/ConventionDispatcher.DelayedConventionScope.cs b/src/EFCore/Metadata/Conventions/Internal/ConventionDispatcher.DelayedConventionScope.cs index 1a91165384b..aecb0ab8c61 100644 --- a/src/EFCore/Metadata/Conventions/Internal/ConventionDispatcher.DelayedConventionScope.cs +++ b/src/EFCore/Metadata/Conventions/Internal/ConventionDispatcher.DelayedConventionScope.cs @@ -402,6 +402,12 @@ public override IConventionPropertyBuilder OnPropertyAdded(IConventionPropertyBu return propertyBuilder.Metadata.IsNullable; } + public override bool? OnPropertyAutoLoadChanged(IConventionPropertyBuilder propertyBuilder) + { + Add(new OnPropertyAutoLoadChangedNode(propertyBuilder)); + return propertyBuilder.Metadata.IsAutoLoaded; + } + public override bool? OnElementTypeNullabilityChanged(IConventionElementTypeBuilder builder) { Add(new OnElementTypeNullabilityChangedNode(builder)); @@ -992,6 +998,14 @@ public override void Run(ConventionDispatcher dispatcher) => dispatcher._immediateConventionScope.OnPropertyNullabilityChanged(PropertyBuilder); } + private sealed class OnPropertyAutoLoadChangedNode(IConventionPropertyBuilder propertyBuilder) : ConventionNode + { + public IConventionPropertyBuilder PropertyBuilder { get; } = propertyBuilder; + + public override void Run(ConventionDispatcher dispatcher) + => dispatcher._immediateConventionScope.OnPropertyAutoLoadChanged(PropertyBuilder); + } + private sealed class OnElementTypeNullabilityChangedNode(IConventionElementTypeBuilder builder) : ConventionNode { public IConventionElementTypeBuilder ElementTypeBuilder { get; } = builder; diff --git a/src/EFCore/Metadata/Conventions/Internal/ConventionDispatcher.ImmediateConventionScope.cs b/src/EFCore/Metadata/Conventions/Internal/ConventionDispatcher.ImmediateConventionScope.cs index aefed1db3d9..458caaef088 100644 --- a/src/EFCore/Metadata/Conventions/Internal/ConventionDispatcher.ImmediateConventionScope.cs +++ b/src/EFCore/Metadata/Conventions/Internal/ConventionDispatcher.ImmediateConventionScope.cs @@ -1575,6 +1575,41 @@ public IConventionModelBuilder OnModelInitialized(IConventionModelBuilder modelB return !propertyBuilder.Metadata.IsInModel ? null : _boolConventionContext.Result; } + public override bool? OnPropertyAutoLoadChanged(IConventionPropertyBuilder propertyBuilder) + { + if (!propertyBuilder.Metadata.DeclaringType.IsInModel) + { + return null; + } +#if DEBUG + var initialValue = propertyBuilder.Metadata.IsAutoLoaded; +#endif + using (dispatcher.DelayConventions()) + { + _boolConventionContext.ResetState(propertyBuilder.Metadata.IsAutoLoaded); + foreach (var propertyConvention in conventionSet.PropertyAutoLoadChangedConventions) + { + if (!propertyBuilder.Metadata.IsInModel) + { + return null; + } + + propertyConvention.ProcessPropertyAutoLoadChanged(propertyBuilder, _boolConventionContext); + if (_boolConventionContext.ShouldStopProcessing()) + { + return _boolConventionContext.Result; + } +#if DEBUG + Check.DebugAssert( + initialValue == propertyBuilder.Metadata.IsAutoLoaded, + $"Convention {propertyConvention.GetType().Name} changed value without terminating"); +#endif + } + } + + return !propertyBuilder.Metadata.IsInModel ? null : _boolConventionContext.Result; + } + public override bool? OnElementTypeNullabilityChanged(IConventionElementTypeBuilder builder) { if (!builder.Metadata.CollectionProperty.IsInModel) diff --git a/src/EFCore/Metadata/Conventions/Internal/ConventionDispatcher.cs b/src/EFCore/Metadata/Conventions/Internal/ConventionDispatcher.cs index 4e480e5568a..64e14889935 100644 --- a/src/EFCore/Metadata/Conventions/Internal/ConventionDispatcher.cs +++ b/src/EFCore/Metadata/Conventions/Internal/ConventionDispatcher.cs @@ -684,6 +684,15 @@ public virtual IConventionModelBuilder OnModelFinalizing(IConventionModelBuilder public virtual bool? OnPropertyNullabilityChanged(IConventionPropertyBuilder propertyBuilder) => _scope.OnPropertyNullabilityChanged(propertyBuilder); + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public virtual bool? OnPropertyAutoLoadChanged(IConventionPropertyBuilder propertyBuilder) + => _scope.OnPropertyAutoLoadChanged(propertyBuilder); + /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to /// the same compatibility standards as public APIs. It may be changed or removed without notice in diff --git a/src/EFCore/Metadata/Conventions/RuntimeModelConvention.cs b/src/EFCore/Metadata/Conventions/RuntimeModelConvention.cs index f1025d6e5c3..30fc69b6a23 100644 --- a/src/EFCore/Metadata/Conventions/RuntimeModelConvention.cs +++ b/src/EFCore/Metadata/Conventions/RuntimeModelConvention.cs @@ -378,7 +378,8 @@ private static RuntimeProperty Create(IProperty property, RuntimeTypeBase runtim providerValueComparer: property.GetProviderValueComparer(), jsonValueReaderWriter: property.GetJsonValueReaderWriter(), typeMapping: property.GetTypeMapping(), - sentinel: property.Sentinel) + sentinel: property.Sentinel, + autoLoaded: property.IsAutoLoaded) : ((RuntimeComplexType)runtimeType).AddProperty( property.Name, property.ClrType, @@ -402,7 +403,8 @@ private static RuntimeProperty Create(IProperty property, RuntimeTypeBase runtim providerValueComparer: property.GetProviderValueComparer(), jsonValueReaderWriter: property.GetJsonValueReaderWriter(), typeMapping: property.GetTypeMapping(), - sentinel: property.Sentinel); + sentinel: property.Sentinel, + autoLoaded: property.IsAutoLoaded); private static RuntimeElementType Create(RuntimeProperty runtimeProperty, IElementType element) => runtimeProperty.SetElementType( diff --git a/src/EFCore/Metadata/IConventionProperty.cs b/src/EFCore/Metadata/IConventionProperty.cs index c89367eb30d..6c84a182213 100644 --- a/src/EFCore/Metadata/IConventionProperty.cs +++ b/src/EFCore/Metadata/IConventionProperty.cs @@ -97,6 +97,23 @@ public interface IConventionProperty : IReadOnlyProperty, IConventionPropertyBas /// The configuration source for . ConfigurationSource? GetIsConcurrencyTokenConfigurationSource(); + /// + /// Sets a value indicating whether this property is automatically loaded when the entity is queried from the database. + /// + /// + /// A value indicating whether this property is automatically loaded. + /// to reset to default. + /// + /// Indicates whether the configuration was specified using a data annotation. + /// The configured value. + bool? SetIsAutoLoaded(bool? autoLoaded, bool fromDataAnnotation = false); + + /// + /// Returns the configuration source for . + /// + /// The configuration source for . + ConfigurationSource? GetIsAutoLoadedConfigurationSource(); + /// /// Returns a value indicating whether the property was created implicitly and isn't based on the CLR model. /// diff --git a/src/EFCore/Metadata/IMutableProperty.cs b/src/EFCore/Metadata/IMutableProperty.cs index 019e86cb3cc..44787f982c3 100644 --- a/src/EFCore/Metadata/IMutableProperty.cs +++ b/src/EFCore/Metadata/IMutableProperty.cs @@ -50,6 +50,13 @@ public interface IMutableProperty : IReadOnlyProperty, IMutablePropertyBase /// new bool IsConcurrencyToken { get; set; } + /// + /// Gets or sets a value indicating whether this property is automatically loaded when the entity is queried + /// from the database. When set to , the property value will not be read from the database + /// and the property will be excluded from UPDATE statements unless explicitly loaded or modified. + /// + new bool IsAutoLoaded { get; set; } + /// /// Gets or sets the sentinel value that indicates that this property is not set. /// diff --git a/src/EFCore/Metadata/IReadOnlyProperty.cs b/src/EFCore/Metadata/IReadOnlyProperty.cs index be9a4c06802..b702054ba28 100644 --- a/src/EFCore/Metadata/IReadOnlyProperty.cs +++ b/src/EFCore/Metadata/IReadOnlyProperty.cs @@ -45,6 +45,14 @@ IReadOnlyEntityType DeclaringEntityType /// bool IsConcurrencyToken { get; } + /// + /// Gets a value indicating whether this property is automatically loaded when the entity is queried from the database. + /// When set to , the property value will not be read from the database and the property will be + /// excluded from UPDATE statements unless explicitly loaded or modified. + /// + virtual bool IsAutoLoaded + => true; + /// /// Returns the for the given property from a finalized model. /// @@ -391,6 +399,11 @@ string ToDebugString(MetadataDebugStringOptions options = MetadataDebugStringOpt builder.Append(" Concurrency"); } + if (!IsAutoLoaded) + { + builder.Append(" NoAutoLoad"); + } + if (Sentinel != null && !Equals(Sentinel, ClrType.GetDefaultValue())) { builder.Append(" Sentinel:").Append(Sentinel); diff --git a/src/EFCore/Metadata/Internal/InternalPropertyBuilder.cs b/src/EFCore/Metadata/Internal/InternalPropertyBuilder.cs index 79502c4b441..1c82d09b6fc 100644 --- a/src/EFCore/Metadata/Internal/InternalPropertyBuilder.cs +++ b/src/EFCore/Metadata/Internal/InternalPropertyBuilder.cs @@ -147,6 +147,33 @@ public virtual bool CanSetIsConcurrencyToken(bool? concurrencyToken, Configurati => configurationSource.Overrides(Metadata.GetIsConcurrencyTokenConfigurationSource()) || Metadata.IsConcurrencyToken == concurrencyToken; + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public virtual InternalPropertyBuilder? IsAutoLoaded(bool? autoLoaded, ConfigurationSource configurationSource) + { + if (CanSetIsAutoLoaded(autoLoaded, configurationSource)) + { + Metadata.SetIsAutoLoaded(autoLoaded, configurationSource); + return this; + } + + return null; + } + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public virtual bool CanSetIsAutoLoaded(bool? autoLoaded, ConfigurationSource? configurationSource) + => configurationSource.Overrides(Metadata.GetIsAutoLoadedConfigurationSource()) + || Metadata.IsAutoLoaded == autoLoaded; + /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to /// the same compatibility standards as public APIs. It may be changed or removed without notice in @@ -928,6 +955,14 @@ public virtual bool CanSetElementType(Type? elementType, ConfigurationSource? co oldIsConcurrencyTokenConfigurationSource.Value); } + var oldIsAutoLoadedConfigurationSource = Metadata.GetIsAutoLoadedConfigurationSource(); + if (oldIsAutoLoadedConfigurationSource.HasValue) + { + newPropertyBuilder.IsAutoLoaded( + Metadata.IsAutoLoaded, + oldIsAutoLoadedConfigurationSource.Value); + } + var oldValueGeneratedConfigurationSource = Metadata.GetValueGeneratedConfigurationSource(); if (oldValueGeneratedConfigurationSource.HasValue) { @@ -1167,6 +1202,26 @@ bool IConventionPropertyBuilder.CanSetIsConcurrencyToken(bool? concurrencyToken, => CanSetIsConcurrencyToken( concurrencyToken, fromDataAnnotation ? ConfigurationSource.DataAnnotation : ConfigurationSource.Convention); + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + IConventionPropertyBuilder? IConventionPropertyBuilder.IsAutoLoaded(bool? autoLoaded, bool fromDataAnnotation) + => IsAutoLoaded( + autoLoaded, fromDataAnnotation ? ConfigurationSource.DataAnnotation : ConfigurationSource.Convention); + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + bool IConventionPropertyBuilder.CanSetIsAutoLoaded(bool? autoLoaded, bool fromDataAnnotation) + => CanSetIsAutoLoaded( + autoLoaded, fromDataAnnotation ? ConfigurationSource.DataAnnotation : ConfigurationSource.Convention); + /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to /// the same compatibility standards as public APIs. It may be changed or removed without notice in diff --git a/src/EFCore/Metadata/Internal/Property.cs b/src/EFCore/Metadata/Internal/Property.cs index b8209d2eea4..1607e7eefb2 100644 --- a/src/EFCore/Metadata/Internal/Property.cs +++ b/src/EFCore/Metadata/Internal/Property.cs @@ -19,6 +19,7 @@ public class Property : PropertyBase, IMutableProperty, IConventionProperty, IRu private InternalPropertyBuilder? _builder; private bool? _isConcurrencyToken; + private bool? _isAutoLoaded; private bool? _isNullable; private object? _sentinel; private ValueGenerated? _valueGenerated; @@ -30,6 +31,7 @@ public class Property : PropertyBase, IMutableProperty, IConventionProperty, IRu private ConfigurationSource? _isNullableConfigurationSource; private ConfigurationSource? _sentinelConfigurationSource; private ConfigurationSource? _isConcurrencyTokenConfigurationSource; + private ConfigurationSource? _isAutoLoadedConfigurationSource; private ConfigurationSource? _valueGeneratedConfigurationSource; private ConfigurationSource? _typeMappingConfigurationSource; @@ -339,6 +341,55 @@ private static bool DefaultIsConcurrencyToken public virtual ConfigurationSource? GetIsConcurrencyTokenConfigurationSource() => _isConcurrencyTokenConfigurationSource; + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public virtual bool IsAutoLoaded + { + get => _isAutoLoaded ?? DefaultIsAutoLoaded; + set => SetIsAutoLoaded(value, ConfigurationSource.Explicit); + } + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public virtual bool? SetIsAutoLoaded(bool? autoLoaded, ConfigurationSource configurationSource) + { + EnsureMutable(); + + var isChanging = IsAutoLoaded != autoLoaded; + if (isChanging) + { + _isAutoLoaded = autoLoaded; + } + + _isAutoLoadedConfigurationSource = autoLoaded == null + ? null + : configurationSource.Max(_isAutoLoadedConfigurationSource); + + return isChanging + ? DeclaringType.Model.ConventionDispatcher.OnPropertyAutoLoadChanged(Builder) + : autoLoaded; + } + + private static bool DefaultIsAutoLoaded + => true; + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public virtual ConfigurationSource? GetIsAutoLoadedConfigurationSource() + => _isAutoLoadedConfigurationSource; + /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to /// the same compatibility standards as public APIs. It may be changed or removed without notice in @@ -1793,6 +1844,17 @@ IEnumerable IProperty.GetContainingKeys() => SetIsConcurrencyToken( concurrencyToken, fromDataAnnotation ? ConfigurationSource.DataAnnotation : ConfigurationSource.Convention); + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + [DebuggerStepThrough] + bool? IConventionProperty.SetIsAutoLoaded(bool? autoLoaded, bool fromDataAnnotation) + => SetIsAutoLoaded( + autoLoaded, fromDataAnnotation ? ConfigurationSource.DataAnnotation : ConfigurationSource.Convention); + /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to /// the same compatibility standards as public APIs. It may be changed or removed without notice in diff --git a/src/EFCore/Metadata/RuntimeProperty.cs b/src/EFCore/Metadata/RuntimeProperty.cs index 54b65638029..94f69f49c93 100644 --- a/src/EFCore/Metadata/RuntimeProperty.cs +++ b/src/EFCore/Metadata/RuntimeProperty.cs @@ -20,6 +20,7 @@ public class RuntimeProperty : RuntimePropertyBase, IRuntimeProperty private readonly bool _isNullable; private readonly ValueGenerated _valueGenerated; private readonly bool _isConcurrencyToken; + private readonly bool _isAutoLoaded; private object? _sentinel; private volatile object? _sentinelFromProviderValue; private readonly PropertySaveBehavior _beforeSaveBehavior; @@ -64,7 +65,8 @@ public RuntimeProperty( ValueComparer? providerValueComparer, JsonValueReaderWriter? jsonValueReaderWriter, CoreTypeMapping? typeMapping, - object? sentinel) + object? sentinel, + bool autoLoaded) : base(name, propertyInfo, fieldInfo, propertyAccessMode) { DeclaringType = declaringType; @@ -72,6 +74,7 @@ public RuntimeProperty( _sentinel = sentinel; _isNullable = nullable; _isConcurrencyToken = concurrencyToken; + _isAutoLoaded = autoLoaded; _valueGenerated = valueGenerated; _beforeSaveBehavior = beforeSaveBehavior; _afterSaveBehavior = afterSaveBehavior; @@ -414,6 +417,13 @@ bool IReadOnlyProperty.IsConcurrencyToken get => _isConcurrencyToken; } + /// + bool IReadOnlyProperty.IsAutoLoaded + { + [DebuggerStepThrough] + get => _isAutoLoaded; + } + /// [DebuggerStepThrough] int? IReadOnlyProperty.GetMaxLength() diff --git a/src/EFCore/Metadata/RuntimeTypeBase.cs b/src/EFCore/Metadata/RuntimeTypeBase.cs index 683cf8c7788..c5b56d1e806 100644 --- a/src/EFCore/Metadata/RuntimeTypeBase.cs +++ b/src/EFCore/Metadata/RuntimeTypeBase.cs @@ -197,6 +197,7 @@ protected virtual IEnumerable GetDerivedTypes() /// The for this property. /// The for this property. /// The property value to use to consider the property not set. + /// A value indicating whether this property is automatically loaded from the database. /// The newly created property. public virtual RuntimeProperty AddProperty( string name, @@ -221,7 +222,8 @@ public virtual RuntimeProperty AddProperty( ValueComparer? providerValueComparer = null, JsonValueReaderWriter? jsonValueReaderWriter = null, CoreTypeMapping? typeMapping = null, - object? sentinel = null) + object? sentinel = null, + bool autoLoaded = true) { var property = new RuntimeProperty( name, @@ -247,7 +249,8 @@ public virtual RuntimeProperty AddProperty( providerValueComparer, jsonValueReaderWriter, typeMapping, - sentinel); + sentinel, + autoLoaded); _properties.Add(property.Name, property); diff --git a/src/EFCore/Properties/CoreStrings.Designer.cs b/src/EFCore/Properties/CoreStrings.Designer.cs index 193253511d8..491e64911ba 100644 --- a/src/EFCore/Properties/CoreStrings.Designer.cs +++ b/src/EFCore/Properties/CoreStrings.Designer.cs @@ -152,6 +152,38 @@ public static string AutoIncludeNavigationCycle(object? cycleNavigations) GetString("AutoIncludeNavigationCycle", nameof(cycleNavigations)), cycleNavigations); + /// + /// The property '{property}' on type '{type}' is a concurrency token and cannot be configured as not auto-loaded. Concurrency tokens must always be loaded. + /// + public static string AutoLoadedConcurrencyTokenProperty(object? property, object? type) + => string.Format( + GetString("AutoLoadedConcurrencyTokenProperty", 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. + /// + public static string AutoLoadedDiscriminatorProperty(object? property, object? type) + => string.Format( + GetString("AutoLoadedDiscriminatorProperty", nameof(property), nameof(type)), + property, type); + + /// + /// The property '{property}' on type '{type}' is part of a foreign key and cannot be configured as not auto-loaded. Foreign key properties must always be loaded. + /// + public static string AutoLoadedForeignKeyProperty(object? property, object? type) + => string.Format( + GetString("AutoLoadedForeignKeyProperty", nameof(property), nameof(type)), + property, type); + + /// + /// The property '{property}' on type '{type}' is part of a key and cannot be configured as not auto-loaded. Key properties must always be loaded. + /// + public static string AutoLoadedKeyProperty(object? property, object? type) + => string.Format( + GetString("AutoLoadedKeyProperty", nameof(property), nameof(type)), + property, type); + /// /// The backing field '{field}' cannot be set for the indexer property '{entityType}.{property}'. Ensure no backing fields are specified for indexer properties. /// diff --git a/src/EFCore/Properties/CoreStrings.resx b/src/EFCore/Properties/CoreStrings.resx index 38331c9aad2..7c363a7b47f 100644 --- a/src/EFCore/Properties/CoreStrings.resx +++ b/src/EFCore/Properties/CoreStrings.resx @@ -165,6 +165,18 @@ Cycle detected while auto-including navigations: {cycleNavigations}. To fix this issue, either don't configure at least one navigation in the cycle as auto included in 'OnModelCreating' or call 'IgnoreAutoInclude' method on the query. + + 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 a discriminator and cannot be configured as not auto-loaded. Discriminator properties must always be loaded. + + + The property '{property}' on type '{type}' is part of a foreign key and cannot be configured as not auto-loaded. Foreign key properties must always be loaded. + + + The property '{property}' on type '{type}' is part of a key and cannot be configured as not auto-loaded. Key properties must always be loaded. + The backing field '{field}' cannot be set for the indexer property '{entityType}.{property}'. Ensure no backing fields are specified for indexer properties. diff --git a/src/EFCore/Update/IUpdateEntry.cs b/src/EFCore/Update/IUpdateEntry.cs index 6a7482ab25b..94b1ec41740 100644 --- a/src/EFCore/Update/IUpdateEntry.cs +++ b/src/EFCore/Update/IUpdateEntry.cs @@ -59,6 +59,14 @@ public interface IUpdateEntry /// if the property is modified, otherwise . bool IsModified(IProperty property); + /// + /// Gets a value indicating if the specified property is loaded. If , the property + /// value was not read from the database and the property should be excluded from update operations. + /// + /// The property to be checked. + /// if the property value has been loaded, otherwise . + bool IsLoaded(IProperty property); + /// /// Gets a value indicating if the specified complex property is modified. If , /// the current value assigned to the property should be saved to the database. diff --git a/test/EFCore.Cosmos.FunctionalTests/Scaffolding/CompiledModelCosmosTest.cs b/test/EFCore.Cosmos.FunctionalTests/Scaffolding/CompiledModelCosmosTest.cs index 1bb335b8012..c1a5cb5bfa4 100644 --- a/test/EFCore.Cosmos.FunctionalTests/Scaffolding/CompiledModelCosmosTest.cs +++ b/test/EFCore.Cosmos.FunctionalTests/Scaffolding/CompiledModelCosmosTest.cs @@ -202,6 +202,13 @@ protected override void BuildBigModel(ModelBuilder modelBuilder, bool jsonColumn { base.BuildBigModel(modelBuilder, jsonColumns); + // Cosmos provider doesn't support partial property loading + modelBuilder.Entity( + b => + { + b.Property(e => e.NullableString).Metadata.IsAutoLoaded = true; + }); + modelBuilder.Entity>(eb => eb.ToContainer("Dependents")); modelBuilder.Entity>(eb => eb.HasDiscriminator().IsComplete(false)); diff --git a/test/EFCore.Cosmos.FunctionalTests/TestUtilities/CosmosTestStore.cs b/test/EFCore.Cosmos.FunctionalTests/TestUtilities/CosmosTestStore.cs index b4ef5fa0749..0d56c570a44 100644 --- a/test/EFCore.Cosmos.FunctionalTests/TestUtilities/CosmosTestStore.cs +++ b/test/EFCore.Cosmos.FunctionalTests/TestUtilities/CosmosTestStore.cs @@ -630,6 +630,9 @@ public bool HasStoreGeneratedValue(IProperty property) public bool IsModified(IProperty property) => throw new NotImplementedException(); + public bool IsLoaded(IProperty property) + => throw new NotImplementedException(); + public bool IsStoreGenerated(IProperty property) => throw new NotImplementedException(); diff --git a/test/EFCore.Cosmos.Tests/Infrastructure/CosmosModelValidatorTest.cs b/test/EFCore.Cosmos.Tests/Infrastructure/CosmosModelValidatorTest.cs index 1945d63ed98..4bf4a2ae0f5 100644 --- a/test/EFCore.Cosmos.Tests/Infrastructure/CosmosModelValidatorTest.cs +++ b/test/EFCore.Cosmos.Tests/Infrastructure/CosmosModelValidatorTest.cs @@ -615,6 +615,27 @@ public virtual void Passes_with_valid_triggers() Validate(modelBuilder); } + [ConditionalFact] + public virtual void Detects_cosmos_property_not_auto_loaded() + { + var modelBuilder = CreateConventionModelBuilder(); + + modelBuilder.Entity( + eb => + { + eb.Property(e => e.Name); + eb.Property(e => e.PartitionId); + }); + + var model = modelBuilder.Model; + var property = model.FindEntityType(typeof(Customer))!.FindProperty(nameof(Customer.Name))!; + property.IsAutoLoaded = false; + + VerifyError( + CosmosStrings.AutoLoadedCosmosProperty(nameof(Customer.Name), nameof(Customer)), + modelBuilder); + } + protected class SpecialCustomer : Customer { public string SpecialProperty { get; set; } diff --git a/test/EFCore.InMemory.FunctionalTests/Scaffolding/Baselines/BigModel/ManyTypesEntityType.cs b/test/EFCore.InMemory.FunctionalTests/Scaffolding/Baselines/BigModel/ManyTypesEntityType.cs index 47ff742e3f7..5dd899344ac 100644 --- a/test/EFCore.InMemory.FunctionalTests/Scaffolding/Baselines/BigModel/ManyTypesEntityType.cs +++ b/test/EFCore.InMemory.FunctionalTests/Scaffolding/Baselines/BigModel/ManyTypesEntityType.cs @@ -13007,7 +13007,8 @@ public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType bas typeof(string), propertyInfo: typeof(CompiledModelTestBase.ManyTypes).GetProperty("NullableString", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), fieldInfo: typeof(CompiledModelTestBase.ManyTypes).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), - nullable: true); + nullable: true, + autoLoaded: false); nullableString.SetGetter( string (CompiledModelTestBase.ManyTypes instance) => ManyTypesUnsafeAccessors.NullableString(instance), bool (CompiledModelTestBase.ManyTypes instance) => ManyTypesUnsafeAccessors.NullableString(instance) == null); diff --git a/test/EFCore.InMemory.FunctionalTests/Scaffolding/Baselines/No_NativeAOT/ManyTypesEntityType.cs b/test/EFCore.InMemory.FunctionalTests/Scaffolding/Baselines/No_NativeAOT/ManyTypesEntityType.cs index 50f899d64e3..ff71c00cf9e 100644 --- a/test/EFCore.InMemory.FunctionalTests/Scaffolding/Baselines/No_NativeAOT/ManyTypesEntityType.cs +++ b/test/EFCore.InMemory.FunctionalTests/Scaffolding/Baselines/No_NativeAOT/ManyTypesEntityType.cs @@ -1567,7 +1567,8 @@ public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType bas typeof(string), propertyInfo: typeof(CompiledModelTestBase.ManyTypes).GetProperty("NullableString", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), fieldInfo: typeof(CompiledModelTestBase.ManyTypes).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), - nullable: true); + nullable: true, + autoLoaded: false); var nullableStringArray = runtimeEntityType.AddProperty( "NullableStringArray", diff --git a/test/EFCore.Relational.Specification.Tests/Scaffolding/CompiledModelRelationalTestBase.cs b/test/EFCore.Relational.Specification.Tests/Scaffolding/CompiledModelRelationalTestBase.cs index e16b309e589..784fd6c2d1d 100644 --- a/test/EFCore.Relational.Specification.Tests/Scaffolding/CompiledModelRelationalTestBase.cs +++ b/test/EFCore.Relational.Specification.Tests/Scaffolding/CompiledModelRelationalTestBase.cs @@ -143,6 +143,7 @@ protected override void AssertBigModel(IModel model, bool jsonColumns) Assert.Throws(model.GetCollation).Message); var manyTypesType = model.FindEntityType(typeof(ManyTypes))!; + Assert.False(manyTypesType.FindProperty(nameof(ManyTypes.NullableString))!.IsAutoLoaded); Assert.Equal("ManyTypes", manyTypesType.GetTableName()); Assert.Null(manyTypesType.GetSchema()); diff --git a/test/EFCore.Relational.Specification.Tests/Update/NonSharedModelUpdatesTestBase.cs b/test/EFCore.Relational.Specification.Tests/Update/NonSharedModelUpdatesTestBase.cs index fa2c97c10df..59a8aece5ea 100644 --- a/test/EFCore.Relational.Specification.Tests/Update/NonSharedModelUpdatesTestBase.cs +++ b/test/EFCore.Relational.Specification.Tests/Update/NonSharedModelUpdatesTestBase.cs @@ -124,6 +124,112 @@ public class Blog public string? Name { get; set; } } + [ConditionalTheory, MemberData(nameof(IsAsyncData))] + public virtual async Task Update_entity_with_not_loaded_property_excludes_column_from_SQL(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 = "Original description" }); + await context.SaveChangesAsync(); + }); + + await ExecuteWithStrategyInTransactionAsync( + contextFactory, + async context => + { + var blog = new BlogWithDescription { Id = 1, Name = "Updated Blog" }; + context.Update(blog); + + var entry = context.Entry(blog); + // Description starts as not-loaded (IsAutoLoaded = false) + Assert.False(entry.Property(e => e.Description).IsLoaded); + Assert.False(entry.Property(e => e.Description).IsModified); + if (async) + { + await context.SaveChangesAsync(); + } + else + { + context.SaveChanges(); + } + }, + async context => + { + var blog = await context.Set().SingleAsync(); + Assert.Equal("Updated Blog", blog.Name); + Assert.Equal("Original description", blog.Description); + }); + } + + [ConditionalTheory, MemberData(nameof(IsAsyncData))] + public virtual async Task Save_and_query_with_partially_loaded_primitive_collection(bool async) + { + var contextFactory = await InitializeNonSharedTest( + onModelCreating: mb => + { + mb.Entity( + b => + { + b.Property(e => e.Tags).Metadata.IsAutoLoaded = false; + }); + }, + seed: async context => + { + context.Add(new BlogWithTags { Name = "EF Blog", Tags = ["efcore", "dotnet"] }); + await context.SaveChangesAsync(); + }); + + await ExecuteWithStrategyInTransactionAsync( + contextFactory, + async context => + { + var blog = new BlogWithTags { Id = 1, Name = "Updated Blog" }; + context.Update(blog); + + var entry = context.Entry(blog); + Assert.False(entry.Property(e => e.Tags).IsLoaded); + Assert.False(entry.Property(e => e.Tags).IsModified); + + if (async) + { + await context.SaveChangesAsync(); + } + else + { + context.SaveChanges(); + } + }, + async context => + { + var blog = await context.Set().SingleAsync(); + Assert.Equal("Updated Blog", blog.Name); + Assert.Equal(new[] { "efcore", "dotnet" }, blog.Tags); + }); + } + + private class BlogWithDescription + { + public int Id { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } + } + + private class BlogWithTags + { + public int Id { get; set; } + public string? Name { get; set; } + public List Tags { get; set; } = []; + } + [ConditionalTheory, MemberData(nameof(IsAsyncData))] // Issue #36059 public virtual async Task Replacing_owned_entity_with_FK_to_another_entity(bool async) { diff --git a/test/EFCore.Relational.Tests/Infrastructure/RelationalModelValidatorTest.cs b/test/EFCore.Relational.Tests/Infrastructure/RelationalModelValidatorTest.cs index 0eb2ae524f5..8e5c381f46d 100644 --- a/test/EFCore.Relational.Tests/Infrastructure/RelationalModelValidatorTest.cs +++ b/test/EFCore.Relational.Tests/Infrastructure/RelationalModelValidatorTest.cs @@ -3868,6 +3868,43 @@ public static IQueryable MethodF() => throw new NotImplementedException(); } + [ConditionalFact] + public virtual void Detects_json_mapped_property_not_auto_loaded() + { + var modelBuilder = CreateConventionModelBuilder(); + + modelBuilder.Entity( + eb => + { + eb.OwnsOne( + e => e.Owned, ob => + { + ob.Property(e => e.Details); + }); + }); + + var model = modelBuilder.Model; + var ownedType = model.FindEntityType(typeof(AutoLoadJsonOwned))!; + ownedType.SetContainerColumnName("Owned"); + var property = ownedType.FindProperty(nameof(AutoLoadJsonOwned.Details))!; + property.IsAutoLoaded = false; + + VerifyError( + RelationalStrings.AutoLoadedJsonProperty(nameof(AutoLoadJsonOwned.Details), ownedType.DisplayName()), + modelBuilder); + } + + protected class AutoLoadJsonPrincipal + { + public int Id { get; set; } + public AutoLoadJsonOwned Owned { get; set; } = null!; + } + + protected class AutoLoadJsonOwned + { + public string Details { get; set; } = null!; + } + protected virtual TestHelpers.TestModelBuilder CreateModelBuilderWithoutConvention(bool sensitiveDataLoggingEnabled = false) => TestHelpers.CreateConventionBuilder( CreateModelLogger(sensitiveDataLoggingEnabled), CreateValidationLogger(sensitiveDataLoggingEnabled), diff --git a/test/EFCore.Specification.Tests/Scaffolding/CompiledModelTestBase.cs b/test/EFCore.Specification.Tests/Scaffolding/CompiledModelTestBase.cs index 38084cfa33b..703cae0bf88 100644 --- a/test/EFCore.Specification.Tests/Scaffolding/CompiledModelTestBase.cs +++ b/test/EFCore.Specification.Tests/Scaffolding/CompiledModelTestBase.cs @@ -268,6 +268,8 @@ protected virtual void BuildBigModel(ModelBuilder modelBuilder, bool jsonColumns b.Property(e => e.TimeSpanToTicksConverterProperty).HasConversion(); b.Property(e => e.UriToStringConverterProperty).HasConversion(); b.Property(e => e.NullIntToNullStringConverterProperty).HasConversion(); + + b.Property(e => e.NullableString).Metadata.IsAutoLoaded = false; }); } @@ -283,6 +285,9 @@ protected virtual void AssertBigModel(IModel model, bool jsonColumns) Assert.Null(manyTypesType.FindIndexerPropertyInfo()); Assert.Equal(ChangeTrackingStrategy.Snapshot, manyTypesType.GetChangeTrackingStrategy()); + var stringProp = manyTypesType.FindProperty(nameof(ManyTypes.NullableString))!; + Assert.False(stringProp.IsAutoLoaded); + var ipAddressCollection = manyTypesType.FindProperty(nameof(ManyTypes.IPAddressReadOnlyCollection)); if (ipAddressCollection != null) { diff --git a/test/EFCore.SqlServer.FunctionalTests/Scaffolding/Baselines/BigModel/ManyTypesEntityType.cs b/test/EFCore.SqlServer.FunctionalTests/Scaffolding/Baselines/BigModel/ManyTypesEntityType.cs index 622d5f6f1b4..55c2201c361 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Scaffolding/Baselines/BigModel/ManyTypesEntityType.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Scaffolding/Baselines/BigModel/ManyTypesEntityType.cs @@ -13083,7 +13083,8 @@ public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType bas typeof(string), propertyInfo: typeof(CompiledModelTestBase.ManyTypes).GetProperty("NullableString", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), fieldInfo: typeof(CompiledModelTestBase.ManyTypes).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), - nullable: true); + nullable: true, + autoLoaded: false); nullableString.SetGetter( string (CompiledModelTestBase.ManyTypes instance) => ManyTypesUnsafeAccessors.NullableString(instance), bool (CompiledModelTestBase.ManyTypes instance) => ManyTypesUnsafeAccessors.NullableString(instance) == null); diff --git a/test/EFCore.SqlServer.FunctionalTests/Scaffolding/Baselines/BigModel_with_JSON_columns/ManyTypesEntityType.cs b/test/EFCore.SqlServer.FunctionalTests/Scaffolding/Baselines/BigModel_with_JSON_columns/ManyTypesEntityType.cs index 622d5f6f1b4..55c2201c361 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Scaffolding/Baselines/BigModel_with_JSON_columns/ManyTypesEntityType.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Scaffolding/Baselines/BigModel_with_JSON_columns/ManyTypesEntityType.cs @@ -13083,7 +13083,8 @@ public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType bas typeof(string), propertyInfo: typeof(CompiledModelTestBase.ManyTypes).GetProperty("NullableString", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), fieldInfo: typeof(CompiledModelTestBase.ManyTypes).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), - nullable: true); + nullable: true, + autoLoaded: false); nullableString.SetGetter( string (CompiledModelTestBase.ManyTypes instance) => ManyTypesUnsafeAccessors.NullableString(instance), bool (CompiledModelTestBase.ManyTypes instance) => ManyTypesUnsafeAccessors.NullableString(instance) == null); diff --git a/test/EFCore.SqlServer.FunctionalTests/Scaffolding/Baselines/No_NativeAOT/ManyTypesEntityType.cs b/test/EFCore.SqlServer.FunctionalTests/Scaffolding/Baselines/No_NativeAOT/ManyTypesEntityType.cs index 8e8c4fa5fbf..c76e8641220 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Scaffolding/Baselines/No_NativeAOT/ManyTypesEntityType.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Scaffolding/Baselines/No_NativeAOT/ManyTypesEntityType.cs @@ -1632,7 +1632,8 @@ public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType bas typeof(string), propertyInfo: typeof(CompiledModelTestBase.ManyTypes).GetProperty("NullableString", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), fieldInfo: typeof(CompiledModelTestBase.ManyTypes).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), - nullable: true); + nullable: true, + autoLoaded: false); nullableString.AddAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.None); var nullableStringArray = runtimeEntityType.AddProperty( diff --git a/test/EFCore.SqlServer.FunctionalTests/Update/NonSharedModelUpdatesSqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Update/NonSharedModelUpdatesSqlServerTest.cs index c222d2a1386..4c694f86785 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Update/NonSharedModelUpdatesSqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Update/NonSharedModelUpdatesSqlServerTest.cs @@ -195,6 +195,50 @@ WHEN NOT MATCHED THEN private void AssertSql(params string[] expected) => TestSqlLoggerFactory.AssertBaseline(expected); + public override async Task Update_entity_with_not_loaded_property_excludes_column_from_SQL(bool async) + { + await base.Update_entity_with_not_loaded_property_excludes_column_from_SQL(async); + + AssertSql( + """ +@p1='1' +@p0='Updated Blog' (Size = 4000) + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +UPDATE [BlogWithDescription] SET [Name] = @p0 +OUTPUT 1 +WHERE [Id] = @p1; +""", + // + """ +SELECT TOP(2) [b].[Id], [b].[Description], [b].[Name] +FROM [BlogWithDescription] AS [b] +"""); + } + + public override async Task Save_and_query_with_partially_loaded_primitive_collection(bool async) + { + await base.Save_and_query_with_partially_loaded_primitive_collection(async); + + AssertSql( + """ +@p1='1' +@p0='Updated Blog' (Size = 4000) + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +UPDATE [BlogWithTags] SET [Name] = @p0 +OUTPUT 1 +WHERE [Id] = @p1; +""", + // + """ +SELECT TOP(2) [b].[Id], [b].[Name], [b].[Tags] +FROM [BlogWithTags] AS [b] +"""); + } + protected override ITestStoreFactory NonSharedTestStoreFactory => SqlServerTestStoreFactory.Instance; } diff --git a/test/EFCore.SqlServer.Tests/Metadata/Conventions/SqlServerAutoLoadConventionTest.cs b/test/EFCore.SqlServer.Tests/Metadata/Conventions/SqlServerAutoLoadConventionTest.cs new file mode 100644 index 00000000000..4ae71a5ecf5 --- /dev/null +++ b/test/EFCore.SqlServer.Tests/Metadata/Conventions/SqlServerAutoLoadConventionTest.cs @@ -0,0 +1,90 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Data.SqlTypes; + +namespace Microsoft.EntityFrameworkCore.Metadata.Conventions; + +#nullable enable + +public class SqlServerAutoLoadConventionTest +{ + [ConditionalFact] + public void Vector_property_configured_as_not_auto_loaded_by_convention() + { + var modelBuilder = SqlServerTestHelpers.Instance.CreateConventionBuilder(); + modelBuilder.Entity( + b => + { + b.Property(e => e.Vector).HasColumnType("vector(3)"); + b.Property(e => e.Name); + }); + + var model = modelBuilder.FinalizeModel(); + + var entityType = model.FindEntityType(typeof(EntityWithVector))!; + Assert.False(entityType.FindProperty(nameof(EntityWithVector.Vector))!.IsAutoLoaded); + } + + [ConditionalFact] + public void Vector_property_can_be_manually_configured_as_not_auto_loaded() + { + var modelBuilder = SqlServerTestHelpers.Instance.CreateConventionBuilder(); + modelBuilder.Entity( + b => + { + b.Property(e => e.Vector).HasColumnType("vector(3)"); + b.Property(e => e.Name); + }); + + var model = modelBuilder.Model; + var property = model.FindEntityType(typeof(EntityWithVector))!.FindProperty(nameof(EntityWithVector.Vector))!; + property.IsAutoLoaded = false; + + var finalModel = modelBuilder.FinalizeModel(); + Assert.False(finalModel.FindEntityType(typeof(EntityWithVector))!.FindProperty(nameof(EntityWithVector.Vector))!.IsAutoLoaded); + } + + [ConditionalFact] + public void Explicit_auto_load_overrides_convention() + { + var modelBuilder = SqlServerTestHelpers.Instance.CreateConventionBuilder(); + modelBuilder.Entity( + b => + { + b.Property(e => e.Vector).HasColumnType("vector(3)"); + b.Property(e => e.Name); + }); + + var model = modelBuilder.Model; + var property = model.FindEntityType(typeof(EntityWithVector))!.FindProperty(nameof(EntityWithVector.Vector))!; + property.IsAutoLoaded = true; + + var finalModel = modelBuilder.FinalizeModel(); + Assert.True(finalModel.FindEntityType(typeof(EntityWithVector))!.FindProperty(nameof(EntityWithVector.Vector))!.IsAutoLoaded); + } + + [ConditionalFact] + public void Non_vector_property_remains_auto_loaded() + { + var modelBuilder = SqlServerTestHelpers.Instance.CreateConventionBuilder(); + modelBuilder.Entity( + b => + { + b.Property(e => e.Vector).HasColumnType("vector(3)"); + b.Property(e => e.Name); + }); + + var model = modelBuilder.FinalizeModel(); + + var entityType = model.FindEntityType(typeof(EntityWithVector))!; + Assert.True(entityType.FindProperty(nameof(EntityWithVector.Name))!.IsAutoLoaded); + } + + private class EntityWithVector + { + public int Id { get; set; } + public string? Name { get; set; } + public SqlVector Vector { get; set; } + } +} diff --git a/test/EFCore.Sqlite.FunctionalTests/Scaffolding/Baselines/BigModel/ManyTypesEntityType.cs b/test/EFCore.Sqlite.FunctionalTests/Scaffolding/Baselines/BigModel/ManyTypesEntityType.cs index 816b1ffef03..280d6e6eaf1 100644 --- a/test/EFCore.Sqlite.FunctionalTests/Scaffolding/Baselines/BigModel/ManyTypesEntityType.cs +++ b/test/EFCore.Sqlite.FunctionalTests/Scaffolding/Baselines/BigModel/ManyTypesEntityType.cs @@ -12056,7 +12056,8 @@ public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType bas typeof(string), propertyInfo: typeof(CompiledModelTestBase.ManyTypes).GetProperty("NullableString", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), fieldInfo: typeof(CompiledModelTestBase.ManyTypes).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), - nullable: true); + nullable: true, + autoLoaded: false); nullableString.SetGetter( string (CompiledModelTestBase.ManyTypes instance) => ManyTypesUnsafeAccessors.NullableString(instance), bool (CompiledModelTestBase.ManyTypes instance) => ManyTypesUnsafeAccessors.NullableString(instance) == null); diff --git a/test/EFCore.Sqlite.FunctionalTests/Scaffolding/Baselines/BigModel_with_JSON_columns/ManyTypesEntityType.cs b/test/EFCore.Sqlite.FunctionalTests/Scaffolding/Baselines/BigModel_with_JSON_columns/ManyTypesEntityType.cs index 816b1ffef03..280d6e6eaf1 100644 --- a/test/EFCore.Sqlite.FunctionalTests/Scaffolding/Baselines/BigModel_with_JSON_columns/ManyTypesEntityType.cs +++ b/test/EFCore.Sqlite.FunctionalTests/Scaffolding/Baselines/BigModel_with_JSON_columns/ManyTypesEntityType.cs @@ -12056,7 +12056,8 @@ public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType bas typeof(string), propertyInfo: typeof(CompiledModelTestBase.ManyTypes).GetProperty("NullableString", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), fieldInfo: typeof(CompiledModelTestBase.ManyTypes).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), - nullable: true); + nullable: true, + autoLoaded: false); nullableString.SetGetter( string (CompiledModelTestBase.ManyTypes instance) => ManyTypesUnsafeAccessors.NullableString(instance), bool (CompiledModelTestBase.ManyTypes instance) => ManyTypesUnsafeAccessors.NullableString(instance) == null); diff --git a/test/EFCore.Sqlite.FunctionalTests/Scaffolding/Baselines/No_NativeAOT/ManyTypesEntityType.cs b/test/EFCore.Sqlite.FunctionalTests/Scaffolding/Baselines/No_NativeAOT/ManyTypesEntityType.cs index 6247347dc2f..a78785e8cee 100644 --- a/test/EFCore.Sqlite.FunctionalTests/Scaffolding/Baselines/No_NativeAOT/ManyTypesEntityType.cs +++ b/test/EFCore.Sqlite.FunctionalTests/Scaffolding/Baselines/No_NativeAOT/ManyTypesEntityType.cs @@ -1444,7 +1444,8 @@ public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType bas typeof(string), propertyInfo: typeof(CompiledModelTestBase.ManyTypes).GetProperty("NullableString", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), fieldInfo: typeof(CompiledModelTestBase.ManyTypes).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), - nullable: true); + nullable: true, + autoLoaded: false); var nullableStringArray = runtimeEntityType.AddProperty( "NullableStringArray", diff --git a/test/EFCore.Tests/ChangeTracking/Internal/StateDataTest.cs b/test/EFCore.Tests/ChangeTracking/Internal/StateDataTest.cs index 54da7a148b1..9a930b4a890 100644 --- a/test/EFCore.Tests/ChangeTracking/Internal/StateDataTest.cs +++ b/test/EFCore.Tests/ChangeTracking/Internal/StateDataTest.cs @@ -17,7 +17,8 @@ public void Can_read_and_manipulate_modification_flags() InternalEntryBase.PropertyFlag.Unknown, InternalEntryBase.PropertyFlag.IsLoaded, InternalEntryBase.PropertyFlag.IsTemporary, - InternalEntryBase.PropertyFlag.IsStoreGenerated); + InternalEntryBase.PropertyFlag.IsStoreGenerated, + InternalEntryBase.PropertyFlag.IsPropertyNotLoaded); } } @@ -33,7 +34,8 @@ public void Can_read_and_manipulate_null_flags() InternalEntryBase.PropertyFlag.Unknown, InternalEntryBase.PropertyFlag.IsLoaded, InternalEntryBase.PropertyFlag.IsTemporary, - InternalEntryBase.PropertyFlag.IsStoreGenerated); + InternalEntryBase.PropertyFlag.IsStoreGenerated, + InternalEntryBase.PropertyFlag.IsPropertyNotLoaded); } } @@ -49,7 +51,8 @@ public void Can_read_and_manipulate_not_set_flags() InternalEntryBase.PropertyFlag.Null, InternalEntryBase.PropertyFlag.IsLoaded, InternalEntryBase.PropertyFlag.IsTemporary, - InternalEntryBase.PropertyFlag.IsStoreGenerated); + InternalEntryBase.PropertyFlag.IsStoreGenerated, + InternalEntryBase.PropertyFlag.IsPropertyNotLoaded); } } @@ -65,7 +68,8 @@ public void Can_read_and_manipulate_is_loaded_flags() InternalEntryBase.PropertyFlag.Null, InternalEntryBase.PropertyFlag.Unknown, InternalEntryBase.PropertyFlag.IsTemporary, - InternalEntryBase.PropertyFlag.IsStoreGenerated); + InternalEntryBase.PropertyFlag.IsStoreGenerated, + InternalEntryBase.PropertyFlag.IsPropertyNotLoaded); } } @@ -81,7 +85,8 @@ public void Can_read_and_manipulate_temporary_flags() InternalEntryBase.PropertyFlag.Modified, InternalEntryBase.PropertyFlag.Null, InternalEntryBase.PropertyFlag.Unknown, - InternalEntryBase.PropertyFlag.IsStoreGenerated); + InternalEntryBase.PropertyFlag.IsStoreGenerated, + InternalEntryBase.PropertyFlag.IsPropertyNotLoaded); } } @@ -97,7 +102,25 @@ public void Can_read_and_manipulate_store_generated_flags() InternalEntryBase.PropertyFlag.Modified, InternalEntryBase.PropertyFlag.Null, InternalEntryBase.PropertyFlag.Unknown, - InternalEntryBase.PropertyFlag.IsTemporary); + InternalEntryBase.PropertyFlag.IsTemporary, + InternalEntryBase.PropertyFlag.IsPropertyNotLoaded); + } + } + + [ConditionalFact] + public void Can_read_and_manipulate_is_property_loaded_flags() + { + for (var i = 0; i < 70; i++) + { + PropertyManipulation( + i, + InternalEntryBase.PropertyFlag.IsPropertyNotLoaded, + InternalEntryBase.PropertyFlag.Modified, + InternalEntryBase.PropertyFlag.Null, + InternalEntryBase.PropertyFlag.Unknown, + InternalEntryBase.PropertyFlag.IsLoaded, + InternalEntryBase.PropertyFlag.IsTemporary, + InternalEntryBase.PropertyFlag.IsStoreGenerated); } } @@ -108,7 +131,8 @@ private void PropertyManipulation( InternalEntryBase.PropertyFlag unusedFlag2, InternalEntryBase.PropertyFlag unusedFlag3, InternalEntryBase.PropertyFlag unusedFlag4, - InternalEntryBase.PropertyFlag unusedFlag5) + InternalEntryBase.PropertyFlag unusedFlag5, + InternalEntryBase.PropertyFlag unusedFlag6) { var data = new InternalEntryBase.StateData(propertyCount, propertyCount); @@ -118,6 +142,7 @@ private void PropertyManipulation( Assert.False(data.AnyPropertiesFlagged(unusedFlag3)); Assert.False(data.AnyPropertiesFlagged(unusedFlag4)); Assert.False(data.AnyPropertiesFlagged(unusedFlag5)); + Assert.False(data.AnyPropertiesFlagged(unusedFlag6)); for (var i = 0; i < propertyCount; i++) { @@ -131,6 +156,7 @@ private void PropertyManipulation( Assert.False(data.IsPropertyFlagged(j, unusedFlag3)); Assert.False(data.IsPropertyFlagged(j, unusedFlag4)); Assert.False(data.IsPropertyFlagged(j, unusedFlag5)); + Assert.False(data.IsPropertyFlagged(j, unusedFlag6)); } Assert.True(data.AnyPropertiesFlagged(propertyFlag)); @@ -139,6 +165,7 @@ private void PropertyManipulation( Assert.False(data.AnyPropertiesFlagged(unusedFlag3)); Assert.False(data.AnyPropertiesFlagged(unusedFlag4)); Assert.False(data.AnyPropertiesFlagged(unusedFlag5)); + Assert.False(data.AnyPropertiesFlagged(unusedFlag6)); } for (var i = 0; i < propertyCount; i++) @@ -153,6 +180,7 @@ private void PropertyManipulation( Assert.False(data.IsPropertyFlagged(j, unusedFlag3)); Assert.False(data.IsPropertyFlagged(j, unusedFlag4)); Assert.False(data.IsPropertyFlagged(j, unusedFlag5)); + Assert.False(data.IsPropertyFlagged(j, unusedFlag6)); } Assert.Equal(i < propertyCount - 1, data.AnyPropertiesFlagged(propertyFlag)); @@ -161,6 +189,7 @@ private void PropertyManipulation( Assert.False(data.AnyPropertiesFlagged(unusedFlag3)); Assert.False(data.AnyPropertiesFlagged(unusedFlag4)); Assert.False(data.AnyPropertiesFlagged(unusedFlag5)); + Assert.False(data.AnyPropertiesFlagged(unusedFlag6)); } for (var i = 0; i < propertyCount; i++) @@ -171,6 +200,7 @@ private void PropertyManipulation( Assert.False(data.IsPropertyFlagged(i, unusedFlag3)); Assert.False(data.IsPropertyFlagged(i, unusedFlag4)); Assert.False(data.IsPropertyFlagged(i, unusedFlag5)); + Assert.False(data.IsPropertyFlagged(i, unusedFlag6)); } data.FlagAllProperties(propertyCount, propertyFlag, flagged: true); @@ -181,6 +211,7 @@ private void PropertyManipulation( Assert.False(data.AnyPropertiesFlagged(unusedFlag3)); Assert.False(data.AnyPropertiesFlagged(unusedFlag4)); Assert.False(data.AnyPropertiesFlagged(unusedFlag5)); + Assert.False(data.AnyPropertiesFlagged(unusedFlag6)); for (var i = 0; i < propertyCount; i++) { @@ -190,6 +221,7 @@ private void PropertyManipulation( Assert.False(data.IsPropertyFlagged(i, unusedFlag3)); Assert.False(data.IsPropertyFlagged(i, unusedFlag4)); Assert.False(data.IsPropertyFlagged(i, unusedFlag5)); + Assert.False(data.IsPropertyFlagged(i, unusedFlag6)); } data.FlagAllProperties(propertyCount, propertyFlag, flagged: false); @@ -200,6 +232,7 @@ private void PropertyManipulation( Assert.False(data.AnyPropertiesFlagged(unusedFlag3)); Assert.False(data.AnyPropertiesFlagged(unusedFlag4)); Assert.False(data.AnyPropertiesFlagged(unusedFlag5)); + Assert.False(data.AnyPropertiesFlagged(unusedFlag6)); for (var i = 0; i < propertyCount; i++) { @@ -209,6 +242,7 @@ private void PropertyManipulation( Assert.False(data.IsPropertyFlagged(i, unusedFlag3)); Assert.False(data.IsPropertyFlagged(i, unusedFlag4)); Assert.False(data.IsPropertyFlagged(i, unusedFlag5)); + Assert.False(data.IsPropertyFlagged(i, unusedFlag6)); } } diff --git a/test/EFCore.Tests/ChangeTracking/PropertyEntryTest.cs b/test/EFCore.Tests/ChangeTracking/PropertyEntryTest.cs index c84ac274908..bb940558c2f 100644 --- a/test/EFCore.Tests/ChangeTracking/PropertyEntryTest.cs +++ b/test/EFCore.Tests/ChangeTracking/PropertyEntryTest.cs @@ -4883,4 +4883,381 @@ private struct FieldTog { public string? Text; } + + #region IsAutoLoaded / IsLoaded change tracking tests + + public static IEnumerable TrackingMethodData + => [[EntityState.Added], [EntityState.Unchanged], [EntityState.Modified], [EntityState.Deleted]]; + + private static void TrackEntity(DbContext context, object entity, EntityState state) + => context.Entry(entity).State = state; + + [ConditionalTheory] + [MemberData(nameof(TrackingMethodData))] + public void Not_auto_loaded_property_initial_state(EntityState state) + { + using var context = new AutoLoadContext(); + var entity = new AutoLoadBlog { Id = 1, Name = "EF Blog" }; + TrackEntity(context, entity, state); + + var entry = context.Entry(entity); + var nameEntry = entry.Property(e => e.Name); + var descEntry = entry.Property(e => e.Description); + + // Normal property is always loaded + Assert.True(nameEntry.IsLoaded); + + // Not-auto-loaded property with sentinel value is not loaded for all tracking methods + Assert.False(descEntry.IsLoaded); + Assert.False(descEntry.IsModified); + } + + [ConditionalTheory] + [MemberData(nameof(TrackingMethodData))] + public void Setting_value_on_not_loaded_property_marks_loaded_and_modified(EntityState state) + { + using var context = new AutoLoadContext(); + var entity = new AutoLoadBlog { Id = 1, Name = "EF Blog" }; + TrackEntity(context, entity, state); + + var entry = context.Entry(entity); + var descEntry = entry.Property(e => e.Description); + + Assert.False(descEntry.IsLoaded); + + descEntry.CurrentValue = "Updated description"; + + if (state == EntityState.Deleted) + { + // PropertyChanged skips SetPropertyModified for Deleted entities + Assert.False(descEntry.IsLoaded); + Assert.False(descEntry.IsModified); + } + else + { + Assert.True(descEntry.IsLoaded); + + // In Added state, IsModified is always false + Assert.Equal(state != EntityState.Added, descEntry.IsModified); + } + } + + [ConditionalTheory] + [MemberData(nameof(TrackingMethodData))] + public void DetectChanges_skips_not_loaded_property_detects_loaded_change(EntityState state) + { + using var context = new AutoLoadContext(); + var entity = new AutoLoadBlog { Id = 1, Name = "EF Blog" }; + TrackEntity(context, entity, state); + + if (state is EntityState.Added or EntityState.Deleted) + { + // In Added/Deleted state, DetectChanges doesn't flag properties as modified + return; + } + + // Modify the loaded property directly + entity.Name = "Updated Blog"; + + context.ChangeTracker.DetectChanges(); + + var entry = context.Entry(entity); + Assert.True(entry.Property(e => e.Name).IsModified); + Assert.False(entry.Property(e => e.Description).IsModified); + Assert.False(entry.Property(e => e.Description).IsLoaded); + Assert.Equal(EntityState.Modified, entry.State); + } + + [ConditionalTheory] + [MemberData(nameof(TrackingMethodData))] + public void DetectChanges_marks_not_loaded_property_as_loaded_when_value_is_no_longer_sentinel(EntityState state) + { + using var context = new AutoLoadContext(); + var entity = new AutoLoadBlog { Id = 1, Name = "EF Blog" }; + TrackEntity(context, entity, state); + + if (state is EntityState.Added or EntityState.Deleted) + { + return; + } + + var entry = context.Entry(entity); + Assert.False(entry.Property(e => e.Description).IsLoaded); + + // Set the unloaded property to a non-sentinel value directly on the entity + entity.Description = "Now has a value"; + + context.ChangeTracker.DetectChanges(); + + Assert.True(entry.Property(e => e.Description).IsLoaded); + Assert.True(entry.Property(e => e.Description).IsModified); + Assert.Equal(EntityState.Modified, entry.State); + } + + [ConditionalTheory] + [MemberData(nameof(TrackingMethodData))] + public void SetEntityState_Modified_skips_not_loaded_properties(EntityState state) + { + using var context = new AutoLoadContext(); + var entity = new AutoLoadBlog { Id = 1, Name = "EF Blog" }; + TrackEntity(context, entity, state); + + var entry = context.Entry(entity); + entry.State = EntityState.Modified; + + // Loaded properties should be flagged as modified + Assert.True(entry.Property(e => e.Name).IsModified); + + // Not-loaded properties (sentinel value) should NOT be flagged as modified + Assert.False(entry.Property(e => e.Description).IsModified); + Assert.False(entry.Property(e => e.Description).IsLoaded); + } + + [ConditionalTheory] + [MemberData(nameof(TrackingMethodData))] + public void Not_auto_loaded_property_starts_unloaded(EntityState state) + { + using var context = new AutoLoadContext(); + + // Entity where the not-auto-loaded property has sentinel (null) + var entity1 = new AutoLoadBlog { Id = 1, Name = "Blog1" }; + TrackEntity(context, entity1, state); + Assert.False(context.Entry(entity1).Property(e => e.Description).IsLoaded); + Assert.False(context.Entry(entity1).Property(e => e.Description).IsModified); + + // Entity where the not-auto-loaded property has a non-sentinel value + var entity2 = new AutoLoadBlog { Id = 2, Name = "Blog2", Description = "Has value" }; + TrackEntity(context, entity2, state); + + // Non-sentinel value: sentinel check detects a real value, so property is loaded + Assert.True(context.Entry(entity2).Property(e => e.Description).IsLoaded); + Assert.Equal(state == EntityState.Modified, context.Entry(entity2).Property(e => e.Description).IsModified); + } + + [ConditionalTheory] + [MemberData(nameof(TrackingMethodData))] + public void AcceptChanges_preserves_not_loaded_flag(EntityState state) + { + using var context = new AutoLoadContext(); + var entity = new AutoLoadBlog { Id = 1, Name = "EF Blog" }; + TrackEntity(context, entity, state); + + var entry = context.Entry(entity); + Assert.False(entry.Property(e => e.Description).IsLoaded); + + context.ChangeTracker.AcceptAllChanges(); + + if (state == EntityState.Deleted) + { + Assert.Equal(EntityState.Detached, entry.State); + } + else + { + Assert.Equal(EntityState.Unchanged, entry.State); + Assert.False(entry.Property(e => e.Description).IsLoaded); + Assert.False(entry.Property(e => e.Description).IsModified); + } + } + + [ConditionalTheory] + [MemberData(nameof(TrackingMethodData))] + public void PropertyEntry_IsLoaded_setter_controls_loaded_state(EntityState state) + { + using var context = new AutoLoadContext(); + var entity = new AutoLoadBlog { Id = 1, Name = "EF Blog" }; + TrackEntity(context, entity, state); + + var entry = context.Entry(entity); + var descEntry = entry.Property(e => e.Description); + + Assert.False(descEntry.IsLoaded); + descEntry.IsLoaded = true; + Assert.True(descEntry.IsLoaded); + descEntry.IsLoaded = false; + Assert.False(descEntry.IsLoaded); + } + + [ConditionalTheory] + [MemberData(nameof(TrackingMethodData))] + public void Setting_IsModified_true_on_not_loaded_marks_loaded(EntityState state) + { + using var context = new AutoLoadContext(); + var entity = new AutoLoadBlog { Id = 1, Name = "EF Blog" }; + TrackEntity(context, entity, state); + + var entry = context.Entry(entity); + var descEntry = entry.Property(e => e.Description); + + Assert.False(descEntry.IsLoaded); + + descEntry.IsModified = true; + + Assert.True(descEntry.IsLoaded); + Assert.Equal(state is not EntityState.Added and not EntityState.Deleted, descEntry.IsModified); + } + + [ConditionalTheory] + [MemberData(nameof(TrackingMethodData))] + public void Shadow_property_not_auto_loaded_tracked_correctly(EntityState state) + { + using var context = new AutoLoadShadowContext(); + var entity = new AutoLoadShadowEntity { Id = 1, Name = "Test" }; + TrackEntity(context, entity, state); + + var entry = context.Entry(entity); + var shadowEntry = entry.Property("ShadowDesc"); + + Assert.False(shadowEntry.IsLoaded); + Assert.False(shadowEntry.IsModified); + + shadowEntry.CurrentValue = "Shadow value"; + + if (state == EntityState.Deleted) + { + // PropertyChanged skips SetPropertyModified for Deleted entities + Assert.False(shadowEntry.IsLoaded); + Assert.False(shadowEntry.IsModified); + } + else + { + Assert.True(shadowEntry.IsLoaded); + + // In Added state, IsModified is always false + Assert.Equal(state != EntityState.Added, shadowEntry.IsModified); + } + } + + [ConditionalTheory] + [MemberData(nameof(TrackingMethodData))] + public void Reject_changes_with_not_loaded_property(EntityState state) + { + using var context = new AutoLoadContext(); + var entity = new AutoLoadBlog { Id = 1, Name = "EF Blog" }; + TrackEntity(context, entity, state); + + var entry = context.Entry(entity); + Assert.False(entry.Property(e => e.Description).IsLoaded); + + // Reject changes by reverting to Unchanged (or Detached for Added) + entry.State = state == EntityState.Added ? EntityState.Detached : EntityState.Unchanged; + + if (state == EntityState.Added) + { + Assert.Equal(EntityState.Detached, entry.State); + } + else + { + Assert.Equal(EntityState.Unchanged, entry.State); + Assert.False(entry.Property(e => e.Description).IsLoaded); + Assert.False(entry.Property(e => e.Description).IsModified); + } + } + + private class AutoLoadBlog + { + public int Id { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } + } + + private class AutoLoadContext : DbContext + { + protected internal override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder + .UseInternalServiceProvider(InMemoryFixture.DefaultServiceProvider) + .UseInMemoryDatabase(GetType().FullName!); + + protected internal override void OnModelCreating(ModelBuilder modelBuilder) + => modelBuilder.Entity( + b => + { + b.Property(e => e.Name); + b.Property(e => e.Description).Metadata.IsAutoLoaded = false; + }); + } + + private class AutoLoadShadowEntity + { + public int Id { get; set; } + public string? Name { get; set; } + } + + private class AutoLoadShadowContext : DbContext + { + protected internal override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder + .UseInternalServiceProvider(InMemoryFixture.DefaultServiceProvider) + .UseInMemoryDatabase(GetType().FullName!); + + protected internal override void OnModelCreating(ModelBuilder modelBuilder) + => modelBuilder.Entity( + b => + { + b.Property(e => e.Name); + b.Property("ShadowDesc").Metadata.IsAutoLoaded = false; + }); + } + + [ConditionalFact] + public void DetectComplexPropertyChange_skips_not_loaded_inner_properties() + { + using var context = new AutoLoadComplexContext(); + var entity = new AutoLoadComplexEntity { Id = 1, Name = "Test" }; + context.Attach(entity); + + var entry = context.Entry(entity); + + // Address is a nullable complex property, initially null + Assert.Null(entry.ComplexProperty(e => e.Address).CurrentValue); + + // Set the complex property to a non-null value, triggering DetectComplexPropertyChange + entity.Address = new Address { Street = "123 Main St", ZipCode = null }; + context.ChangeTracker.DetectChanges(); + + // Street is auto-loaded, so it should be modified + var streetProperty = entry.ComplexProperty(e => e.Address).Property(e => e.Street); + Assert.True(streetProperty.IsModified); + + // ZipCode is not auto-loaded and has sentinel value, so it should NOT be modified + var zipProperty = entry.ComplexProperty(e => e.Address).Property(e => e.ZipCode); + Assert.False(zipProperty.IsModified); + Assert.False(zipProperty.IsLoaded); + } + + private class Address + { + public string? Street { get; set; } + public string? ZipCode { get; set; } + } + + private class AutoLoadComplexEntity + { + public int Id { get; set; } + public string? Name { get; set; } + public Address? Address { get; set; } + } + + private class AutoLoadComplexContext : DbContext + { + protected internal override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder + .UseInternalServiceProvider(InMemoryFixture.DefaultServiceProvider) + .UseInMemoryDatabase(GetType().FullName!); + + protected internal override void OnModelCreating(ModelBuilder modelBuilder) + => modelBuilder.Entity( + b => + { + b.Property(e => e.Name); + b.ComplexProperty( + e => e.Address, ab => + { + ab.IsRequired(false); + ab.Property(a => a.Street); + ab.Property(a => a.ZipCode).Metadata.IsAutoLoaded = false; + }); + }); + } + + #endregion } diff --git a/test/EFCore.Tests/ExceptionTest.cs b/test/EFCore.Tests/ExceptionTest.cs index e231b50f118..0026c1840a4 100644 --- a/test/EFCore.Tests/ExceptionTest.cs +++ b/test/EFCore.Tests/ExceptionTest.cs @@ -115,6 +115,9 @@ public void SetPropertyModified(IProperty property) public bool IsModified(IProperty property) => throw new NotImplementedException(); + public bool IsLoaded(IProperty property) + => throw new NotImplementedException(); + public bool HasTemporaryValue(IProperty property) => throw new NotImplementedException(); diff --git a/test/EFCore.Tests/Infrastructure/ModelValidatorTest.cs b/test/EFCore.Tests/Infrastructure/ModelValidatorTest.cs index 7ce87479ebf..bf42921f9bd 100644 --- a/test/EFCore.Tests/Infrastructure/ModelValidatorTest.cs +++ b/test/EFCore.Tests/Infrastructure/ModelValidatorTest.cs @@ -2244,4 +2244,149 @@ protected class NonSignedIntegerKeyEntity { public uint Id { get; set; } } + + [ConditionalFact] + public virtual void Detects_key_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(AutoLoadEntity))!.FindProperty(nameof(AutoLoadEntity.Id))!; + property.IsAutoLoaded = false; + + VerifyError( + CoreStrings.AutoLoadedKeyProperty(nameof(AutoLoadEntity.Id), nameof(AutoLoadEntity)), + modelBuilder); + } + + [ConditionalFact] + public virtual void Detects_alternate_key_property_not_auto_loaded() + { + var modelBuilder = CreateConventionModelBuilder(); + modelBuilder.Entity( + eb => + { + eb.Property(e => e.Id); + eb.Property(e => e.Name); + eb.HasAlternateKey(e => e.Name); + }); + + var model = modelBuilder.Model; + var property = model.FindEntityType(typeof(AutoLoadEntity))!.FindProperty(nameof(AutoLoadEntity.Name))!; + property.IsAutoLoaded = false; + + VerifyError( + CoreStrings.AutoLoadedKeyProperty(nameof(AutoLoadEntity.Name), nameof(AutoLoadEntity)), + modelBuilder); + } + + [ConditionalFact] + public virtual void Detects_foreign_key_property_not_auto_loaded() + { + var modelBuilder = CreateConventionModelBuilder(); + modelBuilder.Entity( + eb => + { + eb.HasKey(e => e.Id); + eb.Property(e => e.Name); + }); + modelBuilder.Entity( + eb => + { + eb.HasKey(e => e.Id); + eb.Property(e => e.PrincipalId); + eb.HasOne().WithMany().HasForeignKey(e => e.PrincipalId); + }); + + var model = modelBuilder.Model; + var property = model.FindEntityType(typeof(AutoLoadDependent))!.FindProperty(nameof(AutoLoadDependent.PrincipalId))!; + property.IsAutoLoaded = false; + + VerifyError( + CoreStrings.AutoLoadedForeignKeyProperty(nameof(AutoLoadDependent.PrincipalId), nameof(AutoLoadDependent)), + modelBuilder); + } + + [ConditionalFact] + public virtual void Detects_concurrency_token_not_auto_loaded() + { + var modelBuilder = CreateConventionModelBuilder(); + modelBuilder.Entity( + eb => + { + eb.Property(e => e.Id); + eb.Property(e => e.Name).IsConcurrencyToken(); + }); + + var model = modelBuilder.Model; + var property = model.FindEntityType(typeof(AutoLoadEntity))!.FindProperty(nameof(AutoLoadEntity.Name))!; + property.IsAutoLoaded = false; + + VerifyError( + CoreStrings.AutoLoadedConcurrencyTokenProperty(nameof(AutoLoadEntity.Name), nameof(AutoLoadEntity)), + modelBuilder); + } + + [ConditionalFact] + public virtual void Detects_discriminator_not_auto_loaded() + { + var modelBuilder = CreateConventionModelBuilder(); + modelBuilder.Entity( + eb => + { + eb.Property(e => e.Id); + eb.Property(e => e.Name); + eb.HasDiscriminator(e => e.Name); + }); + + var model = modelBuilder.Model; + var property = model.FindEntityType(typeof(AutoLoadEntity))!.FindProperty(nameof(AutoLoadEntity.Name))!; + property.IsAutoLoaded = false; + + VerifyError( + CoreStrings.AutoLoadedDiscriminatorProperty(nameof(AutoLoadEntity.Name), nameof(AutoLoadEntity)), + modelBuilder); + } + + [ConditionalFact] + public virtual void Allows_non_key_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(AutoLoadEntity))!.FindProperty(nameof(AutoLoadEntity.Name))!; + property.IsAutoLoaded = false; + + Validate(modelBuilder); + } + + protected class AutoLoadEntity + { + public int Id { get; set; } + public string Name { get; set; } = null!; + } + + protected class AutoLoadPrincipal + { + public int Id { get; set; } + public string Name { get; set; } = null!; + } + + protected class AutoLoadDependent + { + public int Id { get; set; } + public int PrincipalId { get; set; } + } } diff --git a/test/EFCore.Tests/Metadata/Conventions/ConventionDispatcherTest.cs b/test/EFCore.Tests/Metadata/Conventions/ConventionDispatcherTest.cs index 6e9ffb9e66d..60187f77bfa 100644 --- a/test/EFCore.Tests/Metadata/Conventions/ConventionDispatcherTest.cs +++ b/test/EFCore.Tests/Metadata/Conventions/ConventionDispatcherTest.cs @@ -3556,6 +3556,137 @@ public void ProcessPropertyNullabilityChanged( } } + [InlineData(false, false), InlineData(true, false), InlineData(false, true), InlineData(true, true), ConditionalTheory] + public void OnPropertyAutoLoadChanged_calls_conventions_in_order(bool useBuilder, bool useScope) + { + var conventions = new ConventionSet(); + + var convention1 = new PropertyAutoLoadChangedConvention(false); + var convention2 = new PropertyAutoLoadChangedConvention(true); + var convention3 = new PropertyAutoLoadChangedConvention(false); + conventions.Add(convention1); + conventions.Add(convention2); + conventions.Add(convention3); + + var model = new Model(conventions); + + var scope = useScope ? model.DelayConventions() : null; + + var propertyBuilder = model.Builder.Entity(typeof(Order), ConfigurationSource.Convention) + .Property(typeof(string), "Name", ConfigurationSource.Convention); + if (useBuilder) + { + propertyBuilder.IsAutoLoaded(false, ConfigurationSource.Convention); + } + else + { + propertyBuilder.Metadata.IsAutoLoaded = false; + } + + if (useScope) + { + Assert.Empty(convention1.Calls); + Assert.Empty(convention2.Calls); + } + else + { + Assert.Equal(new bool?[] { false }, convention1.Calls); + Assert.Equal(new bool?[] { false }, convention2.Calls); + } + + Assert.Empty(convention3.Calls); + + if (useBuilder) + { + propertyBuilder.IsAutoLoaded(true, ConfigurationSource.Convention); + } + else + { + propertyBuilder.Metadata.IsAutoLoaded = true; + } + + if (useScope) + { + Assert.Empty(convention1.Calls); + Assert.Empty(convention2.Calls); + } + else + { + Assert.Equal(new bool?[] { false, true }, convention1.Calls); + Assert.Equal(new bool?[] { false, true }, convention2.Calls); + } + + Assert.Empty(convention3.Calls); + + if (useBuilder) + { + propertyBuilder.IsAutoLoaded(true, ConfigurationSource.Convention); + } + else + { + propertyBuilder.Metadata.IsAutoLoaded = true; + } + + if (useScope) + { + Assert.Empty(convention1.Calls); + Assert.Empty(convention2.Calls); + } + else + { + Assert.Equal(new bool?[] { false, true }, convention1.Calls); + Assert.Equal(new bool?[] { false, true }, convention2.Calls); + } + + Assert.Empty(convention3.Calls); + + if (useBuilder) + { + propertyBuilder.IsAutoLoaded(false, ConfigurationSource.Convention); + } + else + { + propertyBuilder.Metadata.IsAutoLoaded = false; + } + + scope?.Dispose(); + + if (useScope) + { + Assert.Equal(new bool?[] { false, false, false }, convention1.Calls); + Assert.Equal(new bool?[] { false, false, false }, convention2.Calls); + } + else + { + Assert.Equal(new bool?[] { false, true, false }, convention1.Calls); + Assert.Equal(new bool?[] { false, true, false }, convention2.Calls); + } + + Assert.Empty(convention3.Calls); + + AssertSetOperations( + new PropertyAutoLoadChangedConvention(terminate: true), + conventions, conventions.PropertyAutoLoadChangedConventions); + } + + private class PropertyAutoLoadChangedConvention(bool terminate) : IPropertyAutoLoadChangedConvention + { + public readonly List Calls = []; + private readonly bool _terminate = terminate; + + public void ProcessPropertyAutoLoadChanged( + IConventionPropertyBuilder propertyBuilder, + IConventionContext context) + { + Calls.Add(propertyBuilder.Metadata.IsAutoLoaded); + + if (_terminate) + { + context.StopProcessing(); + } + } + } + [InlineData(false, false), InlineData(true, false), InlineData(false, true), InlineData(true, true), ConditionalTheory] public void OnPropertyFieldChanged_calls_conventions_in_order(bool useBuilder, bool useScope) { diff --git a/test/EFCore.Tests/Metadata/Internal/InternalPropertyBuilderTest.cs b/test/EFCore.Tests/Metadata/Internal/InternalPropertyBuilderTest.cs index 92f11ff2998..74ba899a508 100644 --- a/test/EFCore.Tests/Metadata/Internal/InternalPropertyBuilderTest.cs +++ b/test/EFCore.Tests/Metadata/Internal/InternalPropertyBuilderTest.cs @@ -20,6 +20,39 @@ public void Property_added_by_name_is_non_shadow_if_matches_Clr_property() Assert.False(property.IsShadowProperty()); } + [ConditionalFact] + public void Can_only_override_lower_or_equal_source_IsAutoLoaded() + { + var builder = CreateInternalPropertyBuilder(); + var metadata = builder.Metadata; + + Assert.NotNull(builder.IsAutoLoaded(false, ConfigurationSource.DataAnnotation)); + Assert.NotNull(builder.IsAutoLoaded(true, ConfigurationSource.DataAnnotation)); + + Assert.True(metadata.IsAutoLoaded); + + Assert.Null(builder.IsAutoLoaded(false, ConfigurationSource.Convention)); + Assert.True(metadata.IsAutoLoaded); + } + + [ConditionalFact] + public void Can_only_override_existing_IsAutoLoaded_value_explicitly() + { + var metadata = CreateProperty(); + Assert.Null(metadata.GetIsAutoLoadedConfigurationSource()); + metadata.IsAutoLoaded = false; + var builder = metadata.Builder; + + Assert.Equal(ConfigurationSource.Explicit, metadata.GetIsAutoLoadedConfigurationSource()); + Assert.NotNull(builder.IsAutoLoaded(false, ConfigurationSource.DataAnnotation)); + Assert.Null(builder.IsAutoLoaded(true, ConfigurationSource.DataAnnotation)); + + Assert.False(metadata.IsAutoLoaded); + + Assert.NotNull(builder.IsAutoLoaded(true, ConfigurationSource.Explicit)); + Assert.True(metadata.IsAutoLoaded); + } + [ConditionalFact] public void Can_only_override_lower_or_equal_source_ConcurrencyToken() { diff --git a/test/EFCore.Tests/Metadata/Internal/PropertyTest.cs b/test/EFCore.Tests/Metadata/Internal/PropertyTest.cs index 50baabcab77..7ba6b930733 100644 --- a/test/EFCore.Tests/Metadata/Internal/PropertyTest.cs +++ b/test/EFCore.Tests/Metadata/Internal/PropertyTest.cs @@ -217,6 +217,28 @@ public void Can_mark_property_as_using_ValueGenerated() Assert.Equal(ValueGenerated.Never, property.ValueGenerated); } + [ConditionalFact] + public void Property_is_auto_loaded_by_default() + { + var entityType = CreateModel().AddEntityType(typeof(Entity)); + var property = entityType.AddProperty("Name", typeof(string)); + + Assert.True(property.IsAutoLoaded); + } + + [ConditionalFact] + public void Can_mark_property_as_not_auto_loaded() + { + var entityType = CreateModel().AddEntityType(typeof(Entity)); + var property = entityType.AddProperty("Name", typeof(string)); + + property.IsAutoLoaded = false; + Assert.False(property.IsAutoLoaded); + + property.IsAutoLoaded = true; + Assert.True(property.IsAutoLoaded); + } + [ConditionalFact] public void Property_is_not_concurrency_token_by_default() { From 79a416d2fe9addc8b75da17975f9509d7df6adbe Mon Sep 17 00:00:00 2001 From: Andriy Svyryd Date: Tue, 3 Mar 2026 00:12:47 -0800 Subject: [PATCH 2/3] React to PR feedback --- .../SqlServerAutoLoadConvention.cs | 22 +++++----- .../Internal/InternalEntryBase.cs | 1 + .../Conventions/AutoLoadConvention.cs | 16 +++---- .../Scaffolding/CompiledModelCosmosTest.cs | 16 ++----- .../Update/NonSharedModelUpdatesTestBase.cs | 44 +++++-------------- 5 files changed, 33 insertions(+), 66 deletions(-) diff --git a/src/EFCore.SqlServer/Metadata/Conventions/SqlServerAutoLoadConvention.cs b/src/EFCore.SqlServer/Metadata/Conventions/SqlServerAutoLoadConvention.cs index 381f20d035c..b676efa3e02 100644 --- a/src/EFCore.SqlServer/Metadata/Conventions/SqlServerAutoLoadConvention.cs +++ b/src/EFCore.SqlServer/Metadata/Conventions/SqlServerAutoLoadConvention.cs @@ -13,16 +13,12 @@ namespace Microsoft.EntityFrameworkCore.Metadata.Conventions; /// /// See Model building conventions for more information and examples. /// -public class SqlServerAutoLoadConvention : AutoLoadConvention +/// +/// Creates a new instance of . +/// +/// Parameter object containing dependencies for this convention. +public class SqlServerAutoLoadConvention(ProviderConventionSetBuilderDependencies dependencies) : AutoLoadConvention(dependencies) { - /// - /// Creates a new instance of . - /// - /// Parameter object containing dependencies for this convention. - public SqlServerAutoLoadConvention(ProviderConventionSetBuilderDependencies dependencies) - : base(dependencies) - { - } /// protected override bool ShouldBeAutoLoaded(IConventionProperty property) @@ -33,8 +29,10 @@ protected override bool ShouldBeAutoLoaded(IConventionProperty property) return typeMapping is not SqlServerVectorTypeMapping; } - // Fall back to CLR type check when type mapping hasn't been resolved yet - return property.GetValueConverter() == null - && property.ClrType.TryGetElementType(typeof(SqlVector<>)) is null; + // Fall back to CLR type check when type mapping hasn't been resolved yet. + // 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; } } diff --git a/src/EFCore/ChangeTracking/Internal/InternalEntryBase.cs b/src/EFCore/ChangeTracking/Internal/InternalEntryBase.cs index 05fb2eb8f5e..c54b6774c59 100644 --- a/src/EFCore/ChangeTracking/Internal/InternalEntryBase.cs +++ b/src/EFCore/ChangeTracking/Internal/InternalEntryBase.cs @@ -1675,6 +1675,7 @@ void CheckForNullCollection(IProperty property) { if (property.GetElementType() != null && !property.IsNullable + && IsLoaded(property) && GetCurrentValue(property) == null && (property.DeclaringType is not IComplexType complexType || GetCurrentValue(complexType.ComplexProperty) != null)) diff --git a/src/EFCore/Metadata/Conventions/AutoLoadConvention.cs b/src/EFCore/Metadata/Conventions/AutoLoadConvention.cs index 87be9cd2742..f0c312e7173 100644 --- a/src/EFCore/Metadata/Conventions/AutoLoadConvention.cs +++ b/src/EFCore/Metadata/Conventions/AutoLoadConvention.cs @@ -11,21 +11,17 @@ namespace Microsoft.EntityFrameworkCore.Metadata.Conventions; /// /// See Model building conventions for more information and examples. /// -public class AutoLoadConvention : IModelFinalizingConvention +/// +/// Creates a new instance of . +/// +/// Parameter object containing dependencies for this convention. +public class AutoLoadConvention(ProviderConventionSetBuilderDependencies dependencies) : IModelFinalizingConvention { - /// - /// Creates a new instance of . - /// - /// Parameter object containing dependencies for this convention. - public AutoLoadConvention(ProviderConventionSetBuilderDependencies dependencies) - { - Dependencies = dependencies; - } /// /// Dependencies for this service. /// - protected virtual ProviderConventionSetBuilderDependencies Dependencies { get; } + protected virtual ProviderConventionSetBuilderDependencies Dependencies { get; } = dependencies; /// public virtual void ProcessModelFinalizing( diff --git a/test/EFCore.Cosmos.FunctionalTests/Scaffolding/CompiledModelCosmosTest.cs b/test/EFCore.Cosmos.FunctionalTests/Scaffolding/CompiledModelCosmosTest.cs index c1a5cb5bfa4..21f4b5790ea 100644 --- a/test/EFCore.Cosmos.FunctionalTests/Scaffolding/CompiledModelCosmosTest.cs +++ b/test/EFCore.Cosmos.FunctionalTests/Scaffolding/CompiledModelCosmosTest.cs @@ -204,10 +204,7 @@ protected override void BuildBigModel(ModelBuilder modelBuilder, bool jsonColumn // Cosmos provider doesn't support partial property loading modelBuilder.Entity( - b => - { - b.Property(e => e.NullableString).Metadata.IsAutoLoaded = true; - }); + b => b.Property(e => e.NullableString).Metadata.IsAutoLoaded = true); modelBuilder.Entity>(eb => eb.ToContainer("Dependents")); modelBuilder.Entity>(eb => eb.HasDiscriminator().IsComplete(false)); @@ -226,15 +223,13 @@ protected override void BuildBigModel(ModelBuilder modelBuilder, bool jsonColumn }); modelBuilder.Entity>>(b => - { // Cosmos provider cannot map collections of elements with converters. See Issue #34026. b.OwnsMany( typeof(OwnedType).FullName!, "ManyOwned", b => { b.Ignore("RefTypeArray"); b.Ignore("RefTypeList"); - }); - }); + })); modelBuilder.Entity(b => { @@ -643,9 +638,7 @@ protected override void BuildComplexTypesModel(ModelBuilder modelBuilder) }); modelBuilder.Entity>>( - eb => - { - eb.ComplexCollection, OwnedType>( + eb => eb.ComplexCollection, OwnedType>( "ManyOwned", "OwnedCollection", ob => { ob.Ignore(e => e.RefTypeArray); @@ -656,8 +649,7 @@ protected override void BuildComplexTypesModel(ModelBuilder modelBuilder) cb.Ignore(e => e.RefTypeList); cb.Ignore(e => e.RefTypeArray); }); - }); - }); + })); } protected override void AssertBigModel(IModel model, bool jsonColumns) diff --git a/test/EFCore.Relational.Specification.Tests/Update/NonSharedModelUpdatesTestBase.cs b/test/EFCore.Relational.Specification.Tests/Update/NonSharedModelUpdatesTestBase.cs index 59a8aece5ea..71538537d02 100644 --- a/test/EFCore.Relational.Specification.Tests/Update/NonSharedModelUpdatesTestBase.cs +++ b/test/EFCore.Relational.Specification.Tests/Update/NonSharedModelUpdatesTestBase.cs @@ -17,19 +17,13 @@ public virtual async Task Principal_and_dependent_roundtrips_with_cycle_breaking var contextFactory = await InitializeNonSharedTest( onModelCreating: mb => { - mb.Entity(b => - { - b.HasOne(a => a.AuthorsClub) + mb.Entity(b => b.HasOne(a => a.AuthorsClub) .WithMany() - .HasForeignKey(a => a.AuthorsClubId); - }); + .HasForeignKey(a => a.AuthorsClubId)); - mb.Entity(b => - { - b.HasOne(book => book.Author) + mb.Entity(b => b.HasOne(book => book.Author) .WithMany() - .HasForeignKey(book => book.AuthorId); - }); + .HasForeignKey(book => book.AuthorId)); }); await ExecuteWithStrategyInTransactionAsync( @@ -128,14 +122,8 @@ public class Blog public virtual async Task Update_entity_with_not_loaded_property_excludes_column_from_SQL(bool async) { var contextFactory = await InitializeNonSharedTest( - onModelCreating: mb => - { - mb.Entity( - b => - { - b.Property(e => e.Description).Metadata.IsAutoLoaded = false; - }); - }, + onModelCreating: mb => mb.Entity( + b => b.Property(e => e.Description).Metadata.IsAutoLoaded = false), seed: async context => { context.Add(new BlogWithDescription { Name = "EF Blog", Description = "Original description" }); @@ -174,14 +162,12 @@ await ExecuteWithStrategyInTransactionAsync( public virtual async Task Save_and_query_with_partially_loaded_primitive_collection(bool async) { var contextFactory = await InitializeNonSharedTest( - onModelCreating: mb => - { - mb.Entity( + 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"] }); @@ -236,9 +222,7 @@ public virtual async Task Replacing_owned_entity_with_FK_to_another_entity(bool var contextFactory = await InitializeNonSharedTest( onModelCreating: mb => { - mb.Entity(b => - { - b.OwnsOne(d => d.File, fb => + mb.Entity(b => b.OwnsOne(d => d.File, fb => { fb.Property(f => f.Id).ValueGeneratedNever(); fb.HasOne(f => f.Content) @@ -246,13 +230,9 @@ public virtual async Task Replacing_owned_entity_with_FK_to_another_entity(bool .HasForeignKey(f => f.ContentId) .IsRequired() .OnDelete(DeleteBehavior.Restrict); - }); - }); + })); - mb.Entity(b => - { - b.Property(c => c.Id).ValueGeneratedNever(); - }); + mb.Entity(b => b.Property(c => c.Id).ValueGeneratedNever()); }); var oldContentId = Guid.NewGuid(); From a1d06fafcc23145a00620c6c904a5b4722a608f1 Mon Sep 17 00:00:00 2001 From: Andriy Svyryd Date: Tue, 3 Mar 2026 09:26:17 -0800 Subject: [PATCH 3/3] React to more PR feedback --- .../Conventions/SqlServerAutoLoadConvention.cs | 4 ---- .../ChangeTracking/Internal/InternalEntryBase.cs | 11 ++++++++++- src/EFCore/Metadata/Conventions/AutoLoadConvention.cs | 3 --- src/EFCore/Metadata/Internal/Property.cs | 2 +- .../Scaffolding/CompiledModelCosmosTest.cs | 7 +++---- .../Scaffolding/CompiledModelRelationalTestBase.cs | 1 - .../Scaffolding/CompiledModelTestBase.cs | 10 ++++++++-- 7 files changed, 22 insertions(+), 16 deletions(-) diff --git a/src/EFCore.SqlServer/Metadata/Conventions/SqlServerAutoLoadConvention.cs b/src/EFCore.SqlServer/Metadata/Conventions/SqlServerAutoLoadConvention.cs index b676efa3e02..2ccbeb8f21b 100644 --- a/src/EFCore.SqlServer/Metadata/Conventions/SqlServerAutoLoadConvention.cs +++ b/src/EFCore.SqlServer/Metadata/Conventions/SqlServerAutoLoadConvention.cs @@ -13,13 +13,9 @@ namespace Microsoft.EntityFrameworkCore.Metadata.Conventions; /// /// See Model building conventions for more information and examples. /// -/// -/// Creates a new instance of . -/// /// Parameter object containing dependencies for this convention. public class SqlServerAutoLoadConvention(ProviderConventionSetBuilderDependencies dependencies) : AutoLoadConvention(dependencies) { - /// protected override bool ShouldBeAutoLoaded(IConventionProperty property) { diff --git a/src/EFCore/ChangeTracking/Internal/InternalEntryBase.cs b/src/EFCore/ChangeTracking/Internal/InternalEntryBase.cs index c54b6774c59..e0bf02dda27 100644 --- a/src/EFCore/ChangeTracking/Internal/InternalEntryBase.cs +++ b/src/EFCore/ChangeTracking/Internal/InternalEntryBase.cs @@ -207,7 +207,7 @@ protected virtual bool PrepareForAdd(EntityState newState) { return false; } - + if (EntityState == EntityState.Modified) { _stateData.FlagAllProperties( @@ -384,6 +384,15 @@ public virtual void MarkUnchangedFromQuery() { EntityState = EntityState.Unchanged; + foreach (var property in StructuralType.GetFlattenedProperties()) + { + if (!property.IsAutoLoaded) + { + _stateData.FlagProperty( + property.GetIndex(), PropertyFlag.IsPropertyNotLoaded, HasSentinelValue(property)); + } + } + foreach (var complexCollection in StructuralType.GetFlattenedComplexProperties()) { if (complexCollection.IsCollection) diff --git a/src/EFCore/Metadata/Conventions/AutoLoadConvention.cs b/src/EFCore/Metadata/Conventions/AutoLoadConvention.cs index f0c312e7173..0adc81b5129 100644 --- a/src/EFCore/Metadata/Conventions/AutoLoadConvention.cs +++ b/src/EFCore/Metadata/Conventions/AutoLoadConvention.cs @@ -11,9 +11,6 @@ namespace Microsoft.EntityFrameworkCore.Metadata.Conventions; /// /// See Model building conventions for more information and examples. /// -/// -/// Creates a new instance of . -/// /// Parameter object containing dependencies for this convention. public class AutoLoadConvention(ProviderConventionSetBuilderDependencies dependencies) : IModelFinalizingConvention { diff --git a/src/EFCore/Metadata/Internal/Property.cs b/src/EFCore/Metadata/Internal/Property.cs index 1607e7eefb2..17480395373 100644 --- a/src/EFCore/Metadata/Internal/Property.cs +++ b/src/EFCore/Metadata/Internal/Property.cs @@ -363,7 +363,7 @@ public virtual bool IsAutoLoaded { EnsureMutable(); - var isChanging = IsAutoLoaded != autoLoaded; + var isChanging = IsAutoLoaded != (autoLoaded ?? DefaultIsAutoLoaded); if (isChanging) { _isAutoLoaded = autoLoaded; diff --git a/test/EFCore.Cosmos.FunctionalTests/Scaffolding/CompiledModelCosmosTest.cs b/test/EFCore.Cosmos.FunctionalTests/Scaffolding/CompiledModelCosmosTest.cs index 21f4b5790ea..8c63b118deb 100644 --- a/test/EFCore.Cosmos.FunctionalTests/Scaffolding/CompiledModelCosmosTest.cs +++ b/test/EFCore.Cosmos.FunctionalTests/Scaffolding/CompiledModelCosmosTest.cs @@ -202,10 +202,6 @@ protected override void BuildBigModel(ModelBuilder modelBuilder, bool jsonColumn { base.BuildBigModel(modelBuilder, jsonColumns); - // Cosmos provider doesn't support partial property loading - modelBuilder.Entity( - b => b.Property(e => e.NullableString).Metadata.IsAutoLoaded = true); - modelBuilder.Entity>(eb => eb.ToContainer("Dependents")); modelBuilder.Entity>(eb => eb.HasDiscriminator().IsComplete(false)); @@ -663,6 +659,9 @@ protected override void AssertBigModel(IModel model, bool jsonColumns) protected override int ExpectedComplexTypeProperties => 12; + protected override bool SupportsNonAutoLoadedProperties + => false; + protected override TestHelpers TestHelpers => CosmosTestHelpers.Instance; diff --git a/test/EFCore.Relational.Specification.Tests/Scaffolding/CompiledModelRelationalTestBase.cs b/test/EFCore.Relational.Specification.Tests/Scaffolding/CompiledModelRelationalTestBase.cs index 784fd6c2d1d..e16b309e589 100644 --- a/test/EFCore.Relational.Specification.Tests/Scaffolding/CompiledModelRelationalTestBase.cs +++ b/test/EFCore.Relational.Specification.Tests/Scaffolding/CompiledModelRelationalTestBase.cs @@ -143,7 +143,6 @@ protected override void AssertBigModel(IModel model, bool jsonColumns) Assert.Throws(model.GetCollation).Message); var manyTypesType = model.FindEntityType(typeof(ManyTypes))!; - Assert.False(manyTypesType.FindProperty(nameof(ManyTypes.NullableString))!.IsAutoLoaded); Assert.Equal("ManyTypes", manyTypesType.GetTableName()); Assert.Null(manyTypesType.GetSchema()); diff --git a/test/EFCore.Specification.Tests/Scaffolding/CompiledModelTestBase.cs b/test/EFCore.Specification.Tests/Scaffolding/CompiledModelTestBase.cs index 703cae0bf88..6db059e5fe1 100644 --- a/test/EFCore.Specification.Tests/Scaffolding/CompiledModelTestBase.cs +++ b/test/EFCore.Specification.Tests/Scaffolding/CompiledModelTestBase.cs @@ -269,7 +269,10 @@ protected virtual void BuildBigModel(ModelBuilder modelBuilder, bool jsonColumns b.Property(e => e.UriToStringConverterProperty).HasConversion(); b.Property(e => e.NullIntToNullStringConverterProperty).HasConversion(); - b.Property(e => e.NullableString).Metadata.IsAutoLoaded = false; + if (SupportsNonAutoLoadedProperties) + { + b.Property(e => e.NullableString).Metadata.IsAutoLoaded = false; + } }); } @@ -286,7 +289,7 @@ protected virtual void AssertBigModel(IModel model, bool jsonColumns) Assert.Equal(ChangeTrackingStrategy.Snapshot, manyTypesType.GetChangeTrackingStrategy()); var stringProp = manyTypesType.FindProperty(nameof(ManyTypes.NullableString))!; - Assert.False(stringProp.IsAutoLoaded); + Assert.Equal(!SupportsNonAutoLoadedProperties, stringProp.IsAutoLoaded); var ipAddressCollection = manyTypesType.FindProperty(nameof(ManyTypes.IPAddressReadOnlyCollection)); if (ipAddressCollection != null) @@ -1372,6 +1375,9 @@ protected virtual void AssertComplexTypes(IModel model) protected virtual int ExpectedComplexTypeProperties => 14; + protected virtual bool SupportsNonAutoLoadedProperties + => true; + public class CustomValueComparer() : ValueComparer(false); public class ManyTypesIdConverter() : ValueConverter(v => v.Id, v => new ManyTypesId(v));