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 e4f55b3..ad777ca 100644 --- a/src/6.0-JsonMergePatch.SystemText/Builders/PatchBuilder.cs +++ b/src/6.0-JsonMergePatch.SystemText/Builders/PatchBuilder.cs @@ -5,6 +5,34 @@ namespace Morcatko.AspNetCore.JsonMergePatch.SystemText.Builders { + public static 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 ? diff.RootElement.Clone() : 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); + } + } + public static class PatchBuilder { private static object ToObject(this JsonElement jsonElement) @@ -67,6 +95,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 +117,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..b8b66ac --- /dev/null +++ b/src/6.0-JsonMergePatch.Tests/PatchBuilderTests.cs @@ -0,0 +1,117 @@ +using System.Linq; +using System.Text.Json; +using Morcatko.AspNetCore.JsonMergePatch.SystemText.Builders; +using Xunit; + +namespace Morcatko.AspNetCore.JsonMergePatch.Tests; + +public class PatchBuilderTests +{ + [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); + } + + [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