efcore icon indicating copy to clipboard operation
efcore copied to clipboard

EF 7.0.9 returns a non-null value despite explicitly selecting for a nullable value.

Open space-alien opened this issue 2 years ago • 4 comments

EF 7.0.9 returns a non-null value despite explicitly selecting for a nullable value.

Seems similar to https://github.com/dotnet/efcore/issues/26744

Here is a complete Console app repro:

Program.cs

using Microsoft.EntityFrameworkCore;


// Reset DB
var db = new TestContext();
db.Database.EnsureDeleted();
db.Database.EnsureCreated();


// Add Customers
{
    // #1 - customer with active plan
    db.Add(new Customer { Id = 1, ServicePlan = new ServicePlan { TerminationDate = null } });

    // #2 - customer with past plan
    db.Add(new Customer { Id = 2, ServicePlan = new ServicePlan { TerminationDate = DateTime.Now.AddDays(-1) } });

    // #3 - customer who never had a plan
    db.Add(new Customer { Id = 3, ServicePlan = null });

    db.SaveChanges();
}


// Test queries

// OK
var neverCustomers = db.Customers.Where(c => c.ServicePlan == null).ToList();
PrintCustomerIds(neverCustomers); // "3"

// OK
var customers = db.Customers.Where(c => c.ServicePlan != null).ToList();
PrintCustomerIds(customers); // "1, 2"

// OK
var pastCustomers = db.Customers.Where(c => c.ServicePlan.TerminationDate != null).ToList();
PrintCustomerIds(pastCustomers); // "2"

// OK
var validData = db.Customers
    .Select(c => c.ServicePlan == null ? (bool?)null : c.ServicePlan.TerminationDate != null);
PrintNullableBools(validData); // OUTPUT: "False, True, Null"

// BUG. Should produce the same output as the previous query.
var badData = db.Customers
    .Select(c => c.ServicePlan == null ? (bool?)null : c.ServicePlan.TerminationDate.HasValue);
PrintNullableBools(badData); // OUTPUT: "False, True, False". EXPECTED: "False, True, Null"


// Helpers
void PrintCustomerIds(IEnumerable<Customer> customers)
    => Console.WriteLine(string.Join(", ", customers.Select(x => x.Id)));

void PrintNullableBools(IEnumerable<bool?> data)
    => Console.WriteLine(string.Join(", ",
        data.Select(b => b == null ? "Null" : b.ToString())));


// Model
public class Customer
{
    public int Id { get; set; }
    public ServicePlan? ServicePlan { get; set; }
}

public class ServicePlan
{
    public int Id { get; set; }
    public DateTime? TerminationDate { get; set; }
}


// DbContext
public class TestContext : DbContext
{
    public DbSet<Customer> Customers { get; set; }
    public DbSet<ServicePlan> ServicePlans { get; set; }

    public string DbPath { get; }

    public TestContext()
    {
        var folder = Environment.SpecialFolder.LocalApplicationData;
        var path = Environment.GetFolderPath(folder);
        DbPath = Path.Join(path, "EfCoreNullableQuerying.db");
    }

    protected override void OnConfiguring(DbContextOptionsBuilder options)
        => options.UseSqlite($"Data Source={DbPath}");
}

EfCoreNullableQuerying.csproj

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net7.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="7.0.9" />
  </ItemGroup>
</Project>

space-alien avatar Jul 13 '23 18:07 space-alien

I've added the validData query above, to illustrate the different behaviour when selecting a nullable bool vs a non-nullable bool.

space-alien avatar Jul 13 '23 19:07 space-alien

@maumar Looks like an issue with .HasValue and null propagation.

ajcvickers avatar Jul 15 '23 14:07 ajcvickers

NullCheckRemovingExpressionVisitor converts

DbSet<Customer>()
    .Select(c => c.ServicePlan == null ? null : (bool?)c.ServicePlan.TerminationDate.HasValue)

into

DbSet<Customer>()
    .Select(c => (bool?)c.ServicePlan.TerminationDate.HasValue)

which is correct in general, but not in case of HasValue. However, even if we fix that, there is another issue in the translation phase - we generate:

SELECT CASE
    WHEN [s].[TerminationDate] IS NOT NULL THEN CAST(1 AS bit)
    ELSE CAST(0 AS bit)
END
FROM [Customers] AS [c]
LEFT JOIN [ServicePlans] AS [s] ON [c].[ServicePlanId] = [s].[Id]

maumar avatar Oct 09 '23 22:10 maumar

@maumar the first issue affects all of the providers; the second one is yet another occurrence of #34001

ranma42 avatar Jun 28 '24 16:06 ranma42