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()