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..2ccbeb8f21b --- /dev/null +++ b/src/EFCore.SqlServer/Metadata/Conventions/SqlServerAutoLoadConvention.cs @@ -0,0 +1,34 @@ +// 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. +/// +/// Parameter object containing dependencies for this convention. +public class SqlServerAutoLoadConvention(ProviderConventionSetBuilderDependencies dependencies) : AutoLoadConvention(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. + // 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.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..e0bf02dda27 100644 --- a/src/EFCore/ChangeTracking/Internal/InternalEntryBase.cs +++ b/src/EFCore/ChangeTracking/Internal/InternalEntryBase.cs @@ -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()) @@ -363,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) @@ -407,7 +437,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 +454,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 +497,7 @@ public void SetPropertyModified( var propertyIndex = property.GetIndex(); _stateData.FlagProperty(propertyIndex, PropertyFlag.Unknown, false); + _stateData.FlagProperty(propertyIndex, PropertyFlag.IsPropertyNotLoaded, false); var currentState = _stateData.EntityState; @@ -1626,6 +1684,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/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..0adc81b5129 --- /dev/null +++ b/src/EFCore/Metadata/Conventions/AutoLoadConvention.cs @@ -0,0 +1,62 @@ +// 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. +/// +/// Parameter object containing dependencies for this convention. +public class AutoLoadConvention(ProviderConventionSetBuilderDependencies dependencies) : IModelFinalizingConvention +{ + + /// + /// Dependencies for this service. + /// + protected virtual ProviderConventionSetBuilderDependencies Dependencies { get; } = dependencies; + + /// + 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..17480395373 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 ?? DefaultIsAutoLoaded); + 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..8c63b118deb 100644 --- a/test/EFCore.Cosmos.FunctionalTests/Scaffolding/CompiledModelCosmosTest.cs +++ b/test/EFCore.Cosmos.FunctionalTests/Scaffolding/CompiledModelCosmosTest.cs @@ -219,15 +219,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 => { @@ -636,9 +634,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); @@ -649,8 +645,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) @@ -664,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.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/Update/NonSharedModelUpdatesTestBase.cs b/test/EFCore.Relational.Specification.Tests/Update/NonSharedModelUpdatesTestBase.cs index fa2c97c10df..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( @@ -124,15 +118,111 @@ 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; + b.Property(e => e.Tags).Metadata.Sentinel = new List(); + }), + seed: async context => + { + context.Add(new BlogWithTags { Name = "EF Blog", Tags = ["efcore", "dotnet"] }); + await context.SaveChangesAsync(); + }); + + await ExecuteWithStrategyInTransactionAsync( + contextFactory, + async context => + { + var blog = 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) { 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) @@ -140,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(); 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..6db059e5fe1 100644 --- a/test/EFCore.Specification.Tests/Scaffolding/CompiledModelTestBase.cs +++ b/test/EFCore.Specification.Tests/Scaffolding/CompiledModelTestBase.cs @@ -268,6 +268,11 @@ 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(); + + if (SupportsNonAutoLoadedProperties) + { + b.Property(e => e.NullableString).Metadata.IsAutoLoaded = false; + } }); } @@ -283,6 +288,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.Equal(!SupportsNonAutoLoadedProperties, stringProp.IsAutoLoaded); + var ipAddressCollection = manyTypesType.FindProperty(nameof(ManyTypes.IPAddressReadOnlyCollection)); if (ipAddressCollection != null) { @@ -1367,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)); 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() {