From 4681a852e3b0ecc29fc32e6f6e63c29f4f02cf2f Mon Sep 17 00:00:00 2001 From: maliming Date: Fri, 31 Jul 2026 14:42:32 +0800 Subject: [PATCH] Add HasAbpJsonConversion property builder extension --- .../Modeling/AbpPropertyBuilderExtensions.cs | 26 +++++++ .../ValueComparers/AbpJsonValueComparer.cs | 26 +++++++ .../AbpJsonValueComparer_Tests.cs | 74 +++++++++++++++++++ ...IdentityDbContextModelBuilderExtensions.cs | 13 +--- 4 files changed, 128 insertions(+), 11 deletions(-) create mode 100644 framework/src/Volo.Abp.EntityFrameworkCore/Volo/Abp/EntityFrameworkCore/Modeling/AbpPropertyBuilderExtensions.cs create mode 100644 framework/src/Volo.Abp.EntityFrameworkCore/Volo/Abp/EntityFrameworkCore/ValueComparers/AbpJsonValueComparer.cs create mode 100644 framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/ValueComparers/AbpJsonValueComparer_Tests.cs 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 {