Skip to content
25 changes: 20 additions & 5 deletions src/EFCore/Metadata/Internal/InternalForeignKeyBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -3492,13 +3499,21 @@ private static IReadOnlyList<InternalForeignKeyBuilder> 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.
/// </summary>
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)))
{
Comment thread
AndriySvyryd marked this conversation as resolved.
principalEntityTypeBuilder = principalEntityType.Builder;
}
Comment thread
AndriySvyryd marked this conversation as resolved.
else if (Metadata.PrincipalEntityType.IsInModel)
{
principalEntityTypeBuilder = Metadata.PrincipalEntityType.Builder;
principalEntityType = Metadata.PrincipalEntityType;
Expand Down
11 changes: 10 additions & 1 deletion src/EFCore/Metadata/Internal/RelationshipSnapshot.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,19 @@ public RelationshipSnapshot(
List<(SkipNavigation, ConfigurationSource)>? referencingSkipNavigations)
{
Relationship = relationship;
PrincipalEntityType = relationship.Metadata.PrincipalEntityType;
OwnedEntityTypeSnapshot = ownedEntityTypeSnapshot;
ReferencingSkipNavigations = referencingSkipNavigations;
}

/// <summary>
/// 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.
/// </summary>
public virtual EntityType PrincipalEntityType { [DebuggerStepThrough] get; }

/// <summary>
/// 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
Expand Down Expand Up @@ -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(
Expand Down
111 changes: 111 additions & 0 deletions test/EFCore.Tests/Metadata/Internal/InternalForeignKeyBuilderTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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());

Expand Down
Loading