|
10 | 10 | using Microsoft.EntityFrameworkCore.Query.Internal; |
11 | 11 | using Microsoft.EntityFrameworkCore.Query.SqlExpressions; |
12 | 12 | using Microsoft.EntityFrameworkCore.Storage.Json; |
| 13 | +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; |
13 | 14 | using static System.Linq.Expressions.Expression; |
14 | 15 |
|
15 | 16 | namespace Microsoft.EntityFrameworkCore.Query; |
@@ -3000,7 +3001,90 @@ Expression valueExpression |
3000 | 3001 | var converter = typeMapping.Converter; |
3001 | 3002 |
|
3002 | 3003 | 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) |
3004 | 3088 | { |
3005 | 3089 | // if IProperty is available, we can reliably get the converter from the model and then incorporate FromProvider(Typed) delegate |
3006 | 3090 | // into the expression. This way we have consistent behavior between precompiled and normal queries (same code path) |
@@ -3074,12 +3158,14 @@ Expression valueExpression |
3074 | 3158 | } |
3075 | 3159 | } |
3076 | 3160 |
|
3077 | | - if (valueExpression.Type != type) |
| 3161 | + if (!primitiveCollectionJsonHandled |
| 3162 | + && valueExpression.Type != type) |
3078 | 3163 | { |
3079 | 3164 | valueExpression = Convert(valueExpression, type); |
3080 | 3165 | } |
3081 | 3166 |
|
3082 | | - if (nullable) |
| 3167 | + if (!primitiveCollectionJsonHandled |
| 3168 | + && nullable) |
3083 | 3169 | { |
3084 | 3170 | Expression replaceExpression; |
3085 | 3171 | if (converter?.ConvertsNulls == true) |
@@ -3277,6 +3363,30 @@ private Expression CreateReadJsonPropertyValueExpression( |
3277 | 3363 | nullExpression, |
3278 | 3364 | resultExpression); |
3279 | 3365 | } |
| 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 | + } |
3280 | 3390 |
|
3281 | 3391 | if (_detailedErrorsEnabled) |
3282 | 3392 | { |
|
0 commit comments