dotnet / efcore

EF Core is a modern object-database mapper for .NET. It supports LINQ queries, change tracking, updates, and schema migrations.
https://docs.microsoft.com/ef/
MIT License
13.69k stars 3.17k forks source link

Consider improving model validation for when the derived type only has navigations and no discriminator #34107

Open alexandis opened 3 months ago

alexandis commented 3 months ago

NET8. I will try to describe the issue as briefly as possible.

1) Nuget package EntityConfig contains model-DB mappings; 2) Nuget package Base contains entity TenantInner and consumes EntityConfig: entity TenantInner is bound to table TENANT; 3) Solution MySolution consumes packages EntityConfig and Base and contains entity Tenant which inherits TenantInner. Tenant only contains navigation properties and naturally references the same table - TENANT.

The reason why I splitted TenantInner and Tenant: TenantInner is used in many solutions. Tenant contains several navigation properties - we do not want to pull them all into Base, since they are needed in MySolution only.

Problem: I cannot make this structure work, getting either "A key cannot be configured on 'Tenant' because it is a derived type" or "ORA-00904: "C1"."DISCRIMINATOR": invalid identifier" (this one happens when trying to construct EF query joining DbSet<Tenant> property in DbContext) exceptions, depending on the way I've tried to resolve the issue.

My latest setup (I don't think TenantInner structure is relevant here, so skipping):

============ EntityConfig Nuget package ===========================================

    public class TenantEntityConfig<TBase> : EntityConfigBase<TBase> where TBase : TenantEntity
    {
        public TenantEntityConfig(Action<EntityTypeBuilder<TBase>> builder = null)
            : base(builder)
        { }

        public override void Configure(EntityTypeBuilder<TBase> builder)
        {
            builder.ToTable("TENANT");
            builder.HasKey(x => x.Id);
            builder.Property(x => x.Id)
                   .HasColumnName("ID").IsRequired()
                   .ValueGeneratedNever();
            builder.Property(x => x.ShortName)
                   .HasColumnName("NAME");
            ...
            builder.Property(x => x.CompanyId)
                   .HasColumnName("COMPANY_ID").IsRequired();
            ...
            base.Configure(builder);
        }
    }

    public class TenantEntityConfig : TenantEntityConfig<TenantEntity>
    { }

============ MySolution ===========================================

    public class Tenant : TenantInner
    {
        public Company Company { get; set; }
        ...
    }

    builder.ApplyConfiguration(new TenantEntityConfig<TenantInner>());
    builder.Entity<Tenant>(builder =>
    {
        builder.HasBaseType<TenantInner>();
        builder.HasOne(x => x.Company)
               .WithMany()
               .HasPrincipalKey(x => x.Id)
               .HasForeignKey(x => x.CompanyId);
        ...
    });

I don't understand why it should not work: as I see it, when DbSet<Tenant> is referenced - the EF should construct the query using TENANT table and its navigation properties tables (if included - COMPANY etc.). If DbSet<TenantInner> is referenced (in Base package) - the same TENANT table should be used. However, I don't know how to let EF know that "discriminator" is not relevant here.

ajcvickers commented 3 months ago

This issue is lacking enough information for us to be able to fully understand what is happening. Please attach a small, runnable project or post a small, runnable code listing that reproduces what you are seeing so that we can investigate.

alexandis commented 3 months ago

Please find attached the SIMPLIFIED version. However, it looks like the root cause is the same. Just in case, I am also adding front-end part - the error is reproduced when trying to run the site.

The error in backend: "Invalid column name 'Discriminator'". The expected and required behavior: both ExtendedGood and Good classes work without a hitch with the same DB table.

Sasha.zip

And - yes, we tried to use builder.HasOne<Tenant>().WithOne() approach (as someone suggested for the this kind of issues) for TenantInner entity, but it did not work for all the scenarios. To me, it looks more like a hack since there is no TenantInner which "has" Tenant or vice versa: Tenant "is" TenantInner.

ajcvickers commented 3 months ago

@alexandis I don't see any error when running your code. Can you post the full exception and stack trace?

alexandis commented 3 months ago

Sure. When running front-end - https://localhost:4300 (pulling data from back-end) - getting this. You will see this exception in the console of the running host:

info: HttpApi.Controllers.GoodController[0] Trying to retrieve goods fail: Microsoft.EntityFrameworkCore.Database.Command[20102] Failed executing DbCommand (12ms) [Parameters=[], CommandType='Text', CommandTimeout='30'] SELECT [g].[Id], [g].[Description], [g].[Discriminator], [g].[Name] FROM [Good] AS [g] fail: Microsoft.EntityFrameworkCore.Query[10100] An exception occurred while iterating over the results of a query for context type 'EntityFramework.SashaTestDbContext'. Microsoft.Data.SqlClient.SqlException (0x80131904): Invalid column name 'Discriminator'. at Microsoft.Data.SqlClient.SqlCommand.<>c.b__211_0(Task1 result) at System.Threading.Tasks.ContinuationResultTaskFromResultTask2.InnerInvoke() at System.Threading.ExecutionContext.RunInternal(ExecutionContext executionContext, ContextCallback callback, Object state) --- End of stack trace from previous location --- at System.Threading.ExecutionContext.RunInternal(ExecutionContext executionContext, ContextCallback callback, Object state) at System.Threading.Tasks.Task.ExecuteWithThreadLocal(Task& currentTaskSlot, Thread threadPoolThread) --- End of stack trace from previous location --- at Microsoft.EntityFrameworkCore.Storage.RelationalCommand.ExecuteReaderAsync(RelationalCommandParameterObject parameterObject, CancellationToken cancellationToken) at Microsoft.EntityFrameworkCore.Storage.RelationalCommand.ExecuteReaderAsync(RelationalCommandParameterObject parameterObject, CancellationToken cancellationToken) at Microsoft.EntityFrameworkCore.Query.Internal.SingleQueryingEnumerable1.AsyncEnumerator.InitializeReaderAsync(AsyncEnumerator enumerator, CancellationToken cancellationToken) at Microsoft.EntityFrameworkCore.SqlServer.Storage.Internal.SqlServerExecutionStrategy.ExecuteAsync[TState,TResult](TState state, Func4 operation, Func4 verifySucceeded, CancellationToken cancellationToken) at Microsoft.EntityFrameworkCore.Query.Internal.SingleQueryingEnumerable1.AsyncEnumerator.MoveNextAsync() ClientConnectionId:349f16e3-ab59-4b68-9184-a0459b2e47b6 Error Number:207,State:1,Class:16 Microsoft.Data.SqlClient.SqlException (0x80131904): Invalid column name 'Discriminator'. at Microsoft.Data.SqlClient.SqlCommand.<>c.b__211_0(Task1 result) at System.Threading.Tasks.ContinuationResultTaskFromResultTask2.InnerInvoke() at System.Threading.ExecutionContext.RunInternal(ExecutionContext executionContext, ContextCallback callback, Object state) --- End of stack trace from previous location --- at System.Threading.ExecutionContext.RunInternal(ExecutionContext executionContext, ContextCallback callback, Object state) at System.Threading.Tasks.Task.ExecuteWithThreadLocal(Task& currentTaskSlot, Thread threadPoolThread) --- End of stack trace from previous location --- at Microsoft.EntityFrameworkCore.Storage.RelationalCommand.ExecuteReaderAsync(RelationalCommandParameterObject parameterObject, CancellationToken cancellationToken) at Microsoft.EntityFrameworkCore.Storage.RelationalCommand.ExecuteReaderAsync(RelationalCommandParameterObject parameterObject, CancellationToken cancellationToken) at Microsoft.EntityFrameworkCore.Query.Internal.SingleQueryingEnumerable1.AsyncEnumerator.InitializeReaderAsync(AsyncEnumerator enumerator, CancellationToken cancellationToken) at Microsoft.EntityFrameworkCore.SqlServer.Storage.Internal.SqlServerExecutionStrategy.ExecuteAsync[TState,TResult](TState state, Func4 operation, Func4 verifySucceeded, CancellationToken cancellationToken) at Microsoft.EntityFrameworkCore.Query.Internal.SingleQueryingEnumerable1.AsyncEnumerator.MoveNextAsync() ClientConnectionId:349f16e3-ab59-4b68-9184-a0459b2e47b6 Error Number:207,State:1,Class:16

ajcvickers commented 2 months ago

@alexandis You configuration creates a mapped inheritance hierarchy using TPH mapping:

Model: 
  EntityType: ExtendedGood Base: Good
  EntityType: Good
    Properties: 
      Id (int) Required PK AfterSave:Throw ValueGenerated.OnAdd
      Description (string)
      Discriminator (no field, string) Shadow Required AfterSave:Throw MaxLength(13)
      Name (string) Required
    Keys: 
      Id PK

This means that the table needs to have a discriminator column in order to determine which of the two types to create when executing a query. EF Core doesn't support having multiple types mapped to the same table, but without any inheritance mapping or relationship between the two types. This is because EF cannot determine the type to create for each row read.

Note for team: should we allow this with shared-type entity types?

AndriySvyryd commented 2 months ago

should we allow this with shared-type entity types?

No. We still need a discriminator to determine which entity type to use. If we were to allow instantiating either then it would become a particular case of https://github.com/dotnet/efcore/issues/15310

alexandis commented 2 months ago

Hmm... So what is the way out in my situation? I have a base type in Nuget package. And I have the inherited types in the solutions which consume this Nuget package. Each of the inherited types have Navigation properties of the types, which I cannot move to the Nuget package. From DB perspective, the base type and the inherited types still reference the same DB table. Pretty common situation, IMHO...

alexandis commented 2 months ago

Any update here?

ajcvickers commented 1 month ago

@alexandis I don't believe there is any way to map those entity types. EF needs to understand the relationship between the types in order to determine what types to create and which navigations we refer to which types.

alexandis commented 1 month ago

To me it looks like a dead end now. Or i don't understand some obvious solution.

I've simplified the example as much as I could to demonstrate the issue.

Type BaseA resides in the solution A (NuGet).

Type ExtendedA resides in the solution B (which consumes A) and inherites BaseA. So it still IS a BaseA, but has navigation properties that also reside in the solution B.

I've tried to use a hack builder.ApplyConfiguration(new BaseAConfig<BaseA>(builder => builder.HasOne<ExtendedA>().WithOne().HasPrincipalKey<ExtendedA>(e => e.Id))) as recommended by someone, but it made updating or creating ExtendedA complicated and raising exceptions.

ajcvickers commented 1 month ago

Putting on the backlog to consider if we should do any kind of model validation against this.

AndriySvyryd commented 1 month ago

Related: https://github.com/dotnet/efcore/issues/34368