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.67k stars 3.16k forks source link

EF Core Inheritance: Profile Field Null on Retrieval Despite of Configured Relationships #32352

Open fasoltanzadeh opened 10 months ago

fasoltanzadeh commented 10 months ago

I'm encountering an issue with Entity Framework Core (EF Core) related to lazy loading, inheritance, and property retrieval. Initially, I had the following entity classes:

public class Pool
{
    public int Id { get; set; }
    public Profile? Profile
    {
        get
        {
            return ORMAProfileAccessor as Profile ?? ORMBProfileAccessor;
        }

        set
        {
            switch (value)
            {
                case AProfile aProfile:
                    ORMAProfileAccessor = aProfile;
                    break;
                case BProfile bProfile:
                    ORMBProfileAccessor = bProfile;
                    break;
                default:
                    throw new InvalidCastException("Invalid profile type");
            }
        }
    }
    internal virtual AProfile? ORMAProfileAccessor { get; set; }
    internal virtual BProfile? ORMBProfileAccessor { get; set; }
}

public abstract class Profile
{
    public Profile() { }
    public int Id { get; set; }
}

public class AProfile : Profile
{
    public AProfile() { }
    public virtual Pool? Pool { get; set; }
}

public class BProfile : Profile
{
    public BProfile() { }
    public virtual Pool? Pool { get; set; }
}

In the OnModelCreating method, I configured the relationships as follows:

modelBuilder.Entity<AProfile>().HasOne(P => P.Pool).WithOne(Q => Q.ORMAProfileAccessor).HasForeignKey<Pool>("AProfileId").IsRequired(false);
modelBuilder.Entity<BProfile>().HasOne(P => P.Pool).WithOne(Q => Q.ORMBProfileAccessor).HasForeignKey<Pool>("BProfileId").IsRequired(false);
modelBuilder.Entity<Pool>().Ignore(P => P.Profile);

The issue I'm facing now is that after saving a Pool object to the database with associated Profile, upon retrieving the Pool elsewhere in the code, the Profile field remains null. However, retrieving a Profile separately shows that the Pool field within the Profile object is correctly populated. For example:

var retrievedPool = dbContext.Pools.FirstOrDefault();
if (retrievedPool != null)
{
    Console.WriteLine($"Retrieved Pool ID: {retrievedPool.Id}");
    Console.WriteLine($"Retrieved Pool Profile ID: {retrievedPool.Profile?.Id}"); // This returns null
}

How can I ensure that when retrieving a Pool, the Profile field is populated with the associated Profile type?

Any insights or suggestions would be highly appreciated. Thank you!

EF Core version: 6.0.16 Database provider:Microsoft.EntityFrameworkCore.SqlServer Target framework: .NET 6.0 IDE: Visual Studio 2022 17.4

ajcvickers commented 10 months ago

@fasoltanzadeh How are you configuring lazy-loading? Does it not throw saying that "ORMAProfileAccessor" cannot be overridden?

fasoltanzadeh commented 10 months ago

@fasoltanzadeh How are you configuring lazy-loading? Does it not throw saying that "ORMAProfileAccessor" cannot be overridden?

@ajcvickers No It does work perfectly and it fills the database correctly, Here is the config for lazy loading:

 optionsBuilder.UseLazyLoadingProxies();
ajcvickers commented 10 months ago

Note for triage: underlying issue here is that internal properties are considered valid for lazy-loading, but then don't work because they cannot be overridden. Simple repro:

using (var context = new SomeDbContext())
{
    await context.Database.EnsureDeletedAsync();
    await context.Database.EnsureCreatedAsync();

    context.Add(new Pool { ORMAProfileAccessor = new AProfile() } );

    await context.SaveChangesAsync();
}

using (var context = new SomeDbContext())
{
    var pool = context.Set<Pool>().Single();
    Console.WriteLine(pool.ORMAProfileAccessor);
}

public class SomeDbContext : DbContext
{
    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        => optionsBuilder
            .UseSqlServer(@"Data Source=(LocalDb)\MSSQLLocalDB;Database=AllTogetherNow")
            .UseLazyLoadingProxies()
            .LogTo(Console.WriteLine, LogLevel.Information)
            .EnableSensitiveDataLogging();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<AProfile>().HasOne(P => P.Pool).WithOne(Q => Q.ORMAProfileAccessor).HasForeignKey<Pool>("AProfileId").IsRequired(false);
    }
}

public class Pool
{
    public int Id { get; set; }
    internal virtual AProfile ORMAProfileAccessor { get; set; }
}

public class AProfile
{
    public int Id { get; set; }
    public virtual Pool? Pool { get; set; }
}
soroshsabz commented 10 months ago

ITNOA

I have same issue

meghasemim1999 commented 10 months ago

I have the same issue here.

Reza-Noei commented 10 months ago

@ajcvickers I'm not sure if its directly related to internal properties. I'm protecting all of my navigation properties (Collection<T> most of the time) by marking them as internal.

So I make them visible to EF using (in .csproj file):

<ItemGroup>
    <InternalsVisibleTo Include="Application.Data"/>
</ItemGroup>

I haven't seen any issue with Collections.

soroshsabz commented 10 months ago

@ajcvickers Did you have any time estimate to resolve this issue?

ajcvickers commented 10 months ago

@soroshsabz This issue is tracking making EF throw in model validation when internal navigations are used in lazy-loading proxies. The reason the navigation is null is that proxies cannot override internal methods, since they are not visible to the proxy assembly. This isn't a critical bug to fix, since the scenario is already invalid, it should just throw with a better exception message.

soroshsabz commented 10 months ago

@ajcvickers as you can see in @Reza-Noei comments, internal accessor does not cause of this problem, I have many project with internal accessor (and <InternalsVisibleTo Include="Assembly"/>) and works correctly too.

So I do not think the root cause of the problem from this place

soroshsabz commented 10 months ago

@ajcvickers I think you can probably reproduce this bug, without using any internal too.

such as below code

public class Pool
{
    public int Id { get; set; }
    public Profile? Profile
    {
        get
        {
            return ORMAProfileAccessor as Profile ?? ORMBProfileAccessor;
        }

        set
        {
            switch (value)
            {
                case AProfile aProfile:
                    ORMAProfileAccessor = aProfile;
                    break;
                case BProfile bProfile:
                    ORMBProfileAccessor = bProfile;
                    break;
                default:
                    throw new InvalidCastException("Invalid profile type");
            }
        }
    }
    public virtual AProfile? ORMAProfileAccessor { get; set; }
    public virtual BProfile? ORMBProfileAccessor { get; set; }
}

public abstract class Profile
{
    public Profile() { }
    public int Id { get; set; }
}

public class AProfile : Profile
{
    public AProfile() { }
    public virtual Pool? Pool { get; set; }
}

public class BProfile : Profile
{
    public BProfile() { }
    public virtual Pool? Pool { get; set; }
}
soroshsabz commented 10 months ago

related to https://github.com/dotnet/EntityFramework.Docs/issues/4486

soroshsabz commented 10 months ago

@ajcvickers I add some complete project for reproducing this problem, without using any internal in https://github.com/soroshsabz/TestSolution/tree/main/Source/ConsoleTest/ConsoleApp1

Please see it

thanks :)

ajcvickers commented 10 months ago

@soroshsabz That code doesn't use lazy-loading. When I add lazy-loading proxies, then I get:

C:\Users\avickers\AppData\Local\Programs\Rider\plugins\dpa\DotFiles\JetBrains.DPA.Runner.exe --handle=19956 --backend-pid=31380 --etw-collect-flags=3 --detach-event-name=dpa.detach.19956 C:/local/code/AllTogetherNow/Daily/bin/Debug/net8.0/Daily.exe
Hello, World!
Pool2: 1
Pool2.Profile: 2
Pool2: 1
Pool2.Profile: 2
soroshsabz commented 10 months ago

@ajcvickers Sorry, I mistake.

I have question, why proxy could not override internal navigation when I set <InternalsVisibleTo Include="Assembly"/>?

ajcvickers commented 9 months ago

Note for triage: verified that lazy-loading works with [assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")]. Putting on the backlog to throw when attempting to load if the navigation exists but is not visible.