From 4fa08c66d30d9d22e10f21902c1f4470a38e06ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alan=20Vrani=C4=87?= Date: Tue, 13 Aug 2024 17:07:36 +0200 Subject: [PATCH 1/3] feat: add unit test support to system text json version --- .../6.0-JsonMergePatch.SystemText.csproj | 1 + .../Builders/PatchBuilder.cs | 50 ++++++++++++++- .../PatchBuilderTests.cs | 64 +++++++++++++++++++ 3 files changed, 114 insertions(+), 1 deletion(-) create mode 100644 src/6.0-JsonMergePatch.Tests/PatchBuilderTests.cs diff --git a/src/6.0-JsonMergePatch.SystemText/6.0-JsonMergePatch.SystemText.csproj b/src/6.0-JsonMergePatch.SystemText/6.0-JsonMergePatch.SystemText.csproj index 6e04a18..a55d1f3 100644 --- a/src/6.0-JsonMergePatch.SystemText/6.0-JsonMergePatch.SystemText.csproj +++ b/src/6.0-JsonMergePatch.SystemText/6.0-JsonMergePatch.SystemText.csproj @@ -30,6 +30,7 @@ + diff --git a/src/6.0-JsonMergePatch.SystemText/Builders/PatchBuilder.cs b/src/6.0-JsonMergePatch.SystemText/Builders/PatchBuilder.cs index e4f55b3..1fc45f7 100644 --- a/src/6.0-JsonMergePatch.SystemText/Builders/PatchBuilder.cs +++ b/src/6.0-JsonMergePatch.SystemText/Builders/PatchBuilder.cs @@ -2,9 +2,46 @@ using System; using System.Reflection; using System.Text.Json; +using Morcatko.AspNetCore.JsonMergePatch.NewtonsoftJson.Builders; +using Newtonsoft.Json.Linq; namespace Morcatko.AspNetCore.JsonMergePatch.SystemText.Builders { + public class PatchBuilder where TModel : class + { + public static JsonMergePatchDocument Build(TModel original, TModel patched, JsonMergePatchOptions options = null) + { + var diff = DiffBuilder.Build(original, patched); + var jsonElement = diff != null ? JsonElementFromJObject(diff) : JsonDocument.Parse("{}").RootElement; + return PatchBuilder.CreatePatchDocument(jsonElement, options); + } + + public static JsonMergePatchDocument Build(string jsonObjectPatch, JsonSerializerOptions jsonOptions = null, JsonMergePatchOptions options = null) + { + var jsonElement = JsonDocument.Parse(jsonObjectPatch).RootElement; + return PatchBuilder.CreatePatchDocument(jsonElement, jsonOptions ?? new JsonSerializerOptions(), options ?? new JsonMergePatchOptions()); + } + + public static JsonMergePatchDocument Build(object jsonObjectPatch, JsonMergePatchOptions options = null) + { + var json = JsonSerializer.Serialize(jsonObjectPatch); + var jsonElement = JsonDocument.Parse(json).RootElement; + return PatchBuilder.CreatePatchDocument(jsonElement, options); + } + + public static JsonMergePatchDocument Build(JsonElement jsonObjectPatch, JsonMergePatchOptions options = null) + { + return PatchBuilder.CreatePatchDocument(jsonObjectPatch, options); + } + + private static JsonElement JsonElementFromJObject(JObject jObject) + { + var jsonString = jObject.ToString(); + using var doc = JsonDocument.Parse(jsonString); + return doc.RootElement.Clone(); + } + } + public static class PatchBuilder { private static object ToObject(this JsonElement jsonElement) @@ -67,6 +104,17 @@ private static void AddOperation(IInternalJsonMergePatchDocument jsonMergePatchD } static readonly Type internalJsonMergePatchDocumentType = typeof(InternalJsonMergePatchDocument<>); + + internal static JsonMergePatchDocument CreatePatchDocument(JsonElement patchObject, JsonMergePatchOptions options = null) where TModel : class + { + return CreatePatchDocument(typeof(TModel), patchObject, new JsonSerializerOptions(), options ?? new JsonMergePatchOptions()) as JsonMergePatchDocument; + } + + internal static JsonMergePatchDocument CreatePatchDocument(JsonElement patchObject, JsonSerializerOptions jsonOptions, JsonMergePatchOptions mergePatchOptions) where TModel : class + { + return CreatePatchDocument(typeof(TModel), patchObject, jsonOptions, mergePatchOptions) as JsonMergePatchDocument; + } + internal static IInternalJsonMergePatchDocument CreatePatchDocument(Type modelType, JsonElement jsonElement, JsonSerializerOptions jsonOptions, JsonMergePatchOptions mergePatchOptions) { var jsonMergePatchType = internalJsonMergePatchDocumentType.MakeGenericType(modelType); @@ -78,4 +126,4 @@ internal static IInternalJsonMergePatchDocument CreatePatchDocument(Type modelTy return jsonMergePatchDocument; } } -} +} \ No newline at end of file diff --git a/src/6.0-JsonMergePatch.Tests/PatchBuilderTests.cs b/src/6.0-JsonMergePatch.Tests/PatchBuilderTests.cs new file mode 100644 index 0000000..aac325e --- /dev/null +++ b/src/6.0-JsonMergePatch.Tests/PatchBuilderTests.cs @@ -0,0 +1,64 @@ +using System.Text.Json; +using Morcatko.AspNetCore.JsonMergePatch.SystemText.Builders; +using Xunit; + +namespace Morcatko.AspNetCore.JsonMergePatch.Tests; + +public class PatchBuilderTests +{ + private class TestModel + { + public string Name { get; set; } + public int Age { get; set; } + } + + [Fact] + public void Build_FromOriginalAndPatched_ShouldCreatePatchDocument() + { + var original = new TestModel { Name = "John", Age = 30 }; + var patched = new TestModel { Name = "John", Age = 31 }; + + var patchDocument = PatchBuilder.Build(original, patched); + + Assert.NotNull(patchDocument); + Assert.Single(patchDocument.Operations); + Assert.Equal("/Age", patchDocument.Operations[0].path); + Assert.Equal(31, patchDocument.Operations[0].value); + } + + [Fact] + public void Build_FromJsonString_ShouldCreatePatchDocument() + { + var jsonPatch = "{\"Age\":31}"; + var patchDocument = PatchBuilder.Build(jsonPatch); + + Assert.NotNull(patchDocument); + Assert.Single(patchDocument.Operations); + Assert.Equal("/Age", patchDocument.Operations[0].path); + Assert.Equal(31, patchDocument.Operations[0].value); + } + + [Fact] + public void Build_FromObject_ShouldCreatePatchDocument() + { + var patchObject = new { Age = 31 }; + var patchDocument = PatchBuilder.Build(patchObject); + + Assert.NotNull(patchDocument); + Assert.Single(patchDocument.Operations); + Assert.Equal("/Age", patchDocument.Operations[0].path); + Assert.Equal(31, patchDocument.Operations[0].value); + } + + [Fact] + public void Build_FromJsonElement_ShouldCreatePatchDocument() + { + var jsonElement = JsonDocument.Parse("{\"Age\":31}").RootElement; + var patchDocument = PatchBuilder.Build(jsonElement); + + Assert.NotNull(patchDocument); + Assert.Single(patchDocument.Operations); + Assert.Equal("/Age", patchDocument.Operations[0].path); + Assert.Equal(31, patchDocument.Operations[0].value); + } +} \ No newline at end of file From 65d34e94be1352adcc5c0ad335f69ed6bfa8aa68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alan=20Vrani=C4=87?= Date: Thu, 15 Aug 2024 09:21:32 +0200 Subject: [PATCH 2/3] feat: completely remove dependency to Newtonsoft and rewrite only to use System.Text.Json. Add unit test that covers complete patch case from start to finish with patching, ignoring and null value replacement --- .../6.0-JsonMergePatch.SystemText.csproj | 1 - .../Builders/DiffBuilder.cs | 99 +++++++++++++++++++ .../Builders/PatchBuilder.cs | 11 +-- .../PatchBuilderTests.cs | 67 +++++++++++-- 4 files changed, 160 insertions(+), 18 deletions(-) create mode 100644 src/6.0-JsonMergePatch.SystemText/Builders/DiffBuilder.cs diff --git a/src/6.0-JsonMergePatch.SystemText/6.0-JsonMergePatch.SystemText.csproj b/src/6.0-JsonMergePatch.SystemText/6.0-JsonMergePatch.SystemText.csproj index a55d1f3..6e04a18 100644 --- a/src/6.0-JsonMergePatch.SystemText/6.0-JsonMergePatch.SystemText.csproj +++ b/src/6.0-JsonMergePatch.SystemText/6.0-JsonMergePatch.SystemText.csproj @@ -30,7 +30,6 @@ - diff --git a/src/6.0-JsonMergePatch.SystemText/Builders/DiffBuilder.cs b/src/6.0-JsonMergePatch.SystemText/Builders/DiffBuilder.cs new file mode 100644 index 0000000..19456d2 --- /dev/null +++ b/src/6.0-JsonMergePatch.SystemText/Builders/DiffBuilder.cs @@ -0,0 +1,99 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; + +namespace Morcatko.AspNetCore.JsonMergePatch.SystemText.Builders +{ + public static class DiffBuilder + { + public static JsonDocument Build(TModel original, TModel patched) where TModel : class + { + var originalJson = JsonSerializer.SerializeToElement(original); + var patchedJson = JsonSerializer.SerializeToElement(patched); + return BuildDiff(originalJson, patchedJson); + } + + private static JsonDocument BuildDiff(JsonElement original, JsonElement patched) + { + if (original.ValueKind == JsonValueKind.Null && patched.ValueKind == JsonValueKind.Null) + return JsonDocument.Parse("{}"); + + if (original.ValueKind == JsonValueKind.Null) + return JsonDocument.Parse(patched.GetRawText()); + + if (patched.ValueKind == JsonValueKind.Null) + return JsonDocument.Parse("null"); + + if (original.ValueKind == JsonValueKind.Array || patched.ValueKind == JsonValueKind.Array) + return BuildArrayDiff(original, patched); + + return original.ValueKind == JsonValueKind.Object ? + BuildObjectDiff(original, patched) : BuildValueDiff(original, patched); + } + + private static JsonDocument BuildObjectDiff(JsonElement original, JsonElement patched) + { + var result = new Dictionary(); + + var propertyNames = original.EnumerateObject() + .Select(p => p.Name) + .Union(patched.EnumerateObject().Select(p => p.Name)) + .Distinct(); + + foreach (var propertyName in propertyNames) + { + var originalPropExists = original.TryGetProperty(propertyName, out var originalValue); + var patchedPropExists = patched.TryGetProperty(propertyName, out var patchedValue); + + // If the property exists in both and is unchanged, skip it + if (originalPropExists && + patchedPropExists && + originalValue.ValueKind == patchedValue.ValueKind && + originalValue.ToString() == patchedValue.ToString()) + { + continue; + } + + var patchToken = BuildDiff( + originalPropExists ? originalValue : default, + patchedPropExists ? patchedValue : default + ); + + if (patchToken != null && patchToken.RootElement.ValueKind != JsonValueKind.Undefined) + result[propertyName] = patchToken.RootElement.Clone(); + } + + if (!result.Any()) { + return JsonDocument.Parse("{}"); + } + + var serializedResult = JsonSerializer.Serialize(result); + return JsonDocument.Parse(serializedResult); + + } + + private static JsonDocument BuildValueDiff(JsonElement original, JsonElement patched) + { + return JsonDocument.Parse(!original.Equals(patched) ? patched.GetRawText() : "{}"); + } + + private static JsonDocument BuildArrayDiff(JsonElement original, JsonElement patched) + { + return JsonDocument.Parse(JsonArrayEquals(original, patched) ? "{}" : patched.GetRawText()); + + bool JsonArrayEquals(JsonElement left, JsonElement right) + { + if (left.GetArrayLength() != right.GetArrayLength()) + return false; + + for (int i = 0; i < left.GetArrayLength(); i++) + { + var diff = BuildDiff(left[i], right[i]); + if (diff.RootElement.ValueKind != JsonValueKind.Undefined) + return false; + } + return true; + } + } + } +} \ No newline at end of file diff --git a/src/6.0-JsonMergePatch.SystemText/Builders/PatchBuilder.cs b/src/6.0-JsonMergePatch.SystemText/Builders/PatchBuilder.cs index 1fc45f7..f4190e7 100644 --- a/src/6.0-JsonMergePatch.SystemText/Builders/PatchBuilder.cs +++ b/src/6.0-JsonMergePatch.SystemText/Builders/PatchBuilder.cs @@ -2,8 +2,6 @@ using System; using System.Reflection; using System.Text.Json; -using Morcatko.AspNetCore.JsonMergePatch.NewtonsoftJson.Builders; -using Newtonsoft.Json.Linq; namespace Morcatko.AspNetCore.JsonMergePatch.SystemText.Builders { @@ -12,7 +10,7 @@ public class PatchBuilder where TModel : class public static JsonMergePatchDocument Build(TModel original, TModel patched, JsonMergePatchOptions options = null) { var diff = DiffBuilder.Build(original, patched); - var jsonElement = diff != null ? JsonElementFromJObject(diff) : JsonDocument.Parse("{}").RootElement; + var jsonElement = diff != null ? diff.RootElement.Clone() : JsonDocument.Parse("{}").RootElement; return PatchBuilder.CreatePatchDocument(jsonElement, options); } @@ -33,13 +31,6 @@ public static JsonMergePatchDocument Build(JsonElement jsonObjectPatch, { return PatchBuilder.CreatePatchDocument(jsonObjectPatch, options); } - - private static JsonElement JsonElementFromJObject(JObject jObject) - { - var jsonString = jObject.ToString(); - using var doc = JsonDocument.Parse(jsonString); - return doc.RootElement.Clone(); - } } public static class PatchBuilder diff --git a/src/6.0-JsonMergePatch.Tests/PatchBuilderTests.cs b/src/6.0-JsonMergePatch.Tests/PatchBuilderTests.cs index aac325e..b8b66ac 100644 --- a/src/6.0-JsonMergePatch.Tests/PatchBuilderTests.cs +++ b/src/6.0-JsonMergePatch.Tests/PatchBuilderTests.cs @@ -1,4 +1,5 @@ -using System.Text.Json; +using System.Linq; +using System.Text.Json; using Morcatko.AspNetCore.JsonMergePatch.SystemText.Builders; using Xunit; @@ -6,12 +7,6 @@ namespace Morcatko.AspNetCore.JsonMergePatch.Tests; public class PatchBuilderTests { - private class TestModel - { - public string Name { get; set; } - public int Age { get; set; } - } - [Fact] public void Build_FromOriginalAndPatched_ShouldCreatePatchDocument() { @@ -61,4 +56,62 @@ public void Build_FromJsonElement_ShouldCreatePatchDocument() Assert.Equal("/Age", patchDocument.Operations[0].path); Assert.Equal(31, patchDocument.Operations[0].value); } + + [Fact] + public void PatchNewsletter_ShouldApplyChangesCorrectly() + { + var originalModel = new TestModel + { + Name = "John", + Surname = "Appleseed", + Age = 30 + }; + + var patchDto = new TestPatchDto + { + Name = "John", // Unchanged + Surname = null, // Set to null + Age = 31 // Changed + }; + + var patchDocument = PatchBuilder.Build(new TestPatchDto + { + Name = originalModel.Name, + Surname = originalModel.Surname, + Age = originalModel.Age + }, patchDto); + + Assert.NotNull(patchDocument); + Assert.Equal(2, patchDocument.Operations.Count); // Only 'TemplateName' and 'Age' should change + + var templateNameOperation = patchDocument.Operations.FirstOrDefault(op => op.path == "/Surname"); + var ageOperation = patchDocument.Operations.FirstOrDefault(op => op.path == "/Age"); + + Assert.NotNull(templateNameOperation); + Assert.Null(templateNameOperation.value); // Set to null + + Assert.NotNull(ageOperation); + Assert.Equal(31, ageOperation.value); // Updated to 31 + + patchDocument.ApplyToT(originalModel); + + Assert.Equal("John", originalModel.Name); // Unchanged + Assert.Equal(31, originalModel.Age); // Updated + Assert.Null(originalModel.Surname); // Set to null + } +} + +public class TestPatchDto +{ + public string? Name { get; set; } + public string? Surname { get; set; } + public int? Age { get; set; } +} + + +public class TestModel +{ + public string Name { get; init; } + public string Surname { get; init; } + public int Age { get; init; } } \ No newline at end of file From 346920dab4b13a67748996fdb22a982ab2bc7c44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alan=20Vrani=C4=87?= Date: Thu, 15 Aug 2024 09:25:43 +0200 Subject: [PATCH 3/3] fix: make PatchBuilder static because it is never instantiated --- src/6.0-JsonMergePatch.SystemText/Builders/PatchBuilder.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/6.0-JsonMergePatch.SystemText/Builders/PatchBuilder.cs b/src/6.0-JsonMergePatch.SystemText/Builders/PatchBuilder.cs index f4190e7..ad777ca 100644 --- a/src/6.0-JsonMergePatch.SystemText/Builders/PatchBuilder.cs +++ b/src/6.0-JsonMergePatch.SystemText/Builders/PatchBuilder.cs @@ -5,7 +5,7 @@ namespace Morcatko.AspNetCore.JsonMergePatch.SystemText.Builders { - public class PatchBuilder where TModel : class + public static class PatchBuilder where TModel : class { public static JsonMergePatchDocument Build(TModel original, TModel patched, JsonMergePatchOptions options = null) {