Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Set TPH discriminator value correctly when discriminator is part of the primary key #21206

Closed
lenraven opened this issue Jun 10, 2020 · 3 comments · Fixed by #25652
Closed

Set TPH discriminator value correctly when discriminator is part of the primary key #21206

lenraven opened this issue Jun 10, 2020 · 3 comments · Fixed by #25652
Labels
area-change-tracking closed-fixed The issue has been fixed and is/will be included in the release indicated by the issue milestone. customer-reported type-bug
Milestone

Comments

@lenraven
Copy link

I found a problem when I try to use the discriminator of a TPH mapping as part of primary key.

The Model what I want to map

public abstract class ProviderContract
{
    public Partner Partner { get; set; }
}

public class ProviderContract1 : ProviderContract
{
    // This is a specific type in the original model what is stored in JSON in the SQL
    public string Details { get; set; }
}

public class ProviderContract2 : ProviderContract
{
    // This is a specific type in the original model what is stored in JSON in the SQL
    public string Details { get; set; }
}

public class Partner
{
    public string Id { get; set; }
}

public class Provider
{
    public string Id { get; set; }
}

SQL schema

Table: [ProviderContracts]
Columns:

  • [PartnerId]: FK to Partners table
  • [ProviderId]: FK to Providers table and type discriminator
  • [Details]: JSON serialized format of the Details property

PK: [PartnerId], [ProviderId]

DbContext

    public class SampleDbContext : DbContext
    {
        public DbSet<Partner> Partners => Set<Partner>();
        public DbSet<ProviderContract> ProviderContracts => Set<ProviderContract>();
        public DbSet<Provider> Providers => Set<Provider>();

        protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        {
            optionsBuilder.UseSqlServer(Program.ConnectionString);
            base.OnConfiguring(optionsBuilder);
        }

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.Entity<Provider>().Property(p => p.Id).HasMaxLength(10).IsRequired().IsUnicode(false);
            modelBuilder.Entity<Provider>().HasData(new Provider {Id = "prov1"}, new Provider {Id = "prov2"});

            modelBuilder.Entity<Partner>().Property(p => p.Id).HasMaxLength(10).IsRequired().IsUnicode(false);
            modelBuilder.Entity<Partner>().HasData(new Partner { Id = "partner1" });

            var contractModelBuilder = modelBuilder.Entity<ProviderContract>();

            contractModelBuilder.HasOne(p => p.Partner).WithMany().IsRequired().HasForeignKey("PartnerId");
            contractModelBuilder.HasOne<Provider>().WithMany().IsRequired().HasForeignKey("ProviderId");
            contractModelBuilder.Property<string>("ProviderId").IsRequired().HasMaxLength(10).IsUnicode(false);
            
            contractModelBuilder.HasDiscriminator<string>("ProviderId")
                .HasValue<ProviderContract1>("prov1")
                .HasValue<ProviderContract2>("prov2");

            contractModelBuilder.HasKey("PartnerId", "ProviderId");

            modelBuilder.Entity<TfProviderContract>()
                        .HasBaseType<ProviderContract>()
                        .Property(p => p.Details).IsRequired().HasColumnName(nameof(ProviderContract1.Details));
            modelBuilder.Entity<WizzProviderContract>()
                        .HasBaseType<ProviderContract>()
                        .Property(p => p.Details).IsRequired().HasColumnName(nameof(ProviderContract2.Details));
        }
    }

Sample usage

    class Program
    {
        public static readonly string ConnectionString = "Server=.;Database=EfSamplePoc;Integrated Security=true;";

        static async Task Main()
        {
            await using var context = new SampleDbContext();

            var partner = await context.Partners.FirstAsync();

            context.ProviderContracts.Add(new ProviderContract1()
                {
                    Partner = partner,
                    Details = "Provider Contract Details"
                });

            await context.SaveChangesAsync();
        }
    }

When I run this program, the save changes will throw the following error:

Microsoft.EntityFrameworkCore.DbUpdateException: 'An error occurred while updating the entries. See the inner exception for details.'

Inner Exception:
SqlException: String or binary data would be truncated in table 'EfSamplePoc.dbo.ProviderContracts', column 'ProviderId'. Truncated value: 'ProviderCo'.
The statement has been terminated.

Stack trace:
   at Microsoft.EntityFrameworkCore.Update.ReaderModificationCommandBatch.<ExecuteAsync>d__29.MoveNext()
   at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
   at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(Task task)
   at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.<ExecuteAsync>d__8.MoveNext()
   at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
   at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.<ExecuteAsync>d__8.MoveNext()
   at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
   at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(Task task)
   at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task)
   at System.Runtime.CompilerServices.TaskAwaiter`1.GetResult()
   at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.<SaveChangesAsync>d__97.MoveNext()
   at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
   at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(Task task)
   at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task)
   at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.<SaveChangesAsync>d__101.MoveNext()
   at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
   at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(Task task)
   at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task)
   at System.Runtime.CompilerServices.TaskAwaiter`1.GetResult()
   at Microsoft.EntityFrameworkCore.SqlServer.Storage.Internal.SqlServerExecutionStrategy.<ExecuteAsync>d__7`2.MoveNext()
   at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
   at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(Task task)
   at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task)
   at System.Runtime.CompilerServices.TaskAwaiter`1.GetResult()
   at Microsoft.EntityFrameworkCore.DbContext.<SaveChangesAsync>d__54.MoveNext()
   at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
   at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(Task task)
   at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task)
   at System.Runtime.CompilerServices.TaskAwaiter`1.GetResult()
   at EFCoreSamples.Program.<Main>d__1.MoveNext() in C:\Source\Temp\EFCoreSamples\EFCoreSamples\Program.cs:line 49
   at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
   at EFCoreSamples.Program.<Main>d__1.MoveNext() in C:\Source\Temp\EFCoreSamples\EFCoreSamples\Program.cs:line 49
   at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
   at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(Task task)
   at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task)
   at System.Runtime.CompilerServices.TaskAwaiter.GetResult()
   at EFCoreSamples.Program.<Main>()

The problem is that the EF tries to execute the following SQL command:

Microsoft.EntityFrameworkCore.Database.Command: Error: Failed executing DbCommand (100ms)
[Parameters=[
    @p0='partner1' (Nullable = false) (Size = 10) (DbType = AnsiString),
    @p1='ProviderContract' (Nullable = false) (Size = -1) (DbType = AnsiString),
    @p2='Provider Contract Deatils' (Nullable = false) (Size = 4000)],
    CommandType='Text', CommandTimeout='30']

SET NOCOUNT ON;
INSERT INTO [ProviderContracts] ([PartnerId], [ProviderId], [Details])
VALUES (@p0, @p1, @p2);

Workaround

  • Create a shadow property on ProviderContract: Id
  • This Id property should be the PK
  • Create a unique index for the following columns:
    • PartnerId
    • ProviderId
The modified DbContext
    public class SampleDbContext : DbContext
    {
        public DbSet<Partner> Partners => Set<Partner>();
        public DbSet<ProviderContract> ProviderContracts => Set<ProviderContract>();
        public DbSet<Provider> Providers => Set<Provider>();

        protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        {
            optionsBuilder.UseSqlServer(Program.ConnectionString);
            base.OnConfiguring(optionsBuilder);
        }

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.Entity<Provider>().Property(p => p.Id).HasMaxLength(10).IsRequired().IsUnicode(false);
            modelBuilder.Entity<Provider>().HasData(new Provider {Id = "prov1"}, new Provider {Id = "prov2"});

            modelBuilder.Entity<Partner>().Property(p => p.Id).HasMaxLength(10).IsRequired().IsUnicode(false);
            modelBuilder.Entity<Partner>().HasData(new Partner { Id = "partner1" });

            var contractModelBuilder = modelBuilder.Entity<ProviderContract>();

            // Configure the shadow property
            contractModelBuilder.Property<int>("Id").IsRequired().UseIdentityColumn();

            contractModelBuilder.HasOne(p => p.Partner).WithMany().IsRequired().HasForeignKey("PartnerId");
            contractModelBuilder.HasOne<Provider>().WithMany().IsRequired().HasForeignKey("ProviderId");
            contractModelBuilder.Property<string>("ProviderId").IsRequired().HasMaxLength(10).IsUnicode(false);
            
            contractModelBuilder.HasDiscriminator<string>("ProviderId")
                .HasValue<ProviderContract1>("prov1")
                .HasValue<ProviderContract2>("prov2");

            // Create the unique index.
            contractModelBuilder.HasIndex("PartnerId", "ProviderId").HasName("UX_PartnerId_ProviderId").IsUnique();

            // Configure the shadow property as PK
            contractModelBuilder.HasKey("Id");

            modelBuilder.Entity<TfProviderContract>()
                        .HasBaseType<ProviderContract>()
                        .Property(p => p.Details).IsRequired().HasColumnName(nameof(ProviderContract1.Details));
            modelBuilder.Entity<WizzProviderContract>()
                        .HasBaseType<ProviderContract>()
                        .Property(p => p.Details).IsRequired().HasColumnName(nameof(ProviderContract2.Details));
        }
    }

When I made these changes on the mapping, the sample program works fine.

Further technical details

EF Core version: 3.1.3
Database provider: Microsoft.EntityFrameworkCore.SqlServer
Target framework: .NET Framework 4.7.2
Operating system: Windows 10
IDE: Visual Studio 2019 16.6.1

@ajcvickers
Copy link
Member

Note for team: The reported exception is no longer thrown in EF Core 5.0. However, since the value is also on the dependent end of a relationship, we currently throw:

/home/ajcvickers/efcore/.dotnet/dotnet /home/ajcvickers/AllTogetherNow/Daily/bin/Debug/netcoreapp5.0/Daily.dll
Unhandled exception. System.InvalidOperationException: The value of 'ProviderContract1.ProviderId' is unknown when attempting to save changes. This is because the property is also part of a foreign key for which the principal entity in the relationship is not known.
   at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.InternalEntityEntry.PrepareToSave()
   at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.GetEntriesToSave(Boolean cascadeChanges)
   at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChangesAsync(DbContext _, Boolean acceptAllChangesOnSuccess, CancellationToken cancellationToken)
   at Microsoft.EntityFrameworkCore.SqlServer.Storage.Internal.SqlServerExecutionStrategy.ExecuteAsync[TState,TResult](TState state, Func`4 operation, Func`4 verifySucceeded, CancellationToken cancellationToken)
   at Microsoft.EntityFrameworkCore.DbContext.SaveChangesAsync(Boolean acceptAllChangesOnSuccess, CancellationToken cancellationToken)
   at Program.Main() in /home/ajcvickers/AllTogetherNow/Daily/Daily.cs:line 96
   at Program.Main() in /home/ajcvickers/AllTogetherNow/Daily/Daily.cs:line 96
   at Program.<Main>()

Process finished with exit code 134.

This might be fixable by changing the order of discriminator verses PK/FK initialization.

@ajcvickers
Copy link
Member

Investigated this a bit more and it seems like the discriminator property is being incorrectly set to "ProviderContract". Explicitly setting to the correct value works:

var partner = await context.Partners.FirstAsync();
var contract = new ProviderContract1
{
    Partner = partner,
    Details = "Provider Contract Details"
};

context.Add(contract);
context.Entry(contract).Property("ProviderId").CurrentValue = "prov1";

Marking this as a bug.

@ajcvickers ajcvickers added this to the Backlog milestone Jun 15, 2020
@ajcvickers ajcvickers self-assigned this Jun 15, 2020
@ajcvickers ajcvickers modified the milestones: Backlog, 6.0.0 Nov 5, 2020
@jotatoledo
Copy link

After migrating from 3.1 to 5.0 Im seeing a similar issue, under different conditions e.g. data model.

ajcvickers added a commit that referenced this issue Aug 22, 2021
Also, fixes #21206.

Let value generators indicate that they return stable values, and don't mark these values as Unknown. This stops values generated for discriminator properties from being marked as Unknown.
@ajcvickers ajcvickers added the closed-fixed The issue has been fixed and is/will be included in the release indicated by the issue milestone. label Aug 22, 2021
@ajcvickers ajcvickers changed the title TPH type discriminator part of the primary key runtime error Set TPH discriminator value correctly when discriminator is part of the primary key Aug 22, 2021
ajcvickers added a commit that referenced this issue Aug 24, 2021
…d collection (#25652)

* Detect unknown shadow primary key values when attempting to save owned collection

Fixes #19856

The fundamental issue here is that the key for owned collections is, by default, formed from shadow properties. This means the values of these properties are lost when the entities in the collection are no longer tracked by a DbContext. When the entities are then later attached there is no way to get these values back. (Contrast this with owned non-collection entities, where the shadow key value can be synthesized from the owner key.) This in turn means that there is no way to identify these entities in the database, and there is therefore no way to update or delete them.

This PR detects this situation and throws an exception with a (hopefully) helpful error message.

The best way to deal with this is to add a CLR property for the key (which can be private) to the owned entity type. The key values are then preserved while the entities are not being tracked, and re-attaching the entities works correctly. We should document this.

* Adding async code and tests.

Also, fixes #21206.

Let value generators indicate that they return stable values, and don't mark these values as Unknown. This stops values generated for discriminator properties from being marked as Unknown.
@ajcvickers ajcvickers modified the milestones: 6.0.0, 6.0.0-rc2 Aug 26, 2021
@ajcvickers ajcvickers modified the milestones: 6.0.0-rc2, 6.0.0 Nov 8, 2021
@ajcvickers ajcvickers removed their assignment Sep 1, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
area-change-tracking closed-fixed The issue has been fixed and is/will be included in the release indicated by the issue milestone. customer-reported type-bug
Projects
None yet
Development

Successfully merging a pull request may close this issue.

3 participants