Skip to content

Commit 6a2d7a8

Browse files
authored
Improve handling of null values in JSON properties mapped to primitive collections (#38498)
Fixes #38454
1 parent 654a010 commit 6a2d7a8

7 files changed

Lines changed: 547 additions & 7 deletions

src/EFCore.Relational/Query/RelationalShapedQueryCompilingExpressionVisitor.ShaperProcessingExpressionVisitor.ClientMethods.cs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
using System.Collections;
55
using System.Runtime.CompilerServices;
6+
using System.Text;
67
using System.Text.Json;
78
using Microsoft.EntityFrameworkCore.Internal;
89
using Microsoft.EntityFrameworkCore.Query.Internal;
@@ -79,6 +80,9 @@ private static readonly MethodInfo MaterializeJsonNullableValueStructuralTypeMet
7980
private static readonly MethodInfo MaterializeJsonEntityCollectionMethodInfo
8081
= typeof(ShaperProcessingExpressionVisitor).GetTypeInfo().GetDeclaredMethod(nameof(MaterializeJsonEntityCollection))!;
8182

83+
private static readonly MethodInfo ReadPrimitiveCollectionFromJsonMethodInfo
84+
= typeof(ShaperProcessingExpressionVisitor).GetTypeInfo().GetDeclaredMethod(nameof(ReadPrimitiveCollectionFromJson))!;
85+
8286
private static readonly MethodInfo InverseCollectionFixupMethod
8387
= typeof(ShaperProcessingExpressionVisitor).GetTypeInfo().GetDeclaredMethod(nameof(InverseCollectionFixup))!;
8488

@@ -956,6 +960,45 @@ static async Task<RelationalDataReader> InitializeReaderAsync(
956960
dataReaderContext.HasNext = false;
957961
}
958962

963+
/// <summary>
964+
/// This is an internal API that supports the Entity Framework Core infrastructure and not subject to
965+
/// the same compatibility standards as public APIs. It may be changed or removed without notice in
966+
/// any release. You should only use it directly in your code with extreme caution and knowing that
967+
/// doing so can result in application failures when updating to a new Entity Framework Core release.
968+
/// </summary>
969+
[EntityFrameworkInternal]
970+
public static object? ReadPrimitiveCollectionFromJson(
971+
string? json,
972+
JsonValueReaderWriter readerWriter,
973+
bool nullable,
974+
string propertyName)
975+
{
976+
if (json == null)
977+
{
978+
return null;
979+
}
980+
981+
// Preserve the diagnostics of the converter path (JsonValueReaderWriter.FromJsonString), which rejects
982+
// empty/whitespace JSON strings before tokenizing.
983+
if (string.IsNullOrWhiteSpace(json))
984+
{
985+
throw new InvalidOperationException(CoreStrings.EmptyJsonString);
986+
}
987+
988+
// A primitive collection mapped to a column is read by parsing the JSON string with the collection's
989+
// JsonValueReaderWriter (which doesn't handle the 'null' token). The stored value may be a JSON 'null'
990+
// token (e.g. the literal string "null"), which must be materialized as null for an optional property,
991+
// rather than letting the reader/writer throw. See issues #34881 and #38454.
992+
var manager = new Utf8JsonReaderManager(new JsonReaderData(Encoding.UTF8.GetBytes(json)), null);
993+
manager.MoveNext();
994+
995+
return manager.CurrentReader.TokenType == JsonTokenType.Null
996+
? nullable
997+
? null
998+
: throw new InvalidOperationException(RelationalStrings.NullValueInRequiredJsonProperty(propertyName))
999+
: readerWriter.FromJson(ref manager);
1000+
}
1001+
9591002
/// <summary>
9601003
/// This is an internal API that supports the Entity Framework Core infrastructure and not subject to
9611004
/// the same compatibility standards as public APIs. It may be changed or removed without notice in

src/EFCore.Relational/Query/RelationalShapedQueryCompilingExpressionVisitor.ShaperProcessingExpressionVisitor.cs

Lines changed: 113 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
using Microsoft.EntityFrameworkCore.Query.Internal;
1111
using Microsoft.EntityFrameworkCore.Query.SqlExpressions;
1212
using Microsoft.EntityFrameworkCore.Storage.Json;
13+
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
1314
using static System.Linq.Expressions.Expression;
1415

1516
namespace Microsoft.EntityFrameworkCore.Query;
@@ -3000,7 +3001,90 @@ Expression valueExpression
30003001
var converter = typeMapping.Converter;
30013002

30023003
var converterExpression = default(Expression);
3003-
if (converter != null)
3004+
var primitiveCollectionJsonHandled = false;
3005+
3006+
// #34881/#38454: A primitive collection mapped to a column is stored as a JSON string and read via the collection's
3007+
// JsonValueReaderWriter. That reader/writer doesn't handle a JSON 'null' token, so we peek the first token here and
3008+
// short-circuit to null (or throw for a required property) before invoking the reader/writer, rather than letting it
3009+
// throw a cryptic "Invalid token type: 'Null'". This applies both when materializing an entity (the property is
3010+
// available) and when projecting the collection column directly (no property; a collection type mapping is
3011+
// identified by its ElementTypeMapping).
3012+
var jsonPrimitiveCollectionReaderWriter = converter is { ConvertsNulls: false }
3013+
? property is IProperty { IsPrimitiveCollection: true } primitiveCollectionProperty
3014+
? primitiveCollectionProperty.GetJsonValueReaderWriter() ?? primitiveCollectionProperty.GetTypeMapping().JsonValueReaderWriter
3015+
: property is null && typeMapping.ElementTypeMapping is not null
3016+
? typeMapping.JsonValueReaderWriter
3017+
: null
3018+
: null;
3019+
3020+
if (jsonPrimitiveCollectionReaderWriter is not null)
3021+
{
3022+
Expression jsonReaderWriterExpression;
3023+
if (property is IProperty jsonProperty)
3024+
{
3025+
var liftableConstantParameter = Parameter(typeof(MaterializerLiftableConstantContext), "c");
3026+
jsonReaderWriterExpression = _parentVisitor.Dependencies.LiftableConstantFactory.CreateLiftableConstant(
3027+
jsonPrimitiveCollectionReaderWriter,
3028+
Lambda<Func<MaterializerLiftableConstantContext, object>>(
3029+
Coalesce(
3030+
Call(
3031+
LiftableConstantExpressionHelpers.BuildMemberAccessForProperty(
3032+
jsonProperty, liftableConstantParameter),
3033+
PropertyGetJsonValueReaderWriterMethod),
3034+
Property(
3035+
Call(
3036+
LiftableConstantExpressionHelpers.BuildMemberAccessForProperty(
3037+
jsonProperty, liftableConstantParameter),
3038+
PropertyGetTypeMappingMethod),
3039+
nameof(CoreTypeMapping.JsonValueReaderWriter))),
3040+
liftableConstantParameter),
3041+
jsonProperty.Name + "JsonReaderWriter",
3042+
typeof(JsonValueReaderWriter));
3043+
}
3044+
else
3045+
{
3046+
// No property is available (e.g. projecting the collection column directly), so we can't reference the
3047+
// reader/writer via the property. Use its ConstructorExpression, which is a quotable expression tree that
3048+
// reconstructs the reader/writer (the same mechanism CollectionToJsonStringConverter uses).
3049+
jsonReaderWriterExpression = jsonPrimitiveCollectionReaderWriter.ConstructorExpression;
3050+
if (jsonReaderWriterExpression.Type != typeof(JsonValueReaderWriter))
3051+
{
3052+
jsonReaderWriterExpression = Convert(jsonReaderWriterExpression, typeof(JsonValueReaderWriter));
3053+
}
3054+
}
3055+
3056+
if (valueExpression.Type != typeof(string))
3057+
{
3058+
valueExpression = Convert(valueExpression, typeof(string));
3059+
}
3060+
3061+
// When there's no property this is a projection, which has no notion of "required", so a JSON 'null'
3062+
// token is always materialized as null rather than throwing.
3063+
Expression readExpression = Call(
3064+
ReadPrimitiveCollectionFromJsonMethodInfo,
3065+
valueExpression,
3066+
jsonReaderWriterExpression,
3067+
Constant((property as IProperty)?.IsNullable ?? true),
3068+
Constant((property as IProperty)?.Name ?? string.Empty));
3069+
3070+
if (readExpression.Type != type)
3071+
{
3072+
readExpression = Convert(readExpression, type);
3073+
}
3074+
3075+
if (nullable)
3076+
{
3077+
// The column itself may be SQL NULL (DbNull), distinct from a JSON 'null' token in a non-null string.
3078+
readExpression = Condition(
3079+
Call(dbDataReader, IsDbNullMethod, indexExpression),
3080+
Default(type),
3081+
readExpression);
3082+
}
3083+
3084+
valueExpression = readExpression;
3085+
primitiveCollectionJsonHandled = true;
3086+
}
3087+
else if (converter != null)
30043088
{
30053089
// if IProperty is available, we can reliably get the converter from the model and then incorporate FromProvider(Typed) delegate
30063090
// into the expression. This way we have consistent behavior between precompiled and normal queries (same code path)
@@ -3074,12 +3158,14 @@ Expression valueExpression
30743158
}
30753159
}
30763160

3077-
if (valueExpression.Type != type)
3161+
if (!primitiveCollectionJsonHandled
3162+
&& valueExpression.Type != type)
30783163
{
30793164
valueExpression = Convert(valueExpression, type);
30803165
}
30813166

3082-
if (nullable)
3167+
if (!primitiveCollectionJsonHandled
3168+
&& nullable)
30833169
{
30843170
Expression replaceExpression;
30853171
if (converter?.ConvertsNulls == true)
@@ -3277,6 +3363,30 @@ private Expression CreateReadJsonPropertyValueExpression(
32773363
nullExpression,
32783364
resultExpression);
32793365
}
3366+
else if (property.GetElementType() is not null)
3367+
{
3368+
// A required primitive collection nested in a JSON document can't be materialized from a JSON 'null'
3369+
// token. Throw a clear, property-named error instead of the cryptic reader/writer "Invalid token type".
3370+
if (resultExpression.Type != property.ClrType)
3371+
{
3372+
resultExpression = Convert(resultExpression, property.ClrType);
3373+
}
3374+
3375+
resultExpression = Condition(
3376+
Equal(
3377+
Property(
3378+
Field(
3379+
jsonReaderManagerParameter,
3380+
Utf8JsonReaderManagerCurrentReaderField),
3381+
Utf8JsonReaderTokenTypeProperty),
3382+
Constant(JsonTokenType.Null)),
3383+
Throw(
3384+
New(
3385+
typeof(InvalidOperationException).GetConstructor([typeof(string)])!,
3386+
Constant(RelationalStrings.NullValueInRequiredJsonProperty(property.Name))),
3387+
property.ClrType),
3388+
resultExpression);
3389+
}
32803390

32813391
if (_detailedErrorsEnabled)
32823392
{

src/EFCore.Relational/Storage/RelationalTypeMapping.cs

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -678,15 +678,16 @@ protected virtual string GenerateNonNullSqlLiteral(object value)
678678
/// <returns>The default provider value.</returns>
679679
public virtual object? GetDefaultProviderValue()
680680
{
681+
var providerType = (Converter?.ProviderClrType ?? ClrType).UnwrapNullableType();
682+
683+
// A primitive collection is serialized to a JSON string in its column, so its default value must be an empty JSON
684+
// array rather than an empty string (which isn't valid JSON).
681685
if (ElementTypeMapping is not null
682-
&& Converter?.GetType() is { IsGenericType: true } converterType
683-
&& converterType.GetGenericTypeDefinition() == typeof(CollectionToJsonStringConverter<>))
686+
&& JsonValueReaderWriter != null)
684687
{
685688
return "[]";
686689
}
687690

688-
var providerType = (Converter?.ProviderClrType ?? ClrType).UnwrapNullableType();
689-
690691
return providerType == typeof(string)
691692
? string.Empty
692693
: providerType.IsArray

test/EFCore.Relational.Specification.Tests/Query/PrecompiledQueryRelationalFixture.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
using Post = Microsoft.EntityFrameworkCore.Query.PrecompiledQueryRelationalTestBase.Post;
88
using JsonRoot = Microsoft.EntityFrameworkCore.Query.PrecompiledQueryRelationalTestBase.JsonRoot;
99
using JsonBranch = Microsoft.EntityFrameworkCore.Query.PrecompiledQueryRelationalTestBase.JsonBranch;
10+
using EntityWithPrimitiveCollection = Microsoft.EntityFrameworkCore.Query.PrecompiledQueryRelationalTestBase.EntityWithPrimitiveCollection;
1011

1112
namespace Microsoft.EntityFrameworkCore.Query;
1213

@@ -96,7 +97,17 @@ protected override async Task SeedAsync(PrecompiledQueryRelationalTestBase.Preco
9697
};
9798

9899
context.Posts.AddRange(post11, post12, post21, post22, post23);
100+
101+
context.EntitiesWithPrimitiveCollection.AddRange(
102+
new EntityWithPrimitiveCollection { Id = 1, Tags = ["a", "b"] },
103+
new EntityWithPrimitiveCollection { Id = 2, Tags = ["x", "y"] });
99104
await context.SaveChangesAsync();
105+
106+
// Overwrite the second entity's collection column with the JSON 'null' token (as legacy/external data might),
107+
// rather than a SQL NULL, so the materializer's null-token peek path is exercised under precompilation.
108+
await context.Database.ExecuteSqlRawAsync(
109+
TestStore.NormalizeDelimitersInRawString(
110+
"UPDATE [EntitiesWithPrimitiveCollection] SET [Tags] = 'null' WHERE [Id] = 2"));
100111
}
101112

102113
public abstract PrecompiledQueryTestHelpers PrecompiledQueryTestHelpers { get; }

test/EFCore.Relational.Specification.Tests/Query/PrecompiledQueryRelationalTestBase.cs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1230,10 +1230,39 @@ public virtual Task Unsafe_accessor_gets_generated_once_for_multiple_queries()
12301230
interceptorCodeAsserter: code => Assert.Equal(
12311231
2, code.Split("private static extern ref int UnsafeAccessor_Microsoft_EntityFrameworkCore_Query_Blog_Id_Set").Length));
12321232

1233+
// #34881/#38454: A primitive collection mapped to a column is read via a JsonValueReaderWriter that is emitted as a
1234+
// liftable constant. This test makes sure that liftable constant is handled correctly under query precompilation,
1235+
// including the JSON 'null' token peek path (the second entity's column holds the literal 'null' token).
1236+
[Fact]
1237+
public virtual Task Materialize_entity_with_primitive_collection_mapped_to_column()
1238+
=> Test(
1239+
"""
1240+
var entities = await context.EntitiesWithPrimitiveCollection.OrderBy(e => e.Id).ToListAsync();
1241+
1242+
Assert.Equal(2, entities.Count);
1243+
Assert.Equal(new[] { "a", "b" }, entities[0].Tags);
1244+
Assert.Null(entities[1].Tags);
1245+
""");
1246+
1247+
// Projecting the collection column directly reaches the materializer without an IProperty, so the JsonValueReaderWriter
1248+
// is emitted via its (quotable) ConstructorExpression rather than a property-based liftable constant. This makes sure
1249+
// that path is quotable under precompilation, including the JSON 'null' token peek.
1250+
[Fact]
1251+
public virtual Task Project_primitive_collection_mapped_to_column()
1252+
=> Test(
1253+
"""
1254+
var tags = await context.EntitiesWithPrimitiveCollection.OrderBy(e => e.Id).Select(e => e.Tags).ToListAsync();
1255+
1256+
Assert.Equal(2, tags.Count);
1257+
Assert.Equal(new[] { "a", "b" }, tags[0]);
1258+
Assert.Null(tags[1]);
1259+
""");
1260+
12331261
public class PrecompiledQueryContext(DbContextOptions options) : DbContext(options)
12341262
{
12351263
public DbSet<Blog> Blogs { get; set; } = null!;
12361264
public DbSet<Post> Posts { get; set; } = null!;
1265+
public DbSet<EntityWithPrimitiveCollection> EntitiesWithPrimitiveCollection { get; set; } = null!;
12371266

12381267
protected override void OnModelCreating(ModelBuilder modelBuilder)
12391268
{
@@ -1247,6 +1276,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
12471276
});
12481277
modelBuilder.Entity<Blog>().HasMany(x => x.Posts).WithOne(x => x.Blog).OnDelete(DeleteBehavior.Cascade);
12491278
modelBuilder.Entity<Post>().Property(x => x.Id).ValueGeneratedNever();
1279+
modelBuilder.Entity<EntityWithPrimitiveCollection>().Property(x => x.Id).ValueGeneratedNever();
12501280
}
12511281
}
12521282

@@ -1337,5 +1367,15 @@ public class Post
13371367
public Blog? Blog { get; set; }
13381368
}
13391369

1370+
public class EntityWithPrimitiveCollection
1371+
{
1372+
public int Id { get; set; }
1373+
1374+
// Mapped to a column as a JSON string via the primitive-collection convention (CollectionToJsonStringConverter).
1375+
// An array (rather than List<T>) is used so entity materialization assigns the collection directly instead of
1376+
// going through the PopulateList optimization (which is unrelated to the JSON null-token handling under test).
1377+
public string[]? Tags { get; set; }
1378+
}
1379+
13401380
public static readonly IEnumerable<object[]> IsAsyncData = [[false], [true]];
13411381
}

0 commit comments

Comments
 (0)