dotnet / dotnet/efcore

Support collections of foreign key values

Open
#31,683 2 comments 0 reactions 0 assignees View on GitHub
area-change-tracking area-model-building area-query customer-reported
Dominant language
C#
Stars
14.8k
Forks
3.4k
PR merge metrics
PR metrics pending

Description

### Issue
I have 2 entities were one has a property pointing to the primary key of another entity - a **classic foreign key**.
The issue is: I use **typed ids** ("owned entities", "value objects") for this and **EF Core throws during configuration** if I have the foreign key configured.

This is _**similar**_ to #30373, but as far I understand the point of "_you are using typed ids as property **_and_** as entity_" does not apply here.

Without the `HasOne` configuration it works fine, but **no foreign key is created** and **Entity Framework will sometimes reorders `INSERTs`**. This is a problem if in one `SaveChanges` call 2 referencing entities are created in the database. Depending on the ordering this, of course, fails with **foreign key constraint violation**.
As I currently cannot instruct EF Core that this foreign key constraint exists in database, this happens.

### Exception
`Microsoft.EntityFrameworkCore.Metadata.Internal.InternalEntityTypeBuilder.HasRelationship` seems to return `null` which throws when `Metadata` is accessed.
![image](https://github.com/dotnet/efcore/assets/886232/d78abd8c-5c82-4866-a366-0ccf4b0cdb38)

```
Unhandled exception. System.NullReferenceException: Object reference not set to an instance of an object.
at Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder.HasOneBuilder(MemberIdentity navigationId, EntityType relatedEntityType)
at Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder`1.HasOne[TRelatedEntity](Expression`1 navigationExpression)
at OwnedForeignKeyIssue.OrmMapping.DataDbContext.Configure(EntityTypeBuilder`1 builder) in C:\\DataDbContext.cs:line 59
at OwnedForeignKeyIssue.OrmMapping.DataDbContext.OnModelCreating(ModelBuilder modelBuilder) in C:\\DataDbContext.cs:line 28
at Microsoft.EntityFrameworkCore.Infrastructure.ModelCustomizer.Customize(ModelBuilder modelBuilder, DbContext context)
at Microsoft.EntityFrameworkCore.Infrastructure.ModelSource.CreateModel(DbContext context, IConventionSetBuilder conventionSetBuilder, ModelDependencies modelDependencies)
at Microsoft.EntityFrameworkCore.Infrastructure.ModelSource.GetModel(DbContext context, ModelCreationDependencies modelCreationDependencies, Boolean designTime)
at Microsoft.EntityFrameworkCore.Internal.DbContextServices.CreateModel(Boolean designTime)
at Microsoft.EntityFrameworkCore.Internal.DbContextServices.get_Model()
at Microsoft.EntityFrameworkCore.Infrastructure.EntityFrameworkServicesBuilder.<>c.b__8_4(IServiceProvider p)
at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.VisitFactory(FactoryCallSite factoryCallSite, RuntimeResolverContext context)
at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteVisitor`2.VisitCallSiteMain(ServiceCallSite callSite, TArgument argument)
at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.VisitCache(ServiceCallSite callSite, RuntimeResolverContext context, ServiceProviderEngineScope serviceProviderEngine, RuntimeResolverLock lockType)
at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.VisitScopeCache(ServiceCallSite callSite, RuntimeResolverContext context)
at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteVisitor`2.VisitCallSite(ServiceCallSite callSite, TArgument argument)
at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.VisitConstructor(ConstructorCallSite constructorCallSite, RuntimeResolverContext context)
at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteVisitor`2.VisitCallSiteMain(ServiceCallSite callSite, TArgument argument)
at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.VisitCache(ServiceCallSite callSite, RuntimeResolverContext context, ServiceProviderEngineScope serviceProviderEngine, RuntimeResolverLock lockType)
at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.VisitScopeCache(ServiceCallSite callSite, RuntimeResolverContext context)
at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteVisitor`2.VisitCallSite(ServiceCallSite callSite, TArgument argument)
at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.VisitConstructor(ConstructorCallSite constructorCallSite, RuntimeResolverContext context)
at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteVisitor`2.VisitCallSiteMain(ServiceCallSite callSite, TArgument argument)
at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.VisitCache(ServiceCallSite callSite, RuntimeResolverContext context, ServiceProviderEngineScope serviceProviderEngine, RuntimeResolverLock lockType)
at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.VisitScopeCache(ServiceCallSite callSite, RuntimeResolverContext context)
at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteVisitor`2.VisitCallSite(ServiceCallSite callSite, TArgument argument)
at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.VisitConstructor(ConstructorCallSite constructorCallSite, RuntimeResolverContext context)
at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteVisitor`2.VisitCallSiteMain(ServiceCallSite callSite, TArgument argument)
at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.VisitCache(ServiceCallSite callSite, RuntimeResolverContext context, ServiceProviderEngineScope serviceProviderEngine, RuntimeResolverLock lockType)
at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.VisitScopeCache(ServiceCallSite callSite, RuntimeResolverContext context)
at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteVisitor`2.VisitCallSite(ServiceCallSite callSite, TArgument argument)
at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.VisitConstructor(ConstructorCallSite constructorCallSite, RuntimeResolverContext context)
at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteVisitor`2.VisitCallSiteMain(ServiceCallSite callSite, TArgument argument)
at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.VisitCache(ServiceCallSite callSite, RuntimeResolverContext context, ServiceProviderEngineScope serviceProviderEngine, RuntimeResolverLock lockType)
at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.VisitScopeCache(ServiceCallSite callSite, RuntimeResolverContext context)
at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteVisitor`2.VisitCallSite(ServiceCallSite callSite, TArgument argument)
at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.VisitConstructor(ConstructorCallSite constructorCallSite, RuntimeResolverContext context)
at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteVisitor`2.VisitCallSiteMain(ServiceCallSite callSite, TArgument argument)
at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.VisitCache(ServiceCallSite callSite, RuntimeResolverContext context, ServiceProviderEngineScope serviceProviderEngine, RuntimeResolverLock lockType)
at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.VisitScopeCache(ServiceCallSite callSite, RuntimeResolverContext context)
at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteVisitor`2.VisitCallSite(ServiceCallSite callSite, TArgument argument)
at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.Resolve(ServiceCallSite callSite, ServiceProviderEngineScope scope)
at Microsoft.Extensions.DependencyInjection.ServiceLookup.DynamicServiceProviderEngine.<>c__DisplayClass2_0.b__0(ServiceProviderEngineScope scope)
at Microsoft.Extensions.DependencyInjection.ServiceProvider.GetService(Type serviceType, ServiceProviderEngineScope serviceProviderEngineScope)
at Microsoft.Extensions.DependencyInjection.ServiceLookup.ServiceProviderEngineScope.GetService(Type serviceType)
at Microsoft.Extensions.DependencyInjection.ServiceProviderServiceExtensions.GetRequiredService(IServiceProvider provider, Type serviceType)
at Microsoft.Extensions.DependencyInjection.ServiceProviderServiceExtensions.GetRequiredService[T](IServiceProvider provider)
at Microsoft.EntityFrameworkCore.DbContext.get_DbContextDependencies()
at Microsoft.EntityFrameworkCore.DbContext.get_ContextServices()
at Microsoft.EntityFrameworkCore.DbContext.get_InternalServiceProvider()
at Microsoft.EntityFrameworkCore.DbContext.Microsoft.EntityFrameworkCore.Infrastructure.IInfrastructure.get_Instance()
at Microsoft.EntityFrameworkCore.Infrastructure.Internal.InfrastructureExtensions.GetService[TService](IInfrastructure`1 accessor)
at Microsoft.EntityFrameworkCore.Infrastructure.AccessorExtensions.GetService[TService](IInfrastructure`1 accessor)
at Microsoft.EntityFrameworkCore.Infrastructure.DatabaseFacade.get_Dependencies()
at Microsoft.EntityFrameworkCore.Infrastructure.DatabaseFacade.EnsureDeleted()
at Program.$(String[] args) in C:\\Program.cs:line 31
at Program.(String[] args)
```

### Expectation
`Microsoft.EntityFrameworkCore.Metadata.Internal.InternalEntityTypeBuilder.HasRelationship` should be able to find the relationship, even if using typed ids, as without `HasOne` EntityFramework can handle typed ids just fine.

### Repo
The following code shows 3 cases
* **As-is**: `NullReferenceException` in EF Core
* **Repo 1**: Removing `HasOne` (revert to as-is and search for "Repo 1"): Most stuff works, but no foreign key is created and `INSERT` reordering gets a problem
* FK needs to be added _manually_ after a successful `EnsureDatabase()` call (or just running Repo 2 before) and by removing `EnsureDeleted()`
* **Repo 2**: Switching `SchemaId` with a `Guid` (revert to as-is and search for "Repo 2"): Everything works, also the foreign key is created, but the whole point of typed ids is out the window.

#### DbContext and Entity Configuration
```C#
public partial class DataDbContext : DbContext
{
public DataDbContext(DbContextOptions options)
: base(options)
{
}

public DbSet DataEntities => Set();

public DbSet Schemas => Set();

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);

modelBuilder.Owned();
modelBuilder.Owned();

Configure(modelBuilder.Entity());
Configure(modelBuilder.Entity());
}

public void Configure(EntityTypeBuilder builder)
{
builder.ToTable("Schemas");

builder.HasKey(x => x.Id);

builder.Property(x => x.Id)
// Repo 2: Comment this is you don't use SchemaId as Id for Schema
.HasConversion(x => x.Value, x => SchemaId.From(x))
;
}

public void Configure(EntityTypeBuilder builder)
{
builder.ToTable("DataEntities");

builder.HasKey(x => x.Id);

builder.Property(x => x.Id)
.HasConversion(x => x.Value, x => DataEntityId.From(x));

builder.Property(x => x.SchemaId)
.HasColumnName("SchemaId")
// Repo 2: Comment this is you don't use SchemaId as Id for Schema
.HasConversion(x => x.Value, x => SchemaId.From(x))
;

// Repo 1: Using SchemaId *works only* if the following foreign key configuration *is commented out*
builder.HasOne()
.WithMany()
.HasForeignKey(data => data.SchemaId)
.HasPrincipalKey(schema => schema.Id)
.IsRequired()
.HasConstraintName("FK_DataEntities_SchemaId_Schemas_Id");
}
}
```

#### Entities and Typed Ids
```C#
public class Schema
{
// Repo 2: Toggle the next 2 properties - if you change to SchemaId it breaks
public virtual SchemaId Id { get; set; }
//public Guid Id { get; set; }
}

public record SchemaId(Guid Value)
{
public static SchemaId From(Guid value)
{
return new SchemaId(value);
}

public static SchemaId Generate()
{
return new SchemaId(Guid.NewGuid());
}

public static SchemaId Parse(string value)
{
return new SchemaId(Guid.Parse(value));
}

public static bool TryParse(string value, [NotNullWhen(true)] out SchemaId? id)
{
if (Guid.TryParse(value, out var guid))
{
id = From(guid);
return true;
}

id = null;
return false;
}

public static implicit operator Guid(SchemaId value)
{
return value?.Value ?? default;
}

public static implicit operator SchemaId(Guid value)
{
return new SchemaId(value);
}
}

public class DataEntity
{
public virtual DataEntityId Id { get; set; }

// Repo 2: Toggle the next 2 properties - if you change to SchemaId it breaks
public virtual SchemaId SchemaId { get; set; }
//public Guid SchemaId { get; set; }
}

public record DataEntityId(Guid Value)
{
public static DataEntityId From(Guid value)
{
return new DataEntityId(value);
}

public static DataEntityId Generate()
{
return new DataEntityId(Guid.NewGuid());
}

public static DataEntityId Parse(string value)
{
return new DataEntityId(Guid.Parse(value));
}

public static implicit operator Guid(DataEntityId value)
{
return value.Value;
}

public static explicit operator DataEntityId(Guid value)
{
return new DataEntityId(value);
}
}
```

#### Test Console Application
```C#
Console.WriteLine("Repo: Typed foreign key bug with HasOne:");
Console.WriteLine("Either");
Console.WriteLine(" * remove HasOne configuration in DataEntity configuration ('Repo 1') which removes foreign key and can trigger a
wrong order or SQL insert statements, OR");
Console.WriteLine(" * don't use SchemaId in Schema and use a Guid ('Repo 2')");
Console.WriteLine();
Console.WriteLine("To switch between the 2: in source code, search for 'Repo 1' or 'Repo 2' to change the commented code to toggle between the different scenarios");
Console.WriteLine();

var services = new ServiceCollection();
services.AddDbContext(
options =>
{
var connectionString = "Server=(localdb)\\MSSQLLOCALDB;Initial Catalog=OwnedTypedForeignKey;Integrated Security=true;";

options.UseSqlServer(connectionString);
});

var sp = services.BuildServiceProvider();

Console.WriteLine("Configured, get DbContext");

var saveDbContext = sp.GetRequiredService();

Console.WriteLine("Create database");
saveDbContext.Database.EnsureDeleted();
saveDbContext.Database.EnsureCreated();

Console.WriteLine("Generate some data and insert it");
var schemaId = SchemaId.Generate();
var schema = new Schema()
{
Id = schemaId,
};
saveDbContext.Add(schema);

var dataEntityId = DataEntityId.Generate();
var dataEntity = new DataEntity
{
Id = dataEntityId,
SchemaId = schemaId
};

saveDbContext.Add(dataEntity);

await saveDbContext.SaveChangesAsync();
Console.WriteLine("Saved");

Console.WriteLine("Load data");
var loadDbContext = sp.GetRequiredService();

var loadedSchemas = loadDbContext.Schemas.ToList();
Console.WriteLine("Schemas: " + string.Join(", ", loadedSchemas.Select(x => x.Id)));

var loadedDataEntities = loadDbContext.DataEntities.ToList();
Console.WriteLine($"Data: {string.Join(", ", loadedDataEntities.Select(x => $"{x.Id} (Schema {x.SchemaId})"))}");

Console.WriteLine();
Console.WriteLine("If HasOne is configured, check database to see the foreign key is generated");
```

### Provider and Version Information
EF Core version: **7.0.10**
Database provider: Microsoft.EntityFrameworkCore.SqlServer **7.0.10**
Target framework: **.NET 7.0**
Operating system: Windows 10 22H2 (Build 19045.3393)
IDE: Visual Studio 2022 17.8.0 Preview 1.0

This also happens with latest **.NET 8 Preview 7** (SDK, EF Core,...).

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.