From 8cea5863783a82b902d1e7ad3a9b063f079ccddd Mon Sep 17 00:00:00 2001 From: Andriy Svyryd Date: Thu, 6 Feb 2025 11:07:12 -0800 Subject: [PATCH] Add support for optional complex types to model building Breaking change: uniquify and validate complex type columns Part of #31376 --- ...nalCSharpRuntimeAnnotationCodeGenerator.cs | 6 +- .../RelationalPropertyExtensions.cs | 115 ++++--- .../RelationalModelValidator.cs | 107 ++++--- .../Conventions/SharedTableConvention.cs | 17 +- .../Metadata/Internal/RelationalModel.cs | 71 +++-- .../Properties/RelationalStrings.Designer.cs | 18 +- .../Properties/RelationalStrings.resx | 8 +- ...anslatingExpressionVisitor.CreateSelect.cs | 4 +- ...lationalSqlTranslatingExpressionVisitor.cs | 6 +- .../Internal/InternalEntityEntry.cs | 3 +- src/EFCore/Infrastructure/ModelValidator.cs | 290 +++++++++--------- .../Metadata/Internal/ComplexProperty.cs | 13 + src/EFCore/Properties/CoreStrings.Designer.cs | 6 +- src/EFCore/Properties/CoreStrings.resx | 2 +- .../ShapedQueryCompilingExpressionVisitor.cs | 2 +- .../ComplexTypes/PrincipalBaseEntityType.cs | 1 + ...rpMigrationsGeneratorTest.ModelSnapshot.cs | 11 +- .../ComplexTypes/PrincipalBaseEntityType.cs | 1 + .../LazyLoadProxyRelationalTestBase.cs | 48 +++ .../Migrations/MigrationsTestBase.cs | 55 +++- .../CompiledModelRelationalTestBase.cs | 9 +- .../TableSplittingTestBase.cs | 104 ++++--- .../RelationalModelValidatorTest.cs | 29 +- .../Internal/MigrationsModelDifferTest.cs | 6 +- .../ComplexTypesTrackingTestBase.cs | 26 +- .../LazyLoadProxyTestBase.cs | 2 +- .../ModelBuilderTest.ComplexType.cs | 15 +- .../Scaffolding/CompiledModelTestBase.cs | 9 +- .../TestModels/TransportationModel/Engine.cs | 10 +- .../TransportationModel/PoweredVehicle.cs | 4 +- .../LazyLoadProxySqlServerTest.cs | 4 +- .../ComplexTypes/DbContextModelBuilder.cs | 42 ++- .../ComplexTypes/PrincipalBaseEntityType.cs | 1 + .../LazyLoadProxySqliteTest.cs | 4 +- .../Infrastructure/ModelValidatorTest.cs | 15 + 35 files changed, 657 insertions(+), 407 deletions(-) create mode 100644 test/EFCore.Relational.Specification.Tests/LazyLoadProxyRelationalTestBase.cs diff --git a/src/EFCore.Relational/Design/Internal/RelationalCSharpRuntimeAnnotationCodeGenerator.cs b/src/EFCore.Relational/Design/Internal/RelationalCSharpRuntimeAnnotationCodeGenerator.cs index 485e89c3968..b49811c5e21 100644 --- a/src/EFCore.Relational/Design/Internal/RelationalCSharpRuntimeAnnotationCodeGenerator.cs +++ b/src/EFCore.Relational/Design/Internal/RelationalCSharpRuntimeAnnotationCodeGenerator.cs @@ -1498,7 +1498,7 @@ public virtual void Generate(IStoredProcedureMapping sprocMapping, CSharpRuntime private void GenerateAddMapping( ITableMappingBase tableMapping, string tableVariable, - string entityTypeVariable, + string structuralTypeVariable, string tableMappingsVariable, string tableMappingVariable, string mappingType, @@ -1510,7 +1510,7 @@ private void GenerateAddMapping( var typeBase = tableMapping.TypeBase; mainBuilder - .Append($"var {tableMappingVariable} = new {mappingType}({entityTypeVariable}, ") + .Append($"var {tableMappingVariable} = new {mappingType}({structuralTypeVariable}, ") .Append($"{tableVariable}, {additionalParameter ?? ""}{code.Literal(tableMapping.IncludesDerivedTypes)}"); if (tableMapping.IsSharedTablePrincipal.HasValue @@ -1549,7 +1549,7 @@ private void GenerateAddMapping( foreach (var internalForeignKey in table.GetRowInternalForeignKeys(entityType)) { mainBuilder - .Append(tableVariable).Append($".AddRowInternalForeignKey({entityTypeVariable}, ") + .Append(tableVariable).Append($".AddRowInternalForeignKey({structuralTypeVariable}, ") .AppendLine("RelationalModel.GetForeignKey(this,").IncrementIndent() .AppendLine($"{code.Literal(internalForeignKey.DeclaringEntityType.Name)},") .AppendLine($"{code.Literal(internalForeignKey.Properties.Select(p => p.Name).ToArray())},") diff --git a/src/EFCore.Relational/Extensions/RelationalPropertyExtensions.cs b/src/EFCore.Relational/Extensions/RelationalPropertyExtensions.cs index ac188c89e0a..cd18f05e611 100644 --- a/src/EFCore.Relational/Extensions/RelationalPropertyExtensions.cs +++ b/src/EFCore.Relational/Extensions/RelationalPropertyExtensions.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Runtime.CompilerServices; using System.Text; @@ -230,21 +231,54 @@ public static string GetDefaultColumnName(this IReadOnlyProperty property) StringBuilder? builder = null; var currentStoreObject = storeObject; if (property.DeclaringType is IReadOnlyEntityType entityType) + { + builder = CreateOwnershipPrefix(entityType, storeObject, builder); + } + else if (StoreObjectIdentifier.Create(property.DeclaringType, currentStoreObject.StoreObjectType) == currentStoreObject + || property.DeclaringType.GetMappingFragments(storeObject.StoreObjectType) + .Any(f => f.StoreObject == currentStoreObject)) + { + builder = CreateComplexPrefix((IReadOnlyComplexType)property.DeclaringType, storeObject, builder); + } + + var baseName = storeObject.StoreObjectType == StoreObjectType.Table ? property.GetDefaultColumnName() : property.Name; + if (builder == null) + { + return baseName; + } + + builder.Append(baseName); + baseName = builder.ToString(); + + return Uniquifier.Truncate(baseName, property.DeclaringType.Model.GetMaxIdentifierLength()); + + [return: NotNullIfNotNull("builder")] + static StringBuilder? CreateOwnershipPrefix(IReadOnlyEntityType entityType, in StoreObjectIdentifier storeObject, StringBuilder? builder) { while (true) { var ownership = entityType.GetForeignKeys().SingleOrDefault(fk => fk.IsOwnership); if (ownership == null) { - break; + return builder; } var ownerType = ownership.PrincipalEntityType; - if (StoreObjectIdentifier.Create(ownerType, currentStoreObject.StoreObjectType) != currentStoreObject - && ownerType.GetMappingFragments(storeObject.StoreObjectType) - .All(f => f.StoreObject != currentStoreObject)) + if (StoreObjectIdentifier.Create(ownerType, storeObject.StoreObjectType) != storeObject) { - break; + var foundMappedFragment = false; + foreach (var fragment in ownerType.GetMappingFragments(storeObject.StoreObjectType)) + { + if (fragment.StoreObject == storeObject) + { + foundMappedFragment = true; + } + } + + if (!foundMappedFragment) + { + return builder; + } } builder ??= new StringBuilder(); @@ -254,31 +288,27 @@ public static string GetDefaultColumnName(this IReadOnlyProperty property) entityType = ownerType; } } - else if (StoreObjectIdentifier.Create(property.DeclaringType, currentStoreObject.StoreObjectType) == currentStoreObject - || property.DeclaringType.GetMappingFragments(storeObject.StoreObjectType) - .Any(f => f.StoreObject == currentStoreObject)) + + static StringBuilder CreateComplexPrefix(IReadOnlyComplexType complexType, in StoreObjectIdentifier storeObject, StringBuilder? builder) { - var complexType = (IReadOnlyComplexType)property.DeclaringType; builder ??= new StringBuilder(); - while (complexType != null) + while (true) { builder.Insert(0, "_"); builder.Insert(0, complexType.ComplexProperty.Name); - complexType = complexType.ComplexProperty.DeclaringType as IReadOnlyComplexType; + switch (complexType.ComplexProperty.DeclaringType) + { + case IReadOnlyComplexType declaringComplexType: + complexType = declaringComplexType; + break; + case IReadOnlyEntityType declaringEntityType: + return CreateOwnershipPrefix(declaringEntityType, storeObject, builder); + default: + return builder; + } } } - - var baseName = storeObject.StoreObjectType == StoreObjectType.Table ? property.GetDefaultColumnName() : property.Name; - if (builder == null) - { - return baseName; - } - - builder.Append(baseName); - baseName = builder.ToString(); - - return Uniquifier.Truncate(baseName, property.DeclaringType.Model.GetMaxIdentifierLength()); } /// @@ -1183,7 +1213,6 @@ public static void SetIsFixedLength(this IMutableProperty property, bool? fixedL /// /// This depends on the property itself and also how it is mapped. For example, /// derived non-nullable properties in a TPH type hierarchy will be mapped to nullable columns. - /// As well as properties on optional types sharing the same table. /// /// The . /// if the mapped column is nullable; otherwise. @@ -1191,7 +1220,14 @@ public static bool IsColumnNullable(this IReadOnlyProperty property) => property.IsNullable || (property.DeclaringType.ContainingEntityType is IReadOnlyEntityType entityType && entityType.BaseType != null - && entityType.GetMappingStrategy() == RelationalAnnotationNames.TphMappingStrategy); + && entityType.GetMappingStrategy() == RelationalAnnotationNames.TphMappingStrategy) + || (property.DeclaringType is IReadOnlyComplexType complexType + && IsNullable(complexType.ComplexProperty)); + + private static bool IsNullable(IReadOnlyComplexProperty complexProperty) + => complexProperty.IsNullable + || (complexProperty.DeclaringType is IReadOnlyComplexType complexType + && IsNullable(complexType.ComplexProperty)); /// /// Checks whether the column mapped to the given property will be nullable @@ -1222,7 +1258,9 @@ public static bool IsColumnNullable(this IReadOnlyProperty property, in StoreObj || (property.DeclaringType.ContainingEntityType is IReadOnlyEntityType entityType && ((entityType.BaseType != null && entityType.GetMappingStrategy() == RelationalAnnotationNames.TphMappingStrategy) - || IsOptionalSharingDependent(entityType, storeObject, 0))); + || IsOptionalSharingDependent(entityType, storeObject, 0))) + || (property.DeclaringType is IReadOnlyComplexType complexType + && IsNullable(complexType.ComplexProperty)); } private static bool IsOptionalSharingDependent( @@ -1476,7 +1514,7 @@ public static RelationalTypeMapping GetRelationalTypeMapping(this IReadOnlyPrope if (property.DeclaringType.IsMappedToJson()) { //JSON-splitting is not supported - //issue #28574 + //Issue #28574 return null; } @@ -1494,20 +1532,15 @@ public static RelationalTypeMapping GetRelationalTypeMapping(this IReadOnlyPrope // Using a hashset is detrimental to the perf when there are no cycles for (var i = 0; i < Metadata.Internal.RelationalEntityTypeExtensions.MaxEntityTypesSharingTable; i++) { - var entityType = rootProperty.DeclaringType as IReadOnlyEntityType; - if (entityType == null) - { - break; - } - + var entityType = rootProperty.DeclaringType.ContainingEntityType; IReadOnlyProperty? linkedProperty = null; - foreach (var p in entityType + foreach (var principalProperty in entityType .FindRowInternalForeignKeys(storeObject) - .SelectMany(fk => fk.PrincipalEntityType.GetProperties())) + .SelectMany(static fk => fk.PrincipalEntityType.GetProperties())) { - if (p.GetColumnName(storeObject) == column) + if (principalProperty.GetColumnName(storeObject) == column) { - linkedProperty = p; + linkedProperty = principalProperty; break; } } @@ -1538,8 +1571,8 @@ public static RelationalTypeMapping GetRelationalTypeMapping(this IReadOnlyPrope // Using a hashset is detrimental to the perf when there are no cycles for (var i = 0; i < Metadata.Internal.RelationalEntityTypeExtensions.MaxEntityTypesSharingTable; i++) { - var entityType = principalProperty.DeclaringType as IReadOnlyEntityType; - var linkingRelationship = entityType?.FindRowInternalForeignKeys(storeObject).FirstOrDefault(); + var entityType = principalProperty.DeclaringType.ContainingEntityType; + var linkingRelationship = entityType.FindRowInternalForeignKeys(storeObject).FirstOrDefault(); if (linkingRelationship == null) { break; @@ -1566,8 +1599,8 @@ public static RelationalTypeMapping GetRelationalTypeMapping(this IReadOnlyPrope // Using a hashset is detrimental to the perf when there are no cycles for (var i = 0; i < Metadata.Internal.RelationalEntityTypeExtensions.MaxEntityTypesSharingTable; i++) { - var entityType = principalProperty.DeclaringType as IReadOnlyEntityType; - var linkingRelationship = entityType?.FindRowInternalForeignKeys(storeObject).FirstOrDefault(); + var entityType = principalProperty.DeclaringType.ContainingEntityType; + var linkingRelationship = entityType.FindRowInternalForeignKeys(storeObject).FirstOrDefault(); if (linkingRelationship == null) { break; @@ -1595,7 +1628,7 @@ public static RelationalTypeMapping GetRelationalTypeMapping(this IReadOnlyPrope /// The property. /// The property facet overrides. public static IEnumerable GetOverrides(this IReadOnlyProperty property) - => RelationalPropertyOverrides.Get(property) ?? Enumerable.Empty(); + => RelationalPropertyOverrides.Get(property) ?? []; /// /// diff --git a/src/EFCore.Relational/Infrastructure/RelationalModelValidator.cs b/src/EFCore.Relational/Infrastructure/RelationalModelValidator.cs index 868319c8b14..a2bd60aaecc 100644 --- a/src/EFCore.Relational/Infrastructure/RelationalModelValidator.cs +++ b/src/EFCore.Relational/Infrastructure/RelationalModelValidator.cs @@ -97,6 +97,22 @@ static void ValidateType(ITypeBase typeBase) } } + /// + protected override void ValidatePropertyMapping(IConventionComplexProperty complexProperty) + { + base.ValidatePropertyMapping(complexProperty); + + var typeBase = complexProperty.DeclaringType; + + if (!typeBase.IsMappedToJson() + && complexProperty.IsNullable + && complexProperty.ComplexType.GetProperties().All(m => m.IsNullable)) + { + throw new InvalidOperationException( + RelationalStrings.ComplexPropertyOptionalTableSharing(typeBase.DisplayName(), complexProperty.Name)); + } + } + /// /// Validates the mapping/configuration of SQL queries in the model. /// @@ -1239,16 +1255,56 @@ protected virtual void ValidateSharedColumnsCompatibility( if (missingConcurrencyTokens != null) { missingConcurrencyTokens.Clear(); - foreach (var (key, readOnlyProperties) in concurrencyColumns!) + foreach (var (concurrencyColumn, concurrencyProperties) in concurrencyColumns!) { - if (TableSharingConcurrencyTokenConvention.IsConcurrencyTokenMissing(readOnlyProperties, entityType, mappedTypes)) + if (TableSharingConcurrencyTokenConvention.IsConcurrencyTokenMissing(concurrencyProperties, entityType, mappedTypes)) { - missingConcurrencyTokens.Add(key); + missingConcurrencyTokens.Add(concurrencyColumn); } } } - foreach (var property in entityType.GetDeclaredProperties()) + ValidateCompatible(entityType, storeObject, propertyMappings, missingConcurrencyTokens, logger); + + if (missingConcurrencyTokens != null) + { + foreach (var concurrencyColumn in missingConcurrencyTokens) + { + throw new InvalidOperationException( + RelationalStrings.MissingConcurrencyColumn( + entityType.DisplayName(), concurrencyColumn, storeObject.DisplayName())); + } + } + } + + var columnOrders = new Dictionary>(); + foreach (var property in propertyMappings.Values) + { + var columnOrder = property.GetColumnOrder(storeObject); + if (!columnOrder.HasValue) + { + continue; + } + + var columns = columnOrders.GetOrAddNew(columnOrder.Value); + columns.Add(property.GetColumnName(storeObject)!); + } + + if (columnOrders.Any(g => g.Value.Count > 1)) + { + logger.DuplicateColumnOrders( + storeObject, + columnOrders.Where(g => g.Value.Count > 1).SelectMany(g => g.Value).ToList()); + } + + void ValidateCompatible( + ITypeBase structuralType, + in StoreObjectIdentifier storeObject, + Dictionary propertyMappings, + HashSet? missingConcurrencyTokens, + IDiagnosticsLogger logger) + { + foreach (var property in structuralType.GetDeclaredProperties()) { var columnName = property.GetColumnName(storeObject); if (columnName == null) @@ -1276,39 +1332,14 @@ protected virtual void ValidateSharedColumnsCompatibility( storeObject.DisplayName())); } - ValidateCompatible(property, duplicateProperty, columnName, storeObject, logger); + this.ValidateCompatible(property, duplicateProperty, columnName, storeObject, logger); } - if (missingConcurrencyTokens != null) + foreach (var complexProperty in structuralType.GetDeclaredComplexProperties()) { - foreach (var missingColumn in missingConcurrencyTokens) - { - throw new InvalidOperationException( - RelationalStrings.MissingConcurrencyColumn( - entityType.DisplayName(), missingColumn, storeObject.DisplayName())); - } + ValidateCompatible(complexProperty.ComplexType, storeObject, propertyMappings, missingConcurrencyTokens, logger); } } - - var columnOrders = new Dictionary>(); - foreach (var property in propertyMappings.Values) - { - var columnOrder = property.GetColumnOrder(storeObject); - if (!columnOrder.HasValue) - { - continue; - } - - var columns = columnOrders.GetOrAddNew(columnOrder.Value); - columns.Add(property.GetColumnName(storeObject)!); - } - - if (columnOrders.Any(g => g.Value.Count > 1)) - { - logger.DuplicateColumnOrders( - storeObject, - columnOrders.Where(g => g.Value.Count > 1).SelectMany(g => g.Value).ToList()); - } } /// @@ -1326,17 +1357,7 @@ protected virtual void ValidateCompatible( in StoreObjectIdentifier storeObject, IDiagnosticsLogger logger) { - if (property.IsColumnNullable(storeObject) != duplicateProperty.IsColumnNullable(storeObject)) - { - throw new InvalidOperationException( - RelationalStrings.DuplicateColumnNameNullabilityMismatch( - duplicateProperty.DeclaringType.DisplayName(), - duplicateProperty.Name, - property.DeclaringType.DisplayName(), - property.Name, - columnName, - storeObject.DisplayName())); - } + // NB: Properties can have different nullability, the resulting column will be non-nullable if any of the properties is non-nullable var currentMaxLength = property.GetMaxLength(storeObject); var previousMaxLength = duplicateProperty.GetMaxLength(storeObject); diff --git a/src/EFCore.Relational/Metadata/Conventions/SharedTableConvention.cs b/src/EFCore.Relational/Metadata/Conventions/SharedTableConvention.cs index b0f722f86da..be7355e1a53 100644 --- a/src/EFCore.Relational/Metadata/Conventions/SharedTableConvention.cs +++ b/src/EFCore.Relational/Metadata/Conventions/SharedTableConvention.cs @@ -235,9 +235,18 @@ private static void UniquifyColumnNames( continue; } - var identifyingMemberInfo = property.PropertyInfo ?? (MemberInfo?)property.FieldInfo; - if ((identifyingMemberInfo != null - && identifyingMemberInfo.IsSameAs(otherProperty.PropertyInfo ?? (MemberInfo?)otherProperty.FieldInfo)) + var declaringEntityType = property.DeclaringType as IConventionEntityType; +#pragma warning disable EF1001 // Internal EF Core API usage. + var identifyingMemberInfo = property.GetIdentifyingMemberInfo(); + var isInheritedSharedMember = identifyingMemberInfo != null + && ((declaringEntityType != null && identifyingMemberInfo.DeclaringType != type.ClrType) + || (declaringEntityType == null + && otherProperty.DeclaringType is IConventionComplexType otherDeclaringComplexType + && ((IConventionComplexType)property.DeclaringType).ComplexProperty.GetIdentifyingMemberInfo() + .IsSameAs(otherDeclaringComplexType.ComplexProperty.GetIdentifyingMemberInfo()))) + && identifyingMemberInfo.IsSameAs(otherProperty.GetIdentifyingMemberInfo()); +#pragma warning restore EF1001 // Internal EF Core API usage. + if (isInheritedSharedMember || (property.IsPrimaryKey() && otherProperty.IsPrimaryKey()) || (property.IsConcurrencyToken && otherProperty.IsConcurrencyToken) || (!property.Builder.CanSetColumnName(null) && !otherProperty.Builder.CanSetColumnName(null))) @@ -262,7 +271,7 @@ private static void UniquifyColumnNames( if (!usePrefix || (!property.DeclaringType.IsStrictlyDerivedFrom(otherProperty.DeclaringType) && !otherProperty.DeclaringType.IsStrictlyDerivedFrom(property.DeclaringType)) - || (property.DeclaringType as IConventionEntityType)?.FindRowInternalForeignKeys(storeObject).Any() == true) + || declaringEntityType?.FindRowInternalForeignKeys(storeObject).Any() == true) { var newColumnName = TryUniquify(property, columnName, columns, storeObject, usePrefix, maxLength); if (newColumnName != null) diff --git a/src/EFCore.Relational/Metadata/Internal/RelationalModel.cs b/src/EFCore.Relational/Metadata/Internal/RelationalModel.cs index ae7dd11e42c..8e2294fe404 100644 --- a/src/EFCore.Relational/Metadata/Internal/RelationalModel.cs +++ b/src/EFCore.Relational/Metadata/Internal/RelationalModel.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Reflection.Metadata; using System.Text; using System.Text.Json; @@ -1559,54 +1560,60 @@ private static void PopulateRowInternalForeignKeys(TableBase tab mainMapping.SetIsSharedTablePrincipal(true); } - var referencingInternalForeignKeyMap = table.ReferencingRowInternalForeignKeys; - if (referencingInternalForeignKeyMap != null) - { - var optionalTypes = new Dictionary(); - var entityTypesToVisit = new Queue<(ITypeBase, bool)>(); - entityTypesToVisit.Enqueue(((IEntityType)mainMapping.TypeBase, false)); + var optionalTypes = new Dictionary(); + var entityTypesToVisit = new Queue<(ITypeBase, bool)>(); + entityTypesToVisit.Enqueue(((IEntityType)mainMapping.TypeBase, false)); - while (entityTypesToVisit.Count > 0) + while (entityTypesToVisit.Count > 0) + { + var (typeBase, optional) = entityTypesToVisit.Dequeue(); + if (optionalTypes.TryGetValue(typeBase, out var previouslyOptional) + && (!previouslyOptional || optional)) { - var (typeBase, optional) = entityTypesToVisit.Dequeue(); - if (optionalTypes.TryGetValue(typeBase, out var previouslyOptional) - && (!previouslyOptional || optional)) - { - continue; - } + continue; + } - optionalTypes[typeBase] = optional; + optionalTypes[typeBase] = optional; - if (typeBase is IComplexType complexType) + foreach (var complexProperty in typeBase.GetComplexProperties()) + { + if (!complexProperty.ComplexType.IsMappedToJson()) { - var complexProperty = complexType.ComplexProperty; - entityTypesToVisit.Enqueue((complexProperty.DeclaringType, optional || complexProperty.IsNullable)); - continue; + entityTypesToVisit.Enqueue((complexProperty.ComplexType, optional || complexProperty.IsNullable)); } + } - var entityType = (IEntityType)typeBase; - if (referencingInternalForeignKeyMap.TryGetValue(entityType, out var referencingInternalForeignKeys)) + if (typeBase is not IEntityType entityType) + { + continue; + } + + var referencingInternalForeignKeyMap = table.ReferencingRowInternalForeignKeys; + if (referencingInternalForeignKeyMap != null + && referencingInternalForeignKeyMap.TryGetValue(entityType, out var referencingInternalForeignKeys)) + { + foreach (var referencingForeignKey in referencingInternalForeignKeys) { - foreach (var referencingForeignKey in referencingInternalForeignKeys) - { - entityTypesToVisit.Enqueue( - (referencingForeignKey.DeclaringEntityType, optional || !referencingForeignKey.IsRequiredDependent)); - } + entityTypesToVisit.Enqueue( + (referencingForeignKey.DeclaringEntityType, optional || !referencingForeignKey.IsRequiredDependent)); } + } - if (table.EntityTypeMappings.Single(etm => etm.TypeBase == typeBase).IncludesDerivedTypes == true) + if (table.EntityTypeMappings.Single(etm => etm.TypeBase == typeBase).IncludesDerivedTypes == true) + { + foreach (var directlyDerivedEntityType in entityType.GetDirectlyDerivedTypes()) { - foreach (var directlyDerivedEntityType in entityType.GetDirectlyDerivedTypes()) + if (mappedEntityTypes.Contains(directlyDerivedEntityType) + && !optionalTypes.ContainsKey(directlyDerivedEntityType)) { - if (mappedEntityTypes.Contains(directlyDerivedEntityType) - && !optionalTypes.ContainsKey(directlyDerivedEntityType)) - { - entityTypesToVisit.Enqueue((directlyDerivedEntityType, optional)); - } + entityTypesToVisit.Enqueue((directlyDerivedEntityType, optional)); } } } + } + if (optionalTypes.Count > 1) + { table.OptionalTypes = optionalTypes; } } diff --git a/src/EFCore.Relational/Properties/RelationalStrings.Designer.cs b/src/EFCore.Relational/Properties/RelationalStrings.Designer.cs index 927566137aa..5220ac5bddc 100644 --- a/src/EFCore.Relational/Properties/RelationalStrings.Designer.cs +++ b/src/EFCore.Relational/Properties/RelationalStrings.Designer.cs @@ -95,6 +95,14 @@ public static string CompiledModelFunctionTranslation(object? function) GetString("CompiledModelFunctionTranslation", nameof(function)), function); + /// + /// The optional complex property '{type}.{property}' is mapped using table sharing, but only contains optional properties. Add a required property or discriminator or map this complex property to a JSON column. + /// + public static string ComplexPropertyOptionalTableSharing(object? type, object? property) + => string.Format( + GetString("ComplexPropertyOptionalTableSharing", nameof(type), nameof(property)), + type, property); + /// /// The computed column SQL has not been specified for the column '{table}.{column}'. Specify the SQL before using Entity Framework to create the database schema. /// @@ -465,14 +473,6 @@ public static string DuplicateColumnNameMaxLengthMismatch(object? entityType1, o GetString("DuplicateColumnNameMaxLengthMismatch", nameof(entityType1), nameof(property1), nameof(entityType2), nameof(property2), nameof(columnName), nameof(table), nameof(maxLength1), nameof(maxLength2)), entityType1, property1, entityType2, property2, columnName, table, maxLength1, maxLength2); - /// - /// '{entityType1}.{property1}' and '{entityType2}.{property2}' are both mapped to column '{columnName}' in '{table}', but are configured with different column nullability settings. - /// - public static string DuplicateColumnNameNullabilityMismatch(object? entityType1, object? property1, object? entityType2, object? property2, object? columnName, object? table) - => string.Format( - GetString("DuplicateColumnNameNullabilityMismatch", nameof(entityType1), nameof(property1), nameof(entityType2), nameof(property2), nameof(columnName), nameof(table)), - entityType1, property1, entityType2, property2, columnName, table); - /// /// '{entityType1}.{property1}' and '{entityType2}.{property2}' are both mapped to column '{columnName}' in '{table}', but are configured to use different column orders ('{columnOrder1}' and '{columnOrder2}'). /// @@ -498,7 +498,7 @@ public static string DuplicateColumnNameProviderTypeMismatch(object? entityType1 entityType1, property1, entityType2, property2, columnName, table, type1, type2); /// - /// '{entityType1}.{property1}' and '{entityType2}.{property2}' are both mapped to column '{columnName}' in '{table}', but the properties are contained within the same hierarchy. All properties on an entity type must be mapped to unique different columns. + /// '{entityType1}.{property1}' and '{entityType2}.{property2}' are both mapped to column '{columnName}' in '{table}', but the properties are contained within the same hierarchy. All properties on an entity type must be mapped to different columns. /// public static string DuplicateColumnNameSameHierarchy(object? entityType1, object? property1, object? entityType2, object? property2, object? columnName, object? table) => string.Format( diff --git a/src/EFCore.Relational/Properties/RelationalStrings.resx b/src/EFCore.Relational/Properties/RelationalStrings.resx index 8f6792eb005..2641f26d37e 100644 --- a/src/EFCore.Relational/Properties/RelationalStrings.resx +++ b/src/EFCore.Relational/Properties/RelationalStrings.resx @@ -148,6 +148,9 @@ The function '{function}' has a custom translation. Compiled model can't be generated, because custom function translations are not supported. + + The optional complex property '{type}.{property}' is mapped using table sharing, but only contains optional properties. Add a required property or discriminator or map this complex property to a JSON column. + The computed column SQL has not been specified for the column '{table}.{column}'. Specify the SQL before using Entity Framework to create the database schema. @@ -289,9 +292,6 @@ '{entityType1}.{property1}' and '{entityType2}.{property2}' are both mapped to column '{columnName}' in '{table}', but are configured with different maximum lengths ('{maxLength1}' and '{maxLength2}'). - - '{entityType1}.{property1}' and '{entityType2}.{property2}' are both mapped to column '{columnName}' in '{table}', but are configured with different column nullability settings. - '{entityType1}.{property1}' and '{entityType2}.{property2}' are both mapped to column '{columnName}' in '{table}', but are configured to use different column orders ('{columnOrder1}' and '{columnOrder2}'). @@ -302,7 +302,7 @@ '{entityType1}.{property1}' and '{entityType2}.{property2}' are both mapped to column '{columnName}' in '{table}', but are configured to use differing provider types ('{type1}' and '{type2}'). - '{entityType1}.{property1}' and '{entityType2}.{property2}' are both mapped to column '{columnName}' in '{table}', but the properties are contained within the same hierarchy. All properties on an entity type must be mapped to unique different columns. + '{entityType1}.{property1}' and '{entityType2}.{property2}' are both mapped to column '{columnName}' in '{table}', but the properties are contained within the same hierarchy. All properties on an entity type must be mapped to different columns. '{entityType1}.{property1}' and '{entityType2}.{property2}' are both mapped to column '{columnName}' in '{table}', but are configured with different scales ('{scale1}' and '{scale2}'). diff --git a/src/EFCore.Relational/Query/RelationalQueryableMethodTranslatingExpressionVisitor.CreateSelect.cs b/src/EFCore.Relational/Query/RelationalQueryableMethodTranslatingExpressionVisitor.CreateSelect.cs index 53cbe8b7bff..4a901fa5bd8 100644 --- a/src/EFCore.Relational/Query/RelationalQueryableMethodTranslatingExpressionVisitor.CreateSelect.cs +++ b/src/EFCore.Relational/Query/RelationalQueryableMethodTranslatingExpressionVisitor.CreateSelect.cs @@ -474,7 +474,7 @@ private void AddEntitySelectConditions(SelectExpression selectExpression, IEntit && allNonSharedNonPkProperties.All(p => p.IsNullable)) { var atLeastOneNonNullValueInNullablePropertyCondition = allNonSharedNonPkProperties - .Select(e => IsNotNull(e, projection)) + .Select(p => IsNotNull(p, projection)) .Aggregate(_sqlExpressionFactory.OrElse); predicate = predicate == null @@ -607,7 +607,7 @@ protected virtual SelectExpression CreateSelect( var table = entityType.GetViewOrTableMappings().SingleOrDefault()?.Table ?? entityType.GetDefaultMappings().Single().Table; var tableAlias = tableExpressionBase.Alias!; - // TODO: We'll need to make sure this is correct when we add support for JSON complex types. + // TODO: We'll need to make sure this is correct when we add support for JSON complex types, #31252 var tableMap = new Dictionary { [table] = tableAlias }; var projection = new StructuralTypeProjectionExpression( diff --git a/src/EFCore.Relational/Query/RelationalSqlTranslatingExpressionVisitor.cs b/src/EFCore.Relational/Query/RelationalSqlTranslatingExpressionVisitor.cs index 25ac98092b1..512c676a158 100644 --- a/src/EFCore.Relational/Query/RelationalSqlTranslatingExpressionVisitor.cs +++ b/src/EFCore.Relational/Query/RelationalSqlTranslatingExpressionVisitor.cs @@ -1334,7 +1334,7 @@ private StructuralTypeReferenceExpression BindComplexProperty( case { Parameter: StructuralTypeShaperExpression shaper }: var projection = (StructuralTypeProjectionExpression)Visit(shaper.ValueBufferExpression); - // TODO: Move all this logic into StructuralTypeProjectionExpression + // TODO: Move all this logic into StructuralTypeProjectionExpression, #31376 Check.DebugAssert(projection.IsNullable == shaper.IsNullable, "Nullability mismatch"); return new StructuralTypeReferenceExpression(projection.BindComplexProperty(complexProperty)); @@ -1906,7 +1906,7 @@ bool TryRewriteComplexTypeEquality([NotNullWhen(true)] out Expression? result) || IsNullSqlConstantExpression(right)) { // TODO: when we support optional complex types - or projecting required complex types via optional navigations - we'll - // be able to translate this. + // be able to translate this, #31376 throw new InvalidOperationException(RelationalStrings.CannotCompareComplexTypeToNull); } @@ -1918,7 +1918,7 @@ bool TryRewriteComplexTypeEquality([NotNullWhen(true)] out Expression? result) // If a complex type is the result of a subquery, then comparing its columns would mean duplicating the subquery, which would // be potentially very inefficient. - // TODO: Enable this by extracting the subquery out to a common table expressions (WITH) + // TODO: Enable this by extracting the subquery out to a common table expressions (WITH), #31237 if (leftReference is { Subquery: not null } || rightReference is { Subquery: not null }) { throw new InvalidOperationException(RelationalStrings.SubqueryOverComplexTypesNotSupported(complexType.DisplayName())); diff --git a/src/EFCore/ChangeTracking/Internal/InternalEntityEntry.cs b/src/EFCore/ChangeTracking/Internal/InternalEntityEntry.cs index ac9758001ed..641f23f47a5 100644 --- a/src/EFCore/ChangeTracking/Internal/InternalEntityEntry.cs +++ b/src/EFCore/ChangeTracking/Internal/InternalEntityEntry.cs @@ -1677,7 +1677,8 @@ void CheckForNullComplexProperties() foreach (var complexProperty in entityType.GetFlattenedComplexProperties()) { if (!complexProperty.IsNullable - && this[complexProperty] == null) + && this[complexProperty] == null + && complexProperty.ComplexType.GetProperties().Any(p => !p.IsNullable)) { throw new InvalidOperationException( CoreStrings.NullRequiredComplexProperty( diff --git a/src/EFCore/Infrastructure/ModelValidator.cs b/src/EFCore/Infrastructure/ModelValidator.cs index 35d015c811f..9012bc3a4bc 100644 --- a/src/EFCore/Infrastructure/ModelValidator.cs +++ b/src/EFCore/Infrastructure/ModelValidator.cs @@ -137,185 +137,195 @@ protected virtual void ValidatePropertyMapping( foreach (var entityType in conventionModel.GetEntityTypes()) { - Validate(entityType); + ValidatePropertyMapping(entityType, conventionModel); } + } + + /// + /// Validates property mappings for a given type. + /// + /// The type base to validate. + /// The model to validate. + protected virtual void ValidatePropertyMapping(IConventionTypeBase typeBase, IConventionModel model) + { + var unmappedProperty = typeBase.GetDeclaredProperties().FirstOrDefault( + p => (!ConfigurationSource.Convention.Overrides(p.GetConfigurationSource()) + // Use a better condition for non-persisted properties when issue #14121 is implemented + || !p.IsImplicitlyCreated()) + && p.FindTypeMapping() == null); - void Validate(IConventionTypeBase typeBase) + if (unmappedProperty != null) { - var unmappedProperty = typeBase.GetDeclaredProperties().FirstOrDefault( - p => (!ConfigurationSource.Convention.Overrides(p.GetConfigurationSource()) - // Use a better condition for non-persisted properties when issue #14121 is implemented - || !p.IsImplicitlyCreated()) - && p.FindTypeMapping() == null); + ThrowPropertyNotMappedException( + (unmappedProperty.GetValueConverter()?.ProviderClrType ?? unmappedProperty.ClrType).ShortDisplayName(), + typeBase, + unmappedProperty); + } - if (unmappedProperty != null) - { - ThrowPropertyNotMappedException( - (unmappedProperty.GetValueConverter()?.ProviderClrType ?? unmappedProperty.ClrType).ShortDisplayName(), - typeBase, - unmappedProperty); - } + foreach (var complexProperty in typeBase.GetDeclaredComplexProperties()) + { + ValidatePropertyMapping(complexProperty); - foreach (var complexProperty in typeBase.GetDeclaredComplexProperties()) - { - if (complexProperty.IsShadowProperty()) - { - throw new InvalidOperationException( - CoreStrings.ComplexPropertyShadow(typeBase.DisplayName(), complexProperty.Name)); - } + ValidatePropertyMapping(complexProperty.ComplexType, model); + } - if (complexProperty.IsIndexerProperty()) - { - throw new InvalidOperationException( - CoreStrings.ComplexPropertyIndexer(typeBase.DisplayName(), complexProperty.Name)); - } + if (typeBase.ClrType == Model.DefaultPropertyBagType) + { + return; + } - if (complexProperty.IsCollection) - { - throw new InvalidOperationException( - CoreStrings.ComplexPropertyCollection(typeBase.DisplayName(), complexProperty.Name)); - } + var runtimeProperties = typeBase.GetRuntimeProperties(); + var clrProperties = new HashSet(StringComparer.Ordinal); + clrProperties.UnionWith( + runtimeProperties.Values + .Where(pi => pi.IsCandidateProperty(needsWrite: false)) + .Select(pi => pi.GetSimpleMemberName())); - if (complexProperty.IsNullable) - { - throw new InvalidOperationException( - CoreStrings.ComplexPropertyOptional(typeBase.DisplayName(), complexProperty.Name)); - } + clrProperties.ExceptWith(typeBase.GetMembers().Select(p => p.Name)); - if (!complexProperty.ComplexType.GetMembers().Any()) - { - throw new InvalidOperationException( - CoreStrings.EmptyComplexType(complexProperty.ComplexType.DisplayName())); - } + if (typeBase.IsPropertyBag) + { + clrProperties.ExceptWith(DictionaryProperties); + } - Validate(complexProperty.ComplexType); - } + if (clrProperties.Count <= 0) + { + return; + } - if (typeBase.ClrType == Model.DefaultPropertyBagType) + foreach (var clrPropertyName in clrProperties) + { + if (typeBase.FindIgnoredConfigurationSource(clrPropertyName) != null) { - return; + continue; } - var runtimeProperties = typeBase.GetRuntimeProperties(); - var clrProperties = new HashSet(StringComparer.Ordinal); - clrProperties.UnionWith( - runtimeProperties.Values - .Where(pi => pi.IsCandidateProperty(needsWrite: false)) - .Select(pi => pi.GetSimpleMemberName())); - - clrProperties.ExceptWith(typeBase.GetMembers().Select(p => p.Name)); + var clrProperty = runtimeProperties[clrPropertyName]; + var propertyType = clrProperty.PropertyType; + var targetSequenceType = propertyType.TryGetSequenceType(); - if (typeBase.IsPropertyBag) + if (model.FindIgnoredConfigurationSource(propertyType) != null + || model.IsIgnoredType(propertyType) + || (targetSequenceType != null + && (model.FindIgnoredConfigurationSource(targetSequenceType) != null + || model.IsIgnoredType(targetSequenceType)))) { - clrProperties.ExceptWith(DictionaryProperties); + continue; } - if (clrProperties.Count <= 0) + var targetType = Dependencies.MemberClassifier.FindCandidateNavigationPropertyType( + clrProperty, model, useAttributes: true, out var targetOwned); + if (targetType == null + && clrProperty.FindSetterProperty() == null) { - return; + continue; } - foreach (var clrPropertyName in clrProperties) + var isAdHoc = Equals(model.FindAnnotation(CoreAnnotationNames.AdHocModel)?.Value, true); + if (targetType != null) { - if (typeBase.FindIgnoredConfigurationSource(clrPropertyName) != null) - { - continue; - } - - var clrProperty = runtimeProperties[clrPropertyName]; - var propertyType = clrProperty.PropertyType; - var targetSequenceType = propertyType.TryGetSequenceType(); + var targetShared = model.IsShared(targetType); + targetOwned ??= IsOwned(targetType, model); - if (conventionModel.FindIgnoredConfigurationSource(propertyType) != null - || conventionModel.IsIgnoredType(propertyType) - || (targetSequenceType != null - && (conventionModel.FindIgnoredConfigurationSource(targetSequenceType) != null - || conventionModel.IsIgnoredType(targetSequenceType)))) + if (typeBase is not IConventionEntityType entityType) { - continue; - } + if (!((IReadOnlyComplexType)typeBase).IsContainedBy(targetType)) + { + throw new InvalidOperationException( + CoreStrings.NavigationNotAddedComplexType( + typeBase.DisplayName(), clrProperty.Name, propertyType.ShortDisplayName())); + } - var targetType = Dependencies.MemberClassifier.FindCandidateNavigationPropertyType( - clrProperty, conventionModel, useAttributes: true, out var targetOwned); - if (targetType == null - && clrProperty.FindSetterProperty() == null) - { continue; } - var isAdHoc = Equals(model.FindAnnotation(CoreAnnotationNames.AdHocModel)?.Value, true); - if (targetType != null) + // ReSharper disable CheckForReferenceEqualityInstead.1 + // ReSharper disable CheckForReferenceEqualityInstead.3 + if ((isAdHoc + || !entityType.IsKeyless + || targetSequenceType == null) + && entityType.GetDerivedTypes().All( + dt => dt.GetDeclaredNavigations().FirstOrDefault(n => n.Name == clrProperty.GetSimpleMemberName()) + == null) + && (!(targetShared || targetOwned.Value) + || !targetType.Equals(entityType.ClrType)) + && (!entityType.IsInOwnershipPath(targetType) + || targetSequenceType == null)) { - var targetShared = conventionModel.IsShared(targetType); - targetOwned ??= IsOwned(targetType, conventionModel); - - if (typeBase is not IConventionEntityType entityType) + if (entityType.IsOwned() + && targetOwned.Value) { - if (!((IReadOnlyComplexType)typeBase).IsContainedBy(targetType)) - { - throw new InvalidOperationException( - CoreStrings.NavigationNotAddedComplexType( - typeBase.DisplayName(), clrProperty.Name, propertyType.ShortDisplayName())); - } - - continue; + throw new InvalidOperationException( + CoreStrings.AmbiguousOwnedNavigation( + typeBase.DisplayName() + "." + clrProperty.Name, targetType.ShortDisplayName())); } - // ReSharper disable CheckForReferenceEqualityInstead.1 - // ReSharper disable CheckForReferenceEqualityInstead.3 - if ((isAdHoc - || !entityType.IsKeyless - || targetSequenceType == null) - && entityType.GetDerivedTypes().All( - dt => dt.GetDeclaredNavigations().FirstOrDefault(n => n.Name == clrProperty.GetSimpleMemberName()) - == null) - && (!(targetShared || targetOwned.Value) - || !targetType.Equals(entityType.ClrType)) - && (!entityType.IsInOwnershipPath(targetType) - || targetSequenceType == null)) + if (targetShared) { - if (entityType.IsOwned() - && targetOwned.Value) - { - throw new InvalidOperationException( - CoreStrings.AmbiguousOwnedNavigation( - typeBase.DisplayName() + "." + clrProperty.Name, targetType.ShortDisplayName())); - } - - if (targetShared) - { - throw new InvalidOperationException( - CoreStrings.NonConfiguredNavigationToSharedType(clrProperty.Name, typeBase.DisplayName())); - } - throw new InvalidOperationException( - isAdHoc - ? CoreStrings.NavigationNotAddedAdHoc( - typeBase.DisplayName(), clrProperty.Name, propertyType.ShortDisplayName()) - : CoreStrings.NavigationNotAdded( - typeBase.DisplayName(), clrProperty.Name, propertyType.ShortDisplayName())); + CoreStrings.NonConfiguredNavigationToSharedType(clrProperty.Name, typeBase.DisplayName())); } - // ReSharper restore CheckForReferenceEqualityInstead.3 - // ReSharper restore CheckForReferenceEqualityInstead.1 - } - else if (targetSequenceType == null && propertyType.IsInterface - || targetSequenceType?.IsInterface == true) - { - throw new InvalidOperationException( - CoreStrings.InterfacePropertyNotAdded( - typeBase.DisplayName(), clrProperty.Name, propertyType.ShortDisplayName())); - } - else - { throw new InvalidOperationException( isAdHoc - ? CoreStrings.PropertyNotAddedAdHoc( + ? CoreStrings.NavigationNotAddedAdHoc( typeBase.DisplayName(), clrProperty.Name, propertyType.ShortDisplayName()) - : CoreStrings.PropertyNotAdded( + : CoreStrings.NavigationNotAdded( typeBase.DisplayName(), clrProperty.Name, propertyType.ShortDisplayName())); } + + // ReSharper restore CheckForReferenceEqualityInstead.3 + // ReSharper restore CheckForReferenceEqualityInstead.1 } + else if (targetSequenceType == null && propertyType.IsInterface + || targetSequenceType?.IsInterface == true) + { + throw new InvalidOperationException( + CoreStrings.InterfacePropertyNotAdded( + typeBase.DisplayName(), clrProperty.Name, propertyType.ShortDisplayName())); + } + else + { + throw new InvalidOperationException( + isAdHoc + ? CoreStrings.PropertyNotAddedAdHoc( + typeBase.DisplayName(), clrProperty.Name, propertyType.ShortDisplayName()) + : CoreStrings.PropertyNotAdded( + typeBase.DisplayName(), clrProperty.Name, propertyType.ShortDisplayName())); + } + } + } + + /// + /// Validates property mappings for a given complex property. + /// + /// The complex property to validate. + protected virtual void ValidatePropertyMapping(IConventionComplexProperty complexProperty) + { + var typeBase = complexProperty.DeclaringType; + + if (complexProperty.IsShadowProperty()) + { + throw new InvalidOperationException( + CoreStrings.ComplexPropertyShadow(typeBase.DisplayName(), complexProperty.Name)); + } + + if (complexProperty.IsIndexerProperty()) + { + throw new InvalidOperationException( + CoreStrings.ComplexPropertyIndexer(typeBase.DisplayName(), complexProperty.Name)); + } + + if (!complexProperty.ComplexType.GetMembers().Any()) + { + throw new InvalidOperationException( + CoreStrings.EmptyComplexType(complexProperty.ComplexType.DisplayName())); + } + + if (complexProperty.IsCollection) + { + throw new InvalidOperationException( + CoreStrings.ComplexPropertyCollection(typeBase.DisplayName(), complexProperty.Name)); } } @@ -464,7 +474,7 @@ protected virtual void ValidateNoShadowKeys( } /// - /// Validates the mapping/configuration of mutable in the model. + /// Validates the mapping/configuration of mutable keys in the model. /// /// The model to validate. /// The logger to use. @@ -942,7 +952,7 @@ static void Validate(ITypeBase typeBase) } /// - /// Validates the type mapping of properties the model. + /// Validates the type mapping of properties in the model. /// /// The model to validate. /// The logger to use. @@ -1033,7 +1043,7 @@ protected virtual void ValidateEntityClrTypes( } /// - /// Validates the mapping of primitive collection properties the model. + /// Validates the mapping of primitive collection properties in the model. /// /// The model to validate. /// The logger to use. diff --git a/src/EFCore/Metadata/Internal/ComplexProperty.cs b/src/EFCore/Metadata/Internal/ComplexProperty.cs index e1825f1ec1b..4ed7d147515 100644 --- a/src/EFCore/Metadata/Internal/ComplexProperty.cs +++ b/src/EFCore/Metadata/Internal/ComplexProperty.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics.CodeAnalysis; +using System.Reflection.Metadata; using Microsoft.EntityFrameworkCore.Internal; namespace Microsoft.EntityFrameworkCore.Metadata.Internal; @@ -44,6 +45,12 @@ public ComplexProperty( targetTypeName ?? declaringType.GetOwnedName(targetType.ShortDisplayName(), name), targetType, this, configurationSource); _builder = new InternalComplexPropertyBuilder(this, declaringType.Model.Builder); + + if (collection) + { + _isNullable = false; + _isNullableConfigurationSource = configurationSource; + } } /// @@ -138,6 +145,12 @@ public virtual bool IsNullable throw new InvalidOperationException( CoreStrings.CannotBeNullable(Name, DeclaringType.DisplayName(), ClrType.ShortDisplayName())); } + + if (IsCollection) + { + throw new InvalidOperationException( + CoreStrings.ComplexPropertyOptional(DeclaringType.DisplayName(), Name)); + } } _isNullableConfigurationSource = configurationSource.Max(_isNullableConfigurationSource); diff --git a/src/EFCore/Properties/CoreStrings.Designer.cs b/src/EFCore/Properties/CoreStrings.Designer.cs index 3c2c0a631d4..eccacfe97de 100644 --- a/src/EFCore/Properties/CoreStrings.Designer.cs +++ b/src/EFCore/Properties/CoreStrings.Designer.cs @@ -553,7 +553,7 @@ public static string ComplexPropertyNotFound(object? type, object? property) type, property); /// - /// Configuring the complex property '{type}.{property}' as optional is not supported, call 'IsRequired()'. See https://github.com/dotnet/efcore/issues/31376 for more information. + /// Configuring the collection complex property '{type}.{property}' as optional is invalid. /// public static string ComplexPropertyOptional(object? type, object? property) => string.Format( @@ -1003,7 +1003,7 @@ public static string EFConstantInvoked => GetString("EFConstantInvoked"); /// - /// 'EF.Constant()' isn't supported your by provider. + /// 'EF.Constant()' isn't supported by your provider. /// public static string EFConstantNotSupported => GetString("EFConstantNotSupported"); @@ -1155,7 +1155,7 @@ public static string ErrorMaterializingPropertyInvalidCast(object? entityType, o entityType, property, expectedType, actualType); /// - /// The methods '{methodName}' and '{asyncMethodName}' are not supported by the current database provider. Please contact the publisher of the database provider for more information. + /// The methods '{methodName}' and '{asyncMethodName}' are not supported by the current database provider. Please contact the publisher of the database provider for more information. /// public static string ExecuteQueriesNotSupported(object? methodName, object? asyncMethodName) => string.Format( diff --git a/src/EFCore/Properties/CoreStrings.resx b/src/EFCore/Properties/CoreStrings.resx index c010b57c389..768afc55907 100644 --- a/src/EFCore/Properties/CoreStrings.resx +++ b/src/EFCore/Properties/CoreStrings.resx @@ -316,7 +316,7 @@ The complex property '{type}.{property}' could not be found. Ensure that the property exists and has been included in the model as a complex property. - Configuring the complex property '{type}.{property}' as optional is not supported, call 'IsRequired()'. See https://github.com/dotnet/efcore/issues/31376 for more information. + Configuring the collection complex property '{type}.{property}' as optional is invalid. Configuring the complex property '{type}.{property}' in shadow state isn't supported. See https://github.com/dotnet/efcore/issues/31243 for more information. diff --git a/src/EFCore/Query/ShapedQueryCompilingExpressionVisitor.cs b/src/EFCore/Query/ShapedQueryCompilingExpressionVisitor.cs index fbc596366bf..dfb9a8251ea 100644 --- a/src/EFCore/Query/ShapedQueryCompilingExpressionVisitor.cs +++ b/src/EFCore/Query/ShapedQueryCompilingExpressionVisitor.cs @@ -707,7 +707,7 @@ private BlockExpression CreateFullMaterializeExpression( new EntityMaterializerSourceParameters( concreteTypeBase, "instance", queryTrackingBehavior), materializationContextVariable); - // TODO: Properly support shadow properties for complex types? + // TODO: Properly support shadow properties for complex types #35613 if (_queryStateManager && concreteTypeBase is IRuntimeEntityType { ShadowPropertyCount: > 0 } runtimeEntityType) { diff --git a/test/EFCore.Cosmos.FunctionalTests/Scaffolding/Baselines/ComplexTypes/PrincipalBaseEntityType.cs b/test/EFCore.Cosmos.FunctionalTests/Scaffolding/Baselines/ComplexTypes/PrincipalBaseEntityType.cs index a623bbfedd4..020db221697 100644 --- a/test/EFCore.Cosmos.FunctionalTests/Scaffolding/Baselines/ComplexTypes/PrincipalBaseEntityType.cs +++ b/test/EFCore.Cosmos.FunctionalTests/Scaffolding/Baselines/ComplexTypes/PrincipalBaseEntityType.cs @@ -1370,6 +1370,7 @@ public static RuntimeComplexProperty Create(RuntimeComplexType declaringType) typeof(CompiledModelTestBase.PrincipalBase), propertyInfo: typeof(CompiledModelTestBase.OwnedType).GetProperty("Principal", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), fieldInfo: typeof(CompiledModelTestBase.OwnedType).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + nullable: true, propertyCount: 12); var complexType = complexProperty.ComplexType; diff --git a/test/EFCore.Design.Tests/Migrations/Design/CSharpMigrationsGeneratorTest.ModelSnapshot.cs b/test/EFCore.Design.Tests/Migrations/Design/CSharpMigrationsGeneratorTest.ModelSnapshot.cs index ce667239197..a9c0c44c61d 100644 --- a/test/EFCore.Design.Tests/Migrations/Design/CSharpMigrationsGeneratorTest.ModelSnapshot.cs +++ b/test/EFCore.Design.Tests/Migrations/Design/CSharpMigrationsGeneratorTest.ModelSnapshot.cs @@ -6029,8 +6029,8 @@ public virtual void Complex_properties_are_stored_in_snapshot() .HasColumnType("nvarchar(max)") .IsSparse(); eb.ComplexProperty(e => e.EntityWithStringKey) - .IsRequired() - .Ignore(e => e.Properties); + .Ignore(e => e.Properties) + .Property(e => e.Id).IsRequired(); eb.HasPropertyAnnotation("PropertyAnnotation", 1); eb.HasTypeAnnotation("TypeAnnotation", 2); }); @@ -6067,9 +6067,8 @@ public virtual void Complex_properties_are_stored_in_snapshot() b1.ComplexProperty>("EntityWithStringKey", "Microsoft.EntityFrameworkCore.Migrations.Design.CSharpMigrationsGeneratorTest+EntityWithOneProperty.EntityWithTwoProperties#EntityWithTwoProperties.EntityWithStringKey#EntityWithStringKey", b2 => { - b2.IsRequired(); - b2.Property("Id") + .IsRequired() .HasColumnType("nvarchar(max)"); }); @@ -6104,7 +6103,7 @@ public virtual void Complex_properties_are_stored_in_snapshot() var nestedComplexProperty = complexType.FindComplexProperty(nameof(EntityWithTwoProperties.EntityWithStringKey)); Assert.False(nestedComplexProperty.IsCollection); - Assert.False(nestedComplexProperty.IsNullable); + Assert.True(nestedComplexProperty.IsNullable); var nestedComplexType = nestedComplexProperty.ComplexType; Assert.Equal( "Microsoft.EntityFrameworkCore.Migrations.Design.CSharpMigrationsGeneratorTest+EntityWithOneProperty.EntityWithTwoProperties#EntityWithTwoProperties.EntityWithStringKey#EntityWithStringKey", @@ -6114,7 +6113,7 @@ public virtual void Complex_properties_are_stored_in_snapshot() nestedComplexType.DisplayName()); Assert.Equal(nameof(EntityWithOneProperty), nestedComplexType.GetTableName()); var nestedIdProperty = nestedComplexType.FindProperty(nameof(EntityWithStringKey.Id)); - Assert.True(nestedIdProperty.IsNullable); + Assert.False(nestedIdProperty.IsNullable); }, validate: true); diff --git a/test/EFCore.InMemory.FunctionalTests/Scaffolding/Baselines/ComplexTypes/PrincipalBaseEntityType.cs b/test/EFCore.InMemory.FunctionalTests/Scaffolding/Baselines/ComplexTypes/PrincipalBaseEntityType.cs index 9c6ca022a14..47319d88c4c 100644 --- a/test/EFCore.InMemory.FunctionalTests/Scaffolding/Baselines/ComplexTypes/PrincipalBaseEntityType.cs +++ b/test/EFCore.InMemory.FunctionalTests/Scaffolding/Baselines/ComplexTypes/PrincipalBaseEntityType.cs @@ -1610,6 +1610,7 @@ public static RuntimeComplexProperty Create(RuntimeComplexType declaringType) typeof(CompiledModelTestBase.PrincipalBase), propertyInfo: typeof(CompiledModelTestBase.OwnedType).GetProperty("Principal", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), fieldInfo: typeof(CompiledModelTestBase.OwnedType).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + nullable: true, propertyCount: 14); var complexType = complexProperty.ComplexType; diff --git a/test/EFCore.Relational.Specification.Tests/LazyLoadProxyRelationalTestBase.cs b/test/EFCore.Relational.Specification.Tests/LazyLoadProxyRelationalTestBase.cs new file mode 100644 index 00000000000..9b137b3b58f --- /dev/null +++ b/test/EFCore.Relational.Specification.Tests/LazyLoadProxyRelationalTestBase.cs @@ -0,0 +1,48 @@ +// 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; + +public abstract class LazyLoadProxyRelationalTestBase(TFixture fixture) : LazyLoadProxyTestBase(fixture) + where TFixture : LazyLoadProxyRelationalTestBase.LoadRelationalFixtureBase +{ + public abstract class LoadRelationalFixtureBase : LoadFixtureBase + { + protected override void OnModelCreating(ModelBuilder modelBuilder, DbContext context) + { + base.OnModelCreating(modelBuilder, context); + + modelBuilder.Entity().ComplexProperty(c => c.Culture, ConfigureCulture); + + modelBuilder.Entity().ComplexProperty(q => q.Culture, ConfigureCulture); + + modelBuilder.Entity(fb => + { + fb.ComplexProperty(f => f.Culture, ConfigureCulture); + fb.ComplexProperty(f => f.Milk, ConfigureMilk); + }); + + modelBuilder.Entity(mb => + { + mb.ComplexProperty(f => f.Culture, ConfigureCulture); + mb.ComplexProperty(f => f.Milk, ConfigureMilk); + }); + + static void ConfigureCulture(ComplexPropertyBuilder cb) + { + cb.Property(c => c.Rating).HasColumnName("Culture_Rating"); + cb.Property(c => c.Subspecies).HasColumnName("Culture_Subspecies"); + cb.Property(c => c.Species).HasColumnName("Culture_Species"); + cb.Property(c => c.Validation).HasColumnName("Culture_Validation"); + } + + static void ConfigureMilk(ComplexPropertyBuilder mb) + { + mb.Property(c => c.Rating).HasColumnName("Milk_Rating"); + mb.Property(c => c.Subspecies).HasColumnName("Milk_Subspecies"); + mb.Property(c => c.Species).HasColumnName("Milk_Species"); + mb.Property(c => c.Validation).HasColumnName("Milk_Validation"); + } + } + } +} diff --git a/test/EFCore.Relational.Specification.Tests/Migrations/MigrationsTestBase.cs b/test/EFCore.Relational.Specification.Tests/Migrations/MigrationsTestBase.cs index a110b27d4e4..7403fbb2c00 100644 --- a/test/EFCore.Relational.Specification.Tests/Migrations/MigrationsTestBase.cs +++ b/test/EFCore.Relational.Specification.Tests/Migrations/MigrationsTestBase.cs @@ -976,8 +976,8 @@ public virtual Task Add_column_shared() builder => { builder.Entity("Base").Property("Id"); - builder.Entity("Derived1").HasBaseType("Base").Property("Foo"); - builder.Entity("Derived2").HasBaseType("Base").Property("Foo"); + builder.Entity("Derived1").HasBaseType("Base").Property("Foo").HasColumnName("Foo"); + builder.Entity("Derived2").HasBaseType("Base").Property("Foo").HasColumnName("Foo"); }, builder => { }, builder => builder.Entity("Base").Property("Foo"), @@ -2707,17 +2707,61 @@ public virtual Task Create_table_with_complex_type_with_required_properties_on_d c => { Assert.Equal("MyComplex_Prop", c.Name); - Assert.Equal(true, c.IsNullable); + Assert.True(c.IsNullable); + }, + c => + { + Assert.Equal("MyComplex_MyNestedComplex_Bar", c.Name); + Assert.True(c.IsNullable); + }, + c => + { + Assert.Equal("MyComplex_MyNestedComplex_Foo", c.Name); + Assert.True(c.IsNullable); + }); + }); + + [ConditionalFact] + public virtual Task Create_table_with_optional_complex_type_with_required_properties() + => Test( + builder => { }, + builder => + { + builder.Entity( + "Supplier", e => + { + e.ToTable("Suppliers"); + e.Property("Id").ValueGeneratedOnAdd(); + e.HasKey("Id"); + e.Property("Number"); + e.ComplexProperty( + "MyComplex", ct => + { + ct.ComplexProperty("MyNestedComplex"); + }); + }); + }, + model => + { + var contactsTable = Assert.Single(model.Tables.Where(t => t.Name == "Suppliers")); + Assert.Collection( + contactsTable.Columns, + c => Assert.Equal("Id", c.Name), + c => Assert.Equal("Number", c.Name), + c => + { + Assert.Equal("MyComplex_Prop", c.Name); + Assert.True(c.IsNullable); }, c => { Assert.Equal("MyComplex_MyNestedComplex_Bar", c.Name); - Assert.Equal(true, c.IsNullable); + Assert.True(c.IsNullable); }, c => { Assert.Equal("MyComplex_MyNestedComplex_Foo", c.Name); - Assert.Equal(true, c.IsNullable); + Assert.True(c.IsNullable); }); }); @@ -2726,7 +2770,6 @@ protected class MyComplex [Required] public string Prop { get; set; } - [Required] public MyNestedComplex Nested { get; set; } } diff --git a/test/EFCore.Relational.Specification.Tests/Scaffolding/CompiledModelRelationalTestBase.cs b/test/EFCore.Relational.Specification.Tests/Scaffolding/CompiledModelRelationalTestBase.cs index 563367bdcfd..2be53109a1f 100644 --- a/test/EFCore.Relational.Specification.Tests/Scaffolding/CompiledModelRelationalTestBase.cs +++ b/test/EFCore.Relational.Specification.Tests/Scaffolding/CompiledModelRelationalTestBase.cs @@ -426,7 +426,7 @@ public override Task ComplexTypes() AssertComplexTypes, c => { - // Sprocs not supported with complex types + // Sprocs not supported with complex types, see #31235 //c.Set>>().Add( // new PrincipalDerived> // { @@ -480,9 +480,12 @@ protected override void AssertComplexTypes(IModel model) CoreStrings.RuntimeModelMissingData, Assert.Throws(() => detailsProperty.GetColumnOrder()).Message); - var principalTable = StoreObjectIdentifier.Create(complexType, StoreObjectType.Table)!.Value; + var principalTableId = StoreObjectIdentifier.Create(complexType, StoreObjectType.Table)!.Value; - Assert.Equal("Deets", detailsProperty.GetColumnName(principalTable)); + Assert.Equal("Deets", detailsProperty.GetColumnName(principalTableId)); + + var principalTable = principalBase.GetTableMappings().Single().Table; + Assert.False(principalTable.IsOptional(complexType)); var dbFunction = model.FindDbFunction("PrincipalBaseTvf")!; Assert.False(dbFunction.IsNullable); diff --git a/test/EFCore.Relational.Specification.Tests/TableSplittingTestBase.cs b/test/EFCore.Relational.Specification.Tests/TableSplittingTestBase.cs index 04bec022b59..d7ae7f68a1c 100644 --- a/test/EFCore.Relational.Specification.Tests/TableSplittingTestBase.cs +++ b/test/EFCore.Relational.Specification.Tests/TableSplittingTestBase.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Reflection.Emit; using Microsoft.EntityFrameworkCore.Diagnostics.Internal; using Microsoft.EntityFrameworkCore.TestModels.TransportationModel; @@ -177,11 +178,12 @@ await InitializeAsync( { vb.Property(v => v.SeatingCapacity).HasColumnName("SeatingCapacity"); }); - modelBuilder.Entity( + + modelBuilder.Entity( vb => { vb.ComplexProperty( - v => v.Engine, eb => + v => v.FuelTank, eb => { eb.Property("SeatingCapacity").HasColumnName("SeatingCapacity"); }); @@ -193,23 +195,27 @@ await InitializeAsync( var scooterEntry = await context.AddAsync( new PoweredVehicle { - Name = "Electric scooter", + Name = "Gas scooter", SeatingCapacity = 1, - Engine = new Engine(), - Operator = new Operator { Name = "Kai Saunders", Details = new OperatorDetails() } + Engine = new IntermittentCombustionEngine { FuelTank = new FuelTank { Capacity = 5 } }, + Operator = new Operator { Name = "Dante Hutchinson", Details = new OperatorDetails() } }); context.SaveChanges(); - //Assert.Equal(scooter.SeatingCapacity, scooterEntry.ComplexProperty(v => v.Engine).TargetEntry.Property("SeatingCapacity").CurrentValue); + Assert.Equal(scooterEntry.Entity.SeatingCapacity, + scooterEntry.Reference(v => (IntermittentCombustionEngine)v.Engine).TargetEntry + .ComplexProperty(v => v.FuelTank).Property("SeatingCapacity").CurrentValue); } - //using (var context = CreateContext()) - //{ - // var scooter = context.Set().Single(v => v.Name == "Electric scooter"); + using (var context = CreateContext()) + { + // Requires query support for shadow properties in complex types #35613 + //var scooter = context.Set().Include(v => v.Engine).Single(v => v.Name == "Gas scooter"); - // Assert.Equal(scooter.SeatingCapacity, context.Entry(scooter).ComplexProperty(v => v.Engine).TargetEntry.Property("SeatingCapacity").CurrentValue); - //} + //Assert.Equal(scooter.SeatingCapacity, context.Entry(scooter).Reference(v => (IntermittentCombustionEngine)v.Engine).TargetEntry + // .ComplexProperty(v => v.FuelTank).Property("SeatingCapacity").CurrentValue); + } } [ConditionalFact] @@ -260,6 +266,7 @@ await InitializeAsync( context.SaveChanges(); + Assert.Equal(0, scooter.SeatingCapacity); Assert.Equal(0, engineCapacityEntry.OriginalValue); Assert.Equal(0, engineCapacityEntry.CurrentValue); } @@ -295,12 +302,13 @@ await InitializeAsync( { vb.Property(v => v.SeatingCapacity).HasColumnName("SeatingCapacity").IsConcurrencyToken(); }); - modelBuilder.Entity( + modelBuilder.Entity( vb => { vb.ComplexProperty( - v => v.Engine, eb => + v => v.FuelTank, eb => { + eb.IsRequired(false); eb.Property("SeatingCapacity").HasColumnName("SeatingCapacity").IsConcurrencyToken(); }); }); @@ -311,38 +319,42 @@ await InitializeAsync( var scooterEntry = await context.AddAsync( new PoweredVehicle { - Name = "Electric scooter", + Name = "Gas scooter", SeatingCapacity = 1, - Engine = new Engine(), - Operator = new Operator { Name = "Kai Saunders", Details = new OperatorDetails() } + Engine = new IntermittentCombustionEngine { FuelTank = new FuelTank { Capacity = 5 } }, + Operator = new Operator { Name = "Dante Hutchinson", Details = new OperatorDetails() } }); context.SaveChanges(); } + // Requires query support for shadow properties in complex types #35613 //using (var context = CreateContext()) //{ - // var scooter = context.Set().Single(v => v.Name == "Electric scooter"); + // var scooter = context.Set().Include(v => v.Engine).Single(v => v.Name == "Gas scooter"); // Assert.Equal(1, scooter.SeatingCapacity); - // scooter.Engine = new Engine(); + // scooter.Engine = new IntermittentCombustionEngine { FuelTank = new FuelTank { Capacity = 5 } }; - // var engineCapacityEntry = context.Entry(scooter).ComplexProperty(v => v.Engine).TargetEntry.Property("SeatingCapacity"); + // var seatingCapacityEntry = context.Entry(scooter).Reference(v => (IntermittentCombustionEngine)v.Engine).TargetEntry + // .ComplexProperty(v => v.FuelTank).Property("SeatingCapacity"); - // Assert.Equal(0, engineCapacityEntry.OriginalValue); + // Assert.Equal(0, seatingCapacityEntry.OriginalValue); // context.SaveChanges(); - // Assert.Equal(0, engineCapacityEntry.OriginalValue); - // Assert.Equal(0, engineCapacityEntry.CurrentValue); + // Assert.Equal(0, scooter.SeatingCapacity); + // Assert.Equal(0, seatingCapacityEntry.OriginalValue); + // Assert.Equal(0, seatingCapacityEntry.CurrentValue); //} //using (var context = CreateContext()) //{ - // var scooter = context.Set().Single(v => v.Name == "Electric scooter"); + // var scooter = context.Set().Include(v => v.Engine).Single(v => v.Name == "Gas scooter"); - // Assert.Equal(scooter.SeatingCapacity, context.Entry(scooter).ComplexProperty(v => v.Engine).TargetEntry.Property("SeatingCapacity").CurrentValue); + // Assert.Equal(scooter.SeatingCapacity, context.Entry(scooter).Reference(v => (IntermittentCombustionEngine)v.Engine).TargetEntry + // .ComplexProperty(v => v.FuelTank).Property("SeatingCapacity").CurrentValue); // scooter.SeatingCapacity = 2; // context.SaveChanges(); @@ -350,10 +362,11 @@ await InitializeAsync( //using (var context = CreateContext()) //{ - // var scooter = context.Set().Include(v => v.Engine).Single(v => v.Name == "Electric scooter"); + // var scooter = context.Set().Include(v => v.Engine).Single(v => v.Name == "Gas scooter"); // Assert.Equal(2, scooter.SeatingCapacity); - // Assert.Equal(2, context.Entry(scooter).ComplexProperty(v => v.Engine).TargetEntry.Property("SeatingCapacity").CurrentValue); + // Assert.Equal(2, context.Entry(scooter).Reference(v => (IntermittentCombustionEngine)v.Engine).TargetEntry + // .ComplexProperty(v => v.FuelTank).Property("SeatingCapacity").CurrentValue); //} } @@ -935,40 +948,33 @@ protected virtual void OnModelCreating(ModelBuilder modelBuilder) protected virtual void OnModelCreatingComplex(ModelBuilder modelBuilder) { OnModelCreating(modelBuilder); - modelBuilder.Ignore(); - modelBuilder.Ignore(); modelBuilder.Ignore(); - modelBuilder.Ignore(); modelBuilder.Ignore(); - modelBuilder.Ignore(); + modelBuilder.Ignore(); + modelBuilder.Ignore(); modelBuilder.Ignore(); modelBuilder.Ignore(); - modelBuilder.Entity( - vb => + modelBuilder.Entity(ob => { - vb.Property(v => v.Name).HasColumnName("Name"); - vb.Ignore(v => v.Operator); - vb.ComplexProperty( - v => v.Operator, ob => - { - ob.IsRequired(); - ob.Property(o => o.VehicleName).HasColumnName("Name"); - ob.ComplexProperty(o => o.Details) - .IsRequired() - .Property(o => o.VehicleName).HasColumnName("Name"); - }); + ob.ComplexProperty(o => o.Details) + .IsRequired() + .Ignore(o => o.VehicleName); }); - modelBuilder.Entity( - vb => + modelBuilder.Entity(vb => vb.ToTable("Vehicles")); + modelBuilder.Entity(vb => { - vb.Ignore(v => v.Engine); vb.ComplexProperty( - v => v.Engine, eb => + v => v.FuelTank, eb => { eb.IsRequired(); - eb.Property(o => o.VehicleName).HasColumnName("Name"); + eb.Ignore(f => f.Engine); + eb.Ignore(f => f.Vehicle); }); + vb.ToTable("Vehicles"); }); + modelBuilder.Entity().ToTable("Vehicles"); + modelBuilder.Entity().ToTable("Vehicles"); + modelBuilder.Entity().ToTable("Vehicles"); } protected virtual void OnSharedModelCreating(ModelBuilder modelBuilder) @@ -1043,6 +1049,6 @@ protected class MeterReadingDetail protected enum MeterReadingStatus { Running = 0, - NotAccesible = 2 + NotAccessible = 2 } } diff --git a/test/EFCore.Relational.Tests/Infrastructure/RelationalModelValidatorTest.cs b/test/EFCore.Relational.Tests/Infrastructure/RelationalModelValidatorTest.cs index fa7f1afb8fb..2d40977dd48 100644 --- a/test/EFCore.Relational.Tests/Infrastructure/RelationalModelValidatorTest.cs +++ b/test/EFCore.Relational.Tests/Infrastructure/RelationalModelValidatorTest.cs @@ -590,6 +590,23 @@ public virtual void Detects_properties_mapped_to_the_same_column_within_hierarch modelBuilder); } + [ConditionalFact] + public virtual void Detects_properties_mapped_to_the_same_column_on_complex_type() + { + var modelBuilder = CreateConventionModelBuilder(); + + modelBuilder.Entity(eb => + { + eb.ComplexProperty(b => b.A).Property(a => a.P0).HasColumnName(nameof(A.P0)); + eb.ComplexProperty(b => b.A).Property(a => a.P1).HasColumnName(nameof(A.P0)); + }); + + VerifyError( + RelationalStrings.DuplicateColumnNameSameHierarchy( + "B.A#A", nameof(A.P0), "B.A#A", nameof(A.P1), nameof(A.P0), nameof(B)), + modelBuilder); + } + [ConditionalFact] public virtual void Passes_for_incompatible_shared_columns_in_shared_table_with_different_provider_types() { @@ -1240,7 +1257,7 @@ public virtual void Detects_duplicate_column_names_within_hierarchy_with_differe } [ConditionalFact] - public virtual void Detects_duplicate_column_names_with_different_column_nullability() + public virtual void Passes_on_duplicate_column_names_with_different_column_nullability() { var modelBuilder = CreateConventionModelBuilder(); @@ -1251,10 +1268,12 @@ public virtual void Detects_duplicate_column_names_with_different_column_nullabi modelBuilder.Entity().ToTable("Table").Property(b => b.P0).HasColumnName(nameof(A.P0)); modelBuilder.Entity().ToTable("Table").Property(g => g.P0).HasColumnName(nameof(A.P0)).IsRequired(); - VerifyError( - RelationalStrings.DuplicateColumnNameNullabilityMismatch( - nameof(B), nameof(B.P0), nameof(G), nameof(G.P0), nameof(A.P0), "Table"), - modelBuilder); + var model = Validate(modelBuilder); + + var column = model.FindEntityType(typeof(B)).GetProperty(nameof(A.P0)).GetTableColumnMappings().Single().Column; + + Assert.Equal(2, column.PropertyMappings.Count()); + Assert.False(column.IsNullable); } [ConditionalFact] diff --git a/test/EFCore.Relational.Tests/Migrations/Internal/MigrationsModelDifferTest.cs b/test/EFCore.Relational.Tests/Migrations/Internal/MigrationsModelDifferTest.cs index 29f2d8b765d..d6040f9efd0 100644 --- a/test/EFCore.Relational.Tests/Migrations/Internal/MigrationsModelDifferTest.cs +++ b/test/EFCore.Relational.Tests/Migrations/Internal/MigrationsModelDifferTest.cs @@ -1942,7 +1942,7 @@ public void Throws_on_conflicting_seed_data(bool enableSensitiveLogging) { x.ToTable("Firefly"); x.Property("Id"); - x.Property("Name"); + x.Property("Name").HasColumnName("Name"); x.HasData( new { Id = 42, Name = "1" }); }); @@ -1953,7 +1953,7 @@ public void Throws_on_conflicting_seed_data(bool enableSensitiveLogging) { x.ToTable("Firefly"); x.Property("Id"); - x.Property("Name"); + x.Property("Name").HasColumnName("Name"); x.HasOne("Firefly", null).WithOne().HasForeignKey("FireflyDetails", "Id"); x.HasData( new { Id = 42, Name = "2" }); @@ -7763,7 +7763,7 @@ public void Change_TPT_to_TPC_with_FKs_and_seed_data() { x.ToTable("Dogs"); x.Property("Id"); - x.Property("PreyId"); + x.Property("PreyId").HasColumnName("PreyId"); }); }, target => diff --git a/test/EFCore.Specification.Tests/ComplexTypesTrackingTestBase.cs b/test/EFCore.Specification.Tests/ComplexTypesTrackingTestBase.cs index c7c372ee85d..02766ea3f53 100644 --- a/test/EFCore.Specification.Tests/ComplexTypesTrackingTestBase.cs +++ b/test/EFCore.Specification.Tests/ComplexTypesTrackingTestBase.cs @@ -541,9 +541,6 @@ public virtual async Task Throws_only_when_saving_with_null_top_level_complex_pr var yogurt = CreateYogurt(context, nullMilk: true); var entry = async ? await context.AddAsync(yogurt) : context.Add(yogurt); - entry.State = EntityState.Unchanged; - context.ChangeTracker.DetectChanges(); - entry.State = EntityState.Modified; Assert.Equal( CoreStrings.NullRequiredComplexProperty("Yogurt", "Milk"), @@ -560,9 +557,6 @@ public virtual async Task Throws_only_when_saving_with_null_second_level_complex var yogurt = CreateYogurt(context, nullManufacturer: true); var entry = async ? await context.AddAsync(yogurt) : context.Add(yogurt); - entry.State = EntityState.Unchanged; - context.ChangeTracker.DetectChanges(); - entry.State = EntityState.Modified; Assert.Equal( CoreStrings.NullRequiredComplexProperty("Culture", "Manufacturer"), @@ -573,20 +567,20 @@ public virtual async Task Throws_only_when_saving_with_null_second_level_complex [ConditionalTheory] [InlineData(false)] [InlineData(true)] - public virtual async Task Throws_only_when_saving_with_null_third_level_complex_property(bool async) + public virtual async Task Can_save_null_third_level_complex_property_with_all_optional_properties(bool async) { using var context = CreateContext(); - var yogurt = CreateYogurt(context, nullTag: true); - var entry = async ? await context.AddAsync(yogurt) : context.Add(yogurt); - entry.State = EntityState.Unchanged; - context.ChangeTracker.DetectChanges(); - entry.State = EntityState.Modified; + await context.Database.CreateExecutionStrategy().ExecuteAsync( + context, async context => + { + using var transaction = context.Database.BeginTransaction(); - Assert.Equal( - CoreStrings.NullRequiredComplexProperty("License", "Tag"), - (await Assert.ThrowsAsync( - () => async ? context.SaveChangesAsync() : Task.FromResult(context.SaveChanges()))).Message); + var yogurt = CreateYogurt(context, nullTag: true); + var entry = async ? await context.AddAsync(yogurt) : context.Add(yogurt); + + context.SaveChanges(); + }); } [ConditionalTheory] diff --git a/test/EFCore.Specification.Tests/LazyLoadProxyTestBase.cs b/test/EFCore.Specification.Tests/LazyLoadProxyTestBase.cs index 2012b95f63d..7409f555a8a 100644 --- a/test/EFCore.Specification.Tests/LazyLoadProxyTestBase.cs +++ b/test/EFCore.Specification.Tests/LazyLoadProxyTestBase.cs @@ -4365,7 +4365,7 @@ public virtual void Lazy_loading_finds_correct_entity_type_with_multiple_queries } [ConditionalFact] - public virtual void Lazy_loading_shares_service__property_on_derived_types() + public virtual void Lazy_loading_shares_service_property_on_derived_types() { using var context = CreateContext(lazyLoadingEnabled: true); var parson = context.Set().Single(); diff --git a/test/EFCore.Specification.Tests/ModelBuilding/ModelBuilderTest.ComplexType.cs b/test/EFCore.Specification.Tests/ModelBuilding/ModelBuilderTest.ComplexType.cs index ac2c52eb438..a8fbc2dcbf0 100644 --- a/test/EFCore.Specification.Tests/ModelBuilding/ModelBuilderTest.ComplexType.cs +++ b/test/EFCore.Specification.Tests/ModelBuilding/ModelBuilderTest.ComplexType.cs @@ -5,6 +5,7 @@ using System.Dynamic; using Microsoft.EntityFrameworkCore.ChangeTracking.Internal; using Microsoft.EntityFrameworkCore.Metadata.Internal; +using static Microsoft.EntityFrameworkCore.DbLoggerCategory; // ReSharper disable InconsistentNaming namespace Microsoft.EntityFrameworkCore.ModelBuilding; @@ -1637,18 +1638,22 @@ public virtual void Complex_properties_can_be_configured_by_type() } [ConditionalFact] - public virtual void Throws_for_optional_complex_property() + public virtual void Complex_properties_can_be_configured_as_optional() { var modelBuilder = CreateModelBuilder(); modelBuilder + .Ignore() + .Ignore() + .Ignore() + .Ignore() .Entity() .ComplexProperty(e => e.Customer).IsRequired(false); - Assert.Equal( - CoreStrings.ComplexPropertyOptional( - nameof(ComplexProperties), nameof(ComplexProperties.Customer)), - Assert.Throws(modelBuilder.FinalizeModel).Message); + var model = modelBuilder.FinalizeModel(); + + var complexProperty = model.FindEntityType(typeof(ComplexProperties)).GetComplexProperties().Single(); + Assert.True(complexProperty.IsNullable); } [ConditionalFact] diff --git a/test/EFCore.Specification.Tests/Scaffolding/CompiledModelTestBase.cs b/test/EFCore.Specification.Tests/Scaffolding/CompiledModelTestBase.cs index 03ae7397e7e..49d25825ebf 100644 --- a/test/EFCore.Specification.Tests/Scaffolding/CompiledModelTestBase.cs +++ b/test/EFCore.Specification.Tests/Scaffolding/CompiledModelTestBase.cs @@ -1191,8 +1191,7 @@ protected virtual void BuildComplexTypesModel(ModelBuilder modelBuilder) eb.ComplexProperty( e => e.Owned, eb => { - eb.IsRequired() - .HasField("_ownedField") + eb.HasField("_ownedField") .UsePropertyAccessMode(PropertyAccessMode.Field) .HasChangeTrackingStrategy(ChangeTrackingStrategy.ChangingAndChangedNotificationsWithOriginalValues) .HasPropertyAnnotation("goo", "ber") @@ -1210,7 +1209,6 @@ protected virtual void BuildComplexTypesModel(ModelBuilder modelBuilder) eb.ComplexProperty( o => o.Principal, cb => { - cb.IsRequired(); cb.Property("FlagsEnum2"); }); }); @@ -1274,7 +1272,10 @@ protected virtual void AssertComplexTypes(IModel model) Assert.NotNull(detailsProperty.GetValueComparer()); Assert.NotNull(detailsProperty.GetKeyValueComparer()); - var nestedComplexType = complexType.FindComplexProperty(nameof(OwnedType.Principal))!.ComplexType; + var nestedComplexProperty = complexType.FindComplexProperty(nameof(OwnedType.Principal))!; + Assert.True(nestedComplexProperty.IsNullable); + + var nestedComplexType = nestedComplexProperty.ComplexType; Assert.Equal(ExpectedComplexTypeProperties, nestedComplexType.GetProperties().Count()); diff --git a/test/EFCore.Specification.Tests/TestModels/TransportationModel/Engine.cs b/test/EFCore.Specification.Tests/TestModels/TransportationModel/Engine.cs index 2d4dbff7501..99295a759c2 100644 --- a/test/EFCore.Specification.Tests/TestModels/TransportationModel/Engine.cs +++ b/test/EFCore.Specification.Tests/TestModels/TransportationModel/Engine.cs @@ -3,16 +3,14 @@ namespace Microsoft.EntityFrameworkCore.TestModels.TransportationModel; -#nullable disable - public class Engine { - public string VehicleName { get; set; } - public string Description { get; set; } + public string VehicleName { get; set; } = null!; + public string? Description { get; set; } public int Computed { get; set; } - public PoweredVehicle Vehicle { get; set; } + public PoweredVehicle Vehicle { get; set; } = null!; - public override bool Equals(object obj) + public override bool Equals(object? obj) => obj is Engine other && VehicleName == other.VehicleName && Description == other.Description; diff --git a/test/EFCore.Specification.Tests/TestModels/TransportationModel/PoweredVehicle.cs b/test/EFCore.Specification.Tests/TestModels/TransportationModel/PoweredVehicle.cs index 2ac847697e9..b22e5ff9a25 100644 --- a/test/EFCore.Specification.Tests/TestModels/TransportationModel/PoweredVehicle.cs +++ b/test/EFCore.Specification.Tests/TestModels/TransportationModel/PoweredVehicle.cs @@ -3,11 +3,9 @@ namespace Microsoft.EntityFrameworkCore.TestModels.TransportationModel; -#nullable disable - public class PoweredVehicle : Vehicle { - public Engine Engine { get; set; } + public Engine? Engine { get; set; } public override bool Equals(object obj) => obj is PoweredVehicle other diff --git a/test/EFCore.SqlServer.FunctionalTests/LazyLoadProxySqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/LazyLoadProxySqlServerTest.cs index ec26af32c05..23a5c503386 100644 --- a/test/EFCore.SqlServer.FunctionalTests/LazyLoadProxySqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/LazyLoadProxySqlServerTest.cs @@ -5,7 +5,7 @@ namespace Microsoft.EntityFrameworkCore; #nullable disable -public class LazyLoadProxySqlServerTest : LazyLoadProxyTestBase +public class LazyLoadProxySqlServerTest : LazyLoadProxyRelationalTestBase { public LazyLoadProxySqlServerTest(LoadSqlServerFixture fixture) : base(fixture) @@ -529,7 +529,7 @@ private void AssertSql(string expected) private string Sql { get; set; } - public class LoadSqlServerFixture : LoadFixtureBase + public class LoadSqlServerFixture : LoadRelationalFixtureBase { public TestSqlLoggerFactory TestSqlLoggerFactory => (TestSqlLoggerFactory)ListLoggerFactory; diff --git a/test/EFCore.SqlServer.FunctionalTests/Scaffolding/Baselines/ComplexTypes/DbContextModelBuilder.cs b/test/EFCore.SqlServer.FunctionalTests/Scaffolding/Baselines/ComplexTypes/DbContextModelBuilder.cs index 1019047df88..3f551c4981e 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Scaffolding/Baselines/ComplexTypes/DbContextModelBuilder.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Scaffolding/Baselines/ComplexTypes/DbContextModelBuilder.cs @@ -80,18 +80,30 @@ private IRelationalModel CreateRelationalModel() microsoftEntityFrameworkCoreScaffoldingCompiledModelTestBasePrincipalBaseTableBase.Columns.Add("Owned_Details", owned_DetailsColumnBase); var owned_NumberColumnBase = new ColumnBase("Owned_Number", "int", microsoftEntityFrameworkCoreScaffoldingCompiledModelTestBasePrincipalBaseTableBase); microsoftEntityFrameworkCoreScaffoldingCompiledModelTestBasePrincipalBaseTableBase.Columns.Add("Owned_Number", owned_NumberColumnBase); - var owned_Principal_AlternateIdColumnBase = new ColumnBase("Owned_Principal_AlternateId", "uniqueidentifier", microsoftEntityFrameworkCoreScaffoldingCompiledModelTestBasePrincipalBaseTableBase); + var owned_Principal_AlternateIdColumnBase = new ColumnBase("Owned_Principal_AlternateId", "uniqueidentifier", microsoftEntityFrameworkCoreScaffoldingCompiledModelTestBasePrincipalBaseTableBase) + { + IsNullable = true + }; microsoftEntityFrameworkCoreScaffoldingCompiledModelTestBasePrincipalBaseTableBase.Columns.Add("Owned_Principal_AlternateId", owned_Principal_AlternateIdColumnBase); - var owned_Principal_Enum1ColumnBase = new ColumnBase("Owned_Principal_Enum1", "int", microsoftEntityFrameworkCoreScaffoldingCompiledModelTestBasePrincipalBaseTableBase); + var owned_Principal_Enum1ColumnBase = new ColumnBase("Owned_Principal_Enum1", "int", microsoftEntityFrameworkCoreScaffoldingCompiledModelTestBasePrincipalBaseTableBase) + { + IsNullable = true + }; microsoftEntityFrameworkCoreScaffoldingCompiledModelTestBasePrincipalBaseTableBase.Columns.Add("Owned_Principal_Enum1", owned_Principal_Enum1ColumnBase); var owned_Principal_Enum2ColumnBase = new ColumnBase("Owned_Principal_Enum2", "int", microsoftEntityFrameworkCoreScaffoldingCompiledModelTestBasePrincipalBaseTableBase) { IsNullable = true }; microsoftEntityFrameworkCoreScaffoldingCompiledModelTestBasePrincipalBaseTableBase.Columns.Add("Owned_Principal_Enum2", owned_Principal_Enum2ColumnBase); - var owned_Principal_FlagsEnum1ColumnBase = new ColumnBase("Owned_Principal_FlagsEnum1", "int", microsoftEntityFrameworkCoreScaffoldingCompiledModelTestBasePrincipalBaseTableBase); + var owned_Principal_FlagsEnum1ColumnBase = new ColumnBase("Owned_Principal_FlagsEnum1", "int", microsoftEntityFrameworkCoreScaffoldingCompiledModelTestBasePrincipalBaseTableBase) + { + IsNullable = true + }; microsoftEntityFrameworkCoreScaffoldingCompiledModelTestBasePrincipalBaseTableBase.Columns.Add("Owned_Principal_FlagsEnum1", owned_Principal_FlagsEnum1ColumnBase); - var owned_Principal_FlagsEnum2ColumnBase = new ColumnBase("Owned_Principal_FlagsEnum2", "int", microsoftEntityFrameworkCoreScaffoldingCompiledModelTestBasePrincipalBaseTableBase); + var owned_Principal_FlagsEnum2ColumnBase = new ColumnBase("Owned_Principal_FlagsEnum2", "int", microsoftEntityFrameworkCoreScaffoldingCompiledModelTestBasePrincipalBaseTableBase) + { + IsNullable = true + }; microsoftEntityFrameworkCoreScaffoldingCompiledModelTestBasePrincipalBaseTableBase.Columns.Add("Owned_Principal_FlagsEnum2", owned_Principal_FlagsEnum2ColumnBase); var owned_Principal_IdColumnBase = new ColumnBase("Owned_Principal_Id", "bigint", microsoftEntityFrameworkCoreScaffoldingCompiledModelTestBasePrincipalBaseTableBase) { @@ -276,10 +288,16 @@ private IRelationalModel CreateRelationalModel() var owned_NumberColumn = new Column("Owned_Number", "int", principalBaseTable); principalBaseTable.Columns.Add("Owned_Number", owned_NumberColumn); owned_NumberColumn.Accessors = ColumnAccessorsFactory.CreateGeneric(owned_NumberColumn); - var owned_Principal_AlternateIdColumn = new Column("Owned_Principal_AlternateId", "uniqueidentifier", principalBaseTable); + var owned_Principal_AlternateIdColumn = new Column("Owned_Principal_AlternateId", "uniqueidentifier", principalBaseTable) + { + IsNullable = true + }; principalBaseTable.Columns.Add("Owned_Principal_AlternateId", owned_Principal_AlternateIdColumn); owned_Principal_AlternateIdColumn.Accessors = ColumnAccessorsFactory.CreateGeneric(owned_Principal_AlternateIdColumn); - var owned_Principal_Enum1Column = new Column("Owned_Principal_Enum1", "int", principalBaseTable); + var owned_Principal_Enum1Column = new Column("Owned_Principal_Enum1", "int", principalBaseTable) + { + IsNullable = true + }; principalBaseTable.Columns.Add("Owned_Principal_Enum1", owned_Principal_Enum1Column); owned_Principal_Enum1Column.Accessors = ColumnAccessorsFactory.CreateGeneric(owned_Principal_Enum1Column); var owned_Principal_Enum2Column = new Column("Owned_Principal_Enum2", "int", principalBaseTable) @@ -288,10 +306,16 @@ private IRelationalModel CreateRelationalModel() }; principalBaseTable.Columns.Add("Owned_Principal_Enum2", owned_Principal_Enum2Column); owned_Principal_Enum2Column.Accessors = ColumnAccessorsFactory.CreateGeneric(owned_Principal_Enum2Column); - var owned_Principal_FlagsEnum1Column = new Column("Owned_Principal_FlagsEnum1", "int", principalBaseTable); + var owned_Principal_FlagsEnum1Column = new Column("Owned_Principal_FlagsEnum1", "int", principalBaseTable) + { + IsNullable = true + }; principalBaseTable.Columns.Add("Owned_Principal_FlagsEnum1", owned_Principal_FlagsEnum1Column); owned_Principal_FlagsEnum1Column.Accessors = ColumnAccessorsFactory.CreateGeneric(owned_Principal_FlagsEnum1Column); - var owned_Principal_FlagsEnum2Column = new Column("Owned_Principal_FlagsEnum2", "int", principalBaseTable); + var owned_Principal_FlagsEnum2Column = new Column("Owned_Principal_FlagsEnum2", "int", principalBaseTable) + { + IsNullable = true + }; principalBaseTable.Columns.Add("Owned_Principal_FlagsEnum2", owned_Principal_FlagsEnum2Column); owned_Principal_FlagsEnum2Column.Accessors = ColumnAccessorsFactory.CreateGeneric(owned_Principal_FlagsEnum2Column); var owned_Principal_IdColumn = new Column("Owned_Principal_Id", "bigint", principalBaseTable) @@ -971,7 +995,7 @@ private IRelationalModel CreateRelationalModel() var tableMappings1 = new List(); principalBase0.SetRuntimeAnnotation("Relational:TableMappings", tableMappings1); var principalBaseTableMapping1 = new TableMapping(principalBase0, principalBaseTable, true); - principalBaseTable.AddTypeMapping(principalBaseTableMapping1, false); + principalBaseTable.AddTypeMapping(principalBaseTableMapping1, true); tableMappings1.Add(principalBaseTableMapping1); RelationalModel.CreateColumnMapping(owned_Principal_AlternateIdColumn, principalBase0.FindProperty("AlternateId")!, principalBaseTableMapping1); RelationalModel.CreateColumnMapping(owned_Principal_Enum1Column, principalBase0.FindProperty("Enum1")!, principalBaseTableMapping1); diff --git a/test/EFCore.SqlServer.FunctionalTests/Scaffolding/Baselines/ComplexTypes/PrincipalBaseEntityType.cs b/test/EFCore.SqlServer.FunctionalTests/Scaffolding/Baselines/ComplexTypes/PrincipalBaseEntityType.cs index ad691c8482c..c5e6c132300 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Scaffolding/Baselines/ComplexTypes/PrincipalBaseEntityType.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Scaffolding/Baselines/ComplexTypes/PrincipalBaseEntityType.cs @@ -1772,6 +1772,7 @@ public static RuntimeComplexProperty Create(RuntimeComplexType declaringType) typeof(CompiledModelTestBase.PrincipalBase), propertyInfo: typeof(CompiledModelTestBase.OwnedType).GetProperty("Principal", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), fieldInfo: typeof(CompiledModelTestBase.OwnedType).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + nullable: true, propertyCount: 14); var complexType = complexProperty.ComplexType; diff --git a/test/EFCore.Sqlite.FunctionalTests/LazyLoadProxySqliteTest.cs b/test/EFCore.Sqlite.FunctionalTests/LazyLoadProxySqliteTest.cs index 99d375a0cf5..0d62473377d 100644 --- a/test/EFCore.Sqlite.FunctionalTests/LazyLoadProxySqliteTest.cs +++ b/test/EFCore.Sqlite.FunctionalTests/LazyLoadProxySqliteTest.cs @@ -6,7 +6,7 @@ namespace Microsoft.EntityFrameworkCore; #nullable disable public class LazyLoadProxySqliteTest(LazyLoadProxySqliteTest.LoadSqliteFixture fixture) - : LazyLoadProxyTestBase(fixture) + : LazyLoadProxyRelationalTestBase(fixture) { [ConditionalFact] public void IsLoaded_is_not_set_if_loading_principal_collection_fails() @@ -1582,7 +1582,7 @@ protected override string SerializedBlogs1 ] """; - public class LoadSqliteFixture : LoadFixtureBase + public class LoadSqliteFixture : LoadRelationalFixtureBase { public ThrowingInterceptor Interceptor { get; } = new(); diff --git a/test/EFCore.Tests/Infrastructure/ModelValidatorTest.cs b/test/EFCore.Tests/Infrastructure/ModelValidatorTest.cs index e6ca88c0332..643ca9cc5df 100644 --- a/test/EFCore.Tests/Infrastructure/ModelValidatorTest.cs +++ b/test/EFCore.Tests/Infrastructure/ModelValidatorTest.cs @@ -1150,6 +1150,21 @@ public virtual void Detects_collection_complex_properties() modelBuilder); } + [ConditionalFact] + public virtual void Detects_optional_collection_complex_properties() + { + var modelBuilder = CreateConventionModelBuilder(); + modelBuilder.Ignore(typeof(Order)); + + var model = modelBuilder.Model; + var customerEntity = model.AddEntityType(typeof(Customer)); + var collection = customerEntity.AddComplexProperty(nameof(Customer.Orders), collection: true); + + Assert.Equal( + CoreStrings.ComplexPropertyOptional("Customer", "Orders"), + Assert.Throws(() => collection.IsNullable = true).Message); + } + [ConditionalFact] public virtual void Detects_shadow_complex_properties() {