Skip to content

Commit eb7ed67

Browse files
committed
Replace SystemTextJsonPatch
1 parent 1fc1a8b commit eb7ed67

File tree

9 files changed

+213
-26
lines changed

9 files changed

+213
-26
lines changed

Directory.packages.props

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
<PackageVersion Include="Microsoft.Data.SqlClient" Version="6.0.2"/>
1010
<PackageVersion Include="MySqlConnector" Version="2.4.0"/>
1111
<PackageVersion Include="Npgsql" Version="9.0.3"/>
12-
<PackageVersion Include="SystemTextJsonPatch" Version="4.2.0"/>
1312
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.3"/>
1413
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1"/>
1514
<PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.3"/>

samples/Sample/Program.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -127,12 +127,12 @@
127127
var orderPatch = await store.GetDiff("ord-1", proposedOrder);
128128
Console.WriteLine($"Patch operations vs stored ord-1 ({orderPatch!.Operations.Count} changes):");
129129
foreach (var op in orderPatch.Operations)
130-
Console.WriteLine($" {op.OperationType} {op.Path}{op.Value}");
130+
Console.WriteLine($" {op.Op} {op.Path}{op.Value}");
131131

132132
// Apply the patch to a fresh copy
133133
var freshOrder = await store.Get<Order>("ord-1");
134-
orderPatch.ApplyTo(freshOrder!);
135-
Console.WriteLine($"After applying: Status={freshOrder.Status}, City={freshOrder.ShippingAddress.City}, Lines={freshOrder.Lines.Count}, Tags=[{string.Join(",", freshOrder.Tags)}]");
134+
var patchedOrder = orderPatch.ApplyTo(freshOrder!);
135+
Console.WriteLine($"After applying: Status={patchedOrder.Status}, City={patchedOrder.ShippingAddress.City}, Lines={patchedOrder.Lines.Count}, Tags=[{string.Join(",", patchedOrder.Tags)}]");
136136
Console.WriteLine();
137137

138138
// ═══════════════════════════════════════════════════════════════════

src/Shiny.DocumentDb/DocumentStore.cs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
using System.Text.Json.Nodes;
88
using System.Text.Json.Serialization.Metadata;
99
using Shiny.DocumentDb.Internal;
10-
using SystemTextJsonPatch;
1110

1211
namespace Shiny.DocumentDb;
1312

src/Shiny.DocumentDb/IDocumentStore.cs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
using System.Linq.Expressions;
22
using System.Text.Json.Serialization.Metadata;
3-
using SystemTextJsonPatch;
43

54
namespace Shiny.DocumentDb;
65

src/Shiny.DocumentDb/Internal/JsonDiff.cs

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
using System.Text.Json;
22
using System.Text.Json.Nodes;
3-
using SystemTextJsonPatch;
4-
using SystemTextJsonPatch.Operations;
53

64
namespace Shiny.DocumentDb.Internal;
75

@@ -17,17 +15,16 @@ public static JsonPatchDocument<T> CreatePatch<T>(
1715
var modified = JsonNode.Parse(modifiedJson)?.AsObject()
1816
?? throw new InvalidOperationException("Modified document JSON is not a valid object.");
1917

20-
var patch = new JsonPatchDocument<T>();
21-
patch.Options = options;
22-
BuildDiff(original, modified, "", patch.Operations);
23-
return patch;
18+
var operations = new List<JsonPatchOperation>();
19+
BuildDiff(original, modified, "", operations);
20+
return new JsonPatchDocument<T>(operations, options);
2421
}
2522

26-
static void BuildDiff<T>(
23+
static void BuildDiff(
2724
JsonObject original,
2825
JsonObject modified,
2926
string prefix,
30-
List<Operation<T>> operations) where T : class
27+
List<JsonPatchOperation> operations)
3128
{
3229
foreach (var prop in modified)
3330
{
@@ -36,11 +33,11 @@ static void BuildDiff<T>(
3633

3734
if (origValue is null && prop.Value is not null)
3835
{
39-
operations.Add(new Operation<T>("add", path, null, ToJsonElement(prop.Value)));
36+
operations.Add(JsonPatchOperation.Add(path, ToJsonElement(prop.Value)));
4037
}
4138
else if (prop.Value is null && origValue is not null)
4239
{
43-
operations.Add(new Operation<T>("replace", path, null, null));
40+
operations.Add(JsonPatchOperation.Replace(path, null));
4441
}
4542
else if (origValue is not null && prop.Value is not null)
4643
{
@@ -50,7 +47,7 @@ static void BuildDiff<T>(
5047
}
5148
else if (!JsonNode.DeepEquals(origValue, prop.Value))
5249
{
53-
operations.Add(new Operation<T>("replace", path, null, ToJsonElement(prop.Value)));
50+
operations.Add(JsonPatchOperation.Replace(path, ToJsonElement(prop.Value)));
5451
}
5552
}
5653
}
@@ -59,7 +56,7 @@ static void BuildDiff<T>(
5956
{
6057
if (!modified.ContainsKey(prop.Key))
6158
{
62-
operations.Add(new Operation<T>("remove", prefix + "/" + prop.Key, null));
59+
operations.Add(JsonPatchOperation.Remove(prefix + "/" + prop.Key));
6360
}
6461
}
6562
}
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
using System.Diagnostics.CodeAnalysis;
2+
using System.Text.Json;
3+
using System.Text.Json.Nodes;
4+
using System.Text.Json.Serialization.Metadata;
5+
6+
namespace Shiny.DocumentDb;
7+
8+
public sealed class JsonPatchDocument<T> where T : class
9+
{
10+
readonly JsonSerializerOptions? options;
11+
12+
public IReadOnlyList<JsonPatchOperation> Operations { get; }
13+
14+
public JsonPatchDocument(IReadOnlyList<JsonPatchOperation> operations, JsonSerializerOptions? options = null)
15+
{
16+
Operations = operations;
17+
this.options = options;
18+
}
19+
20+
public T ApplyTo(T target, JsonTypeInfo<T> typeInfo)
21+
{
22+
var node = JsonSerializer.SerializeToNode(target, typeInfo)
23+
?? throw new InvalidOperationException("Serialization produced null.");
24+
ApplyOperations(node);
25+
return JsonSerializer.Deserialize(node, typeInfo)
26+
?? throw new InvalidOperationException("Deserialization produced null.");
27+
}
28+
29+
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Reflection path only used when JsonTypeInfo is not provided.")]
30+
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Reflection path only used when JsonTypeInfo is not provided.")]
31+
public T ApplyTo(T target, JsonSerializerOptions? options = null)
32+
{
33+
var opts = options ?? this.options;
34+
var node = JsonSerializer.SerializeToNode(target, opts)
35+
?? throw new InvalidOperationException("Serialization produced null.");
36+
ApplyOperations(node);
37+
return JsonSerializer.Deserialize<T>(node, opts!)
38+
?? throw new InvalidOperationException("Deserialization produced null.");
39+
}
40+
41+
void ApplyOperations(JsonNode root)
42+
{
43+
foreach (var op in Operations)
44+
ApplyOp(root, op);
45+
}
46+
47+
static void ApplyOp(JsonNode root, JsonPatchOperation op)
48+
{
49+
var segments = ParsePath(op.Path);
50+
switch (op.Op)
51+
{
52+
case "add":
53+
case "replace":
54+
SetValue(root, segments, op.Value);
55+
break;
56+
case "remove":
57+
RemoveValue(root, segments);
58+
break;
59+
case "copy":
60+
var sourceVal = GetValue(root, ParsePath(op.From!));
61+
SetValue(root, segments, sourceVal != null
62+
? JsonDocument.Parse(sourceVal.ToJsonString()).RootElement.Clone()
63+
: null);
64+
break;
65+
case "move":
66+
var moveVal = GetValue(root, ParsePath(op.From!));
67+
RemoveValue(root, ParsePath(op.From!));
68+
SetValue(root, segments, moveVal != null
69+
? JsonDocument.Parse(moveVal.ToJsonString()).RootElement.Clone()
70+
: null);
71+
break;
72+
case "test":
73+
var actual = GetValue(root, segments);
74+
var expected = op.Value.HasValue
75+
? JsonNode.Parse(op.Value.Value.GetRawText())
76+
: null;
77+
if (!JsonNode.DeepEquals(actual, expected))
78+
throw new InvalidOperationException($"Test operation failed for path '{op.Path}'.");
79+
break;
80+
default:
81+
throw new NotSupportedException($"Unsupported patch operation: {op.Op}");
82+
}
83+
}
84+
85+
static string[] ParsePath(string path)
86+
{
87+
if (string.IsNullOrEmpty(path) || path == "/")
88+
return [];
89+
90+
// RFC 6901: skip leading '/'
91+
return path[1..].Split('/');
92+
}
93+
94+
static JsonNode? GetValue(JsonNode root, string[] segments)
95+
{
96+
var current = root;
97+
foreach (var seg in segments)
98+
{
99+
if (current is JsonObject obj)
100+
current = obj[seg];
101+
else if (current is JsonArray arr && int.TryParse(seg, out var idx))
102+
current = arr[idx];
103+
else
104+
return null;
105+
}
106+
return current;
107+
}
108+
109+
static void SetValue(JsonNode root, string[] segments, JsonElement? value)
110+
{
111+
if (segments.Length == 0)
112+
throw new InvalidOperationException("Cannot set the root node.");
113+
114+
var parent = NavigateToParent(root, segments);
115+
var key = segments[^1];
116+
var nodeValue = value.HasValue ? JsonNode.Parse(value.Value.GetRawText()) : null;
117+
118+
if (parent is JsonObject obj)
119+
{
120+
obj[key] = nodeValue;
121+
}
122+
else if (parent is JsonArray arr && int.TryParse(key, out var idx))
123+
{
124+
if (idx == arr.Count)
125+
arr.Add(nodeValue);
126+
else
127+
arr[idx] = nodeValue;
128+
}
129+
}
130+
131+
static void RemoveValue(JsonNode root, string[] segments)
132+
{
133+
if (segments.Length == 0)
134+
throw new InvalidOperationException("Cannot remove the root node.");
135+
136+
var parent = NavigateToParent(root, segments);
137+
var key = segments[^1];
138+
139+
if (parent is JsonObject obj)
140+
obj.Remove(key);
141+
else if (parent is JsonArray arr && int.TryParse(key, out var idx))
142+
arr.RemoveAt(idx);
143+
}
144+
145+
static JsonNode NavigateToParent(JsonNode root, string[] segments)
146+
{
147+
var current = root;
148+
for (var i = 0; i < segments.Length - 1; i++)
149+
{
150+
var seg = segments[i];
151+
if (current is JsonObject obj)
152+
current = obj[seg] ?? throw new InvalidOperationException($"Path segment '{seg}' not found.");
153+
else if (current is JsonArray arr && int.TryParse(seg, out var idx))
154+
current = arr[idx] ?? throw new InvalidOperationException($"Array index '{idx}' not found.");
155+
else
156+
throw new InvalidOperationException($"Cannot navigate path segment '{seg}'.");
157+
}
158+
return current;
159+
}
160+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
using System.Text.Json;
2+
3+
namespace Shiny.DocumentDb;
4+
5+
public sealed class JsonPatchOperation
6+
{
7+
public string Op { get; }
8+
public string Path { get; }
9+
public string? From { get; }
10+
public JsonElement? Value { get; }
11+
12+
JsonPatchOperation(string op, string path, string? from, JsonElement? value)
13+
{
14+
Op = op;
15+
Path = path;
16+
From = from;
17+
Value = value;
18+
}
19+
20+
public static JsonPatchOperation Add(string path, JsonElement? value)
21+
=> new("add", path, null, value);
22+
23+
public static JsonPatchOperation Replace(string path, JsonElement? value)
24+
=> new("replace", path, null, value);
25+
26+
public static JsonPatchOperation Remove(string path)
27+
=> new("remove", path, null, null);
28+
29+
public static JsonPatchOperation Copy(string from, string path)
30+
=> new("copy", path, from, null);
31+
32+
public static JsonPatchOperation Move(string from, string path)
33+
=> new("move", path, from, null);
34+
35+
public static JsonPatchOperation Test(string path, JsonElement? value)
36+
=> new("test", path, null, value);
37+
}

src/Shiny.DocumentDb/Shiny.DocumentDb.csproj

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,4 @@
55
<PackageTags>documentdb;json;nosql;shiny</PackageTags>
66
</PropertyGroup>
77

8-
<ItemGroup>
9-
<PackageReference Include="SystemTextJsonPatch" />
10-
</ItemGroup>
118
</Project>

tests/Shiny.DocumentDb.Tests/PatchDocumentTests.cs

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
using Shiny.DocumentDb.Tests.Fixtures;
2-
using SystemTextJsonPatch;
32
using Xunit;
43

54
namespace Shiny.DocumentDb.Tests;
@@ -153,11 +152,11 @@ public async Task GetDiff_PatchCanBeApplied()
153152
// Get stored doc and apply patch
154153
var stored = await this.store.Get<User>("user-1");
155154
Assert.NotNull(stored);
156-
patch.ApplyTo(stored);
155+
var patched = patch.ApplyTo(stored);
157156

158-
Assert.Equal("Bob", stored.Name);
159-
Assert.Equal(35, stored.Age);
160-
Assert.Equal("bob@test.com", stored.Email);
157+
Assert.Equal("Bob", patched.Name);
158+
Assert.Equal(35, patched.Age);
159+
Assert.Equal("bob@test.com", patched.Email);
161160
}
162161

163162
[Fact]

0 commit comments

Comments
 (0)