diff --git a/src/EFCore/Metadata/Builders/CollectionCollectionBuilder.cs b/src/EFCore/Metadata/Builders/CollectionCollectionBuilder.cs index 2131ba12e1d..a4db5c04972 100644 --- a/src/EFCore/Metadata/Builders/CollectionCollectionBuilder.cs +++ b/src/EFCore/Metadata/Builders/CollectionCollectionBuilder.cs @@ -94,6 +94,15 @@ public virtual EntityTypeBuilder UsingEntity( [NotNull] Func configureRight, [NotNull] Func configureLeft) { + var existingAssociationEntityType = (EntityType) + (LeftNavigation.ForeignKey?.DeclaringEntityType + ?? RightNavigation.ForeignKey?.DeclaringEntityType); + if (existingAssociationEntityType != null) + { + ModelBuilder.RemoveAssociationEntityIfAutomaticallyCreated( + existingAssociationEntityType, false, ConfigurationSource.Explicit); + } + var entityTypeBuilder = new EntityTypeBuilder( ModelBuilder.Entity(joinEntity, ConfigurationSource.Explicit).Metadata); diff --git a/src/EFCore/Metadata/Builders/CollectionCollectionBuilder`.cs b/src/EFCore/Metadata/Builders/CollectionCollectionBuilder`.cs index 90758064e0a..b0db5b3bf49 100644 --- a/src/EFCore/Metadata/Builders/CollectionCollectionBuilder`.cs +++ b/src/EFCore/Metadata/Builders/CollectionCollectionBuilder`.cs @@ -4,6 +4,7 @@ using System; using JetBrains.Annotations; using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata.Internal; namespace Microsoft.EntityFrameworkCore.Metadata.Builders { @@ -50,6 +51,15 @@ public virtual EntityTypeBuilder UsingEntity, ReferenceCollectionBuilder> configureLeft) where TAssociationEntity : class { + var existingAssociationEntityType = (EntityType) + (LeftNavigation.ForeignKey?.DeclaringEntityType + ?? RightNavigation.ForeignKey?.DeclaringEntityType); + if (existingAssociationEntityType != null) + { + ModelBuilder.RemoveAssociationEntityIfAutomaticallyCreated( + existingAssociationEntityType, false, ConfigurationSource.Explicit); + } + var entityTypeBuilder = new EntityTypeBuilder( ModelBuilder.Entity(typeof(TAssociationEntity), ConfigurationSource.Explicit).Metadata); diff --git a/src/EFCore/Metadata/Conventions/BackingFieldConvention.cs b/src/EFCore/Metadata/Conventions/BackingFieldConvention.cs index e69d7bf8f4d..b37df7903fc 100644 --- a/src/EFCore/Metadata/Conventions/BackingFieldConvention.cs +++ b/src/EFCore/Metadata/Conventions/BackingFieldConvention.cs @@ -80,7 +80,8 @@ private FieldInfo GetFieldToSet(IConventionPropertyBase propertyBase) { if (propertyBase == null || !ConfigurationSource.Convention.Overrides(propertyBase.GetFieldInfoConfigurationSource()) - || propertyBase.IsIndexerProperty()) + || propertyBase.IsIndexerProperty() + || (propertyBase.PropertyInfo == null && propertyBase.FieldInfo == null)) { return null; } diff --git a/src/EFCore/Metadata/Conventions/DerivedTypeDiscoveryConvention.cs b/src/EFCore/Metadata/Conventions/DerivedTypeDiscoveryConvention.cs index 0fe9f88f97b..c1ef72e9474 100644 --- a/src/EFCore/Metadata/Conventions/DerivedTypeDiscoveryConvention.cs +++ b/src/EFCore/Metadata/Conventions/DerivedTypeDiscoveryConvention.cs @@ -48,7 +48,8 @@ public virtual void ProcessEntityTypeAdded( && t.FindDeclaredOwnership() == null && model.FindIsOwnedConfigurationSource(t.ClrType) == null && ((t.BaseType == null && clrType.IsAssignableFrom(t.ClrType)) - || (t.BaseType == entityType.BaseType && FindClosestBaseType(t) == entityType))) + || (t.BaseType == entityType.BaseType && FindClosestBaseType(t) == entityType)) + && !t.HasSharedClrType) .ToList(); foreach (var directlyDerivedType in directlyDerivedTypes) diff --git a/src/EFCore/Metadata/Conventions/Infrastructure/ProviderConventionSetBuilder.cs b/src/EFCore/Metadata/Conventions/Infrastructure/ProviderConventionSetBuilder.cs index 919fcf3d5ad..ca9a0e4e051 100644 --- a/src/EFCore/Metadata/Conventions/Infrastructure/ProviderConventionSetBuilder.cs +++ b/src/EFCore/Metadata/Conventions/Infrastructure/ProviderConventionSetBuilder.cs @@ -200,6 +200,8 @@ public virtual ConventionSet CreateConventionSet() conventionSet.NavigationRemovedConventions.Add(relationshipDiscoveryConvention); + conventionSet.SkipNavigationAddedConventions.Add(new ManyToManyConvention(Dependencies)); + conventionSet.IndexAddedConventions.Add(foreignKeyIndexConvention); conventionSet.IndexRemovedConventions.Add(foreignKeyIndexConvention); diff --git a/src/EFCore/Metadata/Conventions/Internal/ConventionDispatcher.ImmediateConventionScope.cs b/src/EFCore/Metadata/Conventions/Internal/ConventionDispatcher.ImmediateConventionScope.cs index be8a37b18c8..d25d5e505e3 100644 --- a/src/EFCore/Metadata/Conventions/Internal/ConventionDispatcher.ImmediateConventionScope.cs +++ b/src/EFCore/Metadata/Conventions/Internal/ConventionDispatcher.ImmediateConventionScope.cs @@ -710,7 +710,6 @@ public override IConventionSkipNavigationBuilder OnSkipNavigationAdded( { if (navigationBuilder.Metadata.Builder == null) { - Check.DebugAssert(false, "null builder"); return null; } diff --git a/src/EFCore/Metadata/Conventions/ManyToManyConvention.cs b/src/EFCore/Metadata/Conventions/ManyToManyConvention.cs new file mode 100644 index 00000000000..ae9881575ba --- /dev/null +++ b/src/EFCore/Metadata/Conventions/ManyToManyConvention.cs @@ -0,0 +1,79 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Linq; +using JetBrains.Annotations; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Microsoft.EntityFrameworkCore.Metadata.Conventions.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata.Internal; +using Microsoft.EntityFrameworkCore.Utilities; + +namespace Microsoft.EntityFrameworkCore.Metadata.Conventions +{ + /// + /// A convention which looks for matching skip navigations and automatically creates + /// a many-to-many association entity with suitable foreign keys, sets the two + /// matching skip navigations to use those foreign keys and makes them inverses of + /// one another. + /// + public class ManyToManyConvention : ISkipNavigationAddedConvention + { + /// + /// Creates a new instance of . + /// + /// Parameter object containing dependencies for this convention. + public ManyToManyConvention([NotNull] ProviderConventionSetBuilderDependencies dependencies) + { + Dependencies = dependencies; + } + + /// + /// Parameter object containing service dependencies. + /// + protected virtual ProviderConventionSetBuilderDependencies Dependencies { get; } + + /// + /// Called after a skip navigation is added to an entity type. + /// + /// The builder for the skip navigation. + /// Additional information associated with convention execution. + public virtual void ProcessSkipNavigationAdded( + IConventionSkipNavigationBuilder skipNavigationBuilder, + IConventionContext context) + { + Check.NotNull(skipNavigationBuilder, "skipNavigationBuilder"); + Check.NotNull(context, "context"); + + var skipNavigation = skipNavigationBuilder.Metadata; + if (skipNavigation.ForeignKey != null + || skipNavigation.TargetEntityType == skipNavigation.DeclaringEntityType + || !skipNavigation.IsCollection) + { + // do not create an automatic many-to-many association entity type + // for a self-referencing skip navigation, or for one that + // is already "in use" (i.e. has its Foreign Key assigned). + return; + } + + var matchingSkipNavigation = skipNavigation.TargetEntityType + .GetSkipNavigations() + .FirstOrDefault(sn => sn.TargetEntityType == skipNavigation.DeclaringEntityType); + + if (matchingSkipNavigation == null + || matchingSkipNavigation.ForeignKey != null + || !matchingSkipNavigation.IsCollection) + { + // do not create an automatic many-to-many association entity type if + // the matching skip navigation is already "in use" (i.e. + // has its Foreign Key assigned). + return; + } + + var model = (Model)skipNavigation.DeclaringEntityType.Model; + model.Builder.AssociationEntity( + (SkipNavigation)skipNavigation, + (SkipNavigation)matchingSkipNavigation, + ConfigurationSource.Convention); + } + } +} diff --git a/src/EFCore/Metadata/Conventions/ModelCleanupConvention.cs b/src/EFCore/Metadata/Conventions/ModelCleanupConvention.cs index d2372e891ff..4bde6be65b3 100644 --- a/src/EFCore/Metadata/Conventions/ModelCleanupConvention.cs +++ b/src/EFCore/Metadata/Conventions/ModelCleanupConvention.cs @@ -71,7 +71,8 @@ private IReadOnlyList GetRoots(IConventionModel model, Co private void RemoveNavigationlessForeignKeys(IConventionModelBuilder modelBuilder) { - foreach (var entityType in modelBuilder.Metadata.GetEntityTypes()) + foreach (var entityType in modelBuilder.Metadata.GetEntityTypes() + .Where(e => !((EntityType)e).IsAutomaticallyCreatedAssociationEntityType)) { foreach (var foreignKey in entityType.GetDeclaredForeignKeys().ToList()) { @@ -106,11 +107,15 @@ public GraphAdapter([NotNull] IConventionModel model) public override IEnumerable GetOutgoingNeighbors(IConventionEntityType from) => from.GetForeignKeys().Where(fk => fk.DependentToPrincipal != null).Select(fk => fk.PrincipalEntityType) - .Union(from.GetReferencingForeignKeys().Where(fk => fk.PrincipalToDependent != null).Select(fk => fk.DeclaringEntityType)); + .Union(from.GetReferencingForeignKeys().Where(fk => fk.PrincipalToDependent != null).Select(fk => fk.DeclaringEntityType)) + .Union(from.GetSkipNavigations().Where(sn => sn.ForeignKey != null).Select(sn => sn.ForeignKey.DeclaringEntityType)) + .Union(from.GetSkipNavigations().Where(sn => sn.TargetEntityType != null).Select(sn => sn.TargetEntityType)); public override IEnumerable GetIncomingNeighbors(IConventionEntityType to) => to.GetForeignKeys().Where(fk => fk.PrincipalToDependent != null).Select(fk => fk.PrincipalEntityType) - .Union(to.GetReferencingForeignKeys().Where(fk => fk.DependentToPrincipal != null).Select(fk => fk.DeclaringEntityType)); + .Union(to.GetReferencingForeignKeys().Where(fk => fk.DependentToPrincipal != null).Select(fk => fk.DeclaringEntityType)) + .Union(to.GetSkipNavigations().Where(sn => sn.ForeignKey != null).Select(sn => sn.ForeignKey.DeclaringEntityType)) + .Union(to.GetSkipNavigations().Where(sn => sn.TargetEntityType != null).Select(sn => sn.TargetEntityType)); public override void Clear() { diff --git a/src/EFCore/Metadata/Conventions/RelationshipDiscoveryConvention.cs b/src/EFCore/Metadata/Conventions/RelationshipDiscoveryConvention.cs index 4235aaf17fa..40501637151 100644 --- a/src/EFCore/Metadata/Conventions/RelationshipDiscoveryConvention.cs +++ b/src/EFCore/Metadata/Conventions/RelationshipDiscoveryConvention.cs @@ -239,7 +239,8 @@ private static IReadOnlyList RemoveIncompatibleWithExisti while (relationshipCandidate.NavigationProperties.Count > 0) { var navigationProperty = relationshipCandidate.NavigationProperties[0]; - var existingNavigation = entityType.FindNavigation(navigationProperty.GetSimpleMemberName()); + var navigationPropertyName = navigationProperty.GetSimpleMemberName(); + var existingNavigation = entityType.FindNavigation(navigationPropertyName); if (existingNavigation != null && (existingNavigation.DeclaringEntityType != entityType || existingNavigation.TargetEntityType != targetEntityType)) @@ -248,6 +249,15 @@ private static IReadOnlyList RemoveIncompatibleWithExisti continue; } + var existingSkipNavigation = entityType.FindSkipNavigation(navigationPropertyName); + if (existingSkipNavigation != null + && (existingSkipNavigation.DeclaringEntityType != entityType + || existingSkipNavigation.TargetEntityType != targetEntityType)) + { + relationshipCandidate.NavigationProperties.Remove(navigationProperty); + continue; + } + if (relationshipCandidate.NavigationProperties.Count == 1 && relationshipCandidate.InverseProperties.Count == 0) { @@ -573,29 +583,73 @@ private void CreateRelationships( foreach (var navigationProperty in relationshipCandidate.NavigationProperties.ToList()) { - var existingNavigation = entityType.FindDeclaredNavigation(navigationProperty.GetSimpleMemberName()); - if (existingNavigation != null - && existingNavigation.ForeignKey.DeclaringEntityType.Builder - .HasNoRelationship(existingNavigation.ForeignKey) == null - && existingNavigation.ForeignKey.Builder.HasNavigation( - (string)null, existingNavigation.IsOnDependent) == null) + var navigationPropertyName = navigationProperty.GetSimpleMemberName(); + var existingNavigation = entityType.FindDeclaredNavigation(navigationPropertyName); + if (existingNavigation != null) + { + if (existingNavigation.ForeignKey.DeclaringEntityType.Builder + .HasNoRelationship(existingNavigation.ForeignKey) == null + && existingNavigation.ForeignKey.Builder.HasNavigation( + (string)null, existingNavigation.IsOnDependent) == null) + { + // Navigations of higher configuration source are not ambiguous + relationshipCandidate.NavigationProperties.Remove(navigationProperty); + } + } + else { - // Navigations of higher configuration source are not ambiguous - relationshipCandidate.NavigationProperties.Remove(navigationProperty); + var associationEntityType = (EntityType)entityType + .FindDeclaredSkipNavigation(navigationPropertyName)? + .ForeignKey?.DeclaringEntityType; + if (associationEntityType != null) + { + var modelBuilder = associationEntityType.Model.Builder; + // The PropertyInfo underlying this skip navigation has become + // ambiguous since we used it, so remove the association entity + // if it was automatically created. + if (modelBuilder.RemoveAssociationEntityIfAutomaticallyCreated( + associationEntityType, true, ConfigurationSource.Convention) == null) + { + // Navigations of higher configuration source are not ambiguous + relationshipCandidate.NavigationProperties.Remove(navigationProperty); + } + } } } foreach (var inverseProperty in relationshipCandidate.InverseProperties.ToList()) { - var existingInverse = targetEntityType.FindDeclaredNavigation(inverseProperty.GetSimpleMemberName()); - if (existingInverse != null - && existingInverse.ForeignKey.DeclaringEntityType.Builder - .HasNoRelationship(existingInverse.ForeignKey) == null - && existingInverse.ForeignKey.Builder.HasNavigation( - (string)null, existingInverse.IsOnDependent) == null) + var inversePropertyName = inverseProperty.GetSimpleMemberName(); + var existingInverse = targetEntityType.FindDeclaredNavigation(inversePropertyName); + if (existingInverse != null) { - // Navigations of higher configuration source are not ambiguous - relationshipCandidate.InverseProperties.Remove(inverseProperty); + if (existingInverse.ForeignKey.DeclaringEntityType.Builder + .HasNoRelationship(existingInverse.ForeignKey) == null + && existingInverse.ForeignKey.Builder.HasNavigation( + (string)null, existingInverse.IsOnDependent) == null) + { + // Navigations of higher configuration source are not ambiguous + relationshipCandidate.InverseProperties.Remove(inverseProperty); + } + } + else + { + var associationEntityType = (EntityType)targetEntityType + .FindDeclaredSkipNavigation(inversePropertyName)? + .ForeignKey?.DeclaringEntityType; + if (associationEntityType != null) + { + var modelBuilder = associationEntityType.Model.Builder; + // The PropertyInfo underlying this skip navigation has become + // ambiguous since we used it, so remove the association entity + // if it was automatically created. + if (modelBuilder.RemoveAssociationEntityIfAutomaticallyCreated( + associationEntityType, true, ConfigurationSource.Convention) == null) + { + // Navigations of higher configuration source are not ambiguous + relationshipCandidate.NavigationProperties.Remove(inverseProperty); + } + } } } @@ -677,10 +731,14 @@ private void CreateRelationships( } else { - entityTypeBuilder.HasRelationship( - targetEntityType, - navigation, - inverse); + if (entityTypeBuilder.HasRelationship( + targetEntityType, + navigation, + inverse) == null) + { + HasManyToManyRelationship( + entityTypeBuilder, relationshipCandidate, navigation, inverse); + } } } } @@ -725,6 +783,90 @@ private void CreateRelationships( } } + private void HasManyToManyRelationship( + IConventionEntityTypeBuilder entityTypeBuilder, + RelationshipCandidate relationshipCandidate, + PropertyInfo navigation, + PropertyInfo inverse) + { + var navigationTargetType = navigation.PropertyType.TryGetSequenceType(); + var inverseTargetType = inverse.PropertyType.TryGetSequenceType(); + if (navigationTargetType == null + || inverseTargetType == null) + { + // these are not many-to-many navigations + return; + } + + if (navigationTargetType == inverseTargetType) + { + // do not automatically create many-to-many associations to self + return; + } + + var leftEntityType = entityTypeBuilder.Metadata; + var rightEntityType = relationshipCandidate.TargetTypeBuilder.Metadata; + if (navigationTargetType != rightEntityType.ClrType + || inverseTargetType != leftEntityType.ClrType) + { + // this relationship should be defined between different + // entity types further up at least one of their inheritance trees + return; + } + + var leftSkipNavigation = leftEntityType.FindSkipNavigation(navigation); + var rightSkipNavigation = rightEntityType.FindSkipNavigation(inverse); + if (leftSkipNavigation != null + || rightSkipNavigation != null) + { + // skip navigations have already been configured using these properties + return; + } + + var navigationName = navigation.GetSimpleMemberName(); + var inverseName = inverse.GetSimpleMemberName(); + if (((EntityType)leftEntityType) + .FindNavigationsInHierarchy(navigationName).FirstOrDefault() != null + || ((EntityType)rightEntityType) + .FindNavigationsInHierarchy(inverseName).FirstOrDefault() != null) + { + // navigations have already been configured using these properties + return; + } + + leftSkipNavigation = leftEntityType.AddSkipNavigation( + navigationName, navigation, rightEntityType, + collection: true, onDependent: false, fromDataAnnotation: false); + if (leftSkipNavigation == null) + { + return; + } + + rightSkipNavigation = rightEntityType.AddSkipNavigation( + inverseName, inverse, leftEntityType, + collection: true, onDependent: false, fromDataAnnotation: false); + if (rightSkipNavigation == null) + { + // Failed to create the right skip navigation - so remove + // the left skip navigation we just created as well. + leftEntityType.RemoveSkipNavigation(leftSkipNavigation); + return; + } + + var associationEntityTypeBuilder = + ((InternalModelBuilder)entityTypeBuilder.ModelBuilder).AssociationEntity( + (SkipNavigation)leftSkipNavigation, + (SkipNavigation)rightSkipNavigation, + ConfigurationSource.Convention); + if (associationEntityTypeBuilder == null) + { + // Failed to create the association entity type - so remove + // both the skip navigations we just created as well. + leftEntityType.RemoveSkipNavigation(leftSkipNavigation); + rightEntityType.RemoveSkipNavigation(rightSkipNavigation); + } + } + /// /// Called after an entity type is added to the model. /// diff --git a/src/EFCore/Metadata/Internal/EntityType.cs b/src/EFCore/Metadata/Internal/EntityType.cs index 1f55aa40ace..a8aa4ec4d29 100644 --- a/src/EFCore/Metadata/Internal/EntityType.cs +++ b/src/EFCore/Metadata/Internal/EntityType.cs @@ -3049,6 +3049,16 @@ public virtual void CheckDiscriminatorValue([NotNull] IEntityType entityType, [C } } + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public virtual bool IsAutomaticallyCreatedAssociationEntityType + => GetConfigurationSource() == ConfigurationSource.Convention + && ClrType == typeof(Dictionary); + #endregion #region Explicit interface implementations diff --git a/src/EFCore/Metadata/Internal/InternalEntityTypeBuilder.cs b/src/EFCore/Metadata/Internal/InternalEntityTypeBuilder.cs index 2f50a755e1e..691a60c3dfc 100644 --- a/src/EFCore/Metadata/Internal/InternalEntityTypeBuilder.cs +++ b/src/EFCore/Metadata/Internal/InternalEntityTypeBuilder.cs @@ -1050,6 +1050,7 @@ public virtual InternalEntityTypeBuilder Ignore([NotNull] string name, Configura && inverse.DeclaringEntityType.Builder .CanRemoveSkipNavigation(inverse, configurationSource)) { + inverse.SetInverse(null, configurationSource); inverse.DeclaringEntityType.RemoveSkipNavigation(inverse); } @@ -1120,6 +1121,7 @@ public virtual InternalEntityTypeBuilder Ignore([NotNull] string name, Configura && inverse.DeclaringEntityType.Builder .CanRemoveSkipNavigation(inverse, configurationSource)) { + inverse.SetInverse(null, configurationSource); inverse.DeclaringEntityType.RemoveSkipNavigation(inverse); } diff --git a/src/EFCore/Metadata/Internal/InternalModelBuilder.cs b/src/EFCore/Metadata/Internal/InternalModelBuilder.cs index 1a7d8df7877..3e2bb75ae63 100644 --- a/src/EFCore/Metadata/Internal/InternalModelBuilder.cs +++ b/src/EFCore/Metadata/Internal/InternalModelBuilder.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using System.Collections.Generic; using System.Diagnostics; using System.Linq; using JetBrains.Annotations; @@ -211,6 +212,191 @@ private InternalEntityTypeBuilder Entity( return entityTypeWithDefiningNavigation?.Builder; } + private const string AssociationEntityTypeNameTemplate = "Join_{0}_{1}"; + private const string AssociationPropertyNameTemplate = "{0}_{1}"; + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public virtual InternalEntityTypeBuilder AssociationEntity( + [NotNull] SkipNavigation leftSkipNavigation, + [NotNull] SkipNavigation rightSkipNavigation, + ConfigurationSource configurationSource) + { + Check.NotNull(leftSkipNavigation, nameof(leftSkipNavigation)); + Check.NotNull(rightSkipNavigation, nameof(rightSkipNavigation)); + + var leftEntityType = leftSkipNavigation.DeclaringEntityType; + var rightEntityType = rightSkipNavigation.DeclaringEntityType; + + if (leftSkipNavigation.TargetEntityType != rightEntityType + || rightSkipNavigation.TargetEntityType != leftEntityType) + { + if (configurationSource != ConfigurationSource.Explicit) + { + return null; + } + + throw new InvalidOperationException( + CoreStrings.InvalidSkipNavigationsForAssociationEntityType( + leftEntityType.Name, + leftSkipNavigation.Name, + leftSkipNavigation.TargetEntityType.Name, + rightEntityType.Name, + rightSkipNavigation.Name, + rightSkipNavigation.TargetEntityType.Name)); + } + + // create the association entity type + var otherIdentifiers = Metadata.GetEntityTypes().ToDictionary(et => et.Name, et => 0); + var associationEntityTypeName = Uniquifier.Uniquify( + string.Format( + AssociationEntityTypeNameTemplate, + leftEntityType.ShortName(), + rightEntityType.ShortName()), + otherIdentifiers, + int.MaxValue); + var associationEntityTypeBuilder = + Metadata.AddEntityType( + associationEntityTypeName, + typeof(Dictionary), + configurationSource).Builder; + + // Create left and right foreign keys from the outer entity types to + // the association entity type and configure the skip navigations. + // Roll back if any of this fails. + var leftForeignKey = CreateSkipNavigationForeignKey( + leftSkipNavigation, associationEntityTypeBuilder, configurationSource); + if (leftForeignKey == null) + { + HasNoEntityType(associationEntityTypeBuilder.Metadata, configurationSource); + + if (configurationSource != ConfigurationSource.Explicit) + { + return null; + } + + throw new InvalidOperationException( + CoreStrings.UnableToCreateSkipNavigationForeignKeyOnAssociationEntityType( + leftEntityType.Name, + leftSkipNavigation.Name, + associationEntityTypeName)); + } + + var rightForeignKey = CreateSkipNavigationForeignKey( + rightSkipNavigation, associationEntityTypeBuilder, configurationSource); + if (rightForeignKey == null) + { + // Removing the association entity type will also remove + // the leftForeignKey created above. + HasNoEntityType(associationEntityTypeBuilder.Metadata, configurationSource); + + if (configurationSource != ConfigurationSource.Explicit) + { + return null; + } + + throw new InvalidOperationException( + CoreStrings.UnableToCreateSkipNavigationForeignKeyOnAssociationEntityType( + rightEntityType.Name, + rightSkipNavigation.Name, + associationEntityTypeName)); + } + + leftSkipNavigation.SetForeignKey(leftForeignKey, configurationSource); + rightSkipNavigation.SetForeignKey(rightForeignKey, configurationSource); + leftSkipNavigation.Builder.HasInverse(rightSkipNavigation, configurationSource); + + // Creating the primary key below also negates the need for an index on + // the properties of leftForeignKey - that index is automatically removed. + associationEntityTypeBuilder.PrimaryKey( + leftForeignKey.Properties.Concat(rightForeignKey.Properties).ToList(), + configurationSource); + + return associationEntityTypeBuilder; + } + + private static ForeignKey CreateSkipNavigationForeignKey( + SkipNavigation skipNavigation, + InternalEntityTypeBuilder associationEntityTypeBuilder, + ConfigurationSource configurationSource) + { + var principalEntityType = skipNavigation.DeclaringEntityType; + var principalKey = principalEntityType.FindPrimaryKey(); + if (principalKey == null) + { + return null; + } + + var dependentEndForeignKeyPropertyNames = new List(); + var otherIdentifiers = associationEntityTypeBuilder.Metadata + .GetDeclaredProperties().ToDictionary(p => p.Name, p => 0); + foreach (var property in principalKey.Properties) + { + var propertyName = Uniquifier.Uniquify( + string.Format( + AssociationPropertyNameTemplate, + principalEntityType.ShortName(), + property.Name), + otherIdentifiers, + int.MaxValue); + dependentEndForeignKeyPropertyNames.Add(propertyName); + otherIdentifiers.Add(propertyName, 0); + } + + return associationEntityTypeBuilder + .HasRelationship( + principalEntityType.Name, + dependentEndForeignKeyPropertyNames, + principalKey, + configurationSource) + .Metadata; + } + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public virtual InternalModelBuilder RemoveAssociationEntityIfAutomaticallyCreated( + [NotNull] EntityType associationEntityType, + bool removeSkipNavigations, + ConfigurationSource configurationSource) + { + Check.NotNull(associationEntityType, nameof(associationEntityType)); + + if (!associationEntityType.IsAutomaticallyCreatedAssociationEntityType) + { + return null; + } + + Debug.Assert(associationEntityType.GetForeignKeys().Count() == 2, + "Automatically created association entity types should have exactly 2 foreign keys"); + foreach (var fk in associationEntityType.GetForeignKeys()) + { + var principalEntityType = fk.PrincipalEntityType; + var skipNavigation = principalEntityType + .GetSkipNavigations().FirstOrDefault(sn => fk == sn.ForeignKey); + if (skipNavigation != null) + { + skipNavigation.SetForeignKey(null, configurationSource); + skipNavigation.SetInverse(null, configurationSource); + + if (removeSkipNavigations + && principalEntityType.Builder + .CanRemoveSkipNavigation(skipNavigation, configurationSource)) + { + principalEntityType.RemoveSkipNavigation(skipNavigation); + } + } + } + + return HasNoEntityType(associationEntityType, configurationSource); + } + /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to /// the same compatibility standards as public APIs. It may be changed or removed without notice in diff --git a/src/EFCore/Metadata/Internal/InternalSkipNavigationBuilder.cs b/src/EFCore/Metadata/Internal/InternalSkipNavigationBuilder.cs index fccd17f0eaf..dcb0d22b62d 100644 --- a/src/EFCore/Metadata/Internal/InternalSkipNavigationBuilder.cs +++ b/src/EFCore/Metadata/Internal/InternalSkipNavigationBuilder.cs @@ -122,6 +122,7 @@ public virtual bool CanSetForeignKey([CanBeNull] ForeignKey foreignKey, Configur return (Metadata.DeclaringEntityType == (Metadata.IsOnDependent ? foreignKey.DeclaringEntityType : foreignKey.PrincipalEntityType)) && (Metadata.Inverse?.AssociationEntityType == null + || Metadata.Inverse?.AssociationEntityType?.IsAutomaticallyCreatedAssociationEntityType == true || Metadata.Inverse.AssociationEntityType == (Metadata.IsOnDependent ? foreignKey.PrincipalEntityType : foreignKey.DeclaringEntityType)); } diff --git a/src/EFCore/Metadata/Internal/SkipNavigation.cs b/src/EFCore/Metadata/Internal/SkipNavigation.cs index a37efbea688..b278d465d38 100644 --- a/src/EFCore/Metadata/Internal/SkipNavigation.cs +++ b/src/EFCore/Metadata/Internal/SkipNavigation.cs @@ -193,10 +193,23 @@ public virtual ForeignKey SetForeignKey([CanBeNull] ForeignKey foreignKey, Confi if (Inverse?.AssociationEntityType != null && Inverse.AssociationEntityType != AssociationEntityType) { - throw new InvalidOperationException(CoreStrings.SkipInverseMismatchedForeignKey( - foreignKey.Properties.Format(), - Name, AssociationEntityType.DisplayName(), - Inverse.Name, Inverse.AssociationEntityType.DisplayName())); + if (!Inverse.AssociationEntityType.IsAutomaticallyCreatedAssociationEntityType) + { + throw new InvalidOperationException(CoreStrings.SkipInverseMismatchedForeignKey( + foreignKey.Properties.Format(), + Name, AssociationEntityType.DisplayName(), + Inverse.Name, Inverse.AssociationEntityType.DisplayName())); + } + + // Have reset the foreign key of a skip navigation on one side of an + // automatically created association entity. That entity is only + // useful if both sides are configured - so remove that + // entity and set the Inverse skip navigation to have no foreign key. + // If the user wants to set the Inverse's foreign key to also point + // to the new association entity they will need to do that manually. + AssociationEntityType.Model.Builder.RemoveAssociationEntityIfAutomaticallyCreated( + AssociationEntityType, false, configurationSource); + Inverse.SetForeignKey(null, configurationSource); } return isChanging diff --git a/src/EFCore/Properties/CoreStrings.Designer.cs b/src/EFCore/Properties/CoreStrings.Designer.cs index 7035476d6e4..1ede2c33080 100644 --- a/src/EFCore/Properties/CoreStrings.Designer.cs +++ b/src/EFCore/Properties/CoreStrings.Designer.cs @@ -2708,6 +2708,22 @@ public static string UnhandledNavigationBase([CanBeNull] object type) GetString("UnhandledNavigationBase", nameof(type)), type); + /// + /// Cannot create an association entity type using skip navigations '{leftEntityType}.{leftSkipNavigationName}' which targets entity type '{leftTargetEntityType}', and '{rightEntityType}.{rightSkipNavigationName}' which targets '{rightTargetEntityType}'. They should target one another. + /// + public static string InvalidSkipNavigationsForAssociationEntityType([CanBeNull] object leftEntityType, [CanBeNull] object leftSkipNavigationName, [CanBeNull] object leftTargetEntityType, [CanBeNull] object rightEntityType, [CanBeNull] object rightSkipNavigationName, [CanBeNull] object rightTargetEntityType) + => string.Format( + GetString("InvalidSkipNavigationsForAssociationEntityType", nameof(leftEntityType), nameof(leftSkipNavigationName), nameof(leftTargetEntityType), nameof(rightEntityType), nameof(rightSkipNavigationName), nameof(rightTargetEntityType)), + leftEntityType, leftSkipNavigationName, leftTargetEntityType, rightEntityType, rightSkipNavigationName, rightTargetEntityType); + + /// + /// Cannot create a foreign key for skip navigation '{declaringEntityType}.{skipNavigationName}' on association entity type '{associationEntityType}'. Ensure '{declaringEntityType}' has a primary key and is not ignored in the model. + /// + public static string UnableToCreateSkipNavigationForeignKeyOnAssociationEntityType([CanBeNull] object declaringEntityType, [CanBeNull] object skipNavigationName, [CanBeNull] object associationEntityType) + => string.Format( + GetString("UnableToCreateSkipNavigationForeignKeyOnAssociationEntityType", nameof(declaringEntityType), nameof(skipNavigationName), nameof(associationEntityType)), + declaringEntityType, skipNavigationName, associationEntityType); + private static string GetString(string name, params string[] formatterNames) { var value = _resourceManager.GetString(name); diff --git a/src/EFCore/Properties/CoreStrings.resx b/src/EFCore/Properties/CoreStrings.resx index 1d1b2edc99a..6a666614dbd 100644 --- a/src/EFCore/Properties/CoreStrings.resx +++ b/src/EFCore/Properties/CoreStrings.resx @@ -1421,4 +1421,10 @@ Unhandled 'INavigationBase' of type '{type}'. + + Cannot create an association entity type using skip navigations '{leftEntityType}.{leftSkipNavigationName}' which targets entity type '{leftTargetEntityType}', and '{rightEntityType}.{rightSkipNavigationName}' which targets '{rightTargetEntityType}'. They should target one another. + + + Cannot create a foreign key for skip navigation '{declaringEntityType}.{skipNavigationName}' on association entity type '{associationEntityType}'. Ensure '{declaringEntityType}' has a primary key and is not ignored in the model. + \ No newline at end of file diff --git a/test/EFCore.Design.Tests/Migrations/ModelSnapshotSqlServerTest.cs b/test/EFCore.Design.Tests/Migrations/ModelSnapshotSqlServerTest.cs index 4da163dc8d8..9beec7398e1 100644 --- a/test/EFCore.Design.Tests/Migrations/ModelSnapshotSqlServerTest.cs +++ b/test/EFCore.Design.Tests/Migrations/ModelSnapshotSqlServerTest.cs @@ -260,6 +260,20 @@ private class EntityWithNullableEnumType public Days? Day { get; set; } } + private class ManyToManyLeft + { + public int Id { get; set; } + public string Name { get; set; } + public List Rights { get; set; } + } + + private class ManyToManyRight + { + public int Id { get; set; } + public string Description { get; set; } + public List Lefts { get; set; } + } + private class CustomValueGenerator : ValueGenerator { public override int Next(EntityEntry entry) => throw new NotImplementedException(); @@ -1041,6 +1055,135 @@ public virtual void Foreign_keys_are_stored_in_snapshot() }); } + [ConditionalFact] + public virtual void Many_to_many_join_table_stored_in_snapshot() + { + Test( + builder => + { + builder + .Entity() + .HasMany(l => l.Rights) + .WithMany(r => r.Lefts); + }, + AddBoilerPlate( + GetHeading() + + @" + modelBuilder.Entity(""Join_ManyToManyRight_ManyToManyLeft"", b => + { + b.Property(""ManyToManyRight_Id"") + .HasColumnType(""int""); + + b.Property(""ManyToManyLeft_Id"") + .HasColumnType(""int""); + + b.HasKey(""ManyToManyRight_Id"", ""ManyToManyLeft_Id""); + + b.HasIndex(""ManyToManyLeft_Id""); + + b.ToTable(""Join_ManyToManyRight_ManyToManyLeft""); + }); + + modelBuilder.Entity(""Microsoft.EntityFrameworkCore.Migrations.ModelSnapshotSqlServerTest+ManyToManyLeft"", b => + { + b.Property(""Id"") + .ValueGeneratedOnAdd() + .HasColumnType(""int"") + .UseIdentityColumn(); + + b.Property(""Name"") + .HasColumnType(""nvarchar(max)""); + + b.HasKey(""Id""); + + b.ToTable(""ManyToManyLeft""); + }); + + modelBuilder.Entity(""Microsoft.EntityFrameworkCore.Migrations.ModelSnapshotSqlServerTest+ManyToManyRight"", b => + { + b.Property(""Id"") + .ValueGeneratedOnAdd() + .HasColumnType(""int"") + .UseIdentityColumn(); + + b.Property(""Description"") + .HasColumnType(""nvarchar(max)""); + + b.HasKey(""Id""); + + b.ToTable(""ManyToManyRight""); + }); + + modelBuilder.Entity(""Join_ManyToManyRight_ManyToManyLeft"", b => + { + b.HasOne(""Microsoft.EntityFrameworkCore.Migrations.ModelSnapshotSqlServerTest+ManyToManyLeft"", null) + .WithMany() + .HasForeignKey(""ManyToManyLeft_Id"") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne(""Microsoft.EntityFrameworkCore.Migrations.ModelSnapshotSqlServerTest+ManyToManyRight"", null) + .WithMany() + .HasForeignKey(""ManyToManyRight_Id"") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + });", usingSystem: true), + model => + { + var associationEntity = model.FindEntityType("Join_ManyToManyRight_ManyToManyLeft"); + Assert.NotNull(associationEntity); + Assert.Collection(associationEntity.GetDeclaredProperties(), + p => + { + Assert.Equal("ManyToManyRight_Id", p.Name); + Assert.True(p.IsShadowProperty()); + }, + p => + { + Assert.Equal("ManyToManyLeft_Id", p.Name); + Assert.True(p.IsShadowProperty()); + }); + Assert.Collection(associationEntity.FindDeclaredPrimaryKey().Properties, + p => + { + Assert.Equal("ManyToManyRight_Id", p.Name); + }, + p => + { + Assert.Equal("ManyToManyLeft_Id", p.Name); + }); + Assert.Collection(associationEntity.GetDeclaredForeignKeys(), + fk => + { + Assert.Equal("Microsoft.EntityFrameworkCore.Migrations.ModelSnapshotSqlServerTest+ManyToManyLeft", fk.PrincipalEntityType.Name); + Assert.Collection(fk.PrincipalKey.Properties, + p => + { + Assert.Equal("Id", p.Name); + }); + Assert.Collection(fk.Properties, + p => + { + Assert.Equal("ManyToManyLeft_Id", p.Name); + }); + }, + fk => + { + Assert.Equal("Microsoft.EntityFrameworkCore.Migrations.ModelSnapshotSqlServerTest+ManyToManyRight", fk.PrincipalEntityType.Name); + Assert.Collection(fk.PrincipalKey.Properties, + p => + { + Assert.Equal("Id", p.Name); + }); + Assert.Collection(fk.Properties, + p => + { + Assert.Equal("ManyToManyRight_Id", p.Name); + }); + }); + }); + } + [ConditionalFact] public virtual void TableName_preserved_when_generic() { diff --git a/test/EFCore.Tests/Metadata/Conventions/ManyToManyConventionTest.cs b/test/EFCore.Tests/Metadata/Conventions/ManyToManyConventionTest.cs new file mode 100644 index 00000000000..d725908cda0 --- /dev/null +++ b/test/EFCore.Tests/Metadata/Conventions/ManyToManyConventionTest.cs @@ -0,0 +1,307 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using Microsoft.EntityFrameworkCore.Diagnostics.Internal; +using Microsoft.EntityFrameworkCore.Internal; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Microsoft.EntityFrameworkCore.Metadata.Conventions.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata.Conventions.Internal; +using Microsoft.EntityFrameworkCore.Metadata.Internal; +using Microsoft.EntityFrameworkCore.TestUtilities; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +// ReSharper disable MemberCanBePrivate.Local +// ReSharper disable UnusedAutoPropertyAccessor.Local +// ReSharper disable UnusedMember.Global +// ReSharper disable ParameterOnlyUsedForPreconditionCheck.Local +// ReSharper disable UnusedMember.Local +// ReSharper disable ClassNeverInstantiated.Local +// ReSharper disable InconsistentNaming +namespace Microsoft.EntityFrameworkCore.Metadata.Conventions +{ + public class ManyToManyConventionTest + { + [ConditionalFact] + public void Association_entity_type_is_not_created_for_self_association() + { + var modelBuilder = CreateInternalModeBuilder(); + var manyToManySelf = modelBuilder.Entity(typeof(ManyToManySelf), ConfigurationSource.Convention); + + manyToManySelf.PrimaryKey(new[] { nameof(ManyToManySelf.Id) }, ConfigurationSource.Convention); + + var firstSkipNav = manyToManySelf.HasSkipNavigation( + new MemberIdentity(typeof(ManyToManySelf).GetProperty(nameof(ManyToManySelf.ManyToManySelf1))), + manyToManySelf.Metadata, + ConfigurationSource.Convention); + var secondSkipNav = manyToManySelf.HasSkipNavigation( + new MemberIdentity(typeof(ManyToManySelf).GetProperty(nameof(ManyToManySelf.ManyToManySelf2))), + manyToManySelf.Metadata, + ConfigurationSource.Convention); + + RunConvention(firstSkipNav); + + Assert.Empty(manyToManySelf.Metadata.Model.GetEntityTypes() + .Where(et => et.IsAutomaticallyCreatedAssociationEntityType)); + } + + [ConditionalFact] + public void Association_entity_type_is_not_created_when_no_matching_skip_navigation() + { + var modelBuilder = CreateInternalModeBuilder(); + var manyToManyFirst = modelBuilder.Entity(typeof(ManyToManyFirst), ConfigurationSource.Convention); + var manyToManySecond = modelBuilder.Entity(typeof(ManyToManySecond), ConfigurationSource.Convention); + var manyToManyJoin = modelBuilder.Entity(typeof(ManyToManyJoin), ConfigurationSource.Convention); + + var manyToManyFirstPK = manyToManyFirst.PrimaryKey(new[] { nameof(ManyToManyFirst.Id) }, ConfigurationSource.Convention); + var manyToManySecondPK = manyToManySecond.PrimaryKey(new[] { nameof(ManyToManySecond.Id) }, ConfigurationSource.Convention); + + var skipNavOnFirst = manyToManyFirst.HasSkipNavigation( + new MemberIdentity(typeof(ManyToManyFirst).GetProperty(nameof(ManyToManyFirst.ManyToManySeconds))), + manyToManySecond.Metadata, + ConfigurationSource.Convention); + // do not create second skipNav + + RunConvention(skipNavOnFirst); + + Assert.Empty(manyToManyFirst.Metadata.Model.GetEntityTypes() + .Where(et => et.IsAutomaticallyCreatedAssociationEntityType)); + } + + [ConditionalFact] + public void Association_entity_type_is_not_created_when_skip_navigation_is_not_collection() + { + var modelBuilder = CreateInternalModeBuilder(); + var manyToManyFirst = modelBuilder.Entity(typeof(ManyToManyFirst), ConfigurationSource.Convention); + var manyToManySecond = modelBuilder.Entity(typeof(ManyToManySecond), ConfigurationSource.Convention); + var manyToManyJoin = modelBuilder.Entity(typeof(ManyToManyJoin), ConfigurationSource.Convention); + + var manyToManyFirstPK = manyToManyFirst.PrimaryKey(new[] { nameof(ManyToManyFirst.Id) }, ConfigurationSource.Convention); + var manyToManySecondPK = manyToManySecond.PrimaryKey(new[] { nameof(ManyToManySecond.Id) }, ConfigurationSource.Convention); + + var skipNavOnFirst = manyToManyFirst.HasSkipNavigation( + new MemberIdentity(typeof(ManyToManyFirst).GetProperty(nameof(ManyToManyFirst.Second))), + manyToManySecond.Metadata, + ConfigurationSource.Convention, + collection: false); + var skipNavOnSecond = manyToManySecond.HasSkipNavigation( + new MemberIdentity(typeof(ManyToManySecond).GetProperty(nameof(ManyToManySecond.ManyToManyFirsts))), + manyToManyFirst.Metadata, + ConfigurationSource.Convention); + + RunConvention(skipNavOnFirst); + + Assert.Empty(manyToManyFirst.Metadata.Model.GetEntityTypes() + .Where(et => et.IsAutomaticallyCreatedAssociationEntityType)); + } + + [ConditionalFact] + public void Association_entity_type_is_not_created_when_matching_skip_navigation_is_not_collection() + { + var modelBuilder = CreateInternalModeBuilder(); + var manyToManyFirst = modelBuilder.Entity(typeof(ManyToManyFirst), ConfigurationSource.Convention); + var manyToManySecond = modelBuilder.Entity(typeof(ManyToManySecond), ConfigurationSource.Convention); + var manyToManyJoin = modelBuilder.Entity(typeof(ManyToManyJoin), ConfigurationSource.Convention); + + var manyToManyFirstPK = manyToManyFirst.PrimaryKey(new[] { nameof(ManyToManyFirst.Id) }, ConfigurationSource.Convention); + var manyToManySecondPK = manyToManySecond.PrimaryKey(new[] { nameof(ManyToManySecond.Id) }, ConfigurationSource.Convention); + + var skipNavOnFirst = manyToManyFirst.HasSkipNavigation( + new MemberIdentity(typeof(ManyToManyFirst).GetProperty(nameof(ManyToManyFirst.ManyToManySeconds))), + manyToManySecond.Metadata, + ConfigurationSource.Convention); + var skipNavOnSecond = manyToManySecond.HasSkipNavigation( + new MemberIdentity(typeof(ManyToManySecond).GetProperty(nameof(ManyToManySecond.First))), + manyToManyFirst.Metadata, + ConfigurationSource.Convention, + collection: false); + + RunConvention(skipNavOnFirst); + + Assert.Empty(manyToManyFirst.Metadata.Model.GetEntityTypes() + .Where(et => et.IsAutomaticallyCreatedAssociationEntityType)); + } + + [ConditionalFact] + public void Association_entity_type_is_not_created_when_skip_navigation_already_in_use() + { + var modelBuilder = CreateInternalModeBuilder(); + var manyToManyFirst = modelBuilder.Entity(typeof(ManyToManyFirst), ConfigurationSource.Convention); + var manyToManySecond = modelBuilder.Entity(typeof(ManyToManySecond), ConfigurationSource.Convention); + var manyToManyJoin = modelBuilder.Entity(typeof(ManyToManyJoin), ConfigurationSource.Convention); + + var manyToManyFirstPK = manyToManyFirst.PrimaryKey(new[] { nameof(ManyToManyFirst.Id) }, ConfigurationSource.Convention); + var manyToManySecondPK = manyToManySecond.PrimaryKey(new[] { nameof(ManyToManySecond.Id) }, ConfigurationSource.Convention); + + var skipNavOnFirst = manyToManyFirst.HasSkipNavigation( + new MemberIdentity(typeof(ManyToManyFirst).GetProperty(nameof(ManyToManyFirst.ManyToManySeconds))), + manyToManySecond.Metadata, + ConfigurationSource.Convention); + var skipNavOnSecond = manyToManySecond.HasSkipNavigation( + new MemberIdentity(typeof(ManyToManySecond).GetProperty(nameof(ManyToManySecond.ManyToManyFirsts))), + manyToManyFirst.Metadata, + ConfigurationSource.Convention); + + // assign a non-null foreign key to skipNavOnFirst to make it appear to be "in use" + var leftFK = manyToManyJoin.HasRelationship( + manyToManyFirst.Metadata.Name, + new[] { nameof(ManyToManyJoin.LeftId) }, + manyToManyFirstPK.Metadata, + ConfigurationSource.Convention); + skipNavOnFirst.Metadata.SetForeignKey(leftFK.Metadata, ConfigurationSource.Convention); + + RunConvention(skipNavOnFirst); + + Assert.Empty(manyToManyFirst.Metadata.Model.GetEntityTypes() + .Where(et => et.IsAutomaticallyCreatedAssociationEntityType)); + } + + [ConditionalFact] + public void Association_entity_type_is_not_created_when_matching_skip_navigation_already_in_use() + { + var modelBuilder = CreateInternalModeBuilder(); + var manyToManyFirst = modelBuilder.Entity(typeof(ManyToManyFirst), ConfigurationSource.Convention); + var manyToManySecond = modelBuilder.Entity(typeof(ManyToManySecond), ConfigurationSource.Convention); + var manyToManyJoin = modelBuilder.Entity(typeof(ManyToManyJoin), ConfigurationSource.Convention); + + var manyToManyFirstPK = manyToManyFirst.PrimaryKey(new[] { nameof(ManyToManyFirst.Id) }, ConfigurationSource.Convention); + var manyToManySecondPK = manyToManySecond.PrimaryKey(new[] { nameof(ManyToManySecond.Id) }, ConfigurationSource.Convention); + + var skipNavOnFirst = manyToManyFirst.HasSkipNavigation( + new MemberIdentity(typeof(ManyToManyFirst).GetProperty(nameof(ManyToManyFirst.ManyToManySeconds))), + manyToManySecond.Metadata, + ConfigurationSource.Convention); + var skipNavOnSecond = manyToManySecond.HasSkipNavigation( + new MemberIdentity(typeof(ManyToManySecond).GetProperty(nameof(ManyToManySecond.ManyToManyFirsts))), + manyToManyFirst.Metadata, + ConfigurationSource.Convention); + + // assign a non-null foreign key to skipNavOnSecond to make it appear to be "in use" + var rightFK = manyToManyJoin.HasRelationship( + manyToManySecond.Metadata.Name, + new[] { nameof(ManyToManyJoin.RightId) }, + manyToManySecondPK.Metadata, + ConfigurationSource.Convention); + skipNavOnSecond.Metadata.SetForeignKey(rightFK.Metadata, ConfigurationSource.Convention); + + RunConvention(skipNavOnFirst); + + Assert.Empty(manyToManyFirst.Metadata.Model.GetEntityTypes() + .Where(et => et.IsAutomaticallyCreatedAssociationEntityType)); + } + + [ConditionalFact] + public void Association_entity_type_is_created() + { + var modelBuilder = CreateInternalModeBuilder(); + var manyToManyFirst = modelBuilder.Entity(typeof(ManyToManyFirst), ConfigurationSource.Convention); + var manyToManySecond = modelBuilder.Entity(typeof(ManyToManySecond), ConfigurationSource.Convention); + + manyToManyFirst.PrimaryKey(new[] { nameof(ManyToManyFirst.Id) }, ConfigurationSource.Convention); + manyToManySecond.PrimaryKey(new[] { nameof(ManyToManySecond.Id) }, ConfigurationSource.Convention); + + var skipNavOnFirst = manyToManyFirst.HasSkipNavigation( + new MemberIdentity(typeof(ManyToManyFirst).GetProperty(nameof(ManyToManyFirst.ManyToManySeconds))), + manyToManySecond.Metadata, + ConfigurationSource.Convention); + var skipNavOnSecond = manyToManySecond.HasSkipNavigation( + new MemberIdentity(typeof(ManyToManySecond).GetProperty(nameof(ManyToManySecond.ManyToManyFirsts))), + manyToManyFirst.Metadata, + ConfigurationSource.Convention); + + RunConvention(skipNavOnFirst); + + var joinEntityType = manyToManyFirst.Metadata.Model.GetEntityTypes() + .Single(et => et.IsAutomaticallyCreatedAssociationEntityType); + + Assert.Equal("Join_ManyToManyFirst_ManyToManySecond", joinEntityType.Name); + + var skipNavOnManyToManyFirst = manyToManyFirst.Metadata.GetSkipNavigations().Single(); + var skipNavOnManyToManySecond = manyToManySecond.Metadata.GetSkipNavigations().Single(); + Assert.Equal("ManyToManySeconds", skipNavOnManyToManyFirst.Name); + Assert.Equal("ManyToManyFirsts", skipNavOnManyToManySecond.Name); + Assert.Same(skipNavOnManyToManyFirst.Inverse, skipNavOnManyToManySecond); + Assert.Same(skipNavOnManyToManySecond.Inverse, skipNavOnManyToManyFirst); + + var manyToManyFirstForeignKey = skipNavOnManyToManyFirst.ForeignKey; + var manyToManySecondForeignKey = skipNavOnManyToManySecond.ForeignKey; + Assert.NotNull(manyToManyFirstForeignKey); + Assert.NotNull(manyToManySecondForeignKey); + Assert.Equal(2, joinEntityType.GetForeignKeys().Count()); + Assert.Equal(manyToManyFirstForeignKey.DeclaringEntityType, joinEntityType); + Assert.Equal(manyToManySecondForeignKey.DeclaringEntityType, joinEntityType); + + var key = joinEntityType.FindPrimaryKey(); + Assert.Equal( + new[] { + nameof(ManyToManyFirst) + "_" + nameof(ManyToManyFirst.Id), + nameof(ManyToManySecond) + "_" + nameof(ManyToManySecond.Id) }, + key.Properties.Select(p => p.Name)); + } + + public ListLoggerFactory ListLoggerFactory { get; } + = new ListLoggerFactory(l => l == DbLoggerCategory.Model.Name); + + private DiagnosticsLogger CreateLogger() + { + var options = new LoggingOptions(); + options.Initialize(new DbContextOptionsBuilder().EnableSensitiveDataLogging(false).Options); + var modelLogger = new DiagnosticsLogger( + ListLoggerFactory, + options, + new DiagnosticListener("Fake"), + new TestLoggingDefinitions(), + new NullDbContextLogger()); + return modelLogger; + } + + private InternalSkipNavigationBuilder RunConvention(InternalSkipNavigationBuilder skipNavBuilder) + { + var context = new ConventionContext(skipNavBuilder.Metadata.DeclaringEntityType.Model.ConventionDispatcher); + CreateManyToManyConvention().ProcessSkipNavigationAdded(skipNavBuilder, context); + return context.ShouldStopProcessing() ? (InternalSkipNavigationBuilder)context.Result : skipNavBuilder; + } + + private ManyToManyConvention CreateManyToManyConvention() + => new ManyToManyConvention(CreateDependencies()); + + private ProviderConventionSetBuilderDependencies CreateDependencies() + => InMemoryTestHelpers.Instance.CreateContextServices().GetRequiredService() + .With(CreateLogger()); + + private InternalModelBuilder CreateInternalModeBuilder() + { + return new InternalModelBuilder(new Model(new ConventionSet())); + } + + private class ManyToManyFirst + { + public int Id { get; set; } + public IEnumerable ManyToManySeconds { get; set; } + public ManyToManySecond Second { get; set; } + } + + private class ManyToManySecond + { + public int Id { get; set; } + public IEnumerable ManyToManyFirsts { get; set; } + public ManyToManyFirst First { get; set; } + } + + private class ManyToManySelf + { + public int Id { get; set; } + public IEnumerable ManyToManySelf1 { get; set; } + public IEnumerable ManyToManySelf2 { get; set; } + } + + private class ManyToManyJoin + { + public int LeftId { get; set; } + public int RightId { get; set; } + } + } +} diff --git a/test/EFCore.Tests/Metadata/Conventions/RelationshipDiscoveryConventionTest.cs b/test/EFCore.Tests/Metadata/Conventions/RelationshipDiscoveryConventionTest.cs index 16acc0b0f80..27649854f5a 100644 --- a/test/EFCore.Tests/Metadata/Conventions/RelationshipDiscoveryConventionTest.cs +++ b/test/EFCore.Tests/Metadata/Conventions/RelationshipDiscoveryConventionTest.cs @@ -239,16 +239,142 @@ public void Many_to_one_bidirectional_is_discovered() } [ConditionalFact] - public void Many_to_many_bidirectional_is_not_discovered() + public void Many_to_many_bidirectional_is_not_discovered_if_self_association() { - var entityBuilder = CreateInternalEntityBuilder(); + var modelBuilder = CreateInternalModeBuilder(); + var manyToManySelf = modelBuilder.Entity(typeof(ManyToManySelf), ConfigurationSource.Convention); - Assert.Same(entityBuilder, RunConvention(entityBuilder)); - Cleanup(entityBuilder.ModelBuilder); + manyToManySelf.PrimaryKey(new[] { nameof(ManyToManySelf.Id) }, ConfigurationSource.Convention); - Assert.Empty(entityBuilder.Metadata.GetForeignKeys()); - Assert.Empty(entityBuilder.Metadata.GetNavigations()); - Assert.Single(entityBuilder.Metadata.Model.GetEntityTypes()); + RunConvention(manyToManySelf); + + Assert.Empty(manyToManySelf.Metadata.Model.GetEntityTypes() + .Where(et => et.IsAutomaticallyCreatedAssociationEntityType)); + Assert.Empty(manyToManySelf.Metadata.GetSkipNavigations()); + } + + + [ConditionalFact] + public void Many_to_many_bidirectional_is_not_discovered_if_relationship_should_be_on_ancestors() + { + var modelBuilder = CreateInternalModeBuilder(); + var derivedManyToManyFirst = modelBuilder.Entity(typeof(DerivedManyToManyFirst), ConfigurationSource.Convention); + var derivedManyToManySecond = modelBuilder.Entity(typeof(DerivedManyToManySecond), ConfigurationSource.Convention); + + derivedManyToManyFirst.PrimaryKey(new[] { nameof(DerivedManyToManyFirst.Id) }, ConfigurationSource.Convention); + derivedManyToManySecond.PrimaryKey(new[] { nameof(DerivedManyToManySecond.Id) }, ConfigurationSource.Convention); + + RunConvention(derivedManyToManyFirst); + + Assert.Empty(derivedManyToManyFirst.Metadata.Model.GetEntityTypes() + .Where(et => et.IsAutomaticallyCreatedAssociationEntityType)); + Assert.Empty(derivedManyToManyFirst.Metadata.GetSkipNavigations()); + Assert.Empty(derivedManyToManySecond.Metadata.GetSkipNavigations()); + } + + [ConditionalFact] + public void Many_to_many_bidirectional_is_not_discovered_if_skip_navigations_already_created() + { + var modelBuilder = CreateInternalModeBuilder(); + var manyToManyFirst = modelBuilder.Entity(typeof(ManyToManyFirst), ConfigurationSource.Convention); + var manyToManySecond = modelBuilder.Entity(typeof(ManyToManySecond), ConfigurationSource.Convention); + + manyToManyFirst.PrimaryKey(new[] { nameof(ManyToManyFirst.Id) }, ConfigurationSource.Convention); + manyToManySecond.PrimaryKey(new[] { nameof(ManyToManySecond.Id) }, ConfigurationSource.Convention); + + var skipNavOnFirst = manyToManyFirst.HasSkipNavigation( + new MemberIdentity(typeof(ManyToManyFirst).GetProperty(nameof(ManyToManyFirst.ManyToManySeconds))), + manyToManySecond.Metadata, + ConfigurationSource.Convention); + var skipNavOnSecond = manyToManySecond.HasSkipNavigation( + new MemberIdentity(typeof(ManyToManySecond).GetProperty(nameof(ManyToManySecond.ManyToManyFirsts))), + manyToManyFirst.Metadata, + ConfigurationSource.Convention); + + RunConvention(manyToManyFirst); + + Assert.Empty(manyToManyFirst.Metadata.Model.GetEntityTypes() + .Where(et => et.IsAutomaticallyCreatedAssociationEntityType)); + + // the previously created skip navigations are unaffected + Assert.Same(skipNavOnFirst.Metadata, manyToManyFirst.Metadata.GetSkipNavigations().Single()); + Assert.Same(skipNavOnSecond.Metadata, manyToManySecond.Metadata.GetSkipNavigations().Single()); + } + + [ConditionalFact] + public void Many_to_many_bidirectional_is_not_discovered_if_missing_left_primary_key() + { + var modelBuilder = CreateInternalModeBuilder(); + var manyToManyFirst = modelBuilder.Entity(typeof(ManyToManyFirst), ConfigurationSource.Convention); + var manyToManySecond = modelBuilder.Entity(typeof(ManyToManySecond), ConfigurationSource.Convention); + + // left PK not defined + manyToManySecond.PrimaryKey(new[] { nameof(ManyToManySecond.Id) }, ConfigurationSource.Convention); + + RunConvention(manyToManyFirst); + + Assert.Empty(manyToManyFirst.Metadata.Model.GetEntityTypes() + .Where(et => et.IsAutomaticallyCreatedAssociationEntityType)); + Assert.Empty(manyToManyFirst.Metadata.GetSkipNavigations()); + Assert.Empty(manyToManySecond.Metadata.GetSkipNavigations()); + } + + [ConditionalFact] + public void Many_to_many_bidirectional_is_not_discovered_if_missing_right_primary_key() + { + var modelBuilder = CreateInternalModeBuilder(); + var manyToManyFirst = modelBuilder.Entity(typeof(ManyToManyFirst), ConfigurationSource.Convention); + var manyToManySecond = modelBuilder.Entity(typeof(ManyToManySecond), ConfigurationSource.Convention); + + manyToManyFirst.PrimaryKey(new[] { nameof(ManyToManyFirst.Id) }, ConfigurationSource.Convention); + // right PK not defined + + RunConvention(manyToManyFirst); + + Assert.Empty(manyToManyFirst.Metadata.Model.GetEntityTypes() + .Where(et => et.IsAutomaticallyCreatedAssociationEntityType)); + Assert.Empty(manyToManyFirst.Metadata.GetSkipNavigations()); + Assert.Empty(manyToManySecond.Metadata.GetSkipNavigations()); + } + + [ConditionalFact] + public void Many_to_many_bidirectional_is_discovered() + { + var modelBuilder = CreateInternalModeBuilder(); + var manyToManyFirst = modelBuilder.Entity(typeof(ManyToManyFirst), ConfigurationSource.Convention); + var manyToManySecond = modelBuilder.Entity(typeof(ManyToManySecond), ConfigurationSource.Convention); + + manyToManyFirst.PrimaryKey(new[] { nameof(ManyToManyFirst.Id) }, ConfigurationSource.Convention); + manyToManySecond.PrimaryKey(new[] { nameof(ManyToManySecond.Id) }, ConfigurationSource.Convention); + + RunConvention(manyToManyFirst); + + var joinEntityType = manyToManyFirst.Metadata.Model.GetEntityTypes() + .Single(et => et.IsAutomaticallyCreatedAssociationEntityType); + + Assert.Equal("Join_ManyToManyFirst_ManyToManySecond", joinEntityType.Name); + + var navigationOnManyToManyFirst = manyToManyFirst.Metadata.GetSkipNavigations().Single(); + var navigationOnManyToManySecond = manyToManySecond.Metadata.GetSkipNavigations().Single(); + Assert.Equal("ManyToManySeconds", navigationOnManyToManyFirst.Name); + Assert.Equal("ManyToManyFirsts", navigationOnManyToManySecond.Name); + Assert.Same(navigationOnManyToManyFirst.Inverse, navigationOnManyToManySecond); + Assert.Same(navigationOnManyToManySecond.Inverse, navigationOnManyToManyFirst); + + var manyToManyFirstForeignKey = navigationOnManyToManyFirst.ForeignKey; + var manyToManySecondForeignKey = navigationOnManyToManySecond.ForeignKey; + Assert.NotNull(manyToManyFirstForeignKey); + Assert.NotNull(manyToManySecondForeignKey); + Assert.Equal(2, joinEntityType.GetForeignKeys().Count()); + Assert.Equal(manyToManyFirstForeignKey.DeclaringEntityType, joinEntityType); + Assert.Equal(manyToManySecondForeignKey.DeclaringEntityType, joinEntityType); + + var key = joinEntityType.FindPrimaryKey(); + Assert.Equal( + new[] { + nameof(ManyToManyFirst) + "_" + nameof(ManyToManyFirst.Id), + nameof(ManyToManySecond) + "_" + nameof(ManyToManySecond.Id) }, + key.Properties.Select(p => p.Name)); } [ConditionalFact] @@ -1128,7 +1254,7 @@ private ProviderConventionSetBuilderDependencies CreateDependencies() => InMemoryTestHelpers.Instance.CreateContextServices().GetRequiredService() .With(CreateLogger()); - private InternalEntityTypeBuilder CreateInternalEntityBuilder(params Action[] onEntityAdded) + private InternalModelBuilder CreateInternalModeBuilder(params Action[] onEntityAdded) { var conventions = new ConventionSet(); if (onEntityAdded != null) @@ -1142,7 +1268,12 @@ private InternalEntityTypeBuilder CreateInternalEntityBuilder(params Action(params Action[] onEntityAdded) + { + var modelBuilder = CreateInternalModeBuilder(onEntityAdded); var entityBuilder = modelBuilder.Entity(typeof(T), ConfigurationSource.DataAnnotation); return entityBuilder; @@ -1239,22 +1370,33 @@ public static void IgnoreNavigation(IConventionEntityTypeBuilder entityTypeBuild private class ManyToManyFirst { - public static readonly PropertyInfo NavigationProperty = - typeof(OneToManyPrincipal).GetProperty("ManyToManySeconds", BindingFlags.Public | BindingFlags.Instance); - public int Id { get; set; } public IEnumerable ManyToManySeconds { get; set; } } private class ManyToManySecond { - public static readonly PropertyInfo NavigationProperty = - typeof(OneToManyPrincipal).GetProperty("ManyToManyFirsts", BindingFlags.Public | BindingFlags.Instance); - public int Id { get; set; } public IEnumerable ManyToManyFirsts { get; set; } } + private class ManyToManySelf + { + public int Id { get; set; } + public IEnumerable ManyToManySelf1 { get; set; } + public IEnumerable ManyToManySelf2 { get; set; } + } + + private class DerivedManyToManyFirst : ManyToManyFirst + { + public string Name { get; set; } + } + + private class DerivedManyToManySecond : ManyToManySecond + { + public string Name { get; set; } + } + private class MultipleNavigationsFirst { public static readonly PropertyInfo CollectionNavigationProperty = diff --git a/test/EFCore.Tests/Metadata/Internal/InternalModelBuilderTest.cs b/test/EFCore.Tests/Metadata/Internal/InternalModelBuilderTest.cs index 360f7f97dc4..8748c4925e1 100644 --- a/test/EFCore.Tests/Metadata/Internal/InternalModelBuilderTest.cs +++ b/test/EFCore.Tests/Metadata/Internal/InternalModelBuilderTest.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using System.Collections.Generic; using System.Linq; using System.Reflection; using Microsoft.EntityFrameworkCore.Diagnostics; @@ -347,6 +348,298 @@ public void Can_mark_type_as_owned_type() Assert.Throws(() => modelBuilder.Owned(typeof(Details), ConfigurationSource.Explicit)).Message); } + [ConditionalFact] + public void Throws_if_create_association_entity_type_with_invalid_left_skip_navigation() + { + var model = new Model(); + var modelBuilder = CreateModelBuilder(model); + + var manyToManyLeft = modelBuilder.Entity(typeof(ManyToManyLeft), ConfigurationSource.Convention); + var manyToManyRight = modelBuilder.Entity(typeof(ManyToManyRight), ConfigurationSource.Convention); + var manyToManyOtherRight = modelBuilder.Entity(typeof(ManyToManyOtherRight), ConfigurationSource.Convention); + + // skipNavOnLeft does not target skipNavOnRight.DeclaringEntityType + var skipNavOnLeft = manyToManyLeft.HasSkipNavigation( + new MemberIdentity(typeof(ManyToManyLeft).GetProperty(nameof(ManyToManyLeft.OtherRights))), + manyToManyOtherRight.Metadata, + ConfigurationSource.Convention); + var skipNavOnRight = manyToManyRight.HasSkipNavigation( + new MemberIdentity(typeof(ManyToManyRight).GetProperty(nameof(ManyToManyRight.Lefts))), + manyToManyLeft.Metadata, + ConfigurationSource.Convention); + + Assert.Equal( + CoreStrings.InvalidSkipNavigationsForAssociationEntityType( + "Microsoft.EntityFrameworkCore.Metadata.Internal.InternalModelBuilderTest+ManyToManyLeft", + nameof(ManyToManyLeft.OtherRights), + "Microsoft.EntityFrameworkCore.Metadata.Internal.InternalModelBuilderTest+ManyToManyOtherRight", + "Microsoft.EntityFrameworkCore.Metadata.Internal.InternalModelBuilderTest+ManyToManyRight", + nameof(ManyToManyRight.Lefts), + "Microsoft.EntityFrameworkCore.Metadata.Internal.InternalModelBuilderTest+ManyToManyLeft"), + Assert.Throws(() => modelBuilder.AssociationEntity( + skipNavOnLeft.Metadata, skipNavOnRight.Metadata, ConfigurationSource.Explicit)).Message); + + Assert.Empty(model.GetEntityTypes() + .Where(e => e.IsAutomaticallyCreatedAssociationEntityType)); + } + + [ConditionalFact] + public void Throws_if_create_association_entity_type_with_invalid_right_skip_navigation() + { + var model = new Model(); + var modelBuilder = CreateModelBuilder(model); + + var manyToManyLeft = modelBuilder.Entity(typeof(ManyToManyLeft), ConfigurationSource.Convention); + var manyToManyRight = modelBuilder.Entity(typeof(ManyToManyRight), ConfigurationSource.Convention); + var manyToManyOtherLeft = modelBuilder.Entity(typeof(ManyToManyOtherLeft), ConfigurationSource.Convention); + + // skipNavOnRight does not target skipNavOnLeft.DeclaringEntityType + var skipNavOnLeft = manyToManyLeft.HasSkipNavigation( + new MemberIdentity(typeof(ManyToManyLeft).GetProperty(nameof(ManyToManyLeft.Rights))), + manyToManyRight.Metadata, + ConfigurationSource.Convention); + var skipNavOnRight = manyToManyRight.HasSkipNavigation( + new MemberIdentity(typeof(ManyToManyRight).GetProperty(nameof(ManyToManyRight.OtherLefts))), + manyToManyOtherLeft.Metadata, + ConfigurationSource.Convention); + + Assert.Equal( + CoreStrings.InvalidSkipNavigationsForAssociationEntityType( + "Microsoft.EntityFrameworkCore.Metadata.Internal.InternalModelBuilderTest+ManyToManyLeft", + nameof(ManyToManyLeft.Rights), + "Microsoft.EntityFrameworkCore.Metadata.Internal.InternalModelBuilderTest+ManyToManyRight", + "Microsoft.EntityFrameworkCore.Metadata.Internal.InternalModelBuilderTest+ManyToManyRight", + nameof(ManyToManyRight.OtherLefts), + "Microsoft.EntityFrameworkCore.Metadata.Internal.InternalModelBuilderTest+ManyToManyOtherLeft"), + Assert.Throws(() => modelBuilder.AssociationEntity( + skipNavOnLeft.Metadata, skipNavOnRight.Metadata, ConfigurationSource.Explicit)).Message); + + Assert.Empty(model.GetEntityTypes() + .Where(e => e.IsAutomaticallyCreatedAssociationEntityType)); + } + + [ConditionalFact] + public void Throws_if_create_association_entity_type_with_left_entity_type_with_no_primary_key() + { + var model = new Model(); + var modelBuilder = CreateModelBuilder(model); + + var manyToManyLeft = modelBuilder.Entity(typeof(ManyToManyLeft), ConfigurationSource.Convention); + var manyToManyRight = modelBuilder.Entity(typeof(ManyToManyRight), ConfigurationSource.Convention); + // do not define manyToManyLeft.PrimaryKey + manyToManyRight.PrimaryKey(new[] { nameof(ManyToManyRight.Id) }, ConfigurationSource.Convention); + + var skipNavOnLeft = manyToManyLeft.HasSkipNavigation( + new MemberIdentity(typeof(ManyToManyLeft).GetProperty(nameof(ManyToManyLeft.Rights))), + manyToManyRight.Metadata, + ConfigurationSource.Convention); + var skipNavOnRight = manyToManyRight.HasSkipNavigation( + new MemberIdentity(typeof(ManyToManyRight).GetProperty(nameof(ManyToManyRight.Lefts))), + manyToManyLeft.Metadata, + ConfigurationSource.Convention); + + Assert.Equal( + CoreStrings.UnableToCreateSkipNavigationForeignKeyOnAssociationEntityType( + "Microsoft.EntityFrameworkCore.Metadata.Internal.InternalModelBuilderTest+ManyToManyLeft", + nameof(ManyToManyLeft.Rights), + "Join_ManyToManyLeft_ManyToManyRight"), + Assert.Throws(() => modelBuilder.AssociationEntity( + skipNavOnLeft.Metadata, skipNavOnRight.Metadata, ConfigurationSource.Explicit)).Message); + + Assert.Empty(model.GetEntityTypes() + .Where(e => e.IsAutomaticallyCreatedAssociationEntityType)); + } + + [ConditionalFact] + public void Throws_if_create_association_entity_type_with_right_entity_type_with_no_primary_key() + { + var model = new Model(); + var modelBuilder = CreateModelBuilder(model); + + var manyToManyLeft = modelBuilder.Entity(typeof(ManyToManyLeft), ConfigurationSource.Convention); + var manyToManyRight = modelBuilder.Entity(typeof(ManyToManyRight), ConfigurationSource.Convention); + manyToManyLeft.PrimaryKey(new[] { nameof(ManyToManyLeft.Id) }, ConfigurationSource.Convention); + // do not define manyToManyRight.PrimaryKey + + var skipNavOnLeft = manyToManyLeft.HasSkipNavigation( + new MemberIdentity(typeof(ManyToManyLeft).GetProperty(nameof(ManyToManyLeft.Rights))), + manyToManyRight.Metadata, + ConfigurationSource.Convention); + var skipNavOnRight = manyToManyRight.HasSkipNavigation( + new MemberIdentity(typeof(ManyToManyRight).GetProperty(nameof(ManyToManyRight.Lefts))), + manyToManyLeft.Metadata, + ConfigurationSource.Convention); + + Assert.Equal( + CoreStrings.UnableToCreateSkipNavigationForeignKeyOnAssociationEntityType( + "Microsoft.EntityFrameworkCore.Metadata.Internal.InternalModelBuilderTest+ManyToManyRight", + nameof(ManyToManyRight.Lefts), + "Join_ManyToManyLeft_ManyToManyRight"), + Assert.Throws(() => modelBuilder.AssociationEntity( + skipNavOnLeft.Metadata, skipNavOnRight.Metadata, ConfigurationSource.Explicit)).Message); + + Assert.Empty(model.GetEntityTypes() + .Where(e => e.IsAutomaticallyCreatedAssociationEntityType)); + } + + [ConditionalFact] + public void Can_create_association_entity_type() + { + var model = new Model(); + var modelBuilder = CreateModelBuilder(model); + + var manyToManyLeft = modelBuilder.Entity(typeof(ManyToManyLeft), ConfigurationSource.Convention); + var manyToManyRight = modelBuilder.Entity(typeof(ManyToManyRight), ConfigurationSource.Convention); + manyToManyLeft.PrimaryKey(new[] { nameof(ManyToManyLeft.Id) }, ConfigurationSource.Convention); + manyToManyRight.PrimaryKey(new[] { nameof(ManyToManyRight.Id) }, ConfigurationSource.Convention); + + var skipNavOnLeft = manyToManyLeft.HasSkipNavigation( + new MemberIdentity(typeof(ManyToManyLeft).GetProperty(nameof(ManyToManyLeft.Rights))), + manyToManyRight.Metadata, + ConfigurationSource.Convention); + var skipNavOnRight = manyToManyRight.HasSkipNavigation( + new MemberIdentity(typeof(ManyToManyRight).GetProperty(nameof(ManyToManyRight.Lefts))), + manyToManyLeft.Metadata, + ConfigurationSource.Convention); + + var associationEntityTypeBuilder = modelBuilder.AssociationEntity( + skipNavOnLeft.Metadata, skipNavOnRight.Metadata, ConfigurationSource.Convention); + var associationEntityType = associationEntityTypeBuilder.Metadata; + + Assert.NotNull(associationEntityType); + Assert.Equal("Join_ManyToManyLeft_ManyToManyRight", associationEntityType.Name); + Assert.Collection(associationEntityType.FindDeclaredPrimaryKey().Properties, + p => + { + Assert.Equal(nameof(ManyToManyLeft) + "_" + nameof(ManyToManyLeft.Id), p.Name); + }, + p => + { + Assert.Equal(nameof(ManyToManyRight) + "_" + nameof(ManyToManyRight.Id), p.Name); + }); + Assert.Collection(associationEntityType.GetForeignKeys(), + fk => + { + Assert.Equal("Microsoft.EntityFrameworkCore.Metadata.Internal.InternalModelBuilderTest+ManyToManyLeft", fk.PrincipalEntityType.Name); + Assert.False(fk.IsUnique); + Assert.Null(fk.PrincipalToDependent); + Assert.Null(fk.DependentToPrincipal); + var fkProp = Assert.Single(fk.Properties); + Assert.Equal(nameof(ManyToManyLeft) + "_" + nameof(ManyToManyLeft.Id), fkProp.Name); + }, + fk => + { + Assert.Equal("Microsoft.EntityFrameworkCore.Metadata.Internal.InternalModelBuilderTest+ManyToManyRight", fk.PrincipalEntityType.Name); + Assert.False(fk.IsUnique); + Assert.Null(fk.PrincipalToDependent); + Assert.Null(fk.DependentToPrincipal); + var fkProp = Assert.Single(fk.Properties); + Assert.Equal(nameof(ManyToManyRight) + "_" + nameof(ManyToManyRight.Id), fkProp.Name); + }); + } + + [ConditionalTheory] + [InlineData(true)] + [InlineData(false)] + public void Can_remove_automatically_created_association_entity_type(bool removeSkipNavs) + { + var model = new Model(); + var modelBuilder = CreateModelBuilder(model); + + var manyToManyLeft = modelBuilder.Entity(typeof(ManyToManyLeft), ConfigurationSource.Convention); + var manyToManyRight = modelBuilder.Entity(typeof(ManyToManyRight), ConfigurationSource.Convention); + manyToManyLeft.PrimaryKey(new[] { nameof(ManyToManyLeft.Id) }, ConfigurationSource.Convention); + manyToManyRight.PrimaryKey(new[] { nameof(ManyToManyRight.Id) }, ConfigurationSource.Convention); + + var skipNavOnLeft = manyToManyLeft.HasSkipNavigation( + new MemberIdentity(typeof(ManyToManyLeft).GetProperty(nameof(ManyToManyLeft.Rights))), + manyToManyRight.Metadata, + ConfigurationSource.Convention); + var skipNavOnRight = manyToManyRight.HasSkipNavigation( + new MemberIdentity(typeof(ManyToManyRight).GetProperty(nameof(ManyToManyRight.Lefts))), + manyToManyLeft.Metadata, + ConfigurationSource.Convention); + + var associationEntityTypeBuilder = modelBuilder.AssociationEntity( + skipNavOnLeft.Metadata, skipNavOnRight.Metadata, ConfigurationSource.Convention); + var associationEntityType = associationEntityTypeBuilder.Metadata; + + Assert.NotNull(associationEntityType); + + Assert.NotNull(modelBuilder.RemoveAssociationEntityIfAutomaticallyCreated( + associationEntityType, removeSkipNavs, ConfigurationSource.Convention)); + + Assert.Empty(model.GetEntityTypes() + .Where(e => e.IsAutomaticallyCreatedAssociationEntityType)); + + var leftSkipNav = manyToManyLeft.Metadata.FindDeclaredSkipNavigation(nameof(ManyToManyLeft.Rights)); + var rightSkipNav = manyToManyRight.Metadata.FindDeclaredSkipNavigation(nameof(ManyToManyRight.Lefts)); + if (removeSkipNavs) + { + Assert.Null(leftSkipNav); + Assert.Null(rightSkipNav); + } + else + { + Assert.NotNull(leftSkipNav); + Assert.NotNull(rightSkipNav); + } + } + + [ConditionalTheory] + [InlineData(true)] + [InlineData(false)] + public void Cannot_remove_manually_created_association_entity_type(bool removeSkipNavs) + { + var model = new Model(); + var modelBuilder = CreateModelBuilder(model); + + var manyToManyLeft = modelBuilder.Entity(typeof(ManyToManyLeft), ConfigurationSource.Convention); + var manyToManyRight = modelBuilder.Entity(typeof(ManyToManyRight), ConfigurationSource.Convention); + var manyToManyJoin = modelBuilder.Entity(typeof(ManyToManyJoin), ConfigurationSource.Convention); + var manyToManyLeftPK = manyToManyLeft.PrimaryKey(new[] { nameof(ManyToManyLeft.Id) }, ConfigurationSource.Convention); + var manyToManyRightPK = manyToManyRight.PrimaryKey(new[] { nameof(ManyToManyRight.Id) }, ConfigurationSource.Convention); + manyToManyJoin.PrimaryKey(new[] { nameof(ManyToManyJoin.LeftId), nameof(ManyToManyJoin.RightId) }, ConfigurationSource.Convention); + + var skipNavOnLeft = manyToManyLeft.HasSkipNavigation( + new MemberIdentity(typeof(ManyToManyLeft).GetProperty(nameof(ManyToManyLeft.Rights))), + manyToManyRight.Metadata, + ConfigurationSource.Convention); + var skipNavOnRight = manyToManyRight.HasSkipNavigation( + new MemberIdentity(typeof(ManyToManyRight).GetProperty(nameof(ManyToManyRight.Lefts))), + manyToManyLeft.Metadata, + ConfigurationSource.Convention); + + var leftFK = manyToManyJoin.HasRelationship( + manyToManyLeft.Metadata.Name, + new[] { nameof(ManyToManyJoin.LeftId) }, + manyToManyLeftPK.Metadata, + ConfigurationSource.Convention); + skipNavOnLeft.Metadata.SetForeignKey(leftFK.Metadata, ConfigurationSource.Convention); + var rightFK = manyToManyJoin.HasRelationship( + manyToManyRight.Metadata.Name, + new[] { nameof(ManyToManyJoin.RightId) }, + manyToManyRightPK.Metadata, + ConfigurationSource.Convention); + skipNavOnRight.Metadata.SetForeignKey(rightFK.Metadata, ConfigurationSource.Convention); + skipNavOnLeft.HasInverse(skipNavOnRight.Metadata, ConfigurationSource.Convention); + + var associationEntityType = skipNavOnLeft.Metadata.AssociationEntityType; + Assert.NotNull(associationEntityType); + Assert.Same(associationEntityType, skipNavOnRight.Metadata.AssociationEntityType); + + Assert.Null(modelBuilder.RemoveAssociationEntityIfAutomaticallyCreated( + associationEntityType, removeSkipNavs, ConfigurationSource.Convention)); + + var leftSkipNav = manyToManyLeft.Metadata.FindDeclaredSkipNavigation(nameof(ManyToManyLeft.Rights)); + var rightSkipNav = manyToManyRight.Metadata.FindDeclaredSkipNavigation(nameof(ManyToManyRight.Lefts)); + Assert.NotNull(leftSkipNav); + Assert.NotNull(rightSkipNav); + + Assert.Same(leftSkipNav.AssociationEntityType, rightSkipNav.AssociationEntityType); + Assert.Same(manyToManyJoin.Metadata, leftSkipNav.AssociationEntityType); + } + private static void Cleanup(InternalModelBuilder modelBuilder) { new ModelCleanupConvention(CreateDependencies()) @@ -404,5 +697,37 @@ private class Details { public string Name { get; set; } } + + private class ManyToManyLeft + { + public int Id { get; set; } + public List Rights { get; set; } + public List OtherRights { get; set; } + } + + private class ManyToManyRight + { + public int Id { get; set; } + public List Lefts { get; set; } + public List OtherLefts { get; set; } + } + + private class ManyToManyOtherLeft + { + public int Id { get; set; } + public List Rights { get; set; } + } + + private class ManyToManyOtherRight + { + public int Id { get; set; } + public List Lefts { get; set; } + } + + private class ManyToManyJoin + { + public int LeftId { get; set; } + public int RightId { get; set; } + } } } diff --git a/test/EFCore.Tests/Metadata/Internal/InternalSkipNavigationBuilderTest.cs b/test/EFCore.Tests/Metadata/Internal/InternalSkipNavigationBuilderTest.cs index 9de0e8758c7..2db940b08d8 100644 --- a/test/EFCore.Tests/Metadata/Internal/InternalSkipNavigationBuilderTest.cs +++ b/test/EFCore.Tests/Metadata/Internal/InternalSkipNavigationBuilderTest.cs @@ -126,14 +126,23 @@ public void Can_only_override_lower_or_equal_source_ForeignKey() var builder = CreateInternalSkipNavigationBuilder(); IConventionSkipNavigation metadata = builder.Metadata; - var fk = (ForeignKey)metadata.DeclaringEntityType.Model.Builder.Entity(typeof(OrderProduct)) + // the skip navigation is pointing to the automatically-generated association entity type + var originalFK = metadata.ForeignKey; + Assert.NotNull(originalFK); + Assert.Equal(ConfigurationSource.Convention, metadata.GetForeignKeyConfigurationSource()); + + var orderProductEntity = metadata.DeclaringEntityType.Model.Builder.Entity(typeof(OrderProduct)); + var fk = (ForeignKey)orderProductEntity .HasRelationship(metadata.DeclaringEntityType, nameof(OrderProduct.Order)) .IsUnique(false) .Metadata; - Assert.Null(metadata.ForeignKey); - Assert.Null(metadata.GetForeignKeyConfigurationSource()); + // skip navigation is unaffected by the FK created above + Assert.NotSame(fk, metadata.ForeignKey); + Assert.Same(originalFK, metadata.ForeignKey); + Assert.Equal(ConfigurationSource.Convention, metadata.GetForeignKeyConfigurationSource()); + // now explicitly assign the skip navigation's ForeignKey Assert.True(builder.CanSetForeignKey(fk, ConfigurationSource.DataAnnotation)); Assert.NotNull(builder.HasForeignKey(fk, ConfigurationSource.DataAnnotation)); @@ -161,16 +170,19 @@ public void Can_only_override_lower_or_equal_source_Inverse() var builder = CreateInternalSkipNavigationBuilder(); IConventionSkipNavigation metadata = builder.Metadata; + // the skip navigation is pointing to the automatically-generated + // association entity type and so is its inverse var inverse = (SkipNavigation)metadata.TargetEntityType.Builder.HasSkipNavigation( Product.OrdersProperty, metadata.DeclaringEntityType) .Metadata; - Assert.Null(metadata.Inverse); - Assert.Null(metadata.GetInverseConfigurationSource()); - Assert.Null(inverse.Inverse); - Assert.Null(inverse.GetInverseConfigurationSource()); + Assert.NotNull(metadata.Inverse); + Assert.Equal(ConfigurationSource.Convention, metadata.GetInverseConfigurationSource()); + Assert.NotNull(inverse.Inverse); + Assert.Equal(ConfigurationSource.Convention, inverse.GetInverseConfigurationSource()); + // now explicitly assign the skip navigation's Inverse Assert.True(builder.CanSetInverse(inverse, ConfigurationSource.DataAnnotation)); Assert.NotNull(builder.HasInverse(inverse, ConfigurationSource.DataAnnotation)); diff --git a/test/EFCore.Tests/ModelBuilding/ManyToManyTestBase.cs b/test/EFCore.Tests/ModelBuilding/ManyToManyTestBase.cs index d8303fd0814..847c9deaca5 100644 --- a/test/EFCore.Tests/ModelBuilding/ManyToManyTestBase.cs +++ b/test/EFCore.Tests/ModelBuilding/ManyToManyTestBase.cs @@ -5,6 +5,7 @@ using System.Linq; using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Metadata.Internal; using Xunit; // ReSharper disable InconsistentNaming @@ -123,7 +124,66 @@ public virtual void Finds_existing_navigations_and_uses_associated_FK_with_field } [ConditionalFact] - public virtual void Configures_association_type() + public virtual void Association_type_is_automatically_configured_by_convention() + { + var modelBuilder = CreateModelBuilder(); + var model = modelBuilder.Model; + + modelBuilder.Entity(); + + var manyToManyA = model.FindEntityType(typeof(AutomaticManyToManyA)); + var manyToManyB = model.FindEntityType(typeof(AutomaticManyToManyB)); + var joinEntityType = model.GetEntityTypes() + .Where(et => ((EntityType)et).IsAutomaticallyCreatedAssociationEntityType) + .Single(); + Assert.Equal("Join_AutomaticManyToManyB_AutomaticManyToManyA", joinEntityType.Name); + + var navigationOnManyToManyA = manyToManyA.GetSkipNavigations().Single(); + var navigationOnManyToManyB = manyToManyB.GetSkipNavigations().Single(); + Assert.Equal("Bs", navigationOnManyToManyA.Name); + Assert.Equal("As", navigationOnManyToManyB.Name); + Assert.Same(navigationOnManyToManyA.Inverse, navigationOnManyToManyB); + Assert.Same(navigationOnManyToManyB.Inverse, navigationOnManyToManyA); + + var manyToManyAForeignKey = navigationOnManyToManyA.ForeignKey; + var manyToManyBForeignKey = navigationOnManyToManyB.ForeignKey; + Assert.NotNull(manyToManyAForeignKey); + Assert.NotNull(manyToManyBForeignKey); + Assert.Equal(2, joinEntityType.GetForeignKeys().Count()); + Assert.Equal(manyToManyAForeignKey.DeclaringEntityType, joinEntityType); + Assert.Equal(manyToManyBForeignKey.DeclaringEntityType, joinEntityType); + + var key = joinEntityType.FindPrimaryKey(); + Assert.Equal( + new[] { + nameof(AutomaticManyToManyB) + "_" + nameof(AutomaticManyToManyB.Id), + nameof(AutomaticManyToManyA) + "_" + nameof(AutomaticManyToManyA.Id) }, + key.Properties.Select(p => p.Name)); + + modelBuilder.FinalizeModel(); + } + + [ConditionalFact] + public virtual void Association_type_is_not_automatically_configured_when_navigations_are_ambiguous() + { + var modelBuilder = CreateModelBuilder(); + var model = modelBuilder.Model; + + modelBuilder.Entity(); + + var hob = model.FindEntityType(typeof(Hob)); + var nob = model.FindEntityType(typeof(Nob)); + Assert.NotNull(hob); + Assert.NotNull(nob); + Assert.Empty(model.GetEntityTypes() + .Where(et => ((EntityType)et).IsAutomaticallyCreatedAssociationEntityType)); + + Assert.Empty(hob.GetSkipNavigations()); + Assert.Empty(nob.GetSkipNavigations()); + } + + [ConditionalFact] + public virtual void Can_configure_association_type_using_fluent_api() { var modelBuilder = CreateModelBuilder(); var model = modelBuilder.Model; @@ -189,6 +249,9 @@ public virtual void Throws_for_conflicting_many_to_one_on_left() var modelBuilder = CreateModelBuilder(); var model = modelBuilder.Model; + // make sure we do not set up the automatic many-to-many relationship + modelBuilder.Entity().Ignore(e => e.Products); + modelBuilder.Entity() .HasMany(o => o.Products).WithOne(); @@ -209,6 +272,9 @@ public virtual void Throws_for_conflicting_many_to_one_on_right() var modelBuilder = CreateModelBuilder(); var model = modelBuilder.Model; + // make sure we do not set up the automatic many-to-many relationship + modelBuilder.Entity().Ignore(e => e.Products); + modelBuilder.Entity() .HasMany(o => o.Products).WithOne(); diff --git a/test/EFCore.Tests/ModelBuilding/ManyToOneTestBase.cs b/test/EFCore.Tests/ModelBuilding/ManyToOneTestBase.cs index 73fdad8503c..93d649a35bf 100644 --- a/test/EFCore.Tests/ModelBuilding/ManyToOneTestBase.cs +++ b/test/EFCore.Tests/ModelBuilding/ManyToOneTestBase.cs @@ -1611,6 +1611,9 @@ public virtual void Removes_existing_unidirectional_one_to_one_relationship() .HasForeignKey( e => new { e.HobId1, e.HobId2 }); + // The below means the relationship is no longer + // using Nob.Hob. After that it is allowed to override + // Hob.Nob's inverse in the HasOne().WithMany() call below. modelBuilder.Entity().HasOne().WithOne(e => e.Nob); var dependentType = model.FindEntityType(typeof(Hob)); @@ -1622,6 +1625,7 @@ public virtual void Removes_existing_unidirectional_one_to_one_relationship() modelBuilder.FinalizeModel(); + // assert 1:N relationship defined through the HasOne().WithMany() call above var fk = dependentType.GetForeignKeys().Single(); Assert.False(fk.IsUnique); Assert.Same(fk, dependentType.GetNavigations().Single(n => n.Name == nameof(Hob.Nob)).ForeignKey); @@ -1629,6 +1633,9 @@ public virtual void Removes_existing_unidirectional_one_to_one_relationship() Assert.Same(principalKey, principalType.FindPrimaryKey()); Assert.Same(dependentKey, dependentType.FindPrimaryKey()); + // The 1:N relationship above has "used up" Hob.Nob and Nob.Hobs, + // so now the RelationshipDiscoveryConvention should be able + // to unambiguously and automatically match up Nob.Hob and Hob.Nobs var oldFk = principalType.GetForeignKeys().Single(); AssertEqual(new[] { nameof(Nob.HobId1), nameof(Nob.HobId2) }, oldFk.Properties.Select(p => p.Name)); Assert.Same(oldFk, dependentType.GetNavigations().Single(n => n.Name == nameof(Hob.Nobs)).ForeignKey); diff --git a/test/EFCore.Tests/ModelBuilding/OneToManyTestBase.cs b/test/EFCore.Tests/ModelBuilding/OneToManyTestBase.cs index a391c44ad1d..740dc28d138 100644 --- a/test/EFCore.Tests/ModelBuilding/OneToManyTestBase.cs +++ b/test/EFCore.Tests/ModelBuilding/OneToManyTestBase.cs @@ -1844,7 +1844,7 @@ public virtual void Throws_on_existing_many_to_many() nameof(Category) + "." + nameof(Category.Products), nameof(Product), nameof(Category) + "." + nameof(Category.Products), - nameof(Product)), + nameof(Product) + "." + nameof(Product.Categories)), Assert.Throws( () => modelBuilder.Entity() .HasMany(o => o.Products).WithOne()).Message); @@ -1855,8 +1855,18 @@ public virtual void Throws_on_existing_one_to_one_relationship() { var modelBuilder = HobNobBuilder(); var model = modelBuilder.Model; + + // set up a 1:1 relationship using Nob.Hob and Hob.Nob modelBuilder.Entity().HasOne(e => e.Hob).WithOne(e => e.Nob); + // Now that Nob.Hob and Hob.Nob are used, Nob.Hobs and Hob.Nobs + // are no longer ambiguous and so we automatically create the many-to-many + // relationship. We want to test that the HasMany().WithOne() call + // causes a clash with the 1:1 relationship set up above. But if we + // did not call Ignore below, it would report a clash with the + // automatic many-to-many instead. + modelBuilder.Entity().Ignore(e => e.Nobs); + var dependentType = model.FindEntityType(typeof(Hob)); var principalType = model.FindEntityType(typeof(Nob)); @@ -1878,6 +1888,9 @@ public virtual void Removes_existing_unidirectional_one_to_one_relationship() var model = modelBuilder.Model; modelBuilder.Entity().HasOne(e => e.Hob).WithOne(e => e.Nob); + // The below ensures that the relationship is no longer + // using Nob.Hob. After that it is allowed to override + // Hob.Nob's inverse in the HasMany().WithOne() call below. modelBuilder.Entity().HasOne().WithOne(e => e.Nob); var dependentType = model.FindEntityType(typeof(Hob)); @@ -1887,10 +1900,16 @@ public virtual void Removes_existing_unidirectional_one_to_one_relationship() modelBuilder.Entity().HasMany(e => e.Hobs).WithOne(e => e.Nob); + // assert the 1:N relationship defined through the HasMany().WithOne() call above var fk = dependentType.GetForeignKeys().Single(); Assert.False(fk.IsUnique); Assert.Equal(nameof(Nob.Hobs), fk.PrincipalToDependent.Name); Assert.Equal(nameof(Hob.Nob), fk.DependentToPrincipal.Name); + + // The 1:N relationship above has "used up" Hob.Nob and Nob.Hobs, + // so now the RelationshipDiscoveryConvention should be able + // to unambiguously and automatically match up Nob.Hob and Hob.Nobs + // in a different 1:N relationship. var otherFk = principalType.GetForeignKeys().Single(); Assert.False(fk.IsUnique); Assert.Equal(nameof(Hob.Nobs), otherFk.PrincipalToDependent.Name); diff --git a/test/EFCore.Tests/ModelBuilding/TestModel.cs b/test/EFCore.Tests/ModelBuilding/TestModel.cs index 4b8e1f61615..d5b94963b2e 100644 --- a/test/EFCore.Tests/ModelBuilding/TestModel.cs +++ b/test/EFCore.Tests/ModelBuilding/TestModel.cs @@ -1035,5 +1035,22 @@ public class OneToOneOwnedWithField public OneToOneOwnerWithField OneToOneOwner { get; set; } } + + public class AutomaticManyToManyA + { + public int Id { get; set; } + public string Name { get; set; } + + public List Bs { get; set; } + } + + + public class AutomaticManyToManyB + { + public int Id { get; set; } + public string Name { get; set; } + + public List As { get; set; } + } } }