From adcc266369ac7e87ef8d83af2c5b278e6d00cae7 Mon Sep 17 00:00:00 2001 From: JoasE <32096708+JoasE@users.noreply.github.com> Date: Tue, 24 Mar 2026 15:58:32 +0100 Subject: [PATCH 01/10] Fix run value converters that convert nulls for JSON columns During (de)serialization, check if converter converts nulls and run for null values if so Closes: #37983 --- ...sitor.ShaperProcessingExpressionVisitor.cs | 52 +++- .../Update/ModificationCommand.cs | 8 +- .../ValueConvertersEndToEndTestBase.cs | 31 ++- ...alueConvertersEndToEndSqlServerJsonTest.cs | 262 ++++++++++++++++++ 4 files changed, 340 insertions(+), 13 deletions(-) create mode 100644 test/EFCore.SqlServer.FunctionalTests/ValueConvertersEndToEndSqlServerJsonTest.cs diff --git a/src/EFCore.Relational/Query/RelationalShapedQueryCompilingExpressionVisitor.ShaperProcessingExpressionVisitor.cs b/src/EFCore.Relational/Query/RelationalShapedQueryCompilingExpressionVisitor.ShaperProcessingExpressionVisitor.cs index cbd03fd935b..ce8ce18e136 100644 --- a/src/EFCore.Relational/Query/RelationalShapedQueryCompilingExpressionVisitor.ShaperProcessingExpressionVisitor.cs +++ b/src/EFCore.Relational/Query/RelationalShapedQueryCompilingExpressionVisitor.ShaperProcessingExpressionVisitor.cs @@ -3188,6 +3188,56 @@ private Expression CreateReadJsonPropertyValueExpression( resultExpression = Convert(resultExpression, property.ClrType); } + var converter = property.GetTypeMapping().Converter; + Expression nullExpression; + if (converter?.ConvertsNulls == true) + { + var typeMappingExpression = Call( + Convert( + _parentVisitor.Dependencies.LiftableConstantFactory.CreateLiftableConstant( + property, + LiftableConstantExpressionHelpers.BuildMemberAccessLambdaForProperty(property), + property.Name + "Property", + typeof(IPropertyBase)), + typeof(IReadOnlyProperty)), + PropertyGetTypeMappingMethod); + + var converterExpression = (Expression)Property(typeMappingExpression, nameof(CoreTypeMapping.Converter)); + + var converterType = converter.GetType(); + var typedConverterType = converterType.GetGenericTypeImplementations(typeof(ValueConverter<,>)).FirstOrDefault(); + if (typedConverterType != null) + { + if (converterExpression.Type != converter.GetType()) + { + converterExpression = Convert(converterExpression, converter.GetType()); + } + + nullExpression = Invoke( + Property( + converterExpression, + nameof(ValueConverter.ConvertFromProviderTyped)), + Default(converter.ProviderClrType)); + } + else + { + nullExpression = Invoke( + Property( + converterExpression, + nameof(ValueConverter.ConvertFromProvider)), + Default(typeof(object))); + } + + if (nullExpression.Type != property.ClrType) + { + nullExpression = Convert(nullExpression, property.ClrType); + } + } + else + { + nullExpression = Default(property.ClrType); + } + resultExpression = Condition( Equal( Property( @@ -3196,7 +3246,7 @@ private Expression CreateReadJsonPropertyValueExpression( Utf8JsonReaderManagerCurrentReaderField), Utf8JsonReaderTokenTypeProperty), Constant(JsonTokenType.Null)), - Default(property.ClrType), + nullExpression, resultExpression); } diff --git a/src/EFCore.Relational/Update/ModificationCommand.cs b/src/EFCore.Relational/Update/ModificationCommand.cs index bd6c225501c..02f24476494 100644 --- a/src/EFCore.Relational/Update/ModificationCommand.cs +++ b/src/EFCore.Relational/Update/ModificationCommand.cs @@ -8,6 +8,7 @@ using Microsoft.EntityFrameworkCore.ChangeTracking.Internal; using Microsoft.EntityFrameworkCore.Internal; using Microsoft.EntityFrameworkCore.Metadata.Internal; +using Microsoft.EntityFrameworkCore.Storage.Internal; using IColumnMapping = Microsoft.EntityFrameworkCore.Metadata.IColumnMapping; using ITableMapping = Microsoft.EntityFrameworkCore.Metadata.ITableMapping; @@ -969,11 +970,12 @@ private void WriteJsonObject( #pragma warning disable EF1001 // Internal EF Core API usage. writer.WritePropertyName(jsonPropertyName); - if (propertyValue is not null) + var jsonValueReaderWriter = property.GetJsonValueReaderWriter() ?? property.GetTypeMapping().JsonValueReaderWriter; + if (propertyValue is not null || + jsonValueReaderWriter is IJsonConvertedValueReaderWriter { Converter.ConvertsNulls: true }) { - var jsonValueReaderWriter = property.GetJsonValueReaderWriter() ?? property.GetTypeMapping().JsonValueReaderWriter; Check.DebugAssert(jsonValueReaderWriter is not null, "Missing JsonValueReaderWriter on JSON property"); - jsonValueReaderWriter.ToJson(writer, propertyValue); + jsonValueReaderWriter.ToJson(writer, propertyValue!); } else { diff --git a/test/EFCore.Specification.Tests/ValueConvertersEndToEndTestBase.cs b/test/EFCore.Specification.Tests/ValueConvertersEndToEndTestBase.cs index 7cfb4bffdcf..fe7aaf54e90 100644 --- a/test/EFCore.Specification.Tests/ValueConvertersEndToEndTestBase.cs +++ b/test/EFCore.Specification.Tests/ValueConvertersEndToEndTestBase.cs @@ -4,6 +4,7 @@ using System.Net; using System.Net.NetworkInformation; using System.Text.Json; +using Microsoft.EntityFrameworkCore.TestModels.ConcurrencyModel; // ReSharper disable StaticMemberInGenericType namespace Microsoft.EntityFrameworkCore; @@ -168,9 +169,10 @@ public virtual async Task Can_insert_and_read_back_with_conversions(int[] valueO { var entity = new ConvertingEntity(); + Add(context, entity); + SetPropertyValues(context, entity, valueOrder[0], -1); - context.Add(entity); await context.SaveChangesAsync(); id = entity.Id; @@ -178,28 +180,39 @@ public virtual async Task Can_insert_and_read_back_with_conversions(int[] valueO using (var context = CreateContext()) { - SetPropertyValues(context, await context.Set().SingleAsync(e => e.Id == id), valueOrder[1], valueOrder[0]); + SetPropertyValues(context, await GetAsync(context, id), valueOrder[1], valueOrder[0]); await context.SaveChangesAsync(); } using (var context = CreateContext()) { - SetPropertyValues(context, await context.Set().SingleAsync(e => e.Id == id), valueOrder[2], valueOrder[1]); + SetPropertyValues(context, await GetAsync(context, id), valueOrder[2], valueOrder[1]); await context.SaveChangesAsync(); } using (var context = CreateContext()) { - SetPropertyValues(context, await context.Set().SingleAsync(e => e.Id == id), valueOrder[3], valueOrder[2]); + SetPropertyValues(context, await GetAsync(context, id), valueOrder[3], valueOrder[2]); await context.SaveChangesAsync(); } } - private static void SetPropertyValues(DbContext context, ConvertingEntity entity, int valueIndex, int previousValueIndex) + protected virtual void Add(DbContext context, ConvertingEntity entity) + => context.Add(entity); + + protected virtual Task GetAsync(DbContext context, Guid id) + => context.Set().SingleAsync(e => e.Id == id); + + protected virtual PropertyEntry Property(DbContext context, ConvertingEntity entity, IProperty property) + => context.Entry(entity).Property(property); + + protected virtual ITypeBase FindType(DbContext context) + => context.Model.FindEntityType( + typeof(ConvertingEntity))!; + + private void SetPropertyValues(DbContext context, ConvertingEntity entity, int valueIndex, int previousValueIndex) { - var entry = context.Entry(entity); - foreach (var property in context.Model.FindEntityType( - entity.GetType())!.GetProperties().Where(p => !p.IsPrimaryKey() && !p.IsShadowProperty())) + foreach (var property in FindType(context).GetProperties().Where(p => !p.IsPrimaryKey() && !p.IsShadowProperty())) { var testValues = (property.ClrType == typeof(string) ? StringTestValues[property.GetValueConverter()!.ProviderClrType.UnwrapNullableType()] @@ -211,7 +224,7 @@ private static void SetPropertyValues(DbContext context, ConvertingEntity entity testValues[3] = null; } - var propertyEntry = entry.Property(property); + var propertyEntry = Property(context, entity, property); if (previousValueIndex >= 0 && property.FindAnnotation("Relational:DefaultValue") == null) diff --git a/test/EFCore.SqlServer.FunctionalTests/ValueConvertersEndToEndSqlServerJsonTest.cs b/test/EFCore.SqlServer.FunctionalTests/ValueConvertersEndToEndSqlServerJsonTest.cs new file mode 100644 index 00000000000..72048db7a32 --- /dev/null +++ b/test/EFCore.SqlServer.FunctionalTests/ValueConvertersEndToEndSqlServerJsonTest.cs @@ -0,0 +1,262 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.EntityFrameworkCore; + +public class ValueConvertersEndToEndSqlServerJsonTest(ValueConvertersEndToEndSqlServerJsonTest.ValueConvertersEndToEndSqlServerJsonFixture fixture) + : ValueConvertersEndToEndTestBase(fixture) +{ + protected override void Add(DbContext context, ConvertingEntity entity) + { + var root = new RootEntity + { + Id = Guid.NewGuid(), + ConvertingEntity = entity + }; + entity.Id = root.Id; + context.Add(root); + } + + protected override async Task GetAsync(DbContext context, Guid id) + => (await context.Set() + .Where(e => e.Id == id) + .SingleAsync()).ConvertingEntity; + + protected override PropertyEntry Property(DbContext context, ConvertingEntity entity, IProperty property) + => context.ChangeTracker.Entries().Single(x => x.Entity.ConvertingEntity == entity).Property(property); + + protected override ITypeBase FindType(DbContext context) + => context.Model.GetEntityTypes().Single(x => x.ClrType == typeof(RootEntity)).GetComplexProperties().Single().ComplexType; + + public class ValueConvertersEndToEndSqlServerJsonFixture : ValueConvertersEndToEndFixtureBase + { + protected override ITestStoreFactory TestStoreFactory + => SqlServerTestStoreFactory.Instance; + + public TestSqlLoggerFactory TestSqlLoggerFactory + => (TestSqlLoggerFactory)ListLoggerFactory; + + protected override void OnModelCreating(ModelBuilder modelBuilder, DbContext context) + { + modelBuilder.Entity().ComplexProperty( + e => e.ConvertingEntity, b => + { + b.ToJson(); + b.Ignore(x => x.Id); + b.Property(e => e.BoolAsChar).HasConversion(new BoolToTwoValuesConverter('O', 'N')); + b.Property(e => e.BoolAsNullableChar).HasConversion(new BoolToTwoValuesConverter('O', 'N')); + b.Property(e => e.NullableBoolAsChar).HasConversion(new BoolToTwoValuesConverter('O', 'N')); + b.Property(e => e.NullableBoolAsNullableChar).HasConversion(new BoolToTwoValuesConverter('O', 'N')); + + b.Property(e => e.BoolAsString).HasConversion(new BoolToStringConverter("Non", "Oui")); + b.Property(e => e.BoolAsNullableString).HasConversion( + new BoolToTwoValuesConverter("Non", "Oui", mappingHints: new ConverterMappingHints(size: 3))); + b.Property(e => e.NullableBoolAsString).HasConversion(new BoolToStringConverter("Non", "Oui")); + b.Property(e => e.NullableBoolAsNullableString).HasConversion( + new BoolToTwoValuesConverter("Non", "Oui", mappingHints: new ConverterMappingHints(size: 3))); + + b.Property(e => e.BoolAsInt).HasConversion(new BoolToZeroOneConverter()); + b.Property(e => e.BoolAsNullableInt).HasConversion(new BoolToZeroOneConverter()); + b.Property(e => e.NullableBoolAsInt).HasConversion(new BoolToZeroOneConverter()); + b.Property(e => e.NullableBoolAsNullableInt).HasConversion(new BoolToZeroOneConverter()); + + b.Property(e => e.IntAsLong).HasConversion(new CastingConverter()); + b.Property(e => e.IntAsNullableLong).HasConversion(new CastingConverter()); + b.Property(e => e.NullableIntAsLong).HasConversion(new CastingConverter()); + b.Property(e => e.NullableIntAsNullableLong).HasConversion(new CastingConverter()); + + b.Property(e => e.BytesAsString).HasConversion( + (ValueConverter)new BytesToStringConverter(), new ArrayStructuralComparer()); + b.Property(e => e.BytesAsNullableString).HasConversion( + (ValueConverter)new BytesToStringConverter(), new ArrayStructuralComparer()); + b.Property(e => e.NullableBytesAsString).HasConversion( + new BytesToStringConverter(), new ArrayStructuralComparer()); + b.Property(e => e.NullableBytesAsNullableString).HasConversion( + new BytesToStringConverter(), new ArrayStructuralComparer()); + + b.Property(e => e.CharAsString).HasConversion(new CharToStringConverter()); + b.Property(e => e.NullableCharAsString).HasConversion(new CharToStringConverter()); + b.Property(e => e.CharAsNullableString).HasConversion(new CharToStringConverter()); + b.Property(e => e.NullableCharAsNullableString).HasConversion(new CharToStringConverter()); + + b.Property(e => e.DateTimeOffsetToBinary).HasConversion(new DateTimeOffsetToBinaryConverter()); + b.Property(e => e.DateTimeOffsetToNullableBinary).HasConversion(new DateTimeOffsetToBinaryConverter()); + b.Property(e => e.NullableDateTimeOffsetToBinary).HasConversion(new DateTimeOffsetToBinaryConverter()); + b.Property(e => e.NullableDateTimeOffsetToNullableBinary).HasConversion(new DateTimeOffsetToBinaryConverter()); + + b.Property(e => e.DateTimeOffsetToString).HasConversion(new DateTimeOffsetToStringConverter()); + b.Property(e => e.DateTimeOffsetToNullableString).HasConversion(new DateTimeOffsetToStringConverter()); + b.Property(e => e.NullableDateTimeOffsetToString).HasConversion(new DateTimeOffsetToStringConverter()); + b.Property(e => e.NullableDateTimeOffsetToNullableString).HasConversion(new DateTimeOffsetToStringConverter()); + + b.Property(e => e.DateTimeToBinary).HasConversion(new DateTimeToBinaryConverter()); + b.Property(e => e.DateTimeToNullableBinary).HasConversion(new DateTimeToBinaryConverter()); + b.Property(e => e.NullableDateTimeToBinary).HasConversion(new DateTimeToBinaryConverter()); + b.Property(e => e.NullableDateTimeToNullableBinary).HasConversion(new DateTimeToBinaryConverter()); + + b.Property(e => e.DateTimeToString).HasConversion(new DateTimeToStringConverter()); + b.Property(e => e.DateTimeToNullableString).HasConversion(new DateTimeToStringConverter()); + b.Property(e => e.NullableDateTimeToString).HasConversion(new DateTimeToStringConverter()); + b.Property(e => e.NullableDateTimeToNullableString).HasConversion(new DateTimeToStringConverter()); + + b.Property(e => e.DateOnlyToString).HasConversion(new DateOnlyToStringConverter()); + b.Property(e => e.DateOnlyToNullableString).HasConversion(new DateOnlyToStringConverter()); + b.Property(e => e.NullableDateOnlyToString).HasConversion(new DateOnlyToStringConverter()); + b.Property(e => e.NullableDateOnlyToNullableString).HasConversion(new DateOnlyToStringConverter()); + + b.Property(e => e.EnumToString).HasConversion(new EnumToStringConverter()); + b.Property(e => e.EnumToNullableString).HasConversion(new EnumToStringConverter()); + b.Property(e => e.NullableEnumToString).HasConversion(new EnumToStringConverter()); + b.Property(e => e.NullableEnumToNullableString).HasConversion(new EnumToStringConverter()); + + b.Property(e => e.EnumToNumber).HasConversion(new EnumToNumberConverter()); + b.Property(e => e.EnumToNullableNumber).HasConversion(new EnumToNumberConverter()); + b.Property(e => e.NullableEnumToNumber).HasConversion(new EnumToNumberConverter()); + b.Property(e => e.NullableEnumToNullableNumber).HasConversion(new EnumToNumberConverter()); + + b.Property(e => e.GuidToString).HasConversion(new GuidToStringConverter()); + b.Property(e => e.GuidToNullableString).HasConversion(new GuidToStringConverter()); + b.Property(e => e.NullableGuidToString).HasConversion(new GuidToStringConverter()); + b.Property(e => e.NullableGuidToNullableString).HasConversion(new GuidToStringConverter()); + + b.Property(e => e.GuidToBytes).HasConversion(new GuidToBytesConverter()); + b.Property(e => e.GuidToNullableBytes).HasConversion(new GuidToBytesConverter()); + b.Property(e => e.NullableGuidToBytes).HasConversion(new GuidToBytesConverter()); + b.Property(e => e.NullableGuidToNullableBytes).HasConversion(new GuidToBytesConverter()); + + b.Property(e => e.IPAddressToString).HasConversion((ValueConverter)new IPAddressToStringConverter()); + b.Property(e => e.IPAddressToNullableString).HasConversion((ValueConverter)new IPAddressToStringConverter()); + b.Property(e => e.NullableIPAddressToString).HasConversion(new IPAddressToStringConverter()); + b.Property(e => e.NullableIPAddressToNullableString).HasConversion(new IPAddressToStringConverter()); + + b.Property(e => e.IPAddressToBytes).HasConversion((ValueConverter)new IPAddressToBytesConverter()); + b.Property(e => e.IPAddressToNullableBytes).HasConversion((ValueConverter)new IPAddressToBytesConverter()); + b.Property(e => e.NullableIPAddressToBytes).HasConversion(new IPAddressToBytesConverter()); + b.Property(e => e.NullableIPAddressToNullableBytes).HasConversion(new IPAddressToBytesConverter()); + + b.Property(e => e.PhysicalAddressToString).HasConversion((ValueConverter)new PhysicalAddressToStringConverter()); + b.Property(e => e.PhysicalAddressToNullableString) + .HasConversion((ValueConverter)new PhysicalAddressToStringConverter()); + b.Property(e => e.NullablePhysicalAddressToString).HasConversion(new PhysicalAddressToStringConverter()); + b.Property(e => e.NullablePhysicalAddressToNullableString).HasConversion(new PhysicalAddressToStringConverter()); + + b.Property(e => e.PhysicalAddressToBytes).HasConversion((ValueConverter)new PhysicalAddressToBytesConverter()); + b.Property(e => e.PhysicalAddressToNullableBytes) + .HasConversion((ValueConverter)new PhysicalAddressToBytesConverter()); + b.Property(e => e.NullablePhysicalAddressToBytes).HasConversion(new PhysicalAddressToBytesConverter()); + b.Property(e => e.NullablePhysicalAddressToNullableBytes).HasConversion(new PhysicalAddressToBytesConverter()); + + b.Property(e => e.NumberToString).HasConversion(new NumberToStringConverter()); + b.Property(e => e.NumberToNullableString).HasConversion(new NumberToStringConverter()); + b.Property(e => e.NullableNumberToString).HasConversion(new NumberToStringConverter()); + b.Property(e => e.NullableNumberToNullableString).HasConversion(new NumberToStringConverter()); + + b.Property(e => e.NumberToBytes).HasConversion(new NumberToBytesConverter()); + b.Property(e => e.NumberToNullableBytes).HasConversion(new NumberToBytesConverter()); + b.Property(e => e.NullableNumberToBytes).HasConversion(new NumberToBytesConverter()); + b.Property(e => e.NullableNumberToNullableBytes).HasConversion(new NumberToBytesConverter()); + + b.Property(e => e.StringToBool).HasConversion(new StringToBoolConverter()); + b.Property(e => e.StringToNullableBool).HasConversion(new StringToBoolConverter()); + b.Property(e => e.NullableStringToBool).HasConversion((ValueConverter)new StringToBoolConverter()); + b.Property(e => e.NullableStringToNullableBool).HasConversion((ValueConverter)new StringToBoolConverter()); + + b.Property(e => e.StringToBytes).HasConversion((ValueConverter)new StringToBytesConverter(Encoding.UTF32)); + b.Property(e => e.StringToNullableBytes).HasConversion((ValueConverter)new StringToBytesConverter(Encoding.UTF32)); + b.Property(e => e.NullableStringToBytes).HasConversion(new StringToBytesConverter(Encoding.UTF32)); + b.Property(e => e.NullableStringToNullableBytes).HasConversion(new StringToBytesConverter(Encoding.UTF32)); + + b.Property(e => e.StringToChar).HasConversion(new StringToCharConverter()); + b.Property(e => e.StringToNullableChar).HasConversion(new StringToCharConverter()); + b.Property(e => e.NullableStringToChar).HasConversion((ValueConverter)new StringToCharConverter()); + b.Property(e => e.NullableStringToNullableChar).HasConversion((ValueConverter)new StringToCharConverter()); + + b.Property(e => e.StringToDateTime).HasConversion(new StringToDateTimeConverter()); + b.Property(e => e.StringToNullableDateTime).HasConversion(new StringToDateTimeConverter()); + b.Property(e => e.NullableStringToDateTime).HasConversion((ValueConverter)new StringToDateTimeConverter()); + b.Property(e => e.NullableStringToNullableDateTime).HasConversion((ValueConverter)new StringToDateTimeConverter()); + + b.Property(e => e.StringToDateTimeOffset).HasConversion(new StringToDateTimeOffsetConverter()); + b.Property(e => e.StringToNullableDateTimeOffset).HasConversion(new StringToDateTimeOffsetConverter()); + b.Property(e => e.NullableStringToDateTimeOffset) + .HasConversion((ValueConverter)new StringToDateTimeOffsetConverter()); + b.Property(e => e.NullableStringToNullableDateTimeOffset) + .HasConversion((ValueConverter)new StringToDateTimeOffsetConverter()); + + b.Property(e => e.StringToEnum).HasConversion(new StringToEnumConverter()); + b.Property(e => e.StringToNullableEnum).HasConversion(new StringToEnumConverter()); + b.Property(e => e.NullableStringToEnum).HasConversion((ValueConverter)new StringToEnumConverter()); + b.Property(e => e.NullableStringToNullableEnum) + .HasConversion((ValueConverter)new StringToEnumConverter()); + + b.Property(e => e.StringToGuid).HasConversion(new StringToGuidConverter()); + b.Property(e => e.StringToNullableGuid).HasConversion(new StringToGuidConverter()); + b.Property(e => e.NullableStringToGuid).HasConversion((ValueConverter)new StringToGuidConverter()); + b.Property(e => e.NullableStringToNullableGuid).HasConversion((ValueConverter)new StringToGuidConverter()); + + b.Property(e => e.StringToNumber).HasConversion(new StringToNumberConverter()); + b.Property(e => e.StringToNullableNumber).HasConversion(new StringToNumberConverter()); + b.Property(e => e.NullableStringToNumber).HasConversion((ValueConverter)new StringToNumberConverter()); + b.Property(e => e.NullableStringToNullableNumber) + .HasConversion((ValueConverter)new StringToNumberConverter()); + + b.Property(e => e.StringToTimeSpan).HasConversion(new StringToTimeSpanConverter()); + b.Property(e => e.StringToNullableTimeSpan).HasConversion(new StringToTimeSpanConverter()); + b.Property(e => e.NullableStringToTimeSpan).HasConversion((ValueConverter)new StringToTimeSpanConverter()); + b.Property(e => e.NullableStringToNullableTimeSpan).HasConversion((ValueConverter)new StringToTimeSpanConverter()); + + b.Property(e => e.TimeSpanToTicks).HasConversion(new TimeSpanToTicksConverter()); + b.Property(e => e.TimeSpanToNullableTicks).HasConversion(new TimeSpanToTicksConverter()); + b.Property(e => e.NullableTimeSpanToTicks).HasConversion(new TimeSpanToTicksConverter()); + b.Property(e => e.NullableTimeSpanToNullableTicks).HasConversion(new TimeSpanToTicksConverter()); + + b.Property(e => e.TimeSpanToString).HasConversion(new TimeSpanToStringConverter()); + b.Property(e => e.TimeSpanToNullableString).HasConversion(new TimeSpanToStringConverter()); + b.Property(e => e.NullableTimeSpanToString).HasConversion(new TimeSpanToStringConverter()); + b.Property(e => e.NullableTimeSpanToNullableString).HasConversion(new TimeSpanToStringConverter()); + + b.Property(e => e.UriToString).HasConversion((ValueConverter)new UriToStringConverter()); + b.Property(e => e.UriToNullableString).HasConversion((ValueConverter)new UriToStringConverter()); + b.Property(e => e.NullableUriToString).HasConversion(new UriToStringConverter()); + b.Property(e => e.NullableUriToNullableString).HasConversion(new UriToStringConverter()); + + b.Property(e => e.NonNullIntToNullString).HasConversion(new NonNullIntToNullStringConverter()); + b.Property(e => e.NonNullIntToNonNullString).HasConversion(new NonNullIntToNonNullStringConverter()); + b.Property(e => e.NullIntToNullString).HasConversion(new NullIntToNullStringConverter()).IsRequired(false); + b.Property(e => e.NullIntToNonNullString).HasConversion(new NullIntToNonNullStringConverter()).IsRequired(false); + + b.Property(e => e.NullStringToNonNullString).HasConversion(new NullStringToNonNullStringConverter()).IsRequired(); + b.Property(e => e.NonNullStringToNullString).HasConversion(new NonNullStringToNullStringConverter()) + .IsRequired(false); + + b.Property(e => e.NullableListOfInt).HasConversion( + (ValueConverter?)new ListOfIntToJsonConverter(), new ListOfIntComparer()); + + b.Property(e => e.ListOfInt).HasConversion( + new ListOfIntToJsonConverter(), new ListOfIntComparer()); + + b.Property(e => e.NullableEnumerableOfInt).HasConversion( + (ValueConverter?)new EnumerableOfIntToJsonConverter(), new EnumerableOfIntComparer()); + + b.Property(e => e.EnumerableOfInt).HasConversion( + new EnumerableOfIntToJsonConverter(), new EnumerableOfIntComparer()); + }); + + var complexType = modelBuilder.Model.GetEntityTypes().Single(x => x.ClrType == typeof(RootEntity)).GetComplexProperties().Single().ComplexType; + foreach (var property in complexType.GetProperties()) + { + if (property.GetValueConverter() is null) + { + Assert.Fail("All properties should have a value converter configured"); + } + } + } + } + + protected class RootEntity + { + public Guid Id { get; set; } + + public ConvertingEntity ConvertingEntity { get; set; } = null!; + } +} From 8399c8d8567d5d0f9bfa235baf693bc81c8aa08b Mon Sep 17 00:00:00 2001 From: JoasE <32096708+JoasE@users.noreply.github.com> Date: Tue, 24 Mar 2026 16:26:51 +0100 Subject: [PATCH 02/10] Add tests --- .../Update/JsonUpdateJsonTypeSqlServerTest.cs | 8 ++++---- .../Update/JsonUpdateSqlServerTest.cs | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/test/EFCore.SqlServer.FunctionalTests/Update/JsonUpdateJsonTypeSqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Update/JsonUpdateJsonTypeSqlServerTest.cs index a8a747d71b8..61c307ab008 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Update/JsonUpdateJsonTypeSqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Update/JsonUpdateJsonTypeSqlServerTest.cs @@ -2497,9 +2497,9 @@ public override async Task Add_and_update_nested_optional_primitive_collection(b var parameterSize = value switch { - true => "1558", - false => "1555", - _ => "1557" + true => "1560", + false => "1557", + _ => "1559" }; var updateParameter = value switch @@ -2531,7 +2531,7 @@ public override async Task Add_and_update_nested_optional_primitive_collection(b AssertSql( @"@p0='[{""TestBoolean"":false,""TestBooleanCollection"":[],""TestByte"":0,""TestByteArray"":null,""TestByteCollection"":null,""TestCharacter"":""\u0000"",""TestCharacterCollection"":" + characterCollection - + @",""TestDateOnly"":""0001-01-01"",""TestDateOnlyCollection"":[],""TestDateTime"":""0001-01-01T00:00:00"",""TestDateTimeCollection"":[],""TestDateTimeOffset"":""0001-01-01T00:00:00+00:00"",""TestDateTimeOffsetCollection"":[],""TestDecimal"":0,""TestDecimalCollection"":[],""TestDefaultString"":null,""TestDefaultStringCollection"":[],""TestDouble"":0,""TestDoubleCollection"":[],""TestEnum"":0,""TestEnumCollection"":[],""TestEnumWithIntConverter"":0,""TestEnumWithIntConverterCollection"":[],""TestGuid"":""00000000-0000-0000-0000-000000000000"",""TestGuidCollection"":[],""TestInt16"":0,""TestInt16Collection"":[],""TestInt32"":0,""TestInt32Collection"":[],""TestInt64"":0,""TestInt64Collection"":[],""TestMaxLengthString"":null,""TestMaxLengthStringCollection"":[],""TestNullableEnum"":null,""TestNullableEnumCollection"":[],""TestNullableEnumWithConverterThatHandlesNulls"":null,""TestNullableEnumWithConverterThatHandlesNullsCollection"":[],""TestNullableEnumWithIntConverter"":null,""TestNullableEnumWithIntConverterCollection"":[],""TestNullableInt32"":null,""TestNullableInt32Collection"":[],""TestSignedByte"":0,""TestSignedByteCollection"":[],""TestSingle"":0,""TestSingleCollection"":[],""TestTimeOnly"":""00:00:00.0000000"",""TestTimeOnlyCollection"":[],""TestTimeSpan"":""0:00:00"",""TestTimeSpanCollection"":[],""TestUnsignedInt16"":0,""TestUnsignedInt16Collection"":[],""TestUnsignedInt32"":0,""TestUnsignedInt32Collection"":[],""TestUnsignedInt64"":0,""TestUnsignedInt64Collection"":[]}]' (Nullable = false) (Size = " + + @",""TestDateOnly"":""0001-01-01"",""TestDateOnlyCollection"":[],""TestDateTime"":""0001-01-01T00:00:00"",""TestDateTimeCollection"":[],""TestDateTimeOffset"":""0001-01-01T00:00:00+00:00"",""TestDateTimeOffsetCollection"":[],""TestDecimal"":0,""TestDecimalCollection"":[],""TestDefaultString"":null,""TestDefaultStringCollection"":[],""TestDouble"":0,""TestDoubleCollection"":[],""TestEnum"":0,""TestEnumCollection"":[],""TestEnumWithIntConverter"":0,""TestEnumWithIntConverterCollection"":[],""TestGuid"":""00000000-0000-0000-0000-000000000000"",""TestGuidCollection"":[],""TestInt16"":0,""TestInt16Collection"":[],""TestInt32"":0,""TestInt32Collection"":[],""TestInt64"":0,""TestInt64Collection"":[],""TestMaxLengthString"":null,""TestMaxLengthStringCollection"":[],""TestNullableEnum"":null,""TestNullableEnumCollection"":[],""TestNullableEnumWithConverterThatHandlesNulls"":""Null"",""TestNullableEnumWithConverterThatHandlesNullsCollection"":[],""TestNullableEnumWithIntConverter"":null,""TestNullableEnumWithIntConverterCollection"":[],""TestNullableInt32"":null,""TestNullableInt32Collection"":[],""TestSignedByte"":0,""TestSignedByteCollection"":[],""TestSingle"":0,""TestSingleCollection"":[],""TestTimeOnly"":""00:00:00.0000000"",""TestTimeOnlyCollection"":[],""TestTimeSpan"":""0:00:00"",""TestTimeSpanCollection"":[],""TestUnsignedInt16"":0,""TestUnsignedInt16Collection"":[],""TestUnsignedInt32"":0,""TestUnsignedInt32Collection"":[],""TestUnsignedInt64"":0,""TestUnsignedInt64Collection"":[]}]' (Nullable = false) (Size = " + parameterSize + @") @p1='7624' diff --git a/test/EFCore.SqlServer.FunctionalTests/Update/JsonUpdateSqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Update/JsonUpdateSqlServerTest.cs index 8d6856d6194..504aad29e14 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Update/JsonUpdateSqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Update/JsonUpdateSqlServerTest.cs @@ -2445,9 +2445,9 @@ public override async Task Add_and_update_nested_optional_primitive_collection(b var parameterSize = value switch { - true => "1558", - false => "1555", - _ => "1557" + true => "1560", + false => "1557", + _ => "1559" }; var updateParameterSize = value switch @@ -2466,7 +2466,7 @@ public override async Task Add_and_update_nested_optional_primitive_collection(b var p0 = @"@p0='[{""TestBoolean"":false,""TestBooleanCollection"":[],""TestByte"":0,""TestByteArray"":null,""TestByteCollection"":null,""TestCharacter"":""\u0000"",""TestCharacterCollection"":" + characterCollection -+ @",""TestDateOnly"":""0001-01-01"",""TestDateOnlyCollection"":[],""TestDateTime"":""0001-01-01T00:00:00"",""TestDateTimeCollection"":[],""TestDateTimeOffset"":""0001-01-01T00:00:00+00:00"",""TestDateTimeOffsetCollection"":[],""TestDecimal"":0,""TestDecimalCollection"":[],""TestDefaultString"":null,""TestDefaultStringCollection"":[],""TestDouble"":0,""TestDoubleCollection"":[],""TestEnum"":0,""TestEnumCollection"":[],""TestEnumWithIntConverter"":0,""TestEnumWithIntConverterCollection"":[],""TestGuid"":""00000000-0000-0000-0000-000000000000"",""TestGuidCollection"":[],""TestInt16"":0,""TestInt16Collection"":[],""TestInt32"":0,""TestInt32Collection"":[],""TestInt64"":0,""TestInt64Collection"":[],""TestMaxLengthString"":null,""TestMaxLengthStringCollection"":[],""TestNullableEnum"":null,""TestNullableEnumCollection"":[],""TestNullableEnumWithConverterThatHandlesNulls"":null,""TestNullableEnumWithConverterThatHandlesNullsCollection"":[],""TestNullableEnumWithIntConverter"":null,""TestNullableEnumWithIntConverterCollection"":[],""TestNullableInt32"":null,""TestNullableInt32Collection"":[],""TestSignedByte"":0,""TestSignedByteCollection"":[],""TestSingle"":0,""TestSingleCollection"":[],""TestTimeOnly"":""00:00:00.0000000"",""TestTimeOnlyCollection"":[],""TestTimeSpan"":""0:00:00"",""TestTimeSpanCollection"":[],""TestUnsignedInt16"":0,""TestUnsignedInt16Collection"":[],""TestUnsignedInt32"":0,""TestUnsignedInt32Collection"":[],""TestUnsignedInt64"":0,""TestUnsignedInt64Collection"":[]}]' (Nullable = false) (Size = " ++ @",""TestDateOnly"":""0001-01-01"",""TestDateOnlyCollection"":[],""TestDateTime"":""0001-01-01T00:00:00"",""TestDateTimeCollection"":[],""TestDateTimeOffset"":""0001-01-01T00:00:00+00:00"",""TestDateTimeOffsetCollection"":[],""TestDecimal"":0,""TestDecimalCollection"":[],""TestDefaultString"":null,""TestDefaultStringCollection"":[],""TestDouble"":0,""TestDoubleCollection"":[],""TestEnum"":0,""TestEnumCollection"":[],""TestEnumWithIntConverter"":0,""TestEnumWithIntConverterCollection"":[],""TestGuid"":""00000000-0000-0000-0000-000000000000"",""TestGuidCollection"":[],""TestInt16"":0,""TestInt16Collection"":[],""TestInt32"":0,""TestInt32Collection"":[],""TestInt64"":0,""TestInt64Collection"":[],""TestMaxLengthString"":null,""TestMaxLengthStringCollection"":[],""TestNullableEnum"":null,""TestNullableEnumCollection"":[],""TestNullableEnumWithConverterThatHandlesNulls"":""Null"",""TestNullableEnumWithConverterThatHandlesNullsCollection"":[],""TestNullableEnumWithIntConverter"":null,""TestNullableEnumWithIntConverterCollection"":[],""TestNullableInt32"":null,""TestNullableInt32Collection"":[],""TestSignedByte"":0,""TestSignedByteCollection"":[],""TestSingle"":0,""TestSingleCollection"":[],""TestTimeOnly"":""00:00:00.0000000"",""TestTimeOnlyCollection"":[],""TestTimeSpan"":""0:00:00"",""TestTimeSpanCollection"":[],""TestUnsignedInt16"":0,""TestUnsignedInt16Collection"":[],""TestUnsignedInt32"":0,""TestUnsignedInt32Collection"":[],""TestUnsignedInt64"":0,""TestUnsignedInt64Collection"":[]}]' (Nullable = false) (Size = " + parameterSize + @")"; From fe913b6ce3490aa591a558550342a0975c109a6b Mon Sep 17 00:00:00 2001 From: JoasE <32096708+JoasE@users.noreply.github.com> Date: Tue, 24 Mar 2026 16:49:56 +0100 Subject: [PATCH 03/10] Fix RelationalJsonUtilities --- .../Query/Internal/RelationalJsonUtilities.cs | 11 ++- .../Query/AdHocJsonQueryRelationalTestBase.cs | 68 +++++++++++++++++++ .../Query/AdHocJsonQuerySqlServerTestBase.cs | 14 ++++ .../Update/JsonUpdateSqliteTest.cs | 2 +- 4 files changed, 91 insertions(+), 4 deletions(-) diff --git a/src/EFCore.Relational/Query/Internal/RelationalJsonUtilities.cs b/src/EFCore.Relational/Query/Internal/RelationalJsonUtilities.cs index 88162c03f9f..064b284f5ba 100644 --- a/src/EFCore.Relational/Query/Internal/RelationalJsonUtilities.cs +++ b/src/EFCore.Relational/Query/Internal/RelationalJsonUtilities.cs @@ -4,6 +4,7 @@ using System.Collections; using System.Text; using System.Text.Json; +using Microsoft.EntityFrameworkCore.Storage.Internal; namespace Microsoft.EntityFrameworkCore.Query.Internal; @@ -89,8 +90,13 @@ void WriteJsonObject(Utf8JsonWriter writer, IComplexType complexType, object? ob Check.DebugAssert(jsonPropertyName is not null); writer.WritePropertyName(jsonPropertyName); + var jsonValueReaderWriter = property.GetJsonValueReaderWriter() ?? property.GetTypeMapping().JsonValueReaderWriter; + var propertyValue = property.GetGetter().GetClrValue(objectValue); - if (propertyValue is null) + if (propertyValue is null +#pragma warning disable EF1001 // Internal EF Core API usage. + && jsonValueReaderWriter is not IJsonConvertedValueReaderWriter { Converter.ConvertsNulls: true }) +#pragma warning restore EF1001 // Internal EF Core API usage. { if (!property.IsNullable) { @@ -101,9 +107,8 @@ void WriteJsonObject(Utf8JsonWriter writer, IComplexType complexType, object? ob } else { - var jsonValueReaderWriter = property.GetJsonValueReaderWriter() ?? property.GetTypeMapping().JsonValueReaderWriter; Check.DebugAssert(jsonValueReaderWriter is not null, "Missing JsonValueReaderWriter on JSON property"); - jsonValueReaderWriter.ToJson(writer, propertyValue); + jsonValueReaderWriter.ToJson(writer, propertyValue!); } } diff --git a/test/EFCore.Relational.Specification.Tests/Query/AdHocJsonQueryRelationalTestBase.cs b/test/EFCore.Relational.Specification.Tests/Query/AdHocJsonQueryRelationalTestBase.cs index e62be31c9f2..6ea02f68f6c 100644 --- a/test/EFCore.Relational.Specification.Tests/Query/AdHocJsonQueryRelationalTestBase.cs +++ b/test/EFCore.Relational.Specification.Tests/Query/AdHocJsonQueryRelationalTestBase.cs @@ -693,6 +693,74 @@ public class JsonNestedType #endregion HasJsonPropertyName + #region Value converter equality null scalar + + [ConditionalFact] + public virtual async Task Value_converter_equality_null_scalar() + { + var contextFactory = await InitializeNonSharedTest( + onConfiguring: b => b.ConfigureWarnings(ConfigureWarnings), + onModelCreating: m => m.Entity().ComplexProperty(e => e.Json, b => + { + b.ToJson(); + + b.Property(j => j.IntToString).HasConversion(new Context37983_StringToIntConverter()); + }), + seed: context => + { + context.Set().Add(new Context37983.Entity + { + Json = new Context37983.JsonComplexType + { + IntToString = null, + } + }); + + return context.SaveChangesAsync(); + }); + + await using var context = contextFactory.CreateDbContext(); + + TestSqlLoggerFactory.Clear(); + + var complexType = new Context37983.JsonComplexType + { + IntToString = null, + }; + + Assert.Equal(1, await context.Set().CountAsync(e => e.Json == complexType)); + } + + protected class Context37983(DbContextOptions options) : DbContext(options) + { + public DbSet Entities { get; set; } + + public class Entity + { + public int Id { get; set; } + public JsonComplexType Json { get; set; } + } + + public class JsonComplexType + { + public int? IntToString { get; set; } + } + } + + protected class Context37983_StringToIntConverter : ValueConverter + { + public Context37983_StringToIntConverter() + : base( + v => v == null ? "" : v.ToString(), + v => int.Parse(v)) + { + } + + public override bool ConvertsNulls => true; + } + + #endregion + protected TestSqlLoggerFactory TestSqlLoggerFactory => (TestSqlLoggerFactory)ListLoggerFactory; diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/AdHocJsonQuerySqlServerTestBase.cs b/test/EFCore.SqlServer.FunctionalTests/Query/AdHocJsonQuerySqlServerTestBase.cs index 123a5a2228e..a2af824e218 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Query/AdHocJsonQuerySqlServerTestBase.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Query/AdHocJsonQuerySqlServerTestBase.cs @@ -680,6 +680,20 @@ public override async Task Entity_splitting_with_owned_json() SELECT TOP(2) [m].[Id], [m].[PropertyInMainTable], [o].[PropertyInOtherTable], [m].[Json] FROM [MyEntity] AS [m] INNER JOIN [OtherTable] AS [o] ON [m].[Id] = [o].[Id] +"""); + } + + public override async Task Value_converter_equality_null_scalar() + { + await base.Value_converter_equality_null_scalar(); + + AssertSql( + """ +@entity_equality_complexType='{"IntToString":"\u003Cnull\u003E"}' (Size = 34) + +SELECT COUNT(*) +FROM [Entities] AS [e] +WHERE [e].[Json] = @entity_equality_complexType """); } } diff --git a/test/EFCore.Sqlite.FunctionalTests/Update/JsonUpdateSqliteTest.cs b/test/EFCore.Sqlite.FunctionalTests/Update/JsonUpdateSqliteTest.cs index 3fb88eaccc1..01a4af8a28c 100644 --- a/test/EFCore.Sqlite.FunctionalTests/Update/JsonUpdateSqliteTest.cs +++ b/test/EFCore.Sqlite.FunctionalTests/Update/JsonUpdateSqliteTest.cs @@ -2310,7 +2310,7 @@ public override async Task Add_and_update_nested_optional_primitive_collection(b AssertSql( @"@p0='[{""TestBoolean"":false,""TestBooleanCollection"":[],""TestByte"":0,""TestByteArray"":null,""TestByteCollection"":null,""TestCharacter"":""\u0000"",""TestCharacterCollection"":" + characterCollection - + @",""TestDateOnly"":""0001-01-01"",""TestDateOnlyCollection"":[],""TestDateTime"":""0001-01-01 00:00:00"",""TestDateTimeCollection"":[],""TestDateTimeOffset"":""0001-01-01 00:00:00+00:00"",""TestDateTimeOffsetCollection"":[],""TestDecimal"":""0.0"",""TestDecimalCollection"":[],""TestDefaultString"":null,""TestDefaultStringCollection"":[],""TestDouble"":0,""TestDoubleCollection"":[],""TestEnum"":0,""TestEnumCollection"":[],""TestEnumWithIntConverter"":0,""TestEnumWithIntConverterCollection"":[],""TestGuid"":""00000000-0000-0000-0000-000000000000"",""TestGuidCollection"":[],""TestInt16"":0,""TestInt16Collection"":[],""TestInt32"":0,""TestInt32Collection"":[],""TestInt64"":0,""TestInt64Collection"":[],""TestMaxLengthString"":null,""TestMaxLengthStringCollection"":[],""TestNullableEnum"":null,""TestNullableEnumCollection"":[],""TestNullableEnumWithConverterThatHandlesNulls"":null,""TestNullableEnumWithConverterThatHandlesNullsCollection"":[],""TestNullableEnumWithIntConverter"":null,""TestNullableEnumWithIntConverterCollection"":[],""TestNullableInt32"":null,""TestNullableInt32Collection"":[],""TestSignedByte"":0,""TestSignedByteCollection"":[],""TestSingle"":0,""TestSingleCollection"":[],""TestTimeOnly"":""00:00:00.0000000"",""TestTimeOnlyCollection"":[],""TestTimeSpan"":""0:00:00"",""TestTimeSpanCollection"":[],""TestUnsignedInt16"":0,""TestUnsignedInt16Collection"":[],""TestUnsignedInt32"":0,""TestUnsignedInt32Collection"":[],""TestUnsignedInt64"":0,""TestUnsignedInt64Collection"":[]}]' (Nullable = false) (Size = " + + @",""TestDateOnly"":""0001-01-01"",""TestDateOnlyCollection"":[],""TestDateTime"":""0001-01-01 00:00:00"",""TestDateTimeCollection"":[],""TestDateTimeOffset"":""0001-01-01 00:00:00+00:00"",""TestDateTimeOffsetCollection"":[],""TestDecimal"":""0.0"",""TestDecimalCollection"":[],""TestDefaultString"":null,""TestDefaultStringCollection"":[],""TestDouble"":0,""TestDoubleCollection"":[],""TestEnum"":0,""TestEnumCollection"":[],""TestEnumWithIntConverter"":0,""TestEnumWithIntConverterCollection"":[],""TestGuid"":""00000000-0000-0000-0000-000000000000"",""TestGuidCollection"":[],""TestInt16"":0,""TestInt16Collection"":[],""TestInt32"":0,""TestInt32Collection"":[],""TestInt64"":0,""TestInt64Collection"":[],""TestMaxLengthString"":null,""TestMaxLengthStringCollection"":[],""TestNullableEnum"":null,""TestNullableEnumCollection"":[],""TestNullableEnumWithConverterThatHandlesNulls"":""Null"",""TestNullableEnumWithConverterThatHandlesNullsCollection"":[],""TestNullableEnumWithIntConverter"":null,""TestNullableEnumWithIntConverterCollection"":[],""TestNullableInt32"":null,""TestNullableInt32Collection"":[],""TestSignedByte"":0,""TestSignedByteCollection"":[],""TestSingle"":0,""TestSingleCollection"":[],""TestTimeOnly"":""00:00:00.0000000"",""TestTimeOnlyCollection"":[],""TestTimeSpan"":""0:00:00"",""TestTimeSpanCollection"":[],""TestUnsignedInt16"":0,""TestUnsignedInt16Collection"":[],""TestUnsignedInt32"":0,""TestUnsignedInt32Collection"":[],""TestUnsignedInt64"":0,""TestUnsignedInt64Collection"":[]}]' (Nullable = false) (Size = " + parameterSize + @") @p1='7624' From b6cdfc50aab22281678f521b1ec31bd204af6ff9 Mon Sep 17 00:00:00 2001 From: JoasE <32096708+JoasE@users.noreply.github.com> Date: Wed, 25 Mar 2026 21:11:42 +0100 Subject: [PATCH 04/10] Fix tests --- .../ValueConvertersEndToEndSqlServerJsonTest.cs | 2 ++ .../Update/JsonUpdateSqliteTest.cs | 6 +++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/test/EFCore.SqlServer.FunctionalTests/ValueConvertersEndToEndSqlServerJsonTest.cs b/test/EFCore.SqlServer.FunctionalTests/ValueConvertersEndToEndSqlServerJsonTest.cs index 72048db7a32..0682a962c3a 100644 --- a/test/EFCore.SqlServer.FunctionalTests/ValueConvertersEndToEndSqlServerJsonTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/ValueConvertersEndToEndSqlServerJsonTest.cs @@ -30,6 +30,8 @@ protected override ITypeBase FindType(DbContext context) public class ValueConvertersEndToEndSqlServerJsonFixture : ValueConvertersEndToEndFixtureBase { + protected override string StoreName => nameof(ValueConvertersEndToEndSqlServerJsonFixture); + protected override ITestStoreFactory TestStoreFactory => SqlServerTestStoreFactory.Instance; diff --git a/test/EFCore.Sqlite.FunctionalTests/Update/JsonUpdateSqliteTest.cs b/test/EFCore.Sqlite.FunctionalTests/Update/JsonUpdateSqliteTest.cs index 01a4af8a28c..bece74e6b91 100644 --- a/test/EFCore.Sqlite.FunctionalTests/Update/JsonUpdateSqliteTest.cs +++ b/test/EFCore.Sqlite.FunctionalTests/Update/JsonUpdateSqliteTest.cs @@ -2295,9 +2295,9 @@ public override async Task Add_and_update_nested_optional_primitive_collection(b var parameterSize = value switch { - true => "1562", - false => "1559", - _ => "1561" + true => "1564", + false => "1561", + _ => "1563" }; var updateParameter = value switch From 4fcee53aefe3f8571bee6cab48e7bd05288af81d Mon Sep 17 00:00:00 2001 From: JoasE <32096708+JoasE@users.noreply.github.com> Date: Fri, 27 Mar 2026 11:26:26 +0100 Subject: [PATCH 05/10] Clean --- .../ValueConvertersEndToEndTestBase.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/test/EFCore.Specification.Tests/ValueConvertersEndToEndTestBase.cs b/test/EFCore.Specification.Tests/ValueConvertersEndToEndTestBase.cs index fe7aaf54e90..d4429b82171 100644 --- a/test/EFCore.Specification.Tests/ValueConvertersEndToEndTestBase.cs +++ b/test/EFCore.Specification.Tests/ValueConvertersEndToEndTestBase.cs @@ -4,7 +4,6 @@ using System.Net; using System.Net.NetworkInformation; using System.Text.Json; -using Microsoft.EntityFrameworkCore.TestModels.ConcurrencyModel; // ReSharper disable StaticMemberInGenericType namespace Microsoft.EntityFrameworkCore; From 84dae2f07832e2b90d58bbe09794297980428abf Mon Sep 17 00:00:00 2001 From: JoasE <32096708+JoasE@users.noreply.github.com> Date: Mon, 30 Mar 2026 11:30:37 +0200 Subject: [PATCH 06/10] Workaround 1 --- .../Query/Internal/RelationalJsonUtilities.cs | 12 ++++++++---- src/EFCore.Relational/Update/ModificationCommand.cs | 11 ++++++++--- .../Internal/IJsonConvertedValueReaderWriter.cs | 10 ++++++++++ .../Storage/Json/JsonConvertedValueReaderWriter.cs | 11 +++++++++++ 4 files changed, 37 insertions(+), 7 deletions(-) diff --git a/src/EFCore.Relational/Query/Internal/RelationalJsonUtilities.cs b/src/EFCore.Relational/Query/Internal/RelationalJsonUtilities.cs index 064b284f5ba..5d169f309ca 100644 --- a/src/EFCore.Relational/Query/Internal/RelationalJsonUtilities.cs +++ b/src/EFCore.Relational/Query/Internal/RelationalJsonUtilities.cs @@ -93,11 +93,15 @@ void WriteJsonObject(Utf8JsonWriter writer, IComplexType complexType, object? ob var jsonValueReaderWriter = property.GetJsonValueReaderWriter() ?? property.GetTypeMapping().JsonValueReaderWriter; var propertyValue = property.GetGetter().GetClrValue(objectValue); - if (propertyValue is null + if (propertyValue is null) + { + if (jsonValueReaderWriter is IJsonConvertedValueReaderWriter jsonConvertedValueReaderWriter) + { #pragma warning disable EF1001 // Internal EF Core API usage. - && jsonValueReaderWriter is not IJsonConvertedValueReaderWriter { Converter.ConvertsNulls: true }) + jsonConvertedValueReaderWriter.ToJson(writer, propertyValue); #pragma warning restore EF1001 // Internal EF Core API usage. - { + return; + } if (!property.IsNullable) { throw new InvalidOperationException(RelationalStrings.NullValueInRequiredJsonProperty(property.Name)); @@ -108,7 +112,7 @@ void WriteJsonObject(Utf8JsonWriter writer, IComplexType complexType, object? ob else { Check.DebugAssert(jsonValueReaderWriter is not null, "Missing JsonValueReaderWriter on JSON property"); - jsonValueReaderWriter.ToJson(writer, propertyValue!); + jsonValueReaderWriter.ToJson(writer, propertyValue); } } diff --git a/src/EFCore.Relational/Update/ModificationCommand.cs b/src/EFCore.Relational/Update/ModificationCommand.cs index 02f24476494..13bce05cf47 100644 --- a/src/EFCore.Relational/Update/ModificationCommand.cs +++ b/src/EFCore.Relational/Update/ModificationCommand.cs @@ -971,14 +971,19 @@ private void WriteJsonObject( writer.WritePropertyName(jsonPropertyName); var jsonValueReaderWriter = property.GetJsonValueReaderWriter() ?? property.GetTypeMapping().JsonValueReaderWriter; - if (propertyValue is not null || - jsonValueReaderWriter is IJsonConvertedValueReaderWriter { Converter.ConvertsNulls: true }) + if (propertyValue is not null) { Check.DebugAssert(jsonValueReaderWriter is not null, "Missing JsonValueReaderWriter on JSON property"); - jsonValueReaderWriter.ToJson(writer, propertyValue!); + jsonValueReaderWriter.ToJson(writer, propertyValue); } else { + if (jsonValueReaderWriter is IJsonConvertedValueReaderWriter jsonConvertedValueReaderWriter) + { + jsonConvertedValueReaderWriter.ToJson(writer, null); + continue; + } + writer.WriteNullValue(); } } diff --git a/src/EFCore/Storage/Internal/IJsonConvertedValueReaderWriter.cs b/src/EFCore/Storage/Internal/IJsonConvertedValueReaderWriter.cs index 595cd5726fe..a6fafb4ea97 100644 --- a/src/EFCore/Storage/Internal/IJsonConvertedValueReaderWriter.cs +++ b/src/EFCore/Storage/Internal/IJsonConvertedValueReaderWriter.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Text.Json; + namespace Microsoft.EntityFrameworkCore.Storage.Internal; /// @@ -11,6 +13,14 @@ namespace Microsoft.EntityFrameworkCore.Storage.Internal; /// public interface IJsonConvertedValueReaderWriter : ICompositeJsonValueReaderWriter { + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + void ToJson(Utf8JsonWriter writer, object? value); + /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to /// the same compatibility standards as public APIs. It may be changed or removed without notice in diff --git a/src/EFCore/Storage/Json/JsonConvertedValueReaderWriter.cs b/src/EFCore/Storage/Json/JsonConvertedValueReaderWriter.cs index 8c137b0e3df..0977685ce63 100644 --- a/src/EFCore/Storage/Json/JsonConvertedValueReaderWriter.cs +++ b/src/EFCore/Storage/Json/JsonConvertedValueReaderWriter.cs @@ -32,6 +32,17 @@ public JsonConvertedValueReaderWriter( _converter = converter; } + void IJsonConvertedValueReaderWriter.ToJson(Utf8JsonWriter writer, object? value) + { + if (value == null && !_converter.ConvertsNulls) + { + writer.WriteNullValue(); + return; + } + + _providerReaderWriter.ToJson(writer, _converter.ConvertToProvider(value)!); + } + /// public override TModel FromJsonTyped(ref Utf8JsonReaderManager manager, object? existingObject = null) => (TModel)_converter.ConvertFromProvider(_providerReaderWriter.FromJsonTyped(ref manager, existingObject))!; From becc2ce246c769c24fc8894b729f0b21a7e79b82 Mon Sep 17 00:00:00 2001 From: JoasE <32096708+JoasE@users.noreply.github.com> Date: Tue, 31 Mar 2026 10:22:10 +0200 Subject: [PATCH 07/10] Add HandlesNulls to JsonValueReaderWriter --- .../Query/Internal/RelationalJsonUtilities.cs | 9 +------- .../Update/ModificationCommand.cs | 8 +------ .../IJsonConvertedValueReaderWriter.cs | 8 ------- .../Json/JsonConvertedValueReaderWriter.cs | 23 ++++++++++--------- .../Storage/Json/JsonValueReaderWriter.cs | 11 ++++++++- .../Storage/Json/JsonValueReaderWriter`.cs | 11 +++++++-- 6 files changed, 33 insertions(+), 37 deletions(-) diff --git a/src/EFCore.Relational/Query/Internal/RelationalJsonUtilities.cs b/src/EFCore.Relational/Query/Internal/RelationalJsonUtilities.cs index 5d169f309ca..8e19aaf3b99 100644 --- a/src/EFCore.Relational/Query/Internal/RelationalJsonUtilities.cs +++ b/src/EFCore.Relational/Query/Internal/RelationalJsonUtilities.cs @@ -93,15 +93,8 @@ void WriteJsonObject(Utf8JsonWriter writer, IComplexType complexType, object? ob var jsonValueReaderWriter = property.GetJsonValueReaderWriter() ?? property.GetTypeMapping().JsonValueReaderWriter; var propertyValue = property.GetGetter().GetClrValue(objectValue); - if (propertyValue is null) + if (propertyValue is null && jsonValueReaderWriter?.HandlesNulls != true) { - if (jsonValueReaderWriter is IJsonConvertedValueReaderWriter jsonConvertedValueReaderWriter) - { -#pragma warning disable EF1001 // Internal EF Core API usage. - jsonConvertedValueReaderWriter.ToJson(writer, propertyValue); -#pragma warning restore EF1001 // Internal EF Core API usage. - return; - } if (!property.IsNullable) { throw new InvalidOperationException(RelationalStrings.NullValueInRequiredJsonProperty(property.Name)); diff --git a/src/EFCore.Relational/Update/ModificationCommand.cs b/src/EFCore.Relational/Update/ModificationCommand.cs index 13bce05cf47..a56ee535fbf 100644 --- a/src/EFCore.Relational/Update/ModificationCommand.cs +++ b/src/EFCore.Relational/Update/ModificationCommand.cs @@ -971,19 +971,13 @@ private void WriteJsonObject( writer.WritePropertyName(jsonPropertyName); var jsonValueReaderWriter = property.GetJsonValueReaderWriter() ?? property.GetTypeMapping().JsonValueReaderWriter; - if (propertyValue is not null) + if (propertyValue is not null || jsonValueReaderWriter?.HandlesNulls == true) { Check.DebugAssert(jsonValueReaderWriter is not null, "Missing JsonValueReaderWriter on JSON property"); jsonValueReaderWriter.ToJson(writer, propertyValue); } else { - if (jsonValueReaderWriter is IJsonConvertedValueReaderWriter jsonConvertedValueReaderWriter) - { - jsonConvertedValueReaderWriter.ToJson(writer, null); - continue; - } - writer.WriteNullValue(); } } diff --git a/src/EFCore/Storage/Internal/IJsonConvertedValueReaderWriter.cs b/src/EFCore/Storage/Internal/IJsonConvertedValueReaderWriter.cs index a6fafb4ea97..41ae20dad23 100644 --- a/src/EFCore/Storage/Internal/IJsonConvertedValueReaderWriter.cs +++ b/src/EFCore/Storage/Internal/IJsonConvertedValueReaderWriter.cs @@ -13,14 +13,6 @@ namespace Microsoft.EntityFrameworkCore.Storage.Internal; /// public interface IJsonConvertedValueReaderWriter : ICompositeJsonValueReaderWriter { - /// - /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to - /// the same compatibility standards as public APIs. It may be changed or removed without notice in - /// any release. You should only use it directly in your code with extreme caution and knowing that - /// doing so can result in application failures when updating to a new Entity Framework Core release. - /// - void ToJson(Utf8JsonWriter writer, object? value); - /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to /// the same compatibility standards as public APIs. It may be changed or removed without notice in diff --git a/src/EFCore/Storage/Json/JsonConvertedValueReaderWriter.cs b/src/EFCore/Storage/Json/JsonConvertedValueReaderWriter.cs index 0977685ce63..7152c6319a8 100644 --- a/src/EFCore/Storage/Json/JsonConvertedValueReaderWriter.cs +++ b/src/EFCore/Storage/Json/JsonConvertedValueReaderWriter.cs @@ -32,16 +32,8 @@ public JsonConvertedValueReaderWriter( _converter = converter; } - void IJsonConvertedValueReaderWriter.ToJson(Utf8JsonWriter writer, object? value) - { - if (value == null && !_converter.ConvertsNulls) - { - writer.WriteNullValue(); - return; - } - - _providerReaderWriter.ToJson(writer, _converter.ConvertToProvider(value)!); - } + /// + public override bool HandlesNulls => _converter.ConvertsNulls; /// public override TModel FromJsonTyped(ref Utf8JsonReaderManager manager, object? existingObject = null) @@ -49,7 +41,16 @@ public override TModel FromJsonTyped(ref Utf8JsonReaderManager manager, object? /// public override void ToJsonTyped(Utf8JsonWriter writer, TModel value) - => _providerReaderWriter.ToJson(writer, (TProvider)_converter.ConvertToProvider(value)!); + { + var convertedValue = _converter.ConvertToProvider(value); + if (convertedValue == null && !_providerReaderWriter.HandlesNulls) + { + writer.WriteNullValue(); + return; + } + + _providerReaderWriter.ToJson(writer, convertedValue); + } JsonValueReaderWriter ICompositeJsonValueReaderWriter.InnerReaderWriter => _providerReaderWriter; diff --git a/src/EFCore/Storage/Json/JsonValueReaderWriter.cs b/src/EFCore/Storage/Json/JsonValueReaderWriter.cs index f61f541659d..9cdf5ba4623 100644 --- a/src/EFCore/Storage/Json/JsonValueReaderWriter.cs +++ b/src/EFCore/Storage/Json/JsonValueReaderWriter.cs @@ -21,6 +21,15 @@ internal JsonValueReaderWriter() { } + /// + /// If , then the nulls will be passed to the writer's method. Otherwise null + /// values will always be written as . + /// + /// + /// The default is . + /// + public virtual bool HandlesNulls { get; } = false; + /// /// Reads the value from a UTF8 JSON stream or buffer. /// @@ -48,7 +57,7 @@ internal JsonValueReaderWriter() /// /// The into which the value should be written. /// The value to write. - public abstract void ToJson(Utf8JsonWriter writer, object value); + public abstract void ToJson(Utf8JsonWriter writer, object? value); /// /// The type of the value being read/written. diff --git a/src/EFCore/Storage/Json/JsonValueReaderWriter`.cs b/src/EFCore/Storage/Json/JsonValueReaderWriter`.cs index f6b67545f7e..b595270df2d 100644 --- a/src/EFCore/Storage/Json/JsonValueReaderWriter`.cs +++ b/src/EFCore/Storage/Json/JsonValueReaderWriter`.cs @@ -15,8 +15,15 @@ public sealed override object FromJson(ref Utf8JsonReaderManager manager, object => FromJsonTyped(ref manager, existingObject)!; /// - public sealed override void ToJson(Utf8JsonWriter writer, object value) - => ToJsonTyped(writer, (TValue)value!); + public sealed override void ToJson(Utf8JsonWriter writer, object? value) + { + if (value == null && !HandlesNulls) + { + throw new ArgumentNullException(nameof(value)); + } + + ToJsonTyped(writer, (TValue)value!); + } /// public sealed override Type ValueType From c7fada14db9b6941b93df8a73c7a370014ca8d78 Mon Sep 17 00:00:00 2001 From: JoasE <32096708+JoasE@users.noreply.github.com> Date: Tue, 31 Mar 2026 10:38:27 +0200 Subject: [PATCH 08/10] Clean --- src/EFCore.Relational/Query/Internal/RelationalJsonUtilities.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/EFCore.Relational/Query/Internal/RelationalJsonUtilities.cs b/src/EFCore.Relational/Query/Internal/RelationalJsonUtilities.cs index 8e19aaf3b99..a90c25b940d 100644 --- a/src/EFCore.Relational/Query/Internal/RelationalJsonUtilities.cs +++ b/src/EFCore.Relational/Query/Internal/RelationalJsonUtilities.cs @@ -4,7 +4,6 @@ using System.Collections; using System.Text; using System.Text.Json; -using Microsoft.EntityFrameworkCore.Storage.Internal; namespace Microsoft.EntityFrameworkCore.Query.Internal; From ab4749b80e0f070a6a9328b182ce4b1e923722e4 Mon Sep 17 00:00:00 2001 From: JoasE <32096708+JoasE@users.noreply.github.com> Date: Tue, 31 Mar 2026 10:49:00 +0200 Subject: [PATCH 09/10] Rename to HandlesNullWrites --- .../Query/Internal/RelationalJsonUtilities.cs | 2 +- src/EFCore.Relational/Update/ModificationCommand.cs | 2 +- src/EFCore/Storage/Json/JsonConvertedValueReaderWriter.cs | 4 ++-- src/EFCore/Storage/Json/JsonValueReaderWriter.cs | 2 +- src/EFCore/Storage/Json/JsonValueReaderWriter`.cs | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/EFCore.Relational/Query/Internal/RelationalJsonUtilities.cs b/src/EFCore.Relational/Query/Internal/RelationalJsonUtilities.cs index a90c25b940d..fa48fa15bcb 100644 --- a/src/EFCore.Relational/Query/Internal/RelationalJsonUtilities.cs +++ b/src/EFCore.Relational/Query/Internal/RelationalJsonUtilities.cs @@ -92,7 +92,7 @@ void WriteJsonObject(Utf8JsonWriter writer, IComplexType complexType, object? ob var jsonValueReaderWriter = property.GetJsonValueReaderWriter() ?? property.GetTypeMapping().JsonValueReaderWriter; var propertyValue = property.GetGetter().GetClrValue(objectValue); - if (propertyValue is null && jsonValueReaderWriter?.HandlesNulls != true) + if (propertyValue is null && jsonValueReaderWriter?.HandlesNullWrites != true) { if (!property.IsNullable) { diff --git a/src/EFCore.Relational/Update/ModificationCommand.cs b/src/EFCore.Relational/Update/ModificationCommand.cs index a56ee535fbf..9c789b0f784 100644 --- a/src/EFCore.Relational/Update/ModificationCommand.cs +++ b/src/EFCore.Relational/Update/ModificationCommand.cs @@ -971,7 +971,7 @@ private void WriteJsonObject( writer.WritePropertyName(jsonPropertyName); var jsonValueReaderWriter = property.GetJsonValueReaderWriter() ?? property.GetTypeMapping().JsonValueReaderWriter; - if (propertyValue is not null || jsonValueReaderWriter?.HandlesNulls == true) + if (propertyValue is not null || jsonValueReaderWriter?.HandlesNullWrites == true) { Check.DebugAssert(jsonValueReaderWriter is not null, "Missing JsonValueReaderWriter on JSON property"); jsonValueReaderWriter.ToJson(writer, propertyValue); diff --git a/src/EFCore/Storage/Json/JsonConvertedValueReaderWriter.cs b/src/EFCore/Storage/Json/JsonConvertedValueReaderWriter.cs index 7152c6319a8..74a36927a6e 100644 --- a/src/EFCore/Storage/Json/JsonConvertedValueReaderWriter.cs +++ b/src/EFCore/Storage/Json/JsonConvertedValueReaderWriter.cs @@ -33,7 +33,7 @@ public JsonConvertedValueReaderWriter( } /// - public override bool HandlesNulls => _converter.ConvertsNulls; + public override bool HandlesNullWrites => _converter.ConvertsNulls; /// public override TModel FromJsonTyped(ref Utf8JsonReaderManager manager, object? existingObject = null) @@ -43,7 +43,7 @@ public override TModel FromJsonTyped(ref Utf8JsonReaderManager manager, object? public override void ToJsonTyped(Utf8JsonWriter writer, TModel value) { var convertedValue = _converter.ConvertToProvider(value); - if (convertedValue == null && !_providerReaderWriter.HandlesNulls) + if (convertedValue == null && !_providerReaderWriter.HandlesNullWrites) { writer.WriteNullValue(); return; diff --git a/src/EFCore/Storage/Json/JsonValueReaderWriter.cs b/src/EFCore/Storage/Json/JsonValueReaderWriter.cs index 9cdf5ba4623..94daf881dcb 100644 --- a/src/EFCore/Storage/Json/JsonValueReaderWriter.cs +++ b/src/EFCore/Storage/Json/JsonValueReaderWriter.cs @@ -28,7 +28,7 @@ internal JsonValueReaderWriter() /// /// The default is . /// - public virtual bool HandlesNulls { get; } = false; + public virtual bool HandlesNullWrites { get; } = false; /// /// Reads the value from a UTF8 JSON stream or buffer. diff --git a/src/EFCore/Storage/Json/JsonValueReaderWriter`.cs b/src/EFCore/Storage/Json/JsonValueReaderWriter`.cs index b595270df2d..1c9735fb8d2 100644 --- a/src/EFCore/Storage/Json/JsonValueReaderWriter`.cs +++ b/src/EFCore/Storage/Json/JsonValueReaderWriter`.cs @@ -17,7 +17,7 @@ public sealed override object FromJson(ref Utf8JsonReaderManager manager, object /// public sealed override void ToJson(Utf8JsonWriter writer, object? value) { - if (value == null && !HandlesNulls) + if (value == null && !HandlesNullWrites) { throw new ArgumentNullException(nameof(value)); } From 1b592cfac364f82a75cf38bad7051f20c822267c Mon Sep 17 00:00:00 2001 From: JoasE <32096708+JoasE@users.noreply.github.com> Date: Tue, 31 Mar 2026 10:53:46 +0200 Subject: [PATCH 10/10] Clean --- src/EFCore.Relational/Update/ModificationCommand.cs | 1 - src/EFCore/Storage/Internal/IJsonConvertedValueReaderWriter.cs | 2 -- 2 files changed, 3 deletions(-) diff --git a/src/EFCore.Relational/Update/ModificationCommand.cs b/src/EFCore.Relational/Update/ModificationCommand.cs index 9c789b0f784..a9019737e93 100644 --- a/src/EFCore.Relational/Update/ModificationCommand.cs +++ b/src/EFCore.Relational/Update/ModificationCommand.cs @@ -8,7 +8,6 @@ using Microsoft.EntityFrameworkCore.ChangeTracking.Internal; using Microsoft.EntityFrameworkCore.Internal; using Microsoft.EntityFrameworkCore.Metadata.Internal; -using Microsoft.EntityFrameworkCore.Storage.Internal; using IColumnMapping = Microsoft.EntityFrameworkCore.Metadata.IColumnMapping; using ITableMapping = Microsoft.EntityFrameworkCore.Metadata.ITableMapping; diff --git a/src/EFCore/Storage/Internal/IJsonConvertedValueReaderWriter.cs b/src/EFCore/Storage/Internal/IJsonConvertedValueReaderWriter.cs index 41ae20dad23..595cd5726fe 100644 --- a/src/EFCore/Storage/Internal/IJsonConvertedValueReaderWriter.cs +++ b/src/EFCore/Storage/Internal/IJsonConvertedValueReaderWriter.cs @@ -1,8 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Text.Json; - namespace Microsoft.EntityFrameworkCore.Storage.Internal; ///