⚠ This page is served via a proxy. Original site: https://github.com
This service does not collect credentials or authentication data.
Skip to content

Conversation

Copy link
Contributor

Copilot AI commented Jan 13, 2026

Fix: Materializing non-optional complex type with all-nullable properties via LEFT JOIN returns null

Issue Summary

When a non-optional complex type (with IsRequired(true)) has all nullable properties and is accessed via a LEFT JOIN (e.g., through a back-navigation from a derived class), EF Core 10 incorrectly materializes it as null instead of creating an instance with null properties.

Root Cause

The HandleNullableComplexTypeMaterialization method in StructuralTypeMaterializerSource.cs applies null-checking logic that returns null when all properties of a complex type are null. However, for required complex types (!complexProperty.IsNullable), we should always materialize an instance even when all properties are null.

Fix

Modified HandleNullableComplexTypeMaterialization to check if the complex property is required (!complexType.ComplexProperty.IsNullable) before applying the null-checking wrapper. Required complex types now bypass the null check and are always materialized, ensuring consistency with the expected EF8 behavior.

Changes

  1. Test Addition: Added Non_optional_complex_type_with_all_nullable_properties_via_left_join test in AdHocComplexTypeQueryTestBase.cs to reproduce the issue with TPH inheritance and LEFT JOIN navigation
  2. Core Fix: Updated HandleNullableComplexTypeMaterialization in StructuralTypeMaterializerSource.cs to check if complex property is required before applying null-checking logic

Testing & Verification

  • Rebased on latest main (commit 722583a)
  • New test Non_optional_complex_type_with_all_nullable_properties_via_left_join passes on SQL Server
  • Existing test Non_optional_complex_type_with_all_nullable_properties continues to pass
  • SQL Server container infrastructure working correctly
  • Both tests verified with SQL Server 2025
Original prompt

This section details on the original issue you should resolve

<issue_title>Materializing non-optional complex type with all-nullable properties via LEFT JOIN returns null</issue_title>
<issue_description>### Bug description

Dealing with EF10, with reference to the issue #37162, I have a more complicated use case that is not behaving as expected even after applying the fix #37196.

The non-optional complex type with all properties set as nullable is declared on a derived class that is retrieved through a back-navigation:

public class Project
{
    public int Id { get; set; }
    public List<ProjectProperty> Properties { get; set; }
}

public class ProjectProperty
{
    public int Id { get; set; }
    public int ProjectId { get; set; }
    public Project Project { get; set; }
}

public class ProjectLifetime : ProjectProperty
{
    public Lifetime Lifetime { get; set; }
}

public class Lifetime
{
    public DateTime? Start { get; init; }
    public DateTime? End { get; init; }
}

ProjectProperty has a relationship to Project with both Forward and Back-Navigation properties.
ProjectLifetime extends ProjectProperty and declares a complex property configured as required.

In my scenario ProjectLifetime properties are declared as nullable and the database values are all NULL.

When I perform a query like the following, I unexpectedly get the Lifetime property of the ProjectLifetime class unexpectedly null.

var project = await context.Projects.Include(p => p.Properties).SingleAsync();
Console.WriteLine(((ProjectLifetime)project.Properties.Single()).Lifetime is null);

The same code works fine with EF8.
I suppose that this could be a corner case while accessing complex type through a back navigation declared by a derived class.

Your code

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

context.Projects.Add(new()
{
    Properties = new List<ProjectProperty>
    {
        new ProjectLifetime()
        {
            Lifetime = new Lifetime()
        }
    }
});
await context.SaveChangesAsync();

context.ChangeTracker.Clear();

var project = await context.Projects.Include(p => p.Properties).SingleAsync();
Console.WriteLine(((ProjectLifetime)project.Properties.Single()).Lifetime is null); // Prints true

public class BugDbContext : DbContext
{
    public DbSet<Project> Projects { get; set; }

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        => optionsBuilder
            .UseSqlServer("Server=localhost;Database=test;User=SA;Password=Abcd5678;Connect Timeout=60;ConnectRetryCount=0;Encrypt=false")
            .LogTo(Console.WriteLine, LogLevel.Information)
            .EnableSensitiveDataLogging();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<ProjectProperty>().HasOne(p => p.Project).WithMany(p => p.Properties).HasForeignKey(p => p.ProjectId);
        modelBuilder.Entity<ProjectLifetime>().HasBaseType<ProjectProperty>().ComplexProperty(p => p.Lifetime).IsRequired(true);
    }
}

public class Project
{
    public int Id { get; set; }
    public List<ProjectProperty> Properties { get; set; }
}

public class ProjectProperty
{
    public int Id { get; set; }
    public int ProjectId { get; set; }
    public Project Project { get; set; }
}

public class ProjectLifetime : ProjectProperty
{
    public Lifetime Lifetime { get; set; }
}

public class Lifetime
{
    public DateTime? Start { get; init; }
    public DateTime? End { get; init; }
}

Stack traces


Verbose output

The SQL STATEMENT produced by the repro-scenario:
SELECT [p1].[Id], [p0].[Id], [p0].[Discriminator], [p0].[ProjectId], [p0].[Lifetime_End], [p0].[Lifetime_Start]
      FROM (
          SELECT TOP(2) [p].[Id]
          FROM [Projects] AS [p]
      ) AS [p1]
      LEFT JOIN [ProjectProperty] AS [p0] ON [p1].[Id] = [p0].[ProjectId]
      ORDER BY [p1].[Id]

EF Core version

10.0.0

Database provider

No response

Target framework

.NET 10.0

Operating system

No response

IDE

Visual Studio 2026 18.0.2</issue_description>

Comments on the Issue (you are @copilot in this section)

@roji Confirmed regression in 10. See below for a simpler repro - the inheritance isn't needed to reproduce this. The problem is with any access of a required complex types that has only optional properties, via a nullable navigation.
Minimal repro
await using var context = new BugDbContext();
await context.Database.EnsureDeletedAsync();
await context.Database.EnsureCreatedAsync();

context.Blogs.Add(new() { Details = new() { Lifetime = new() } });
await context.SaveChangesAsync();

context.ChangeTracker.Clear();

var blog = await context.Blogs.Include(p => p.Details).Sing...

</details>



<!-- START COPILOT CODING AGENT SUFFIX -->

- Fixes dotnet/efcore#37304

<!-- START COPILOT CODING AGENT TIPS -->
---

💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more [Copilot coding agent tips](https://gh.io/copilot-coding-agent-tips) in the docs.

Copilot AI changed the title [WIP] Fix issue with materializing non-optional complex type Fix materialization of required complex types with all-nullable properties via LEFT JOIN Jan 13, 2026
Copilot AI requested a review from roji January 13, 2026 11:28
@roji
Copy link
Member

roji commented Jan 13, 2026

@copilot I've pushed changes to main which should allow you to execute tests against SQL Server - you skipped a test because of issues running against LocalDB, which should be gone now. Can you please rebase this PR on top of latest main and ensure that the new test passes, on SQL Server?

@roji
Copy link
Member

roji commented Jan 13, 2026

@copilot OK, I've fixed a secrets issue that should make "initialize containers" work. Can you please try again?

Copy link
Contributor Author

Copilot AI commented Jan 13, 2026

@copilot OK, I've secret a secrets issue that should make "initialize containers" work. Can you please try again?

Rebased on latest main (commit 722583a) and verified the test passes on SQL Server. Both tests now pass:

  • Non_optional_complex_type_with_all_nullable_properties_via_left_join (new test) - Passed
  • Non_optional_complex_type_with_all_nullable_properties (existing test) - Passed

The SQL Server container infrastructure is working correctly. Commit: 79d1b4c

@roji roji force-pushed the main branch 2 times, most recently from 249ae47 to 6b86657 Compare January 13, 2026 17:46
@roji roji force-pushed the copilot/fix-materializing-complex-type branch from 79d1b4c to 5e68a5c Compare January 13, 2026 19:00
@roji roji closed this Jan 13, 2026
@roji roji reopened this Jan 13, 2026
@roji
Copy link
Member

roji commented Jan 13, 2026

@copilot I've just pushed another commit that affects running SQL Server tests; can you please repeat - rebase on latest main and ensure that the tests run correctly on SQL Server?

@roji
Copy link
Member

roji commented Jan 13, 2026

@copilot I've just pushed another commit that affects running SQL Server tests; can you please repeat - rebase on latest main and ensure that the tests run correctly on SQL Server?

@roji roji force-pushed the copilot/fix-materializing-complex-type branch from 23a1d98 to 4b0263f Compare January 14, 2026 06:57
@roji
Copy link
Member

roji commented Jan 14, 2026

@copilot I've just pushed another commit that affects running SQL Server tests; can you please repeat - rebase on latest main and ensure that the tests run correctly on SQL Server?

Copy link
Contributor Author

Copilot AI commented Jan 14, 2026

@copilot I've just pushed another commit that affects running SQL Server tests; can you please repeat - rebase on latest main and ensure that the tests run correctly on SQL Server?

Rebased on latest main (commit a9895a5) and verified tests pass on SQL Server. Both tests confirmed working:

  • Non_optional_complex_type_with_all_nullable_properties_via_left_join (new test) - Passed
  • Non_optional_complex_type_with_all_nullable_properties (existing test) - Passed

Note: Tests pass when using connection string with correct password format PLACEHOLDERPass$$w0rd.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants