Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions ChronoJsonDiffPatch/ChronoJsonDiffPatch/SkipCondition.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,11 @@ public interface ISkipCondition<in TEntity>
/// </summary>
/// <param name="initialEntity">state before applying the patch <paramref name="failedPatch"/> (but not necessarily before apply any patch in the chain)</param>
/// <param name="errorWhilePatching">any error that occurred</param>
/// <param name="failedPatch">the patch that lead to the exception <paramref name="errorWhilePatching"/></param>
/// <param name="failedPatch">the patch that lead to the exception <paramref name="errorWhilePatching"/>; null if the error occurred during final deserialization after all patches were applied</param>
/// <returns>true if the patch should be skipped</returns>
public bool ShouldSkipPatch(
TEntity initialEntity,
TimeRangePatch failedPatch,
TimeRangePatch? failedPatch,
Exception errorWhilePatching
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ public SkipPatchesWithUnmatchedListItems(Func<TEntity, List<TListItem>?> listAcc
/// </summary>
public virtual bool ShouldSkipPatch(
TEntity initialEntity,
TimeRangePatch failedPatch,
TimeRangePatch? failedPatch,
Exception errorWhilePatching
)
{
Expand Down
61 changes: 57 additions & 4 deletions ChronoJsonDiffPatch/ChronoJsonDiffPatch/TimeRangePatchChain.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,22 @@ public IReadOnlyList<TimeRangePatch> SkippedPatches
/// </summary>
public bool PatchesHaveBeenSkipped
{
get => SkippedPatches?.Any() == true;
get => SkippedPatches?.Any() == true || FinalDeserializationFailed;
}

/// <summary>
/// Set to true if the final deserialization of the accumulated JToken back to <typeparamref name="TEntity"/>
/// 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.
/// </summary>
/// <remarks>
/// When this is true, <see cref="PatchToDate(TEntity, DateTimeOffset)"/> returns <c>initialEntity</c> as-is.
/// In <see cref="PatchingDirection.AntiParallelWithTime"/> mode, <c>initialEntity</c> is the state at +infinity
/// (the "current" state), NOT the historical state at the requested key date.
/// In <see cref="PatchingDirection.ParallelWithTime"/> mode, <c>initialEntity</c> is the state at -infinity.
/// </remarks>
public bool FinalDeserializationFailed { get; private set; }

/// <summary>
/// converts the given <paramref name="entity"/> to an JToken using the serializer configured in the constructor (or default)
/// </summary>
Expand Down Expand Up @@ -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)
{
Expand Down Expand Up @@ -670,11 +684,30 @@ var existingPatch in GetAll()
/// <param name="initialEntity">the state of <typeparamref name="TEntity"/> at the beginning of time</param>
/// <param name="keyDate">the date up to which you'd like to apply the patches</param>
/// <returns>the state of the entity after all the patches up to <paramref name="keyDate"/> have been applied</returns>
[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;
}
}

/// <summary>
Expand All @@ -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;
}
}

/// <summary>
Expand Down
145 changes: 145 additions & 0 deletions ChronoJsonDiffPatch/ChronoJsonDiffPatchTests/ListPatchingTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,13 @@
internal record ListItem
{
[JsonPropertyName("value")]
public string Value { get; set; }

Check warning on line 12 in ChronoJsonDiffPatch/ChronoJsonDiffPatchTests/ListPatchingTests.cs

View workflow job for this annotation

GitHub Actions / unittest (9)

Non-nullable property 'Value' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable.

Check warning on line 12 in ChronoJsonDiffPatch/ChronoJsonDiffPatchTests/ListPatchingTests.cs

View workflow job for this annotation

GitHub Actions / unittest (10)

Non-nullable property 'Value' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable.

Check warning on line 12 in ChronoJsonDiffPatch/ChronoJsonDiffPatchTests/ListPatchingTests.cs

View workflow job for this annotation

GitHub Actions / unittest (9)

Non-nullable property 'Value' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable.

Check warning on line 12 in ChronoJsonDiffPatch/ChronoJsonDiffPatchTests/ListPatchingTests.cs

View workflow job for this annotation

GitHub Actions / unittest (10)

Non-nullable property 'Value' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable.

Check warning on line 12 in ChronoJsonDiffPatch/ChronoJsonDiffPatchTests/ListPatchingTests.cs

View workflow job for this annotation

GitHub Actions / coverage

Non-nullable property 'Value' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable.
}

internal record EntityWithList
{
[JsonPropertyName("myList")]
public List<ListItem> MyList { get; set; }

Check warning on line 18 in ChronoJsonDiffPatch/ChronoJsonDiffPatchTests/ListPatchingTests.cs

View workflow job for this annotation

GitHub Actions / unittest (9)

Non-nullable property 'MyList' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable.

Check warning on line 18 in ChronoJsonDiffPatch/ChronoJsonDiffPatchTests/ListPatchingTests.cs

View workflow job for this annotation

GitHub Actions / unittest (10)

Non-nullable property 'MyList' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable.

Check warning on line 18 in ChronoJsonDiffPatch/ChronoJsonDiffPatchTests/ListPatchingTests.cs

View workflow job for this annotation

GitHub Actions / unittest (9)

Non-nullable property 'MyList' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable.

Check warning on line 18 in ChronoJsonDiffPatch/ChronoJsonDiffPatchTests/ListPatchingTests.cs

View workflow job for this annotation

GitHub Actions / unittest (10)

Non-nullable property 'MyList' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable.

Check warning on line 18 in ChronoJsonDiffPatch/ChronoJsonDiffPatchTests/ListPatchingTests.cs

View workflow job for this annotation

GitHub Actions / coverage

Non-nullable property 'MyList' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable.

Check warning on line 18 in ChronoJsonDiffPatch/ChronoJsonDiffPatchTests/ListPatchingTests.cs

View workflow job for this annotation

GitHub Actions / coverage

Non-nullable property 'MyList' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable.
}

[Fact]
Expand Down Expand Up @@ -193,6 +193,151 @@
antiparallelChain.PatchesHaveBeenSkipped.Should().BeTrue();
}

/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[Fact]
public void PatchToDate_Without_SkipConditions_Throws_When_Final_Deserialization_Fails()
{
// Build a valid chain first with the default deserializer
var buildChain = new TimeRangePatchChain<EntityWithList>();
var initialEntity = new EntityWithList
{
MyList = new List<ListItem> { new() { Value = "A" } },
};
var keyDate = new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero);
var updatedEntity = new EntityWithList
{
MyList = new List<ListItem>
{
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<EntityWithList>(
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<System.Text.Json.JsonException>();
chainWithBrokenDeserializer.FinalDeserializationFailed.Should().BeFalse();
}

/// <summary>
/// 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 <see cref="TimeRangePatchChain{TEntity}.FinalDeserializationFailed"/> is set.
/// </summary>
[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<EntityWithList>();
var initialEntity = new EntityWithList
{
MyList = new List<ListItem> { new() { Value = "A" } },
};
var keyDate = new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero);
var updatedEntity = new EntityWithList
{
MyList = new List<ListItem>
{
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<EntityWithList>(
patches,
deserializer: _ =>
throw new System.Text.Json.JsonException(
"The JSON value could not be converted to List`1"
),
skipConditions: new List<ISkipCondition<EntityWithList>>
{
new IgnoreAllSkipCondition(),
}
);

var result = chainWithBrokenDeserializer.PatchToDate(
initialEntity,
keyDate + TimeSpan.FromDays(1)
);

result.Should().BeSameAs(initialEntity);
chainWithBrokenDeserializer.PatchesHaveBeenSkipped.Should().BeTrue();
chainWithBrokenDeserializer.FinalDeserializationFailed.Should().BeTrue();
}

/// <summary>
/// Verifies that <see cref="TimeRangePatchChain{TEntity}.FinalDeserializationFailed"/> is reset
/// between calls to PatchToDate.
/// </summary>
[Fact]
public void FinalDeserializationFailed_Is_Reset_Between_Calls()
{
var chain = new TimeRangePatchChain<EntityWithList>();
var initialEntity = new EntityWithList
{
MyList = new List<ListItem> { new() { Value = "A" } },
};
var keyDate = new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero);
chain.Add(
initialEntity,
new EntityWithList
{
MyList = new List<ListItem>
{
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);
}

/// <summary>
/// A skip condition that always returns true for any error.
/// This simulates IgnoreEverythingSkipCondition from downstream consumers.
/// </summary>
private class IgnoreAllSkipCondition : ISkipCondition<EntityWithList>
{
public bool ShouldSkipPatch(
EntityWithList initialEntity,
TimeRangePatch? failedPatch,
Exception errorWhilePatching
) => true;
}

private static void ReverseAndRevert(
TimeRangePatchChain<EntityWithList> chain,
EntityWithList initialEntity
Expand Down
Loading