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);