Skip to content

TPC: columns of a complex property inherited by a leaf table lose the complex property prefix, and adding a second one of the same type swaps the data #39244

Closed

Description

@AdCodicem

Bug description

EF Core 11 maps a complex property declared on the root of a TPC hierarchy to the leaf tables (#35025, #37410). In a leaf table, though, the columns are not named as in every other mapping. The prefix of the complex property is missing:

Mapping of Shipping.Zip (declared on the root) Column
TPH, table Vessel Shipping_Zip
TPT, table Vessel Shipping_Zip
TPC, a concrete root's own table Ships Shipping_Zip
TPC, the leaf table of that concrete root, Tankers Zip
TPC, the leaf table Ferries of an abstract root Zip

When two inherited properties collide, SharedTableConvention makes the names unique with the declaring entity type's name, not the complex property's name. Which property keeps the bare name depends on the rest of the model:

  • Shipping and Billing, both Address: Vessel_Zip <- Shipping.Zip, Zip <- Billing.Zip.
  • Nested, Port.Zip and Port.Address.Zip: Zip <- Port.Zip, Address_Zip <- Port.Address.Zip.

The names are surprising, and they also make an ordinary model change corrupt data. Start with Shipping only: its zip is stored in Ferries.Zip. Add Billing, of the same type. The migration is just ALTER TABLE "Ferries" ADD "Vessel_Zip" TEXT NOT NULL DEFAULT ''. The existing Zip column now belongs to Billing.Zip, so every stored shipping zip is read back as the billing zip, and Shipping.Zip is empty:

===== TpcContext: saved Ferry 1 with Shipping.Zip = 75001
===== migration from TpcContext to TpcTwoAddressesContext (Billing added)
  ALTER TABLE "Ferries" ADD "Vessel_Zip" TEXT NOT NULL DEFAULT '';
===== TpcTwoAddressesContext: read Ferry 1 with Shipping.Zip = '', Billing.Zip = '75001'

Cause, on main at 53fc2fc: RelationalPropertyExtensions.GetDefaultColumnName(IReadOnlyProperty, in StoreObjectIdentifier) adds the complex prefix (CreateComplexPrefix, line 292) only when the store object is the table of the complex type's declaring entity type, one of its mapping fragments, or, for a primary key property, the table of a derived type. A TPC leaf table is none of these, so the base name Zip is returned. SharedTableConvention.UniquifyColumnNames (lines 304, 318 and 333, CreatePrefix) then resolves the collision with the short name of the declaring type, walking up from the two Address complex types to Vessel.

EF Core's own baselines show the same naming. In TPCInheritanceTableSplittingQuerySqlServerTest, the leaf tables hold ParentComplexType as [Int], [UniqueInt], [NestedInt] and [NestedComplexType_UniqueInt], and the root's table holds it as [ParentComplexType_Int] and so on, e.g. SELECT [c].[Int] AS [ParentComplexType_Int], ... [c].[NestedComplexType_UniqueInt] AS [ParentComplexType_Nested_UniqueInt]. If this naming is intended, the swap above still seems worth avoiding before 11.0 ships: once it does, these names are in every migration. If it is not, the leaf columns would read Shipping_Zip and Billing_Zip, as in the root's table and in TPH/TPT, whatever else the model holds.

Workaround: name the columns explicitly on the root, v.ComplexProperty(x => x.Shipping, c => c.Property(a => a.Zip).HasColumnName("Shipping_Zip")). The leaf table then gets Shipping_Zip and Billing_Zip (TpcExplicitNamesContext below).

This does not apply to 10.0, which maps no column at all for such a property (#35025).

Repro project: https://github.com/AdCodicem/dotnet-upstream-repros/tree/main/efcore-tpc-complex-column-prefix (./repro.sh runs it from scratch). It is the program below, with a Repro.csproj targeting net11.0 that references Microsoft.EntityFrameworkCore.Sqlite 11.0.0-rc.1.26425.128. Its output (dotnet run):

===== TphContext
  Vessel: Id <- Id, Cars <- Cars, Discriminator <- Discriminator, Shipping_Zip <- Shipping.Zip
===== TptContext
  Ferries: Id <- Id, Cars <- Cars
  Vessel: Id <- Id, Shipping_Zip <- Shipping.Zip
===== TpcContext
  Ferries: Id <- Id, Cars <- Cars, Zip <- Shipping.Zip
===== TpcConcreteRootContext
  Ships: Id <- Id, Shipping_Zip <- Shipping.Zip
  Tankers: Id <- Id, Tons <- Tons, Zip <- Shipping.Zip
===== TpcTwoAddressesContext
  Ferries: Id <- Id, Cars <- Cars, Vessel_Zip <- Shipping.Zip, Zip <- Billing.Zip
===== TpcNestedContext
  Ferries: Id <- Id, Address_Zip <- Port.Address.Zip, Cars <- Cars, Zip <- Port.Zip
===== TpcExplicitNamesContext
  Ferries: Id <- Id, Billing_Zip <- Billing.Zip, Cars <- Cars, Shipping_Zip <- Shipping.Zip
===== TpcContext: saved Ferry 1 with Shipping.Zip = 75001
===== migration from TpcContext to TpcTwoAddressesContext (Billing added)
  ALTER TABLE "Ferries" ADD "Vessel_Zip" TEXT NOT NULL DEFAULT '';
===== TpcTwoAddressesContext: read Ferry 1 with Shipping.Zip = '', Billing.Zip = '75001'

Found while testing AdCodicem.ValueObjects against EF Core 11.0 RC1. Investigation and write-up assisted by Claude (Anthropic); the linked repro was run on Windows 11 and Ubuntu 24.04, and I reviewed this report.

Your code

using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Migrations.Operations;

// Where does each property of the complex types go, per mapping strategy?
Columns<TphContext>();
Columns<TptContext>();
Columns<TpcContext>();
Columns<TpcConcreteRootContext>();
Columns<TpcTwoAddressesContext>();
Columns<TpcNestedContext>();
Columns<TpcExplicitNamesContext>(); // the workaround: column names set on the root

// What a migration does when a second complex property of the same type is added to the TPC root.
BaseContext.Connection.Open();
using (var before = new TpcContext())
{
    before.Database.EnsureCreated();
    before.Add(new Ferry { Id = 1, Cars = 12, Shipping = new Address { Zip = "75001" } });
    before.SaveChanges();
    Console.WriteLine("===== TpcContext: saved Ferry 1 with Shipping.Zip = 75001");
}

using (var before = new TpcContext())
using (var after = new TpcTwoAddressesContext())
{
    Console.WriteLine("===== migration from TpcContext to TpcTwoAddressesContext (Billing added)");
    var target = after.GetService<IDesignTimeModel>().Model;
    var operations = after.GetService<IMigrationsModelDiffer>().GetDifferences(
        before.GetService<IDesignTimeModel>().Model.GetRelationalModel(),
        target.GetRelationalModel());
    foreach (var command in after.GetService<IMigrationsSqlGenerator>().Generate(operations, target))
    {
        Console.WriteLine("  " + command.CommandText.Trim());
        after.Database.ExecuteSqlRaw(command.CommandText);
    }
}

using (var after = new TpcTwoAddressesContext())
{
    var ferry = after.Set<Ferry>().Single();
    Console.WriteLine($"===== TpcTwoAddressesContext: read Ferry 1 with Shipping.Zip = '{ferry.Shipping.Zip}', Billing.Zip = '{ferry.Billing.Zip}'");
}

static void Columns<TContext>()
    where TContext : DbContext, new()
{
    using var db = new TContext();
    Console.WriteLine($"===== {typeof(TContext).Name}");
    foreach (var table in db.GetService<IDesignTimeModel>().Model.GetRelationalModel().Tables)
    {
        Console.WriteLine($"  {table.Name}: " + string.Join(", ", table.Columns.Select(column =>
            column.Name + " <- " + string.Join(" + ", column.PropertyMappings.Select(m => Path(m.Property)).Distinct()))));
    }
}

static string Path(IProperty property)
{
    var path = property.Name;
    var type = property.DeclaringType;
    while (type is IComplexType complexType)
    {
        path = complexType.ComplexProperty.Name + "." + path;
        type = complexType.ComplexProperty.DeclaringType;
    }

    return path;
}

public class Address
{
    public string Zip { get; set; } = "";
}

public class Location
{
    public string Zip { get; set; } = "";
    public Address Address { get; set; } = new();
}

public abstract class Vessel
{
    public int Id { get; set; }
    public Address Shipping { get; set; } = new();
    public Address Billing { get; set; } = new();
    public Location Port { get; set; } = new();
}

public class Ferry : Vessel
{
    public int Cars { get; set; }
}

public class Ship
{
    public int Id { get; set; }
    public Address Shipping { get; set; } = new();
}

public class Tanker : Ship
{
    public int Tons { get; set; }
}

public abstract class BaseContext : DbContext
{
    public static readonly SqliteConnection Connection = new("Data Source=:memory:");

    protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlite(Connection);
}

public class TphContext : BaseContext
{
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Vessel>(v => { v.UseTphMappingStrategy(); v.ComplexProperty(x => x.Shipping); v.Ignore(x => x.Billing); v.Ignore(x => x.Port); });
        modelBuilder.Entity<Ferry>();
    }
}

public class TptContext : BaseContext
{
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Vessel>(v => { v.UseTptMappingStrategy(); v.ComplexProperty(x => x.Shipping); v.Ignore(x => x.Billing); v.Ignore(x => x.Port); });
        modelBuilder.Entity<Ferry>().ToTable("Ferries");
    }
}

public class TpcContext : BaseContext
{
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Vessel>(v => { v.UseTpcMappingStrategy(); v.ComplexProperty(x => x.Shipping); v.Ignore(x => x.Billing); v.Ignore(x => x.Port); });
        modelBuilder.Entity<Ferry>().ToTable("Ferries");
    }
}

public class TpcConcreteRootContext : BaseContext
{
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Ship>(s => { s.UseTpcMappingStrategy(); s.ToTable("Ships"); s.ComplexProperty(x => x.Shipping); });
        modelBuilder.Entity<Tanker>().ToTable("Tankers");
    }
}

public class TpcTwoAddressesContext : BaseContext
{
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Vessel>(v => { v.UseTpcMappingStrategy(); v.ComplexProperty(x => x.Shipping); v.ComplexProperty(x => x.Billing); v.Ignore(x => x.Port); });
        modelBuilder.Entity<Ferry>().ToTable("Ferries");
    }
}

public class TpcNestedContext : BaseContext
{
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Vessel>(v => { v.UseTpcMappingStrategy(); v.Ignore(x => x.Shipping); v.Ignore(x => x.Billing); v.ComplexProperty(x => x.Port, p => p.ComplexProperty(x => x.Address)); });
        modelBuilder.Entity<Ferry>().ToTable("Ferries");
    }
}

public class TpcExplicitNamesContext : BaseContext
{
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Vessel>(v =>
        {
            v.UseTpcMappingStrategy();
            v.ComplexProperty(x => x.Shipping, c => c.Property(a => a.Zip).HasColumnName("Shipping_Zip"));
            v.ComplexProperty(x => x.Billing, c => c.Property(a => a.Zip).HasColumnName("Billing_Zip"));
            v.Ignore(x => x.Port);
        });
        modelBuilder.Entity<Ferry>().ToTable("Ferries");
    }
}

Stack traces

None: nothing throws.

Verbose output

Not relevant.

EF Core version

11.0.0-rc.1.26425.128 (the code is unchanged on main at 53fc2fc)

Database provider

Microsoft.EntityFrameworkCore.Sqlite (the naming is in the relational model, so it applies to every relational provider)

Target framework

.NET 11.0 RC1

Operating system

Windows 11 (10.0.26200) x64 and Ubuntu 24.04 x64

IDE

None (.NET SDK 11.0.100-rc.1.26425.128 CLI)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Type

Projects

No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions