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

Empty instance instead of null returned for owned navigation property #23198

Closed
J-Yen opened this issue Nov 5, 2020 · 6 comments · Fixed by #24573
Closed

Empty instance instead of null returned for owned navigation property #23198

J-Yen opened this issue Nov 5, 2020 · 6 comments · Fixed by #24573
Assignees
Labels
area-query 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

@J-Yen
Copy link

J-Yen commented Nov 5, 2020

When fetching an entity (owner) with a relationship (OwnsOne) to an optional owned type with null values I'm receiving in a certain case an empty instance with all properties null while I'm expecting the navigation property of the owned type itself to be null. According to issue 9005, the plan for .NET Core 3.0 is returning a null entity when all properties are null. This behavior works as expected when owning a type with simple properties or with foreign key relationships, but not when owning a type that owns other types (nested table splitting). I made a small demo to show the inconsistent behavior when fetching null values for a complex owned type that has relationships to other owned types in comparison with other cases that works as expected. In this demo example I conclude the following results when fetching empty navigation properties:

  • HasOne relationship (AnAggregateRoot.AnEntity) => owner is returning null for the navigation property (works as expected).
  • OwnsOne relationship with primitive properties (AnAggregateRoot.AnOwnedTypeWithPrimitiveProperties) => owner is returning null for the owned type (works as expected).
  • OwnsOne relationship with primitive properties and HasOne relationship(s) (AnAggregateRoot.AnOwnedTypeWithPrimitiveAndOwnedProperties ) => owner is returning null for the owned type (works as expected).
  • OwnsOne relationship with HasOne relationship(s) (AnAggregateRoot.AnOwnedTypeWithHasOneProperties) => owner is returning null for the owned type (works as expected).
  • OwnsOne relationship with OwnsOne relationship(s) (AnAggregateRoot.AnOwnedTypeWithOwnedProperties) => owner is returning an empty instance for the navigation property of the owned type with null values for all properties of the owned type (doesn't work as expected).
using System;
using System.Linq;
using Microsoft.EntityFrameworkCore;
using Newtonsoft.Json;

namespace EmptyNavigiationPropertiesIssue
{
    class Program
    {
        static void Main(string[] args)
        {
            using var dbContext = new ExampleContext();
            dbContext.Database.EnsureCreated();
            var anAggregateRoot = new AnAggregateRoot
            {
                Id = Guid.NewGuid().ToString(),
                AnEntity = null,
                AnOwnedTypeWithPrimitiveProperties = null,
                AnOwnedTypeWithPrimitiveAndOwnedProperties = null,
                AnOwnedTypeWithHasOneProperties = null,
                AnOwnedTypeWithOwnedProperties = null,
            };

            dbContext.AnAggregateRoots.Add(aggregateRoot);
            dbContext.SaveChanges();

            var fetchedStudent = dbContext.AnAggregateRoots.SingleOrDefault(x => x.Id == aggregateRoot.Id);
            Console.WriteLine(JsonConvert.SerializeObject(fetchedStudent));
            Console.ReadLine();
        }

        public class ExampleContext : DbContext
        {
            public DbSet<AnAggregateRoot> AnAggregateRoots { get; set; }
            protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
                => optionsBuilder.UseSqlServer("Server=(localdb)\\mssqllocaldb;Database=TestDb;Trusted_Connection=True;MultipleActiveResultSets=true");
            protected override void OnModelCreating(ModelBuilder modelBuilder)
            {
                base.OnModelCreating(modelBuilder);

                modelBuilder.Entity<AnEntity>()
                    .HasKey(x => x.Id);
                modelBuilder.Entity<AnEntity>()
                    .Property(x => x.Name);

                modelBuilder.Entity<AnAggregateRoot>()
                    .HasKey(x => x.Id);
                modelBuilder.Entity<AnAggregateRoot>()
                    .Property(x => x.Id).ValueGeneratedNever();
                modelBuilder.Entity<AnAggregateRoot>()
                    .OwnsOne(x => x.AnOwnedTypeWithOwnedProperties, x =>
                    {
                        x.OwnsOne(y => y.AnOwnedTypeWithPrimitiveProperties1, y => y.Property(e => e.Name));
                        x.OwnsOne(y => y.AnOwnedTypeWithPrimitiveProperties2, y => y.Property(e => e.Name));
                    })
                    .OwnsOne(x => x.AnOwnedTypeWithPrimitiveProperties);
                modelBuilder.Entity<AnAggregateRoot>()
                    .OwnsOne(x => x.AnOwnedTypeWithPrimitiveAndOwnedProperties, x =>
                    {
                        x.OwnsOne(y => y.AnOwnedTypeWithPrimitiveProperties, y => y.Property(e => e.Name));
                        x.Property(y => y.Name);
                    });
                modelBuilder.Entity<AnAggregateRoot>()
                    .OwnsOne(x => x.AnOwnedTypeWithHasOneProperties, x =>
                    {
                        x.HasOne(y => y.AnEntity);
                    });
                modelBuilder.Entity<AnAggregateRoot>()
                    .HasOne(x => x.AnEntity);
            }
        }

        public class AnAggregateRoot
        {
            public string Id { get; set; }
            public AnEntity AnEntity { get; set; }
            public AnOwnedTypeWithPrimitiveProperties1 AnOwnedTypeWithPrimitiveProperties { get; set; }
            public AnOwnedTypeWithPrimitiveAndOwnedProperties AnOwnedTypeWithPrimitiveAndOwnedProperties { get; set; }
            public AnOwnedTypeWithHasOneProperties AnOwnedTypeWithHasOneProperties { get; set; }
            public AnOwnedTypeWithOwnedProperties AnOwnedTypeWithOwnedProperties { get; set; }
        }

        public class AnOwnedTypeWithOwnedProperties
        {
            public AnOwnedTypeWithPrimitiveProperties1 AnOwnedTypeWithPrimitiveProperties1 { get; set; }
            public AnOwnedTypeWithPrimitiveProperties2 AnOwnedTypeWithPrimitiveProperties2 { get; set; }
        }

        public class AnOwnedTypeWithHasOneProperties
        {
            public AnEntity AnEntity { get; set; }
        }

        public class AnOwnedTypeWithPrimitiveAndOwnedProperties
        {
            public AnOwnedTypeWithPrimitiveProperties1 AnOwnedTypeWithPrimitiveProperties { get; set; }
            public string Name { get; set; }
        }

        public class AnOwnedTypeWithPrimitiveProperties1
        {
            public string Name { get; set; }
        }

        public class AnOwnedTypeWithPrimitiveProperties2
        {
            public string Name { get; set; }
        }

        public class AnEntity
        {
            public int Id { get; set; }
            public string Name { get; set; }
        }
    }
}

Console output

{
    "Id": "ce27bd05-e360-435d-8554-a14a65ac6822",
    "AnEntity": null,
    "AnOwnedTypeWithPrimitiveProperties": null,
    "AnOwnedTypeWithPrimitiveAndOwnedProperties": null,
    "AnOwnedTypeWithHasOneProperties": null,
    "AnOwnedTypeWithOwnedProperties": {
        "AnOwnedTypeWithPrimitiveProperties1": null,
        "AnOwnedTypeWithPrimitiveProperties2": null
    }
}

Like you can see in the json output only the owned type with owned properties case is returning an empty instance instead of null. I can't reproduce this behavior when using an in-memory database (all outcome are null as expected there) but I do have this issue when using the SqlServer database provider.

Is this a bug?
I really need the root navigation property to be null in all cases. Is there any workaround possible?

EF Core version: 3.1.9
Database provider: Microsoft.EntityFrameworkCore.SqlServer
Target framework: .NET Core 3.1 and .NET 5.0
Operating system: Windows 1909
IDE: Visual Studio 2019 16.7.6

@ajcvickers
Copy link
Member

Note for triage: this repos on 5.0. It happens when there is an owned dependent that itself references two more owned dependents, but has no other nullable properties. So this:

        public class AnAggregateRoot
        {
            public string Id { get; set; }
            public AnOwnedTypeWithOwnedProperties AnOwnedTypeWithOwnedProperties { get; set; }
        }

        public class AnOwnedTypeWithOwnedProperties
        {
            public AnOwnedTypeWithPrimitiveProperties1 AnOwnedTypeWithPrimitiveProperties1 { get; set; }
            public AnOwnedTypeWithPrimitiveProperties2 AnOwnedTypeWithPrimitiveProperties2 { get; set; }
        }

        public class AnOwnedTypeWithPrimitiveProperties1
        {
            public string Name { get; set; }
        }

        public class AnOwnedTypeWithPrimitiveProperties2
        {
            public string Name { get; set; }
        }

Results in creating an AnOwnedTypeWithOwnedProperties even when the two owned navigations are null:

{
  "Id":"5c21da82-c3c2-4a63-9fa6-7250c9e0732d",
  "AnEntity":null,
  "AnOwnedTypeWithPrimitiveProperties":null,
  "AnOwnedTypeWithPrimitiveAndOwnedProperties":null,
  "AnOwnedTypeWithHasOneProperties":null,
  "AnOwnedTypeWithOwnedProperties":
  {
    "AnOwnedTypeWithPrimitiveProperties1":null,
    "AnOwnedTypeWithPrimitiveProperties2":null
  }
}

However, if we add a nullable property to AnOwnedTypeWithOwnedProperties:

        public class AnOwnedTypeWithOwnedProperties
        {
            public string Name { get; set; }
            public AnOwnedTypeWithPrimitiveProperties1 AnOwnedTypeWithPrimitiveProperties1 { get; set; }
            public AnOwnedTypeWithPrimitiveProperties2 AnOwnedTypeWithPrimitiveProperties2 { get; set; }
        }

Then an instance of AnOwnedTypeWithOwnedProperties is not created:

{
  "Id":"3cde2228-0c82-4b30-a622-cd8f1d75f0e8",
  "AnEntity":null,
  "AnOwnedTypeWithPrimitiveProperties":null,
  "AnOwnedTypeWithPrimitiveAndOwnedProperties":null,
  "AnOwnedTypeWithHasOneProperties":null,
  "AnOwnedTypeWithOwnedProperties":null
}

@smitpatel
Copy link
Contributor

Related #21488

@smitpatel
Copy link
Contributor

As per decision made in #23564 , the intermediate owned entity which is optional dependent but has no properties in it (apart from PK shared with principal), will act as required dependent and we will always materialize the instance. i.e. we are preserving the behavior which is being shown here. This is required in case if the nested owned entity has a value then it would be materialized rather than getting lost if the intermediate just returned null.

@smitpatel smitpatel added closed-fixed The issue has been fixed and is/will be included in the release indicated by the issue milestone. and removed blocked labels Dec 9, 2020
smitpatel added a commit that referenced this issue Dec 9, 2020
An optional dependent considered present
If all non-nullable non-PK properties have a non-null value, or
If all non-nullable non-PK properties are shared with principal then at least one nullable non shared property has a non-null value
If there are no non-shared properties then it is always present and act like a required dependent

Resolves #23564
Resolves #21488
Resolves #23198
smitpatel added a commit that referenced this issue Dec 9, 2020
An optional dependent considered present
If all non-nullable non-PK properties have a non-null value, or
If all non-nullable non-PK properties are shared with principal then at least one nullable non shared property has a non-null value
If there are no non-shared properties then it is always present and act like a required dependent

Resolves #23564
Resolves #21488
Resolves #23198
@J-Yen
Copy link
Author

J-Yen commented Feb 16, 2021

Another issue with fetching owned dependents: #24165

@smitpatel
Copy link
Contributor

As per new decision, since materializing the intermediate owned entity is somewhat non-deterministic without looking at data for nested dependents, we are going to throw for this scenario. User can either configure the first owned relationship to be required dependent (so it will always be materialized) or add a required property in intermediate entity which can serve as discriminator to identify if the intermediate entity exist or not in database.

smitpatel added a commit that referenced this issue Apr 3, 2021
smitpatel added a commit that referenced this issue Apr 5, 2021
@ghost ghost closed this as completed in #24573 Apr 6, 2021
ghost pushed a commit that referenced this issue Apr 6, 2021
@flindby
Copy link

flindby commented Apr 9, 2021

So, if I understand the "solution" to this behavior (when you need the first owned property materialized to null), you should add a required "dummy" property/column that will tell EF if the entire object should be materialized to null or default?

My situation is (example):
RootObject -> OwnsOne(Person) -> OwnsOne(Address).

The Person object will be materialized to default even when saved as null. Is this the expected behavior?

@ajcvickers ajcvickers modified the milestones: 6.0.0, 6.0.0-preview4 Apr 26, 2021
@ajcvickers ajcvickers removed this from the 6.0.0-preview4 milestone Nov 8, 2021
@ajcvickers ajcvickers added this to the 6.0.0 milestone Nov 8, 2021
This issue was closed.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
area-query 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.

4 participants