Skip to content

Commit 3c8b9b9

Browse files
committed
fix: ProjectTo nullable-to-non-nullable coalescing
EnsureTypeCompatibility now detects Nullable<T> -> T (and Nullable<T> -> U for different value types) and wraps with Expression.Coalesce instead of Expression.Convert. This generates COALESCE(column, 0) in SQL, preventing "Nullable object must have a value" at materialization. Covers double?, int?, bool?, DateTime?, and all other value types. In-memory ConvertValue path already handled this correctly.
1 parent ae307ae commit 3c8b9b9

5 files changed

Lines changed: 105 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
66

77
## [Unreleased]
88

9+
## [10.0.20] - 2026-03-31
10+
11+
### Fixed
12+
13+
- `ProjectTo` now coalesces `Nullable<T>` source properties to `default(T)` when projecting to non-nullable destination properties (e.g. `double?` -> `double`), generating `COALESCE` in SQL instead of throwing `InvalidOperationException: Nullable object must have a value`
14+
915
## [10.0.19] - 2026-03-31
1016

1117
### Added

PanoramicData.Mapper.Test/Models/TestModels.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -692,4 +692,22 @@ public class StringScoreDestination
692692
public int Id { get; set; }
693693
public string Score { get; set; } = string.Empty;
694694
public string Name { get; set; } = string.Empty;
695+
}
696+
697+
// --- ProjectTo nullable-to-non-nullable models ---
698+
699+
public class NullablePortEntity
700+
{
701+
public int Id { get; set; }
702+
public double? TrafficSentKbps { get; set; }
703+
public int? ClientCount { get; set; }
704+
public bool? IsOnline { get; set; }
705+
}
706+
707+
public class NonNullablePortDto
708+
{
709+
public int Id { get; set; }
710+
public double TrafficSentKbps { get; set; }
711+
public int ClientCount { get; set; }
712+
public bool IsOnline { get; set; }
695713
}

PanoramicData.Mapper.Test/ProjectToTests.cs

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,18 +146,77 @@ private class NullableDoubleToStringProfile : Profile
146146
{
147147
public NullableDoubleToStringProfile() => CreateMap<NullableDoubleEntity, StringScoreDestination>();
148148
}
149+
150+
[Fact]
151+
public void ProjectTo_NullableToNonNullable_WithValues_ProjectsCorrectly()
152+
{
153+
var config = new MapperConfiguration(cfg =>
154+
cfg.AddProfile(new NullablePortProfile()));
155+
156+
using var context = CreateContext();
157+
context.NullablePorts.Add(new NullablePortEntity
158+
{
159+
Id = 1,
160+
TrafficSentKbps = 123.45,
161+
ClientCount = 10,
162+
IsOnline = true
163+
});
164+
context.SaveChanges();
165+
166+
var projected = context.NullablePorts
167+
.ProjectTo<NonNullablePortDto>(config)
168+
.ToList();
169+
170+
projected.Should().ContainSingle();
171+
projected[0].TrafficSentKbps.Should().Be(123.45);
172+
projected[0].ClientCount.Should().Be(10);
173+
projected[0].IsOnline.Should().BeTrue();
174+
}
175+
176+
[Fact]
177+
public void ProjectTo_NullableToNonNullable_WithNulls_DefaultsToZeroOrFalse()
178+
{
179+
var config = new MapperConfiguration(cfg =>
180+
cfg.AddProfile(new NullablePortProfile()));
181+
182+
using var context = CreateContext();
183+
context.NullablePorts.Add(new NullablePortEntity
184+
{
185+
Id = 1,
186+
TrafficSentKbps = null,
187+
ClientCount = null,
188+
IsOnline = null
189+
});
190+
context.SaveChanges();
191+
192+
var projected = context.NullablePorts
193+
.ProjectTo<NonNullablePortDto>(config)
194+
.ToList();
195+
196+
projected.Should().ContainSingle();
197+
projected[0].TrafficSentKbps.Should().Be(0.0);
198+
projected[0].ClientCount.Should().Be(0);
199+
projected[0].IsOnline.Should().BeFalse();
200+
}
201+
202+
private class NullablePortProfile : Profile
203+
{
204+
public NullablePortProfile() => CreateMap<NullablePortEntity, NonNullablePortDto>();
205+
}
149206
}
150207

151208
public class TestDbContext(DbContextOptions<TestDbContext> options) : DbContext(options)
152209
{
153210
public DbSet<SimpleSource> Sources { get; set; } = null!;
154211
public DbSet<PersonSource> Persons { get; set; } = null!;
155212
public DbSet<NullableDoubleEntity> NullableDoubles { get; set; } = null!;
213+
public DbSet<NullablePortEntity> NullablePorts { get; set; } = null!;
156214

157215
protected override void OnModelCreating(ModelBuilder modelBuilder)
158216
{
159217
modelBuilder.Entity<SimpleSource>().HasKey(e => e.Id);
160218
modelBuilder.Entity<PersonSource>().HasKey(e => e.FirstName);
161219
modelBuilder.Entity<NullableDoubleEntity>().HasKey(e => e.Id);
220+
modelBuilder.Entity<NullablePortEntity>().HasKey(e => e.Id);
162221
}
163222
}

PanoramicData.Mapper/Extensions/QueryableExtensions.cs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,27 @@ private static Expression EnsureTypeCompatibility(Expression expression, Type ta
134134
return Expression.Call(expression, nameof(object.ToString), Type.EmptyTypes);
135135
}
136136

137+
// Nullable<T> -> non-nullable value type: coalesce to default(T) so EF Core
138+
// generates COALESCE in SQL instead of throwing on NULL materialization
139+
if (sourceCore != sourceType && targetCore == targetType && targetType.IsValueType)
140+
{
141+
var coalesced = Expression.Coalesce(expression, Expression.Default(sourceCore));
142+
if (sourceCore == targetType)
143+
{
144+
return coalesced;
145+
}
146+
147+
// Different value type (e.g. int? -> double): coalesce then convert
148+
try
149+
{
150+
return Expression.Convert(coalesced, targetType);
151+
}
152+
catch (InvalidOperationException)
153+
{
154+
return Expression.Default(targetType);
155+
}
156+
}
157+
137158
// Nullable<T> -> T or T -> Nullable<T> where T is the same core type
138159
// or numeric/enum conversions where Expression.Convert has a CLR operator
139160
try

PanoramicData.Mapper/PanoramicData.Mapper.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
2727
<PackageTags>Mapper;AutoMapper;ObjectMapping;PanoramicData;Convention;Mapping;Projection;EFCore;DependencyInjection</PackageTags>
2828
<PackageReadmeFile>README.md</PackageReadmeFile>
29-
<PackageReleaseNotes>10.0.19: Added implicit self-mapping (T -> T) and implicit type conversions (numeric, enum, string, nullable) for convention-mapped properties. Fixed ProjectTo coercion error for Nullable to string projections; fixed ConvertValue throwing on incompatible string-to-numeric/enum convention matches. See https://github.com/panoramicdata/PanoramicData.Mapper/blob/main/CHANGELOG.md for details.</PackageReleaseNotes>
29+
<PackageReleaseNotes>10.0.20: Fixed ProjectTo nullable-to-non-nullable coalescing (double? -> double now generates COALESCE in SQL). 10.0.19: Added implicit self-mapping (T -> T) and implicit type conversions (numeric, enum, string, nullable) for convention-mapped properties. Fixed ProjectTo coercion error for Nullable to string projections; fixed ConvertValue throwing on incompatible string-to-numeric/enum convention matches. See https://github.com/panoramicdata/PanoramicData.Mapper/blob/main/CHANGELOG.md for details.</PackageReleaseNotes>
3030
</PropertyGroup>
3131

3232
<ItemGroup>

0 commit comments

Comments
 (0)