diff --git a/framework/src/Volo.Abp.EntityFrameworkCore/Volo/Abp/EntityFrameworkCore/Modeling/AbpPropertyBuilderExtensions.cs b/framework/src/Volo.Abp.EntityFrameworkCore/Volo/Abp/EntityFrameworkCore/Modeling/AbpPropertyBuilderExtensions.cs
new file mode 100644
index 0000000000..ec7e8529aa
--- /dev/null
+++ b/framework/src/Volo.Abp.EntityFrameworkCore/Volo/Abp/EntityFrameworkCore/Modeling/AbpPropertyBuilderExtensions.cs
@@ -0,0 +1,26 @@
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+using Volo.Abp.EntityFrameworkCore.ValueComparers;
+using Volo.Abp.EntityFrameworkCore.ValueConverters;
+
+namespace Volo.Abp.EntityFrameworkCore.Modeling;
+
+public static class AbpPropertyBuilderExtensions
+{
+ ///
+ /// Stores the property as a JSON serialized string with snapshot-based
+ /// change tracking.
+ ///
+ ///
+ /// A fallback for EF Core providers without owned-JSON (ToJson) or primitive
+ /// collection mapping support. The value is converted as a whole: JSON path
+ /// querying and partial updates are not available, and the property type must
+ /// fully round-trip with System.Text.Json.
+ ///
+ public static PropertyBuilder HasAbpJsonConversion(
+ this PropertyBuilder propertyBuilder)
+ {
+ return propertyBuilder.HasConversion(
+ new AbpJsonValueConverter(),
+ new AbpJsonValueComparer());
+ }
+}
diff --git a/framework/src/Volo.Abp.EntityFrameworkCore/Volo/Abp/EntityFrameworkCore/ValueComparers/AbpJsonValueComparer.cs b/framework/src/Volo.Abp.EntityFrameworkCore/Volo/Abp/EntityFrameworkCore/ValueComparers/AbpJsonValueComparer.cs
new file mode 100644
index 0000000000..117f6d66b1
--- /dev/null
+++ b/framework/src/Volo.Abp.EntityFrameworkCore/Volo/Abp/EntityFrameworkCore/ValueComparers/AbpJsonValueComparer.cs
@@ -0,0 +1,26 @@
+using System.Text.Json;
+using Microsoft.EntityFrameworkCore.ChangeTracking;
+using Volo.Abp.EntityFrameworkCore.ValueConverters;
+
+namespace Volo.Abp.EntityFrameworkCore.ValueComparers;
+
+public class AbpJsonValueComparer : ValueComparer
+{
+ public AbpJsonValueComparer()
+ : base(
+ (left, right) => Serialize(left) == Serialize(right),
+ v => Serialize(v).GetHashCode(),
+ v => Deserialize(Serialize(v)))
+ {
+ }
+
+ private static string Serialize(TPropertyType? value)
+ {
+ return JsonSerializer.Serialize(value, AbpJsonValueConverter.SerializeOptions);
+ }
+
+ private static TPropertyType Deserialize(string value)
+ {
+ return JsonSerializer.Deserialize(value, AbpJsonValueConverter.DeserializeOptions)!;
+ }
+}
diff --git a/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/ValueComparers/AbpJsonValueComparer_Tests.cs b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/ValueComparers/AbpJsonValueComparer_Tests.cs
new file mode 100644
index 0000000000..0039d9577f
--- /dev/null
+++ b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/ValueComparers/AbpJsonValueComparer_Tests.cs
@@ -0,0 +1,74 @@
+using System.Collections.Generic;
+using Shouldly;
+using Volo.Abp.EntityFrameworkCore.ValueComparers;
+using Xunit;
+
+namespace Volo.Abp.EntityFrameworkCore.ValueComparers;
+
+public class AbpJsonValueComparer_Tests
+{
+ [Fact]
+ public void Should_Compare_Collections_By_Content()
+ {
+ var comparer = new AbpJsonValueComparer>();
+
+ comparer.Equals(new List { "a", "b" }, new List { "a", "b" }).ShouldBeTrue();
+ comparer.Equals(new List { "a", "b" }, new List { "b", "a" }).ShouldBeFalse();
+ comparer.GetHashCode(new List { "a", "b" })
+ .ShouldBe(comparer.GetHashCode(new List { "a", "b" }));
+ }
+
+ [Fact]
+ public void Should_Handle_Null_Values()
+ {
+ var comparer = new AbpJsonValueComparer>();
+
+ comparer.Equals(null, null).ShouldBeTrue();
+ comparer.Equals(new List(), null).ShouldBeFalse();
+ }
+
+ [Fact]
+ public void Snapshot_Should_Be_A_Deep_Copy()
+ {
+ var comparer = new AbpJsonValueComparer>();
+ var original = new List { "a" };
+
+ var snapshot = (List)comparer.Snapshot(original)!;
+ original.Add("b");
+
+ snapshot.ShouldBe(new[] { "a" });
+ comparer.Equals(original, snapshot).ShouldBeFalse();
+ }
+
+ [Fact]
+ public void Snapshot_Should_Copy_Nested_Arrays_Of_Typed_Objects()
+ {
+ var comparer = new AbpJsonValueComparer();
+ var original = new SampleData { Count = 1, Tags = new[] { "x" } };
+
+ var snapshot = (SampleData)comparer.Snapshot(original)!;
+ original.Tags[0] = "changed";
+ original.Count = 2;
+
+ snapshot.Tags.ShouldBe(new[] { "x" });
+ snapshot.Count.ShouldBe(1);
+ comparer.Equals(original, snapshot).ShouldBeFalse();
+ }
+
+ [Fact]
+ public void Should_Support_Nullable_Value_Types()
+ {
+ var comparer = new AbpJsonValueComparer();
+
+ comparer.Equals(42, 42).ShouldBeTrue();
+ comparer.Equals(42, 43).ShouldBeFalse();
+ comparer.Equals(null, null).ShouldBeTrue();
+ }
+
+ private class SampleData
+ {
+ public int Count { get; set; }
+
+ public string[] Tags { get; set; } = default!;
+ }
+}
diff --git a/modules/identity/src/Volo.Abp.Identity.EntityFrameworkCore/Volo/Abp/Identity/EntityFrameworkCore/IdentityDbContextModelBuilderExtensions.cs b/modules/identity/src/Volo.Abp.Identity.EntityFrameworkCore/Volo/Abp/Identity/EntityFrameworkCore/IdentityDbContextModelBuilderExtensions.cs
index c1a94abce6..ed423a024b 100644
--- a/modules/identity/src/Volo.Abp.Identity.EntityFrameworkCore/Volo/Abp/Identity/EntityFrameworkCore/IdentityDbContextModelBuilderExtensions.cs
+++ b/modules/identity/src/Volo.Abp.Identity.EntityFrameworkCore/Volo/Abp/Identity/EntityFrameworkCore/IdentityDbContextModelBuilderExtensions.cs
@@ -1,8 +1,6 @@
using System;
-using System.Text.Json;
using JetBrains.Annotations;
using Microsoft.EntityFrameworkCore;
-using Microsoft.EntityFrameworkCore.ChangeTracking;
using Volo.Abp.EntityFrameworkCore.Modeling;
using Volo.Abp.Users.EntityFrameworkCore;
@@ -194,18 +192,11 @@ public static class IdentityDbContextModelBuilderExtensions
{
/* MySQL providers do not support EF Core JSON columns (ToJson),
* so store Data as a serialized json column with the same column
- * name and content. The comparer detects in-place mutations
- * (e.g. sign count updates on login). */
+ * name and content. */
b.Property(p => p.Data)
.HasColumnName(nameof(IdentityUserPasskey.Data))
.HasColumnType("json")
- .HasConversion(
- d => JsonSerializer.Serialize(d, (JsonSerializerOptions?)null),
- s => JsonSerializer.Deserialize(s, (JsonSerializerOptions?)null)!,
- new ValueComparer(
- (l, r) => JsonSerializer.Serialize(l, (JsonSerializerOptions?)null) == JsonSerializer.Serialize(r, (JsonSerializerOptions?)null),
- v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null).GetHashCode(),
- v => JsonSerializer.Deserialize(JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), (JsonSerializerOptions?)null)!));
+ .HasAbpJsonConversion();
}
else
{