Skip to content

Commit 9d497bb

Browse files
authored
Introduce SkipCondition: A possibility to ignore errors while patching (#88)
* Introduce `SkipCondition`: A possibility to ignore errors while patching * format fun
1 parent 7a2a89b commit 9d497bb

File tree

4 files changed

+182
-8
lines changed

4 files changed

+182
-8
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
namespace ChronoJsonDiffPatch;
2+
3+
/// <summary>
4+
/// models cases in which a patch should be skipped / not be applied to an entity
5+
/// </summary>
6+
/// <remarks>
7+
/// Usually this shouldn't be necessary to use if everything is already. But if your data are corrupted somehow, this might be a way to still apply some patches to you data and only skip some instead of all.
8+
/// Use this with caution (but of course we know: as soon as it's possible to ignore errors, we'll use it.
9+
/// The state of the entity after a patch has been skipped is not well-defined.
10+
/// It _might_ be somewhat like what you'd expect but all guarantees are gone.
11+
/// It's the pandora's box of patching you're opening here.
12+
/// Be sure to monitor the state of your entities after applying patches with skipped patches and <see cref="TimeRangePatchChain{TEntity}.PatchesHaveBeenSkipped"/>.
13+
/// </remarks>
14+
/// <typeparam name="TEntity">the entity to which the patches shall be applied</typeparam>
15+
public interface ISkipCondition<in TEntity>
16+
{
17+
/// <summary>
18+
/// If applying <paramref name="failedPatch"/> to <paramref name="initialEntity"/> fails with <paramref name="errorWhilePatching"/>, the patch should be skipped.
19+
/// </summary>
20+
/// <param name="initialEntity">state before applying the patch <paramref name="failedPatch"/> (but not necessarily before apply any patch in the chain)</param>
21+
/// <param name="errorWhilePatching">any error that occurred</param>
22+
/// <param name="failedPatch">the patch that lead to the exception <paramref name="errorWhilePatching"/></param>
23+
/// <returns>true if the patch should be skipped</returns>
24+
public bool ShouldSkipPatch(TEntity initialEntity, TimeRangePatch failedPatch, Exception errorWhilePatching);
25+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
namespace ChronoJsonDiffPatch;
2+
3+
/// <summary>
4+
/// skips patches where an item inside a list is modified or removed that is not present in the initial list (because of corrupted data)
5+
/// </summary>
6+
/// <typeparam name="TEntity">the entity to be patched</typeparam>
7+
/// <typeparam name="TListItem">the items inside the list</typeparam>
8+
public class SkipPatchesWithUnmatchedListItems<TEntity, TListItem> : ISkipCondition<TEntity>
9+
{
10+
private readonly Func<TEntity, List<TListItem>?> _listAccessor;
11+
12+
/// <summary>
13+
/// provide a way to access the list which index is out of range
14+
/// </summary>
15+
/// <param name="listAccessor"></param>
16+
public SkipPatchesWithUnmatchedListItems(Func<TEntity, List<TListItem>?> listAccessor)
17+
{
18+
_listAccessor = listAccessor;
19+
}
20+
21+
/// <summary>
22+
/// <inheritdoc cref="ISkipCondition{TEntity}"/>
23+
/// </summary>
24+
public bool ShouldSkipPatch(TEntity initialEntity, TimeRangePatch failedPatch, Exception errorWhilePatching)
25+
{
26+
if (errorWhilePatching is not ArgumentOutOfRangeException)
27+
{
28+
return false;
29+
}
30+
31+
var list = _listAccessor(initialEntity);
32+
if (list is null)
33+
{
34+
return false;
35+
}
36+
37+
// todo: theoretically I could
38+
// 1. check the json attributes of the list property,
39+
// 2. then inspect the failedPatch.Patch and then
40+
// 3. see if the error _really_ originates from there
41+
// but for now this is a good enough solution
42+
return true;
43+
}
44+
}

ChronoJsonDiffPatch/ChronoJsonDiffPatch/TimeRangePatchChain.cs

+68-8
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,24 @@ protected static TEntity DefaultDeSerializer(string json)
3535

3636
private readonly Func<TEntity, string> _serialize;
3737
private readonly Func<string, TEntity> _deserialize;
38+
private readonly IEnumerable<ISkipCondition<TEntity>>? _skipConditions;
39+
private List<TimeRangePatch> _skippedPatches = new();
40+
41+
/// <summary>
42+
/// patches that have been skipped because of errors
43+
/// </summary>
44+
public IReadOnlyList<TimeRangePatch> SkippedPatches
45+
{
46+
get => _skippedPatches.AsReadOnly();
47+
}
48+
49+
/// <summary>
50+
/// set to true, if, while modifying the chain any of the skip conditions provided to the construct were used.
51+
/// </summary>
52+
public bool PatchesHaveBeenSkipped
53+
{
54+
get => SkippedPatches?.Any() == true;
55+
}
3856

3957
/// <summary>
4058
/// converts the given <paramref name="entity"/> to an JToken using the serializer configured in the constructor (or default)
@@ -77,7 +95,8 @@ private static IEnumerable<TimeRangePatch> PrepareForTimePeriodChainConstructor(
7795

7896
var result = timeperiods.OrderBy(tp => tp.Start);
7997
var ambigousStarts = result.GroupBy(tp => tp.Start).Where(g => g.Count() > 1).Select(g => g.Select(x => x.Start.ToString("o")).First()).Distinct();
80-
var ambigousEnds = result.GroupBy(tp => tp.End).Where(g => g.Count() > 1).Select(g => g.Select(x => x.End.ToString("o")).First()).Distinct(); ;
98+
var ambigousEnds = result.GroupBy(tp => tp.End).Where(g => g.Count() > 1).Select(g => g.Select(x => x.End.ToString("o")).First()).Distinct();
99+
;
81100
bool baseConstructorIsLikelyToCrash = ambigousStarts.Any() || ambigousEnds.Any();
82101
if (baseConstructorIsLikelyToCrash)
83102
{
@@ -88,9 +107,11 @@ private static IEnumerable<TimeRangePatch> PrepareForTimePeriodChainConstructor(
88107
catch (InvalidOperationException invalidOpException) when (invalidOpException.Message.EndsWith("out of range"))
89108
{
90109
// if it would crash and we do know the reasons, then we throw a more meaningful exception here instead of waiting for the base class to crash
91-
throw new ArgumentException($"The given periods contain ambiguous starts ({string.Join(", ", ambigousStarts)}) or ends ({string.Join(", ", ambigousEnds)})", innerException: invalidOpException);
110+
throw new ArgumentException($"The given periods contain ambiguous starts ({string.Join(", ", ambigousStarts)}) or ends ({string.Join(", ", ambigousEnds)})",
111+
innerException: invalidOpException);
92112
}
93113
}
114+
94115
return result;
95116
}
96117

@@ -101,8 +122,10 @@ private static IEnumerable<TimeRangePatch> PrepareForTimePeriodChainConstructor(
101122
/// <param name="patchingDirection">the direction in which the patches are to be applied; <see cref="PatchingDirection"/></param>
102123
/// <param name="serializer">a function that is able to serialize <typeparamref name="TEntity"/></param>
103124
/// <param name="deserializer">a function that is able to deserialize <typeparamref name="TEntity"/></param>
125+
/// <param name="skipConditions">optional conditions under which we allow a patch to fail and ignore its changes</param>
104126
public TimeRangePatchChain(IEnumerable<TimeRangePatch>? timeperiods = null, PatchingDirection patchingDirection = PatchingDirection.ParallelWithTime,
105-
Func<TEntity, string>? serializer = null, Func<string, TEntity>? deserializer = null) : base(PrepareForTimePeriodChainConstructor(timeperiods))
127+
Func<TEntity, string>? serializer = null, Func<string, TEntity>? deserializer = null, IEnumerable<ISkipCondition<TEntity>>? skipConditions = null) : base(
128+
PrepareForTimePeriodChainConstructor(timeperiods))
106129
{
107130
_serialize = serializer ?? DefaultSerializer;
108131
_deserialize = deserializer ?? DefaultDeSerializer;
@@ -112,6 +135,8 @@ public TimeRangePatchChain(IEnumerable<TimeRangePatch>? timeperiods = null, Patc
112135
throw new ArgumentException($"You must not add a patch {patchWithWrongDirection} with direction {patchWithWrongDirection.PatchingDirection}!={PatchingDirection}",
113136
nameof(timeperiods));
114137
}
138+
139+
_skipConditions = skipConditions;
115140
}
116141

117142
/// <summary>
@@ -246,11 +271,13 @@ public void Add(TEntity initialEntity, TEntity changedEntity, DateTimeOffset mom
246271
var jsonStateWithoutMomentPatch = ToJToken(stateWithoutMomentPatch);
247272
var jdpDelta = new JsonDiffPatch();
248273
var jsonStateWithMomentPatch = jdpDelta.Patch(jsonStateWithoutMomentPatch, JsonConvert.DeserializeObject<JToken>(patchToBeAdded.Patch!.RootElement.GetRawText()));
249-
var jsonStateBeforeMomentPatch = ToJToken(artificialChainWithoutTheRecentlyAddedPatch.PatchToDate(initialEntity, infinitelyNarrowPatch.Start - TimeSpan.FromTicks(1)));
274+
var jsonStateBeforeMomentPatch =
275+
ToJToken(artificialChainWithoutTheRecentlyAddedPatch.PatchToDate(initialEntity, infinitelyNarrowPatch.Start - TimeSpan.FromTicks(1)));
250276
var jdpDelta2 = new JsonDiffPatch();
251277
var result = jdpDelta2.Diff(jsonStateBeforeMomentPatch, jsonStateWithMomentPatch);
252278
patchToBeAdded.Patch = ToJsonDocument(result);
253279
}
280+
254281
Remove(infinitelyNarrowPatch);
255282
}
256283
}
@@ -269,6 +296,7 @@ private void AddAndKeepTheFuture(TEntity initialEntity, TimeRangePatch patchToBe
269296
var patchAtKeyDate = ToJsonDocument(new JsonDiffPatch().Diff(stateJustBeforeThePatch, changedToken));
270297
patchToBeAdded.Patch = patchAtKeyDate;
271298
}
299+
272300
entryWhosePatchShouldBeReplaced.Patch = patchToBeAdded.Patch;
273301

274302
// we also need to modify the following entry.
@@ -385,10 +413,12 @@ private void AddAndOverrideTheFuture(TimeRangePatch patchToBeAdded)
385413
{
386414
RemoveAt(futureIndex);
387415
}
416+
388417
if (GetAll().LastOrDefault(p => p.OverlapsWith(patchToBeAdded)) is { } lastOverlappingPatchWhichIsNotDeleted)
389418
{
390419
lastOverlappingPatchWhichIsNotDeleted.ShrinkEndTo(patchToBeAdded.Start);
391420
}
421+
392422
Add(patchToBeAdded);
393423
}
394424

@@ -405,6 +435,7 @@ public TEntity PatchToDate(TEntity initialEntity, DateTimeOffset keyDate)
405435
{
406436
var jdp = new JsonDiffPatch();
407437
var left = ToJToken(initialEntity);
438+
_skippedPatches = new();
408439
switch (PatchingDirection)
409440
{
410441
case PatchingDirection.ParallelWithTime:
@@ -414,7 +445,21 @@ public TEntity PatchToDate(TEntity initialEntity, DateTimeOffset keyDate)
414445
.Where(p => p.Patch!.RootElement.ValueKind != System.Text.Json.JsonValueKind.Null))
415446
{
416447
var jtokenPatch = JsonConvert.DeserializeObject<JToken>(existingPatch.Patch!.RootElement.GetRawText());
417-
left = jdp.Patch(left, jtokenPatch);
448+
try
449+
{
450+
left = jdp.Patch(left, jtokenPatch);
451+
}
452+
catch (Exception exc) when (_skipConditions?.Any() == true)
453+
{
454+
var entityBeforePatch = _deserialize(left.ToString());
455+
if (_skipConditions?.Any(sc => sc.ShouldSkipPatch(entityBeforePatch, existingPatch, exc)) == true)
456+
{
457+
_skippedPatches.Add(existingPatch);
458+
continue;
459+
}
460+
461+
throw; // re-throw
462+
}
418463
}
419464

420465
return _deserialize(JsonConvert.SerializeObject(left));
@@ -426,7 +471,21 @@ public TEntity PatchToDate(TEntity initialEntity, DateTimeOffset keyDate)
426471
.Where(p => p.Patch != null && p.Patch!.RootElement.ValueKind != System.Text.Json.JsonValueKind.Null))
427472
{
428473
var jtokenPatch = JsonConvert.DeserializeObject<JToken>(existingPatch.Patch!.RootElement.GetRawText());
429-
left = jdp.Unpatch(left, jtokenPatch);
474+
try
475+
{
476+
left = jdp.Unpatch(left, jtokenPatch);
477+
}
478+
catch (Exception exc) when (_skipConditions?.Any() == true)
479+
{
480+
var entityBeforePatch = _deserialize(left.ToString());
481+
if (_skipConditions?.Any(sc => sc.ShouldSkipPatch(entityBeforePatch, existingPatch, exc)) == true)
482+
{
483+
_skippedPatches.Add(existingPatch);
484+
continue;
485+
}
486+
487+
throw; // re-throw
488+
}
430489
}
431490

432491
return _deserialize(JsonConvert.SerializeObject(left));
@@ -475,7 +534,7 @@ public Tuple<TEntity, TimeRangePatchChain<TEntity>> Reverse(TEntity initialEntit
475534
return backwardsTrp;
476535
}).OrderBy(trp => trp.From).ToList();
477536
return new Tuple<TEntity, TimeRangePatchChain<TEntity>>(stateAtPlusInfinity,
478-
new TimeRangePatchChain<TEntity>(backwardsPatches, PatchingDirection.AntiParallelWithTime));
537+
new TimeRangePatchChain<TEntity>(backwardsPatches, PatchingDirection.AntiParallelWithTime, skipConditions: _skipConditions));
479538
}
480539
case PatchingDirection.AntiParallelWithTime:
481540
{
@@ -488,7 +547,8 @@ public Tuple<TEntity, TimeRangePatchChain<TEntity>> Reverse(TEntity initialEntit
488547
patchingDirection: PatchingDirection.ParallelWithTime);
489548
return forwardsTrp;
490549
}).OrderBy(trp => trp.From).ToList();
491-
return new Tuple<TEntity, TimeRangePatchChain<TEntity>>(stateAtMinusInfinity, new TimeRangePatchChain<TEntity>(forwardPatches, PatchingDirection.ParallelWithTime));
550+
return new Tuple<TEntity, TimeRangePatchChain<TEntity>>(stateAtMinusInfinity,
551+
new TimeRangePatchChain<TEntity>(forwardPatches, PatchingDirection.ParallelWithTime, skipConditions: _skipConditions));
492552
}
493553
default:
494554
throw new ArgumentOutOfRangeException();

ChronoJsonDiffPatch/ChronoJsonDiffPatchTests/ListPatchingTests.cs

+45
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,51 @@ public void Reproduce_ArgumentOutOfRangeException()
118118
corruptedInitialEntity.MyList.RemoveAt(1);
119119
var applyingPatchesToACorruptedInitialEntity = () => antiparallelChain.PatchToDate(corruptedInitialEntity, keyDate1 - TimeSpan.FromDays(10));
120120
applyingPatchesToACorruptedInitialEntity.Should().ThrowExactly<ArgumentOutOfRangeException>();
121+
antiparallelChain.PatchesHaveBeenSkipped.Should().BeFalse();
122+
}
123+
124+
/// <summary>
125+
/// Shows that the error from <see cref="Reproduce_ArgumentOutOfRangeException"/> can be surpressed using a <see cref="ISkipCondition{TEntity}"/>.
126+
/// </summary>
127+
[Fact]
128+
public void Test_ArgumentOutOfRangeException_Can_Be_Surpressed()
129+
{
130+
var chain = new TimeRangePatchChain<EntityWithList>(skipConditions: new List<ISkipCondition<EntityWithList>>
131+
{ new SkipPatchesWithUnmatchedListItems<EntityWithList, ListItem>(x => x.MyList) });
132+
var initialEntity = new EntityWithList
133+
{
134+
MyList = new List<ListItem>
135+
{
136+
new() { Value = "My First Value" },
137+
}
138+
};
139+
var keyDate1 = new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero);
140+
{
141+
var updatedEntity1 = new EntityWithList
142+
{
143+
MyList = new List<ListItem>
144+
{
145+
new() { Value = "My First Value" },
146+
new() { Value = "My Second Value" }
147+
}
148+
};
149+
150+
chain.Add(initialEntity, updatedEntity1, keyDate1);
151+
chain.Count.Should().Be(2); // [-infinity, keyDate1); [keyDate1, +infinity)
152+
ReverseAndRevert(chain, initialEntity);
153+
}
154+
(var antiparallelInitialEntity, var antiparallelChain) = chain.Reverse(initialEntity);
155+
antiparallelInitialEntity.Should().Match<EntityWithList>(x => x.MyList.Count == 2, because: "Initially the list had 2 items");
156+
var patchingACorrectInitialEntity = () => antiparallelChain.PatchToDate(antiparallelInitialEntity, keyDate1 - TimeSpan.FromDays(10));
157+
patchingACorrectInitialEntity.Should().NotThrow();
158+
159+
var corruptedInitialEntity =
160+
antiparallelInitialEntity; // we modify the reference here, but that's fine. We improve the readability but don't re-use the antiparallelInitialEntity anywhere downstream.
161+
corruptedInitialEntity.MyList.RemoveAt(1);
162+
var applyingPatchesToACorruptedInitialEntity = () => antiparallelChain.PatchToDate(corruptedInitialEntity, keyDate1 - TimeSpan.FromDays(10));
163+
applyingPatchesToACorruptedInitialEntity.Should().NotThrow()
164+
.And.Subject.Invoke().Should().BeEquivalentTo(corruptedInitialEntity);
165+
antiparallelChain.PatchesHaveBeenSkipped.Should().BeTrue();
121166
}
122167

123168
private static void ReverseAndRevert(TimeRangePatchChain<EntityWithList> chain, EntityWithList initialEntity)

0 commit comments

Comments
 (0)