diff --git a/src/EFCore.PG/Storage/Internal/Mapping/NpgsqlJsonTypeMapping.cs b/src/EFCore.PG/Storage/Internal/Mapping/NpgsqlJsonTypeMapping.cs index 9e32d5575..216f745db 100644 --- a/src/EFCore.PG/Storage/Internal/Mapping/NpgsqlJsonTypeMapping.cs +++ b/src/EFCore.PG/Storage/Internal/Mapping/NpgsqlJsonTypeMapping.cs @@ -13,6 +13,9 @@ namespace Npgsql.EntityFrameworkCore.PostgreSQL.Storage.Internal.Mapping; /// public class NpgsqlJsonTypeMapping : NpgsqlTypeMapping { + private static readonly JsonDocumentComparer JsonDocumentComparerInstance = new(); + private static readonly JsonElementComparer JsonElementComparerInstance = new(); + /// /// 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 @@ -29,11 +32,14 @@ public class NpgsqlJsonTypeMapping : NpgsqlTypeMapping /// public NpgsqlJsonTypeMapping(string storeType, Type clrType, CoreTypeMapping? elementTypeMapping = null) : base( - storeType, - clrType, - storeType == "jsonb" ? NpgsqlDbType.Jsonb : NpgsqlDbType.Json, - jsonValueReaderWriter: clrType == typeof(string) ? JsonStringReaderWriter.Instance : null, - elementTypeMapping: elementTypeMapping) + new RelationalTypeMappingParameters( + new CoreTypeMappingParameters( + clrType, + comparer: GetComparer(clrType), + jsonValueReaderWriter: clrType == typeof(string) ? JsonStringReaderWriter.Instance : null, + elementMapping: elementTypeMapping), + storeType), + storeType == "jsonb" ? NpgsqlDbType.Jsonb : NpgsqlDbType.Json) { if (storeType != "json" && storeType != "jsonb") { @@ -135,4 +141,33 @@ public override Expression GenerateCodeLiteral(object value) private static readonly MethodInfo ParseMethod = typeof(JsonDocument).GetMethod(nameof(JsonDocument.Parse), [typeof(string), typeof(JsonDocumentOptions)])!; + + private static ValueComparer? GetComparer(Type clrType) + { + if (clrType == typeof(JsonDocument)) + { + return JsonDocumentComparerInstance; + } + + if (clrType == typeof(JsonElement)) + { + return JsonElementComparerInstance; + } + + return null; + } + + private sealed class JsonDocumentComparer() : ValueComparer( + (a, b) => a == null ? b == null : b != null && JsonElement.DeepEquals(a.RootElement, b.RootElement), + o => o.GetHashCode(), + // Disposing the original JsonDocument will throw when it's later compared against the snapshot + o => o); + + private sealed class JsonElementComparer() : ValueComparer( + (a, b) => a.ValueKind == JsonValueKind.Undefined + ? b.ValueKind == JsonValueKind.Undefined + : b.ValueKind != JsonValueKind.Undefined && JsonElement.DeepEquals(a, b), + // JsonElement.GetHashCode() has inefficient runtime-provided implementation + o => 0, + o => o); } diff --git a/test/EFCore.PG.FunctionalTests/Query/JsonDomChangeTrackingTest.cs b/test/EFCore.PG.FunctionalTests/Query/JsonDomChangeTrackingTest.cs new file mode 100644 index 000000000..2a686448d --- /dev/null +++ b/test/EFCore.PG.FunctionalTests/Query/JsonDomChangeTrackingTest.cs @@ -0,0 +1,266 @@ +using System.ComponentModel.DataAnnotations.Schema; +using System.Text.Json; + +namespace Microsoft.EntityFrameworkCore.Query; + +public class JsonDomChangeTrackingTest : IClassFixture +{ + private JsonDomChangeTrackingFixture Fixture { get; } + + public JsonDomChangeTrackingTest(JsonDomChangeTrackingFixture fixture, ITestOutputHelper testOutputHelper) + { + Fixture = fixture; + Fixture.TestSqlLoggerFactory.Clear(); + Fixture.TestSqlLoggerFactory.SetTestOutputHelper(testOutputHelper); + } + + [Theory] + [InlineData("""{"Name":"John","Age":25}""", true)] + [InlineData("""{"Age":25.00,"Name":"Joe"}""", false)] + public async Task SaveChanges_jsonb_document(string json, bool isModified) + { + await using var ctx = CreateContext(); + await ctx.Database.CreateExecutionStrategy().ExecuteAsync( + ctx, async context => + { + await using var transaction = await context.Database.BeginTransactionAsync(); + + var entity = await context.JsonbEntities.SingleAsync(e => e.Id == 1); + entity.CustomerDocument = JsonDocument.Parse(json); + Fixture.TestSqlLoggerFactory.Clear(); + await context.SaveChangesAsync(); + + if (isModified) + { + AssertSql( + """ +@p1='1' +@p0='System.Text.Json.JsonDocument' (DbType = Object) + +UPDATE "JsonbEntities" SET "CustomerDocument" = @p0 +WHERE "Id" = @p1; +"""); + } + else + { + AssertEmptySql(); + } + }); + } + + [Theory] + [InlineData("""{"Name":"Joe","Age":26}""", true)] + [InlineData("""{"Age":25.00,"Name":"Joe"}""", false)] + public async Task SaveChanges_json_document(string json, bool isModified) + { + await using var ctx = CreateContext(); + await ctx.Database.CreateExecutionStrategy().ExecuteAsync( + ctx, async context => + { + await using var transaction = await context.Database.BeginTransactionAsync(); + + var entity = await context.JsonEntities.SingleAsync(e => e.Id == 1); + entity.CustomerDocument = JsonDocument.Parse(json); + Fixture.TestSqlLoggerFactory.Clear(); + await context.SaveChangesAsync(); + + if (isModified) + { + AssertSql( + """ +@p1='1' +@p0='System.Text.Json.JsonDocument' (DbType = Object) + +UPDATE "JsonEntities" SET "CustomerDocument" = @p0 +WHERE "Id" = @p1; +"""); + } + else + { + AssertEmptySql(); + } + }); + } + + [Theory] + [InlineData("""{"Name":"John","Age":25}""", true)] + [InlineData("""{"Age":25.00,"Name":"Joe"}""", false)] + public async Task SaveChanges_jsonb_element(string json, bool isModified) + { + await using var ctx = CreateContext(); + await ctx.Database.CreateExecutionStrategy().ExecuteAsync( + ctx, async context => + { + await using var transaction = await context.Database.BeginTransactionAsync(); + + var entity = await context.JsonbEntities.SingleAsync(e => e.Id == 1); + entity.CustomerElement = JsonElement.Parse(json); + Fixture.TestSqlLoggerFactory.Clear(); + await context.SaveChangesAsync(); + + if (isModified) + { + AssertSql( + """ +@p1='1' +@p0='{"Name":"John","Age":25}' (DbType = Object) + +UPDATE "JsonbEntities" SET "CustomerElement" = @p0 +WHERE "Id" = @p1; +"""); + } + else + { + AssertEmptySql(); + } + }); + } + + [Theory] + [InlineData("""{"Name":"Joe","Age":26}""", true)] + [InlineData("""{"Age":25.00,"Name":"Joe"}""", false)] + public async Task SaveChanges_json_element(string json, bool isModified) + { + await using var ctx = CreateContext(); + await ctx.Database.CreateExecutionStrategy().ExecuteAsync( + ctx, async context => + { + await using var transaction = await context.Database.BeginTransactionAsync(); + + var entity = await context.JsonEntities.SingleAsync(e => e.Id == 1); + entity.CustomerElement = JsonElement.Parse(json); + Fixture.TestSqlLoggerFactory.Clear(); + await context.SaveChangesAsync(); + + if (isModified) + { + AssertSql( + """ +@p1='1' +@p0='{"Name":"Joe","Age":26}' (DbType = Object) + +UPDATE "JsonEntities" SET "CustomerElement" = @p0 +WHERE "Id" = @p1; +"""); + } + else + { + AssertEmptySql(); + } + }); + } + + [Fact] + public async Task DetectChanges_jsonb_undefined_element_no_throw() + { + await using var ctx = CreateContext(); + + var entity = await ctx.JsonbEntities.SingleAsync(e => e.Id == 1); + entity.CustomerElement = new JsonElement(); + ctx.JsonbEntities.Add( + new JsonbEntity + { + Id = 2, + CustomerElement = new JsonElement(), + CustomerDocument = null + }); + + ctx.ChangeTracker.DetectChanges(); + } + + [Fact] + public async Task DetectChanges_json_undefined_element_no_throw() + { + await using var ctx = CreateContext(); + + var entity = await ctx.JsonEntities.SingleAsync(e => e.Id == 1); + entity.CustomerElement = new JsonElement(); + ctx.JsonEntities.Add( + new JsonEntity + { + Id = 2, + CustomerElement = new JsonElement(), + CustomerDocument = null + }); + + ctx.ChangeTracker.DetectChanges(); + } + + #region Support + + protected JsonDomChangeTrackingContext CreateContext() + => Fixture.CreateContext(); + + private void AssertSql(params string[] expected) + => Fixture.TestSqlLoggerFactory.AssertBaseline(expected); + + private void AssertEmptySql() + => Assert.Empty(Fixture.TestSqlLoggerFactory.SqlStatements); + + public class JsonDomChangeTrackingContext(DbContextOptions options) : PoolableDbContext(options) + { + public DbSet JsonbEntities { get; set; } + public DbSet JsonEntities { get; set; } + + public static async Task SeedAsync(JsonDomChangeTrackingContext context) + { + var customer = CreateCustomer(); + + context.JsonbEntities.Add( + new JsonbEntity + { + Id = 1, + CustomerDocument = customer, + CustomerElement = customer.RootElement + }); + context.JsonEntities.Add( + new JsonEntity + { + Id = 1, + CustomerDocument = customer, + CustomerElement = customer.RootElement + }); + + await context.SaveChangesAsync(); + + static JsonDocument CreateCustomer() + => JsonDocument.Parse("""{"Name":"Joe","Age":25}"""); + } + } + + public class JsonbEntity + { + public required int Id { get; set; } + + public required JsonDocument? CustomerDocument { get; set; } + public required JsonElement CustomerElement { get; set; } + } + + public class JsonEntity + { + public required int Id { get; set; } + + [Column(TypeName = "json")] + public required JsonDocument? CustomerDocument { get; set; } + + [Column(TypeName = "json")] + public required JsonElement CustomerElement { get; set; } + } + + public class JsonDomChangeTrackingFixture : SharedStoreFixtureBase + { + protected override string StoreName + => "JsonDomChangeTrackingTest"; + + protected override ITestStoreFactory TestStoreFactory + => NpgsqlTestStoreFactory.Instance; + + public TestSqlLoggerFactory TestSqlLoggerFactory + => (TestSqlLoggerFactory)ListLoggerFactory; + + protected override Task SeedAsync(JsonDomChangeTrackingContext context) + => JsonDomChangeTrackingContext.SeedAsync(context); + } + + #endregion +} diff --git a/test/EFCore.PG.Tests/Storage/NpgsqlTypeMappingTest.cs b/test/EFCore.PG.Tests/Storage/NpgsqlTypeMappingTest.cs index bfefb835a..bd33a9cbb 100644 --- a/test/EFCore.PG.Tests/Storage/NpgsqlTypeMappingTest.cs +++ b/test/EFCore.PG.Tests/Storage/NpgsqlTypeMappingTest.cs @@ -943,6 +943,15 @@ public void ValueComparer_JsonDocument() var snapshot = (JsonDocument)comparer.Snapshot(source); Assert.Same(source, snapshot); Assert.True(comparer.Equals(source, snapshot)); + Assert.True(comparer.Equals(source, JsonDocument.Parse(json))); + json = """{"Age":25.0,"Name":"Joe"}"""; + source = JsonDocument.Parse(json); + Assert.True(comparer.Equals(source, snapshot)); + json = """{"Name":"Joe","Age":26}"""; + source = JsonDocument.Parse(json); + Assert.False(comparer.Equals(source, snapshot)); + Assert.False(comparer.Equals(source, null)); + Assert.True(comparer.Equals(null, null)); } [Fact] @@ -954,7 +963,15 @@ public void ValueComparer_JsonElement() var comparer = GetMapping(typeof(JsonElement)).Comparer; var snapshot = (JsonElement)comparer.Snapshot(source); Assert.True(comparer.Equals(source, snapshot)); - Assert.False(comparer.Equals(source, JsonDocument.Parse(json).RootElement)); + Assert.True(comparer.Equals(source, JsonDocument.Parse(json).RootElement)); + json = """{"Age":25.0,"Name":"Joe"}"""; + source = JsonDocument.Parse(json).RootElement; + Assert.True(comparer.Equals(source, snapshot)); + json = """{"Name":"Joe","Age":26}"""; + source = JsonDocument.Parse(json).RootElement; + Assert.False(comparer.Equals(source, snapshot)); + Assert.False(comparer.Equals(source, new JsonElement())); + Assert.True(comparer.Equals(new JsonElement(), new JsonElement())); } private static readonly Customer SampleCustomer = new()