dotnet / dotnet/efcore

Shaper discards null checks

Open
#38,762 1 comment 0 reactions 1 assignee Claimed by @AndriySvyryd View on GitHub
area-query customer-reported
Dominant language
C#
Stars
14.8k
Forks
3.4k
PR merge metrics
PR metrics pending

Description

### Bug description

When projecting a query through the primary constructor of a (record) class or struct (e.g. for passing out of a method returning an `IQueryable` for further projection), downstream projections appear to be performed client-side, and downstream projections on the `IQueryable` that access properties of navigation properties have their null checks discarded, resulting in a `NullReferenceException` inside the shaper lambda method when projecting the results.

This occurs with both `Microsoft.EntityFrameworkCore.Sqlite` and with `Microting.EntityFrameworkCore.Mysql`

### Your code

```csharp
#!/usr/bin/env dotnet

#:package Microsoft.Extensions.Hosting@10.0.10
#:package Microsoft.EntityFrameworkCore.Sqlite@10.0.10
#:property PublishAot=false

using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using System.Diagnostics;

if (args.Contains("--wait-for-debugger"))
{
Console.WriteLine("Waiting for debugger to attach");
while (!Debugger.IsAttached)
{
Thread.Sleep(100);
}
Console.WriteLine("Debugger attached");
Debugger.Break();
}

var builder = Host.CreateApplicationBuilder();
builder.Services.AddDbContextFactory(opts => opts.UseSqlite("Filename=test.sqlite"));

using var host = builder.Build();
var ctxfactory = host.Services.GetRequiredService>();

using (var ctx = ctxfactory.CreateDbContext())
{
ctx.Database.EnsureDeleted();
ctx.Database.EnsureCreated();

for (int i = 1; i <= 100; i++)
{
ctx.Add(new TestTable1
{
Table1Id = i,
Field1 = Random.Shared.GetHexString(32),
Field2 = Random.Shared.GetHexString(32)
});
}

for (int i = 1; i <= 100; i++)
{
ctx.Add(new TestTable2
{
Table2Id = i,
Table1Id = Random.Shared.Next(2) == 0 ? null : Random.Shared.Next(1, 100),
Field1 = Random.Shared.GetHexString(32),
Field2 = Random.Shared.GetHexString(32)
});
}

for (int i = 1; i <= 100; i++)
{
ctx.Add(new TestTable3
{
Table3Id = i,
Table2Id = Random.Shared.Next(2) == 0 ? null : Random.Shared.Next(1, 100),
Field1 = Random.Shared.GetHexString(32),
Field2 = Random.Shared.GetHexString(32)
});
}

ctx.SaveChanges();
}

using (var ctx = ctxfactory.CreateDbContext())
{
IQueryable query =
ctx.Set()
.LeftJoin(
ctx.Set()
.Include(e => e.Table1Entry),
o => o.Table2Id,
i => i.Table2Id,
(o, i) => new { Tbl3 = o, Tbl2 = i }
)
.Select(e => new TestRecord(e.Tbl3, e.Tbl2));

var entries =
query
.Select(e => new TestOutput
{
T3Field1 = e.Table3Entry.Field1,
T3Field2 = e.Table3Entry.Field2,
T2Field1 = e.Table2Entry == null ? null : e.Table2Entry.Field1,
T2Field2 = e.Table2Entry == null ? null : e.Table2Entry.Field2,
T1Field1 = e.Table2Entry == null || e.Table2Entry.Table1Entry == null ? null : e.Table2Entry.Table1Entry.Field1,
T1Field2 = e.Table2Entry == null || e.Table2Entry.Table1Entry == null ? null : e.Table2Entry.Table1Entry.Field2,
})
.ToList();
}

Debugger.Break();

public class TestContext(DbContextOptions options) : DbContext(options)
{
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);

modelBuilder.Entity(m =>
{
m.HasKey(e => e.Table1Id);
m.ToTable("TestTable1");
});

modelBuilder.Entity(m =>
{
m.HasKey(e => e.Table2Id);
m.ToTable("TestTable2");
m.HasOne(e => e.Table1Entry).WithMany().HasForeignKey(e => e.Table1Id).HasPrincipalKey(e => e.Table1Id);
});

modelBuilder.Entity(m =>
{
m.HasKey(e => e.Table3Id);
m.ToTable("TestTable3");
});
}
}

public class TestOutput
{
public string? T3Field1 { get; init; }
public string? T3Field2 { get; init; }
public string? T2Field1 { get; init; }
public string? T2Field2 { get; init; }
public string? T1Field1 { get; init; }
public string? T1Field2 { get; init; }
}

public record struct TestRecord(TestTable3 Table3Entry, TestTable2? Table2Entry);

public class TestTable1
{
public int Table1Id { get; set; }
public string? Field1 { get; set; }
public string? Field2 { get; set; }
}

public class TestTable2
{
public int Table2Id { get; set; }
public int? Table1Id { get; set; }
public string? Field1 { get; set; }
public string? Field2 { get; set; }
public TestTable1? Table1Entry { get; set; }
}

public class TestTable3
{
public int Table3Id { get; set; }
public int? Table2Id { get; set; }
public string? Field1 { get; set; }
public string? Field2 { get; set; }
}
```

### Stack traces

```text
Unhandled exception. System.NullReferenceException: Object reference not set to an instance of an object.
at lambda_method156(Closure, QueryContext, DbDataReader, ResultContext, SingleQueryResultCoordinator)
at Microsoft.EntityFrameworkCore.Query.Internal.SingleQueryingEnumerable`1.Enumerator.MoveNext()
at System.Collections.Generic.List`1..ctor(IEnumerable`1 collection)
at System.Linq.Enumerable.ToList[TSource](IEnumerable`1 source)
at Program.$(String[] args) in C:\Users\klightspeed\Documents\TestEFCore\app.cs:line 83
```

### Verbose output

```text
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT "t"."Table3Id", "t"."Field1", "t"."Field2", "t"."Table2Id", "t0"."Table2Id", "t0"."Field1", "t0"."Field2", "t0"."Table1Id", "t1"."Table1Id", "t1"."Field1", "t1"."Field2"
FROM "TestTable3" AS "t"
LEFT JOIN "TestTable2" AS "t0" ON "t"."Table2Id" = "t0"."Table2Id"
LEFT JOIN "TestTable1" AS "t1" ON "t0"."Table1Id" = "t1"."Table1Id"
fail: Microsoft.EntityFrameworkCore.Query[10100]
An exception occurred while iterating over the results of a query for context type 'TestContext'.
System.NullReferenceException: Object reference not set to an instance of an object.
at lambda_method156(Closure, QueryContext, DbDataReader, ResultContext, SingleQueryResultCoordinator)
at Microsoft.EntityFrameworkCore.Query.Internal.SingleQueryingEnumerable`1.Enumerator.MoveNext()
System.NullReferenceException: Object reference not set to an instance of an object.
at lambda_method156(Closure, QueryContext, DbDataReader, ResultContext, SingleQueryResultCoordinator)
at Microsoft.EntityFrameworkCore.Query.Internal.SingleQueryingEnumerable`1.Enumerator.MoveNext()
Unhandled exception. System.NullReferenceException: Object reference not set to an instance of an object.
at lambda_method156(Closure, QueryContext, DbDataReader, ResultContext, SingleQueryResultCoordinator)
at Microsoft.EntityFrameworkCore.Query.Internal.SingleQueryingEnumerable`1.Enumerator.MoveNext()
at System.Collections.Generic.List`1..ctor(IEnumerable`1 collection)
at System.Linq.Enumerable.ToList[TSource](IEnumerable`1 source)
at Program.$(String[] args) in C:\Users\klightspeed\Documents\TestEFCore\app.cs:line 83
Segmentation fault
```

### EF Core version

10.0.10

### Database provider

Microsoft.EntityFrameworkCore.Sqlite

### Target framework

.NET 10

### Operating system

Windows 11

### IDE

Visual Studio 2026 18.8.2

### More Info

`shaperExpression` passed to `Microsoft.EntityFrameworkCore.Query.RelationalShapedQueryCompilingExpressionVisitor.ShaperProcessingExpressionVisitor.ProcessShaper`:
```
.New TestOutput(){
T3Field1 = ((.New TestRecord(
.Extension,
.Extension)).Table3Entry).Field1,
T3Field2 = ((.New TestRecord(
.Extension,
.Extension)).Table3Entry).Field2,
T2Field1 = ((.New TestRecord(
.Extension,
.Extension)).Table2Entry).Field1,
T2Field2 = ((.New TestRecord(
.Extension,
.Extension)).Table2Entry).Field2,
T1Field1 = .If (
(.New TestRecord(
.Extension,
.Extension)).Table2Entry == null || ((.New TestRecord(
.Extension,
.Extension)).Table2Entry).Table1Entry == null
) {
null
} .Else {
(((.New TestRecord(
.Extension,
.Extension)).Table2Entry).Table1Entry).Field1
},
T1Field2 = .If (
(.New TestRecord(
.Extension,
.Extension)).Table2Entry == null || ((.New TestRecord(
.Extension,
.Extension)).Table2Entry).Table1Entry == null
) {
null
} .Else {
(((.New TestRecord(
.Extension,
.Extension)).Table2Entry).Table1Entry).Field2
}
}
```

I found this while debugging an issue with a service using dotnet and EF Core. Certain queries were giving a NullReferenceException.
In this case, the query was of the form:
```cs
public virtual IQueryable QueryBodyMatchLines(
long bodyId,
DateTimeOffset? minDate,
DateTimeOffset? maxDate,
int? maxResults
)
{
var query = Set()
.Where(e => e.BodyId == bodyId)
.LeftJoin(
Set(),
o => o.FileId,
i => i.Id,
(o, i) => new { Body = o, File = i }
)
.LeftJoin(
Set()
.Include(e => e.Software)
.Include(e => e.SchemaEvent)
.Include(e => e.GameVersion),
o => new { o.Body.FileId, o.Body.LineNo },
i => new { i.FileId, i.LineNo },
(o, i) => new { o.File, Info = i, o.Body }
)
.LeftJoin(
Set()
.Include(e => e.Station),
o => new { o.Body.FileId, o.Body.LineNo },
i => new { i.FileId, i.LineNo },
(o, i) => new { o.File, o.Info, o.Body, Station = i }
);

if (minDate?.ToUniversalTime().DateTime is DateTime minTS)
{
query = query.Where(e => e.Body.GatewayTimestamp >= minTS);
}

if (maxDate?.ToUniversalTime().DateTime is DateTime maxTS)
{
query = query.Where(e => e.Body.GatewayTimestamp <= maxTS);
}

return
query
.OrderByDescending(e => e.Body.GatewayTimestamp)
.Take(maxResults ?? 1000)
.Where(e => e.File != null && e.Info != null)
.Select(e => new BodyMatchLineEntry(
e.File!,
e.Body,
e.Info!,
e.Station
));
}
```
and the projection was of the form:
```cs
private protected async Task>> GetBodyMatchEntriesAsync(
ICollection bodyIds,
int? limitMatches,
DateTimeOffset? minDate,
DateTimeOffset? maxDate,
CancellationToken canceltoken
)
{
await using var ctx = await ContextFactory.CreateDbContextAsync(canceltoken);

var matches = new Dictionary>();

foreach (var bodyid in bodyIds)
{
matches[bodyid] = //await
ctx.QueryBodyMatchLines(bodyid, minDate, maxDate, limitMatches)
.Select(e => new MatchEntry
{
FileName = e.File.FileName,
LineNo = e.Body.LineNo,
SoftwareName = e.Info.Software == null ? null : e.Info.Software.SoftwareName,
SoftwareVersion = e.Info.Software == null ? null : e.Info.Software.SoftwareVersion,
Schema = e.Info.SchemaEvent == null ? null : e.Info.SchemaEvent.Schema,
EventType = e.Info.SchemaEvent == null ? null : e.Info.SchemaEvent.EventType,
GameVersion = e.Info.GameVersion == null ? null : e.Info.GameVersion.GameVersion,
GameBuild = e.Info.GameVersion == null ? null : e.Info.GameVersion.GameBuild,
IsOdyssey = e.Info.GameVersion == null ? null : e.Info.GameVersion.IsOdyssey,
IsHorizons = e.Info.GameVersion == null ? null : e.Info.GameVersion.IsHorizons,
Timestamp = e.Info.Timestamp,
GatewayTimestamp = e.Body.GatewayTimestamp,
SystemId = e.Info.SystemId,
BodyId = e.Body.BodyId,
StationId = e.Station == null ? null : e.Station.StationId,
StationName = e.Station == null || e.Station.Station == null ? null : e.Station.Station.StationName,
MarketId = e.Station == null || e.Station.Station == null ? null : e.Station.Station.MarketId
})
.ToList();
//.ToListAsync(canceltoken);
}

return matches;
}
```

The stack trace in this case was:
```
at Microsoft.EntityFrameworkCore.Query.Internal.SingleQueryingEnumerable`1.Enumerator.MoveNext()
at System.Collections.Generic.List`1..ctor(IEnumerable`1 collection)
at System.Linq.Enumerable.ToList[TSource](IEnumerable`1 source)
at EddnIndexLookup.Services.EddnLookupService.d__24.MoveNext() in C:\Users\klightspeed\source\repos\EddnIndexUpdate\EddnIndexLookup\Services\EddnLookupService.cs:line 747
at EddnIndexLookup.Services.EddnLookupService.d__24.MoveNext() in C:\Users\klightspeed\source\repos\EddnIndexUpdate\EddnIndexLookup\Services\EddnLookupService.cs:line 773
```
and the disassembly around the `_ipForWatsonBuckets` was:
```
00007FFB849168C2 call EddnIndex.Common.Models.BodyMatchLineEntry..ctor(EddnIndex.Common.Models.FileInfo, EddnIndex.Common.Models.FileLineBody, EddnIndex.Common.Models.FileLineInfo, EddnIndex.Common.Models.FileLineStation) (07FFB848E1008h)
00007FFB849168C7 mov rcx,rdi
00007FFB849168CA call EddnIndex.Common.Models.BodyMatchLineEntry.get_Station() (07FFB848E1350h)
00007FFB849168CF mov rcx,rax
00007FFB849168D2 cmp dword ptr [rcx],ecx <--- NullReferenceException
00007FFB849168D4 call EddnIndex.Common.Models.FileLineStation.get_StationId() (07FFB848E1368h)
00007FFB849168D9 mov edx,eax
00007FFB849168DB shl rdx,20h
00007FFB849168DF or rdx,1
00007FFB849168E3 mov rcx,rbx
00007FFB849168E6 call EddnIndexLookup.DTO.MatchEntry.set_StationId(System.Nullable`1) (07FFB848E1380h)
```

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.