diff --git a/ChronoJsonDiffPatch/ChronoJsonDiffPatch/SkipCondition.cs b/ChronoJsonDiffPatch/ChronoJsonDiffPatch/SkipCondition.cs index 105d25e..5989954 100644 --- a/ChronoJsonDiffPatch/ChronoJsonDiffPatch/SkipCondition.cs +++ b/ChronoJsonDiffPatch/ChronoJsonDiffPatch/SkipCondition.cs @@ -19,11 +19,11 @@ public interface ISkipCondition /// /// state before applying the patch (but not necessarily before apply any patch in the chain) /// any error that occurred - /// the patch that lead to the exception + /// the patch that lead to the exception ; null if the error occurred during final deserialization after all patches were applied /// true if the patch should be skipped public bool ShouldSkipPatch( TEntity initialEntity, - TimeRangePatch failedPatch, + TimeRangePatch? failedPatch, Exception errorWhilePatching ); } diff --git a/ChronoJsonDiffPatch/ChronoJsonDiffPatch/SkipPatchesWithUnmatchedListItems.cs b/ChronoJsonDiffPatch/ChronoJsonDiffPatch/SkipPatchesWithUnmatchedListItems.cs index 7b11c4b..d1ae46f 100644 --- a/ChronoJsonDiffPatch/ChronoJsonDiffPatch/SkipPatchesWithUnmatchedListItems.cs +++ b/ChronoJsonDiffPatch/ChronoJsonDiffPatch/SkipPatchesWithUnmatchedListItems.cs @@ -23,7 +23,7 @@ public SkipPatchesWithUnmatchedListItems(Func?> listAcc /// public virtual bool ShouldSkipPatch( TEntity initialEntity, - TimeRangePatch failedPatch, + TimeRangePatch? failedPatch, Exception errorWhilePatching ) { diff --git a/ChronoJsonDiffPatch/ChronoJsonDiffPatch/TimeRangePatchChain.cs b/ChronoJsonDiffPatch/ChronoJsonDiffPatch/TimeRangePatchChain.cs index 102e555..97d79dc 100644 --- a/ChronoJsonDiffPatch/ChronoJsonDiffPatch/TimeRangePatchChain.cs +++ b/ChronoJsonDiffPatch/ChronoJsonDiffPatch/TimeRangePatchChain.cs @@ -52,9 +52,22 @@ public IReadOnlyList SkippedPatches /// public bool PatchesHaveBeenSkipped { - get => SkippedPatches?.Any() == true; + get => SkippedPatches?.Any() == true || FinalDeserializationFailed; } + /// + /// Set to true if the final deserialization of the accumulated JToken back to + /// failed after all patches were applied. This means the accumulated patch result was structurally invalid + /// for the target type, and the returned entity is the unpatched initial entity. + /// + /// + /// When this is true, returns initialEntity as-is. + /// In mode, initialEntity is the state at +infinity + /// (the "current" state), NOT the historical state at the requested key date. + /// In mode, initialEntity is the state at -infinity. + /// + public bool FinalDeserializationFailed { get; private set; } + /// /// converts the given to an JToken using the serializer configured in the constructor (or default) /// @@ -556,6 +569,7 @@ private JToken ApplyPatchesToDate(TEntity initialEntity, DateTimeOffset keyDate) var jdp = new JsonDiffPatch(); var left = ToJToken(initialEntity); _skippedPatches = new(); + FinalDeserializationFailed = false; switch (PatchingDirection) { @@ -670,11 +684,30 @@ var existingPatch in GetAll() /// the state of at the beginning of time /// the date up to which you'd like to apply the patches /// the state of the entity after all the patches up to have been applied - [Pure] public TEntity PatchToDate(TEntity initialEntity, DateTimeOffset keyDate) { var left = ApplyPatchesToDate(initialEntity, keyDate); - return _deserialize(JsonConvert.SerializeObject(left)); + try + { + return _deserialize(JsonConvert.SerializeObject(left)); + } + catch (Exception exc) + { + if ( + _skipConditions?.Any(sc => + sc.ShouldSkipPatch(initialEntity, failedPatch: null, exc) + ) == true + ) + { + // The final deserialization failed, but skip conditions say we should tolerate it. + // Return the initial entity as-is because the accumulated patches produced an invalid state. + // Callers should check FinalDeserializationFailed to detect this. + FinalDeserializationFailed = true; + return initialEntity; + } + + throw; + } } /// @@ -697,7 +730,27 @@ public void PatchToDate(TEntity initialEntity, DateTimeOffset keyDate, TEntity t } var left = ApplyPatchesToDate(initialEntity, keyDate); - _populateEntity(JsonConvert.SerializeObject(left), targetEntity); + try + { + _populateEntity(JsonConvert.SerializeObject(left), targetEntity); + } + catch (Exception exc) + { + if ( + _skipConditions?.Any(sc => + sc.ShouldSkipPatch(initialEntity, failedPatch: null, exc) + ) == true + ) + { + // The final population failed, but skip conditions say we should tolerate it. + // Leave targetEntity unchanged because the accumulated patches produced an invalid state. + // Callers should check FinalDeserializationFailed to detect this. + FinalDeserializationFailed = true; + return; + } + + throw; + } } /// diff --git a/ChronoJsonDiffPatch/ChronoJsonDiffPatchTests/ListPatchingTests.cs b/ChronoJsonDiffPatch/ChronoJsonDiffPatchTests/ListPatchingTests.cs index b0257ce..b7c6a1c 100644 --- a/ChronoJsonDiffPatch/ChronoJsonDiffPatchTests/ListPatchingTests.cs +++ b/ChronoJsonDiffPatch/ChronoJsonDiffPatchTests/ListPatchingTests.cs @@ -193,6 +193,151 @@ public void Test_ArgumentOutOfRangeException_Can_Be_Surpressed() antiparallelChain.PatchesHaveBeenSkipped.Should().BeTrue(); } + /// + /// Reproduces a scenario where the final deserialization after patching fails (e.g. because the + /// accumulated JToken has a structurally invalid list), and verifies that WITHOUT skip conditions + /// the exception propagates to the caller. + /// + /// + /// This simulates a production issue (DEV-107694 in TechnicalMasterData) where: + /// - The initial entity had a null list (because of a missing EF Core Include) + /// - Patches that added/modified list items were applied to the null JToken + /// - The accumulated JToken had a structurally invalid list representation + /// - The final System.Text.Json deserialization threw a JsonException + /// + /// The custom deserializer here simulates this by always throwing when deserializing. + /// + [Fact] + public void PatchToDate_Without_SkipConditions_Throws_When_Final_Deserialization_Fails() + { + // Build a valid chain first with the default deserializer + var buildChain = new TimeRangePatchChain(); + var initialEntity = new EntityWithList + { + MyList = new List { new() { Value = "A" } }, + }; + var keyDate = new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero); + var updatedEntity = new EntityWithList + { + MyList = new List + { + new() { Value = "A" }, + new() { Value = "B" }, + }, + }; + buildChain.Add(initialEntity, updatedEntity, keyDate); + + // Extract raw patches and create a chain with a broken deserializer + var patches = buildChain.GetAll().ToList(); + var chainWithBrokenDeserializer = new TimeRangePatchChain( + patches, + deserializer: _ => + throw new System.Text.Json.JsonException( + "The JSON value could not be converted to List`1" + ) + ); + + var act = () => + chainWithBrokenDeserializer.PatchToDate(initialEntity, keyDate + TimeSpan.FromDays(1)); + + act.Should().ThrowExactly(); + chainWithBrokenDeserializer.FinalDeserializationFailed.Should().BeFalse(); + } + + /// + /// Verifies that when the final deserialization fails AND skip conditions are configured, + /// the error is caught and the initial entity is returned. + /// Also verifies that is set. + /// + [Fact] + public void PatchToDate_With_SkipConditions_Returns_InitialEntity_When_Final_Deserialization_Fails() + { + // Build a valid chain first with the default deserializer + var buildChain = new TimeRangePatchChain(); + var initialEntity = new EntityWithList + { + MyList = new List { new() { Value = "A" } }, + }; + var keyDate = new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero); + var updatedEntity = new EntityWithList + { + MyList = new List + { + new() { Value = "A" }, + new() { Value = "B" }, + }, + }; + buildChain.Add(initialEntity, updatedEntity, keyDate); + + // Extract raw patches and create a chain with a broken deserializer + skip condition + var patches = buildChain.GetAll().ToList(); + var chainWithBrokenDeserializer = new TimeRangePatchChain( + patches, + deserializer: _ => + throw new System.Text.Json.JsonException( + "The JSON value could not be converted to List`1" + ), + skipConditions: new List> + { + new IgnoreAllSkipCondition(), + } + ); + + var result = chainWithBrokenDeserializer.PatchToDate( + initialEntity, + keyDate + TimeSpan.FromDays(1) + ); + + result.Should().BeSameAs(initialEntity); + chainWithBrokenDeserializer.PatchesHaveBeenSkipped.Should().BeTrue(); + chainWithBrokenDeserializer.FinalDeserializationFailed.Should().BeTrue(); + } + + /// + /// Verifies that is reset + /// between calls to PatchToDate. + /// + [Fact] + public void FinalDeserializationFailed_Is_Reset_Between_Calls() + { + var chain = new TimeRangePatchChain(); + var initialEntity = new EntityWithList + { + MyList = new List { new() { Value = "A" } }, + }; + var keyDate = new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero); + chain.Add( + initialEntity, + new EntityWithList + { + MyList = new List + { + new() { Value = "A" }, + new() { Value = "B" }, + }, + }, + keyDate + ); + + // Normal patching should NOT set FinalDeserializationFailed + var result = chain.PatchToDate(initialEntity, keyDate + TimeSpan.FromDays(1)); + chain.FinalDeserializationFailed.Should().BeFalse(); + result.MyList.Should().HaveCount(2); + } + + /// + /// A skip condition that always returns true for any error. + /// This simulates IgnoreEverythingSkipCondition from downstream consumers. + /// + private class IgnoreAllSkipCondition : ISkipCondition + { + public bool ShouldSkipPatch( + EntityWithList initialEntity, + TimeRangePatch? failedPatch, + Exception errorWhilePatching + ) => true; + } + private static void ReverseAndRevert( TimeRangePatchChain chain, EntityWithList initialEntity