Skip to content

Commit dcd4e25

Browse files
committed
System.Text.Json: String-Based keyed value objects (and smart enums) can be used as dictionary keys
1 parent 6245b4e commit dcd4e25

File tree

10 files changed

+151
-13
lines changed

10 files changed

+151
-13
lines changed

Directory.Build.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
<PropertyGroup>
44
<Copyright>(c) $([System.DateTime]::Now.Year), Pawel Gerr. All rights reserved.</Copyright>
5-
<VersionPrefix>7.1.0</VersionPrefix>
5+
<VersionPrefix>7.2.0</VersionPrefix>
66
<Authors>Pawel Gerr</Authors>
77
<GenerateDocumentationFile>true</GenerateDocumentationFile>
88
<PackageProjectUrl>https://github.com/PawelGerr/Thinktecture.Runtime.Extensions</PackageProjectUrl>

src/Thinktecture.Runtime.Extensions.Json/Text/Json/Serialization/ValueObjectJsonConverter.cs

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,3 +54,64 @@ public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions
5454
_keyConverter.Write(writer, value.ToValue(), options);
5555
}
5656
}
57+
58+
/// <summary>
59+
/// JSON converter for string-based Value Objects.
60+
/// </summary>
61+
/// <typeparam name="T">Type of the value object.</typeparam>
62+
/// <typeparam name="TValidationError">Type of the validation error.</typeparam>
63+
public sealed class ValueObjectJsonConverter<T, TValidationError> : JsonConverter<T>
64+
where T : IValueObjectFactory<T, string, TValidationError>, IValueObjectConvertable<string>
65+
where TValidationError : class, IValidationError<TValidationError>
66+
{
67+
private static readonly bool _mayReturnInvalidObjects = typeof(IValidatableEnum).IsAssignableFrom(typeof(T));
68+
69+
/// <summary>
70+
/// Initializes a new instance of <see cref="ValueObjectJsonConverter{T,TKey,TValidationError}"/>.
71+
/// </summary>
72+
/// <param name="options">JSON serializer options.</param>
73+
public ValueObjectJsonConverter(JsonSerializerOptions options)
74+
{
75+
ArgumentNullException.ThrowIfNull(options);
76+
}
77+
78+
/// <inheritdoc />
79+
public override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
80+
{
81+
var key = reader.GetString();
82+
83+
if (key is null)
84+
return default;
85+
86+
var validationError = T.Validate(key, null, out var obj);
87+
88+
if (validationError is not null && !_mayReturnInvalidObjects)
89+
throw new JsonException(validationError.ToString() ?? "JSON deserialization failed.");
90+
91+
return obj;
92+
}
93+
94+
/// <inheritdoc />
95+
public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options)
96+
{
97+
if (value is null)
98+
throw new ArgumentNullException(nameof(value));
99+
100+
writer.WriteStringValue(value.ToValue());
101+
}
102+
103+
/// <inheritdoc />
104+
public override T ReadAsPropertyName(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
105+
{
106+
return Read(ref reader, typeToConvert, options) ?? base.ReadAsPropertyName(ref reader, typeToConvert, options);
107+
}
108+
109+
/// <inheritdoc />
110+
public override void WriteAsPropertyName(Utf8JsonWriter writer, T value, JsonSerializerOptions options)
111+
{
112+
if (value is null)
113+
throw new ArgumentNullException(nameof(value));
114+
115+
writer.WritePropertyName(value.ToValue());
116+
}
117+
}

src/Thinktecture.Runtime.Extensions.Json/Text/Json/Serialization/ValueObjectJsonConverterFactory.cs

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,29 @@ public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializer
2929
}
3030
}
3131

32+
/// <summary>
33+
/// Factory for creation of <see cref="ValueObjectJsonConverter{T,TKey,TValidationError}"/>.
34+
/// </summary>
35+
public sealed class ValueObjectJsonConverterFactory<T, TValidationError> : JsonConverterFactory
36+
where T : IValueObjectFactory<T, string, TValidationError>, IValueObjectConvertable<string>
37+
where TValidationError : class, IValidationError<TValidationError>
38+
{
39+
/// <inheritdoc />
40+
public override bool CanConvert(Type typeToConvert)
41+
{
42+
return typeof(T).IsAssignableFrom(typeToConvert);
43+
}
44+
45+
/// <inheritdoc />
46+
public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options)
47+
{
48+
ArgumentNullException.ThrowIfNull(typeToConvert);
49+
ArgumentNullException.ThrowIfNull(options);
50+
51+
return new ValueObjectJsonConverter<T, TValidationError>(options);
52+
}
53+
}
54+
3255
/// <summary>
3356
/// Factory for creation of <see cref="ValueObjectJsonConverter{T,TKey,TValidationError}"/>.
3457
/// </summary>
@@ -71,7 +94,9 @@ public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializer
7194

7295
var validationErrorType = type.GetCustomAttribute<ValueObjectValidationErrorAttribute>()?.Type ?? typeof(ValidationError);
7396

74-
var converterType = typeof(ValueObjectJsonConverter<,,>).MakeGenericType(type, keyType, validationErrorType);
97+
var converterType = keyType == typeof(string)
98+
? typeof(ValueObjectJsonConverter<,>).MakeGenericType(type, validationErrorType)
99+
: typeof(ValueObjectJsonConverter<,,>).MakeGenericType(type, keyType, validationErrorType);
75100
var converter = Activator.CreateInstance(converterType, options);
76101

77102
return (JsonConverter)(converter ?? throw new Exception($"Could not create converter of type '{converterType.Name}'."));

src/Thinktecture.Runtime.Extensions.SourceGenerator/CodeAnalysis/KeyedJsonCodeGenerator.cs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ public override void Generate(CancellationToken cancellationToken)
2222
.DesiredFactories
2323
.FirstOrDefault(f => f.UseForSerialization.Has(SerializationFrameworks.SystemTextJson));
2424
var keyType = customFactory?.TypeFullyQualified ?? _state.KeyMember?.TypeFullyQualified;
25+
var isString = customFactory is null
26+
? _state.KeyMember?.SpecialType == SpecialType.System_String
27+
: customFactory.SpecialType == SpecialType.System_String;
2528

2629
_sb.Append(GENERATED_CODE_PREFIX).Append(@"
2730
");
@@ -34,7 +37,12 @@ namespace ").Append(_state.Type.Namespace).Append(@";
3437
}
3538

3639
_sb.Append(@"
37-
[global::System.Text.Json.Serialization.JsonConverterAttribute(typeof(global::Thinktecture.Text.Json.Serialization.ValueObjectJsonConverterFactory<").Append(_state.Type.TypeFullyQualified).Append(", ").Append(keyType).Append(", ").Append(_state.AttributeInfo.ValidationError.TypeFullyQualified).Append(@">))]
40+
[global::System.Text.Json.Serialization.JsonConverterAttribute(typeof(global::Thinktecture.Text.Json.Serialization.ValueObjectJsonConverterFactory<").Append(_state.Type.TypeFullyQualified).Append(", ");
41+
42+
if (!isString)
43+
_sb.Append(keyType).Append(", ");
44+
45+
_sb.Append(_state.AttributeInfo.ValidationError.TypeFullyQualified).Append(@">))]
3846
partial ").Append(_state.Type.IsReferenceType ? "class" : "struct").Append(" ").Append(_state.Type.Name).Append(@"
3947
{
4048
}

src/Thinktecture.Runtime.Extensions.SourceGenerator/CodeAnalysis/KeyedSerializerGeneratorState.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,15 @@ namespace Thinktecture.CodeAnalysis;
33
public readonly struct KeyedSerializerGeneratorState : IEquatable<KeyedSerializerGeneratorState>, INamespaceAndName
44
{
55
public ITypeInformation Type { get; }
6-
public ITypeFullyQualified? KeyMember { get; }
6+
public IMemberInformation? KeyMember { get; }
77
public AttributeInfo AttributeInfo { get; }
88

99
public string? Namespace => Type.Namespace;
1010
public string Name => Type.Name;
1111

1212
public KeyedSerializerGeneratorState(
1313
ITypeInformation type,
14-
ITypeFullyQualified? keyMember,
14+
IMemberInformation? keyMember,
1515
AttributeInfo attributeInfo)
1616
{
1717
Type = type;

test/Thinktecture.Runtime.Extensions.Json.Tests/Text/Json/Serialization/ValueObjectJsonConverterFactoryTests/ReadJson.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,4 +196,13 @@ public void Should_deserialize_complex_value_object_with_numbers_as_string()
196196

197197
value.Should().BeEquivalentTo(Boundary.Create(1, 2));
198198
}
199+
200+
[Fact]
201+
public void Should_throw_if_non_string_based_enum_is_used_as_dictionary_key()
202+
{
203+
var options = new JsonSerializerOptions { Converters = { new ValueObjectJsonConverterFactory() } };
204+
205+
FluentActions.Invoking(() => JsonSerializer.Deserialize<Dictionary<TestSmartEnum_Class_IntBased, int>>("""{ "1": 1 }""", options))
206+
.Should().Throw<NotSupportedException>();
207+
}
199208
}

test/Thinktecture.Runtime.Extensions.Json.Tests/Text/Json/Serialization/ValueObjectJsonConverterFactoryTests/RoundTrip.cs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
1+
using System.Collections.Generic;
12
using System.Globalization;
3+
using System.Text.Json;
24
using System.Text.Json.Serialization;
5+
using Thinktecture.Runtime.Tests.TestEnums;
36
using Thinktecture.Runtime.Tests.TestValueObjects;
7+
using Thinktecture.Text.Json.Serialization;
48

59
namespace Thinktecture.Runtime.Tests.Text.Json.Serialization.ValueObjectJsonConverterFactoryTests;
610

@@ -191,4 +195,21 @@ public void Should_deserialize_decimal_from_string_with_corresponding_NumberHand
191195
Serialize<TestValueObjectDecimal, decimal>(obj, numberHandling: JsonNumberHandling.WriteAsString).Should().Be(numberAsStringJson);
192196
Deserialize<TestValueObjectDecimal, decimal>($"\"{number}\"", numberHandling: JsonNumberHandling.AllowReadingFromString).Should().Be(obj);
193197
}
198+
199+
[Fact]
200+
public void Should_roundtrip_serialize_dictionary_with_string_based_enum_key()
201+
{
202+
var dictionary = new Dictionary<TestSmartEnum_Class_StringBased, int>
203+
{
204+
{ TestSmartEnum_Class_StringBased.Value1, 1 },
205+
{ TestSmartEnum_Class_StringBased.Value2, 2 }
206+
};
207+
208+
var options = new JsonSerializerOptions { Converters = { new ValueObjectJsonConverterFactory() } };
209+
210+
var json = JsonSerializer.Serialize(dictionary, options);
211+
var deserializedDictionary = JsonSerializer.Deserialize<Dictionary<TestSmartEnum_Class_StringBased, int>>(json, options);
212+
213+
dictionary.Should().BeEquivalentTo(deserializedDictionary);
214+
}
194215
}

test/Thinktecture.Runtime.Extensions.Json.Tests/Text/Json/Serialization/ValueObjectJsonConverterFactoryTests/WriteJson.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,4 +137,18 @@ public void Should_serialize_complex_value_object_with_ValueObjectValidationErro
137137

138138
value.Should().BeEquivalentTo("{\"lower\":1,\"upper\":2}");
139139
}
140+
141+
[Fact]
142+
public void Should_throw_if_non_string_based_enum_is_used_as_dictionary_key()
143+
{
144+
var dictionary = new Dictionary<TestSmartEnum_Class_IntBased, int>
145+
{
146+
{ TestSmartEnum_Class_IntBased.Value1, 1 }
147+
};
148+
149+
var options = new JsonSerializerOptions { Converters = { new ValueObjectJsonConverterFactory() } };
150+
151+
FluentActions.Invoking(() => JsonSerializer.Serialize(dictionary, options))
152+
.Should().Throw<NotSupportedException>();
153+
}
140154
}

test/Thinktecture.Runtime.Extensions.SourceGenerator.Tests/SourceGeneratorTests/JsonSmartEnumSourceGeneratorTests.cs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ public partial class TestEnum
3838
3939
namespace Thinktecture.Tests;
4040
41-
[global::System.Text.Json.Serialization.JsonConverterAttribute(typeof(global::Thinktecture.Text.Json.Serialization.ValueObjectJsonConverterFactory<global::Thinktecture.Tests.TestEnum, string, global::Thinktecture.ValidationError>))]
41+
[global::System.Text.Json.Serialization.JsonConverterAttribute(typeof(global::Thinktecture.Text.Json.Serialization.ValueObjectJsonConverterFactory<global::Thinktecture.Tests.TestEnum, global::Thinktecture.ValidationError>))]
4242
partial class TestEnum
4343
{
4444
}
@@ -70,7 +70,7 @@ public partial class TestEnum
7070
// <auto-generated />
7171
#nullable enable
7272
73-
[global::System.Text.Json.Serialization.JsonConverterAttribute(typeof(global::Thinktecture.Text.Json.Serialization.ValueObjectJsonConverterFactory<global::TestEnum, string, global::Thinktecture.ValidationError>))]
73+
[global::System.Text.Json.Serialization.JsonConverterAttribute(typeof(global::Thinktecture.Text.Json.Serialization.ValueObjectJsonConverterFactory<global::TestEnum, global::Thinktecture.ValidationError>))]
7474
partial class TestEnum
7575
{
7676
}
@@ -106,7 +106,7 @@ public readonly partial struct TestEnum
106106
107107
namespace Thinktecture.Tests;
108108
109-
[global::System.Text.Json.Serialization.JsonConverterAttribute(typeof(global::Thinktecture.Text.Json.Serialization.ValueObjectJsonConverterFactory<global::Thinktecture.Tests.TestEnum, string, global::Thinktecture.ValidationError>))]
109+
[global::System.Text.Json.Serialization.JsonConverterAttribute(typeof(global::Thinktecture.Text.Json.Serialization.ValueObjectJsonConverterFactory<global::Thinktecture.Tests.TestEnum, global::Thinktecture.ValidationError>))]
110110
partial struct TestEnum
111111
{
112112
}
@@ -130,14 +130,14 @@ public TestEnum_EnumJsonConverter()
130130
: this(null)
131131
{
132132
}
133-
133+
134134
public TestEnum_EnumJsonConverter(
135135
JsonConverter<string>? keyConverter)
136136
: base(TestEnum.Get, keyConverter)
137137
{
138138
}
139139
}
140-
140+
141141
[SmartEnum<string>]
142142
[JsonConverter(typeof(TestEnumJsonConverter))]
143143
public partial class TestEnum

test/Thinktecture.Runtime.Extensions.SourceGenerator.Tests/SourceGeneratorTests/JsonValueObjectSourceGeneratorTests.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ public partial class TestValueObject
3737
3838
namespace Thinktecture.Tests;
3939
40-
[global::System.Text.Json.Serialization.JsonConverterAttribute(typeof(global::Thinktecture.Text.Json.Serialization.ValueObjectJsonConverterFactory<global::Thinktecture.Tests.TestValueObject, string, global::Thinktecture.ValidationError>))]
40+
[global::System.Text.Json.Serialization.JsonConverterAttribute(typeof(global::Thinktecture.Text.Json.Serialization.ValueObjectJsonConverterFactory<global::Thinktecture.Tests.TestValueObject, global::Thinktecture.ValidationError>))]
4141
partial class TestValueObject
4242
{
4343
}
@@ -67,7 +67,7 @@ public partial class TestValueObject
6767
// <auto-generated />
6868
#nullable enable
6969
70-
[global::System.Text.Json.Serialization.JsonConverterAttribute(typeof(global::Thinktecture.Text.Json.Serialization.ValueObjectJsonConverterFactory<global::TestValueObject, string, global::Thinktecture.ValidationError>))]
70+
[global::System.Text.Json.Serialization.JsonConverterAttribute(typeof(global::Thinktecture.Text.Json.Serialization.ValueObjectJsonConverterFactory<global::TestValueObject, global::Thinktecture.ValidationError>))]
7171
partial class TestValueObject
7272
{
7373
}
@@ -102,7 +102,7 @@ public readonly partial struct TestValueObject
102102
103103
namespace Thinktecture.Tests;
104104
105-
[global::System.Text.Json.Serialization.JsonConverterAttribute(typeof(global::Thinktecture.Text.Json.Serialization.ValueObjectJsonConverterFactory<global::Thinktecture.Tests.TestValueObject, string, global::Thinktecture.ValidationError>))]
105+
[global::System.Text.Json.Serialization.JsonConverterAttribute(typeof(global::Thinktecture.Text.Json.Serialization.ValueObjectJsonConverterFactory<global::Thinktecture.Tests.TestValueObject, global::Thinktecture.ValidationError>))]
106106
partial struct TestValueObject
107107
{
108108
}

0 commit comments

Comments
 (0)