Skip to content

Commit

Permalink
Merge pull request #12 from chickensoft-games/feat/better-derived-types
Browse files Browse the repository at this point in the history
fix: serialize based on runtime type to support interfaces for serializable model property types
  • Loading branch information
jolexxa authored Jul 25, 2024
2 parents e70ed73 + 9e06e27 commit 6e1d0ec
Show file tree
Hide file tree
Showing 5 changed files with 103 additions and 6 deletions.
2 changes: 2 additions & 0 deletions Chickensoft.Serialization.Tests/test/src/CollectionsTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ public void SerializesCollections() {

var bookAgain = JsonSerializer.Deserialize<Book>(json, options);

bookAgain.ShouldNotBeNull();
bookAgain.ShouldDeepEqual(book);
}

Expand Down Expand Up @@ -241,6 +242,7 @@ public void SerializesAList() {

var bookcaseAgain = JsonSerializer.Deserialize<Bookcase>(json, options);

bookcaseAgain.ShouldNotBeNull();
bookcaseAgain.ShouldDeepEqual(bookcase);
}
}
63 changes: 63 additions & 0 deletions Chickensoft.Serialization.Tests/test/src/InterfaceTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
namespace Chickensoft.Serialization.Tests;

using System.Text.Json;
using Chickensoft.Collections;
using Chickensoft.Introspection;
using Shouldly;
using Xunit;

public partial class InterfaceTest {
public interface IAliasedModel {
int Value { get; }
}

[Meta, Id("aliased_model")]
public partial class AliasedModel : IAliasedModel {
[Save("value")]
public int Value { get; set; }
}

[Meta, Id("interface_test_model")]
public partial class TestModel {
[Save("aliased_model")]
public required IAliasedModel AliasedModel { get; set; }
}

[Fact]
public void SerializesAsInterface() {
var model = new TestModel {
AliasedModel = new AliasedModel { Value = 10 }
};

var options = new JsonSerializerOptions {
WriteIndented = true,
TypeInfoResolver = new SerializableTypeResolver(),
Converters = { new SerializableTypeConverter(new Blackboard()) }
};

var serialized = JsonSerializer.Serialize(model, options);

serialized.ShouldBe(
/*lang=json,strict*/
"""
{
"$type": "interface_test_model",
"$v": 1,
"aliased_model": {
"$type": "aliased_model",
"$v": 1,
"value": 10
}
}
""",
StringCompareShould.IgnoreLineEndings
);

var deserialized = JsonSerializer.Deserialize<TestModel>(
serialized, options
);

deserialized.ShouldNotBeNull();
deserialized.AliasedModel.ShouldBeEquivalentTo(model.AliasedModel);
}
}
2 changes: 2 additions & 0 deletions Chickensoft.Serialization.Tests/test/src/MixAndMatchTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ public void CanUseChickensoftModelsFromOutermostSTJModel() {
var campCounselorAgain =
JsonSerializer.Deserialize<CampCounselor>(json, options);

campCounselorAgain.ShouldNotBeNull();
campCounselorAgain.ShouldDeepEqual(campCounselor);
}

Expand Down Expand Up @@ -133,6 +134,7 @@ public void CanUseSTJModelsFromOutermostChickensoftModel() {
var campInstructorAgain =
JsonSerializer.Deserialize<CampInstructor>(json, options);

campInstructorAgain.ShouldNotBeNull();
campInstructorAgain.ShouldDeepEqual(campInstructor);
}
}
38 changes: 36 additions & 2 deletions Chickensoft.Serialization/src/SerializableTypeConverter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -111,10 +111,34 @@ JsonSerializerOptions options

object? propertyValue = null;

var propertyType = property.GenericType.ClosedType;

if (isPresentInJson) {
// Peek at the type of the property's value to see if it's more
// specific than the type in the metadata (i.e., a derived type).
// This allows us to support concrete implementations of declared types
// for properties that are interfaces or abstract classes.

if (
propertyValueJsonNode is JsonObject propertyJsonObj &&
propertyValueJsonNode[TypeDiscriminator]?.ToString()
is { } propertyTypeId &&
Graph.GetIdentifiableType(propertyTypeId) is { } idType
) {
// Peeking a property value's type only works if the property value
// is a non-null object and it actually has a type discriminator.
// Types with System.Text.Json generated metadata won't necessarily
// have type discriminators or may have a different field name for the
// type discriminator, ensuring those will still be handled by STJ
// itself.
//
// Update known type to be the more specific type.
propertyType = idType;
}

propertyValue = JsonSerializer.Deserialize(
propertyValueJsonNode,
property.GenericType.ClosedType,
propertyType,
options
);
}
Expand All @@ -136,7 +160,7 @@ propertyValue is null &&
var typeInfo =
options
.TypeInfoResolver!
.GetTypeInfo(property.GenericType.ClosedType, options)!;
.GetTypeInfo(propertyType, options)!;

// Our type resolver companion will have cached the closed type of
// the collection type by using the callbacks provided in the generated
Expand Down Expand Up @@ -223,8 +247,18 @@ metadata is not IConcreteIntrospectiveTypeMetadata concreteMetadata
);

var propertyValue = property.Getter(value);
var valueType = propertyValue?.GetType();
var propertyType = property.GenericType.ClosedType;

if (
valueType is { } &&
options.TypeInfoResolver!.GetTypeInfo(valueType, options) is { }
) {
// The actual instance type is a known serializable type, so we assume
// it is more specific than the declared property type. Use it instead.
propertyType = valueType;
}

json[propertyId] = JsonSerializer.SerializeToNode(
value: propertyValue,
inputType: propertyType,
Expand Down
4 changes: 0 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,6 @@ Annoyingly, `System.Text.Json` requires you to tag derived types on the generati
- ❌ Models must have parameterless constructors.
- ❌ Serializable types must be partial.
- ❌ Only collections supported are `HashSet<T>`, `List<T>`, and `Dictionary<TKey, TValue>`.
- ❌ Referencing types by an interface is not supported.

The Chickensoft serializer has strong opinions about how JSON serialization should be done. It's primarily intended to simplify the process of defining models for game save files, but you can use it any C# project which supports C# >= 11.

Expand Down Expand Up @@ -194,9 +193,6 @@ public partial class Lawyer : Person {
}
```

> [!CAUTION]
> A serializable property cannot refer to a type by an interface.
## ⏳ Versioning

The serialization system provides support for versioning models when requirements inevitably change.
Expand Down

0 comments on commit 6e1d0ec

Please sign in to comment.