diff --git a/src/EFCore.Design/Migrations/Design/CSharpSnapshotGenerator.cs b/src/EFCore.Design/Migrations/Design/CSharpSnapshotGenerator.cs index 84f1609ded9..b1941ce3d27 100644 --- a/src/EFCore.Design/Migrations/Design/CSharpSnapshotGenerator.cs +++ b/src/EFCore.Design/Migrations/Design/CSharpSnapshotGenerator.cs @@ -1305,6 +1305,11 @@ private void GenerateSplitTableMapping( using (stringBuilder.Indent()) { + if (fragment.IsOptional) + { + stringBuilder.AppendLine().Append("t.IsOptional();"); + } + GenerateTriggers("t", entityType, table.Name, table.Schema, stringBuilder); GeneratePropertyOverrides("t", entityType, table, stringBuilder); GenerateEntityTypeMappingFragmentAnnotations("t", fragment, stringBuilder); diff --git a/src/EFCore.Relational/Design/Internal/RelationalCSharpRuntimeAnnotationCodeGenerator.cs b/src/EFCore.Relational/Design/Internal/RelationalCSharpRuntimeAnnotationCodeGenerator.cs index 2ebe59c233c..fafbaa05e83 100644 --- a/src/EFCore.Relational/Design/Internal/RelationalCSharpRuntimeAnnotationCodeGenerator.cs +++ b/src/EFCore.Relational/Design/Internal/RelationalCSharpRuntimeAnnotationCodeGenerator.cs @@ -1774,7 +1774,8 @@ private void GenerateAddMapping( .Append($"{tableVariable}, {additionalParameter ?? ""}{code.Literal(tableMapping.IncludesDerivedTypes)}"); if (tableMapping.IsSharedTablePrincipal.HasValue - || tableMapping.IsSplitEntityTypePrincipal.HasValue) + || tableMapping.IsSplitEntityTypePrincipal.HasValue + || tableMapping.IsSplitFragmentOptional) { mainBuilder.AppendLine(")") .AppendLine("{").IncrementIndent(); @@ -1788,7 +1789,14 @@ private void GenerateAddMapping( if (tableMapping.IsSplitEntityTypePrincipal.HasValue) { mainBuilder - .Append("IsSplitEntityTypePrincipal = ").AppendLine(code.Literal(tableMapping.IsSplitEntityTypePrincipal)); + .Append("IsSplitEntityTypePrincipal = ").Append(code.Literal(tableMapping.IsSplitEntityTypePrincipal)); + mainBuilder.AppendLine(tableMapping.IsSplitFragmentOptional ? "," : ""); + } + + if (tableMapping.IsSplitFragmentOptional) + { + mainBuilder + .Append("IsSplitFragmentOptional = ").AppendLine(code.Literal(tableMapping.IsSplitFragmentOptional)); } mainBuilder.DecrementIndent().AppendLine("};"); @@ -2183,7 +2191,8 @@ private void Create( AppendLiteral(storeObject, mainBuilder, code); mainBuilder.AppendLine(",") - .Append(code.Literal(fragment.IsTableExcludedFromMigrations)).AppendLine(");").DecrementIndent(); + .Append(code.Literal(fragment.IsTableExcludedFromMigrations)).AppendLine(",") + .Append(code.Literal(fragment.IsOptional)).AppendLine(");").DecrementIndent(); CreateAnnotations( fragment, diff --git a/src/EFCore.Relational/Diagnostics/EntityTypeMappingFragmentEventData.cs b/src/EFCore.Relational/Diagnostics/EntityTypeMappingFragmentEventData.cs new file mode 100644 index 00000000000..47cb2f252e4 --- /dev/null +++ b/src/EFCore.Relational/Diagnostics/EntityTypeMappingFragmentEventData.cs @@ -0,0 +1,46 @@ +// 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.Diagnostics; + +/// +/// The event payload for events that reference an entity-splitting mapping fragment. +/// +public class EntityTypeMappingFragmentEventData : EventData +{ + /// + /// Initializes a new instance of the class. + /// + /// The event definition. + /// A delegate that generates a log message for this event. + /// The entity type that owns the mapping fragment. + /// The store object for the mapping fragment. + /// A value indicating whether the fragment is now optional. + public EntityTypeMappingFragmentEventData( + EventDefinitionBase eventDefinition, + Func messageGenerator, + IEntityType entityType, + StoreObjectIdentifier storeObject, + bool optional) + : base(eventDefinition, messageGenerator) + { + EntityType = entityType; + StoreObject = storeObject; + IsOptional = optional; + } + + /// + /// Gets the entity type that owns the mapping fragment. + /// + public virtual IEntityType EntityType { get; } + + /// + /// Gets the store object for the mapping fragment. + /// + public virtual StoreObjectIdentifier StoreObject { get; } + + /// + /// Gets a value indicating whether the fragment is now optional. + /// + public virtual bool IsOptional { get; } +} diff --git a/src/EFCore.Relational/Diagnostics/RelationalEventId.cs b/src/EFCore.Relational/Diagnostics/RelationalEventId.cs index c5660f86b76..bbcf2ac34d7 100644 --- a/src/EFCore.Relational/Diagnostics/RelationalEventId.cs +++ b/src/EFCore.Relational/Diagnostics/RelationalEventId.cs @@ -84,6 +84,7 @@ private enum Id MigrationsUserTransactionWarning = CoreEventId.RelationalBaseId + 412, ModelSnapshotNotFound = CoreEventId.RelationalBaseId + 413, OldMigrationVersionWarning = CoreEventId.RelationalBaseId + 414, + EntitySplittingFragmentOptionalityChangedWarning = CoreEventId.RelationalBaseId + 415, // Query events QueryClientEvaluationWarning = CoreEventId.RelationalBaseId + 500, @@ -821,6 +822,21 @@ private static EventId MakeMigrationsId(Id id) /// public static readonly EventId OldMigrationVersionWarning = MakeMigrationsId(Id.OldMigrationVersionWarning); + /// + /// The optionality of an entity-splitting fragment changed since the last migration. + /// + /// + /// + /// This event is in the category. + /// + /// + /// This event uses the payload when used with a + /// . + /// + /// + public static readonly EventId EntitySplittingFragmentOptionalityChangedWarning = + MakeMigrationsId(Id.EntitySplittingFragmentOptionalityChangedWarning); + private static readonly string _queryPrefix = DbLoggerCategory.Query.Name + "."; private static EventId MakeQueryId(Id id) diff --git a/src/EFCore.Relational/Diagnostics/RelationalLoggerExtensions.cs b/src/EFCore.Relational/Diagnostics/RelationalLoggerExtensions.cs index 1abfe932fba..5722aa5f058 100644 --- a/src/EFCore.Relational/Diagnostics/RelationalLoggerExtensions.cs +++ b/src/EFCore.Relational/Diagnostics/RelationalLoggerExtensions.cs @@ -3700,6 +3700,52 @@ private static string ColumnOrderIgnoredWarning(EventDefinitionBase definition, return d.GenerateMessage((p.ColumnOperation.Table, p.ColumnOperation.Schema).FormatTable(), p.ColumnOperation.Name); } + /// + /// 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 static void EntitySplittingFragmentOptionalityChangedWarning( + this IDiagnosticsLogger diagnostics, + IEntityType entityType, + StoreObjectIdentifier storeObject, + bool optional) + { + var definition = RelationalResources.LogEntitySplittingFragmentOptionalityChangedWarning(diagnostics); + + if (diagnostics.ShouldLog(definition)) + { + definition.Log( + diagnostics, + storeObject.DisplayName(), + entityType.DisplayName(), + optional ? "optional" : "required"); + } + + if (diagnostics.NeedsEventData(definition, out var diagnosticSourceEnabled, out var simpleLogEnabled)) + { + var eventData = new EntityTypeMappingFragmentEventData( + definition, + EntitySplittingFragmentOptionalityChangedWarning, + entityType, + storeObject, + optional); + + diagnostics.DispatchEventData(definition, eventData, diagnosticSourceEnabled, simpleLogEnabled); + } + } + + private static string EntitySplittingFragmentOptionalityChangedWarning(EventDefinitionBase definition, EventData payload) + { + var d = (EventDefinition)definition; + var p = (EntityTypeMappingFragmentEventData)payload; + return d.GenerateMessage( + p.StoreObject.DisplayName(), + p.EntityType.DisplayName(), + p.IsOptional ? "optional" : "required"); + } + /// /// Logs for the event. /// diff --git a/src/EFCore.Relational/Diagnostics/RelationalLoggingDefinitions.cs b/src/EFCore.Relational/Diagnostics/RelationalLoggingDefinitions.cs index 90448b21c7b..1efd8b5676c 100644 --- a/src/EFCore.Relational/Diagnostics/RelationalLoggingDefinitions.cs +++ b/src/EFCore.Relational/Diagnostics/RelationalLoggingDefinitions.cs @@ -682,6 +682,15 @@ public abstract class RelationalLoggingDefinitions : LoggingDefinitions [EntityFrameworkInternal] public EventDefinitionBase? LogColumnOrderIgnoredWarning; + /// + /// 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. + /// + [EntityFrameworkInternal] + public EventDefinitionBase? LogEntitySplittingFragmentOptionalityChangedWarning; + /// /// 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.Relational/EFCore.Relational.baseline.json b/src/EFCore.Relational/EFCore.Relational.baseline.json index 30ab4a44cea..dc6e2f47381 100644 --- a/src/EFCore.Relational/EFCore.Relational.baseline.json +++ b/src/EFCore.Relational/EFCore.Relational.baseline.json @@ -2835,6 +2835,25 @@ } ] }, + { + "Type": "class Microsoft.EntityFrameworkCore.Diagnostics.EntityTypeMappingFragmentEventData : Microsoft.EntityFrameworkCore.Diagnostics.EventData", + "Methods": [ + { + "Member": "EntityTypeMappingFragmentEventData(Microsoft.EntityFrameworkCore.Diagnostics.EventDefinitionBase eventDefinition, System.Func messageGenerator, Microsoft.EntityFrameworkCore.Metadata.IEntityType entityType, Microsoft.EntityFrameworkCore.Metadata.StoreObjectIdentifier storeObject, bool optional);" + } + ], + "Properties": [ + { + "Member": "virtual Microsoft.EntityFrameworkCore.Metadata.IEntityType EntityType { get; }" + }, + { + "Member": "virtual bool IsOptional { get; }" + }, + { + "Member": "virtual Microsoft.EntityFrameworkCore.Metadata.StoreObjectIdentifier StoreObject { get; }" + } + ] + }, { "Type": "class Microsoft.EntityFrameworkCore.Diagnostics.EntityTypeSchemaEventData : Microsoft.EntityFrameworkCore.Diagnostics.EventData", "Methods": [ @@ -3913,9 +3932,15 @@ { "Member": "Microsoft.EntityFrameworkCore.Metadata.ConfigurationSource GetConfigurationSource();" }, + { + "Member": "Microsoft.EntityFrameworkCore.Metadata.ConfigurationSource? GetIsOptionalConfigurationSource();" + }, { "Member": "Microsoft.EntityFrameworkCore.Metadata.ConfigurationSource? GetIsTableExcludedFromMigrationsConfigurationSource();" }, + { + "Member": "bool? SetIsOptional(bool? optional, bool fromDataAnnotation = false);" + }, { "Member": "bool? SetIsTableExcludedFromMigrations(bool? excluded, bool fromDataAnnotation = false);" } @@ -5028,6 +5053,9 @@ { "Member": "Microsoft.EntityFrameworkCore.Metadata.IMutableEntityType EntityType { get; }" }, + { + "Member": "bool IsOptional { get; set; }" + }, { "Member": "bool? IsTableExcludedFromMigrations { get; set; }" } @@ -5606,6 +5634,9 @@ { "Member": "Microsoft.EntityFrameworkCore.Metadata.IReadOnlyEntityType EntityType { get; }" }, + { + "Member": "bool IsOptional { get; }" + }, { "Member": "bool? IsTableExcludedFromMigrations { get; }" }, @@ -7189,6 +7220,9 @@ { "Member": "bool? IsSplitEntityTypePrincipal { get; }" }, + { + "Member": "bool IsSplitFragmentOptional { get; }" + }, { "Member": "Microsoft.EntityFrameworkCore.Metadata.ITableBase Table { get; }" }, @@ -8524,6 +8558,15 @@ { "Member": "virtual System.Collections.Generic.IReadOnlyList Entries { get; }" }, + { + "Member": "virtual bool IsOptionalSplitFragment { get; }" + }, + { + "Member": "virtual bool IsOptionalSplitFragmentPayloadAllNull { get; }" + }, + { + "Member": "virtual bool IsOptionalSplitFragmentRowAssumedAbsent { get; }" + }, { "Member": "virtual Microsoft.EntityFrameworkCore.Metadata.IColumnBase? RowsAffectedColumn { get; private set; }" }, @@ -8834,6 +8877,9 @@ { "Member": "virtual Microsoft.EntityFrameworkCore.Metadata.Builders.TableTriggerBuilder HasTrigger(string modelName);" }, + { + "Member": "virtual Microsoft.EntityFrameworkCore.Metadata.Builders.OwnedNavigationSplitTableBuilder IsOptional(bool optional = true);" + }, { "Member": "virtual Microsoft.EntityFrameworkCore.Metadata.Builders.ColumnBuilder Property(string propertyName);" }, @@ -8868,6 +8914,9 @@ { "Member": "virtual Microsoft.EntityFrameworkCore.Metadata.Builders.OwnedNavigationSplitTableBuilder HasAnnotation(string annotation, object? value);" }, + { + "Member": "virtual Microsoft.EntityFrameworkCore.Metadata.Builders.OwnedNavigationSplitTableBuilder IsOptional(bool optional = true);" + }, { "Member": "virtual Microsoft.EntityFrameworkCore.Metadata.Builders.ColumnBuilder Property(System.Linq.Expressions.Expression> propertyExpression);" } @@ -10006,6 +10055,10 @@ "Member": "const string DeleteStoredProcedureParameterMappings", "Value": "Relational:DeleteStoredProcedureParameterMappings" }, + { + "Member": "const string EntityTypeMappingFragmentIsOptional", + "Value": "Relational:EntityTypeMappingFragmentIsOptional" + }, { "Member": "const string FieldValueGetter", "Value": "Relational:FieldValueGetter" @@ -12727,6 +12780,9 @@ { "Member": "static readonly Microsoft.Extensions.Logging.EventId DuplicateColumnOrders" }, + { + "Member": "static readonly Microsoft.Extensions.Logging.EventId EntitySplittingFragmentOptionalityChangedWarning" + }, { "Member": "static readonly Microsoft.Extensions.Logging.EventId ExecuteDeleteFailed" }, @@ -13454,6 +13510,9 @@ { "Member": "static void DuplicateColumnOrders(this Microsoft.EntityFrameworkCore.Diagnostics.IDiagnosticsLogger diagnostics, Microsoft.EntityFrameworkCore.Metadata.StoreObjectIdentifier storeObject, System.Collections.Generic.IReadOnlyList columns);" }, + { + "Member": "static void EntitySplittingFragmentOptionalityChangedWarning(this Microsoft.EntityFrameworkCore.Diagnostics.IDiagnosticsLogger diagnostics, Microsoft.EntityFrameworkCore.Metadata.IEntityType entityType, Microsoft.EntityFrameworkCore.Metadata.StoreObjectIdentifier storeObject, bool optional);" + }, { "Member": "static void ExecuteDeleteFailed(this Microsoft.EntityFrameworkCore.Diagnostics.IDiagnosticsLogger diagnostics, System.Type contextType, System.Exception exception);" }, @@ -16519,6 +16578,12 @@ { "Member": "static string EntitySplittingMissingRequiredPropertiesOptionalDependent(object? entityType, object? storeObject, object? requiredDependentConfig);" }, + { + "Member": "static string EntitySplittingNonNullablePropertyOnOptionalFragment(object? entityType, object? storeObject, object? property);" + }, + { + "Member": "static string EntitySplittingOptionalFragmentSharedTable(object? entityType, object? storeObject, object? principalEntityType);" + }, { "Member": "static string EntitySplittingUnmappedMainFragment(object? entityType, object? storeObject, object? storeObjectType);" }, @@ -18368,7 +18433,7 @@ "Type": "class Microsoft.EntityFrameworkCore.Metadata.RuntimeEntityTypeMappingFragment : Microsoft.EntityFrameworkCore.Infrastructure.AnnotatableBase, Microsoft.EntityFrameworkCore.Metadata.IEntityTypeMappingFragment, Microsoft.EntityFrameworkCore.Metadata.IReadOnlyEntityTypeMappingFragment, Microsoft.EntityFrameworkCore.Infrastructure.IReadOnlyAnnotatable, Microsoft.EntityFrameworkCore.Infrastructure.IAnnotatable", "Methods": [ { - "Member": "RuntimeEntityTypeMappingFragment(Microsoft.EntityFrameworkCore.Metadata.RuntimeEntityType entityType, in Microsoft.EntityFrameworkCore.Metadata.StoreObjectIdentifier storeObject, bool? isTableExcludedFromMigrations);" + "Member": "RuntimeEntityTypeMappingFragment(Microsoft.EntityFrameworkCore.Metadata.RuntimeEntityType entityType, in Microsoft.EntityFrameworkCore.Metadata.StoreObjectIdentifier storeObject, bool? isTableExcludedFromMigrations, bool isOptional = false);" }, { "Member": "override string ToString();" @@ -18378,6 +18443,9 @@ { "Member": "virtual Microsoft.EntityFrameworkCore.Metadata.RuntimeEntityType EntityType { get; }" }, + { + "Member": "virtual bool IsOptional { get; }" + }, { "Member": "virtual bool? IsTableExcludedFromMigrations { get; }" }, @@ -18931,6 +18999,9 @@ { "Member": "virtual Microsoft.EntityFrameworkCore.Metadata.Builders.TableTriggerBuilder HasTrigger(string modelName);" }, + { + "Member": "virtual Microsoft.EntityFrameworkCore.Metadata.Builders.SplitTableBuilder IsOptional(bool optional = true);" + }, { "Member": "virtual Microsoft.EntityFrameworkCore.Metadata.Builders.ColumnBuilder Property(string propertyName);" }, @@ -18965,6 +19036,9 @@ { "Member": "virtual Microsoft.EntityFrameworkCore.Metadata.Builders.SplitTableBuilder HasAnnotation(string annotation, object? value);" }, + { + "Member": "virtual Microsoft.EntityFrameworkCore.Metadata.Builders.SplitTableBuilder IsOptional(bool optional = true);" + }, { "Member": "virtual Microsoft.EntityFrameworkCore.Metadata.Builders.ColumnBuilder Property(System.Linq.Expressions.Expression> propertyExpression);" } diff --git a/src/EFCore.Relational/Infrastructure/RelationalModelValidator.cs b/src/EFCore.Relational/Infrastructure/RelationalModelValidator.cs index adab74ff7c2..9cea823648b 100644 --- a/src/EFCore.Relational/Infrastructure/RelationalModelValidator.cs +++ b/src/EFCore.Relational/Infrastructure/RelationalModelValidator.cs @@ -2398,7 +2398,8 @@ protected virtual void ValidateMappingFragment( entityType.DisplayName(), fragment.StoreObject.DisplayName())); } - foreach (var foreignKey in entityType.FindRowInternalForeignKeys(fragment.StoreObject)) + var rowInternalForeignKeys = entityType.FindRowInternalForeignKeys(fragment.StoreObject).ToList(); + foreach (var foreignKey in rowInternalForeignKeys) { var principalMainFragment = StoreObjectIdentifier.Create( foreignKey.PrincipalEntityType, fragment.StoreObject.StoreObjectType)!.Value; @@ -2413,6 +2414,16 @@ protected virtual void ValidateMappingFragment( } } + if (fragment.IsOptional + && rowInternalForeignKeys.Count > 0) + { + throw new InvalidOperationException( + RelationalStrings.EntitySplittingOptionalFragmentSharedTable( + entityType.DisplayName(), + fragment.StoreObject.DisplayName(), + rowInternalForeignKeys[0].PrincipalEntityType.DisplayName())); + } + var propertiesFound = false; foreach (var property in entityType.GetProperties()) { @@ -2432,6 +2443,14 @@ protected virtual void ValidateMappingFragment( if (!property.IsPrimaryKey()) { propertiesFound = true; + + if (fragment.IsOptional + && !property.IsColumnNullable(fragment.StoreObject)) + { + throw new InvalidOperationException( + RelationalStrings.EntitySplittingNonNullablePropertyOnOptionalFragment( + entityType.DisplayName(), fragment.StoreObject.DisplayName(), property.Name)); + } } } diff --git a/src/EFCore.Relational/Metadata/Builders/OwnedNavigationSplitTableBuilder.cs b/src/EFCore.Relational/Metadata/Builders/OwnedNavigationSplitTableBuilder.cs index ab3030aa4b5..912c54bbfbc 100644 --- a/src/EFCore.Relational/Metadata/Builders/OwnedNavigationSplitTableBuilder.cs +++ b/src/EFCore.Relational/Metadata/Builders/OwnedNavigationSplitTableBuilder.cs @@ -80,6 +80,29 @@ public virtual OwnedNavigationSplitTableBuilder ExcludeFromMigrations(bool exclu return this; } + /// + /// Configures whether a row for this table might not exist even when the principal row exists in the main table + /// for the entity type. + /// + /// + /// + /// When a fragment is optional, a row is only created for it when at least one non-key property mapped to the + /// fragment has a non- value. All non-key properties mapped to an optional fragment + /// must be configured as nullable for that table. + /// + /// + /// See Table splitting for more information and examples. + /// + /// + /// A value indicating whether a row for this table is optional. + /// The same builder instance so that multiple calls can be chained. + public virtual OwnedNavigationSplitTableBuilder IsOptional(bool optional = true) + { + MappingFragment.IsOptional = optional; + + return this; + } + /// /// Configures a database trigger on the table. /// diff --git a/src/EFCore.Relational/Metadata/Builders/OwnedNavigationSplitTableBuilder``.cs b/src/EFCore.Relational/Metadata/Builders/OwnedNavigationSplitTableBuilder``.cs index 9ec9782da31..10689e2f556 100644 --- a/src/EFCore.Relational/Metadata/Builders/OwnedNavigationSplitTableBuilder``.cs +++ b/src/EFCore.Relational/Metadata/Builders/OwnedNavigationSplitTableBuilder``.cs @@ -44,6 +44,25 @@ private OwnedNavigationBuilder OwnedNavigationBu public new virtual OwnedNavigationSplitTableBuilder ExcludeFromMigrations(bool excluded = true) => (OwnedNavigationSplitTableBuilder)base.ExcludeFromMigrations(excluded); + /// + /// Configures whether a row for this table might not exist even when the principal row exists in the main table + /// for the entity type. + /// + /// + /// + /// When a fragment is optional, a row is only created for it when at least one non-key property mapped to the + /// fragment has a non- value. All non-key properties mapped to an optional fragment + /// must be configured as nullable for that table. + /// + /// + /// See Table splitting for more information and examples. + /// + /// + /// A value indicating whether a row for this table is optional. + /// The same builder instance so that multiple calls can be chained. + public new virtual OwnedNavigationSplitTableBuilder IsOptional(bool optional = true) + => (OwnedNavigationSplitTableBuilder)base.IsOptional(optional); + /// /// Maps the property to a column on the current table and returns an object that can be used /// to provide table-specific configuration if the property is mapped to more than one table. diff --git a/src/EFCore.Relational/Metadata/Builders/SplitTableBuilder.cs b/src/EFCore.Relational/Metadata/Builders/SplitTableBuilder.cs index 607c473e412..35d2fc9856f 100644 --- a/src/EFCore.Relational/Metadata/Builders/SplitTableBuilder.cs +++ b/src/EFCore.Relational/Metadata/Builders/SplitTableBuilder.cs @@ -80,6 +80,29 @@ public virtual SplitTableBuilder ExcludeFromMigrations(bool excluded = true) return this; } + /// + /// Configures whether a row for this table might not exist even when the principal row exists in the main table + /// for the entity type. + /// + /// + /// + /// When a fragment is optional, a row is only created for it when at least one non-key property mapped to the + /// fragment has a non- value. All non-key properties mapped to an optional fragment + /// must be configured as nullable for that table. + /// + /// + /// See Table splitting for more information and examples. + /// + /// + /// A value indicating whether a row for this table is optional. + /// The same builder instance so that multiple calls can be chained. + public virtual SplitTableBuilder IsOptional(bool optional = true) + { + MappingFragment.IsOptional = optional; + + return this; + } + /// /// Configures a database trigger on the table. /// diff --git a/src/EFCore.Relational/Metadata/Builders/SplitTableBuilder`.cs b/src/EFCore.Relational/Metadata/Builders/SplitTableBuilder`.cs index c1b1220724f..b3a773aad15 100644 --- a/src/EFCore.Relational/Metadata/Builders/SplitTableBuilder`.cs +++ b/src/EFCore.Relational/Metadata/Builders/SplitTableBuilder`.cs @@ -37,6 +37,25 @@ private EntityTypeBuilder EntityTypeBuilder public new virtual SplitTableBuilder ExcludeFromMigrations(bool excluded = true) => (SplitTableBuilder)base.ExcludeFromMigrations(excluded); + /// + /// Configures whether a row for this table might not exist even when the principal row exists in the main table + /// for the entity type. + /// + /// + /// + /// When a fragment is optional, a row is only created for it when at least one non-key property mapped to the + /// fragment has a non- value. All non-key properties mapped to an optional fragment + /// must be configured as nullable for that table. + /// + /// + /// See Table splitting for more information and examples. + /// + /// + /// A value indicating whether a row for this table is optional. + /// The same builder instance so that multiple calls can be chained. + public new virtual SplitTableBuilder IsOptional(bool optional = true) + => (SplitTableBuilder)base.IsOptional(optional); + /// /// Maps the property to a column on the current table and returns an object that can be used /// to provide table-specific configuration if the property is mapped to more than one table. diff --git a/src/EFCore.Relational/Metadata/Conventions/RelationalRuntimeModelConvention.cs b/src/EFCore.Relational/Metadata/Conventions/RelationalRuntimeModelConvention.cs index 45b8af21534..eff82a712af 100644 --- a/src/EFCore.Relational/Metadata/Conventions/RelationalRuntimeModelConvention.cs +++ b/src/EFCore.Relational/Metadata/Conventions/RelationalRuntimeModelConvention.cs @@ -251,7 +251,8 @@ private static RuntimeEntityTypeMappingFragment Create( => new( runtimeEntityType, entityTypeMappingFragment.StoreObject, - entityTypeMappingFragment.IsTableExcludedFromMigrations); + entityTypeMappingFragment.IsTableExcludedFromMigrations, + entityTypeMappingFragment.IsOptional); /// /// Updates the relational property overrides annotations that will be set on the read-only object. diff --git a/src/EFCore.Relational/Metadata/IConventionEntityTypeMappingFragment.cs b/src/EFCore.Relational/Metadata/IConventionEntityTypeMappingFragment.cs index ff3f453c233..af7403da0a3 100644 --- a/src/EFCore.Relational/Metadata/IConventionEntityTypeMappingFragment.cs +++ b/src/EFCore.Relational/Metadata/IConventionEntityTypeMappingFragment.cs @@ -40,4 +40,18 @@ public interface IConventionEntityTypeMappingFragment : IReadOnlyEntityTypeMappi /// /// The for . ConfigurationSource? GetIsTableExcludedFromMigrationsConfigurationSource(); + + /// + /// Sets a value indicating whether a row might not exist for this fragment's store object even when the + /// principal row exists in the main table for the entity type. + /// + /// A value indicating whether a row for this fragment is optional. + /// Indicates whether the configuration was specified using a data annotation. + bool? SetIsOptional(bool? optional, bool fromDataAnnotation = false); + + /// + /// Gets the for . + /// + /// The for . + ConfigurationSource? GetIsOptionalConfigurationSource(); } diff --git a/src/EFCore.Relational/Metadata/IMutableEntityTypeMappingFragment.cs b/src/EFCore.Relational/Metadata/IMutableEntityTypeMappingFragment.cs index 9a88f1a8365..83d3007de47 100644 --- a/src/EFCore.Relational/Metadata/IMutableEntityTypeMappingFragment.cs +++ b/src/EFCore.Relational/Metadata/IMutableEntityTypeMappingFragment.cs @@ -21,4 +21,11 @@ public interface IMutableEntityTypeMappingFragment : IReadOnlyEntityTypeMappingF /// /// A value indicating whether the associated table is ignored by Migrations. new bool? IsTableExcludedFromMigrations { get; set; } + + /// + /// Gets or sets a value indicating whether a row might not exist for this fragment's store object even when the + /// principal row exists in the main table for the entity type. + /// + /// if a row for this fragment is optional; otherwise. + new bool IsOptional { get; set; } } diff --git a/src/EFCore.Relational/Metadata/IReadOnlyEntityTypeMappingFragment.cs b/src/EFCore.Relational/Metadata/IReadOnlyEntityTypeMappingFragment.cs index 591f6e55291..d03835e54b1 100644 --- a/src/EFCore.Relational/Metadata/IReadOnlyEntityTypeMappingFragment.cs +++ b/src/EFCore.Relational/Metadata/IReadOnlyEntityTypeMappingFragment.cs @@ -29,6 +29,14 @@ public interface IReadOnlyEntityTypeMappingFragment : IReadOnlyAnnotatable /// A value indicating whether the associated table is ignored by Migrations. bool? IsTableExcludedFromMigrations { get; } + /// + /// Gets a value indicating whether a row might not exist for this fragment's store object even when the + /// principal row exists in the main table for the entity type. + /// + /// if a row for this fragment is optional; otherwise. + bool IsOptional + => false; + /// /// /// Creates a human-readable representation of the given metadata. @@ -56,6 +64,11 @@ string ToDebugString(MetadataDebugStringOptions options = MetadataDebugStringOpt builder.Append("ExcludedFromMigrations"); } + if (IsOptional) + { + builder.Append(" Optional"); + } + if ((options & MetadataDebugStringOptions.SingleLine) == 0) { if ((options & MetadataDebugStringOptions.IncludeAnnotations) != 0) diff --git a/src/EFCore.Relational/Metadata/ITableMappingBase.cs b/src/EFCore.Relational/Metadata/ITableMappingBase.cs index f3b42f74bee..1b4e50ca8b0 100644 --- a/src/EFCore.Relational/Metadata/ITableMappingBase.cs +++ b/src/EFCore.Relational/Metadata/ITableMappingBase.cs @@ -40,6 +40,13 @@ public interface ITableMappingBase : IAnnotatable /// bool? IsSplitEntityTypePrincipal { get; } + /// + /// Gets the value indicating whether a row for this mapping might not exist even when a row exists in the + /// principal table-like object for the entity type. Always for the principal mapping. + /// + bool IsSplitFragmentOptional + => false; + /// /// Gets the value indicating whether the mapped table-like object includes rows for the derived entity types. /// Set to for inherited mappings. if the entity type has no derived types. @@ -111,6 +118,11 @@ string ToDebugString(MetadataDebugStringOptions options = MetadataDebugStringOpt builder.Append("IsSplitEntityTypePrincipal"); } + if (IsSplitFragmentOptional) + { + builder.Append(" IsSplitFragmentOptional"); + } + if (!singleLine && (options & MetadataDebugStringOptions.IncludeAnnotations) != 0) { builder.Append(AnnotationsToDebugString(indent + 2)); diff --git a/src/EFCore.Relational/Metadata/Internal/EntityTypeMappingFragment.cs b/src/EFCore.Relational/Metadata/Internal/EntityTypeMappingFragment.cs index eed19d44965..e9e3a59c22c 100644 --- a/src/EFCore.Relational/Metadata/Internal/EntityTypeMappingFragment.cs +++ b/src/EFCore.Relational/Metadata/Internal/EntityTypeMappingFragment.cs @@ -16,10 +16,12 @@ public class EntityTypeMappingFragment : IConventionEntityTypeMappingFragment { private bool? _isTableExcludedFromMigrations; + private bool? _isOptional; private InternalEntityTypeMappingFragmentBuilder? _builder; private ConfigurationSource _configurationSource; private ConfigurationSource? _isTableExcludedFromMigrationsConfigurationSource; + private ConfigurationSource? _isOptionalConfigurationSource; /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to @@ -149,6 +151,14 @@ public static EntityTypeMappingFragment MergeInto( !.Metadata; } + var isOptionalConfigurationSource = detachedFragment.GetIsOptionalConfigurationSource(); + if (isOptionalConfigurationSource != null) + { + existingFragment = ((InternalEntityTypeMappingFragmentBuilder)existingFragment.Builder).SetIsOptional( + detachedFragment.IsOptional, isOptionalConfigurationSource.Value) + !.Metadata; + } + return ((InternalEntityTypeMappingFragmentBuilder)existingFragment.Builder) .MergeAnnotationsFrom((EntityTypeMappingFragment)detachedFragment) .Metadata; @@ -191,6 +201,43 @@ public virtual bool? IsTableExcludedFromMigrations public virtual ConfigurationSource? GetIsTableExcludedFromMigrationsConfigurationSource() => _isTableExcludedFromMigrationsConfigurationSource; + /// + public virtual bool IsOptional + { + get => _isOptional ?? false; + set => SetIsOptional(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? SetIsOptional(bool? optional, ConfigurationSource configurationSource) + { + if (!configurationSource.Overrides(_isOptionalConfigurationSource)) + { + return null; + } + + _isOptional = optional; + _isOptionalConfigurationSource = + optional == null + ? null + : configurationSource.Max(_isOptionalConfigurationSource); + return optional; + } + + /// + /// 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? GetIsOptionalConfigurationSource() + => _isOptionalConfigurationSource; + /// /// 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 @@ -321,12 +368,21 @@ IConventionEntityType IConventionEntityTypeMappingFragment.EntityType => SetIsTableExcludedFromMigrations( excluded, fromDataAnnotation ? ConfigurationSource.DataAnnotation : ConfigurationSource.Convention); + bool? IConventionEntityTypeMappingFragment.SetIsOptional(bool? optional, bool fromDataAnnotation) + => SetIsOptional(optional, fromDataAnnotation ? ConfigurationSource.DataAnnotation : ConfigurationSource.Convention); + bool? IReadOnlyEntityTypeMappingFragment.IsTableExcludedFromMigrations { [DebuggerStepThrough] get => IsTableExcludedFromMigrations; } + bool IReadOnlyEntityTypeMappingFragment.IsOptional + { + [DebuggerStepThrough] + get => IsOptional; + } + IConventionEntityTypeMappingFragmentBuilder IConventionEntityTypeMappingFragment.Builder { [DebuggerStepThrough] diff --git a/src/EFCore.Relational/Metadata/Internal/InternalEntityTypeMappingFragmentBuilder.cs b/src/EFCore.Relational/Metadata/Internal/InternalEntityTypeMappingFragmentBuilder.cs index 7f60361e2c0..545f2b8fc97 100644 --- a/src/EFCore.Relational/Metadata/Internal/InternalEntityTypeMappingFragmentBuilder.cs +++ b/src/EFCore.Relational/Metadata/Internal/InternalEntityTypeMappingFragmentBuilder.cs @@ -57,6 +57,37 @@ public virtual bool CanExcludeTableFromMigrations( => configurationSource.Overrides(Metadata.GetIsTableExcludedFromMigrationsConfigurationSource()) || Metadata.IsTableExcludedFromMigrations == excludedFromMigrations; + /// + /// 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 InternalEntityTypeMappingFragmentBuilder? SetIsOptional( + bool? optional, + ConfigurationSource configurationSource) + { + if (!CanSetIsOptional(optional, configurationSource)) + { + return null; + } + + Metadata.SetIsOptional(optional, configurationSource); + return this; + } + + /// + /// 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 CanSetIsOptional( + bool? optional, + ConfigurationSource configurationSource) + => configurationSource.Overrides(Metadata.GetIsOptionalConfigurationSource()) + || Metadata.IsOptional == optional; + /// IConventionEntityTypeMappingFragment IConventionEntityTypeMappingFragmentBuilder.Metadata { diff --git a/src/EFCore.Relational/Metadata/Internal/RelationalModel.cs b/src/EFCore.Relational/Metadata/Internal/RelationalModel.cs index 80b91601aae..e946337f6d6 100644 --- a/src/EFCore.Relational/Metadata/Internal/RelationalModel.cs +++ b/src/EFCore.Relational/Metadata/Internal/RelationalModel.cs @@ -462,7 +462,8 @@ private static void AddTables( databaseModel, tableMappings, includesDerivedTypes: includesDerivedTypes, - isSplitEntityTypePrincipal: false); + isSplitEntityTypePrincipal: false, + isSplitFragmentOptional: fragment.IsOptional); } CreateTableMapping( @@ -494,7 +495,8 @@ private static void CreateTableMapping( RelationalModel databaseModel, List tableMappings, bool? includesDerivedTypes, - bool? isSplitEntityTypePrincipal = null) + bool? isSplitEntityTypePrincipal = null, + bool isSplitFragmentOptional = false) { if (!databaseModel.Tables.TryGetValue((mappedTable.Name, mappedTable.Schema), out var table)) { @@ -505,7 +507,8 @@ private static void CreateTableMapping( var tableMapping = new TableMapping(typeBase, table, includesDerivedTypes) { - IsSplitEntityTypePrincipal = isSplitEntityTypePrincipal + IsSplitEntityTypePrincipal = isSplitEntityTypePrincipal, + IsSplitFragmentOptional = isSplitFragmentOptional }; var containerColumnName = mappedType.GetContainerColumnName(mappedTable); @@ -570,7 +573,8 @@ private static void CreateTableMapping( databaseModel, complexTableMappings, includesDerivedTypes: true, - isSplitEntityTypePrincipal: isSplitEntityTypePrincipal == true ? false : isSplitEntityTypePrincipal); + isSplitEntityTypePrincipal: isSplitEntityTypePrincipal == true ? false : isSplitEntityTypePrincipal, + isSplitFragmentOptional: isSplitFragmentOptional); } if (((ITableMappingBase)tableMapping).ColumnMappings.Any() @@ -978,7 +982,8 @@ private static void AddViews( databaseModel, viewMappings, includesDerivedTypes: includesDerivedTypes, - isSplitEntityTypePrincipal: false); + isSplitEntityTypePrincipal: false, + isSplitFragmentOptional: fragment.IsOptional); } CreateViewMapping( @@ -1010,7 +1015,8 @@ private static void CreateViewMapping( RelationalModel databaseModel, List viewMappings, bool? includesDerivedTypes, - bool? isSplitEntityTypePrincipal = null) + bool? isSplitEntityTypePrincipal = null, + bool isSplitFragmentOptional = false) { if (!databaseModel.Views.TryGetValue((mappedView.Name, mappedView.Schema), out var view)) { @@ -1021,7 +1027,8 @@ private static void CreateViewMapping( var viewMapping = new ViewMapping(entityType, view, includesDerivedTypes) { - IsSplitEntityTypePrincipal = isSplitEntityTypePrincipal + IsSplitEntityTypePrincipal = isSplitEntityTypePrincipal, + IsSplitFragmentOptional = isSplitFragmentOptional }; var containerColumnName = mappedType.GetContainerColumnName(mappedView); @@ -1085,7 +1092,8 @@ private static void CreateViewMapping( databaseModel, complexViewMappings, includesDerivedTypes: true, - isSplitEntityTypePrincipal: isSplitEntityTypePrincipal == true ? false : isSplitEntityTypePrincipal); + isSplitEntityTypePrincipal: isSplitEntityTypePrincipal == true ? false : isSplitEntityTypePrincipal, + isSplitFragmentOptional: isSplitFragmentOptional); } if (((ITableMappingBase)viewMapping).ColumnMappings.Any() diff --git a/src/EFCore.Relational/Metadata/Internal/TableMappingBase.cs b/src/EFCore.Relational/Metadata/Internal/TableMappingBase.cs index 7f2f26fe570..68c7e859838 100644 --- a/src/EFCore.Relational/Metadata/Internal/TableMappingBase.cs +++ b/src/EFCore.Relational/Metadata/Internal/TableMappingBase.cs @@ -85,6 +85,9 @@ public virtual bool AddColumnMapping(TColumnMapping columnMapping) /// public virtual bool? IsSplitEntityTypePrincipal { get; init; } + /// + public virtual bool IsSplitFragmentOptional { get; init; } + /// /// 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.Relational/Metadata/RelationalAnnotationNames.cs b/src/EFCore.Relational/Metadata/RelationalAnnotationNames.cs index 11c4f990bcf..181aa9499eb 100644 --- a/src/EFCore.Relational/Metadata/RelationalAnnotationNames.cs +++ b/src/EFCore.Relational/Metadata/RelationalAnnotationNames.cs @@ -319,6 +319,11 @@ public static class RelationalAnnotationNames /// public const string MappingFragments = Prefix + "MappingFragments"; + /// + /// The name for the annotation that indicates whether an entity type mapping fragment is optional. + /// + public const string EntityTypeMappingFragmentIsOptional = Prefix + "EntityTypeMappingFragmentIsOptional"; + /// /// The name for the annotation that contains table-specific facet overrides. /// @@ -440,6 +445,7 @@ public static class RelationalAnnotationNames TableIndexMappings, UniqueConstraintMappings, MappingFragments, + EntityTypeMappingFragmentIsOptional, RelationalOverrides, ModelDependencies, FieldValueGetter, diff --git a/src/EFCore.Relational/Metadata/RuntimeEntityTypeMappingFragment.cs b/src/EFCore.Relational/Metadata/RuntimeEntityTypeMappingFragment.cs index 2ecbc9b896a..12d40322918 100644 --- a/src/EFCore.Relational/Metadata/RuntimeEntityTypeMappingFragment.cs +++ b/src/EFCore.Relational/Metadata/RuntimeEntityTypeMappingFragment.cs @@ -19,10 +19,14 @@ public class RuntimeEntityTypeMappingFragment : AnnotatableBase, IEntityTypeMapp /// /// A value indicating whether the associated table is ignored by Migrations. /// + /// + /// A value indicating whether a row for this fragment is optional. + /// public RuntimeEntityTypeMappingFragment( RuntimeEntityType entityType, in StoreObjectIdentifier storeObject, - bool? isTableExcludedFromMigrations) + bool? isTableExcludedFromMigrations, + bool isOptional = false) { EntityType = entityType; StoreObject = storeObject; @@ -30,6 +34,11 @@ public RuntimeEntityTypeMappingFragment( { SetAnnotation(RelationalAnnotationNames.IsTableExcludedFromMigrations, isTableExcludedFromMigrations.Value); } + + if (isOptional) + { + SetAnnotation(RelationalAnnotationNames.EntityTypeMappingFragmentIsOptional, true); + } } /// @@ -44,6 +53,10 @@ public RuntimeEntityTypeMappingFragment( public virtual bool? IsTableExcludedFromMigrations => (bool?)this[RelationalAnnotationNames.IsTableExcludedFromMigrations]; + /// + public virtual bool IsOptional + => (bool?)this[RelationalAnnotationNames.EntityTypeMappingFragmentIsOptional] ?? false; + /// public override string ToString() => ((IEntityTypeMappingFragment)this).ToDebugString(MetadataDebugStringOptions.SingleLineDefault); diff --git a/src/EFCore.Relational/Migrations/Internal/MigrationsModelDiffer.cs b/src/EFCore.Relational/Migrations/Internal/MigrationsModelDiffer.cs index 2ce9e5599b4..f6345307887 100644 --- a/src/EFCore.Relational/Migrations/Internal/MigrationsModelDiffer.cs +++ b/src/EFCore.Relational/Migrations/Internal/MigrationsModelDiffer.cs @@ -56,13 +56,15 @@ public MigrationsModelDiffer( IMigrationsAnnotationProvider migrationsAnnotationProvider, IRelationalAnnotationProvider relationalAnnotationProvider, IRowIdentityMapFactory rowIdentityMapFactory, - CommandBatchPreparerDependencies commandBatchPreparerDependencies) + CommandBatchPreparerDependencies commandBatchPreparerDependencies, + IDiagnosticsLogger logger) { TypeMappingSource = typeMappingSource; MigrationsAnnotationProvider = migrationsAnnotationProvider; RelationalAnnotationProvider = relationalAnnotationProvider; RowIdentityMapFactory = rowIdentityMapFactory; CommandBatchPreparerDependencies = commandBatchPreparerDependencies; + Logger = logger; } /// @@ -105,6 +107,14 @@ public MigrationsModelDiffer( /// protected virtual CommandBatchPreparerDependencies CommandBatchPreparerDependencies { get; } + /// + /// 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 virtual IDiagnosticsLogger Logger { get; } + /// /// 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 @@ -597,6 +607,21 @@ protected virtual IEnumerable Diff( yield break; } + foreach (var sourceMapping in source.EntityTypeMappings) + { + var targetMapping = target.EntityTypeMappings.FirstOrDefault( + m => string.Equals(m.TypeBase.Name, sourceMapping.TypeBase.Name, StringComparison.OrdinalIgnoreCase)); + if (targetMapping != null + && sourceMapping.IsSplitFragmentOptional != targetMapping.IsSplitFragmentOptional + && targetMapping.TypeBase is IEntityType targetEntityType) + { + Logger.EntitySplittingFragmentOptionalityChangedWarning( + targetEntityType, + StoreObjectIdentifier.Table(target.Name, target.Schema), + targetMapping.IsSplitFragmentOptional); + } + } + if (source.Schema != target.Schema || source.Name != target.Name) { diff --git a/src/EFCore.Relational/Properties/RelationalStrings.Designer.cs b/src/EFCore.Relational/Properties/RelationalStrings.Designer.cs index 1e36c879e0a..2347e8df110 100644 --- a/src/EFCore.Relational/Properties/RelationalStrings.Designer.cs +++ b/src/EFCore.Relational/Properties/RelationalStrings.Designer.cs @@ -759,6 +759,22 @@ public static string EntitySplittingMissingRequiredPropertiesOptionalDependent(o GetString("EntitySplittingMissingRequiredPropertiesOptionalDependent", nameof(entityType), nameof(storeObject), nameof(requiredDependentConfig)), entityType, storeObject, requiredDependentConfig); + /// + /// Entity type '{entityType}' has an optional split mapping for '{storeObject}', but the non-nullable property '{property}' is mapped to it. All non-key properties mapped to an optional split fragment must be configured as nullable for '{storeObject}'. + /// + public static string EntitySplittingNonNullablePropertyOnOptionalFragment(object? entityType, object? storeObject, object? property) + => string.Format( + GetString("EntitySplittingNonNullablePropertyOnOptionalFragment", nameof(entityType), nameof(storeObject), nameof(property)), + entityType, storeObject, property); + + /// + /// Entity type '{entityType}' has an optional split mapping for '{storeObject}', but that store object is also shared with entity type '{principalEntityType}' via table splitting. Combining an optional split fragment with table sharing on the same store object is not supported. + /// + public static string EntitySplittingOptionalFragmentSharedTable(object? entityType, object? storeObject, object? principalEntityType) + => string.Format( + GetString("EntitySplittingOptionalFragmentSharedTable", nameof(entityType), nameof(storeObject), nameof(principalEntityType)), + entityType, storeObject, principalEntityType); + /// /// Entity type '{entityType}' has a split mapping for '{storeObject}', but it doesn't have a main mapping of the same type. Map '{entityType}' to '{storeObjectType}'. /// @@ -3383,6 +3399,31 @@ public static EventDefinition LogDuplicateColumnOrders(IDiagnost return (EventDefinition)definition; } + /// + /// The optionality of the entity-splitting fragment '{storeObject}' for entity type '{entityType}' changed to '{optionality}' since the last migration. This may be a purely behavioral change with no corresponding schema change; review the generated migration to confirm it reflects the intended change. + /// + public static EventDefinition LogEntitySplittingFragmentOptionalityChangedWarning(IDiagnosticsLogger logger) + { + var definition = ((RelationalLoggingDefinitions)logger.Definitions).LogEntitySplittingFragmentOptionalityChangedWarning; + if (definition == null) + { + definition = NonCapturingLazyInitializer.EnsureInitialized( + ref ((RelationalLoggingDefinitions)logger.Definitions).LogEntitySplittingFragmentOptionalityChangedWarning, + logger, + static logger => new EventDefinition( + logger.Options, + RelationalEventId.EntitySplittingFragmentOptionalityChangedWarning, + LogLevel.Warning, + "RelationalEventId.EntitySplittingFragmentOptionalityChangedWarning", + level => LoggerMessage.Define( + level, + RelationalEventId.EntitySplittingFragmentOptionalityChangedWarning, + _resourceManager.GetString("LogEntitySplittingFragmentOptionalityChangedWarning")!))); + } + + return (EventDefinition)definition; + } + /// /// An exception occurred while executing an 'ExecuteDelete' operation for context type '{contextType}'.{newline}{error} /// diff --git a/src/EFCore.Relational/Properties/RelationalStrings.resx b/src/EFCore.Relational/Properties/RelationalStrings.resx index 7f3778f86a6..d7dde316ddf 100644 --- a/src/EFCore.Relational/Properties/RelationalStrings.resx +++ b/src/EFCore.Relational/Properties/RelationalStrings.resx @@ -400,6 +400,12 @@ Entity type '{entityType}' has a split mapping and is an optional dependent sharing a store object, but it doesn't map any required non-shared property to the main store object. Keep at least one required non-shared property mapped to a column on '{storeObject}' or mark '{entityType}' as a required dependent by calling '{requiredDependentConfig}'. + + Entity type '{entityType}' has an optional split mapping for '{storeObject}', but the non-nullable property '{property}' is mapped to it. All non-key properties mapped to an optional split fragment must be configured as nullable for '{storeObject}'. + + + Entity type '{entityType}' has an optional split mapping for '{storeObject}', but that store object is also shared with entity type '{principalEntityType}' via table splitting. Combining an optional split fragment with table sharing on the same store object is not supported. + Entity type '{entityType}' has a split mapping for '{storeObject}', but it doesn't have a main mapping of the same type. Map '{entityType}' to '{storeObjectType}'. @@ -812,6 +818,10 @@ The configured column orders for the table '{table}' contains duplicates. Ensure the specified column order values are distinct. Conflicting columns: {columns}. Error RelationalEventId.DuplicateColumnOrders string string + + The optionality of the entity-splitting fragment '{storeObject}' for entity type '{entityType}' changed to '{optionality}' since the last migration. This may be a purely behavioral change with no corresponding schema change; review the generated migration to confirm it reflects the intended change. + Warning RelationalEventId.EntitySplittingFragmentOptionalityChangedWarning string string string + An exception occurred while executing an 'ExecuteDelete' operation for context type '{contextType}'.{newline}{error} Error RelationalEventId.ExecuteDeleteFailed Type string Exception diff --git a/src/EFCore.Relational/Query/RelationalQueryableMethodTranslatingExpressionVisitor.CreateSelect.cs b/src/EFCore.Relational/Query/RelationalQueryableMethodTranslatingExpressionVisitor.CreateSelect.cs index 2485d0f98e3..8049f26fe2f 100644 --- a/src/EFCore.Relational/Query/RelationalQueryableMethodTranslatingExpressionVisitor.CreateSelect.cs +++ b/src/EFCore.Relational/Query/RelationalQueryableMethodTranslatingExpressionVisitor.CreateSelect.cs @@ -416,7 +416,10 @@ Expression ProcessComplexPropertyTpc( .Zip(innerColumns, _sqlExpressionFactory.Equal) .Aggregate(_sqlExpressionFactory.AndAlso); - tables.Add(new InnerJoinExpression(tableExpression, joinPredicate, prunable: true)); + tables.Add( + mapping.IsSplitFragmentOptional + ? new LeftJoinExpression(tableExpression, joinPredicate, prunable: true) + : new InnerJoinExpression(tableExpression, joinPredicate, prunable: true)); } } @@ -427,16 +430,19 @@ Expression ProcessComplexPropertyTpc( continue; } - var columnBase = mappings.Select(e => e.Table.FindColumn(property)).First(e => e != null)!; - propertyMap[property] = CreateColumnExpression(property, columnBase, tableMap[columnBase.Table], nullable: false); + var mapping = mappings.First(e => e.Table.FindColumn(property) != null); + var columnBase = mapping.Table.FindColumn(property)!; + propertyMap[property] = CreateColumnExpression( + property, columnBase, tableMap[columnBase.Table], nullable: mapping.IsSplitFragmentOptional); } var complexPropertyMap = new Dictionary(); foreach (var complexProperty in entityType.GetComplexProperties()) { var table = FindTable(complexProperty, mappings); - complexPropertyMap[complexProperty] = ProcessComplexProperty( - complexProperty, table, tableMap[table], containerNullable: false); + var containerNullable = mappings.First(m => m.Table == table).IsSplitFragmentOptional; + complexPropertyMap[complexProperty] = + ProcessComplexProperty(complexProperty, table, tableMap[table], containerNullable: containerNullable); } var projection = new StructuralTypeProjectionExpression( diff --git a/src/EFCore.Relational/Update/Internal/CommandBatchPreparer.cs b/src/EFCore.Relational/Update/Internal/CommandBatchPreparer.cs index 08f184837c9..83b02b160f5 100644 --- a/src/EFCore.Relational/Update/Internal/CommandBatchPreparer.cs +++ b/src/EFCore.Relational/Update/Internal/CommandBatchPreparer.cs @@ -123,6 +123,19 @@ private IEnumerable CreateCommandBatches( continue; } + if (modificationCommand.EntityState == EntityState.Added + && modificationCommand is ModificationCommand { IsOptionalSplitFragment: true } + && modificationCommand.ColumnModifications.Where(m => !m.IsKey).All(m => m.Value is null)) + { + continue; + } + + if (modificationCommand.EntityState == EntityState.Deleted + && modificationCommand is ModificationCommand { IsOptionalSplitFragmentRowAssumedAbsent: true }) + { + continue; + } + if (!batch.TryAddCommand(modificationCommand)) { if (batch.ModificationCommands.Count == 1 diff --git a/src/EFCore.Relational/Update/ModificationCommand.cs b/src/EFCore.Relational/Update/ModificationCommand.cs index c73bacac841..529aadcc9e6 100644 --- a/src/EFCore.Relational/Update/ModificationCommand.cs +++ b/src/EFCore.Relational/Update/ModificationCommand.cs @@ -94,6 +94,40 @@ public virtual EntityState EntityState /// public virtual IColumnBase? RowsAffectedColumn { get; private set; } + /// + /// Gets a value indicating whether this command targets an optional entity-splitting fragment table, i.e. a table + /// for which a row is only expected to exist when at least one non-key property mapped to it is non-. + /// + public virtual bool IsOptionalSplitFragment + => StoreStoredProcedure is null + && _entries.Count > 0 + && GetTableMapping(_entries[0].EntityType) is { IsSplitFragmentOptional: true }; + + /// + /// Gets a value indicating whether this command targets an optional entity-splitting fragment table whose row is + /// assumed not to exist, because every non-key property mapped to it had a original value + /// when the entity was loaded or last saved. + /// + public virtual bool IsOptionalSplitFragmentRowAssumedAbsent + => StoreStoredProcedure is null + && _entries.Count > 0 + && GetTableMapping(_entries[0].EntityType) is { IsSplitFragmentOptional: true } tableMapping + && tableMapping.ColumnMappings + .Where(m => !m.Property.IsPrimaryKey()) + .All(m => _entries[0].GetOriginalValue(m.Property) is null); + + /// + /// Gets a value indicating whether this command targets an optional entity-splitting fragment table for which + /// every non-key property mapped to it now has a current value. + /// + public virtual bool IsOptionalSplitFragmentPayloadAllNull + => StoreStoredProcedure is null + && _entries.Count > 0 + && GetTableMapping(_entries[0].EntityType) is { IsSplitFragmentOptional: true } tableMapping + && tableMapping.ColumnMappings + .Where(m => !m.Property.IsPrimaryKey()) + .All(m => _entries[0].GetCurrentValue(m.Property) is null); + /// /// The list of needed to perform the insert, update, or delete. /// @@ -163,6 +197,21 @@ public virtual void AddEntry(IUpdateEntry entry, bool mainEntry) .Any(m => m.Table.Name == TableName && m.Table.Schema == Schema) ? EntityState.Modified : entry.EntityState; + + // An optional entity-splitting fragment's row may not exist yet. If every non-key value mapped to it was + // null when the entity was loaded, assume the row is absent and insert it rather than updating it. + if (_entityState == EntityState.Modified + && IsOptionalSplitFragmentRowAssumedAbsent) + { + _entityState = EntityState.Added; + } + // Conversely, if the row exists but every non-key value mapped to it is now null, delete it rather than + // leaving behind an all-null row that a later save would misdetect as absent and try to re-insert. + else if (_entityState == EntityState.Modified + && IsOptionalSplitFragmentPayloadAllNull) + { + _entityState = EntityState.Deleted; + } } else { diff --git a/test/EFCore.Design.Tests/Migrations/Design/MigrationScaffolderTest.cs b/test/EFCore.Design.Tests/Migrations/Design/MigrationScaffolderTest.cs index 342b9451b2e..ed726df1c06 100644 --- a/test/EFCore.Design.Tests/Migrations/Design/MigrationScaffolderTest.cs +++ b/test/EFCore.Design.Tests/Migrations/Design/MigrationScaffolderTest.cs @@ -102,7 +102,8 @@ var migrationAssembly new RelationalAnnotationProvider( new RelationalAnnotationProviderDependencies()), services.GetRequiredService(), - services.GetRequiredService()), + services.GetRequiredService(), + new FakeDiagnosticsLogger()), idGenerator, new MigrationsCodeGeneratorSelector( [ diff --git a/test/EFCore.Relational.Specification.Tests/ModelBuilding/RelationalModelBuilderTest.cs b/test/EFCore.Relational.Specification.Tests/ModelBuilding/RelationalModelBuilderTest.cs index 3ca7e6847ca..007b4e1f9b0 100644 --- a/test/EFCore.Relational.Specification.Tests/ModelBuilding/RelationalModelBuilderTest.cs +++ b/test/EFCore.Relational.Specification.Tests/ModelBuilding/RelationalModelBuilderTest.cs @@ -90,6 +90,72 @@ public virtual void Can_use_table_splitting_with_schema() Assert.Null(customerId.GetColumnName(StoreObjectIdentifier.Table("Order"))); } + [Fact] + public virtual void Can_use_optional_table_splitting() + { + var modelBuilder = CreateModelBuilder(); + modelBuilder.HasDefaultSchema("dbo"); + + modelBuilder.Entity().SplitToTable( + "OrderDetails", s => + { + s.IsOptional(); + s.Property(o => o.CustomerId); + }); + modelBuilder.Ignore(); + modelBuilder.Ignore(); + + var model = modelBuilder.FinalizeModel(); + + var entity = model.FindEntityType(typeof(Order))!; + var fragment = entity.FindMappingFragment(StoreObjectIdentifier.Table("OrderDetails", "dbo"))!; + + Assert.True(fragment.IsOptional); + } + + [Fact] + public virtual void Can_revert_optional_table_splitting() + { + var modelBuilder = CreateModelBuilder(); + modelBuilder.HasDefaultSchema("dbo"); + + modelBuilder.Entity().SplitToTable( + "OrderDetails", s => + { + s.IsOptional(); + s.IsOptional(false); + s.Property(o => o.CustomerId); + }); + modelBuilder.Ignore(); + modelBuilder.Ignore(); + + var model = modelBuilder.FinalizeModel(); + + var entity = model.FindEntityType(typeof(Order))!; + var fragment = entity.FindMappingFragment(StoreObjectIdentifier.Table("OrderDetails", "dbo"))!; + + Assert.False(fragment.IsOptional); + } + + [Fact] + public virtual void Table_splitting_fragment_is_not_optional_by_default() + { + var modelBuilder = CreateModelBuilder(); + modelBuilder.HasDefaultSchema("dbo"); + + modelBuilder.Entity().SplitToTable( + "OrderDetails", s => s.Property(o => o.CustomerId)); + modelBuilder.Ignore(); + modelBuilder.Ignore(); + + var model = modelBuilder.FinalizeModel(); + + var entity = model.FindEntityType(typeof(Order))!; + var fragment = entity.FindMappingFragment(StoreObjectIdentifier.Table("OrderDetails", "dbo"))!; + + Assert.False(fragment.IsOptional); + } + [Fact] public virtual void Can_use_view_splitting() { @@ -1827,6 +1893,8 @@ public abstract class TestSplitTableBuilder public abstract TestSplitTableBuilder ExcludeFromMigrations(bool excluded = true); + public abstract TestSplitTableBuilder IsOptional(bool optional = true); + public abstract TestTriggerBuilder HasTrigger(string name); public abstract TestColumnBuilder Property(string propertyName); @@ -1859,6 +1927,9 @@ protected virtual TestSplitTableBuilder Wrap(SplitTableBuilder public override TestSplitTableBuilder ExcludeFromMigrations(bool excluded = true) => Wrap(TableBuilder.ExcludeFromMigrations(excluded)); + public override TestSplitTableBuilder IsOptional(bool optional = true) + => Wrap(TableBuilder.IsOptional(optional)); + public override TestTriggerBuilder HasTrigger(string name) => new NonGenericTestTriggerBuilder(TableBuilder.HasTrigger(name)); @@ -1893,6 +1964,9 @@ protected virtual TestSplitTableBuilder Wrap(SplitTableBuilder tableBui public override TestSplitTableBuilder ExcludeFromMigrations(bool excluded = true) => Wrap(TableBuilder.ExcludeFromMigrations(excluded)); + public override TestSplitTableBuilder IsOptional(bool optional = true) + => Wrap(TableBuilder.IsOptional(optional)); + public override TestTriggerBuilder HasTrigger(string name) => new NonGenericTestTriggerBuilder(TableBuilder.HasTrigger(name)); diff --git a/test/EFCore.Relational.Tests/Infrastructure/RelationalModelValidatorTest.cs b/test/EFCore.Relational.Tests/Infrastructure/RelationalModelValidatorTest.cs index 17c17edf6b6..ed879d40aae 100644 --- a/test/EFCore.Relational.Tests/Infrastructure/RelationalModelValidatorTest.cs +++ b/test/EFCore.Relational.Tests/Infrastructure/RelationalModelValidatorTest.cs @@ -1551,6 +1551,106 @@ public virtual void Detects_unmapped_foreign_keys_in_entity_splitting() LogLevel.Error); } + [Fact] + public virtual void Optional_entity_splitting_fragment_with_nullable_reference_property_is_valid() + { + var modelBuilder = CreateConventionModelBuilder(); + modelBuilder.Entity().SplitToTable( + "AnimalDetails", s => + { + s.IsOptional(); + s.Property(a => a.Name); + }); + + Validate(modelBuilder); + } + + [Fact] + public virtual void Optional_entity_splitting_fragment_with_nullable_value_type_property_is_valid() + { + var modelBuilder = CreateConventionModelBuilder(); + modelBuilder.Entity().ToTable("Cats"); + modelBuilder.Entity().SplitToTable( + "CatDetails", s => + { + s.IsOptional(); + s.Property("OptionalIdentity"); + }); + + Validate(modelBuilder); + } + + [Fact] + public virtual void Detects_non_nullable_reference_property_on_optional_entity_splitting_fragment() + { + var modelBuilder = CreateConventionModelBuilder(); + modelBuilder.Entity().Property(a => a.Name).IsRequired(); + modelBuilder.Entity().SplitToTable( + "AnimalDetails", s => + { + s.IsOptional(); + s.Property(a => a.Name); + }); + + VerifyError( + RelationalStrings.EntitySplittingNonNullablePropertyOnOptionalFragment( + nameof(Animal), "AnimalDetails", nameof(Animal.Name)), + modelBuilder); + } + + [Fact] + public virtual void Detects_non_nullable_value_type_property_on_optional_entity_splitting_fragment() + { + var modelBuilder = CreateConventionModelBuilder(); + modelBuilder.Entity().ToTable("Cats"); + modelBuilder.Entity().SplitToTable( + "CatDetails", s => + { + s.IsOptional(); + s.Property(c => c.Identity); + }); + + VerifyError( + RelationalStrings.EntitySplittingNonNullablePropertyOnOptionalFragment( + nameof(Cat), "CatDetails", nameof(Cat.Identity)), + modelBuilder); + } + + [Fact] + public virtual void Entity_splitting_primary_key_remains_non_nullable_when_fragment_is_optional() + { + var modelBuilder = CreateConventionModelBuilder(); + modelBuilder.Entity().SplitToTable( + "AnimalDetails", s => + { + s.IsOptional(); + s.Property(a => a.Id); + s.Property(a => a.Name); + }); + + var model = Validate(modelBuilder); + var entityType = model.FindEntityType(typeof(Animal))!; + var storeObject = StoreObjectIdentifier.Table("AnimalDetails"); + + Assert.False(entityType.FindProperty(nameof(Animal.Id))!.IsColumnNullable(storeObject)); + } + + [Fact] + public virtual void Mixed_required_and_optional_entity_splitting_fragments_are_valid() + { + var modelBuilder = CreateConventionModelBuilder(); + modelBuilder.Entity().ToTable("Cats"); + modelBuilder.Entity().SplitToTable("CatRequiredDetails", s => s.Property(c => c.Breed)); + modelBuilder.Entity().SplitToTable( + "CatOptionalDetails", s => + { + s.IsOptional(); + s.Property("OptionalNotes"); + }); + + Validate(modelBuilder); + } + [Fact] public virtual void Detects_duplicate_columns_in_derived_types_with_different_types() { diff --git a/test/EFCore.Relational.Tests/Migrations/Internal/MigrationsModelDifferTest.cs b/test/EFCore.Relational.Tests/Migrations/Internal/MigrationsModelDifferTest.cs index 0265a21f787..651ff770ecc 100644 --- a/test/EFCore.Relational.Tests/Migrations/Internal/MigrationsModelDifferTest.cs +++ b/test/EFCore.Relational.Tests/Migrations/Internal/MigrationsModelDifferTest.cs @@ -1,7 +1,9 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Microsoft.EntityFrameworkCore.Diagnostics.Internal; using Microsoft.EntityFrameworkCore.TestUtilities.FakeProvider; +using Microsoft.EntityFrameworkCore.Update.Internal; // ReSharper disable UnusedAutoPropertyAccessor.Local // ReSharper disable ClassNeverInstantiated.Local @@ -1522,6 +1524,91 @@ public void Foreign_key_to_entity_split_principal_ak_on_fragment_points_to_fragm Assert.Equal("Company", m.Name); })); + [Fact] + public void Detects_entity_splitting_fragment_optionality_change() + { + var (sourceModel, targetModel) = BuildCustomerModels(sourceIsOptional: false, targetIsOptional: true); + + var listLoggerFactory = new ListLoggerFactory(_ => true); + var modelDiffer = CreateModelDifferWithLogger(targetModel, listLoggerFactory); + + modelDiffer.GetDifferences(sourceModel.GetRelationalModel(), targetModel.GetRelationalModel()); + + var warning = Assert.Single( + listLoggerFactory.Log, + l => l.Id == RelationalEventId.EntitySplittingFragmentOptionalityChangedWarning); + Assert.Contains("CustomerDetails", warning.Message); + Assert.Contains("Customer", warning.Message); + Assert.Contains("optional", warning.Message); + } + + [Fact] + public void Does_not_warn_when_entity_splitting_fragment_optionality_is_unchanged() + { + var (sourceModel, targetModel) = BuildCustomerModels(sourceIsOptional: false, targetIsOptional: false); + + var listLoggerFactory = new ListLoggerFactory(_ => true); + var modelDiffer = CreateModelDifferWithLogger(targetModel, listLoggerFactory); + + modelDiffer.GetDifferences(sourceModel.GetRelationalModel(), targetModel.GetRelationalModel()); + + Assert.DoesNotContain( + listLoggerFactory.Log, + l => l.Id == RelationalEventId.EntitySplittingFragmentOptionalityChangedWarning); + } + + private (IModel Source, IModel Target) BuildCustomerModels(bool sourceIsOptional, bool targetIsOptional) + { + IModel BuildModel(bool isOptional) + { + var modelBuilder = CreateModelBuilder(skipConventions: false); + modelBuilder.Entity( + "Customer", x => + { + x.Property("Id"); + x.Property("Name"); + x.SplitToTable( + "CustomerDetails", t => + { + if (isOptional) + { + t.IsOptional(); + } + + t.Property("Description"); + }); + }); + + return modelBuilder.FinalizeModel(designTime: true, skipValidation: true); + } + + return (BuildModel(sourceIsOptional), BuildModel(targetIsOptional)); + } + + private MigrationsModelDiffer CreateModelDifferWithLogger(IModel targetModel, ListLoggerFactory listLoggerFactory) + { + var options = new LoggingOptions(); + options.Initialize(new DbContextOptionsBuilder().EnableSensitiveDataLogging().Options); + var logger = new DiagnosticsLogger( + listLoggerFactory, + options, + new DiagnosticListener("Fake"), + TestHelpers.LoggingDefinitions, + new NullDbContextLogger()); + + var targetOptions = TestHelpers.AddProviderOptions(new DbContextOptionsBuilder()).UseModel(targetModel).Options; + + return new MigrationsModelDiffer( + new TestRelationalTypeMappingSource( + TestServiceFactory.Instance.Create(), + TestServiceFactory.Instance.Create()), + new MigrationsAnnotationProvider(new MigrationsAnnotationProviderDependencies()), + new RelationalAnnotationProvider(new RelationalAnnotationProviderDependencies()), + TestServiceFactory.Instance.Create(), + TestHelpers.CreateContext(targetOptions).GetService(), + logger); + } + [Fact] public void Add_owned_types() => Execute( diff --git a/test/EFCore.Relational.Tests/Migrations/Internal/MigrationsModelDifferTestBase.cs b/test/EFCore.Relational.Tests/Migrations/Internal/MigrationsModelDifferTestBase.cs index 5780c75d792..57358b6d105 100644 --- a/test/EFCore.Relational.Tests/Migrations/Internal/MigrationsModelDifferTestBase.cs +++ b/test/EFCore.Relational.Tests/Migrations/Internal/MigrationsModelDifferTestBase.cs @@ -124,5 +124,6 @@ protected virtual MigrationsModelDiffer CreateModelDiffer(DbContextOptions optio new RelationalAnnotationProvider( new RelationalAnnotationProviderDependencies()), TestServiceFactory.Instance.Create(), - TestHelpers.CreateContext(options).GetService()); + TestHelpers.CreateContext(options).GetService(), + new FakeDiagnosticsLogger()); } diff --git a/test/EFCore.SqlServer.FunctionalTests/Scaffolding/Baselines/BigModel/OwnedTypeEntityType.cs b/test/EFCore.SqlServer.FunctionalTests/Scaffolding/Baselines/BigModel/OwnedTypeEntityType.cs index a2838aeefb9..d900a0a9e99 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Scaffolding/Baselines/BigModel/OwnedTypeEntityType.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Scaffolding/Baselines/BigModel/OwnedTypeEntityType.cs @@ -749,7 +749,8 @@ public static void CreateAnnotations(RuntimeEntityType runtimeEntityType) var detailsFragment = new RuntimeEntityTypeMappingFragment( runtimeEntityType, StoreObjectIdentifier.Table("Details", null), - null); + null, + false); fragments.Add(StoreObjectIdentifier.Table("Details", null), detailsFragment); runtimeEntityType.AddAnnotation("Relational:MappingFragments", fragments); runtimeEntityType.AddAnnotation("Relational:FunctionName", null); diff --git a/test/EFCore.SqlServer.FunctionalTests/Scaffolding/Baselines/No_NativeAOT/OwnedTypeEntityType.cs b/test/EFCore.SqlServer.FunctionalTests/Scaffolding/Baselines/No_NativeAOT/OwnedTypeEntityType.cs index ad6ffc6c9d9..f10ea1f353e 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Scaffolding/Baselines/No_NativeAOT/OwnedTypeEntityType.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Scaffolding/Baselines/No_NativeAOT/OwnedTypeEntityType.cs @@ -219,7 +219,8 @@ public static void CreateAnnotations(RuntimeEntityType runtimeEntityType) var detailsFragment = new RuntimeEntityTypeMappingFragment( runtimeEntityType, StoreObjectIdentifier.Table("Details", null), - null); + null, + false); fragments.Add(StoreObjectIdentifier.Table("Details", null), detailsFragment); runtimeEntityType.AddAnnotation("Relational:MappingFragments", fragments); runtimeEntityType.AddAnnotation("Relational:FunctionName", null); diff --git a/test/EFCore.Sqlite.FunctionalTests/OptionalEntitySplittingSqliteTest.cs b/test/EFCore.Sqlite.FunctionalTests/OptionalEntitySplittingSqliteTest.cs new file mode 100644 index 00000000000..be9e7890131 --- /dev/null +++ b/test/EFCore.Sqlite.FunctionalTests/OptionalEntitySplittingSqliteTest.cs @@ -0,0 +1,269 @@ +// 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; + +#nullable disable + +// Whether an optional entity-splitting fragment's row needs to be inserted, updated, deleted, or left alone is +// decided from the entry's tracked original and current values rather than from provider-specific SQL, so these +// scenarios work identically on every relational provider, including ones like Sqlite that have no special support +// for conditional upserts/deletes. +public class OptionalEntitySplittingSqliteTest : NonSharedModelTestBase, IClassFixture +{ + public OptionalEntitySplittingSqliteTest(NonSharedFixture fixture) + : base(fixture) + { + } + + protected override string NonSharedStoreName + => "OptionalEntitySplittingSaveChangesTest"; + + protected override ITestStoreFactory NonSharedTestStoreFactory + => SqliteTestStoreFactory.Instance; + + private static void OnModelCreating(ModelBuilder modelBuilder) + => modelBuilder.Entity(b => + { + b.ToTable("Customers"); + b.Property(c => c.Id).ValueGeneratedNever(); + b.SplitToTable( + "CustomerDetails", t => + { + t.IsOptional(); + t.Property(c => c.Description); + }); + }); + + private async Task> InitializeContextAsync() + => await InitializeNonSharedTest(OnModelCreating); + + [Fact] + public async Task Insert_with_all_null_optional_payload_does_not_touch_fragment_table() + { + var contextFactory = await InitializeContextAsync(); + + await using (var context = contextFactory.CreateDbContext()) + { + context.Customers.Add(new Customer { Id = 1, Name = "Alice" }); + + // No exception: the optional-fragment INSERT is skipped entirely because every payload value mapped + // to it is null. + await context.SaveChangesAsync(); + } + + await using (var context = contextFactory.CreateDbContext()) + { + var customer = await context.Customers.SingleAsync(); + Assert.Equal("Alice", customer.Name); + Assert.Null(customer.Description); + } + } + + [Fact] + public async Task Insert_with_non_null_optional_payload_inserts_fragment_row() + { + var contextFactory = await InitializeContextAsync(); + + await using (var context = contextFactory.CreateDbContext()) + { + context.Customers.Add(new Customer { Id = 1, Name = "Alice", Description = "Some details" }); + + await context.SaveChangesAsync(); + } + + await using (var context = contextFactory.CreateDbContext()) + { + var customer = await context.Customers.SingleAsync(); + Assert.Equal("Some details", customer.Description); + } + } + + [Fact] + public async Task Setting_previously_absent_optional_fragment_inserts_row() + { + var contextFactory = await InitializeContextAsync(); + + await using (var context = contextFactory.CreateDbContext()) + { + context.Customers.Add(new Customer { Id = 1, Name = "Alice" }); + await context.SaveChangesAsync(); + } + + await using (var context = contextFactory.CreateDbContext()) + { + var customer = await context.Customers.SingleAsync(); + customer.Description = "Some details"; + + // The fragment row was never inserted, so this is treated as an INSERT rather than an UPDATE. + // If it were sent as an UPDATE, it would affect zero rows and throw DbUpdateConcurrencyException. + await context.SaveChangesAsync(); + } + + await using (var context = contextFactory.CreateDbContext()) + { + var customer = await context.Customers.SingleAsync(); + Assert.Equal("Some details", customer.Description); + } + } + + [Fact] + public async Task Updating_previously_present_optional_fragment_updates_row() + { + var contextFactory = await InitializeContextAsync(); + + await using (var context = contextFactory.CreateDbContext()) + { + context.Customers.Add(new Customer { Id = 1, Name = "Alice", Description = "Some details" }); + await context.SaveChangesAsync(); + } + + await using (var context = contextFactory.CreateDbContext()) + { + var customer = await context.Customers.SingleAsync(); + customer.Description = "Other details"; + + // The fragment row already exists, so this must be sent as an UPDATE. If it were sent as an INSERT, + // it would violate the primary key and throw. + await context.SaveChangesAsync(); + } + + await using (var context = contextFactory.CreateDbContext()) + { + var customer = await context.Customers.SingleAsync(); + Assert.Equal("Other details", customer.Description); + } + } + + [Fact] + public async Task Clearing_a_present_optional_fragment_back_to_null_deletes_the_row() + { + var contextFactory = await InitializeContextAsync(); + + await using (var context = contextFactory.CreateDbContext()) + { + context.Customers.Add(new Customer { Id = 1, Name = "Alice", Description = "Some details" }); + await context.SaveChangesAsync(); + } + + await using (var context = contextFactory.CreateDbContext()) + { + var customer = await context.Customers.SingleAsync(); + customer.Description = null; + + // Every payload value mapped to the fragment is now null, so the row is deleted rather than left + // behind with an all-null payload that would be indistinguishable from an absent row. + await context.SaveChangesAsync(); + } + + await using (var context = contextFactory.CreateDbContext()) + { + // Reading the mapped property back as null isn't proof the row is gone: a LEFT JOIN reads back + // null for Description whether the CustomerDetails row is absent or present-with-null. Check the + // fragment table directly to actually prove the row was deleted rather than updated to all-null. + var detailsRowCount = await context.Database + .SqlQuery($"SELECT COUNT(*) AS Value FROM CustomerDetails WHERE Id = 1") + .SingleAsync(); + Assert.Equal(0, detailsRowCount); + } + } + + [Fact] + public async Task Setting_a_cleared_optional_fragment_back_to_non_null_does_not_throw() + { + var contextFactory = await InitializeContextAsync(); + + await using (var context = contextFactory.CreateDbContext()) + { + context.Customers.Add(new Customer { Id = 1, Name = "Alice", Description = "Some details" }); + await context.SaveChangesAsync(); + } + + await using (var context = contextFactory.CreateDbContext()) + { + var customer = await context.Customers.SingleAsync(); + customer.Description = null; + await context.SaveChangesAsync(); + } + + await using (var context = contextFactory.CreateDbContext()) + { + var customer = await context.Customers.SingleAsync(); + customer.Description = "New details"; + + // Without deleting the row in the previous save, this would be misdetected as still-absent and sent + // as an INSERT, violating the primary key against the row left behind by that save. + await context.SaveChangesAsync(); + } + + await using (var context = contextFactory.CreateDbContext()) + { + var customer = await context.Customers.SingleAsync(); + Assert.Equal("New details", customer.Description); + } + } + + [Fact] + public async Task Deleting_entity_with_absent_optional_fragment_completes_without_error() + { + var contextFactory = await InitializeContextAsync(); + + await using (var context = contextFactory.CreateDbContext()) + { + context.Customers.Add(new Customer { Id = 1, Name = "Alice" }); + await context.SaveChangesAsync(); + } + + await using (var context = contextFactory.CreateDbContext()) + { + var customer = await context.Customers.SingleAsync(); + context.Customers.Remove(customer); + + // The fragment row was never inserted, so its DELETE is skipped. If it were sent, it would affect + // zero rows and throw DbUpdateConcurrencyException. + await context.SaveChangesAsync(); + } + + await using (var context = contextFactory.CreateDbContext()) + { + Assert.Empty(await context.Customers.ToListAsync()); + } + } + + [Fact] + public async Task Deleting_entity_with_present_optional_fragment_deletes_row() + { + var contextFactory = await InitializeContextAsync(); + + await using (var context = contextFactory.CreateDbContext()) + { + context.Customers.Add(new Customer { Id = 1, Name = "Alice", Description = "Some details" }); + await context.SaveChangesAsync(); + } + + await using (var context = contextFactory.CreateDbContext()) + { + var customer = await context.Customers.SingleAsync(); + context.Customers.Remove(customer); + + await context.SaveChangesAsync(); + } + + await using (var context = contextFactory.CreateDbContext()) + { + Assert.Empty(await context.Customers.ToListAsync()); + } + } + + protected class CustomerContext(DbContextOptions options) : PoolableDbContext(options) + { + public DbSet Customers { get; set; } + } + + protected class Customer + { + public int Id { get; set; } + public string Name { get; set; } + public string Description { get; set; } + } +} diff --git a/test/EFCore.Sqlite.FunctionalTests/Query/OptionalEntitySplittingQuerySqliteTest.cs b/test/EFCore.Sqlite.FunctionalTests/Query/OptionalEntitySplittingQuerySqliteTest.cs new file mode 100644 index 00000000000..f3a6a0f3201 --- /dev/null +++ b/test/EFCore.Sqlite.FunctionalTests/Query/OptionalEntitySplittingQuerySqliteTest.cs @@ -0,0 +1,177 @@ +// 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.Query; + +#nullable disable + +public class OptionalEntitySplittingQuerySqliteTest : NonSharedModelTestBase, IClassFixture +{ + public OptionalEntitySplittingQuerySqliteTest(NonSharedFixture fixture) + : base(fixture) + { + } + + protected override string NonSharedStoreName + => "OptionalEntitySplittingQueryTest"; + + protected override ITestStoreFactory NonSharedTestStoreFactory + => SqliteTestStoreFactory.Instance; + + private static void OnModelCreating(ModelBuilder modelBuilder) + => modelBuilder.Entity(b => + { + b.ToTable("Customers"); + b.Property(c => c.Id).ValueGeneratedNever(); + + // A required fragment: always joined with INNER JOIN. + b.SplitToTable("CustomerStats", t => t.Property(c => c.VisitCount)); + + // An optional fragment: joined with LEFT JOIN, row may be absent. + b.SplitToTable( + "CustomerDetails", t => + { + t.IsOptional(); + t.Property(c => c.Description); + t.Property(c => c.Score); + }); + }); + + private static async Task Seed(CustomerContext context) + { + context.Customers.AddRange( + new Customer { Id = 1, Name = "Alice", VisitCount = 5 }, + new Customer { Id = 2, Name = "Bob", VisitCount = 3 }, + new Customer { Id = 3, Name = "Carol", VisitCount = 1 }); + await context.SaveChangesAsync(); + + // Customer 1: optional fragment row present, with non-null values. + await context.Database.ExecuteSqlRawAsync( + "INSERT INTO CustomerDetails (Id, Description, Score) VALUES (1, 'Has details', 42)"); + + // Customer 2: optional fragment row absent entirely. + + // Customer 3: optional fragment row present, but its payload is null. + await context.Database.ExecuteSqlRawAsync( + "INSERT INTO CustomerDetails (Id, Description, Score) VALUES (3, NULL, NULL)"); + } + + private async Task> InitializeContextAsync() + => await InitializeNonSharedTest(OnModelCreating, seed: Seed, shouldLogCategory: _ => true); + + [Fact] + public async Task Missing_optional_fragment_row_still_returns_entity() + { + var contextFactory = await InitializeContextAsync(); + await using var context = contextFactory.CreateDbContext(); + + var customers = await context.Customers.OrderBy(c => c.Id).ToListAsync(); + + Assert.Equal(3, customers.Count); + + Assert.Equal("Has details", customers[0].Description); + Assert.Equal(42, customers[0].Score); + Assert.Equal(5, customers[0].VisitCount); + + // Absent row: entity still materializes, missing columns are null. + Assert.Null(customers[1].Description); + Assert.Null(customers[1].Score); + Assert.Equal(3, customers[1].VisitCount); + + // Existing row with null payload: same shape as absent row. + Assert.Null(customers[2].Description); + Assert.Null(customers[2].Score); + Assert.Equal(1, customers[2].VisitCount); + } + + [Fact] + public async Task Required_fragment_uses_inner_join_and_optional_fragment_uses_left_join() + { + var contextFactory = await InitializeContextAsync(); + await using var context = contextFactory.CreateDbContext(); + + var queryString = context.Customers.ToQueryString(); + + Assert.Contains("INNER JOIN \"CustomerStats\"", queryString); + Assert.Contains("LEFT JOIN \"CustomerDetails\"", queryString); + } + + [Fact] + public async Task Predicate_on_optional_fragment_value() + { + var contextFactory = await InitializeContextAsync(); + await using var context = contextFactory.CreateDbContext(); + + var customers = await context.Customers.Where(c => c.Description == "Has details").ToListAsync(); + + Assert.Equal([1], customers.Select(c => c.Id)); + } + + [Fact] + public async Task Predicate_comparing_optional_fragment_property_to_null_matches_absent_and_null_row() + { + var contextFactory = await InitializeContextAsync(); + await using var context = contextFactory.CreateDbContext(); + + var customers = await context.Customers.Where(c => c.Description == null).OrderBy(c => c.Id).ToListAsync(); + + Assert.Equal([2, 3], customers.Select(c => c.Id)); + } + + [Fact] + public async Task Projection_containing_optional_fragment_properties() + { + var contextFactory = await InitializeContextAsync(); + await using var context = contextFactory.CreateDbContext(); + + var results = await context.Customers.OrderBy(c => c.Id) + .Select(c => new { c.Id, c.Description, c.Score }) + .ToListAsync(); + + Assert.Equal(3, results.Count); + Assert.Equal("Has details", results[0].Description); + Assert.Null(results[1].Description); + Assert.Null(results[2].Description); + } + + [Fact] + public async Task NoTracking_query_materializes_optional_fragment_correctly() + { + var contextFactory = await InitializeContextAsync(); + await using var context = contextFactory.CreateDbContext(); + + var customers = await context.Customers.AsNoTracking().OrderBy(c => c.Id).ToListAsync(); + + Assert.Equal("Has details", customers[0].Description); + Assert.Null(customers[1].Description); + Assert.Null(customers[2].Description); + + Assert.All(customers, c => Assert.Equal(EntityState.Detached, context.Entry(c).State)); + } + + [Fact] + public async Task Tracking_query_materializes_optional_fragment_correctly() + { + var contextFactory = await InitializeContextAsync(); + await using var context = contextFactory.CreateDbContext(); + + var customers = await context.Customers.OrderBy(c => c.Id).ToListAsync(); + + Assert.All(customers, c => Assert.Equal(EntityState.Unchanged, context.Entry(c).State)); + Assert.Null(customers[1].Description); + } + + protected class CustomerContext(DbContextOptions options) : PoolableDbContext(options) + { + public DbSet Customers { get; set; } + } + + protected class Customer + { + public int Id { get; set; } + public string Name { get; set; } + public int VisitCount { get; set; } + public string Description { get; set; } + public int? Score { get; set; } + } +} diff --git a/test/EFCore.Sqlite.FunctionalTests/Scaffolding/Baselines/BigModel/OwnedTypeEntityType.cs b/test/EFCore.Sqlite.FunctionalTests/Scaffolding/Baselines/BigModel/OwnedTypeEntityType.cs index 77d68a4682d..d788732fd41 100644 --- a/test/EFCore.Sqlite.FunctionalTests/Scaffolding/Baselines/BigModel/OwnedTypeEntityType.cs +++ b/test/EFCore.Sqlite.FunctionalTests/Scaffolding/Baselines/BigModel/OwnedTypeEntityType.cs @@ -683,7 +683,8 @@ public static void CreateAnnotations(RuntimeEntityType runtimeEntityType) var detailsFragment = new RuntimeEntityTypeMappingFragment( runtimeEntityType, StoreObjectIdentifier.Table("Details", null), - null); + null, + false); fragments.Add(StoreObjectIdentifier.Table("Details", null), detailsFragment); runtimeEntityType.AddAnnotation("Relational:MappingFragments", fragments); runtimeEntityType.AddAnnotation("Relational:FunctionName", null); diff --git a/test/EFCore.Sqlite.FunctionalTests/Scaffolding/Baselines/No_NativeAOT/OwnedTypeEntityType.cs b/test/EFCore.Sqlite.FunctionalTests/Scaffolding/Baselines/No_NativeAOT/OwnedTypeEntityType.cs index 9240baeec74..7d026ba5f16 100644 --- a/test/EFCore.Sqlite.FunctionalTests/Scaffolding/Baselines/No_NativeAOT/OwnedTypeEntityType.cs +++ b/test/EFCore.Sqlite.FunctionalTests/Scaffolding/Baselines/No_NativeAOT/OwnedTypeEntityType.cs @@ -205,7 +205,8 @@ public static void CreateAnnotations(RuntimeEntityType runtimeEntityType) var detailsFragment = new RuntimeEntityTypeMappingFragment( runtimeEntityType, StoreObjectIdentifier.Table("Details", null), - null); + null, + false); fragments.Add(StoreObjectIdentifier.Table("Details", null), detailsFragment); runtimeEntityType.AddAnnotation("Relational:MappingFragments", fragments); runtimeEntityType.AddAnnotation("Relational:FunctionName", null);