Skip to content
Merged
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
18 changes: 17 additions & 1 deletion src/SIL.Harmony.Tests/ChangeConverterTests.cs
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using SIL.Harmony.Changes;
using SIL.Harmony.Config;
using SIL.Harmony.Sample;
using SIL.Harmony.Sample.Changes;

namespace SIL.Harmony.Tests;

public class ChangeConverterTests
{
private static JsonSerializerOptions SampleOptions() =>
private static JsonSerializerOptions SampleOptions(UnknownChangeHandling handling = UnknownChangeHandling.Fallback) =>
new ServiceCollection()
.AddCrdtDataSample(":memory:")
.Configure<HarmonyConfig>(c => c.UnknownChangeHandling = handling)
.BuildServiceProvider()
.GetRequiredService<JsonSerializerOptions>();
Comment thread
hahn-kev marked this conversation as resolved.

Expand Down Expand Up @@ -45,6 +47,20 @@ public void Unknown_type_deserializes_to_OpaqueChange()
opaque.RawJson.GetProperty("Priority").GetInt32().Should().Be(7);
opaque.SupportsNewEntity().Should().BeFalse();
opaque.SupportsApplyChange().Should().BeFalse();
opaque.EntityType.Should().BeNull();
}

[Fact]
public void Unknown_type_throws_when_fallback_disabled()
{
var options = SampleOptions(UnknownChangeHandling.Throw);
var json = """
{"$type":"SetWordPriorityChange","EntityId":"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee","Priority":7}
""";

var act = () => JsonSerializer.Deserialize<IChange>(json, options);

act.Should().Throw<JsonException>().WithMessage("*SetWordPriorityChange*");
}

[Fact]
Expand Down
9 changes: 5 additions & 4 deletions src/SIL.Harmony.Tests/DataModelPerformanceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,13 @@ namespace SIL.Harmony.Tests;
[Trait("Category", "Performance")]
public class DataModelPerformanceTests(ITestOutputHelper output)
{
[Fact]
public void AddingChangePerformance()
{
[Fact(
#if DEBUG
Assert.Fail("This test is disabled in debug builds, not reliable");
Skip = "This test is disabled in debug builds, not reliable"
#endif
)]
public void AddingChangePerformance()
{
var summary =
BenchmarkRunner.Run<DataModelPerformanceBenchmarks>(
ManualConfig.CreateEmpty()
Expand Down
2 changes: 1 addition & 1 deletion src/SIL.Harmony/Changes/Change.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ public interface IChange
Guid EntityId { get; set; }

[JsonIgnore]
Type EntityType { get; }
Type? EntityType { get; }

ValueTask ApplyChange(IObjectBase entity, IChangeContext context);
ValueTask<IObjectBase> NewEntity(Commit commit, IChangeContext context);
Expand Down
3 changes: 1 addition & 2 deletions src/SIL.Harmony/Changes/OpaqueChange.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,7 @@ public sealed class OpaqueChange : IChange

public Guid EntityId { get; set; }

public Type EntityType =>
throw new NotSupportedException($"Opaque change '{TypeName}' has no known entity type.");
public Type? EntityType => null;

public ValueTask ApplyChange(IObjectBase entity, IChangeContext context) => default;

Expand Down
12 changes: 10 additions & 2 deletions src/SIL.Harmony/Changes/PeekThenConcreteChangeConverter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,28 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
using SIL.Harmony.Config;

namespace SIL.Harmony.Changes;

/// <summary>
/// Owns <see cref="IChange"/> discrimination. Requires <c>$type</c> as the first JSON property
/// (matching synthetic write order from <see cref="Config.HarmonyConfig"/>).
/// Known discriminators deserialize via cached concrete <see cref="JsonTypeInfo"/>;
/// unknown → <see cref="OpaqueChange"/> preserving the raw payload.
/// an unknown discriminator either throws or falls back to <see cref="OpaqueChange"/>
/// (preserving the raw payload), depending on <see cref="UnknownChangeHandling"/>.
/// </summary>
internal sealed class PeekThenConcreteChangeConverter : JsonConverter<IChange>
{
private readonly KnownType[] _known;
private readonly byte[] _discriminatorPropertyUtf8;
private readonly UnknownChangeHandling _unknownChangeHandling;

public PeekThenConcreteChangeConverter(IReadOnlyDictionary<string, Type> known)
public PeekThenConcreteChangeConverter(IReadOnlyDictionary<string, Type> known, UnknownChangeHandling unknownChangeHandling)
{
_discriminatorPropertyUtf8 = Encoding.UTF8.GetBytes(CrdtConstants.ChangeDiscriminatorProperty);
_known = known.Select(kv => new KnownType(Encoding.UTF8.GetBytes(kv.Key), kv.Value)).ToArray();
_unknownChangeHandling = unknownChangeHandling;
}

public override IChange Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
Expand All @@ -42,6 +46,10 @@ public override IChange Read(ref Utf8JsonReader reader, Type typeToConvert, Json

if (!TryFindKnown(ref reader, out var knownIndex, out var unknownTypeName))
{
if (_unknownChangeHandling != UnknownChangeHandling.Fallback)
throw new JsonException(
$"Unknown IChange \"{CrdtConstants.ChangeDiscriminatorProperty}\" discriminator '{unknownTypeName}'");

reader = checkpoint;
return ReadOpaque(ref reader, unknownTypeName!);
}
Expand Down
28 changes: 21 additions & 7 deletions src/SIL.Harmony/Config/HarmonyConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ public class HarmonyConfig
/// after adding any commit validate the commit history, not great for performance but good for testing.
/// </summary>
public bool AlwaysValidateCommits { get; set; } = true;
/// <summary>
/// Controls how an unknown <see cref="IChange"/> <c>$type</c> is handled during deserialization.
/// Defaults to <see cref="UnknownChangeHandling.Throw"/>; set to <see cref="UnknownChangeHandling.Fallback"/>
/// to preserve unknown changes as <see cref="OpaqueChange"/>.
/// </summary>
public UnknownChangeHandling UnknownChangeHandling { get; set; } = UnknownChangeHandling.Throw;
public ChangeTypeListBuilder ChangeTypeListBuilder { get; } = new();
public IReadOnlyList<RegisteredChangeType> ChangeTypes => ChangeTypeListBuilder.Types;
public ObjectTypeListBuilder ObjectTypeListBuilder { get; } = new();
Expand All @@ -38,17 +44,25 @@ public HarmonyConfig()

private JsonSerializerOptions CreateJsonSerializerOptions()
{
var changeDiscriminators = _lazyChangeDiscriminatorMaps.Value;

var options = new JsonSerializerOptions(JsonSerializerDefaults.General)
{
TypeInfoResolver = MakeJsonTypeResolver()
};
options.Converters.Add(new PeekThenConcreteChangeConverter(changeDiscriminators.ByDiscriminator));
_jsonOptionsBuilder.ApplyTo(options);
var options = new JsonSerializerOptions(JsonSerializerDefaults.General);
ConfigureExternalJsonOptions(options);
return options;
}

/// <summary>
/// Configures <see cref="JsonSerializerOptions"/> for Harmony's serialization of <see cref="IChange"/> and <see cref="IObject"/> types.
/// Also applies any callbacks registered via <see cref="ConfigureJsonOptions(Action{JsonSerializerOptions})"/>.
/// </summary>
public void ConfigureExternalJsonOptions(JsonSerializerOptions options)
{
var changeDiscriminators = _lazyChangeDiscriminatorMaps.Value;
options.TypeInfoResolver = options.TypeInfoResolver?.WithAddedModifier(MakeJsonTypeModifier())
?? MakeJsonTypeResolver();
options.Converters.Add(new PeekThenConcreteChangeConverter(changeDiscriminators.ByDiscriminator, UnknownChangeHandling));
_jsonOptionsBuilder.ApplyTo(options);
}

/// <summary>
/// Registers a callback to customize <see cref="JsonSerializerOptions"/> before they are frozen.
/// Callbacks run after Harmony's type resolver and change converter are configured.
Expand Down
13 changes: 13 additions & 0 deletions src/SIL.Harmony/Config/UnknownChangeHandling.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
namespace SIL.Harmony.Config;

/// <summary>
/// Controls how <c>PeekThenConcreteChangeConverter</c> handles an <see cref="Changes.IChange"/>
/// whose <c>$type</c> is not registered on this client.
/// </summary>
public enum UnknownChangeHandling
{
/// <summary>Throw a <see cref="System.Text.Json.JsonException"/> on an unknown $type (default).</summary>
Throw,
/// <summary>Fall back to <see cref="Changes.OpaqueChange"/>, preserving the raw JSON so it round-trips.</summary>
Fallback,
}
Loading