Efcore: Indirect materialization of entity does not bring owned navigations

Created on 1 Oct 2019  路  6Comments  路  Source: dotnet/efcore

It looks like when you select a subset of fields from an entity and include a property that isn't a database column it's selecting the entire row and caching the result. Normally this is fine but when the entity has owned types the owned type's records aren't being selected. The next time the entity is retrieved with Find() or FindAsync() it uses the cached version which is missing the child records. I've reproduced the issue on both in memory and SQL databases. We didn't have this issue in EF Core v2.2.0.

Steps to reproduce

This will reproduce the issue:
``` C#
using (var db = new TestContext())
{
db.Database.EnsureCreated();
db.Add(new User(1, "Bob", "Jones", new PhoneNumber("555-5555")));
db.SaveChanges();
}

// make sure to create a new context so it wasn't cached on Add
using (var db = new TestContext())
{
var userInfo = db.Set()
.Where(x => x.PhoneNumbers.Any(pn => pn.Number == "555-5555"))
// The FullName property is calculated from FirstName + " " + LastName
.Select(x => new { x.Id, x.FullName })
.FirstOrDefault();

// The cache returns null
var cachedUserPhoneCount = db.Set<User>().Find(1).PhoneNumbers?.Count;
// the full entity from the db returns 1
var dbUserPhoneCount = db.Set<User>().First(x => x.Id == 1).PhoneNumbers?.Count;
if (cachedUserPhoneCount != dbUserPhoneCount)
{
    throw new ApplicationException();
}

}


Here's the setup for the database context:
``` C#
public class TestContext : DbContext
{
    protected override void OnConfiguring(DbContextOptionsBuilder options)
        => options.UseInMemoryDatabase("Test");

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

        var userBuilder = modelBuilder.Entity<User>();

        userBuilder.HasKey(x => x.Id);
        userBuilder.Property(x => x.Id).ValueGeneratedNever();
        userBuilder.OwnsMany(x => x.PhoneNumbers);
    }
}

public class User
{
    private User() { }

    public User(int id, string firstName, string lastName, params PhoneNumber[] phoneNumbers)
    {
        Id = id;
        FirstName = firstName ?? throw new ArgumentNullException(nameof(firstName));
        LastName = lastName ?? throw new ArgumentNullException(nameof(lastName));
        _phoneNumbers = phoneNumbers.ToList();
    }

    public int Id { get; private set; }

    public string FirstName { get; private set; }

    public string LastName { get; private set; }

    public string FullName => FirstName + " " + LastName;

    private readonly List<PhoneNumber> _phoneNumbers;
    public IReadOnlyCollection<PhoneNumber> PhoneNumbers => _phoneNumbers;
}

public class PhoneNumber
{
    public PhoneNumber(string number, bool isPrimary = false)
    {
        Number = number;
        IsPrimary = isPrimary;
    }

    public string Number { get; set; }
    public bool IsPrimary { get; set; }
}

Further technical details

EF Core version: 3.0.0
Database provider: Microsoft.EntityFrameworkCore.SqlServer / Microsoft.EntityFrameworkCore.InMemory
Target framework: .NET Core 3.0
Operating system: Windows 10 Pro
IDE: Visual Studio 2019 16.3.1

closed-fixed customer-reported type-bug

All 6 comments

I was able to reproduce the apparent change in semantics between 2.2.6 and 3.0.0.

I did have to used a slightly modified version of the provided code to get it to actually run as expected -- provided below for convenience.


Packages:

  • dotnet add package Microsoft.EntityFrameworkCore.InMemory -v 2.2.6 or
  • dotnet add package Microsoft.EntityFrameworkCore.InMemory -v 3.0.0

``` C#
using Microsoft.EntityFrameworkCore;
using System;
using System.Collections.Generic;
using System.Linq;

namespace TestCoreApp
{
class Program
{
static void Main(string[] args)
{
using (var db = new TestContext())
{
db.Database.EnsureCreated();
db.Add(new User(1, "Bob", "Jones", new PhoneNumber("555-5555")));
db.SaveChanges();
}

        // make sure to create a new context so it wasn't cached on Add
        using (var db = new TestContext())
        {
            var userInfo = db.Set<User>()
                .Where(x => x.PhoneNumbers.Any(pn => pn.Number == "555-5555"))
                // The FullName property is calculated from FirstName + " " + LastName
                .Select(x => new { x.Id, x.FullName })
                .FirstOrDefault();

            // The cache returns null
            var cachedUserPhoneCount = db.Set<User>().Find(1).PhoneNumbers?.Count;
            // the full entity from the db returns 1
            var dbUserPhoneCount = db.Set<User>().First(x => x.Id == 1).PhoneNumbers?.Count;
            if (cachedUserPhoneCount != dbUserPhoneCount)
            {
                throw new ApplicationException();
            }
        }
    }
}

public class TestContext : DbContext
{
    protected override void OnConfiguring(DbContextOptionsBuilder options)
        => options.UseInMemoryDatabase("Test");

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

        var userBuilder = modelBuilder.Entity<User>();

        userBuilder.HasKey(x => x.Id);
        userBuilder.Property(x => x.Id).ValueGeneratedNever();
        userBuilder.OwnsMany(x => x.PhoneNumbers)
            .HasKey(x => x.Number);
    }
}

public class User
{
    private User() { }

    public User(int id, string firstName, string lastName, params PhoneNumber[] phoneNumbers)
    {
        Id = id;
        FirstName = firstName ?? throw new ArgumentNullException(nameof(firstName));
        LastName = lastName ?? throw new ArgumentNullException(nameof(lastName));
        PhoneNumbers = phoneNumbers.ToList();
    }

    public int Id { get; private set; }

    public string FirstName { get; private set; }

    public string LastName { get; private set; }

    public string FullName => FirstName + " " + LastName;

    public ICollection<PhoneNumber> PhoneNumbers { get; set; }
}

public class PhoneNumber
{
    public PhoneNumber(string number, bool isPrimary = false)
    {
        Number = number;
        IsPrimary = isPrimary;
    }

    public string Number { get; set; }
    public bool IsPrimary { get; set; }
}

}
```

~Selecting non-db field has nothing to do with the issue. Removing userInfo query also reproduce the issue. Find is not loading owned entities.~

@smitpatel That's not quite true. If you replace x.FullName with x.FirstName in my example it will correctly return the phone number for the user. Removing the non-db field will also work correctly.

@dandenton - Realized my mistake and updated the comment & title.

Looking into this a little further, I've found that the root cause has to do with the query compiler deciding to not inject an include into the table query for some projections but not others.

Below is a test that shows this difference in functionality.

diff --git a/test/EFCore.Specification.Tests/Query/OwnedQueryTestBase.cs b/test/EFCore.Specification.Tests/Query/OwnedQueryTestBase.cs
index 0d80c5856..64d5c226c 100644
--- a/test/EFCore.Specification.Tests/Query/OwnedQueryTestBase.cs
+++ b/test/EFCore.Specification.Tests/Query/OwnedQueryTestBase.cs
@@ -362,6 +362,53 @@ public virtual void Throw_for_owned_entities_without_owner_in_tracking_query()
             }
         }

+        [ConditionalTheory]
+        [MemberData(nameof(IsAsyncData))]
+        public virtual async Task Query_loads_owned_nav_automatically_in_projection(bool isAsync)
+        {
+            using (var context = CreateContext())
+            {
+                var set = context.Set<Barton>();
+                var query = set.Where(e => e.Id == 1);
+                var projection = query.Select(e => new { e.Id, z = e.ToString() }).AsTracking();
+
+                if (isAsync)
+                {
+                    _ = await projection.FirstAsync();
+                }
+                else
+                {
+                    _ = projection.First();
+                }
+
+                var entryCounts = context.ChangeTracker.Entries().Count();
+
+                // Currently succeeds
+                Assert.Equal(2, entryCounts);
+            }
+
+            using (var context = CreateContext())
+            {
+                var set = context.Set<Barton>();
+                var query = set.Where(e => e.Id == 1);
+                var projection = query.Select(e => new { e.Id, z = e.GetOnly }).AsTracking();
+
+                if (isAsync)
+                {
+                    _ = await projection.FirstAsync();
+                }
+                else
+                {
+                    _ = projection.First();
+                }
+
+                var entryCounts = context.ChangeTracker.Entries().Count();
+
+                // Currently fails
+                Assert.Equal(2, entryCounts);
+            }
+        }
+
         protected virtual DbContext CreateContext() => Fixture.CreateContext();

         public abstract class OwnedQueryFixtureBase : SharedStoreFixtureBase<PoolableDbContext>, IQueryFixtureBase
@@ -1007,6 +1054,8 @@ protected class Barton
             public Throned Throned { get; set; }

             public string Simple { get; set; }
+
+            public string GetOnly => "Foo";
         }

         protected class Fink

@brandondahler - We know what is the root cause here and what should be the fix.

Was this page helpful?
0 / 5 - 0 ratings