diff --git a/src/EFCore/Metadata/Internal/InternalForeignKeyBuilder.cs b/src/EFCore/Metadata/Internal/InternalForeignKeyBuilder.cs index 2e39914f4ce..c21aa505d49 100644 --- a/src/EFCore/Metadata/Internal/InternalForeignKeyBuilder.cs +++ b/src/EFCore/Metadata/Internal/InternalForeignKeyBuilder.cs @@ -1437,8 +1437,15 @@ private bool CanSetIsUnique(bool? unique, ConfigurationSource? configurationSour configurationSource.Overrides(Metadata.GetPrincipalKeyConfigurationSource()), "configurationSource does not override Metadata.GetPrincipalKeyConfigurationSource"); - principalEntityType = principalEntityType.LeastDerivedType(Metadata.DeclaringEntityType)!; - dependentEntityType = dependentEntityType.LeastDerivedType(Metadata.PrincipalEntityType)!; + // When inverting, the new principal/dependent ends take the place of the old dependent/principal + // ends. If the requested type is a base of the old opposite end, keep the old (more derived) type + // so that an identifying relationship isn't moved to a base type. See #15898. + principalEntityType = principalEntityType.IsAssignableFrom(Metadata.DeclaringEntityType) + ? Metadata.DeclaringEntityType + : principalEntityType.LeastDerivedType(Metadata.DeclaringEntityType)!; + dependentEntityType = dependentEntityType.IsAssignableFrom(Metadata.PrincipalEntityType) + ? Metadata.PrincipalEntityType + : dependentEntityType.LeastDerivedType(Metadata.PrincipalEntityType)!; } else { @@ -3492,13 +3499,21 @@ private static IReadOnlyList FindRelationships( /// 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 InternalForeignKeyBuilder? Attach(InternalEntityTypeBuilder entityTypeBuilder) + public virtual InternalForeignKeyBuilder? Attach( + InternalEntityTypeBuilder entityTypeBuilder, + EntityType? principalEntityType = null) { var configurationSource = Metadata.GetConfigurationSource(); var model = Metadata.DeclaringEntityType.Model; InternalEntityTypeBuilder principalEntityTypeBuilder; - EntityType? principalEntityType; - if (Metadata.PrincipalEntityType.IsInModel) + if (principalEntityType is { IsInModel: true } + && (Metadata.PrincipalEntityType.Name == principalEntityType.Name + || (!principalEntityType.HasSharedClrType + && Metadata.PrincipalEntityType.ClrType == principalEntityType.ClrType))) + { + principalEntityTypeBuilder = principalEntityType.Builder; + } + else if (Metadata.PrincipalEntityType.IsInModel) { principalEntityTypeBuilder = Metadata.PrincipalEntityType.Builder; principalEntityType = Metadata.PrincipalEntityType; diff --git a/src/EFCore/Metadata/Internal/RelationshipSnapshot.cs b/src/EFCore/Metadata/Internal/RelationshipSnapshot.cs index bcfb41abc2a..18c10abbfc7 100644 --- a/src/EFCore/Metadata/Internal/RelationshipSnapshot.cs +++ b/src/EFCore/Metadata/Internal/RelationshipSnapshot.cs @@ -23,10 +23,19 @@ public RelationshipSnapshot( List<(SkipNavigation, ConfigurationSource)>? referencingSkipNavigations) { Relationship = relationship; + PrincipalEntityType = relationship.Metadata.PrincipalEntityType; OwnedEntityTypeSnapshot = ownedEntityTypeSnapshot; ReferencingSkipNavigations = referencingSkipNavigations; } + /// + /// 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 EntityType PrincipalEntityType { [DebuggerStepThrough] get; } + /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to /// the same compatibility standards as public APIs. It may be changed or removed without notice in @@ -65,7 +74,7 @@ public RelationshipSnapshot( { entityTypeBuilder ??= Relationship.Metadata.DeclaringEntityType.Builder; - var newRelationship = Relationship.Attach(entityTypeBuilder); + var newRelationship = Relationship.Attach(entityTypeBuilder, PrincipalEntityType); if (newRelationship != null) { OwnedEntityTypeSnapshot?.Attach( diff --git a/test/EFCore.Tests/Metadata/Internal/InternalForeignKeyBuilderTest.cs b/test/EFCore.Tests/Metadata/Internal/InternalForeignKeyBuilderTest.cs index bea8afdf953..c500645ef30 100644 --- a/test/EFCore.Tests/Metadata/Internal/InternalForeignKeyBuilderTest.cs +++ b/test/EFCore.Tests/Metadata/Internal/InternalForeignKeyBuilderTest.cs @@ -1062,6 +1062,117 @@ public void Can_set_IsConstrained_and_respects_configuration_source() Assert.False(relationshipBuilder.Metadata.IsConstrained); } + [Fact] + public void Inverting_identifying_relationship_keeps_derived_dependent() + { + var modelBuilder = CreateInternalModelBuilder(); + var customerEntityBuilder = modelBuilder.Entity(typeof(Customer), ConfigurationSource.Explicit); + customerEntityBuilder.PrimaryKey([Customer.IdProperty], ConfigurationSource.Explicit); + var orderEntityBuilder = modelBuilder.Entity(typeof(Order), ConfigurationSource.Explicit); + orderEntityBuilder.PrimaryKey([Order.IdProperty], ConfigurationSource.Explicit); + var specialOrderEntityBuilder = modelBuilder.Entity(typeof(SpecialOrder), ConfigurationSource.Explicit); + specialOrderEntityBuilder.HasBaseType(orderEntityBuilder.Metadata, ConfigurationSource.Explicit); + + // Identifying FK: principal = SpecialOrder (derived), dependent = Customer (non-derived), + // where the FK property is the dependent's primary key. + var relationshipBuilder = customerEntityBuilder + .HasRelationship(specialOrderEntityBuilder.Metadata, ConfigurationSource.Convention) + .HasForeignKey([Customer.IdProperty], ConfigurationSource.Explicit) + .HasPrincipalKey(specialOrderEntityBuilder.Metadata.FindPrimaryKey().Properties, ConfigurationSource.Explicit) + .IsUnique(true, ConfigurationSource.Explicit); + + Assert.Same(specialOrderEntityBuilder.Metadata, relationshipBuilder.Metadata.PrincipalEntityType); + Assert.Same(customerEntityBuilder.Metadata, relationshipBuilder.Metadata.DeclaringEntityType); + + // Invert the relationship, requesting the base Order as the new dependent. The inverted FK must + // remain between the derived SpecialOrder and the non-derived Customer rather than collapsing to Order. + var inverted = relationshipBuilder.HasEntityTypes( + customerEntityBuilder.Metadata, orderEntityBuilder.Metadata, ConfigurationSource.Explicit); + + Assert.NotNull(inverted); + Assert.Same(customerEntityBuilder.Metadata, inverted.Metadata.PrincipalEntityType); + Assert.Same(specialOrderEntityBuilder.Metadata, inverted.Metadata.DeclaringEntityType); + } + + [Fact] + public void Attach_uses_provided_principal_type_when_in_model() + { + var modelBuilder = CreateInternalModelBuilder(); + var principalEntityBuilder = modelBuilder.Entity(typeof(Customer), ConfigurationSource.Explicit); + principalEntityBuilder.PrimaryKey([Customer.IdProperty], ConfigurationSource.Explicit); + var dependentEntityBuilder = modelBuilder.Entity(typeof(Order), ConfigurationSource.Explicit); + + var fkBuilder = dependentEntityBuilder + .HasRelationship(principalEntityBuilder.Metadata, ConfigurationSource.Convention); + + var snapshot = InternalEntityTypeBuilder.DetachRelationship(fkBuilder.Metadata); + + // snapshot.PrincipalEntityType is Customer (in model, name matches) — the short-circuit fires + var reattached = snapshot.Attach(dependentEntityBuilder); + + Assert.NotNull(reattached); + Assert.Same(principalEntityBuilder.Metadata, reattached.Metadata.PrincipalEntityType); + Assert.Same(dependentEntityBuilder.Metadata, reattached.Metadata.DeclaringEntityType); + } + + [Fact] + public void Attach_does_not_bind_to_wrong_shared_type_principal_by_clr_type() + { + // Two shared-type entity types with the same CLR type but different names. + // Passing PrincipalB (wrong entity, same CLR type) should NOT cause the FK to bind to it; + // the !HasSharedClrType guard disables CLR-type matching for shared types. + var modelBuilder = CreateInternalModelBuilder(); + + var principalABuilder = modelBuilder.SharedTypeEntity("PrincipalA", typeof(Customer), ConfigurationSource.Explicit)!; + principalABuilder.PrimaryKey([Customer.IdProperty], ConfigurationSource.Explicit); + var principalBBuilder = modelBuilder.SharedTypeEntity("PrincipalB", typeof(Customer), ConfigurationSource.Explicit)!; + + var dependentEntityBuilder = modelBuilder.Entity(typeof(Order), ConfigurationSource.Explicit); + + var fkBuilder = dependentEntityBuilder + .HasRelationship(principalABuilder.Metadata, ConfigurationSource.Convention); + + var snapshot = InternalEntityTypeBuilder.DetachRelationship(fkBuilder.Metadata); + + // Pass PrincipalB (same CLR type as PrincipalA but different name) as principalEntityType. + // Because PrincipalB.HasSharedClrType = true the CLR-type path is skipped; name matching also + // fails, so the fallback uses the FK's recorded PrincipalA (still in model). + var reattached = snapshot.Relationship.Attach(dependentEntityBuilder, principalBBuilder.Metadata); + + Assert.NotNull(reattached); + Assert.Same(principalABuilder.Metadata, reattached.Metadata.PrincipalEntityType); + Assert.NotSame(principalBBuilder.Metadata, reattached.Metadata.PrincipalEntityType); + } + + [Fact] + public void Attach_falls_back_to_model_lookup_when_provided_principal_is_not_in_model() + { + var modelBuilder = CreateInternalModelBuilder(); + var principalEntityBuilder = modelBuilder.Entity(typeof(Customer), ConfigurationSource.Explicit); + principalEntityBuilder.PrimaryKey([Customer.IdProperty], ConfigurationSource.Explicit); + var dependentEntityBuilder = modelBuilder.Entity(typeof(Order), ConfigurationSource.Explicit); + + var fkBuilder = dependentEntityBuilder + .HasRelationship(principalEntityBuilder.Metadata, ConfigurationSource.Convention); + + var stalePrincipal = principalEntityBuilder.Metadata; + var snapshot = InternalEntityTypeBuilder.DetachRelationship(fkBuilder.Metadata); + + // Remove and re-add the principal entity to produce a stale captured reference. + modelBuilder.HasNoEntityType(stalePrincipal, ConfigurationSource.Explicit); + var newPrincipalEntityBuilder = modelBuilder.Entity(typeof(Customer), ConfigurationSource.Explicit)!; + newPrincipalEntityBuilder.PrimaryKey([Customer.IdProperty], ConfigurationSource.Explicit); + + Assert.False(stalePrincipal.IsInModel); + + // snapshot.PrincipalEntityType is stale (IsInModel = false), so the short-circuit is skipped; + // Attach falls back to model lookup by name and finds the new Customer instance. + var reattached = snapshot.Attach(dependentEntityBuilder); + + Assert.NotNull(reattached); + Assert.Same(newPrincipalEntityBuilder.Metadata, reattached.Metadata.PrincipalEntityType); + } + private InternalModelBuilder CreateInternalModelBuilder() => new(new Model());