From ae45d85a8fd8ead4a99a92cc90596054bc7cdc5a Mon Sep 17 00:00:00 2001 From: maumar Date: Thu, 28 Jul 2022 13:12:12 -0700 Subject: [PATCH] Metadata work for json. Separated from main PR to make the review easier. This PR includes: - builder methods, - conventions, - relational model, - model validation, - migrations, - update --- .../Design/AnnotationCodeGenerator.cs | 20 +- .../RelationalPropertyInternalExtensions.cs | 29 + .../RelationalEntityTypeExtensions.cs | 98 +- .../RelationalNavigationBuilderExtensions.cs | 52 + .../RelationalNavigationExtensions.cs | 66 ++ ...ationalOwnedNavigationBuilderExtensions.cs | 134 +++ .../RelationalPropertyBuilderExtensions.cs | 69 ++ .../RelationalPropertyExtensions.cs | 68 +- .../RelationalModelValidator.cs | 306 +++++- .../RelationalConventionSetBuilder.cs | 10 + .../RelationalMapToJsonConvention.cs | 80 ++ ...tionJsonPropertyNameAttributeConvention.cs | 46 + ...ertyJsonPropertyNameAttributeConvention.cs | 46 + .../RelationalValueGenerationConvention.cs | 10 +- .../Metadata/Internal/JsonColumn.cs | 162 ++++ .../Metadata/Internal/JsonViewColumn.cs | 24 + .../RelationalEntityTypeExtensions.cs | 5 + .../RelationalForeignKeyExtensions.cs | 3 +- .../Internal/RelationalKeyExtensions.cs | 3 +- .../Metadata/Internal/RelationalModel.cs | 182 ++-- .../Internal/StoredProcedureParameter.cs | 2 +- .../Metadata/RelationalAnnotationNames.cs | 15 + .../Internal/MigrationsModelDiffer.cs | 83 +- .../Properties/RelationalStrings.Designer.cs | 106 +++ .../Properties/RelationalStrings.resx | 42 + .../Storage/JsonTypeMapping.cs | 48 + .../Update/Internal/CommandBatchPreparer.cs | 5 + .../Update/ModificationCommand.cs | 124 +++ .../RelationalValueGeneratorSelector.cs | 6 + .../SqlServerAnnotationCodeGenerator.cs | 3 +- .../SqlServerTemporalConvention.cs | 1 - .../Internal/SqlServerAnnotationProvider.cs | 8 +- .../Internal/SqlServerJsonTypeMapping.cs | 107 +++ .../Internal/SqlServerTypeMappingSource.cs | 7 +- src/EFCore/Query/ExpressionPrinter.cs | 27 +- ...nExpandingExpressionVisitor.Expressions.cs | 14 +- src/Shared/ExpressionExtensions.cs | 1 - .../Query/OwnedQueryCosmosTest.cs | 16 + .../CustomPartitionKeyIdGenerator.cs | 2 +- .../Metadata/CosmosBuilderExtensionsTest.cs | 2 +- .../CosmosModelBuilderGenericTest.cs | 4 +- .../Design/CSharpMigrationsGeneratorTest.cs | 11 +- .../Migrations/ModelSnapshotSqlServerTest.cs | 160 ++++ .../CSharpRuntimeModelCodeGeneratorTest.cs | 12 +- .../RelationalModelValidatorTest.Json.cs | 554 +++++++++++ .../RelationalModelBuilderTest.cs | 66 +- .../RelationalTestModelBuilderExtensions.cs | 73 ++ .../Query/Ef6GroupByTestBase.cs | 11 +- .../Query/OwnedQueryTestBase.cs | 57 +- .../Migrations/MigrationsSqlServerTest.cs | 885 ++++++++++++++++++ .../Query/OwnedQuerySqlServerTest.cs | 52 + .../Query/TemporalOwnedQuerySqlServerTest.cs | 41 + .../SqlServerModelBuilderTestBase.cs | 438 +++++++++ 53 files changed, 4256 insertions(+), 140 deletions(-) create mode 100644 src/EFCore.Relational/Extensions/Internal/RelationalPropertyInternalExtensions.cs create mode 100644 src/EFCore.Relational/Extensions/RelationalNavigationBuilderExtensions.cs create mode 100644 src/EFCore.Relational/Extensions/RelationalNavigationExtensions.cs create mode 100644 src/EFCore.Relational/Extensions/RelationalOwnedNavigationBuilderExtensions.cs create mode 100644 src/EFCore.Relational/Metadata/Conventions/RelationalMapToJsonConvention.cs create mode 100644 src/EFCore.Relational/Metadata/Conventions/RelationalNavigationJsonPropertyNameAttributeConvention.cs create mode 100644 src/EFCore.Relational/Metadata/Conventions/RelationalPropertyJsonPropertyNameAttributeConvention.cs create mode 100644 src/EFCore.Relational/Metadata/Internal/JsonColumn.cs create mode 100644 src/EFCore.Relational/Metadata/Internal/JsonViewColumn.cs create mode 100644 src/EFCore.Relational/Storage/JsonTypeMapping.cs create mode 100644 src/EFCore.SqlServer/Storage/Internal/SqlServerJsonTypeMapping.cs create mode 100644 test/EFCore.Relational.Tests/Infrastructure/RelationalModelValidatorTest.Json.cs diff --git a/src/EFCore.Relational/Design/AnnotationCodeGenerator.cs b/src/EFCore.Relational/Design/AnnotationCodeGenerator.cs index 63c0d25dcbc..7c05933fc8d 100644 --- a/src/EFCore.Relational/Design/AnnotationCodeGenerator.cs +++ b/src/EFCore.Relational/Design/AnnotationCodeGenerator.cs @@ -35,7 +35,8 @@ public class AnnotationCodeGenerator : IAnnotationCodeGenerator RelationalAnnotationNames.InsertStoredProcedure, RelationalAnnotationNames.UpdateStoredProcedure, RelationalAnnotationNames.MappingFragments, - RelationalAnnotationNames.RelationalOverrides + RelationalAnnotationNames.RelationalOverrides, + RelationalAnnotationNames.JsonColumnTypeMapping }; #region MethodInfos @@ -136,6 +137,10 @@ private static readonly MethodInfo IndexHasFilterNameMethodInfo = typeof(RelationalIndexBuilderExtensions).GetRuntimeMethod( nameof(RelationalIndexBuilderExtensions.HasFilter), new[] { typeof(IndexBuilder), typeof(string) })!; + private static readonly MethodInfo ToJsonMethodInfo + = typeof(RelationalOwnedNavigationBuilderExtensions).GetRuntimeMethod( + nameof(RelationalOwnedNavigationBuilderExtensions.ToJson), new[] { typeof(OwnedNavigationBuilder), typeof(string) })!; + #endregion MethodInfos /// @@ -303,6 +308,19 @@ public virtual IReadOnlyList GenerateFluentApiCalls( } } + if (annotations.TryGetValue(RelationalAnnotationNames.JsonColumnName, out var jsonColumnNameAnnotation) + && jsonColumnNameAnnotation != null && jsonColumnNameAnnotation.Value is string jsonColumnName + && entityType.IsOwned()) + { + methodCallCodeFragments.Add( + new MethodCallCodeFragment( + ToJsonMethodInfo, + jsonColumnName)); + + annotations.Remove(RelationalAnnotationNames.JsonColumnName); + annotations.Remove(RelationalAnnotationNames.JsonColumnTypeMapping); + } + methodCallCodeFragments.AddRange(GenerateFluentApiCallsHelper(entityType, annotations, GenerateFluentApi)); return methodCallCodeFragments; diff --git a/src/EFCore.Relational/Extensions/Internal/RelationalPropertyInternalExtensions.cs b/src/EFCore.Relational/Extensions/Internal/RelationalPropertyInternalExtensions.cs new file mode 100644 index 00000000000..ba1c00b5da6 --- /dev/null +++ b/src/EFCore.Relational/Extensions/Internal/RelationalPropertyInternalExtensions.cs @@ -0,0 +1,29 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.EntityFrameworkCore.Metadata.Internal +{ + /// + /// 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 class RelationalPropertyInternalExtensions + { + /// + /// 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 bool IsOrdinalKeyProperty(this IReadOnlyProperty property) + => property.FindContainingPrimaryKey() is IReadOnlyKey key + && key.Properties.Count > 1 + && !property.IsForeignKey() + && property.Name == "Id" + && property.IsShadowProperty() + && property.ClrType == typeof(int) + && property.GetJsonPropertyName() == null; + } +} diff --git a/src/EFCore.Relational/Extensions/RelationalEntityTypeExtensions.cs b/src/EFCore.Relational/Extensions/RelationalEntityTypeExtensions.cs index 68ec8c0e4db..d24502b2549 100644 --- a/src/EFCore.Relational/Extensions/RelationalEntityTypeExtensions.cs +++ b/src/EFCore.Relational/Extensions/RelationalEntityTypeExtensions.cs @@ -62,7 +62,7 @@ public static class RelationalEntityTypeExtensions var ownership = entityType.FindOwnership(); if (ownership != null - && ownership.IsUnique) + && (ownership.IsUnique || entityType.IsMappedToJson())) { return ownership.PrincipalEntityType.GetTableName(); } @@ -316,7 +316,7 @@ public static IEnumerable GetTableMappings(this IEntityType entit var ownership = entityType.FindOwnership(); return ownership != null - && ownership.IsUnique + && (ownership.IsUnique || entityType.IsMappedToJson()) ? ownership.PrincipalEntityType.GetViewName() : null; } @@ -1385,7 +1385,7 @@ public static IEnumerable FindRowInternalForeignKeys( StoreObjectIdentifier storeObject) { var primaryKey = entityType.FindPrimaryKey(); - if (primaryKey == null) + if (primaryKey == null || entityType.IsMappedToJson()) { yield break; } @@ -1855,4 +1855,96 @@ public static IEnumerable GetDeclaredTriggers(this IEntityType entityT => Trigger.GetDeclaredTriggers(entityType).Cast(); #endregion Trigger + + #region Json + + /// + /// Gets a value indicating whether the specified entity is mapped to a JSON column. + /// + /// The entity type. + /// A value indicating whether the associated entity type is mapped to a JSON column. + public static bool IsMappedToJson(this IReadOnlyEntityType entityType) + => !string.IsNullOrEmpty(entityType.GetJsonColumnName()); + + /// + /// Sets the name of the JSON column to which the entity type is mapped. + /// + /// The entity type to set the JSON column name for. + /// The name to set. + public static void SetJsonColumnName(this IMutableEntityType entityType, string? columnName) + => entityType.SetOrRemoveAnnotation(RelationalAnnotationNames.JsonColumnName, columnName); + + /// + /// Sets the name of the JSON column to which the entity type is mapped. + /// + /// The entity type to set the JSON column name for. + /// The name to set. + /// Indicates whether the configuration was specified using a data annotation. + /// The configured value. + public static string? SetJsonColumnName( + this IConventionEntityType entityType, + string? columnName, + bool fromDataAnnotation = false) + => (string?)entityType.SetAnnotation(RelationalAnnotationNames.JsonColumnName, columnName, fromDataAnnotation)?.Value; + + /// + /// Gets the for the JSON column name. + /// + /// The entity type to set the JSON column name for. + /// The for the JSON column name. + public static ConfigurationSource? GetJsonColumnNameConfigurationSource(this IConventionEntityType entityType) + => entityType.FindAnnotation(RelationalAnnotationNames.JsonColumnName) + ?.GetConfigurationSource(); + + /// + /// Gets the JSON column name to which the entity type is mapped. + /// + /// The entity type to get the JSON column name for. + /// The JSON column name to which the entity type is mapped. + public static string? GetJsonColumnName(this IReadOnlyEntityType entityType) + => entityType.FindAnnotation(RelationalAnnotationNames.JsonColumnName)?.Value is string jsonColumnName + ? jsonColumnName + : (entityType.FindOwnership()?.PrincipalEntityType.GetJsonColumnName()); + + /// + /// Sets the type mapping for the JSON column to which the entity type is mapped. + /// + /// The entity type to set the JSON column type mapping for. + /// The type mapping to set. + public static void SetJsonColumnTypeMapping(this IMutableEntityType entityType, RelationalTypeMapping typeMapping) + => entityType.SetOrRemoveAnnotation(RelationalAnnotationNames.JsonColumnTypeMapping, typeMapping); + + /// + /// Sets the type mapping for the JSON column to which the entity type is mapped. + /// + /// The entity type to set the JSON column type mapping for. + /// The type mapping to set. + /// Indicates whether the configuration was specified using a data annotation. + /// The configured value. + public static RelationalTypeMapping? SetJsonColumnTypeMapping( + this IConventionEntityType entityType, + RelationalTypeMapping? typeMapping, + bool fromDataAnnotation = false) + => (RelationalTypeMapping?)entityType.SetAnnotation(RelationalAnnotationNames.JsonColumnTypeMapping, typeMapping, fromDataAnnotation)?.Value; + + /// + /// Gets the for the JSON column type mapping. + /// + /// The entity type to set the JSON column type mapping for. + /// The for the JSON column type mapping. + public static ConfigurationSource? GetJsonColumnTypeMappingConfigurationSource(this IConventionEntityType entityType) + => entityType.FindAnnotation(RelationalAnnotationNames.JsonColumnTypeMapping) + ?.GetConfigurationSource(); + + /// + /// Gets the JSON column type mapping to which the entity type is mapped. + /// + /// The entity type to get the JSON column type mapping for. + /// The JSON column type mapping to which the entity type is mapped. + public static RelationalTypeMapping? GetJsonColumnTypeMapping(this IReadOnlyEntityType entityType) + => entityType.FindAnnotation(RelationalAnnotationNames.JsonColumnTypeMapping)?.Value is RelationalTypeMapping jsonColumnTypeMapping + ? jsonColumnTypeMapping + : (entityType.FindOwnership()?.PrincipalEntityType.GetJsonColumnTypeMapping()); + + #endregion } diff --git a/src/EFCore.Relational/Extensions/RelationalNavigationBuilderExtensions.cs b/src/EFCore.Relational/Extensions/RelationalNavigationBuilderExtensions.cs new file mode 100644 index 00000000000..0435837e2ba --- /dev/null +++ b/src/EFCore.Relational/Extensions/RelationalNavigationBuilderExtensions.cs @@ -0,0 +1,52 @@ +// 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; + +/// +/// Relational database specific extension methods for . +/// +/// +/// See Modeling entity types and relationships for more information and examples. +/// +public static class RelationalNavigationBuilderExtensions +{ + /// + /// Configures the navigation of an entity mapped to a JSON column, mapping the navigation to a specific JSON property, + /// rather than using the navigation name. + /// + /// The builder for the navigation being configured. + /// JSON property name to be used. + /// Indicates whether the configuration was specified using a data annotation. + /// + /// The same builder instance if the configuration was applied, + /// otherwise. + /// + public static IConventionNavigationBuilder? HasJsonPropertyName( + this IConventionNavigationBuilder navigationBuilder, + string? name, + bool fromDataAnnotation = false) + { + if (!navigationBuilder.CanSetJsonPropertyName(name, fromDataAnnotation)) + { + return null; + } + + navigationBuilder.Metadata.SetJsonPropertyName(name, fromDataAnnotation); + + return navigationBuilder; + } + + /// + /// Returns a value indicating whether the given value can be used as a JSON property name for a given navigation. + /// + /// The builder for the navigation being configured. + /// JSON property name to be used. + /// Indicates whether the configuration was specified using a data annotation. + /// if the given value can be set as JSON property name for this navigation. + public static bool CanSetJsonPropertyName( + this IConventionNavigationBuilder navigationBuilder, + string? name, + bool fromDataAnnotation = false) + => navigationBuilder.CanSetAnnotation(RelationalAnnotationNames.JsonPropertyName, name, fromDataAnnotation); +} diff --git a/src/EFCore.Relational/Extensions/RelationalNavigationExtensions.cs b/src/EFCore.Relational/Extensions/RelationalNavigationExtensions.cs new file mode 100644 index 00000000000..655f5c4f89e --- /dev/null +++ b/src/EFCore.Relational/Extensions/RelationalNavigationExtensions.cs @@ -0,0 +1,66 @@ +// 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; + +/// +/// Navigation extension methods for relational database metadata. +/// +/// +/// See Modeling entity types and relationships for more information and examples. +/// +public static class RelationalNavigationExtensions +{ + /// + /// Gets the value of JSON property name used for the given navigation of an entity mapped to a JSON column. + /// + /// + /// Unless configured explicitly, navigation name is used. + /// + /// The navigation. + /// + /// The value for the JSON property used to store the value of this navigation. + /// is returned for navigations of entities that are not mapped to a JSON column. + /// + public static string? GetJsonPropertyName(this IReadOnlyNavigationBase navigation) + => (string?)navigation.FindAnnotation(RelationalAnnotationNames.JsonPropertyName)?.Value + ?? (!navigation.DeclaringEntityType.IsMappedToJson() ? null : navigation.Name); + + /// + /// Sets the value of JSON property name used for the given navigation of an entity mapped to a JSON column. + /// + /// The navigation. + /// The name to be used. + public static void SetJsonPropertyName(this IMutableNavigationBase navigation, string? name) + => navigation.SetOrRemoveAnnotation( + RelationalAnnotationNames.JsonPropertyName, + Check.NullButNotEmpty(name, nameof(name))); + + /// + /// Sets the value of JSON property name used for the given navigation of an entity mapped to a JSON column. + /// + /// The navigation. + /// The name to be used. + /// Indicates whether the configuration was specified using a data annotation. + /// The configured value. + public static string? SetJsonPropertyName( + this IConventionNavigationBase navigation, + string? name, + bool fromDataAnnotation = false) + { + navigation.SetOrRemoveAnnotation( + RelationalAnnotationNames.JsonPropertyName, + Check.NullButNotEmpty(name, nameof(name)), + fromDataAnnotation); + + return name; + } + + /// + /// Gets the for the JSON property name for a given navigation. + /// + /// The navigation. + /// The for the JSON property name for a given navigation. + public static ConfigurationSource? GetJsonPropertyNameConfigurationSource(this IConventionNavigationBase navigation) + => navigation.FindAnnotation(RelationalAnnotationNames.JsonPropertyName)?.GetConfigurationSource(); +} diff --git a/src/EFCore.Relational/Extensions/RelationalOwnedNavigationBuilderExtensions.cs b/src/EFCore.Relational/Extensions/RelationalOwnedNavigationBuilderExtensions.cs new file mode 100644 index 00000000000..576d62aed1a --- /dev/null +++ b/src/EFCore.Relational/Extensions/RelationalOwnedNavigationBuilderExtensions.cs @@ -0,0 +1,134 @@ +// 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; + +/// +/// Relational database specific extension methods for . +/// +/// +/// See Modeling entity types and relationships for more information and examples. +/// +public static class RelationalOwnedNavigationBuilderExtensions +{ + /// + /// Configures a relationship where this entity type and the entities that it owns are mapped to a JSON column in the database. + /// + /// + /// This method should only be specified for the outer-most owned entity in the given ownership structure. + /// All entities owned by this will be automatically mapped to the same JSON column. + /// The ownerships must still be explicitly defined. + /// Name of the navigation will be used as the JSON column name. + /// + /// The builder for the owned navigation being configured. + /// The same builder instance so that multiple calls can be chained. + public static OwnedNavigationBuilder ToJson(this OwnedNavigationBuilder builder) + { + var navigationName = builder.Metadata.GetNavigation(pointsToPrincipal: false)!.Name; + builder.ToJson(navigationName); + + return builder; + } + + /// + /// Configures a relationship where this entity type and the entities that it owns are mapped to a JSON column in the database. + /// + /// + /// This method should only be specified for the outer-most owned entity in the given ownership structure. + /// All entities owned by this will be automatically mapped to the same JSON column. + /// The ownerships must still be explicitly defined. + /// Name of the navigation will be used as the JSON column name. + /// + /// The builder for the owned navigation being configured. + /// The same builder instance so that multiple calls can be chained. + public static OwnedNavigationBuilder ToJson( + this OwnedNavigationBuilder builder) + where TOwnerEntity : class + where TDependentEntity : class + { + var navigationName = builder.Metadata.GetNavigation(pointsToPrincipal: false)!.Name; + builder.ToJson(navigationName); + + return builder; + } + + /// + /// Configures a relationship where this entity type and the entities that it owns are mapped to a JSON column in the database. + /// + /// + /// This method should only be specified for the outer-most owned entity in the given ownership structure. + /// All entities owned by this will be automatically mapped to the same JSON column. + /// The ownerships must still be explicitly defined. + /// + /// The builder for the owned navigation being configured. + /// JSON column name to use. + /// The same builder instance so that multiple calls can be chained. + public static OwnedNavigationBuilder ToJson( + this OwnedNavigationBuilder builder, + string? jsonColumnName) + where TOwnerEntity : class + where TDependentEntity : class + { + builder.OwnedEntityType.SetJsonColumnName(jsonColumnName); + + return builder; + } + + /// + /// Configures a relationship where this entity type and the entities that it owns are mapped to a JSON column in the database. + /// + /// + /// This method should only be specified for the outer-most owned entity in the given ownership structure. + /// All entities owned by this will be automatically mapped to the same JSON column. + /// The ownerships must still be explicitly defined. + /// + /// The builder for the owned navigation being configured. + /// JSON column name to use. + /// The same builder instance so that multiple calls can be chained. + public static OwnedNavigationBuilder ToJson( + this OwnedNavigationBuilder builder, + string? jsonColumnName) + { + builder.OwnedEntityType.SetJsonColumnName(jsonColumnName); + + return builder; + } + + /// + /// Configures the navigation of an entity mapped to a JSON column, mapping the navigation to a specific JSON property, + /// rather than using the navigation name. + /// + /// The builder for the navigation being configured. + /// JSON property name to be used. + /// The same builder instance so that multiple calls can be chained. + public static OwnedNavigationBuilder HasJsonPropertyName( + this OwnedNavigationBuilder navigationBuilder, + string? name) + { + Check.NullButNotEmpty(name, nameof(name)); + + if (!navigationBuilder.Metadata.PrincipalEntityType.IsOwned()) + { + throw new InvalidOperationException( + RelationalStrings.JsonPropertyNameShouldBeConfiguredOnNestedNavigation); + } + + navigationBuilder.Metadata.GetNavigation(pointsToPrincipal: false)!.SetJsonPropertyName(name); + + return navigationBuilder; + } + + /// + /// Configures the navigation of an entity mapped to a JSON column, mapping the navigation to a specific JSON property, + /// rather than using the navigation name. + /// + /// The builder for the navigation being configured. + /// JSON property name to be used. + /// The same builder instance so that multiple calls can be chained. + public static OwnedNavigationBuilder HasJsonPropertyName( + this OwnedNavigationBuilder navigationBuilder, + string? name) + where TSource : class + where TTarget : class + => (OwnedNavigationBuilder)HasJsonPropertyName((OwnedNavigationBuilder)navigationBuilder, name); +} diff --git a/src/EFCore.Relational/Extensions/RelationalPropertyBuilderExtensions.cs b/src/EFCore.Relational/Extensions/RelationalPropertyBuilderExtensions.cs index 1547bc19942..b6187f7c04d 100644 --- a/src/EFCore.Relational/Extensions/RelationalPropertyBuilderExtensions.cs +++ b/src/EFCore.Relational/Extensions/RelationalPropertyBuilderExtensions.cs @@ -987,4 +987,73 @@ public static bool CanSetCollation( string? collation, bool fromDataAnnotation = false) => propertyBuilder.CanSetAnnotation(RelationalAnnotationNames.Collation, collation, fromDataAnnotation); + + /// + /// Configures the property of an entity mapped to a JSON column, mapping the entity property to a specific JSON property, + /// rather than using the entity property name. + /// + /// The builder for the property being configured. + /// JSON property name to be used. + /// The same builder instance so that multiple calls can be chained. + public static PropertyBuilder HasJsonPropertyName( + this PropertyBuilder propertyBuilder, + string? name) + { + Check.NullButNotEmpty(name, nameof(name)); + + propertyBuilder.Metadata.SetJsonPropertyName(name); + + return propertyBuilder; + } + + /// + /// Configures the property of an entity mapped to a JSON column, mapping the entity property to a specific JSON property, + /// rather than using the entity property name. + /// + /// The builder for the property being configured. + /// JSON property name to be used. + /// The same builder instance so that multiple calls can be chained. + public static PropertyBuilder HasJsonPropertyName( + this PropertyBuilder propertyBuilder, + string? name) + => (PropertyBuilder)HasJsonPropertyName((PropertyBuilder)propertyBuilder, name); + + /// + /// Configures the property of an entity mapped to a JSON column, mapping the entity property to a specific JSON property, + /// rather than using the entity property name. + /// + /// The builder for the property being configured. + /// JSON property name to be used. + /// Indicates whether the configuration was specified using a data annotation. + /// + /// The same builder instance if the configuration was applied, + /// otherwise. + /// + public static IConventionPropertyBuilder? HasJsonPropertyName( + this IConventionPropertyBuilder propertyBuilder, + string? name, + bool fromDataAnnotation = false) + { + if (!propertyBuilder.CanSetJsonPropertyName(name, fromDataAnnotation)) + { + return null; + } + + propertyBuilder.Metadata.SetJsonPropertyName(name, fromDataAnnotation); + + return propertyBuilder; + } + + /// + /// Returns a value indicating whether the given value can be used as a JSON property name for a given entity property. + /// + /// The builder for the property being configured. + /// JSON property name to be used. + /// Indicates whether the configuration was specified using a data annotation. + /// if the given value can be set as JSON property name for this entity property. + public static bool CanSetJsonPropertyName( + this IConventionPropertyBuilder propertyBuilder, + string? name, + bool fromDataAnnotation = false) + => propertyBuilder.CanSetAnnotation(RelationalAnnotationNames.JsonPropertyName, name, fromDataAnnotation); } diff --git a/src/EFCore.Relational/Extensions/RelationalPropertyExtensions.cs b/src/EFCore.Relational/Extensions/RelationalPropertyExtensions.cs index ac1baec11b5..d046748cac3 100644 --- a/src/EFCore.Relational/Extensions/RelationalPropertyExtensions.cs +++ b/src/EFCore.Relational/Extensions/RelationalPropertyExtensions.cs @@ -168,8 +168,13 @@ public static string GetDefaultColumnName(this IReadOnlyProperty property) /// The property. /// The identifier of the table-like store object containing the column. /// The default column name to which the property would be mapped. - public static string GetDefaultColumnName(this IReadOnlyProperty property, in StoreObjectIdentifier storeObject) + public static string? GetDefaultColumnName(this IReadOnlyProperty property, in StoreObjectIdentifier storeObject) { + if (property.DeclaringEntityType.IsMappedToJson()) + { + return null; + } + var sharedTablePrincipalPrimaryKeyProperty = FindSharedObjectRootPrimaryKeyProperty(property, storeObject); if (sharedTablePrincipalPrimaryKeyProperty != null) { @@ -1398,6 +1403,13 @@ public static RelationalTypeMapping GetRelationalTypeMapping(this IReadOnlyPrope private static IReadOnlyProperty? FindSharedObjectRootProperty(IReadOnlyProperty property, in StoreObjectIdentifier storeObject) { + if (property.DeclaringEntityType.IsMappedToJson()) + { + //JSON-splitting is not supported + //issue #28574 + return null; + } + var column = property.GetColumnName(storeObject); if (column == null) { @@ -1452,6 +1464,7 @@ public static RelationalTypeMapping GetRelationalTypeMapping(this IReadOnlyPrope { var linkingRelationship = principalProperty.DeclaringEntityType .FindRowInternalForeignKeys(storeObject).FirstOrDefault(); + if (linkingRelationship == null) { break; @@ -1910,4 +1923,57 @@ private static TValue ThrowReadValueException( throw new InvalidOperationException(message, exception); } + + /// + /// Gets the value of JSON property name used for the given property of an entity mapped to a JSON column. + /// + /// + /// Unless configured explicitly, entity property name is used. + /// + /// The property. + /// + /// The value for the JSON property used to store the value of this entity property. + /// is returned for key properties and for properties of entities that are not mapped to a JSON column. + /// + public static string? GetJsonPropertyName(this IReadOnlyProperty property) + => (string?)property.FindAnnotation(RelationalAnnotationNames.JsonPropertyName)?.Value + ?? (property.IsKey() || !property.DeclaringEntityType.IsMappedToJson() ? null : property.Name); + + /// + /// Sets the value of JSON property name used for the given property of an entity mapped to a JSON column. + /// + /// The property. + /// The name to be used. + public static void SetJsonPropertyName(this IMutableProperty property, string? name) + => property.SetOrRemoveAnnotation( + RelationalAnnotationNames.JsonPropertyName, + Check.NullButNotEmpty(name, nameof(name))); + + /// + /// Sets the value of JSON property name used for the given property of an entity mapped to a JSON column. + /// + /// The property. + /// The name to be used. + /// Indicates whether the configuration was specified using a data annotation. + /// The configured value. + public static string? SetJsonPropertyName( + this IConventionProperty property, + string? name, + bool fromDataAnnotation = false) + { + property.SetOrRemoveAnnotation( + RelationalAnnotationNames.JsonPropertyName, + Check.NullButNotEmpty(name, nameof(name)), + fromDataAnnotation); + + return name; + } + + /// + /// Gets the for the JSON property name for a given entity property. + /// + /// The property. + /// The for the JSON property name for a given entity property. + public static ConfigurationSource? GetJsonPropertyNameConfigurationSource(this IConventionProperty property) + => property.FindAnnotation(RelationalAnnotationNames.JsonPropertyName)?.GetConfigurationSource(); } diff --git a/src/EFCore.Relational/Infrastructure/RelationalModelValidator.cs b/src/EFCore.Relational/Infrastructure/RelationalModelValidator.cs index 04e6106aa95..b2b77e1a73e 100644 --- a/src/EFCore.Relational/Infrastructure/RelationalModelValidator.cs +++ b/src/EFCore.Relational/Infrastructure/RelationalModelValidator.cs @@ -2,8 +2,8 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Data; -using Microsoft.EntityFrameworkCore.Internal; using Microsoft.EntityFrameworkCore.Metadata.Internal; +using Microsoft.EntityFrameworkCore.Internal; namespace Microsoft.EntityFrameworkCore.Infrastructure; @@ -61,6 +61,7 @@ public override void Validate(IModel model, IDiagnosticsLogger @@ -641,25 +642,7 @@ protected virtual void ValidateSharedTableCompatibility( IModel model, IDiagnosticsLogger logger) { - var tables = new Dictionary>(); - foreach (var entityType in model.GetEntityTypes()) - { - var tableId = StoreObjectIdentifier.Create(entityType, StoreObjectType.Table); - if (tableId == null) - { - continue; - } - - var table = tableId.Value; - if (!tables.TryGetValue(table, out var mappedTypes)) - { - mappedTypes = new List(); - tables[table] = mappedTypes; - } - - mappedTypes.Add(entityType); - } - + var tables = BuildSharedTableEntityMap(model.GetEntityTypes().Where(e => !e.IsMappedToJson())); foreach (var (table, mappedTypes) in tables) { ValidateSharedTableCompatibility(mappedTypes, table, logger); @@ -758,6 +741,30 @@ protected virtual void ValidateSharedTableCompatibility( } } + private Dictionary> BuildSharedTableEntityMap(IEnumerable entityTypes) + { + var result = new Dictionary>(); + foreach (var entityType in entityTypes) + { + var tableId = StoreObjectIdentifier.Create(entityType, StoreObjectType.Table); + if (tableId == null) + { + continue; + } + + var table = tableId.Value; + if (!result.TryGetValue(table, out var mappedTypes)) + { + mappedTypes = new List(); + result[table] = mappedTypes; + } + + mappedTypes.Add(entityType); + } + + return result; + } + /// /// Validates the compatibility of entity types sharing a given table. /// @@ -915,7 +922,7 @@ protected virtual void ValidateSharedViewCompatibility( IDiagnosticsLogger logger) { var views = new Dictionary>(); - foreach (var entityType in model.GetEntityTypes()) + foreach (var entityType in model.GetEntityTypes().Where(e => !e.IsMappedToJson())) { var viewsName = entityType.GetViewName(); if (viewsName == null) @@ -2323,6 +2330,263 @@ protected virtual void ValidateTriggers( } } + /// + /// Validates the JSON entities. + /// + /// The model to validate. + /// The logger to use. + protected virtual void ValidateJsonEntities( + IModel model, + IDiagnosticsLogger logger) + { + var tables = BuildSharedTableEntityMap(model.GetEntityTypes()); + foreach (var (table, mappedTypes) in tables) + { + if (mappedTypes.All(x => !x.IsMappedToJson())) + { + continue; + } + + var nonOwnedTypes = mappedTypes.Where(x => !x.IsOwned()); + var nonOwnedTypesCount = nonOwnedTypes.Count(); + if (nonOwnedTypesCount == 0) + { + var nonJsonType = mappedTypes.Where(x => !x.IsMappedToJson()).First(); + + // must be owned collection (mapped to a separate table) that owns a JSON type + // issue #28441 + throw new InvalidOperationException( + RelationalStrings.JsonEntityOwnedByNonJsonOwnedType( + nonJsonType.DisplayName(), table.DisplayName())); + } + + var distinctRootTypes = nonOwnedTypes.Select(x => x.GetRootType()).Distinct().ToList(); + if (distinctRootTypes.Count > 1) + { + // issue #28442 + throw new InvalidOperationException( + RelationalStrings.JsonEntityWithTableSplittingIsNotSupported); + } + + var rootType = distinctRootTypes[0]; + var jsonEntitiesMappedToSameJsonColumn = mappedTypes + .Where(x => x.FindOwnership() is IForeignKey ownership && !ownership.PrincipalEntityType.IsOwned()) + .GroupBy(x => x.GetJsonColumnName()) + .Where(x => x.Key is not null) + .Select(g => new { g.Key, Count = g.Count() }) + .Where(x => x.Count > 1) + .Select(x => x.Key); + + if (jsonEntitiesMappedToSameJsonColumn.FirstOrDefault() is string jsonEntityMappedToSameJsonColumn) + { + // issue #28584 + throw new InvalidOperationException( + RelationalStrings.JsonEntityMultipleRootsMappedToTheSameJsonColumn( + jsonEntityMappedToSameJsonColumn, table.Name)); + } + + ValidateJsonEntityRoot(table, rootType); + + foreach (var jsonEntityType in mappedTypes.Where(x => x.IsMappedToJson())) + { + ValidateJsonEntityNavigations(table, jsonEntityType); + ValidateJsonEntityKey(table, jsonEntityType); + ValidateJsonEntityProperties(table, jsonEntityType); + } + } + + // TODO: support this for raw SQL and function mappings in #19970 and #21627 and remove the check + ValidateJsonEntitiesNotMappedToTableOrView(model.GetEntityTypes()); + ValidateJsonViews(model.GetEntityTypes().Where(t => t.IsMappedToJson())); + } + + private void ValidateJsonEntitiesNotMappedToTableOrView(IEnumerable entityTypes) + { + var entitiesNotMappedToTableOrView = entityTypes.Where(x => !x.IsMappedToJson() + && x.GetSchemaQualifiedTableName() == null + && x.GetSchemaQualifiedViewName() == null); + + foreach (var entityNotMappedToTableOrView in entitiesNotMappedToTableOrView) + { + if (entityNotMappedToTableOrView.GetDeclaredNavigations().Any(x => x.ForeignKey.IsOwnership && x.TargetEntityType.IsMappedToJson())) + { + throw new InvalidOperationException( + RelationalStrings.JsonEntityWithOwnerNotMappedToTableOrView( + entityNotMappedToTableOrView.DisplayName())); + } + } + } + + private void ValidateJsonViews(IEnumerable entityTypes) + { + foreach (var jsonEntityType in entityTypes) + { + var viewName = jsonEntityType.GetViewName(); + if (viewName == null) + { + continue; + } + + var ownership = jsonEntityType.FindOwnership()!; + var ownerViewName = ownership.PrincipalEntityType.GetViewName(); + if (viewName != ownerViewName) + { + throw new InvalidOperationException( + RelationalStrings.JsonEntityMappedToDifferentViewThanOwner( + jsonEntityType.DisplayName(), viewName, ownership.PrincipalEntityType.DisplayName(), ownerViewName)); + } + } + } + + /// + /// Validates the root entity mapped to a JSON column. + /// + /// The store object. + /// The entity type to validate. + protected virtual void ValidateJsonEntityRoot( + in StoreObjectIdentifier storeObject, + IEntityType rootType) + { + var mappingStrategy = rootType.GetMappingStrategy(); + if (mappingStrategy != null && mappingStrategy != RelationalAnnotationNames.TphMappingStrategy) + { + // issue #28443 + throw new InvalidOperationException( + RelationalStrings.JsonEntityWithNonTphInheritanceOnOwner( + rootType.DisplayName(), RelationalAnnotationNames.TphMappingStrategy)); + } + } + + /// + /// Validates navigations of the entity mapped to a JSON column. + /// + /// The store object. + /// The entity type to validate. + protected virtual void ValidateJsonEntityNavigations( + in StoreObjectIdentifier storeObject, + IEntityType jsonEntityType) + { + var ownership = jsonEntityType.FindOwnership()!; + + if (ownership.PrincipalEntityType.IsOwned() + && !ownership.PrincipalEntityType.IsMappedToJson()) + { + // issue #28441 + throw new InvalidOperationException( + RelationalStrings.JsonEntityOwnedByNonJsonOwnedType( + ownership.PrincipalEntityType.DisplayName(), + storeObject.DisplayName())); + } + + foreach (var navigation in jsonEntityType.GetDeclaredNavigations()) + { + if (!navigation.ForeignKey.IsOwnership) + { + throw new InvalidOperationException( + RelationalStrings.JsonEntityReferencingRegularEntity( + jsonEntityType.DisplayName())); + } + } + } + + /// + /// Validate the key of entity mapped to a JSON column. + /// + /// The store object. + /// The entity type containing the key to validate. + protected virtual void ValidateJsonEntityKey( + in StoreObjectIdentifier storeObject, + IEntityType jsonEntityType) + { + var primaryKeyProperties = jsonEntityType.FindPrimaryKey()!.Properties; + var ownership = jsonEntityType.FindOwnership()!; + + foreach (var primaryKeyProperty in primaryKeyProperties) + { + if (primaryKeyProperty.GetJsonPropertyName() != null) + { + // issue #28594 + throw new InvalidOperationException( + RelationalStrings.JsonEntityWithExplicitlyConfiguredJsonPropertyNameOnKey( + primaryKeyProperty.Name, jsonEntityType.DisplayName())); + } + } + + if (!ownership.IsUnique) + { + // for collection entities, make sure that ordinal key is not explicitly defined + var ordinalKeyProperty = primaryKeyProperties.Last(); + if (!ordinalKeyProperty.IsOrdinalKeyProperty()) + { + // issue #28594 + throw new InvalidOperationException( + RelationalStrings.JsonEntityWithExplicitlyConfiguredOrdinalKey( + jsonEntityType.DisplayName())); + } + } + + var ownerEntityTypeKeyPropertiesCount = ownership.PrincipalEntityType.FindPrimaryKey()!.Properties.Count; + var expectedKeyCount = ownership.IsUnique + ? ownerEntityTypeKeyPropertiesCount + : ownerEntityTypeKeyPropertiesCount + 1; + + if (primaryKeyProperties.Count != expectedKeyCount) + { + // issue #28594 + throw new InvalidOperationException( + RelationalStrings.JsonEntityWithIncorrectNumberOfKeyProperties( + jsonEntityType.DisplayName(), expectedKeyCount, primaryKeyProperties.Count)); + } + } + + /// + /// Validate the properties of entity mapped to a JSON column. + /// + /// The store object. + /// The entity type containing the properties to validate. + public virtual void ValidateJsonEntityProperties( + in StoreObjectIdentifier storeObject, + IEntityType jsonEntityType) + { + var jsonPropertyNames = new List(); + foreach (var property in jsonEntityType.GetDeclaredProperties().Where(p => !string.IsNullOrEmpty(p.GetJsonPropertyName()))) + { + if (property.TryGetDefaultValue(out var _)) + { + throw new InvalidOperationException( + RelationalStrings.JsonEntityWithDefaultValueSetOnItsProperty( + jsonEntityType.DisplayName(), property.Name)); + } + + var jsonPropertyName = property.GetJsonPropertyName()!; + if (!jsonPropertyNames.Contains(jsonPropertyName)) + { + jsonPropertyNames.Add(jsonPropertyName); + } + else + { + throw new InvalidOperationException( + RelationalStrings.JsonEntityWithMultiplePropertiesMappedToSameJsonProperty( + jsonEntityType.DisplayName(), jsonPropertyName)); + } + } + + foreach (var navigation in jsonEntityType.GetDeclaredNavigations()) + { + var jsonPropertyName = navigation.GetJsonPropertyName()!; + if (!jsonPropertyNames.Contains(jsonPropertyName)) + { + jsonPropertyNames.Add(jsonPropertyName); + } + else + { + throw new InvalidOperationException( + RelationalStrings.JsonEntityWithMultiplePropertiesMappedToSameJsonProperty( + jsonEntityType.DisplayName(), jsonPropertyName)); + } + } + } + /// /// Throws an with a message containing provider-specific information, when /// available, indicating possible reasons why the property cannot be mapped. diff --git a/src/EFCore.Relational/Metadata/Conventions/Infrastructure/RelationalConventionSetBuilder.cs b/src/EFCore.Relational/Metadata/Conventions/Infrastructure/RelationalConventionSetBuilder.cs index 267884d06b0..4d21828d6bf 100644 --- a/src/EFCore.Relational/Metadata/Conventions/Infrastructure/RelationalConventionSetBuilder.cs +++ b/src/EFCore.Relational/Metadata/Conventions/Infrastructure/RelationalConventionSetBuilder.cs @@ -60,9 +60,14 @@ public override ConventionSet CreateConventionSet() var relationalColumnAttributeConvention = new RelationalColumnAttributeConvention(Dependencies, RelationalDependencies); var relationalCommentAttributeConvention = new RelationalColumnCommentAttributeConvention(Dependencies, RelationalDependencies); + var relationalPropertyJsonPropertyNameAttributeConvention = new RelationalPropertyJsonPropertyNameAttributeConvention(Dependencies, RelationalDependencies); conventionSet.PropertyAddedConventions.Add(relationalColumnAttributeConvention); conventionSet.PropertyAddedConventions.Add(relationalCommentAttributeConvention); + conventionSet.PropertyAddedConventions.Add(relationalPropertyJsonPropertyNameAttributeConvention); + + var relationalNavigationJsonPropertyNameAttributeConvention = new RelationalNavigationJsonPropertyNameAttributeConvention(Dependencies, RelationalDependencies); + conventionSet.NavigationAddedConventions.Add(relationalNavigationJsonPropertyNameAttributeConvention); var tableNameFromDbSetConvention = new TableNameFromDbSetConvention(Dependencies, RelationalDependencies); var entitySplittingConvention = new EntitySplittingConvention(Dependencies, RelationalDependencies); @@ -86,6 +91,9 @@ public override ConventionSet CreateConventionSet() conventionSet.EntityTypeAnnotationChangedConventions.Add(tableNameFromDbSetConvention); + var mapToJsonConvention = new RelationalMapToJsonConvention(Dependencies, RelationalDependencies); + conventionSet.EntityTypeAnnotationChangedConventions.Add(mapToJsonConvention); + ReplaceConvention(conventionSet.ForeignKeyPropertiesChangedConventions, valueGenerationConvention); ReplaceConvention(conventionSet.ForeignKeyOwnershipChangedConventions, valueGenerationConvention); @@ -128,6 +136,8 @@ public override ConventionSet CreateConventionSet() (QueryFilterRewritingConvention)new RelationalQueryFilterRewritingConvention( Dependencies, RelationalDependencies)); + conventionSet.ModelFinalizingConventions.Add(mapToJsonConvention); + ReplaceConvention( conventionSet.ModelFinalizedConventions, (RuntimeModelConvention)new RelationalRuntimeModelConvention(Dependencies, RelationalDependencies)); diff --git a/src/EFCore.Relational/Metadata/Conventions/RelationalMapToJsonConvention.cs b/src/EFCore.Relational/Metadata/Conventions/RelationalMapToJsonConvention.cs new file mode 100644 index 00000000000..a464847e592 --- /dev/null +++ b/src/EFCore.Relational/Metadata/Conventions/RelationalMapToJsonConvention.cs @@ -0,0 +1,80 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; + +namespace Microsoft.EntityFrameworkCore.Metadata.Conventions; + +/// +/// A convention that configures default settings for an entity mapped to a JSON column. +/// +/// +/// See Model building conventions for more information and examples. +/// +public class RelationalMapToJsonConvention : IEntityTypeAnnotationChangedConvention, IModelFinalizingConvention +{ + /// + /// Creates a new instance of . + /// + /// Parameter object containing dependencies for this convention. + /// Parameter object containing relational dependencies for this convention. + public RelationalMapToJsonConvention( + ProviderConventionSetBuilderDependencies dependencies, + RelationalConventionSetBuilderDependencies relationalDependencies) + { + Dependencies = dependencies; + RelationalDependencies = relationalDependencies; + } + + /// + /// Dependencies for this service. + /// + protected virtual ProviderConventionSetBuilderDependencies Dependencies { get; } + + /// + /// Relational provider-specific dependencies for this service. + /// + protected virtual RelationalConventionSetBuilderDependencies RelationalDependencies { get; } + + /// + public virtual void ProcessEntityTypeAnnotationChanged( + IConventionEntityTypeBuilder entityTypeBuilder, + string name, + IConventionAnnotation? annotation, + IConventionAnnotation? oldAnnotation, + IConventionContext context) + { + if (name != RelationalAnnotationNames.JsonColumnName) + { + return; + } + + var jsonColumnName = annotation?.Value as string; + if (!string.IsNullOrEmpty(jsonColumnName)) + { + var jsonColumnTypeMapping = ((IRelationalTypeMappingSource)Dependencies.TypeMappingSource).FindMapping( + typeof(JsonElement))!; + + entityTypeBuilder.Metadata.SetJsonColumnTypeMapping(jsonColumnTypeMapping); + } + else + { + entityTypeBuilder.Metadata.SetJsonColumnTypeMapping(null); + } + } + + /// + public virtual void ProcessModelFinalizing( + IConventionModelBuilder modelBuilder, + IConventionContext context) + { + foreach (var jsonEntityType in modelBuilder.Metadata.GetEntityTypes().Where(e => e.IsMappedToJson())) + { + foreach (var enumProperty in jsonEntityType.GetDeclaredProperties().Where(p => p.ClrType.IsEnum)) + { + // by default store enums as strings - values should be human-readable + enumProperty.Builder.HasConversion(typeof(string)); + } + } + } +} diff --git a/src/EFCore.Relational/Metadata/Conventions/RelationalNavigationJsonPropertyNameAttributeConvention.cs b/src/EFCore.Relational/Metadata/Conventions/RelationalNavigationJsonPropertyNameAttributeConvention.cs new file mode 100644 index 00000000000..6ed6da36af3 --- /dev/null +++ b/src/EFCore.Relational/Metadata/Conventions/RelationalNavigationJsonPropertyNameAttributeConvention.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. + +using System.Text.Json.Serialization; + +namespace Microsoft.EntityFrameworkCore.Metadata.Conventions; + +/// +/// A convention that configures a JSON element name for the navigation property mapped to json +/// based on the attribute. +/// +/// +/// See Model building conventions for more information and examples. +/// +public class RelationalNavigationJsonPropertyNameAttributeConvention : NavigationAttributeConventionBase +{ + /// + /// Creates a new instance of . + /// + /// Parameter object containing dependencies for this convention. + /// Parameter object containing relational dependencies for this convention. + public RelationalNavigationJsonPropertyNameAttributeConvention( + ProviderConventionSetBuilderDependencies dependencies, + RelationalConventionSetBuilderDependencies relationalDependencies) + : base(dependencies) + { + RelationalDependencies = relationalDependencies; + } + + /// + /// Relational provider-specific dependencies for this service. + /// + protected virtual RelationalConventionSetBuilderDependencies RelationalDependencies { get; } + + /// + public override void ProcessNavigationAdded( + IConventionNavigationBuilder navigationBuilder, + JsonPropertyNameAttribute attribute, + IConventionContext context) + { + if (!string.IsNullOrWhiteSpace(attribute.Name)) + { + navigationBuilder.HasJsonPropertyName(attribute.Name, fromDataAnnotation: true); + } + } +} diff --git a/src/EFCore.Relational/Metadata/Conventions/RelationalPropertyJsonPropertyNameAttributeConvention.cs b/src/EFCore.Relational/Metadata/Conventions/RelationalPropertyJsonPropertyNameAttributeConvention.cs new file mode 100644 index 00000000000..33f2b1ab240 --- /dev/null +++ b/src/EFCore.Relational/Metadata/Conventions/RelationalPropertyJsonPropertyNameAttributeConvention.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. + +using System.Text.Json.Serialization; + +namespace Microsoft.EntityFrameworkCore.Metadata.Conventions; + +/// +/// A convention that configures JSON property name based on the applied . +/// +/// +/// See Model building conventions for more information and examples. +/// +public class RelationalPropertyJsonPropertyNameAttributeConvention : PropertyAttributeConventionBase +{ + /// + /// Creates a new instance of . + /// + /// Parameter object containing dependencies for this convention. + /// Parameter object containing relational dependencies for this convention. + public RelationalPropertyJsonPropertyNameAttributeConvention( + ProviderConventionSetBuilderDependencies dependencies, + RelationalConventionSetBuilderDependencies relationalDependencies) + : base(dependencies) + { + RelationalDependencies = relationalDependencies; + } + + /// + /// Relational provider-specific dependencies for this service. + /// + protected virtual RelationalConventionSetBuilderDependencies RelationalDependencies { get; } + + /// + protected override void ProcessPropertyAdded( + IConventionPropertyBuilder propertyBuilder, + JsonPropertyNameAttribute attribute, + MemberInfo clrMember, + IConventionContext context) + { + if (!string.IsNullOrWhiteSpace(attribute.Name)) + { + propertyBuilder.HasJsonPropertyName(attribute.Name, fromDataAnnotation: true); + } + } +} diff --git a/src/EFCore.Relational/Metadata/Conventions/RelationalValueGenerationConvention.cs b/src/EFCore.Relational/Metadata/Conventions/RelationalValueGenerationConvention.cs index 214367c5dc5..934e7be2c7e 100644 --- a/src/EFCore.Relational/Metadata/Conventions/RelationalValueGenerationConvention.cs +++ b/src/EFCore.Relational/Metadata/Conventions/RelationalValueGenerationConvention.cs @@ -1,6 +1,8 @@ // 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.Metadata.Internal; + namespace Microsoft.EntityFrameworkCore.Metadata.Conventions; /// @@ -181,9 +183,11 @@ private void ProcessTableChanged( ? null : table.Name != null ? GetValueGenerated(property, table) - : property.GetMappedStoreObjects(StoreObjectType.InsertStoredProcedure).Any() - ? GetValueGenerated((IReadOnlyProperty)property) - : null; + : property.DeclaringEntityType.IsMappedToJson() && !property.DeclaringEntityType.FindOwnership()!.IsUnique && property.IsOrdinalKeyProperty() + ? ValueGenerated.OnAdd + : property.GetMappedStoreObjects(StoreObjectType.InsertStoredProcedure).Any() + ? GetValueGenerated((IReadOnlyProperty)property) + : null; } /// diff --git a/src/EFCore.Relational/Metadata/Internal/JsonColumn.cs b/src/EFCore.Relational/Metadata/Internal/JsonColumn.cs new file mode 100644 index 00000000000..8b041658f53 --- /dev/null +++ b/src/EFCore.Relational/Metadata/Internal/JsonColumn.cs @@ -0,0 +1,162 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.EntityFrameworkCore.Metadata.Internal; + +/// +/// 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 class JsonColumn : Column, IColumn +{ + private readonly ValueComparer _providerValueComparer; + + /// + /// 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 JsonColumn(string name, string type, Table table, ValueComparer provierValueComparer) + : base(name, type, table) + { + _providerValueComparer = provierValueComparer; + } + + /// + /// 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. + /// + int? IColumn.MaxLength + => null; + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + int? IColumn.Precision + => null; + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + int? IColumn.Scale + => null; + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + bool? IColumn.IsUnicode + => null; + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + bool? IColumn.IsFixedLength + => null; + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + bool IColumn.IsRowVersion + => false; + + /// + /// 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. + /// + int? IColumn.Order + => null; + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + object? IColumn.DefaultValue + => null; + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + string? IColumn.DefaultValueSql + => null; + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + string? IColumn.ComputedColumnSql + => null; + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + bool? IColumn.IsStored + => null; + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + string? IColumn.Comment + => null; + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + string? IColumn.Collation + => null; + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + ValueComparer IColumn.ProviderValueComparer + => _providerValueComparer; + + /// + /// 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. + /// + IColumnMapping? IColumn.FindColumnMapping(IReadOnlyEntityType entityType) + => null; +} diff --git a/src/EFCore.Relational/Metadata/Internal/JsonViewColumn.cs b/src/EFCore.Relational/Metadata/Internal/JsonViewColumn.cs new file mode 100644 index 00000000000..c6e3632964a --- /dev/null +++ b/src/EFCore.Relational/Metadata/Internal/JsonViewColumn.cs @@ -0,0 +1,24 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.EntityFrameworkCore.Metadata.Internal; + +/// +/// 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 class JsonViewColumn : ViewColumn, IViewColumn +{ + /// + /// 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 JsonViewColumn(string name, string type, View view) + : base(name, type, view) + { + } +} diff --git a/src/EFCore.Relational/Metadata/Internal/RelationalEntityTypeExtensions.cs b/src/EFCore.Relational/Metadata/Internal/RelationalEntityTypeExtensions.cs index 437797dcbde..c25d0e0c059 100644 --- a/src/EFCore.Relational/Metadata/Internal/RelationalEntityTypeExtensions.cs +++ b/src/EFCore.Relational/Metadata/Internal/RelationalEntityTypeExtensions.cs @@ -29,6 +29,11 @@ public static IEnumerable FindDeclaredReferencingRowInternalForeign this IEntityType entityType, StoreObjectIdentifier storeObject) { + if (entityType.IsMappedToJson()) + { + yield break; + } + foreach (var foreignKey in entityType.GetDeclaredReferencingForeignKeys()) { var dependentPrimaryKey = foreignKey.DeclaringEntityType.FindPrimaryKey(); diff --git a/src/EFCore.Relational/Metadata/Internal/RelationalForeignKeyExtensions.cs b/src/EFCore.Relational/Metadata/Internal/RelationalForeignKeyExtensions.cs index 74fd9ee34a9..ba53edc7c77 100644 --- a/src/EFCore.Relational/Metadata/Internal/RelationalForeignKeyExtensions.cs +++ b/src/EFCore.Relational/Metadata/Internal/RelationalForeignKeyExtensions.cs @@ -202,7 +202,8 @@ is not { } principalColumns IDiagnosticsLogger? logger) { if (storeObject.StoreObjectType != StoreObjectType.Table - || principalStoreObject.StoreObjectType != StoreObjectType.Table) + || principalStoreObject.StoreObjectType != StoreObjectType.Table + || foreignKey.DeclaringEntityType.IsMappedToJson()) { return null; } diff --git a/src/EFCore.Relational/Metadata/Internal/RelationalKeyExtensions.cs b/src/EFCore.Relational/Metadata/Internal/RelationalKeyExtensions.cs index f437487aaa7..f963eaa1960 100644 --- a/src/EFCore.Relational/Metadata/Internal/RelationalKeyExtensions.cs +++ b/src/EFCore.Relational/Metadata/Internal/RelationalKeyExtensions.cs @@ -118,7 +118,8 @@ public static bool AreCompatible( in StoreObjectIdentifier storeObject, IDiagnosticsLogger? logger) { - if (storeObject.StoreObjectType != StoreObjectType.Table) + if (storeObject.StoreObjectType != StoreObjectType.Table + || key.DeclaringEntityType.IsMappedToJson()) { return null; } diff --git a/src/EFCore.Relational/Metadata/Internal/RelationalModel.cs b/src/EFCore.Relational/Metadata/Internal/RelationalModel.cs index 2b25ebee5e0..b8aea580f44 100644 --- a/src/EFCore.Relational/Metadata/Internal/RelationalModel.cs +++ b/src/EFCore.Relational/Metadata/Internal/RelationalModel.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Text.Json; + namespace Microsoft.EntityFrameworkCore.Metadata.Internal; /// @@ -136,7 +138,7 @@ public static IModel Add( RelationalAnnotationNames.RelationalModel, Create( model, - relationalAnnotationProvider, + relationalAnnotationProvider, relationalTypeMappingSource, designTime)); return model; @@ -160,9 +162,9 @@ public static IRelationalModel Create( { AddDefaultMappings(databaseModel, entityType); - AddTables(databaseModel, entityType); + AddTables(databaseModel, entityType, relationalTypeMappingSource); - AddViews(databaseModel, entityType); + AddViews(databaseModel, entityType, relationalTypeMappingSource); AddSqlQueries(databaseModel, entityType); @@ -366,7 +368,10 @@ private static void AddDefaultMappings(RelationalModel databaseModel, IEntityTyp tableMappings.Reverse(); } - private static void AddTables(RelationalModel databaseModel, IEntityType entityType) + private static void AddTables( + RelationalModel databaseModel, + IEntityType entityType, + IRelationalTypeMappingSource relationalTypeMappingSource) { if (entityType.GetTableName() == null) { @@ -400,6 +405,7 @@ private static void AddTables(RelationalModel databaseModel, IEntityType entityT foreach (var fragment in mappedType.GetMappingFragments(StoreObjectType.Table)) { CreateTableMapping( + relationalTypeMappingSource, entityType, mappedType, fragment.StoreObject, @@ -410,6 +416,7 @@ private static void AddTables(RelationalModel databaseModel, IEntityType entityT } CreateTableMapping( + relationalTypeMappingSource, entityType, mappedType, StoreObjectIdentifier.Table(mappedTableName, mappedSchema), @@ -430,6 +437,7 @@ private static void AddTables(RelationalModel databaseModel, IEntityType entityT } private static void CreateTableMapping( + IRelationalTypeMappingSource relationalTypeMappingSource, IEntityType entityType, IEntityType mappedType, StoreObjectIdentifier mappedTable, @@ -449,40 +457,64 @@ private static void CreateTableMapping( IsSplitEntityTypePrincipal = isSplitEntityTypePrincipal }; - foreach (var property in mappedType.GetProperties()) + var jsonColumnName = mappedType.GetJsonColumnName(); + if (!string.IsNullOrEmpty(jsonColumnName)) { - var columnName = property.GetColumnName(mappedTable); - if (columnName == null) + var ownership = mappedType.GetForeignKeys().Single(fk => fk.IsOwnership); + if (!ownership.PrincipalEntityType.IsMappedToJson()) { - continue; - } + Debug.Assert(table.FindColumn(jsonColumnName) == null); - var column = (Column?)table.FindColumn(columnName); - if (column == null) - { - column = new(columnName, property.GetColumnType(mappedTable), table) + var jsonColumnTypeMapping = relationalTypeMappingSource.FindMapping(typeof(JsonElement))!; + var jsonColumn = new JsonColumn(jsonColumnName, jsonColumnTypeMapping.StoreType, table, jsonColumnTypeMapping.ProviderValueComparer); + table.Columns.Add(jsonColumnName, jsonColumn); + jsonColumn.IsNullable = !ownership.IsRequired || !ownership.IsUnique; + + if (ownership.PrincipalEntityType.BaseType != null) { - IsNullable = property.IsColumnNullable(mappedTable) - }; - table.Columns.Add(columnName, column); + // if navigation is defined on a derived type, the column must be made nullable + jsonColumn.IsNullable = true; + } } - else if (!property.IsColumnNullable(mappedTable)) + } + else + { + foreach (var property in mappedType.GetProperties()) { - column.IsNullable = false; - } + var columnName = property.GetColumnName(mappedTable); + if (columnName == null) + { + continue; + } - var columnMapping = new ColumnMapping(property, column, tableMapping); - tableMapping.AddColumnMapping(columnMapping); - column.AddPropertyMapping(columnMapping); + var column = (Column?)table.FindColumn(columnName); + if (column == null) + { + column = new(columnName, property.GetColumnType(mappedTable), table) + { + IsNullable = property.IsColumnNullable(mappedTable) + }; - if (property.FindRuntimeAnnotationValue(RelationalAnnotationNames.TableColumnMappings) - is not SortedSet columnMappings) - { - columnMappings = new SortedSet(ColumnMappingBaseComparer.Instance); - property.AddRuntimeAnnotation(RelationalAnnotationNames.TableColumnMappings, columnMappings); - } + table.Columns.Add(columnName, column); + } + else if (!property.IsColumnNullable(mappedTable)) + { + column.IsNullable = false; + } - columnMappings.Add(columnMapping); + var columnMapping = new ColumnMapping(property, column, tableMapping); + tableMapping.AddColumnMapping(columnMapping); + column.AddPropertyMapping(columnMapping); + + if (property.FindRuntimeAnnotationValue(RelationalAnnotationNames.TableColumnMappings) + is not SortedSet columnMappings) + { + columnMappings = new SortedSet(ColumnMappingBaseComparer.Instance); + property.AddRuntimeAnnotation(RelationalAnnotationNames.TableColumnMappings, columnMappings); + } + + columnMappings.Add(columnMapping); + } } if (((ITableMappingBase)tableMapping).ColumnMappings.Any() @@ -493,7 +525,10 @@ private static void CreateTableMapping( } } - private static void AddViews(RelationalModel databaseModel, IEntityType entityType) + private static void AddViews( + RelationalModel databaseModel, + IEntityType entityType, + IRelationalTypeMappingSource relationalTypeMappingSource) { if (entityType.GetViewName() == null) { @@ -527,6 +562,7 @@ private static void AddViews(RelationalModel databaseModel, IEntityType entityTy foreach (var fragment in mappedType.GetMappingFragments(StoreObjectType.View)) { CreateViewMapping( + relationalTypeMappingSource, entityType, mappedType, fragment.StoreObject, @@ -537,6 +573,7 @@ private static void AddViews(RelationalModel databaseModel, IEntityType entityTy } CreateViewMapping( + relationalTypeMappingSource, entityType, mappedType, StoreObjectIdentifier.View(mappedViewName, mappedSchema), @@ -557,6 +594,7 @@ private static void AddViews(RelationalModel databaseModel, IEntityType entityTy } private static void CreateViewMapping( + IRelationalTypeMappingSource relationalTypeMappingSource, IEntityType entityType, IEntityType mappedType, StoreObjectIdentifier mappedView, @@ -575,40 +613,64 @@ private static void CreateViewMapping( { IsSplitEntityTypePrincipal = isSplitEntityTypePrincipal }; - foreach (var property in mappedType.GetProperties()) + + var jsonColumnName = mappedType.GetJsonColumnName(); + if (!string.IsNullOrEmpty(jsonColumnName)) { - var columnName = property.GetColumnName(mappedView); - if (columnName == null) + var ownership = mappedType.GetForeignKeys().Single(fk => fk.IsOwnership); + if (!ownership.PrincipalEntityType.IsMappedToJson()) { - continue; - } + Debug.Assert(view.FindColumn(jsonColumnName) == null); - var column = (ViewColumn?)view.FindColumn(columnName); - if (column == null) - { - column = new ViewColumn(columnName, property.GetColumnType(mappedView), view) + var jsonColumnTypeMapping = relationalTypeMappingSource.FindMapping(typeof(JsonElement))!; + var jsonColumn = new JsonViewColumn(jsonColumnName, jsonColumnTypeMapping.StoreType, view); + view.Columns.Add(jsonColumnName, jsonColumn); + jsonColumn.IsNullable = !ownership.IsRequired || !ownership.IsUnique; + + if (ownership.PrincipalEntityType.BaseType != null) { - IsNullable = property.IsColumnNullable(mappedView) - }; - view.Columns.Add(columnName, column); + // if navigation is defined on a derived type, the column must be made nullable + jsonColumn.IsNullable = true; + } } - else if (!property.IsColumnNullable(mappedView)) + } + else + { + foreach (var property in mappedType.GetProperties()) { - column.IsNullable = false; - } + var columnName = property.GetColumnName(mappedView); + if (columnName == null) + { + continue; + } - var columnMapping = new ViewColumnMapping(property, column, viewMapping); - viewMapping.AddColumnMapping(columnMapping); - column.AddPropertyMapping(columnMapping); + var column = (ViewColumn?)view.FindColumn(columnName); + if (column == null) + { + column = new ViewColumn(columnName, property.GetColumnType(mappedView), view) + { + IsNullable = property.IsColumnNullable(mappedView) + }; + view.Columns.Add(columnName, column); + } + else if (!property.IsColumnNullable(mappedView)) + { + column.IsNullable = false; + } - if (property.FindRuntimeAnnotationValue(RelationalAnnotationNames.ViewColumnMappings) - is not SortedSet columnMappings) - { - columnMappings = new SortedSet(ColumnMappingBaseComparer.Instance); - property.AddRuntimeAnnotation(RelationalAnnotationNames.ViewColumnMappings, columnMappings); - } + var columnMapping = new ViewColumnMapping(property, column, viewMapping); + viewMapping.AddColumnMapping(columnMapping); + column.AddPropertyMapping(columnMapping); - columnMappings.Add(columnMapping); + if (property.FindRuntimeAnnotationValue(RelationalAnnotationNames.ViewColumnMappings) + is not SortedSet columnMappings) + { + columnMappings = new SortedSet(ColumnMappingBaseComparer.Instance); + property.AddRuntimeAnnotation(RelationalAnnotationNames.ViewColumnMappings, columnMappings); + } + + columnMappings.Add(columnMapping); + } } if (((ITableMappingBase)viewMapping).ColumnMappings.Any() @@ -1470,9 +1532,15 @@ private static void PopulateRowInternalForeignKeys(TableBase tab } SortedSet? rowInternalForeignKeys = null; - foreach (var foreignKey in entityType.FindForeignKeys(primaryKey.Properties)) + + var foreignKeys = entityType.IsMappedToJson() + ? new[] { entityType.FindOwnership()! } + : entityType.FindForeignKeys(primaryKey.Properties); + + foreach (var foreignKey in foreignKeys) { - if (foreignKey.IsUnique + // for JSON mapped entities we can have row internal FKs for collection navigations + if ((foreignKey.IsUnique || entityType.IsMappedToJson()) && foreignKey.PrincipalKey.IsPrimaryKey() && !foreignKey.DeclaringEntityType.IsAssignableFrom(foreignKey.PrincipalEntityType) && !foreignKey.PrincipalEntityType.IsAssignableFrom(foreignKey.DeclaringEntityType) diff --git a/src/EFCore.Relational/Metadata/Internal/StoredProcedureParameter.cs b/src/EFCore.Relational/Metadata/Internal/StoredProcedureParameter.cs index 9980b1681d7..6153e0ee5a8 100644 --- a/src/EFCore.Relational/Metadata/Internal/StoredProcedureParameter.cs +++ b/src/EFCore.Relational/Metadata/Internal/StoredProcedureParameter.cs @@ -138,7 +138,7 @@ public virtual string Name get => _name ?? (ForRowsAffected ? "RowsAffected" : GetProperty().GetDefaultColumnName( - ((IReadOnlyStoredProcedure)StoredProcedure).GetStoreIdentifier()!.Value)); + ((IReadOnlyStoredProcedure)StoredProcedure).GetStoreIdentifier()!.Value)!); set => SetName(value, ConfigurationSource.Explicit); } diff --git a/src/EFCore.Relational/Metadata/RelationalAnnotationNames.cs b/src/EFCore.Relational/Metadata/RelationalAnnotationNames.cs index 09b3a35b107..3e9f70f306b 100644 --- a/src/EFCore.Relational/Metadata/RelationalAnnotationNames.cs +++ b/src/EFCore.Relational/Metadata/RelationalAnnotationNames.cs @@ -318,4 +318,19 @@ public static class RelationalAnnotationNames /// The name for the reader field value getter delegate annotation. /// public const string FieldValueGetter = Prefix + "FieldValueGetter"; + + /// + /// The name for the annotation specifying JSON column name to which the object is mapped. + /// + public const string JsonColumnName = Prefix + "JsonColumnName"; + + /// + /// The name for the annotation specifying JSON column type mapping. + /// + public const string JsonColumnTypeMapping = Prefix + "JsonColumnTypeMapping"; + + /// + /// The JSON property name for the element that the property/navigation maps to. + /// + public const string JsonPropertyName = Prefix + "JsonPropertyName"; } diff --git a/src/EFCore.Relational/Migrations/Internal/MigrationsModelDiffer.cs b/src/EFCore.Relational/Migrations/Internal/MigrationsModelDiffer.cs index bf74375f8a2..63196d3f3db 100644 --- a/src/EFCore.Relational/Migrations/Internal/MigrationsModelDiffer.cs +++ b/src/EFCore.Relational/Migrations/Internal/MigrationsModelDiffer.cs @@ -640,6 +640,7 @@ protected virtual IEnumerable Add( createTableOperation.Columns.AddRange( GetSortedColumns(target).SelectMany(p => Add(p, diffContext, inline: true)).Cast()); + var primaryKey = target.PrimaryKey; if (primaryKey != null) { @@ -688,7 +689,7 @@ protected virtual IEnumerable Remove( private static IEnumerable GetSortedColumns(ITable table) { - var columns = table.Columns.ToHashSet(); + var columns = table.Columns.Where(x => x is not JsonColumn).ToHashSet(); var sortedColumns = new List(columns.Count); foreach (var property in GetSortedProperties(GetMainType(table).GetRootType(), table)) { @@ -701,9 +702,14 @@ private static IEnumerable GetSortedColumns(ITable table) Check.DebugAssert(columns.Count == 0, "columns is not empty"); + // issue #28539 + // ideally we should inject JSON column in the place corresponding to the navigation that maps to it in the clr type + var jsonColumns = table.Columns.Where(x => x is JsonColumn).OrderBy(x => x.Name); + return sortedColumns.Where(c => c.Order.HasValue).OrderBy(c => c.Order) .Concat(sortedColumns.Where(c => !c.Order.HasValue)) - .Concat(columns); + .Concat(columns) + .Concat(jsonColumns); } private static IEnumerable GetSortedProperties(IEntityType entityType, ITable table) @@ -770,6 +776,12 @@ private static IEnumerable GetSortedProperties(IEntityType entityType { foreach (var linkingForeignKey in table.GetReferencingRowInternalForeignKeys(entityType)) { + // skip JSON entities, their properties are not mapped to anything + if (linkingForeignKey.DeclaringEntityType.IsMappedToJson()) + { + continue; + } + var linkingNavigationProperty = linkingForeignKey.PrincipalToDependent?.PropertyInfo; var properties = GetSortedProperties(linkingForeignKey.DeclaringEntityType, table).ToList(); if (linkingNavigationProperty == null) @@ -1011,25 +1023,17 @@ protected virtual IEnumerable Diff( IsDestructiveChange = isDestructiveChange }; - var sourceTypeMapping = source.StoreTypeMapping; - var targetTypeMapping = target.StoreTypeMapping; - - Initialize( - alterColumnOperation, target, targetTypeMapping, - target.IsNullable, targetMigrationsAnnotations, inline: !source.IsNullable); - - Initialize( - alterColumnOperation.OldColumn, source, sourceTypeMapping, - source.IsNullable, sourceMigrationsAnnotations, inline: true); + InitializeColumnHelper(alterColumnOperation, target, inline: !source.IsNullable); + InitializeColumnHelper(alterColumnOperation.OldColumn, source, inline: true); if (source.Order != target.Order) { - if (source.Order.HasValue) + if (source is not JsonColumn && source.Order.HasValue) { alterColumnOperation.OldColumn.AddAnnotation(RelationalAnnotationNames.ColumnOrder, source.Order.Value); } - if (target.Order.HasValue) + if (target is not JsonColumn && target.Order.HasValue) { alterColumnOperation.AddAnnotation(RelationalAnnotationNames.ColumnOrder, target.Order.Value); } @@ -1039,6 +1043,22 @@ protected virtual IEnumerable Diff( } } + private void InitializeColumnHelper(ColumnOperation columnOperation, IColumn column, bool inline) + { + if (column is JsonColumn jsonColumn) + { + InitializeJsonColumn(columnOperation, jsonColumn, jsonColumn.IsNullable, column.GetAnnotations(), inline); + } + else + { + var columnTypeMapping = column.StoreTypeMapping; + + Initialize( + columnOperation, column, columnTypeMapping, column.IsNullable, + column.GetAnnotations(), inline); + } + } + /// /// 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 @@ -1059,15 +1079,13 @@ protected virtual IEnumerable Add( Name = target.Name }; - var targetTypeMapping = target.StoreTypeMapping; - - Initialize( - operation, target, targetTypeMapping, target.IsNullable, - target.GetAnnotations(), inline); - - if (!inline && target.Order.HasValue) + InitializeColumnHelper(operation, target, inline); + if (target is not JsonColumn) { - operation.AddAnnotation(RelationalAnnotationNames.ColumnOrder, target.Order.Value); + if (!inline && target.Order.HasValue) + { + operation.AddAnnotation(RelationalAnnotationNames.ColumnOrder, target.Order.Value); + } } yield return operation; @@ -1159,6 +1177,26 @@ private void Initialize( columnOperation.AddAnnotations(migrationsAnnotations); } + private void InitializeJsonColumn( + ColumnOperation columnOperation, + JsonColumn jsonColumn, + bool isNullable, + IEnumerable migrationsAnnotations, + bool inline = false) + { + columnOperation.ColumnType = jsonColumn.StoreType; + columnOperation.IsNullable = isNullable; + + // TODO: flow this from type mapping + // issue #28596 + columnOperation.ClrType = typeof(string); + columnOperation.DefaultValue = inline || isNullable + ? null + : GetDefaultValue(columnOperation.ClrType); + + columnOperation.AddAnnotations(migrationsAnnotations); + } + #endregion #region IKey @@ -2351,7 +2389,6 @@ protected virtual IEnumerable DiffCollection( protected virtual bool HasDifferences(IEnumerable source, IEnumerable target) { var unmatched = new List(target); - foreach (var annotation in source) { var index = unmatched.FindIndex( diff --git a/src/EFCore.Relational/Properties/RelationalStrings.Designer.cs b/src/EFCore.Relational/Properties/RelationalStrings.Designer.cs index bb16f9eeaf6..e867d501fc3 100644 --- a/src/EFCore.Relational/Properties/RelationalStrings.Designer.cs +++ b/src/EFCore.Relational/Properties/RelationalStrings.Designer.cs @@ -1787,6 +1787,112 @@ public static string ViewOverrideMismatch(object? propertySpecification, object? public static string VisitChildrenMustBeOverridden => GetString("VisitChildrenMustBeOverridden"); + /// + /// Owned entity type '{nonJsonType}' is mapped to table '{table}' and contains JSON columns. This is currently not supported. All owned types containing a JSON column must be mapped to a JSON column themselves. + /// + public static string JsonEntityOwnedByNonJsonOwnedType(object? nonJsonType, object? table) + => string.Format( + GetString("JsonEntityOwnedByNonJsonOwnedType", nameof(nonJsonType), nameof(table)), + nonJsonType, table); + + /// + /// Table splitting is not supported for entities containing entities mapped to JSON. + /// + public static string JsonEntityWithTableSplittingIsNotSupported + => GetString("JsonEntityWithTableSplittingIsNotSupported"); + + /// + /// Multiple owned root entities are mapped to the same JSON column '{column}' in table '{table}'. Each owned root entity must map to a different column. + /// + public static string JsonEntityMultipleRootsMappedToTheSameJsonColumn(object? column, object? table) + => string.Format( + GetString("JsonEntityMultipleRootsMappedToTheSameJsonColumn", nameof(column), nameof(table)), + column, table); + + /// + /// Entity type '{entity}' references entities mapped to JSON but is not itself mapped to a table or a view.This is not supported. + /// + public static string JsonEntityWithOwnerNotMappedToTableOrView(object? entity) + => string.Format( + GetString("JsonEntityWithOwnerNotMappedToTableOrView", nameof(entity)), + entity); + + /// + /// Entity '{jsonType}' is mapped to JSON and also mapped to a view '{viewName}', however it's owner '{ownerType}' is mapped to a different view '{ownerViewName}'. Every entity mapped to JSON must also map to the same view as it's owner. + /// + public static string JsonEntityMappedToDifferentViewThanOwner(object? jsonType, object? viewName, object? ownerType, object? ownerViewName) + => string.Format( + GetString("JsonEntityMappedToDifferentViewThanOwner", nameof(jsonType), nameof(viewName), nameof(ownerType), nameof(ownerViewName)), + jsonType, viewName, ownerType, ownerViewName); + + /// + /// Entity type '{rootType}' references entities mapped to JSON. Only '{tph}' inheritance is supported for those entities. + /// + public static string JsonEntityWithNonTphInheritanceOnOwner(object? rootType, object? tph) + => string.Format( + GetString("JsonEntityWithNonTphInheritanceOnOwner", nameof(rootType), nameof(tph)), + rootType, tph); + + /// + /// Entity type '{jsonEntity}' is mapped to JSON and has navigation to a regular entity which is not the owner. + /// + public static string JsonEntityReferencingRegularEntity(object? jsonEntity) + => string.Format( + GetString("JsonEntityReferencingRegularEntity", nameof(jsonEntity)), + jsonEntity); + + /// + /// Key property '{keyProperty}' on JSON-mapped entity '{jsonEntity}' should not have JSON property name configured explicitly. + /// + public static string JsonEntityWithExplicitlyConfiguredJsonPropertyNameOnKey(object? keyProperty, object? jsonEntity) + => string.Format( + GetString("JsonEntityWithExplicitlyConfiguredJsonPropertyNameOnKey", nameof(keyProperty), nameof(jsonEntity)), + keyProperty, jsonEntity); + + /// + /// Entity type '{jsonEntity}' is part of collection mapped to JSON and has it's ordinal key defined explicitly. Only implicitly defined ordinal keys are supported. + /// + public static string JsonEntityWithExplicitlyConfiguredOrdinalKey(object? jsonEntity) + => string.Format( + GetString("JsonEntityWithExplicitlyConfiguredOrdinalKey", nameof(jsonEntity)), + jsonEntity); + + /// + /// Entity type '{jsonEntity}' has incorrect number of primary key properties. Expected number is: {expectedCount}, actual number is: {actualCount}. + /// + public static string JsonEntityWithIncorrectNumberOfKeyProperties(object? jsonEntity, object? expectedCount, object? actualCount) + => string.Format( + GetString("JsonEntityWithIncorrectNumberOfKeyProperties", nameof(jsonEntity), nameof(expectedCount), nameof(actualCount)), + jsonEntity, expectedCount, actualCount); + + /// + /// Setting default value on properties of an entity mapped to JSON is not supported. Entity: '{jsonEntity}', property: '{property}'. + /// + public static string JsonEntityWithDefaultValueSetOnItsProperty(object? jsonEntity, object? property) + => string.Format( + GetString("JsonEntityWithDefaultValueSetOnItsProperty", nameof(jsonEntity), nameof(property)), + jsonEntity, property); + + /// + /// Entity '{jsonEntity}' is mapped to JSON and it contains multiple properties or navigations which are mapped to the same JSON property '{property}'. Each property should map to a unique JSON property. + /// + public static string JsonEntityWithMultiplePropertiesMappedToSameJsonProperty(object? jsonEntity, object? property) + => string.Format( + GetString("JsonEntityWithMultiplePropertiesMappedToSameJsonProperty", nameof(jsonEntity), nameof(property)), + jsonEntity, property); + + /// + /// JSON property name should only be configured on nested owned navigations. + /// + public static string JsonPropertyNameShouldBeConfiguredOnNestedNavigation + => GetString("JsonPropertyNameShouldBeConfiguredOnNestedNavigation"); + + /// + /// This method needs to be implemented in the provider. + /// + public static string MethodNeedsToBeImplementedInTheProvider + => GetString("MethodNeedsToBeImplementedInTheProvider"); + private static string GetString(string name, params string[] formatterNames) { var value = _resourceManager.GetString(name)!; diff --git a/src/EFCore.Relational/Properties/RelationalStrings.resx b/src/EFCore.Relational/Properties/RelationalStrings.resx index a5b389dce58..9e0b5121070 100644 --- a/src/EFCore.Relational/Properties/RelationalStrings.resx +++ b/src/EFCore.Relational/Properties/RelationalStrings.resx @@ -1078,4 +1078,46 @@ 'VisitChildren' must be overridden in the class deriving from 'SqlExpression'. + + Owned entity type '{nonJsonType}' is mapped to table '{table}' and contains JSON columns. This is currently not supported. All owned types containing a JSON column must be mapped to a JSON column themselves. + + + Table splitting is not supported for entities containing entities mapped to JSON. + + + Multiple owned root entities are mapped to the same JSON column '{column}' in table '{table}'. Each owned root entity must map to a different column. + + + Entity type '{entity}' references entities mapped to JSON but is not itself mapped to a table or a view.This is not supported. + + + Entity '{jsonType}' is mapped to JSON and also mapped to a view '{viewName}', however it's owner '{ownerType}' is mapped to a different view '{ownerViewName}'. Every entity mapped to JSON must also map to the same view as it's owner. + + + Entity type '{rootType}' references entities mapped to JSON. Only '{tph}' inheritance is supported for those entities. + + + Entity type '{jsonEntity}' is mapped to JSON and has navigation to a regular entity which is not the owner. + + + Key property '{keyProperty}' on JSON-mapped entity '{jsonEntity}' should not have JSON property name configured explicitly. + + + Entity type '{jsonEntity}' is part of collection mapped to JSON and has it's ordinal key defined explicitly. Only implicitly defined ordinal keys are supported. + + + Entity type '{jsonEntity}' has incorrect number of primary key properties. Expected number is: {expectedCount}, actual number is: {actualCount}. + + + Setting default value on properties of an entity mapped to JSON is not supported. Entity: '{jsonEntity}', property: '{property}'. + + + Entity '{jsonEntity}' is mapped to JSON and it contains multiple properties or navigations which are mapped to the same JSON property '{property}'. Each property should map to a unique JSON property. + + + JSON property name should only be configured on nested owned navigations. + + + This method needs to be implemented in the provider. + \ No newline at end of file diff --git a/src/EFCore.Relational/Storage/JsonTypeMapping.cs b/src/EFCore.Relational/Storage/JsonTypeMapping.cs new file mode 100644 index 00000000000..289bd85144c --- /dev/null +++ b/src/EFCore.Relational/Storage/JsonTypeMapping.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. + +using System.Data; +using System.Text.Json; + +namespace Microsoft.EntityFrameworkCore.Storage; + +/// +/// +/// Represents the mapping between a type and a database type. +/// +/// +/// This type is typically used by database providers (and other extensions). It is generally +/// not used in application code. +/// +/// +/// +/// See Implementation of database providers and extensions +/// for more information and examples. +/// +public abstract class JsonTypeMapping : RelationalTypeMapping +{ + /// + /// Initializes a new instance of the class. + /// + /// The name of the database type. + /// The .NET type. + /// The to be used. + protected JsonTypeMapping(string storeType, Type clrType, DbType? dbType) + : base(storeType, clrType, dbType) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// Parameter object for . + protected JsonTypeMapping(RelationalTypeMappingParameters parameters) + : base(parameters) + { + } + + /// + protected override string GenerateNonNullSqlLiteral(object value) + => throw new InvalidOperationException( + RelationalStrings.MethodNeedsToBeImplementedInTheProvider); +} diff --git a/src/EFCore.Relational/Update/Internal/CommandBatchPreparer.cs b/src/EFCore.Relational/Update/Internal/CommandBatchPreparer.cs index 8d54581cfd9..458398ecf93 100644 --- a/src/EFCore.Relational/Update/Internal/CommandBatchPreparer.cs +++ b/src/EFCore.Relational/Update/Internal/CommandBatchPreparer.cs @@ -257,6 +257,11 @@ private static void AddUnchangedSharingEntries( continue; } + if (entry.EntityType.IsMappedToJson()) + { + continue; + } + entry.EntityState = EntityState.Modified; command.AddEntry(entry, sharedCommandsMap.IsMainEntry(entry)); diff --git a/src/EFCore.Relational/Update/ModificationCommand.cs b/src/EFCore.Relational/Update/ModificationCommand.cs index 5c1ca6bbb27..578f98ef130 100644 --- a/src/EFCore.Relational/Update/ModificationCommand.cs +++ b/src/EFCore.Relational/Update/ModificationCommand.cs @@ -1,6 +1,10 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Collections; +using System.Text.Json.Nodes; +using Microsoft.EntityFrameworkCore.ChangeTracking.Internal; +using Microsoft.EntityFrameworkCore.Metadata.Internal; using Microsoft.EntityFrameworkCore.Internal; namespace Microsoft.EntityFrameworkCore.Update; @@ -277,8 +281,64 @@ private List GenerateColumnModifications() } } + var processedJsonNavigations = new List(); foreach (var entry in _entries) { + if (entry.EntityType.IsMappedToJson()) + { + // for JSON entry, traverse to the entry for root JSON entity + // and build entire JSON structure based on it + // this will be the column modification command + var jsonColumnName = entry.EntityType.GetJsonColumnName()!; + var jsonColumnTypeMapping = entry.EntityType.GetJsonColumnTypeMapping()!; + + var currentEntry = entry; + var currentOwnership = currentEntry.EntityType.FindOwnership()!; + while (currentEntry.EntityType.IsMappedToJson()) + { + currentOwnership = currentEntry.EntityType.FindOwnership()!; +#pragma warning disable EF1001 // Internal EF Core API usage. + currentEntry = ((InternalEntityEntry)currentEntry).StateManager.FindPrincipal((InternalEntityEntry)currentEntry, currentOwnership)!; +#pragma warning restore EF1001 // Internal EF Core API usage. + } + + var navigation = currentOwnership.GetNavigation(pointsToPrincipal: false)!; + if (processedJsonNavigations.Contains(navigation)) + { + continue; + } + + processedJsonNavigations.Add(navigation); + var navigationValue = currentEntry.GetCurrentValue(navigation)!; + + var json = CreateJson( + navigationValue, + currentEntry, + currentOwnership.DeclaringEntityType, + ordinal: null, + isCollection: navigation.IsCollection); + + var columnModificationParameters = new ColumnModificationParameters( + jsonColumnName, + originalValue: null, + value: json.ToJsonString(), + property: null, + columnType: jsonColumnTypeMapping.StoreType, + jsonColumnTypeMapping, + read: false, + write: true, + key: false, + condition: false, + _sensitiveLoggingEnabled) + { + GenerateParameterName = _generateParameterName, + }; + + columnModifications.Add(new ColumnModification(columnModificationParameters)); + + continue; + } + var nonMainEntry = !_mainEntryAdded || entry != _entries[0]; var tableMapping = GetTableMapping(entry.EntityType); @@ -389,6 +449,65 @@ private List GenerateColumnModifications() return columnModifications; } + private JsonNode CreateJson(object? navigationValue, IUpdateEntry parentEntry, IEntityType entityType, int? ordinal, bool isCollection) + { + if (navigationValue == null) + { + return new JsonObject(); + } + + if (isCollection) + { + var i = 1; + var jsonNodes = new List(); + foreach (var collectionElement in (IEnumerable)navigationValue) + { + jsonNodes.Add(CreateJson(collectionElement, parentEntry, entityType, i++, isCollection: false)); + } + + return new JsonArray(jsonNodes.ToArray()); + } + +#pragma warning disable EF1001 // Internal EF Core API usage. + var entry = (IUpdateEntry)((InternalEntityEntry)parentEntry).StateManager.TryGetEntry(navigationValue, entityType)!; +#pragma warning restore EF1001 // Internal EF Core API usage. + + var jsonNode = new JsonObject(); + foreach (var property in entityType.GetProperties()) + { + if (property.IsKey()) + { + if (property.IsOrdinalKeyProperty() && ordinal != null) + { + entry.SetStoreGeneratedValue(property, ordinal.Value); + } + + continue; + } + + // jsonPropertyName can only be null for key properties + var jsonPropertyName = property.GetJsonPropertyName()!; + var value = entry.GetCurrentProviderValue(property); + jsonNode[jsonPropertyName] = JsonValue.Create(value); + } + + foreach (var navigation in entityType.GetNavigations()) + { + var jsonPropertyName = navigation.GetJsonPropertyName()!; + var ownedNavigationValue = entry.GetCurrentValue(navigation)!; + var navigationJson = CreateJson( + ownedNavigationValue, + entry, + navigation.TargetEntityType, + ordinal: null, + isCollection: navigation.IsCollection); + + jsonNode[jsonPropertyName] = navigationJson; + } + + return jsonNode; + } + private ITableMapping? GetTableMapping(IEntityType entityType) { ITableMapping? tableMapping = null; @@ -414,6 +533,11 @@ private static void InitializeSharedColumns( { foreach (var columnMapping in tableMapping.ColumnMappings) { + if (columnMapping.Property.DeclaringEntityType.IsMappedToJson()) + { + continue; + } + var columnName = columnMapping.Column.Name; if (!columnMap.TryGetValue(columnName, out var columnPropagator)) { diff --git a/src/EFCore.Relational/ValueGeneration/RelationalValueGeneratorSelector.cs b/src/EFCore.Relational/ValueGeneration/RelationalValueGeneratorSelector.cs index a362cc969aa..fa93bf5f5af 100644 --- a/src/EFCore.Relational/ValueGeneration/RelationalValueGeneratorSelector.cs +++ b/src/EFCore.Relational/ValueGeneration/RelationalValueGeneratorSelector.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 Microsoft.EntityFrameworkCore.Metadata.Internal; using Microsoft.EntityFrameworkCore.ValueGeneration.Internal; namespace Microsoft.EntityFrameworkCore.ValueGeneration; @@ -43,6 +44,11 @@ public RelationalValueGeneratorSelector(ValueGeneratorSelectorDependencies depen /// protected override ValueGenerator? FindForType(IProperty property, IEntityType entityType, Type clrType) { + if (entityType.IsMappedToJson() && property.IsOrdinalKeyProperty()) + { + return _numberFactory.Create(property, entityType); + } + if (property.ValueGenerated != ValueGenerated.Never) { if (clrType.IsInteger() diff --git a/src/EFCore.SqlServer/Design/Internal/SqlServerAnnotationCodeGenerator.cs b/src/EFCore.SqlServer/Design/Internal/SqlServerAnnotationCodeGenerator.cs index 49a7f8ecdd1..46a95aa0b3e 100644 --- a/src/EFCore.SqlServer/Design/Internal/SqlServerAnnotationCodeGenerator.cs +++ b/src/EFCore.SqlServer/Design/Internal/SqlServerAnnotationCodeGenerator.cs @@ -186,7 +186,8 @@ public override IReadOnlyList GenerateFluentApiCalls( /// public override IReadOnlyList GenerateFluentApiCalls( - IRelationalPropertyOverrides overrides, IDictionary annotations) + IRelationalPropertyOverrides overrides, + IDictionary annotations) { return base.GenerateFluentApiCalls(overrides, annotations); } diff --git a/src/EFCore.SqlServer/Metadata/Conventions/SqlServerTemporalConvention.cs b/src/EFCore.SqlServer/Metadata/Conventions/SqlServerTemporalConvention.cs index f5d842fba58..2a995b26ca5 100644 --- a/src/EFCore.SqlServer/Metadata/Conventions/SqlServerTemporalConvention.cs +++ b/src/EFCore.SqlServer/Metadata/Conventions/SqlServerTemporalConvention.cs @@ -27,7 +27,6 @@ public SqlServerTemporalConvention( ProviderConventionSetBuilderDependencies dependencies, RelationalConventionSetBuilderDependencies relationalDependencies) { - Dependencies = dependencies; Dependencies = dependencies; RelationalDependencies = relationalDependencies; } diff --git a/src/EFCore.SqlServer/Metadata/Internal/SqlServerAnnotationProvider.cs b/src/EFCore.SqlServer/Metadata/Internal/SqlServerAnnotationProvider.cs index 5cb7491fdf0..fabd24a58be 100644 --- a/src/EFCore.SqlServer/Metadata/Internal/SqlServerAnnotationProvider.cs +++ b/src/EFCore.SqlServer/Metadata/Internal/SqlServerAnnotationProvider.cs @@ -3,6 +3,7 @@ using System.Globalization; using System.Text; +using Microsoft.EntityFrameworkCore.Metadata.Internal; namespace Microsoft.EntityFrameworkCore.SqlServer.Metadata.Internal; @@ -234,10 +235,11 @@ public override IEnumerable For(IColumn column, bool designTime) string.Format(CultureInfo.InvariantCulture, "{0}, {1}", seed ?? 1, increment ?? 1)); } - // Model validation ensures that these facets are the same on all mapped properties - var property = column.PropertyMappings.First().Property; - if (property.IsSparse() is bool isSparse) + // JSON columns have no property mappings so all annotations that rely on property mappings should be skipped for them + if (column is not JsonColumn + && column.PropertyMappings.FirstOrDefault()?.Property.IsSparse() is bool isSparse) { + // Model validation ensures that these facets are the same on all mapped properties yield return new Annotation(SqlServerAnnotationNames.Sparse, isSparse); } diff --git a/src/EFCore.SqlServer/Storage/Internal/SqlServerJsonTypeMapping.cs b/src/EFCore.SqlServer/Storage/Internal/SqlServerJsonTypeMapping.cs new file mode 100644 index 00000000000..ff01293fae2 --- /dev/null +++ b/src/EFCore.SqlServer/Storage/Internal/SqlServerJsonTypeMapping.cs @@ -0,0 +1,107 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; + +namespace Microsoft.EntityFrameworkCore.SqlServer.Storage.Internal +{ + /// + /// 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 class SqlServerJsonTypeMapping : JsonTypeMapping + { + private static readonly MethodInfo _getStringMethod + = typeof(DbDataReader).GetRuntimeMethod(nameof(DbDataReader.GetString), new[] { typeof(int) })!; + + private static readonly MethodInfo _jsonDocumentParseMethod + = typeof(JsonDocument).GetRuntimeMethod(nameof(JsonDocument.Parse), new[] { typeof(string), typeof(JsonDocumentOptions) })!; + + private static readonly MemberInfo _jsonDocumentRootElementMember + = typeof(JsonDocument).GetRuntimeProperty(nameof(JsonDocument.RootElement))!; + + /// + /// 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 SqlServerJsonTypeMapping(string storeType) + : base(storeType, typeof(JsonElement), System.Data.DbType.String) + { + } + + /// + /// 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 override MethodInfo GetDataReaderMethod() + => _getStringMethod; + + /// + /// 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 override Expression CustomizeDataReaderExpression(Expression expression) + { + if (expression is UnaryExpression unary + && unary.NodeType == ExpressionType.Convert) + { + var parse = Expression.Call( + _jsonDocumentParseMethod, + Expression.Convert( + unary.Operand, + typeof(string)), + Expression.Default(typeof(JsonDocumentOptions))); + + return Expression.MakeMemberAccess(parse, _jsonDocumentRootElementMember); + } + + return base.CustomizeDataReaderExpression(expression); + } + + /// + /// 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 SqlServerJsonTypeMapping(RelationalTypeMappingParameters parameters) + : base(parameters) + { + } + + /// + /// 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 string EscapeSqlLiteral(string literal) + => literal.Replace("'", "''"); + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + protected override string GenerateNonNullSqlLiteral(object value) + => $"'{EscapeSqlLiteral(JsonSerializer.Serialize(value))}'"; + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + protected override RelationalTypeMapping Clone(RelationalTypeMappingParameters parameters) + => new SqlServerJsonTypeMapping(parameters); + } +} diff --git a/src/EFCore.SqlServer/Storage/Internal/SqlServerTypeMappingSource.cs b/src/EFCore.SqlServer/Storage/Internal/SqlServerTypeMappingSource.cs index 603f47f6d20..86710ab616f 100644 --- a/src/EFCore.SqlServer/Storage/Internal/SqlServerTypeMappingSource.cs +++ b/src/EFCore.SqlServer/Storage/Internal/SqlServerTypeMappingSource.cs @@ -3,6 +3,7 @@ using System.Collections; using System.Data; +using System.Text.Json; namespace Microsoft.EntityFrameworkCore.SqlServer.Storage.Internal; @@ -129,6 +130,9 @@ private readonly TimeSpanTypeMapping _time private readonly SqlServerStringTypeMapping _xml = new("xml", unicode: true, storeTypePostfix: StoreTypePostfix.None); + private readonly SqlServerJsonTypeMapping _json + = new("nvarchar(max)"); + private readonly Dictionary _clrTypeMappings; private readonly Dictionary _clrNoFacetTypeMappings; @@ -160,7 +164,8 @@ public SqlServerTypeMappingSource( { typeof(short), _short }, { typeof(float), _real }, { typeof(decimal), _decimal182 }, - { typeof(TimeSpan), _time } + { typeof(TimeSpan), _time }, + { typeof(JsonElement), _json } }; _clrNoFacetTypeMappings diff --git a/src/EFCore/Query/ExpressionPrinter.cs b/src/EFCore/Query/ExpressionPrinter.cs index a7121f6500a..4c5481ec545 100644 --- a/src/EFCore/Query/ExpressionPrinter.cs +++ b/src/EFCore/Query/ExpressionPrinter.cs @@ -321,6 +321,10 @@ public virtual string GenerateBinaryOperator(ExpressionType expressionType) VisitSwitch((SwitchExpression)expression); break; + case ExpressionType.Invoke: + VisitInvocation((InvocationExpression)expression); + break; + case ExpressionType.Extension: VisitExtension(expression); break; @@ -395,7 +399,11 @@ protected override Expression VisitBlock(BlockExpression blockExpression) if (blockExpression.Result != null) { - Append("return "); + if (blockExpression.Result.Type != typeof(void)) + { + Append("return "); + } + Visit(blockExpression.Result); AppendLine(";"); } @@ -1001,6 +1009,23 @@ protected override Expression VisitSwitch(SwitchExpression switchExpression) return switchExpression; } + /// + protected override Expression VisitInvocation(InvocationExpression invocationExpression) + { + _stringBuilder.Append("Invoke("); + Visit(invocationExpression.Expression); + + foreach (var argument in invocationExpression.Arguments) + { + _stringBuilder.Append(", "); + Visit(argument); + } + + _stringBuilder.Append(")"); + + return invocationExpression; + } + /// protected override Expression VisitExtension(Expression extensionExpression) { diff --git a/src/EFCore/Query/Internal/NavigationExpandingExpressionVisitor.Expressions.cs b/src/EFCore/Query/Internal/NavigationExpandingExpressionVisitor.Expressions.cs index f46c983f12b..664c2685935 100644 --- a/src/EFCore/Query/Internal/NavigationExpandingExpressionVisitor.Expressions.cs +++ b/src/EFCore/Query/Internal/NavigationExpandingExpressionVisitor.Expressions.cs @@ -459,7 +459,7 @@ public Expression GetExpression() /// Owned navigations are not expanded, since they map differently in different providers. /// This remembers such references so that they can still be treated like navigations. /// - private sealed class OwnedNavigationReference : Expression + private sealed class OwnedNavigationReference : Expression, IPrintableExpression { public OwnedNavigationReference(Expression parent, INavigation navigation, EntityReference entityReference) { @@ -484,5 +484,17 @@ public override Type Type public override ExpressionType NodeType => ExpressionType.Extension; + + void IPrintableExpression.Print(ExpressionPrinter expressionPrinter) + { + expressionPrinter.AppendLine(nameof(OwnedNavigationReference)); + using (expressionPrinter.Indent()) + { + expressionPrinter.Append("Parent: "); + expressionPrinter.Visit(Parent); + expressionPrinter.AppendLine(); + expressionPrinter.Append("Navigation: " + Navigation.Name + " (OWNED)"); + } + } } } diff --git a/src/Shared/ExpressionExtensions.cs b/src/Shared/ExpressionExtensions.cs index 10f64a628b9..cee9cb2cdbb 100644 --- a/src/Shared/ExpressionExtensions.cs +++ b/src/Shared/ExpressionExtensions.cs @@ -1,7 +1,6 @@ // 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; using System.Diagnostics.CodeAnalysis; #nullable enable diff --git a/test/EFCore.Cosmos.FunctionalTests/Query/OwnedQueryCosmosTest.cs b/test/EFCore.Cosmos.FunctionalTests/Query/OwnedQueryCosmosTest.cs index 32ba658514e..43a864c5642 100644 --- a/test/EFCore.Cosmos.FunctionalTests/Query/OwnedQueryCosmosTest.cs +++ b/test/EFCore.Cosmos.FunctionalTests/Query/OwnedQueryCosmosTest.cs @@ -425,6 +425,22 @@ public override async Task Projecting_collection_correlated_with_keyless_entity_ AssertSql(" "); } + [ConditionalTheory(Skip = "LeftJoin #17314")] + public override async Task Left_join_on_entity_with_owned_navigations(bool async) + { + await base.Left_join_on_entity_with_owned_navigations(async); + + AssertSql(" "); + } + + [ConditionalTheory(Skip = "LeftJoin #17314")] + public override async Task Left_join_on_entity_with_owned_navigations_complex(bool async) + { + await base.Left_join_on_entity_with_owned_navigations_complex(async); + + AssertSql(" "); + } + public override async Task Filter_on_indexer_using_closure(bool async) { await base.Filter_on_indexer_using_closure(async); diff --git a/test/EFCore.Cosmos.FunctionalTests/TestUtilities/CustomPartitionKeyIdGenerator.cs b/test/EFCore.Cosmos.FunctionalTests/TestUtilities/CustomPartitionKeyIdGenerator.cs index 530cb38419c..1db1d244dd2 100644 --- a/test/EFCore.Cosmos.FunctionalTests/TestUtilities/CustomPartitionKeyIdGenerator.cs +++ b/test/EFCore.Cosmos.FunctionalTests/TestUtilities/CustomPartitionKeyIdGenerator.cs @@ -31,7 +31,7 @@ protected override object NextValue(EntityEntry entry) foreach (var property in primaryKey.Properties) { if (property.Name == partitionKey - || property.GetJsonPropertyName() == StoreKeyConvention.IdPropertyJsonName) + || CosmosPropertyExtensions.GetJsonPropertyName(property) == StoreKeyConvention.IdPropertyJsonName) { continue; } diff --git a/test/EFCore.Cosmos.Tests/Metadata/CosmosBuilderExtensionsTest.cs b/test/EFCore.Cosmos.Tests/Metadata/CosmosBuilderExtensionsTest.cs index 1a893b21184..ebe0a50137d 100644 --- a/test/EFCore.Cosmos.Tests/Metadata/CosmosBuilderExtensionsTest.cs +++ b/test/EFCore.Cosmos.Tests/Metadata/CosmosBuilderExtensionsTest.cs @@ -132,7 +132,7 @@ public void Can_set_etag_concurrency_property() Assert.NotNull(etagProperty); Assert.Equal(ValueGenerated.OnAddOrUpdate, etagProperty.ValueGenerated); Assert.True(etagProperty.IsConcurrencyToken); - Assert.Equal("_etag", etagProperty.GetJsonPropertyName()); + Assert.Equal("_etag", CosmosPropertyExtensions.GetJsonPropertyName(etagProperty)); } protected virtual ModelBuilder CreateConventionModelBuilder() diff --git a/test/EFCore.Cosmos.Tests/ModelBuilding/CosmosModelBuilderGenericTest.cs b/test/EFCore.Cosmos.Tests/ModelBuilding/CosmosModelBuilderGenericTest.cs index c486739e8cc..821e4678f0b 100644 --- a/test/EFCore.Cosmos.Tests/ModelBuilding/CosmosModelBuilderGenericTest.cs +++ b/test/EFCore.Cosmos.Tests/ModelBuilding/CosmosModelBuilderGenericTest.cs @@ -158,7 +158,7 @@ public virtual void No_id_property_created_if_another_property_mapped_to_id() Assert.Single(entity.GetKeys().Where(k => k != entity.FindPrimaryKey())); var idProperty = entity.GetDeclaredProperties() - .Single(p => p.GetJsonPropertyName() == StoreKeyConvention.IdPropertyJsonName); + .Single(p => CosmosPropertyExtensions.GetJsonPropertyName(p) == StoreKeyConvention.IdPropertyJsonName); Assert.Single(idProperty.GetContainingKeys()); Assert.NotNull(idProperty.GetValueGeneratorFactory()); } @@ -184,7 +184,7 @@ public virtual void No_id_property_created_if_another_property_mapped_to_id_in_p Assert.Empty(entity.GetKeys().Where(k => k != entity.FindPrimaryKey())); var idProperty = entity.GetDeclaredProperties() - .Single(p => p.GetJsonPropertyName() == StoreKeyConvention.IdPropertyJsonName); + .Single(p => CosmosPropertyExtensions.GetJsonPropertyName(p) == StoreKeyConvention.IdPropertyJsonName); Assert.Single(idProperty.GetContainingKeys()); Assert.Null(idProperty.GetValueGeneratorFactory()); } diff --git a/test/EFCore.Design.Tests/Migrations/Design/CSharpMigrationsGeneratorTest.cs b/test/EFCore.Design.Tests/Migrations/Design/CSharpMigrationsGeneratorTest.cs index 6ed728dec13..e8c76a17815 100644 --- a/test/EFCore.Design.Tests/Migrations/Design/CSharpMigrationsGeneratorTest.cs +++ b/test/EFCore.Design.Tests/Migrations/Design/CSharpMigrationsGeneratorTest.cs @@ -88,7 +88,10 @@ public void Test_new_annotations_handled_for_entity_types() RelationalAnnotationNames.TptMappingStrategy, RelationalAnnotationNames.RelationalModel, RelationalAnnotationNames.ModelDependencies, - RelationalAnnotationNames.FieldValueGetter + RelationalAnnotationNames.FieldValueGetter, + RelationalAnnotationNames.JsonPropertyName, + RelationalAnnotationNames.JsonColumnName, // Appears on entity type but requires specific model (i.e. owned types that can map to json, otherwise validation throws) + RelationalAnnotationNames.JsonColumnTypeMapping, }; // Add a line here if the code generator is supposed to handle this annotation @@ -239,7 +242,10 @@ public void Test_new_annotations_handled_for_properties() RelationalAnnotationNames.RelationalModel, RelationalAnnotationNames.ModelDependencies, RelationalAnnotationNames.Triggers, - RelationalAnnotationNames.FieldValueGetter + RelationalAnnotationNames.FieldValueGetter, + RelationalAnnotationNames.JsonColumnName, + RelationalAnnotationNames.JsonColumnTypeMapping, + RelationalAnnotationNames.JsonPropertyName, }; var columnMapping = $@"{_nl}.{nameof(RelationalPropertyBuilderExtensions.HasColumnType)}(""default_int_mapping"")"; @@ -343,7 +349,6 @@ private static void MissingAnnotationCheck( typeof(RelationalAnnotationNames).GetFields().Where(f => f.Name != "Prefix"))) { var annotationName = (string)field.GetValue(null); - if (!invalidAnnotations.Contains(annotationName)) { var modelBuilder = FakeRelationalTestHelpers.Instance.CreateConventionBuilder(); diff --git a/test/EFCore.Design.Tests/Migrations/ModelSnapshotSqlServerTest.cs b/test/EFCore.Design.Tests/Migrations/ModelSnapshotSqlServerTest.cs index 3ce8325fa77..3b78902af6b 100644 --- a/test/EFCore.Design.Tests/Migrations/ModelSnapshotSqlServerTest.cs +++ b/test/EFCore.Design.Tests/Migrations/ModelSnapshotSqlServerTest.cs @@ -3573,6 +3573,166 @@ protected override void BuildModel(ModelBuilder modelBuilder) Assert.NotNull(testOwnee.FindCheckConstraint("CK_TestOwnee_TestEnum_Enum_Constraint")); }); + [ConditionalFact] + public virtual void Owned_types_mapped_to_json_are_stored_in_snapshot() + => Test( + builder => + { + builder.Entity( + b => + { + b.HasKey(x => x.Id).HasName("PK_Custom"); + + b.OwnsOne(x => x.EntityWithTwoProperties, bb => + { + bb.ToJson(); + bb.Ignore(x => x.Id); + bb.Property(x => x.AlternateId).HasJsonPropertyName("NotKey"); + bb.WithOwner(e => e.EntityWithOneProperty); + bb.OwnsOne(x => x.EntityWithStringKey, bbb => + { + bbb.Ignore(x => x.Id); + bbb.OwnsMany(x => x.Properties, bbbb => bbbb.HasJsonPropertyName("JsonProps")); + }); + }); + }); + }, + AddBoilerPlate( + GetHeading() + + @" + modelBuilder.Entity(""Microsoft.EntityFrameworkCore.Migrations.ModelSnapshotSqlServerTest+EntityWithOneProperty"", b => + { + b.Property(""Id"") + .ValueGeneratedOnAdd() + .HasColumnType(""int""); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property(""Id"")); + + b.HasKey(""Id"") + .HasName(""PK_Custom""); + + b.ToTable(""EntityWithOneProperty""); + }); + + modelBuilder.Entity(""Microsoft.EntityFrameworkCore.Migrations.ModelSnapshotSqlServerTest+EntityWithOneProperty"", b => + { + b.OwnsOne(""Microsoft.EntityFrameworkCore.Migrations.ModelSnapshotSqlServerTest+EntityWithTwoProperties"", ""EntityWithTwoProperties"", b1 => + { + b1.Property(""EntityWithOnePropertyId"") + .HasColumnType(""int""); + + b1.Property(""AlternateId"") + .HasColumnType(""int"") + .HasAnnotation(""Relational:JsonPropertyName"", ""NotKey""); + + b1.HasKey(""EntityWithOnePropertyId""); + + b1.ToTable(""EntityWithOneProperty""); + + b1.ToJson(""EntityWithTwoProperties""); + + b1.WithOwner(""EntityWithOneProperty"") + .HasForeignKey(""EntityWithOnePropertyId""); + + b1.OwnsOne(""Microsoft.EntityFrameworkCore.Migrations.ModelSnapshotSqlServerTest+EntityWithStringKey"", ""EntityWithStringKey"", b2 => + { + b2.Property(""EntityWithTwoPropertiesEntityWithOnePropertyId"") + .HasColumnType(""int""); + + b2.HasKey(""EntityWithTwoPropertiesEntityWithOnePropertyId""); + + b2.ToTable(""EntityWithOneProperty""); + + b2.WithOwner() + .HasForeignKey(""EntityWithTwoPropertiesEntityWithOnePropertyId""); + + b2.OwnsMany(""Microsoft.EntityFrameworkCore.Migrations.ModelSnapshotSqlServerTest+EntityWithStringProperty"", ""Properties"", b3 => + { + b3.Property(""EntityWithStringKeyEntityWithTwoPropertiesEntityWithOnePropertyId"") + .HasColumnType(""int""); + + b3.Property(""Id"") + .HasColumnType(""int""); + + b3.Property(""Name"") + .HasColumnType(""nvarchar(max)""); + + b3.HasKey(""EntityWithStringKeyEntityWithTwoPropertiesEntityWithOnePropertyId"", ""Id""); + + b3.ToTable(""EntityWithOneProperty""); + + b3.WithOwner() + .HasForeignKey(""EntityWithStringKeyEntityWithTwoPropertiesEntityWithOnePropertyId""); + }); + + b2.Navigation(""Properties"") + .HasAnnotation(""Relational:JsonPropertyName"", ""JsonProps""); + }); + + b1.Navigation(""EntityWithOneProperty""); + + b1.Navigation(""EntityWithStringKey""); + }); + + b.Navigation(""EntityWithTwoProperties""); + });", usingSystem: false), + o => + { + var entityWithOneProperty = o.FindEntityType(typeof(EntityWithOneProperty)); + Assert.Equal("PK_Custom", entityWithOneProperty.GetKeys().Single().GetName()); + + var ownership1 = entityWithOneProperty.FindNavigation(nameof(EntityWithOneProperty.EntityWithTwoProperties)) + .ForeignKey; + Assert.Equal("EntityWithOnePropertyId", ownership1.Properties[0].Name); + + Assert.Equal(nameof(EntityWithTwoProperties.EntityWithOneProperty), ownership1.DependentToPrincipal.Name); + Assert.True(ownership1.IsRequired); + Assert.Equal("FK_EntityWithOneProperty_EntityWithOneProperty_EntityWithOnePropertyId", ownership1.GetConstraintName()); + var ownedType1 = ownership1.DeclaringEntityType; + Assert.Equal("EntityWithOnePropertyId", ownedType1.FindPrimaryKey().Properties[0].Name); + + var ownedProperties1 = ownedType1.GetProperties().ToList(); + Assert.Equal("EntityWithOnePropertyId", ownedProperties1[0].Name); + Assert.Equal("AlternateId", ownedProperties1[1].Name); + Assert.Equal("NotKey", RelationalPropertyExtensions.GetJsonPropertyName(ownedProperties1[1])); + + Assert.Equal(nameof(EntityWithOneProperty), ownedType1.GetTableName()); + Assert.Equal("EntityWithTwoProperties", ownedType1.GetJsonColumnName()); + + var ownership2 = ownedType1.FindNavigation(nameof(EntityWithStringKey)).ForeignKey; + Assert.Equal("EntityWithTwoPropertiesEntityWithOnePropertyId", ownership2.Properties[0].Name); + Assert.Equal(nameof(EntityWithTwoProperties.EntityWithStringKey), ownership2.PrincipalToDependent.Name); + Assert.True(ownership2.IsRequired); + + var ownedType2 = ownership2.DeclaringEntityType; + Assert.Equal(nameof(EntityWithStringKey), ownedType2.DisplayName()); + Assert.Equal("EntityWithTwoPropertiesEntityWithOnePropertyId", ownedType2.FindPrimaryKey().Properties[0].Name); + + var ownedProperties2 = ownedType2.GetProperties().ToList(); + Assert.Equal("EntityWithTwoPropertiesEntityWithOnePropertyId", ownedProperties2[0].Name); + + var navigation3 = ownedType2.FindNavigation(nameof(EntityWithStringKey.Properties)); + Assert.Equal("JsonProps", navigation3.GetJsonPropertyName()); + var ownership3 = navigation3.ForeignKey; + Assert.Equal("EntityWithStringKeyEntityWithTwoPropertiesEntityWithOnePropertyId", ownership3.Properties[0].Name); + Assert.Equal(nameof(EntityWithStringKey.Properties), ownership3.PrincipalToDependent.Name); + Assert.True(ownership3.IsRequired); + Assert.False(ownership3.IsUnique); + + var ownedType3 = ownership3.DeclaringEntityType; + Assert.Equal(nameof(EntityWithStringProperty), ownedType3.DisplayName()); + var pkProperties3 = ownedType3.FindPrimaryKey().Properties; + Assert.Equal("EntityWithStringKeyEntityWithTwoPropertiesEntityWithOnePropertyId", pkProperties3[0].Name); + Assert.Equal("Id", pkProperties3[1].Name); + + var ownedProperties3 = ownedType3.GetProperties().ToList(); + Assert.Equal(3, ownedProperties3.Count); + + Assert.Equal("EntityWithStringKeyEntityWithTwoPropertiesEntityWithOnePropertyId", ownedProperties3[0].Name); + Assert.Equal("Id", ownedProperties3[1].Name); + Assert.Equal("Name", ownedProperties3[2].Name); + }); + private class Order { public int Id { get; set; } diff --git a/test/EFCore.Design.Tests/Scaffolding/Internal/CSharpRuntimeModelCodeGeneratorTest.cs b/test/EFCore.Design.Tests/Scaffolding/Internal/CSharpRuntimeModelCodeGeneratorTest.cs index 32081cbbe29..17e3fa07584 100644 --- a/test/EFCore.Design.Tests/Scaffolding/Internal/CSharpRuntimeModelCodeGeneratorTest.cs +++ b/test/EFCore.Design.Tests/Scaffolding/Internal/CSharpRuntimeModelCodeGeneratorTest.cs @@ -4590,7 +4590,7 @@ public static void CreateAnnotations(RuntimeEntityType runtimeEntityType) Assert.Equal(ValueGenerated.Never, id.ValueGenerated); Assert.Equal(PropertySaveBehavior.Throw, id.GetAfterSaveBehavior()); Assert.Equal(PropertySaveBehavior.Save, id.GetBeforeSaveBehavior()); - Assert.Equal("Id", id.GetJsonPropertyName()); + Assert.Equal("Id", CosmosPropertyExtensions.GetJsonPropertyName(id)); Assert.Null(id.GetValueGeneratorFactory()); Assert.Null(id.GetValueConverter()); Assert.NotNull(id.GetValueComparer()); @@ -4605,7 +4605,7 @@ public static void CreateAnnotations(RuntimeEntityType runtimeEntityType) Assert.Equal(ValueGenerated.Never, storeId.ValueGenerated); Assert.Equal(PropertySaveBehavior.Throw, storeId.GetAfterSaveBehavior()); Assert.Equal(PropertySaveBehavior.Save, storeId.GetBeforeSaveBehavior()); - Assert.Equal("id", storeId.GetJsonPropertyName()); + Assert.Equal("id", CosmosPropertyExtensions.GetJsonPropertyName(storeId)); Assert.IsType(storeId.GetValueGeneratorFactory()(storeId, dataEntity)); Assert.Null(storeId.GetValueConverter()); Assert.NotNull(storeId.GetValueComparer()); @@ -4620,7 +4620,7 @@ public static void CreateAnnotations(RuntimeEntityType runtimeEntityType) Assert.Equal(ValueGenerated.Never, partitionId.ValueGenerated); Assert.Equal(PropertySaveBehavior.Throw, partitionId.GetAfterSaveBehavior()); Assert.Equal(PropertySaveBehavior.Save, partitionId.GetBeforeSaveBehavior()); - Assert.Equal("PartitionId", partitionId.GetJsonPropertyName()); + Assert.Equal("PartitionId", CosmosPropertyExtensions.GetJsonPropertyName(partitionId)); Assert.Null(partitionId.GetValueGeneratorFactory()); Assert.Null(partitionId.GetValueConverter()); Assert.IsType>(partitionId.FindTypeMapping().Converter); @@ -4636,7 +4636,7 @@ public static void CreateAnnotations(RuntimeEntityType runtimeEntityType) Assert.Equal(ValueGenerated.OnAddOrUpdate, eTag.ValueGenerated); Assert.Equal(PropertySaveBehavior.Ignore, eTag.GetAfterSaveBehavior()); Assert.Equal(PropertySaveBehavior.Ignore, eTag.GetBeforeSaveBehavior()); - Assert.Equal("_etag", eTag.GetJsonPropertyName()); + Assert.Equal("_etag", CosmosPropertyExtensions.GetJsonPropertyName(eTag)); Assert.Null(eTag.GetValueGeneratorFactory()); Assert.Null(eTag.GetValueConverter()); Assert.NotNull(eTag.GetValueComparer()); @@ -4653,7 +4653,7 @@ public static void CreateAnnotations(RuntimeEntityType runtimeEntityType) Assert.Equal(ValueGenerated.Never, blob.ValueGenerated); Assert.Equal(PropertySaveBehavior.Save, blob.GetAfterSaveBehavior()); Assert.Equal(PropertySaveBehavior.Save, blob.GetBeforeSaveBehavior()); - Assert.Equal("JsonBlob", blob.GetJsonPropertyName()); + Assert.Equal("JsonBlob", CosmosPropertyExtensions.GetJsonPropertyName(blob)); Assert.Null(blob.GetValueGeneratorFactory()); Assert.Null(blob.GetValueConverter()); Assert.NotNull(blob.GetValueComparer()); @@ -4668,7 +4668,7 @@ public static void CreateAnnotations(RuntimeEntityType runtimeEntityType) Assert.Equal(ValueGenerated.OnAddOrUpdate, jObject.ValueGenerated); Assert.Equal(PropertySaveBehavior.Ignore, jObject.GetAfterSaveBehavior()); Assert.Equal(PropertySaveBehavior.Ignore, jObject.GetBeforeSaveBehavior()); - Assert.Equal("", jObject.GetJsonPropertyName()); + Assert.Equal("", CosmosPropertyExtensions.GetJsonPropertyName(jObject)); Assert.Null(jObject.GetValueGeneratorFactory()); Assert.Null(jObject.GetValueConverter()); Assert.NotNull(jObject.GetValueComparer()); diff --git a/test/EFCore.Relational.Tests/Infrastructure/RelationalModelValidatorTest.Json.cs b/test/EFCore.Relational.Tests/Infrastructure/RelationalModelValidatorTest.Json.cs new file mode 100644 index 00000000000..eead16d5cc6 --- /dev/null +++ b/test/EFCore.Relational.Tests/Infrastructure/RelationalModelValidatorTest.Json.cs @@ -0,0 +1,554 @@ +// 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.Infrastructure +{ + public partial class RelationalModelValidatorTest + { + [ConditionalFact] + public void Throw_when_non_json_entity_is_the_owner_of_json_entity_ref_ref() + { + var modelBuilder = CreateConventionModelBuilder(); + modelBuilder.Entity(b => + { + b.OwnsOne(x => x.OwnedReference, bb => + { + bb.Ignore(x => x.NestedCollection); + bb.OwnsOne(x => x.NestedReference, bbb => bbb.ToJson("reference_reference")); + }); + b.Ignore(x => x.OwnedCollection); + }); + + VerifyError( + RelationalStrings.JsonEntityOwnedByNonJsonOwnedType( + nameof(ValidatorJsonOwnedRoot), nameof(ValidatorJsonEntityBasic)), + modelBuilder); + } + + [ConditionalFact] + public void Throw_when_non_json_entity_is_the_owner_of_json_entity_ref_col() + { + var modelBuilder = CreateConventionModelBuilder(); + modelBuilder.Entity(b => + { + b.OwnsOne(x => x.OwnedReference, bb => + { + bb.OwnsMany(x => x.NestedCollection, bbb => bbb.ToJson("reference_collection")); + bb.Ignore(x => x.NestedReference); + }); + b.Ignore(x => x.OwnedCollection); + }); + + VerifyError( + RelationalStrings.JsonEntityOwnedByNonJsonOwnedType( + nameof(ValidatorJsonOwnedRoot), nameof(ValidatorJsonEntityBasic)), + modelBuilder); + } + + [ConditionalFact] + public void Throw_when_non_json_entity_is_the_owner_of_json_entity_col_ref() + { + var modelBuilder = CreateConventionModelBuilder(); + modelBuilder.Entity(b => + { + b.OwnsMany(x => x.OwnedCollection, bb => + { + bb.Ignore(x => x.NestedCollection); + bb.OwnsOne(x => x.NestedReference, bbb => bbb.ToJson("collection_reference")); + }); + b.Ignore(x => x.OwnedReference); + }); + + VerifyError( + RelationalStrings.JsonEntityOwnedByNonJsonOwnedType( + nameof(ValidatorJsonOwnedRoot), nameof(ValidatorJsonOwnedRoot)), + modelBuilder); + } + + [ConditionalFact] + public void Throw_when_non_json_entity_is_the_owner_of_json_entity_col_col() + { + var modelBuilder = CreateConventionModelBuilder(); + modelBuilder.Entity(b => + { + b.OwnsMany(x => x.OwnedCollection, bb => + { + bb.Ignore(x => x.NestedReference); + bb.OwnsMany(x => x.NestedCollection, bbb => bbb.ToJson("collection_collection")); + }); + b.Ignore(x => x.OwnedReference); + }); + + VerifyError( + RelationalStrings.JsonEntityOwnedByNonJsonOwnedType( + nameof(ValidatorJsonOwnedRoot), nameof(ValidatorJsonOwnedRoot)), + modelBuilder); + } + + [ConditionalFact] + public void Throw_when_json_entity_references_another_non_json_entity_via_reference() + { + var modelBuilder = CreateConventionModelBuilder(); + modelBuilder.Entity(); + modelBuilder.Entity(b => + { + b.OwnsOne(x => x.Owned, bb => + { + bb.ToJson("reference"); + bb.HasOne(x => x.Reference).WithOne().HasForeignKey(x => x.Fk); + }); + }); + + VerifyError( + RelationalStrings.JsonEntityReferencingRegularEntity(nameof(ValidatorJsonOwnedReferencingRegularEntity)), + modelBuilder); + } + + [ConditionalFact] + public void Tpt_not_supported_for_owner_of_json_entity_on_base() + { + var modelBuilder = CreateConventionModelBuilder(); + modelBuilder.Entity(b => + { + b.ToTable("Table1"); + b.OwnsOne(x => x.ReferenceOnBase, bb => + { + bb.ToJson("reference"); + }); + }); + + modelBuilder.Entity(b => + { + b.HasBaseType(); + b.ToTable("Table2"); + b.Ignore(x => x.ReferenceOnDerived); + b.Ignore(x => x.CollectionOnDerived); + }); + + VerifyError( + RelationalStrings.JsonEntityWithNonTphInheritanceOnOwner( + nameof(ValidatorJsonEntityInheritanceBase), "TPH"), + modelBuilder); + } + + [ConditionalFact] + public void Tpt_not_supported_for_owner_of_json_entity_on_derived() + { + var modelBuilder = CreateConventionModelBuilder(); + modelBuilder.Entity(b => + { + b.ToTable("Table1"); + b.Ignore(x => x.ReferenceOnBase); + }); + + modelBuilder.Entity(b => + { + b.ToTable("Table2"); + b.OwnsOne(x => x.ReferenceOnDerived, bb => bb.ToJson("reference")); + b.Ignore(x => x.CollectionOnDerived); + }); + + VerifyError( + RelationalStrings.JsonEntityWithNonTphInheritanceOnOwner( + nameof(ValidatorJsonEntityInheritanceBase), "TPH"), + modelBuilder); + } + + [ConditionalFact] + public void Tpt_not_supported_for_owner_of_json_entity_mapping_strategy_explicitly_defined() + { + var modelBuilder = CreateConventionModelBuilder(); + modelBuilder.Entity(b => + { + b.UseTptMappingStrategy(); + b.OwnsOne(x => x.ReferenceOnBase, bb => + { + bb.ToJson("reference"); + }); + }); + + modelBuilder.Entity(b => + { + b.HasBaseType(); + b.Ignore(x => x.ReferenceOnDerived); + b.Ignore(x => x.CollectionOnDerived); + }); + + VerifyError( + RelationalStrings.JsonEntityWithNonTphInheritanceOnOwner( + nameof(ValidatorJsonEntityInheritanceBase), "TPH"), + modelBuilder); + } + + [ConditionalFact] + public void Tpt_not_supported_for_owner_of_json_entity_same_table_names_different_schemas() + { + var modelBuilder = CreateConventionModelBuilder(); + modelBuilder.Entity(b => + { + b.ToTable("Table", "mySchema1"); + b.Ignore(x => x.ReferenceOnBase); + }); + + modelBuilder.Entity(b => + { + b.ToTable("Table", "mySchema2"); + b.OwnsOne(x => x.ReferenceOnDerived, bb => bb.ToJson("reference")); + b.Ignore(x => x.CollectionOnDerived); + }); + + VerifyError( + RelationalStrings.JsonEntityWithNonTphInheritanceOnOwner( + nameof(ValidatorJsonEntityInheritanceBase), "TPH"), + modelBuilder); + } + + [ConditionalFact] + public void Tpc_not_supported_for_owner_of_json_entity() + { + var modelBuilder = CreateConventionModelBuilder(); + modelBuilder.Entity().UseTpcMappingStrategy(); + modelBuilder.Entity(); + modelBuilder.Entity(b => b.Ignore(x => x.ReferenceOnBase)); + + modelBuilder.Entity(b => + { + b.OwnsOne(x => x.ReferenceOnDerived, bb => bb.ToJson("reference")); + b.Ignore(x => x.CollectionOnDerived); + }); + + VerifyError( + RelationalStrings.JsonEntityWithNonTphInheritanceOnOwner( + nameof(ValidatorJsonEntityInheritanceBase), "TPH"), + modelBuilder); + } + + [ConditionalFact] + public void Json_entity_not_mapped_to_table_or_a_view_is_not_supported() + { + var modelBuilder = CreateConventionModelBuilder(); + modelBuilder.Entity(b => + { + b.ToTable((string)null); + b.OwnsOne(x => x.OwnedReference, bb => + { + bb.ToJson("reference"); + bb.Ignore(x => x.NestedReference); + bb.Ignore(x => x.NestedCollection); + }); + b.Ignore(x => x.OwnedCollection); + }); + + VerifyError( + RelationalStrings.JsonEntityWithOwnerNotMappedToTableOrView(nameof(ValidatorJsonEntityBasic)), + modelBuilder); + } + + [ConditionalFact] + public void Json_multiple_json_entities_mapped_to_the_same_column() + { + var modelBuilder = CreateConventionModelBuilder(); + modelBuilder.Entity(b => + { + b.OwnsOne(x => x.Reference1, bb => bb.ToJson("json")); + b.OwnsOne(x => x.Reference2, bb => bb.ToJson("json")); + b.Ignore(x => x.Collection1); + b.Ignore(x => x.Collection2); + }); + + VerifyError( + RelationalStrings.JsonEntityMultipleRootsMappedToTheSameJsonColumn( + "json", nameof(ValidatorJsonEntitySideBySide)), + modelBuilder); + } + + [ConditionalFact] + public void Json_entity_with_defalt_value_on_a_property() + { + var modelBuilder = CreateConventionModelBuilder(); + modelBuilder.Entity(b => + { + b.Ignore(x => x.OwnedCollection); + b.OwnsOne(x => x.OwnedReference, bb => + { + bb.Ignore(x => x.NestedReference); + bb.Ignore(x => x.NestedCollection); + bb.ToJson("json"); + bb.Property(x => x.Name).HasDefaultValue("myDefault"); + }); + }); + + VerifyError( + "Setting default value on properties of an entity mapped to JSON is not supported. Entity: 'ValidatorJsonOwnedRoot', property: 'Name'.", + modelBuilder); + } + + [ConditionalFact] + public void Json_entity_with_table_splitting_throws() + { + var modelBuilder = CreateConventionModelBuilder(); + modelBuilder.Entity(b => + { + b.ToTable("SharedTable"); + b.OwnsOne(x => x.OwnedReference, bb => + { + bb.ToJson("json"); + bb.OwnsOne(x => x.NestedReference); + bb.OwnsMany(x => x.NestedCollection); + }); + b.Ignore(x => x.OwnedCollection); + }); + + modelBuilder.Entity(b => + { + b.ToTable("SharedTable"); + b.HasOne(x => x.Link).WithOne().HasForeignKey(x => x.Id); + }); + + VerifyError( + RelationalStrings.JsonEntityWithTableSplittingIsNotSupported, + modelBuilder); + } + + [ConditionalFact] + public void Json_entity_with_explicit_ordinal_key_on_collection_throws() + { + var modelBuilder = CreateConventionModelBuilder(); + modelBuilder.Entity(b => + { + b.OwnsMany(x => x.OwnedCollection, bb => + { + bb.ToJson("json"); + bb.HasKey(x => x.Ordinal); + }); + }); + + VerifyError( + "Entity type 'ValidatorJsonOwnedExplicitOrdinal' is part of collection mapped to JSON and has it's ordinal key defined explicitly. Only implicitly defined ordinal keys are supported.", + modelBuilder); + } + + [ConditionalFact] + public void Json_entity_with_key_having_json_property_name_configured_explicitly_throws() + { + var modelBuilder = CreateConventionModelBuilder(); + modelBuilder.Entity(b => + { + b.OwnsMany(x => x.OwnedCollection, bb => + { + bb.ToJson("json"); + bb.HasKey(x => x.Ordinal); + bb.Property(x => x.Ordinal).HasJsonPropertyName("Foo"); + }); + }); + + VerifyError( + RelationalStrings.JsonEntityWithExplicitlyConfiguredJsonPropertyNameOnKey( + nameof(ValidatorJsonOwnedExplicitOrdinal.Ordinal), nameof(ValidatorJsonOwnedExplicitOrdinal)), + modelBuilder); + } + + [ConditionalFact] + public void Json_entity_with_multiple_properties_mapped_to_same_json_name() + { + var modelBuilder = CreateConventionModelBuilder(); + modelBuilder.Entity(b => + { + b.OwnsOne(x => x.OwnedReference, bb => + { + bb.Property(x => x.Name).HasJsonPropertyName("Foo"); + bb.Property(x => x.Number).HasJsonPropertyName("Foo"); + bb.ToJson("reference"); + bb.Ignore(x => x.NestedReference); + bb.Ignore(x => x.NestedCollection); + }); + b.Ignore(x => x.OwnedCollection); + }); + + VerifyError( + "Entity 'ValidatorJsonOwnedRoot' is mapped to JSON and it contains multiple properties or navigations which are mapped to the same JSON property 'Foo'. Each property should map to a unique JSON property.", + modelBuilder); + } + + [ConditionalFact] + public void Json_entity_with_property_and_navigation_mapped_to_same_json_name() + { + var modelBuilder = CreateConventionModelBuilder(); + modelBuilder.Entity(b => + { + b.OwnsOne(x => x.OwnedReference, bb => + { + bb.Property(x => x.Name); + bb.Property(x => x.Number); + bb.ToJson("reference"); + bb.OwnsOne(x => x.NestedReference, bbb => bbb.HasJsonPropertyName("Name")); + bb.Ignore(x => x.NestedCollection); + }); +b.Ignore(x => x.OwnedCollection); +}); + + VerifyError( + RelationalStrings.JsonEntityWithMultiplePropertiesMappedToSameJsonProperty( + nameof(ValidatorJsonOwnedRoot), nameof(ValidatorJsonOwnedRoot.Name)), + modelBuilder); + } + + [ConditionalFact] + public void Json_on_base_and_derived_mapped_to_same_column_throws() + { + var modelBuilder = CreateConventionModelBuilder(); + modelBuilder.Entity().OwnsOne(x => x.ReferenceOnBase, b => b.ToJson("jsonColumn")); + modelBuilder.Entity(b => + { + b.HasBaseType(); + b.OwnsOne(x => x.ReferenceOnDerived, bb => bb.ToJson("jsonColumn")); + b.Ignore(x => x.CollectionOnDerived); + }); + + VerifyError( + RelationalStrings.JsonEntityMultipleRootsMappedToTheSameJsonColumn( + "jsonColumn", nameof(ValidatorJsonEntityInheritanceBase)), + modelBuilder); + } + + [ConditionalFact] + public void Json_entity_mapped_to_different_view_than_its_root_aggregate() + { + var modelBuilder = CreateConventionModelBuilder(); + modelBuilder.Entity(b => + { + b.ToView("MyView"); + b.OwnsOne(x => x.OwnedReference, bb => + { + bb.ToJson(); + bb.ToView("MyOtherView"); + bb.Ignore(x => x.NestedReference); + bb.Ignore(x => x.NestedCollection); + }); + b.Ignore(x => x.OwnedCollection); + }); + + VerifyError( + RelationalStrings.JsonEntityMappedToDifferentViewThanOwner( + nameof(ValidatorJsonOwnedRoot), "MyOtherView", nameof(ValidatorJsonEntityBasic), "MyView"), + modelBuilder); + } + + [ConditionalFact] + public void Json_entity_mapped_to_different_view_than_its_parent() + { + var modelBuilder = CreateConventionModelBuilder(); + modelBuilder.Entity(b => + { + b.ToView("MyView"); + b.OwnsOne(x => x.OwnedReference, bb => + { + bb.ToJson(); + bb.ToView("MyView"); + bb.OwnsMany(x => x.NestedCollection, bbb => bbb.ToView("MyOtherView")); + bb.Ignore(x => x.NestedReference); + }); + b.Ignore(x => x.OwnedCollection); + }); + + VerifyError( + RelationalStrings.JsonEntityMappedToDifferentViewThanOwner( + nameof(ValidatorJsonOwnedBranch), "MyOtherView", nameof(ValidatorJsonOwnedRoot), "MyView"), + modelBuilder); + } + + private class ValidatorJsonEntityBasic + { + public int Id { get; set; } + public ValidatorJsonOwnedRoot OwnedReference { get; set; } + public List OwnedCollection { get; set; } + } + + private abstract class ValidatorJsonEntityInheritanceAbstract : ValidatorJsonEntityInheritanceBase + { + public Guid Guid { get; set; } + } + + private class ValidatorJsonEntityInheritanceBase + { + public int Id { get; set; } + public string Name { get; set; } + public ValidatorJsonOwnedBranch ReferenceOnBase { get; set; } + + } + + private class ValidatorJsonEntityInheritanceDerived : ValidatorJsonEntityInheritanceAbstract + { + public bool Switch { get; set; } + + public ValidatorJsonOwnedBranch ReferenceOnDerived { get; set; } + + public List CollectionOnDerived { get; set; } + } + + private class ValidatorJsonOwnedRoot + { + public string Name { get; set; } + public int Number { get; set; } + + public ValidatorJsonOwnedBranch NestedReference { get; set; } + public List NestedCollection { get; set; } + } + + private class ValidatorJsonOwnedBranch + { + public double Number { get; set; } + } + + private class ValidatorJsonEntityExplicitOrdinal + { + public int Id { get; set; } + + public string Name { get; set; } + + public List OwnedCollection { get; set; } + } + + private class ValidatorJsonOwnedExplicitOrdinal + { + public int Ordinal { get; set; } + public DateTime Date { get; set; } + } + + private class ValidatorJsonEntityJsonReferencingRegularEntity + { + public int Id { get; set; } + public ValidatorJsonOwnedReferencingRegularEntity Owned { get; set; } + } + + private class ValidatorJsonOwnedReferencingRegularEntity + { + public string Foo { get; set; } + + public int? Fk { get; set; } + public ValidatorJsonEntityReferencedEntity Reference { get; set; } + } + + private class ValidatorJsonEntityReferencedEntity + { + public int Id { get; set; } + public DateTime Date { get; set; } + } + + private class ValidatorJsonEntitySideBySide + { + public int Id { get; set; } + public string Name { get; set; } + public ValidatorJsonOwnedBranch Reference1 { get; set; } + public ValidatorJsonOwnedBranch Reference2 { get; set; } + public List Collection1 { get; set; } + public List Collection2 { get; set; } + } + + private class ValidatorJsonEntityTableSplitting + { + public int Id { get; set; } + public ValidatorJsonEntityBasic Link { get; set; } + } + } +} diff --git a/test/EFCore.Relational.Tests/ModelBuilding/RelationalModelBuilderTest.cs b/test/EFCore.Relational.Tests/ModelBuilding/RelationalModelBuilderTest.cs index 3589c1e1527..7963d2c2521 100644 --- a/test/EFCore.Relational.Tests/ModelBuilding/RelationalModelBuilderTest.cs +++ b/test/EFCore.Relational.Tests/ModelBuilding/RelationalModelBuilderTest.cs @@ -8,7 +8,7 @@ // ReSharper disable InconsistentNaming namespace Microsoft.EntityFrameworkCore.ModelBuilding; -public class RelationalModelBuilderTest : ModelBuilderTest +public partial class RelationalModelBuilderTest : ModelBuilderTest { public abstract class RelationalNonRelationshipTestBase : NonRelationshipTestBase { @@ -805,6 +805,70 @@ public virtual void Can_use_sproc_mapping_with_owned_reference() Assert.Null(bookOwnership2.DeclaringEntityType.GetUpdateStoredProcedure()); Assert.Null(bookOwnership2.DeclaringEntityType.GetDeleteStoredProcedure()); } +#nullable disable + protected class JsonEntity + { + public int Id { get; set; } + public string Name { get; set; } + + public OwnedEntity OwnedReference1 { get; set; } + public OwnedEntity OwnedReference2 { get; set; } + + public List OwnedCollection1 { get; set; } + public List OwnedCollection2 { get; set; } + } + + protected class OwnedEntity + { + public DateTime Date { get; set; } + public double Fraction { get; set; } + public MyJsonEnum Enum { get; set; } + } + + protected enum MyJsonEnum + { + One, + Two, + Three, + } + + protected class JsonEntityInheritanceBase + { + public int Id { get; set; } + public OwnedEntity OwnedReferenceOnBase { get; set; } + public List OwnedCollectionOnBase { get; set; } + } + + protected class JsonEntityInheritanceDerived : JsonEntityInheritanceBase + { + public string Name { get; set; } + public OwnedEntity OwnedReferenceOnDerived { get; set; } + public List OwnedCollectionOnDerived { get; set; } + } + + protected class OwnedEntityExtraLevel + { + public DateTime Date { get; set; } + public double Fraction { get; set; } + public MyJsonEnum Enum { get; set; } + + public OwnedEntity Reference1 { get; set; } + public OwnedEntity Reference2 { get; set; } + public List Collection1 { get; set; } + public List Collection2 { get; set; } + } + + protected class JsonEntityWithNesting + { + public int Id { get; set; } + public string Name { get; set; } + + public OwnedEntityExtraLevel OwnedReference1 { get; set; } + public OwnedEntityExtraLevel OwnedReference2 { get; set; } + public List OwnedCollection1 { get; set; } + public List OwnedCollection2 { get; set; } + } +#nullable enable } public abstract class TestTableBuilder diff --git a/test/EFCore.Relational.Tests/ModelBuilding/RelationalTestModelBuilderExtensions.cs b/test/EFCore.Relational.Tests/ModelBuilding/RelationalTestModelBuilderExtensions.cs index 560891d0d3d..1b7f8610534 100644 --- a/test/EFCore.Relational.Tests/ModelBuilding/RelationalTestModelBuilderExtensions.cs +++ b/test/EFCore.Relational.Tests/ModelBuilding/RelationalTestModelBuilderExtensions.cs @@ -127,6 +127,23 @@ public static ModelBuilderTest.TestPropertyBuilder IsFixedLength HasJsonPropertyName( + this ModelBuilderTest.TestPropertyBuilder builder, + string? name) + { + switch (builder) + { + case IInfrastructure> genericBuilder: + genericBuilder.Instance.HasJsonPropertyName(name); + break; + case IInfrastructure nonGenericBuilder: + nonGenericBuilder.Instance.HasJsonPropertyName(name); + break; + } + + return builder; + } + public static ModelBuilderTest.TestEntityTypeBuilder UseTpcMappingStrategy( this ModelBuilderTest.TestEntityTypeBuilder builder) where TEntity : class @@ -1331,6 +1348,62 @@ public static ModelBuilderTest.TestOwnedNavigationBuilder ToJson( + this ModelBuilderTest.TestOwnedNavigationBuilder builder) + where TOwnerEntity : class + where TDependentEntity : class + { + switch (builder) + { + case IInfrastructure> genericBuilder: + genericBuilder.Instance.ToJson(); + break; + case IInfrastructure nonGenericBuilder: + nonGenericBuilder.Instance.ToJson(); + break; + } + + return builder; + } + + public static ModelBuilderTest.TestOwnedNavigationBuilder ToJson( + this ModelBuilderTest.TestOwnedNavigationBuilder builder, + string? jsonColumnName) + where TOwnerEntity : class + where TDependentEntity : class + { + switch (builder) + { + case IInfrastructure> genericBuilder: + genericBuilder.Instance.ToJson(jsonColumnName); + break; + case IInfrastructure nonGenericBuilder: + nonGenericBuilder.Instance.ToJson(jsonColumnName); + break; + } + + return builder; + } + + public static ModelBuilderTest.TestOwnedNavigationBuilder HasJsonPropertyName( + this ModelBuilderTest.TestOwnedNavigationBuilder builder, + string? name) + where TOwnerEntity : class + where TDependentEntity : class + { + switch (builder) + { + case IInfrastructure> genericBuilder: + genericBuilder.Instance.HasJsonPropertyName(name); + break; + case IInfrastructure nonGenericBuilder: + nonGenericBuilder.Instance.HasJsonPropertyName(name); + break; + } + + return builder; + } + public static ModelBuilderTest.TestOwnershipBuilder HasConstraintName( this ModelBuilderTest.TestOwnershipBuilder builder, string name) diff --git a/test/EFCore.Specification.Tests/Query/Ef6GroupByTestBase.cs b/test/EFCore.Specification.Tests/Query/Ef6GroupByTestBase.cs index 09d58bb91b9..4444308f5c1 100644 --- a/test/EFCore.Specification.Tests/Query/Ef6GroupByTestBase.cs +++ b/test/EFCore.Specification.Tests/Query/Ef6GroupByTestBase.cs @@ -771,6 +771,8 @@ protected virtual void ClearLog() public abstract class Ef6GroupByFixtureBase : SharedStoreFixtureBase, IQueryFixtureBase { + private ArubaData _expectedData; + protected override string StoreName => "Ef6GroupByTest"; @@ -813,7 +815,14 @@ protected override void Seed(ArubaContext context) => new ArubaData(context); public virtual ISetSource GetExpectedData() - => new ArubaData(); + { + if (_expectedData == null) + { + _expectedData = new ArubaData(); + } + + return _expectedData; + } public IReadOnlyDictionary EntitySorters { get; } = new Dictionary> { diff --git a/test/EFCore.Specification.Tests/Query/OwnedQueryTestBase.cs b/test/EFCore.Specification.Tests/Query/OwnedQueryTestBase.cs index aa74c8bb318..85eb3469f4d 100644 --- a/test/EFCore.Specification.Tests/Query/OwnedQueryTestBase.cs +++ b/test/EFCore.Specification.Tests/Query/OwnedQueryTestBase.cs @@ -791,17 +791,59 @@ public virtual async Task Filter_on_indexer_using_function_argument(bool async) [ConditionalTheory] [MemberData(nameof(IsAsyncData))] public virtual Task Simple_query_entity_with_owned_collection(bool async) - { - return AssertQuery( + => AssertQuery( async, ss => ss.Set()); - } + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Left_join_on_entity_with_owned_navigations(bool async) + => AssertQuery( + async, + ss => from c1 in ss.Set() + join c2 in ss.Set() on c1.Id equals c2.Id into grouping + from c2 in grouping.DefaultIfEmpty() + select new { c1, c2.Id, c2, c2.Orders, c2.PersonAddress }, + elementSorter: e => (e.c1.Id, e.c2.Id), + elementAsserter: (e, a) => + { + AssertEqual(e.c1, a.c1); + AssertEqual(e.Id, a.Id); + AssertEqual(e.c2, a.c2); + AssertCollection(e.Orders, a.Orders, elementSorter: ee => ee.Id); + AssertEqual(e.PersonAddress, a.PersonAddress); + }); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Left_join_on_entity_with_owned_navigations_complex(bool async) + => AssertQuery( + async, + ss => + from o in ss.Set() + join sub in ( + from c1 in ss.Set() + join c2 in ss.Set() on c1.Id equals c2.Id into grouping + from c2 in grouping.DefaultIfEmpty() + select new { c1, c2.Id, c2 }).Distinct() on o.Id equals sub.Id into grouping2 + from sub in grouping2.DefaultIfEmpty() + select new { o, sub }, + elementSorter: e => (e.o.Id, e.sub.c1.Id, e.sub.Id), + elementAsserter: (e, a) => + { + AssertEqual(e.o, a.o); + AssertEqual(e.sub.Id, a.sub.Id); + AssertEqual(e.sub.c1, a.sub.c1); + AssertEqual(e.sub.c2, a.sub.c2); + }); protected virtual DbContext CreateContext() => Fixture.CreateContext(); public abstract class OwnedQueryFixtureBase : SharedStoreFixtureBase, IQueryFixtureBase { + private OwnedQueryData _expectedData; + private static void AssertAddress(OwnedAddress expectedAddress, OwnedAddress actualAddress) { Assert.Equal(expectedAddress["AddressLine"], actualAddress["AddressLine"]); @@ -841,7 +883,14 @@ public Func GetContextCreator() => () => CreateContext(); public virtual ISetSource GetExpectedData() - => new OwnedQueryData(); + { + if (_expectedData == null) + { + _expectedData = new OwnedQueryData(); + } + + return _expectedData; + } public IReadOnlyDictionary EntitySorters { get; } = new Dictionary> { diff --git a/test/EFCore.SqlServer.FunctionalTests/Migrations/MigrationsSqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Migrations/MigrationsSqlServerTest.cs index dd27b926947..293f0032c15 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Migrations/MigrationsSqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Migrations/MigrationsSqlServerTest.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 Microsoft.EntityFrameworkCore.Metadata.Internal; using Microsoft.EntityFrameworkCore.Scaffolding.Metadata.Internal; using Microsoft.EntityFrameworkCore.SqlServer.Internal; using Microsoft.EntityFrameworkCore.SqlServer.Metadata.Internal; @@ -6949,6 +6950,890 @@ FROM [sys].[default_constraints] [d] EXEC(N'ALTER TABLE [Customers] SET (SYSTEM_VERSIONING = ON (HISTORY_TABLE = [' + @historyTableSchema + '].[HistoryTable]))')"); } + [ConditionalFact] + public virtual async Task Create_table_with_json_column() + { + await Test( + builder => { }, + builder => + { + builder.Entity("Entity", e => + { + e.Property("Id").ValueGeneratedOnAdd(); + e.HasKey("Id"); + e.Property("Name"); + e.OwnsOne("Owned", "OwnedReference", o => + { + o.OwnsOne("Nested", "NestedReference", n => + { + n.Property("Number"); + }); + o.OwnsMany("Nested2", "NestedCollection", n => + { + n.Property("Number2"); + }); + o.Property("Date"); + o.ToJson(); + }); + + e.OwnsMany("Owned2", "OwnedCollection", o => + { + o.OwnsOne("Nested3", "NestedReference2", n => + { + n.Property("Number3"); + }); + o.OwnsMany("Nested4", "NestedCollection2", n => + { + n.Property("Number4"); + }); + o.Property("Date2"); + o.ToJson(); + }); + }); + }, + model => + { + var table = Assert.Single(model.Tables); + Assert.Equal("Entity", table.Name); + + Assert.Collection( + table.Columns, + c => Assert.Equal("Id", c.Name), + c => Assert.Equal("Name", c.Name), + c => + { + Assert.Equal("OwnedCollection", c.Name); + Assert.Equal("nvarchar(max)", c.StoreType); + }, + c => + { + Assert.Equal("OwnedReference", c.Name); + Assert.Equal("nvarchar(max)", c.StoreType); + }); + Assert.Same( + table.Columns.Single(c => c.Name == "Id"), + Assert.Single(table.PrimaryKey!.Columns)); + }); + + AssertSql( + @"CREATE TABLE [Entity] ( + [Id] int NOT NULL IDENTITY, + [Name] nvarchar(max) NULL, + [OwnedCollection] nvarchar(max) NULL, + [OwnedReference] nvarchar(max) NOT NULL, + CONSTRAINT [PK_Entity] PRIMARY KEY ([Id]) +);"); + } + + [ConditionalFact] + public virtual async Task Create_table_with_json_column_explicit_json_column_names() + { + await Test( + builder => { }, + builder => + { + builder.Entity("Entity", e => + { + e.Property("Id").ValueGeneratedOnAdd(); + e.HasKey("Id"); + e.Property("Name"); + e.OwnsOne("Owned", "json_reference", o => + { + o.OwnsOne("Nested", "json_reference", n => + { + n.Property("Number"); + }); + o.OwnsMany("Nested2", "NestedCollection", n => + { + n.Property("Number2"); + }); + o.Property("Date"); + o.ToJson(); + }); + + e.OwnsMany("Owned2", "json_collection", o => + { + o.OwnsOne("Nested3", "NestedReference2", n => + { + n.Property("Number3"); + }); + o.OwnsMany("Nested4", "NestedCollection2", n => + { + n.Property("Number4"); + }); + o.Property("Date2"); + o.ToJson(); + }); + }); + }, + model => + { + var table = Assert.Single(model.Tables); + Assert.Equal("Entity", table.Name); + + Assert.Collection( + table.Columns, + c => Assert.Equal("Id", c.Name), + c => Assert.Equal("Name", c.Name), + c => + { + Assert.Equal("json_collection", c.Name); + Assert.Equal("nvarchar(max)", c.StoreType); + }, + c => + { + Assert.Equal("json_reference", c.Name); + Assert.Equal("nvarchar(max)", c.StoreType); + }); + Assert.Same( + table.Columns.Single(c => c.Name == "Id"), + Assert.Single(table.PrimaryKey!.Columns)); + }); + + AssertSql( + @"CREATE TABLE [Entity] ( + [Id] int NOT NULL IDENTITY, + [Name] nvarchar(max) NULL, + [json_collection] nvarchar(max) NULL, + [json_reference] nvarchar(max) NOT NULL, + CONSTRAINT [PK_Entity] PRIMARY KEY ([Id]) +);"); + } + + [ConditionalFact] + public virtual async Task Add_json_columns_to_existing_table() + { + await Test( + builder => builder.Entity("Entity", e => + { + e.Property("Id").ValueGeneratedOnAdd(); + e.HasKey("Id"); + e.Property("Name"); + }), + builder => + { + builder.Entity("Entity", e => + { + e.Property("Id").ValueGeneratedOnAdd(); + e.HasKey("Id"); + e.Property("Name"); + + e.OwnsOne("Owned", "OwnedReference", o => + { + o.OwnsOne("Nested", "NestedReference", n => + { + n.Property("Number"); + }); + o.OwnsMany("Nested2", "NestedCollection", n => + { + n.Property("Number2"); + }); + o.Property("Date"); + o.ToJson(); + }); + + e.OwnsMany("Owned2", "OwnedCollection", o => + { + o.OwnsOne("Nested3", "NestedReference2", n => + { + n.Property("Number3"); + }); + o.OwnsMany("Nested4", "NestedCollection2", n => + { + n.Property("Number4"); + }); + o.Property("Date2"); + o.ToJson(); + }); + }); + }, + model => + { + var table = Assert.Single(model.Tables); + Assert.Equal("Entity", table.Name); + + Assert.Collection( + table.Columns, + c => Assert.Equal("Id", c.Name), + c => Assert.Equal("Name", c.Name), + c => + { + Assert.Equal("OwnedCollection", c.Name); + Assert.Equal("nvarchar(max)", c.StoreType); + }, + c => + { + Assert.Equal("OwnedReference", c.Name); + Assert.Equal("nvarchar(max)", c.StoreType); + }); + Assert.Same( + table.Columns.Single(c => c.Name == "Id"), + Assert.Single(table.PrimaryKey!.Columns)); + }); + + AssertSql( + @"ALTER TABLE [Entity] ADD [OwnedCollection] nvarchar(max) NULL;", + // + @"ALTER TABLE [Entity] ADD [OwnedReference] nvarchar(max) NOT NULL DEFAULT N'';"); + } + + [ConditionalFact] + public virtual async Task Remove_json_columns_from_existing_table() + { + await Test( + builder => + { + builder.Entity("Entity", e => + { + e.Property("Id").ValueGeneratedOnAdd(); + e.HasKey("Id"); + e.Property("Name"); + e.OwnsOne("Owned", "OwnedReference", o => + { + o.OwnsOne("Nested", "NestedReference", n => + { + n.Property("Number"); + }); + o.OwnsMany("Nested2", "NestedCollection", n => + { + n.Property("Number2"); + }); + o.Property("Date"); + o.ToJson(); + }); + + e.OwnsMany("Owned2", "OwnedCollection", o => + { + o.OwnsOne("Nested3", "NestedReference2", n => + { + n.Property("Number3"); + }); + o.OwnsMany("Nested4", "NestedCollection2", n => + { + n.Property("Number4"); + }); + o.Property("Date2"); + o.ToJson(); + }); + }); + }, + builder => builder.Entity("Entity", e => + { + e.Property("Id").ValueGeneratedOnAdd(); + e.HasKey("Id"); + e.Property("Name"); + }), + model => + { + var table = Assert.Single(model.Tables); + Assert.Equal("Entity", table.Name); + + Assert.Collection( + table.Columns, + c => Assert.Equal("Id", c.Name), + c => Assert.Equal("Name", c.Name)); + Assert.Same( + table.Columns.Single(c => c.Name == "Id"), + Assert.Single(table.PrimaryKey!.Columns)); + }); + + AssertSql( + @"DECLARE @var0 sysname; +SELECT @var0 = [d].[name] +FROM [sys].[default_constraints] [d] +INNER JOIN [sys].[columns] [c] ON [d].[parent_column_id] = [c].[column_id] AND [d].[parent_object_id] = [c].[object_id] +WHERE ([d].[parent_object_id] = OBJECT_ID(N'[Entity]') AND [c].[name] = N'OwnedCollection'); +IF @var0 IS NOT NULL EXEC(N'ALTER TABLE [Entity] DROP CONSTRAINT [' + @var0 + '];'); +ALTER TABLE [Entity] DROP COLUMN [OwnedCollection];", + // + @"DECLARE @var1 sysname; +SELECT @var1 = [d].[name] +FROM [sys].[default_constraints] [d] +INNER JOIN [sys].[columns] [c] ON [d].[parent_column_id] = [c].[column_id] AND [d].[parent_object_id] = [c].[object_id] +WHERE ([d].[parent_object_id] = OBJECT_ID(N'[Entity]') AND [c].[name] = N'OwnedReference'); +IF @var1 IS NOT NULL EXEC(N'ALTER TABLE [Entity] DROP CONSTRAINT [' + @var1 + '];'); +ALTER TABLE [Entity] DROP COLUMN [OwnedReference];"); + } + + [ConditionalFact] + public virtual async Task Rename_json_column() + { + await Test( + builder => + { + builder.Entity("Entity", e => + { + e.Property("Id").ValueGeneratedOnAdd(); + e.HasKey("Id"); + e.Property("Name"); + + e.OwnsOne("Owned", "OwnedReference", o => + { + o.OwnsOne("Nested", "NestedReference", n => + { + n.Property("Number"); + }); + o.OwnsMany("Nested2", "NestedCollection", n => + { + n.Property("Number2"); + }); + o.Property("Date"); + o.ToJson("json_reference"); + }); + + e.OwnsMany("Owned2", "OwnedCollection", o => + { + o.OwnsOne("Nested3", "NestedReference2", n => + { + n.Property("Number3"); + }); + o.OwnsMany("Nested4", "NestedCollection2", n => + { + n.Property("Number4"); + }); + o.Property("Date2"); + o.ToJson("json_collection"); + }); + }); + }, + builder => + { + builder.Entity("Entity", e => + { + e.Property("Id").ValueGeneratedOnAdd(); + e.HasKey("Id"); + e.Property("Name"); + + e.OwnsOne("Owned", "OwnedReference", o => + { + o.OwnsOne("Nested", "NestedReference", n => + { + n.Property("Number"); + }); + o.OwnsMany("Nested2", "NestedCollection", n => + { + n.Property("Number2"); + }); + o.Property("Date"); + o.ToJson("new_json_reference"); + }); + + e.OwnsMany("Owned2", "OwnedCollection", o => + { + o.OwnsOne("Nested3", "NestedReference2", n => + { + n.Property("Number3"); + }); + o.OwnsMany("Nested4", "NestedCollection2", n => + { + n.Property("Number4"); + }); + o.Property("Date2"); + o.ToJson("new_json_collection"); + }); + }); + }, + model => + { + var table = Assert.Single(model.Tables); + Assert.Equal("Entity", table.Name); + + Assert.Collection( + table.Columns, + c => Assert.Equal("Id", c.Name), + c => Assert.Equal("Name", c.Name), + c => + { + Assert.Equal("new_json_collection", c.Name); + Assert.Equal("nvarchar(max)", c.StoreType); + }, + c => + { + Assert.Equal("new_json_reference", c.Name); + Assert.Equal("nvarchar(max)", c.StoreType); + }); + Assert.Same( + table.Columns.Single(c => c.Name == "Id"), + Assert.Single(table.PrimaryKey!.Columns)); + }); + + AssertSql( + @"EXEC sp_rename N'[Entity].[json_reference]', N'new_json_reference', N'COLUMN';", + // + @"EXEC sp_rename N'[Entity].[json_collection]', N'new_json_collection', N'COLUMN';"); + } + + [ConditionalFact] + public virtual async Task Rename_table_with_json_column() + { + await Test( + builder => + { + builder.Entity("Entity", e => + { + e.Property("Id").ValueGeneratedOnAdd(); + e.HasKey("Id"); + e.Property("Name"); + e.ToTable("Entities"); + + e.OwnsOne("Owned", "OwnedReference", o => + { + o.OwnsOne("Nested", "NestedReference", n => + { + n.Property("Number"); + }); + o.OwnsMany("Nested2", "NestedCollection", n => + { + n.Property("Number2"); + }); + o.Property("Date"); + o.ToJson(); + }); + + e.OwnsMany("Owned2", "OwnedCollection", o => + { + o.OwnsOne("Nested3", "NestedReference2", n => + { + n.Property("Number3"); + }); + o.OwnsMany("Nested4", "NestedCollection2", n => + { + n.Property("Number4"); + }); + o.Property("Date2"); + o.ToJson(); + }); + }); + }, + builder => + { + builder.Entity("Entity", e => + { + e.Property("Id").ValueGeneratedOnAdd(); + e.HasKey("Id"); + e.Property("Name"); + e.ToTable("NewEntities"); + + e.OwnsOne("Owned", "OwnedReference", o => + { + o.OwnsOne("Nested", "NestedReference", n => + { + n.Property("Number"); + }); + o.OwnsMany("Nested2", "NestedCollection", n => + { + n.Property("Number2"); + }); + o.Property("Date"); + o.ToJson(); + }); + + e.OwnsMany("Owned2", "OwnedCollection", o => + { + o.OwnsOne("Nested3", "NestedReference2", n => + { + n.Property("Number3"); + }); + o.OwnsMany("Nested4", "NestedCollection2", n => + { + n.Property("Number4"); + }); + o.Property("Date2"); + o.ToJson(); + }); + }); + }, + model => + { + var table = Assert.Single(model.Tables); + Assert.Equal("NewEntities", table.Name); + + Assert.Collection( + table.Columns, + c => Assert.Equal("Id", c.Name), + c => Assert.Equal("Name", c.Name), + c => + { + Assert.Equal("OwnedCollection", c.Name); + Assert.Equal("nvarchar(max)", c.StoreType); + }, + c => + { + Assert.Equal("OwnedReference", c.Name); + Assert.Equal("nvarchar(max)", c.StoreType); + }); + Assert.Same( + table.Columns.Single(c => c.Name == "Id"), + Assert.Single(table.PrimaryKey!.Columns)); + }); + + AssertSql( + @"ALTER TABLE [Entities] DROP CONSTRAINT [PK_Entities];", + // + @"EXEC sp_rename N'[Entities]', N'NewEntities';", + // + @"ALTER TABLE [NewEntities] ADD CONSTRAINT [PK_NewEntities] PRIMARY KEY ([Id]);"); + } + + [ConditionalFact] + public virtual async Task Convert_regular_owned_entities_to_json() + { + await Test( + builder => + { + builder.Entity("Entity", e => + { + e.Property("Id").ValueGeneratedOnAdd(); + e.HasKey("Id"); + e.Property("Name"); + + e.OwnsOne("Owned", "OwnedReference", o => + { + o.OwnsOne("Nested", "NestedReference", n => + { + n.Property("Number"); + }); + o.OwnsMany("Nested2", "NestedCollection", n => + { + n.Property("Number2"); + }); + o.Property("Date"); + }); + + e.OwnsMany("Owned2", "OwnedCollection", o => + { + o.OwnsOne("Nested3", "NestedReference2", n => + { + n.Property("Number3"); + }); + o.OwnsMany("Nested4", "NestedCollection2", n => + { + n.Property("Number4"); + }); + o.Property("Date2"); + }); + }); + }, + builder => + { + builder.Entity("Entity", e => + { + e.Property("Id").ValueGeneratedOnAdd(); + e.HasKey("Id"); + e.Property("Name"); + + e.OwnsOne("Owned", "OwnedReference", o => + { + o.OwnsOne("Nested", "NestedReference", n => + { + n.Property("Number"); + }); + o.OwnsMany("Nested2", "NestedCollection", n => + { + n.Property("Number2"); + }); + o.Property("Date"); + o.ToJson(); + }); + + e.OwnsMany("Owned2", "OwnedCollection", o => + { + o.OwnsOne("Nested3", "NestedReference2", n => + { + n.Property("Number3"); + }); + o.OwnsMany("Nested4", "NestedCollection2", n => + { + n.Property("Number4"); + }); + o.Property("Date2"); + o.ToJson(); + }); + }); + }, + model => + { + var table = Assert.Single(model.Tables); + Assert.Equal("Entity", table.Name); + + Assert.Collection( + table.Columns, + c => Assert.Equal("Id", c.Name), + c => Assert.Equal("Name", c.Name), + c => + { + Assert.Equal("OwnedCollection", c.Name); + Assert.Equal("nvarchar(max)", c.StoreType); + }, + c => + { + Assert.Equal("OwnedReference", c.Name); + Assert.Equal("nvarchar(max)", c.StoreType); + }); + Assert.Same( + table.Columns.Single(c => c.Name == "Id"), + Assert.Single(table.PrimaryKey!.Columns)); + }); + + AssertSql( + @"DROP TABLE [Entity_NestedCollection];", + // + @"DROP TABLE [Entity_OwnedCollection_NestedCollection2];", + // + @"DROP TABLE [Entity_OwnedCollection];", + // + @"DECLARE @var0 sysname; +SELECT @var0 = [d].[name] +FROM [sys].[default_constraints] [d] +INNER JOIN [sys].[columns] [c] ON [d].[parent_column_id] = [c].[column_id] AND [d].[parent_object_id] = [c].[object_id] +WHERE ([d].[parent_object_id] = OBJECT_ID(N'[Entity]') AND [c].[name] = N'OwnedReference_Date'); +IF @var0 IS NOT NULL EXEC(N'ALTER TABLE [Entity] DROP CONSTRAINT [' + @var0 + '];'); +ALTER TABLE [Entity] DROP COLUMN [OwnedReference_Date];", + // + @"DECLARE @var1 sysname; +SELECT @var1 = [d].[name] +FROM [sys].[default_constraints] [d] +INNER JOIN [sys].[columns] [c] ON [d].[parent_column_id] = [c].[column_id] AND [d].[parent_object_id] = [c].[object_id] +WHERE ([d].[parent_object_id] = OBJECT_ID(N'[Entity]') AND [c].[name] = N'OwnedReference_NestedReference_Number'); +IF @var1 IS NOT NULL EXEC(N'ALTER TABLE [Entity] DROP CONSTRAINT [' + @var1 + '];'); +ALTER TABLE [Entity] DROP COLUMN [OwnedReference_NestedReference_Number];", + // + @"ALTER TABLE [Entity] ADD [OwnedCollection] nvarchar(max) NULL;", + // + @"ALTER TABLE [Entity] ADD [OwnedReference] nvarchar(max) NOT NULL DEFAULT N'';"); + } + + [ConditionalFact] + public virtual async Task Convert_json_entities_to_regular_owned() + { + await Test( + builder => + { + builder.Entity("Entity", e => + { + e.Property("Id").ValueGeneratedOnAdd(); + e.HasKey("Id"); + e.Property("Name"); + + e.OwnsOne("Owned", "OwnedReference", o => + { + o.OwnsOne("Nested", "NestedReference", n => + { + n.Property("Number"); + }); + o.OwnsMany("Nested2", "NestedCollection", n => + { + n.Property("Number2"); + }); + o.Property("Date"); + o.ToJson(); + }); + + e.OwnsMany("Owned2", "OwnedCollection", o => + { + o.OwnsOne("Nested3", "NestedReference2", n => + { + n.Property("Number3"); + }); + o.OwnsMany("Nested4", "NestedCollection2", n => + { + n.Property("Number4"); + }); + o.Property("Date2"); + o.ToJson(); + }); + }); + }, + builder => + { + builder.Entity("Entity", e => + { + e.Property("Id").ValueGeneratedOnAdd(); + e.HasKey("Id"); + e.Property("Name"); + + e.OwnsOne("Owned", "OwnedReference", o => + { + o.OwnsOne("Nested", "NestedReference", n => + { + n.Property("Number"); + }); + o.OwnsMany("Nested2", "NestedCollection", n => + { + n.Property("Number2"); + }); + o.Property("Date"); + }); + + e.OwnsMany("Owned2", "OwnedCollection", o => + { + o.OwnsOne("Nested3", "NestedReference2", n => + { + n.Property("Number3"); + }); + o.OwnsMany("Nested4", "NestedCollection2", n => + { + n.Property("Number4"); + }); + o.Property("Date2"); + }); + }); + }, + model => + { + Assert.Equal(4, model.Tables.Count()); + }); + + AssertSql( + @"DECLARE @var0 sysname; +SELECT @var0 = [d].[name] +FROM [sys].[default_constraints] [d] +INNER JOIN [sys].[columns] [c] ON [d].[parent_column_id] = [c].[column_id] AND [d].[parent_object_id] = [c].[object_id] +WHERE ([d].[parent_object_id] = OBJECT_ID(N'[Entity]') AND [c].[name] = N'OwnedCollection'); +IF @var0 IS NOT NULL EXEC(N'ALTER TABLE [Entity] DROP CONSTRAINT [' + @var0 + '];'); +ALTER TABLE [Entity] DROP COLUMN [OwnedCollection];", + // + @"DECLARE @var1 sysname; +SELECT @var1 = [d].[name] +FROM [sys].[default_constraints] [d] +INNER JOIN [sys].[columns] [c] ON [d].[parent_column_id] = [c].[column_id] AND [d].[parent_object_id] = [c].[object_id] +WHERE ([d].[parent_object_id] = OBJECT_ID(N'[Entity]') AND [c].[name] = N'OwnedReference'); +IF @var1 IS NOT NULL EXEC(N'ALTER TABLE [Entity] DROP CONSTRAINT [' + @var1 + '];'); +ALTER TABLE [Entity] DROP COLUMN [OwnedReference];", + // + @"ALTER TABLE [Entity] ADD [OwnedReference_Date] datetime2 NULL;", + // + @"ALTER TABLE [Entity] ADD [OwnedReference_NestedReference_Number] int NULL;", + // + @"CREATE TABLE [Entity_NestedCollection] ( + [OwnedEntityId] int NOT NULL, + [Id] int NOT NULL IDENTITY, + [Number2] int NOT NULL, + CONSTRAINT [PK_Entity_NestedCollection] PRIMARY KEY ([OwnedEntityId], [Id]), + CONSTRAINT [FK_Entity_NestedCollection_Entity_OwnedEntityId] FOREIGN KEY ([OwnedEntityId]) REFERENCES [Entity] ([Id]) ON DELETE CASCADE +);", + // + @"CREATE TABLE [Entity_OwnedCollection] ( + [EntityId] int NOT NULL, + [Id] int NOT NULL IDENTITY, + [NestedReference2_Number3] int NULL, + [Date2] datetime2 NOT NULL, + CONSTRAINT [PK_Entity_OwnedCollection] PRIMARY KEY ([EntityId], [Id]), + CONSTRAINT [FK_Entity_OwnedCollection_Entity_EntityId] FOREIGN KEY ([EntityId]) REFERENCES [Entity] ([Id]) ON DELETE CASCADE +);", + // + @"CREATE TABLE [Entity_OwnedCollection_NestedCollection2] ( + [Owned2EntityId] int NOT NULL, + [Owned2Id] int NOT NULL, + [Id] int NOT NULL IDENTITY, + [Number4] int NOT NULL, + CONSTRAINT [PK_Entity_OwnedCollection_NestedCollection2] PRIMARY KEY ([Owned2EntityId], [Owned2Id], [Id]), + CONSTRAINT [FK_Entity_OwnedCollection_NestedCollection2_Entity_OwnedCollection_Owned2EntityId_Owned2Id] FOREIGN KEY ([Owned2EntityId], [Owned2Id]) REFERENCES [Entity_OwnedCollection] ([EntityId], [Id]) ON DELETE CASCADE +);"); + } + + [ConditionalFact] + public virtual async Task Convert_string_column_to_a_json_column_containing_reference() + { + await Test( + builder => + { + builder.Entity("Entity", e => + { + e.Property("Id").ValueGeneratedOnAdd(); + e.HasKey("Id"); + e.Property("Name"); + }); + }, + builder => + { + builder.Entity("Entity", e => + { + e.Property("Id").ValueGeneratedOnAdd(); + e.HasKey("Id"); + + e.OwnsOne("Owned", "OwnedReference", o => + { + o.ToJson("Name"); + o.OwnsOne("Nested", "NestedReference", n => + { + n.Property("Number"); + }); + o.OwnsMany("Nested2", "NestedCollection", n => + { + n.Property("Number2"); + }); + o.Property("Date"); + }); + }); + }, + model => + { + var table = model.Tables.Single(); + Assert.Collection( + table.Columns, + c => Assert.Equal("Id", c.Name), + c => Assert.Equal("Name", c.Name)); + }); + + AssertSql( + @"DECLARE @var0 sysname; +SELECT @var0 = [d].[name] +FROM [sys].[default_constraints] [d] +INNER JOIN [sys].[columns] [c] ON [d].[parent_column_id] = [c].[column_id] AND [d].[parent_object_id] = [c].[object_id] +WHERE ([d].[parent_object_id] = OBJECT_ID(N'[Entity]') AND [c].[name] = N'Name'); +IF @var0 IS NOT NULL EXEC(N'ALTER TABLE [Entity] DROP CONSTRAINT [' + @var0 + '];'); +ALTER TABLE [Entity] ALTER COLUMN [Name] nvarchar(max) NOT NULL; +ALTER TABLE [Entity] ADD DEFAULT N'' FOR [Name];"); + } + + [ConditionalFact] + public virtual async Task Convert_string_column_to_a_json_column_containing_collection() + { + await Test( + builder => + { + builder.Entity("Entity", e => + { + e.Property("Id").ValueGeneratedOnAdd(); + e.HasKey("Id"); + e.Property("Name"); + }); + }, + builder => + { + builder.Entity("Entity", e => + { + e.Property("Id").ValueGeneratedOnAdd(); + e.HasKey("Id"); + + e.OwnsMany("Owned2", "OwnedCollection", o => + { + o.OwnsOne("Nested3", "NestedReference2", n => + { + n.Property("Number3"); + }); + o.OwnsMany("Nested4", "NestedCollection2", n => + { + n.Property("Number4"); + }); + o.Property("Date2"); + o.ToJson("Name"); + }); + }); + }, + model => + { + var table = model.Tables.Single(); + Assert.Collection( + table.Columns, + c => Assert.Equal("Id", c.Name), + c => Assert.Equal("Name", c.Name)); + }); + + AssertSql(); + } + protected override string NonDefaultCollation => _nonDefaultCollation ??= GetDatabaseCollation() == "German_PhoneBook_CI_AS" ? "French_CI_AS" diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/OwnedQuerySqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Query/OwnedQuerySqlServerTest.cs index 9920b7a3cf4..01c5a88eee6 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Query/OwnedQuerySqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Query/OwnedQuerySqlServerTest.cs @@ -1208,6 +1208,58 @@ FROM [Order] AS [o0] ORDER BY [o].[Id], [t].[ClientId], [t].[Id], [t].[OrderClientId], [t].[OrderId]"); } + public override async Task Simple_query_entity_with_owned_collection(bool async) + { + await base.Simple_query_entity_with_owned_collection(async); + + AssertSql( + @"SELECT [s].[Id], [s].[Name], [e].[Id], [e].[Name], [e].[StarId] +FROM [Star] AS [s] +LEFT JOIN [Element] AS [e] ON [s].[Id] = [e].[StarId] +ORDER BY [s].[Id]"); + } + + public override async Task Left_join_on_entity_with_owned_navigations(bool async) + { + await base.Left_join_on_entity_with_owned_navigations(async); + + AssertSql( + @"SELECT [p].[Id], [p].[Name], [p].[StarId], [o].[Id], [o].[Discriminator], [o].[Name], [t].[ClientId], [t].[Id], [t].[OrderDate], [t].[OrderClientId], [t].[OrderId], [t].[Id0], [t].[Detail], [o].[PersonAddress_AddressLine], [o].[PersonAddress_PlaceType], [o].[PersonAddress_ZipCode], [o].[PersonAddress_Country_Name], [o].[PersonAddress_Country_PlanetId], [o].[BranchAddress_BranchName], [o].[BranchAddress_PlaceType], [o].[BranchAddress_Country_Name], [o].[BranchAddress_Country_PlanetId], [o].[LeafBAddress_LeafBType], [o].[LeafBAddress_PlaceType], [o].[LeafBAddress_Country_Name], [o].[LeafBAddress_Country_PlanetId], [o].[LeafAAddress_LeafType], [o].[LeafAAddress_PlaceType], [o].[LeafAAddress_Country_Name], [o].[LeafAAddress_Country_PlanetId], [t0].[ClientId], [t0].[Id], [t0].[OrderDate], [t0].[OrderClientId], [t0].[OrderId], [t0].[Id0], [t0].[Detail] +FROM [Planet] AS [p] +LEFT JOIN [OwnedPerson] AS [o] ON [p].[Id] = [o].[Id] +LEFT JOIN ( + SELECT [o0].[ClientId], [o0].[Id], [o0].[OrderDate], [o1].[OrderClientId], [o1].[OrderId], [o1].[Id] AS [Id0], [o1].[Detail] + FROM [Order] AS [o0] + LEFT JOIN [OrderDetail] AS [o1] ON [o0].[ClientId] = [o1].[OrderClientId] AND [o0].[Id] = [o1].[OrderId] +) AS [t] ON [o].[Id] = [t].[ClientId] +LEFT JOIN ( + SELECT [o2].[ClientId], [o2].[Id], [o2].[OrderDate], [o3].[OrderClientId], [o3].[OrderId], [o3].[Id] AS [Id0], [o3].[Detail] + FROM [Order] AS [o2] + LEFT JOIN [OrderDetail] AS [o3] ON [o2].[ClientId] = [o3].[OrderClientId] AND [o2].[Id] = [o3].[OrderId] +) AS [t0] ON [o].[Id] = [t0].[ClientId] +ORDER BY [p].[Id], [o].[Id], [t].[ClientId], [t].[Id], [t].[OrderClientId], [t].[OrderId], [t].[Id0], [t0].[ClientId], [t0].[Id], [t0].[OrderClientId], [t0].[OrderId]"); + } + + public override async Task Left_join_on_entity_with_owned_navigations_complex(bool async) + { + await base.Left_join_on_entity_with_owned_navigations_complex(async); + + AssertSql( + @"SELECT [p].[Id], [p].[Name], [p].[StarId], [t].[Id], [t].[Name], [t].[StarId], [t].[Id0], [t].[Discriminator], [t].[Name0], [t0].[ClientId], [t0].[Id], [t0].[OrderDate], [t0].[OrderClientId], [t0].[OrderId], [t0].[Id0], [t0].[Detail], [t].[PersonAddress_AddressLine], [t].[PersonAddress_PlaceType], [t].[PersonAddress_ZipCode], [t].[PersonAddress_Country_Name], [t].[PersonAddress_Country_PlanetId], [t].[BranchAddress_BranchName], [t].[BranchAddress_PlaceType], [t].[BranchAddress_Country_Name], [t].[BranchAddress_Country_PlanetId], [t].[LeafBAddress_LeafBType], [t].[LeafBAddress_PlaceType], [t].[LeafBAddress_Country_Name], [t].[LeafBAddress_Country_PlanetId], [t].[LeafAAddress_LeafType], [t].[LeafAAddress_PlaceType], [t].[LeafAAddress_Country_Name], [t].[LeafAAddress_Country_PlanetId] +FROM [Planet] AS [p] +LEFT JOIN ( + SELECT DISTINCT [p0].[Id], [p0].[Name], [p0].[StarId], [o].[Id] AS [Id0], [o].[Discriminator], [o].[Name] AS [Name0], [o].[PersonAddress_AddressLine], [o].[PersonAddress_PlaceType], [o].[PersonAddress_ZipCode], [o].[PersonAddress_Country_Name], [o].[PersonAddress_Country_PlanetId], [o].[BranchAddress_BranchName], [o].[BranchAddress_PlaceType], [o].[BranchAddress_Country_Name], [o].[BranchAddress_Country_PlanetId], [o].[LeafBAddress_LeafBType], [o].[LeafBAddress_PlaceType], [o].[LeafBAddress_Country_Name], [o].[LeafBAddress_Country_PlanetId], [o].[LeafAAddress_LeafType], [o].[LeafAAddress_PlaceType], [o].[LeafAAddress_Country_Name], [o].[LeafAAddress_Country_PlanetId] + FROM [Planet] AS [p0] + LEFT JOIN [OwnedPerson] AS [o] ON [p0].[Id] = [o].[Id] +) AS [t] ON [p].[Id] = [t].[Id0] +LEFT JOIN ( + SELECT [o0].[ClientId], [o0].[Id], [o0].[OrderDate], [o1].[OrderClientId], [o1].[OrderId], [o1].[Id] AS [Id0], [o1].[Detail] + FROM [Order] AS [o0] + LEFT JOIN [OrderDetail] AS [o1] ON [o0].[ClientId] = [o1].[OrderClientId] AND [o0].[Id] = [o1].[OrderId] +) AS [t0] ON [t].[Id0] = [t0].[ClientId] +ORDER BY [p].[Id], [t].[Id], [t].[Id0], [t0].[ClientId], [t0].[Id], [t0].[OrderClientId], [t0].[OrderId]"); + } + private void AssertSql(params string[] expected) => Fixture.TestSqlLoggerFactory.AssertBaseline(expected); diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/TemporalOwnedQuerySqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Query/TemporalOwnedQuerySqlServerTest.cs index 5c66998047a..6a073bfb66f 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Query/TemporalOwnedQuerySqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Query/TemporalOwnedQuerySqlServerTest.cs @@ -1207,6 +1207,47 @@ public override async Task Simple_query_entity_with_owned_collection(bool async) ORDER BY [s].[Id]"); } + public override async Task Left_join_on_entity_with_owned_navigations(bool async) + { + await base.Left_join_on_entity_with_owned_navigations(async); + + AssertSql( + @"SELECT [p].[Id], [p].[Name], [p].[PeriodEnd], [p].[PeriodStart], [p].[StarId], [o].[Id], [o].[Discriminator], [o].[Name], [o].[PeriodEnd], [o].[PeriodStart], [t].[ClientId], [t].[Id], [t].[OrderDate], [t].[PeriodEnd], [t].[PeriodStart], [t].[OrderClientId], [t].[OrderId], [t].[Id0], [t].[Detail], [t].[PeriodEnd0], [t].[PeriodStart0], [o].[PersonAddress_AddressLine], [o].[PersonAddress_PlaceType], [o].[PersonAddress_ZipCode], [o].[PersonAddress_Country_Name], [o].[PersonAddress_Country_PlanetId], [o].[BranchAddress_BranchName], [o].[BranchAddress_PlaceType], [o].[BranchAddress_Country_Name], [o].[BranchAddress_Country_PlanetId], [o].[LeafBAddress_LeafBType], [o].[LeafBAddress_PlaceType], [o].[LeafBAddress_Country_Name], [o].[LeafBAddress_Country_PlanetId], [o].[LeafAAddress_LeafType], [o].[LeafAAddress_PlaceType], [o].[LeafAAddress_Country_Name], [o].[LeafAAddress_Country_PlanetId], [t0].[ClientId], [t0].[Id], [t0].[OrderDate], [t0].[PeriodEnd], [t0].[PeriodStart], [t0].[OrderClientId], [t0].[OrderId], [t0].[Id0], [t0].[Detail], [t0].[PeriodEnd0], [t0].[PeriodStart0] +FROM [Planet] FOR SYSTEM_TIME AS OF '2010-01-01T00:00:00.0000000' AS [p] +LEFT JOIN [OwnedPerson] FOR SYSTEM_TIME AS OF '2010-01-01T00:00:00.0000000' AS [o] ON [p].[Id] = [o].[Id] +LEFT JOIN ( + SELECT [o0].[ClientId], [o0].[Id], [o0].[OrderDate], [o0].[PeriodEnd], [o0].[PeriodStart], [o1].[OrderClientId], [o1].[OrderId], [o1].[Id] AS [Id0], [o1].[Detail], [o1].[PeriodEnd] AS [PeriodEnd0], [o1].[PeriodStart] AS [PeriodStart0] + FROM [Order] FOR SYSTEM_TIME AS OF '2010-01-01T00:00:00.0000000' AS [o0] + LEFT JOIN [OrderDetail] FOR SYSTEM_TIME AS OF '2010-01-01T00:00:00.0000000' AS [o1] ON [o0].[ClientId] = [o1].[OrderClientId] AND [o0].[Id] = [o1].[OrderId] +) AS [t] ON [o].[Id] = [t].[ClientId] +LEFT JOIN ( + SELECT [o2].[ClientId], [o2].[Id], [o2].[OrderDate], [o2].[PeriodEnd], [o2].[PeriodStart], [o3].[OrderClientId], [o3].[OrderId], [o3].[Id] AS [Id0], [o3].[Detail], [o3].[PeriodEnd] AS [PeriodEnd0], [o3].[PeriodStart] AS [PeriodStart0] + FROM [Order] FOR SYSTEM_TIME AS OF '2010-01-01T00:00:00.0000000' AS [o2] + LEFT JOIN [OrderDetail] FOR SYSTEM_TIME AS OF '2010-01-01T00:00:00.0000000' AS [o3] ON [o2].[ClientId] = [o3].[OrderClientId] AND [o2].[Id] = [o3].[OrderId] +) AS [t0] ON [o].[Id] = [t0].[ClientId] +ORDER BY [p].[Id], [o].[Id], [t].[ClientId], [t].[Id], [t].[OrderClientId], [t].[OrderId], [t].[Id0], [t0].[ClientId], [t0].[Id], [t0].[OrderClientId], [t0].[OrderId]"); + } + + public override async Task Left_join_on_entity_with_owned_navigations_complex(bool async) + { + await base.Left_join_on_entity_with_owned_navigations_complex(async); + + AssertSql( + @"SELECT [p].[Id], [p].[Name], [p].[PeriodEnd], [p].[PeriodStart], [p].[StarId], [t].[Id], [t].[Name], [t].[PeriodEnd], [t].[PeriodStart], [t].[StarId], [t].[Id0], [t].[Discriminator], [t].[Name0], [t].[PeriodEnd0], [t].[PeriodStart0], [t0].[ClientId], [t0].[Id], [t0].[OrderDate], [t0].[PeriodEnd], [t0].[PeriodStart], [t0].[OrderClientId], [t0].[OrderId], [t0].[Id0], [t0].[Detail], [t0].[PeriodEnd0], [t0].[PeriodStart0], [t].[PersonAddress_AddressLine], [t].[PersonAddress_PlaceType], [t].[PersonAddress_ZipCode], [t].[PersonAddress_Country_Name], [t].[PersonAddress_Country_PlanetId], [t].[BranchAddress_BranchName], [t].[BranchAddress_PlaceType], [t].[BranchAddress_Country_Name], [t].[BranchAddress_Country_PlanetId], [t].[LeafBAddress_LeafBType], [t].[LeafBAddress_PlaceType], [t].[LeafBAddress_Country_Name], [t].[LeafBAddress_Country_PlanetId], [t].[LeafAAddress_LeafType], [t].[LeafAAddress_PlaceType], [t].[LeafAAddress_Country_Name], [t].[LeafAAddress_Country_PlanetId] +FROM [Planet] FOR SYSTEM_TIME AS OF '2010-01-01T00:00:00.0000000' AS [p] +LEFT JOIN ( + SELECT DISTINCT [p0].[Id], [p0].[Name], [p0].[PeriodEnd], [p0].[PeriodStart], [p0].[StarId], [o].[Id] AS [Id0], [o].[Discriminator], [o].[Name] AS [Name0], [o].[PeriodEnd] AS [PeriodEnd0], [o].[PeriodStart] AS [PeriodStart0], [o].[PersonAddress_AddressLine], [o].[PersonAddress_PlaceType], [o].[PersonAddress_ZipCode], [o].[PersonAddress_Country_Name], [o].[PersonAddress_Country_PlanetId], [o].[BranchAddress_BranchName], [o].[BranchAddress_PlaceType], [o].[BranchAddress_Country_Name], [o].[BranchAddress_Country_PlanetId], [o].[LeafBAddress_LeafBType], [o].[LeafBAddress_PlaceType], [o].[LeafBAddress_Country_Name], [o].[LeafBAddress_Country_PlanetId], [o].[LeafAAddress_LeafType], [o].[LeafAAddress_PlaceType], [o].[LeafAAddress_Country_Name], [o].[LeafAAddress_Country_PlanetId] + FROM [Planet] FOR SYSTEM_TIME AS OF '2010-01-01T00:00:00.0000000' AS [p0] + LEFT JOIN [OwnedPerson] FOR SYSTEM_TIME AS OF '2010-01-01T00:00:00.0000000' AS [o] ON [p0].[Id] = [o].[Id] +) AS [t] ON [p].[Id] = [t].[Id0] +LEFT JOIN ( + SELECT [o0].[ClientId], [o0].[Id], [o0].[OrderDate], [o0].[PeriodEnd], [o0].[PeriodStart], [o1].[OrderClientId], [o1].[OrderId], [o1].[Id] AS [Id0], [o1].[Detail], [o1].[PeriodEnd] AS [PeriodEnd0], [o1].[PeriodStart] AS [PeriodStart0] + FROM [Order] FOR SYSTEM_TIME AS OF '2010-01-01T00:00:00.0000000' AS [o0] + LEFT JOIN [OrderDetail] FOR SYSTEM_TIME AS OF '2010-01-01T00:00:00.0000000' AS [o1] ON [o0].[ClientId] = [o1].[OrderClientId] AND [o0].[Id] = [o1].[OrderId] +) AS [t0] ON [t].[Id0] = [t0].[ClientId] +ORDER BY [p].[Id], [t].[Id], [t].[Id0], [t0].[ClientId], [t0].[Id], [t0].[OrderClientId], [t0].[OrderId]"); + } + // not AssertQuery so original (non-temporal) query gets executed, but data is modified // so results don't match expectations public override Task Owned_entity_without_owner_does_not_throw_for_identity_resolution(bool async, bool useAsTracking) diff --git a/test/EFCore.SqlServer.Tests/ModelBuilding/SqlServerModelBuilderTestBase.cs b/test/EFCore.SqlServer.Tests/ModelBuilding/SqlServerModelBuilderTestBase.cs index a82c70686c3..2bab0ba44e1 100644 --- a/test/EFCore.SqlServer.Tests/ModelBuilding/SqlServerModelBuilderTestBase.cs +++ b/test/EFCore.SqlServer.Tests/ModelBuilding/SqlServerModelBuilderTestBase.cs @@ -4,6 +4,8 @@ #nullable enable // ReSharper disable InconsistentNaming +using static Microsoft.EntityFrameworkCore.ChangeTracking.MemberEntryTest; + namespace Microsoft.EntityFrameworkCore.ModelBuilding; public class SqlServerModelBuilderTestBase : RelationalModelBuilderTest @@ -1419,6 +1421,442 @@ public virtual void Implicit_many_to_many_converted_from_non_temporal_to_tempora Assert.True(joinEntity.IsTemporal()); } + [ConditionalFact] + public virtual void Json_entity_and_normal_owned_can_exist_side_by_side_on_same_entity() + { + var modelBuilder = CreateModelBuilder(); + + modelBuilder.Entity(b => + { + b.OwnsOne(x => x.OwnedReference1); + b.OwnsOne(x => x.OwnedReference2, bb => bb.ToJson("reference")); + b.OwnsMany(x => x.OwnedCollection1); + b.OwnsMany(x => x.OwnedCollection2, bb => bb.ToJson("collection")); + }); + + var model = modelBuilder.FinalizeModel(); + var owner = model.FindEntityType(typeof(JsonEntity))!; + Assert.False(owner.IsMappedToJson()); + Assert.True(owner.GetDeclaredProperties().All(x => x.GetJsonPropertyName() == null)); + + var ownedEntities = model.FindEntityTypes(typeof(OwnedEntity)); + Assert.Equal(4, ownedEntities.Count()); + Assert.Equal(2, ownedEntities.Where(e => e.IsMappedToJson()).Count()); + Assert.Equal(2, ownedEntities.Where(e => e.IsOwned() && !e.IsMappedToJson()).Count()); + var reference = ownedEntities.Where(e => e.GetJsonColumnName() == "reference").Single(); + Assert.Equal("Date", reference.GetProperty("Date").GetJsonPropertyName()); + Assert.Equal("Fraction", reference.GetProperty("Fraction").GetJsonPropertyName()); + Assert.Equal("Enum", reference.GetProperty("Enum").GetJsonPropertyName()); + + var collection = ownedEntities.Where(e => e.GetJsonColumnName() == "collection").Single(); + Assert.Equal("Date", collection.GetProperty("Date").GetJsonPropertyName()); + Assert.Equal("Fraction", collection.GetProperty("Fraction").GetJsonPropertyName()); + Assert.Equal("Enum", collection.GetProperty("Enum").GetJsonPropertyName()); + + var nonJson = ownedEntities.Where(e => !e.IsMappedToJson()).ToList(); + Assert.True(nonJson.All(x => x.GetProperty("Date").GetJsonPropertyName() == null)); + Assert.True(nonJson.All(x => x.GetProperty("Fraction").GetJsonPropertyName() == null)); + Assert.True(nonJson.All(x => x.GetProperty("Enum").GetJsonPropertyName() == null)); + } + + [ConditionalFact] + public virtual void Json_entity_with_tph_inheritance() + { + var modelBuilder = CreateModelBuilder(); + + modelBuilder.Entity(b => + { + b.OwnsOne(x => x.OwnedReferenceOnBase, bb => bb.ToJson("reference_on_base")); + b.OwnsMany(x => x.OwnedCollectionOnBase, bb => bb.ToJson("collection_on_base")); + }); + + modelBuilder.Entity(b => + { + b.HasBaseType(); + b.OwnsOne(x => x.OwnedReferenceOnDerived, bb => bb.ToJson("reference_on_derived")); + b.OwnsMany(x => x.OwnedCollectionOnDerived, bb => bb.ToJson("collection_on_derived")); + }); + + var model = modelBuilder.FinalizeModel(); + var ownedEntities = model.FindEntityTypes(typeof(OwnedEntity)).ToList(); + Assert.Equal(4, ownedEntities.Count()); + + foreach (var ownedEntity in ownedEntities) + { + Assert.Equal("Date", ownedEntity.GetProperty("Date").GetJsonPropertyName()); + Assert.Equal("Fraction", ownedEntity.GetProperty("Fraction").GetJsonPropertyName()); + Assert.Equal("Enum", ownedEntity.GetProperty("Enum").GetJsonPropertyName()); + } + + var jsonColumnNames = ownedEntities.Select(x => x.GetJsonColumnName()).OrderBy(x => x).ToList(); + Assert.Equal("collection_on_base", jsonColumnNames[0]); + Assert.Equal("collection_on_derived", jsonColumnNames[1]); + Assert.Equal("reference_on_base", jsonColumnNames[2]); + Assert.Equal("reference_on_derived", jsonColumnNames[3]); + } + + [ConditionalFact] + public virtual void Json_entity_with_nested_structure_same_property_names() + { + var modelBuilder = CreateModelBuilder(); + modelBuilder.Entity(b => + { + b.OwnsOne(x => x.OwnedReference1, bb => + { + bb.ToJson("ref1"); + bb.OwnsOne(x => x.Reference1); + bb.OwnsOne(x => x.Reference2); + bb.OwnsMany(x => x.Collection1); + bb.OwnsMany(x => x.Collection2); + }); + + b.OwnsOne(x => x.OwnedReference2, bb => + { + bb.ToJson("ref2"); + bb.OwnsOne(x => x.Reference1); + bb.OwnsOne(x => x.Reference2); + bb.OwnsMany(x => x.Collection1); + bb.OwnsMany(x => x.Collection2); + }); + + b.OwnsMany(x => x.OwnedCollection1, bb => + { + bb.ToJson("col1"); + bb.OwnsOne(x => x.Reference1); + bb.OwnsOne(x => x.Reference2); + bb.OwnsMany(x => x.Collection1); + bb.OwnsMany(x => x.Collection2); + }); + + b.OwnsMany(x => x.OwnedCollection2, bb => + { + bb.ToJson("col2"); + bb.OwnsOne(x => x.Reference1); + bb.OwnsOne(x => x.Reference2); + bb.OwnsMany(x => x.Collection1); + bb.OwnsMany(x => x.Collection2); + }); + }); + + var model = modelBuilder.FinalizeModel(); + var outerOwnedEntities = model.FindEntityTypes(typeof(OwnedEntityExtraLevel)); + Assert.Equal(4, outerOwnedEntities.Count()); + + foreach (var outerOwnedEntity in outerOwnedEntities) + { + Assert.Equal("Date", outerOwnedEntity.GetProperty("Date").GetJsonPropertyName()); + Assert.Equal("Fraction", outerOwnedEntity.GetProperty("Fraction").GetJsonPropertyName()); + Assert.Equal("Enum", outerOwnedEntity.GetProperty("Enum").GetJsonPropertyName()); + Assert.Equal("Reference1", outerOwnedEntity.GetNavigations().Single(n => n.Name == "Reference1").GetJsonPropertyName()); + Assert.Equal("Reference2", outerOwnedEntity.GetNavigations().Single(n => n.Name == "Reference2").GetJsonPropertyName()); + Assert.Equal("Collection1", outerOwnedEntity.GetNavigations().Single(n => n.Name == "Collection1").GetJsonPropertyName()); + Assert.Equal("Collection2", outerOwnedEntity.GetNavigations().Single(n => n.Name == "Collection2").GetJsonPropertyName()); + } + + var ownedEntities = model.FindEntityTypes(typeof(OwnedEntity)); + Assert.Equal(16, ownedEntities.Count()); + + foreach (var ownedEntity in ownedEntities) + { + Assert.Equal("Date", ownedEntity.GetProperty("Date").GetJsonPropertyName()); + Assert.Equal("Fraction", ownedEntity.GetProperty("Fraction").GetJsonPropertyName()); + Assert.Equal("Enum", ownedEntity.GetProperty("Enum").GetJsonPropertyName()); + } + } + + [ConditionalFact] + public virtual void Json_entity_nested_enums_have_conversions_to_string_by_default_ToJson_first() + { + var modelBuilder = CreateModelBuilder(); + modelBuilder.Entity(b => + { + b.OwnsOne(x => x.OwnedReference1, bb => + { + bb.ToJson(); + bb.OwnsOne(x => x.Reference1); + bb.OwnsOne(x => x.Reference2); + bb.OwnsMany(x => x.Collection1); + bb.OwnsMany(x => x.Collection2); + }); + + b.Ignore(x => x.OwnedReference2); + b.OwnsMany(x => x.OwnedCollection1, bb => + { + bb.ToJson(); + bb.OwnsOne(x => x.Reference1); + bb.OwnsOne(x => x.Reference2); + bb.OwnsMany(x => x.Collection1); + bb.OwnsMany(x => x.Collection2); + }); + + b.Ignore(x => x.OwnedCollection2); + }); + + var model = modelBuilder.FinalizeModel(); + var outerOwnedEntities = model.FindEntityTypes(typeof(OwnedEntityExtraLevel)); + Assert.Equal(2, outerOwnedEntities.Count()); + + foreach (var outerOwnedEntity in outerOwnedEntities) + { + Assert.True(outerOwnedEntity.IsMappedToJson()); + var myEnum = outerOwnedEntity.GetDeclaredProperties().Where(p => p.ClrType.IsEnum).Single(); + var typeMapping = myEnum.FindRelationalTypeMapping()!; + Assert.True(typeMapping.Converter is EnumToStringConverter); + } + + var ownedEntities = model.FindEntityTypes(typeof(OwnedEntity)); + Assert.Equal(8, ownedEntities.Count()); + + foreach (var ownedEntity in ownedEntities) + { + Assert.True(ownedEntity.IsMappedToJson()); + var myEnum = ownedEntity.GetDeclaredProperties().Where(p => p.ClrType.IsEnum).Single(); + var typeMapping = myEnum.FindRelationalTypeMapping()!; + Assert.True(typeMapping.Converter is EnumToStringConverter); + } + } + + [ConditionalFact] + public virtual void Json_entity_nested_enums_have_conversions_to_string_by_default_ToJson_last() + { + var modelBuilder = CreateModelBuilder(); + modelBuilder.Entity(b => + { + b.OwnsOne(x => x.OwnedReference1, bb => + { + bb.OwnsOne(x => x.Reference1); + bb.OwnsOne(x => x.Reference2); + bb.OwnsMany(x => x.Collection1); + bb.OwnsMany(x => x.Collection2); + bb.ToJson(); + }); + + b.Ignore(x => x.OwnedReference2); + b.OwnsMany(x => x.OwnedCollection1, bb => + { + bb.OwnsOne(x => x.Reference1); + bb.OwnsOne(x => x.Reference2); + bb.OwnsMany(x => x.Collection1); + bb.OwnsMany(x => x.Collection2); + bb.ToJson(); + }); + + b.Ignore(x => x.OwnedCollection2); + }); + + var model = modelBuilder.FinalizeModel(); + var outerOwnedEntities = model.FindEntityTypes(typeof(OwnedEntityExtraLevel)); + Assert.Equal(2, outerOwnedEntities.Count()); + + foreach (var outerOwnedEntity in outerOwnedEntities) + { + Assert.True(outerOwnedEntity.IsMappedToJson()); + var myEnum = outerOwnedEntity.GetDeclaredProperties().Where(p => p.ClrType.IsEnum).Single(); + var typeMapping = myEnum.FindRelationalTypeMapping()!; + Assert.True(typeMapping.Converter is EnumToStringConverter); + } + + var ownedEntities = model.FindEntityTypes(typeof(OwnedEntity)); + Assert.Equal(8, ownedEntities.Count()); + + foreach (var ownedEntity in ownedEntities) + { + Assert.True(ownedEntity.IsMappedToJson()); + var myEnum = ownedEntity.GetDeclaredProperties().Where(p => p.ClrType.IsEnum).Single(); + var typeMapping = myEnum.FindRelationalTypeMapping()!; + Assert.True(typeMapping.Converter is EnumToStringConverter); + } + } + + [ConditionalFact] + public virtual void Entity_mapped_to_json_and_unwound_afterwards_properly_cleans_up_its_state() + { + var modelBuilder = CreateModelBuilder(); + modelBuilder.Entity(b => + { + b.OwnsOne(x => x.OwnedReference1, bb => + { + bb.ToJson(); + bb.OwnsOne(x => x.Reference1); + bb.OwnsOne(x => x.Reference2); + bb.OwnsMany(x => x.Collection1); + bb.OwnsMany(x => x.Collection2); + bb.ToJson(null); + }); + + b.Ignore(x => x.OwnedReference2); + b.OwnsMany(x => x.OwnedCollection1, bb => + { + bb.OwnsOne(x => x.Reference1); + bb.OwnsOne(x => x.Reference2); + bb.OwnsMany(x => x.Collection1); + bb.OwnsMany(x => x.Collection2); + bb.ToJson(); + bb.ToJson(null); + }); + + b.Ignore(x => x.OwnedCollection2); + }); + + var model = modelBuilder.FinalizeModel(); + var outerOwnedEntities = model.FindEntityTypes(typeof(OwnedEntityExtraLevel)); + Assert.Equal(2, outerOwnedEntities.Count()); + + foreach (var outerOwnedEntity in outerOwnedEntities) + { + Assert.False(outerOwnedEntity.IsMappedToJson()); + Assert.Null(outerOwnedEntity.GetJsonColumnTypeMapping()); + var myEnum = outerOwnedEntity.GetDeclaredProperties().Where(p => p.ClrType.IsEnum).Single(); + var typeMapping = myEnum.FindRelationalTypeMapping()!; + + Assert.True(typeMapping.Converter is EnumToNumberConverter); + } + + var ownedEntities = model.FindEntityTypes(typeof(OwnedEntity)); + Assert.Equal(8, ownedEntities.Count()); + + foreach (var ownedEntity in ownedEntities) + { + Assert.False(ownedEntity.IsMappedToJson()); + Assert.Null(ownedEntity.GetJsonColumnTypeMapping()); + var myEnum = ownedEntity.GetDeclaredProperties().Where(p => p.ClrType.IsEnum).Single(); + var typeMapping = myEnum.FindRelationalTypeMapping()!; + Assert.True(typeMapping.Converter is EnumToNumberConverter); + } + } + + [ConditionalFact] + public virtual void Json_entity_mapped_to_view() + { + var modelBuilder = CreateModelBuilder(); + modelBuilder.Entity(b => + { + b.ToView("MyView"); + b.OwnsOne(x => x.OwnedReference1, bb => bb.ToJson()); + b.Ignore(x => x.OwnedReference2); + b.OwnsMany(x => x.OwnedCollection1, bb => bb.ToJson()); + b.Ignore(x => x.OwnedCollection2); + }); + + var model = modelBuilder.FinalizeModel(); + + var owner = model.FindEntityType(typeof(JsonEntity))!; + Assert.Equal("MyView", owner.GetViewName()); + + var ownedEntities = model.FindEntityTypes(typeof(OwnedEntity)); + Assert.Equal(2, ownedEntities.Count()); + Assert.Equal(2, ownedEntities.Where(e => e.IsMappedToJson()).Count()); + Assert.True(ownedEntities.All(x => x.GetViewName() == "MyView")); + } + + [ConditionalFact] + public virtual void Json_entity_with_custom_property_names() + { + var modelBuilder = CreateModelBuilder(); + modelBuilder.Entity(b => + { + b.OwnsOne(x => x.OwnedReference1, bb => + { + bb.ToJson(); + bb.Property(x => x.Date).HasJsonPropertyName("OuterDate"); + bb.Property(x => x.Fraction).HasJsonPropertyName("OuterFraction"); + bb.Property(x => x.Enum).HasJsonPropertyName("OuterEnum"); + bb.OwnsOne(x => x.Reference1, bbb => + { + bbb.HasJsonPropertyName("RenamedReference1"); + bbb.Property(x => x.Date).HasJsonPropertyName("InnerDate"); + bbb.Property(x => x.Fraction).HasJsonPropertyName("InnerFraction"); + bbb.Property(x => x.Enum).HasJsonPropertyName("InnerEnum"); + }); + bb.OwnsOne(x => x.Reference2, bbb => + { + bbb.HasJsonPropertyName("RenamedReference2"); + bbb.Property(x => x.Date).HasJsonPropertyName("InnerDate"); + bbb.Property(x => x.Fraction).HasJsonPropertyName("InnerFraction"); + bbb.Property(x => x.Enum).HasJsonPropertyName("InnerEnum"); + }); + bb.OwnsMany(x => x.Collection1, bbb => + { + bbb.HasJsonPropertyName("RenamedCollection1"); + bbb.Property(x => x.Date).HasJsonPropertyName("InnerDate"); + bbb.Property(x => x.Fraction).HasJsonPropertyName("InnerFraction"); + bbb.Property(x => x.Enum).HasJsonPropertyName("InnerEnum"); + }); + bb.OwnsMany(x => x.Collection2, bbb => + { + bbb.HasJsonPropertyName("RenamedCollection2"); + bbb.Property(x => x.Date).HasJsonPropertyName("InnerDate"); + bbb.Property(x => x.Fraction).HasJsonPropertyName("InnerFraction"); + bbb.Property(x => x.Enum).HasJsonPropertyName("InnerEnum"); + }); + }); + + b.OwnsMany(x => x.OwnedCollection1, bb => + { + bb.Property(x => x.Date).HasJsonPropertyName("OuterDate"); + bb.Property(x => x.Fraction).HasJsonPropertyName("OuterFraction"); + bb.Property(x => x.Enum).HasJsonPropertyName("OuterEnum"); + bb.OwnsOne(x => x.Reference1, bbb => + { + bbb.HasJsonPropertyName("RenamedReference1"); + bbb.Property(x => x.Date).HasJsonPropertyName("InnerDate"); + bbb.Property(x => x.Fraction).HasJsonPropertyName("InnerFraction"); + bbb.Property(x => x.Enum).HasJsonPropertyName("InnerEnum"); + }); + bb.OwnsOne(x => x.Reference2, bbb => + { + bbb.HasJsonPropertyName("RenamedReference2"); + bbb.Property(x => x.Date).HasJsonPropertyName("InnerDate"); + bbb.Property(x => x.Fraction).HasJsonPropertyName("InnerFraction"); + bbb.Property(x => x.Enum).HasJsonPropertyName("InnerEnum"); + }); + bb.OwnsMany(x => x.Collection1, bbb => + { + bbb.HasJsonPropertyName("RenamedCollection1"); + bbb.Property(x => x.Date).HasJsonPropertyName("InnerDate"); + bbb.Property(x => x.Fraction).HasJsonPropertyName("InnerFraction"); + bbb.Property(x => x.Enum).HasJsonPropertyName("InnerEnum"); + }); + bb.OwnsMany(x => x.Collection2, bbb => + { + bbb.HasJsonPropertyName("RenamedCollection2"); + bbb.Property(x => x.Date).HasJsonPropertyName("InnerDate"); + bbb.Property(x => x.Fraction).HasJsonPropertyName("InnerFraction"); + bbb.Property(x => x.Enum).HasJsonPropertyName("InnerEnum"); + }); + bb.ToJson(); + }); + + b.Ignore(x => x.OwnedReference2); + b.Ignore(x => x.OwnedCollection2); + }); + + var model = modelBuilder.FinalizeModel(); + var outerOwnedEntities = model.FindEntityTypes(typeof(OwnedEntityExtraLevel)); + Assert.Equal(2, outerOwnedEntities.Count()); + + foreach (var outerOwnedEntity in outerOwnedEntities) + { + Assert.Equal("OuterDate", outerOwnedEntity.GetProperty("Date").GetJsonPropertyName()); + Assert.Equal("OuterFraction", outerOwnedEntity.GetProperty("Fraction").GetJsonPropertyName()); + Assert.Equal("OuterEnum", outerOwnedEntity.GetProperty("Enum").GetJsonPropertyName()); + Assert.Equal("RenamedReference1", outerOwnedEntity.GetNavigations().Single(n => n.Name == "Reference1").GetJsonPropertyName()); + Assert.Equal("RenamedReference2", outerOwnedEntity.GetNavigations().Single(n => n.Name == "Reference2").GetJsonPropertyName()); + Assert.Equal("RenamedCollection1", outerOwnedEntity.GetNavigations().Single(n => n.Name == "Collection1").GetJsonPropertyName()); + Assert.Equal("RenamedCollection2", outerOwnedEntity.GetNavigations().Single(n => n.Name == "Collection2").GetJsonPropertyName()); + } + + var ownedEntities = model.FindEntityTypes(typeof(OwnedEntity)); + Assert.Equal(8, ownedEntities.Count()); + + foreach (var ownedEntity in ownedEntities) + { + Assert.Equal("InnerDate", ownedEntity.GetProperty("Date").GetJsonPropertyName()); + Assert.Equal("InnerFraction", ownedEntity.GetProperty("Fraction").GetJsonPropertyName()); + Assert.Equal("InnerEnum", ownedEntity.GetProperty("Enum").GetJsonPropertyName()); + } + } + protected override TestModelBuilder CreateModelBuilder(Action? configure = null) => CreateTestModelBuilder(SqlServerTestHelpers.Instance, configure); }