-
Couldn't load subscription status.
- Fork 5.2k
Add built-in tuple serialization support to System.Text.Json #120906
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
+533
−19
Closed
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
23c7046
Initial plan
Copilot eb6d07b
Add tuple converter for reflection serialization
Copilot 42ee349
Update source generator to handle tuples
Copilot ad37fcb
Fix build errors in tuple converter
Copilot b325fcc
Update tests for new tuple serialization behavior
Copilot 76844ba
Add comprehensive tuple tests for reflection and improve test assertions
Copilot 9331f2a
Rename IsTuple to IsClassTuple, use raw string literals, add deserial…
Copilot File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
228 changes: 228 additions & 0 deletions
228
...s/System.Text.Json/src/System/Text/Json/Serialization/Converters/Object/TupleConverter.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,228 @@ | ||
| // Licensed to the .NET Foundation under one or more agreements. | ||
| // The .NET Foundation licenses this file to you under the MIT license. | ||
|
|
||
| using System.Collections.Generic; | ||
| using System.Diagnostics; | ||
| using System.Diagnostics.CodeAnalysis; | ||
| using System.Reflection; | ||
| using System.Text.Json.Reflection; | ||
| using System.Text.Json.Serialization.Metadata; | ||
|
|
||
| namespace System.Text.Json.Serialization.Converters | ||
| { | ||
| /// <summary> | ||
| /// Converter for System.Tuple and System.ValueTuple types that serializes them as objects with Item1, Item2, etc. properties. | ||
| /// Handles long tuples (> 7 elements) by flattening the Rest field. | ||
| /// </summary> | ||
| [RequiresDynamicCode(JsonSerializer.SerializationRequiresDynamicCodeMessage)] | ||
| [RequiresUnreferencedCode(JsonSerializer.SerializationUnreferencedCodeMessage)] | ||
| internal sealed class TupleConverter<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicFields | DynamicallyAccessedMemberTypes.PublicProperties)] T> : JsonObjectConverter<T> | ||
| { | ||
| private readonly List<(string Name, Type Type, Func<T, object?> Getter)> _elements; | ||
|
|
||
| internal override bool CanHaveMetadata => false; | ||
| internal override bool SupportsCreateObjectDelegate => false; | ||
|
|
||
| public TupleConverter() | ||
| { | ||
| _elements = new List<(string, Type, Func<T, object?>)>(); | ||
| PopulateTupleElements(typeof(T), _elements, 0); | ||
| } | ||
|
|
||
| private static void PopulateTupleElements([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicFields | DynamicallyAccessedMemberTypes.PublicProperties)] Type tupleType, List<(string, Type, Func<T, object?>)> elements, int offset) | ||
| { | ||
| if (tupleType.IsValueTuple()) | ||
| { | ||
| PopulateValueTupleElements(tupleType, elements, offset); | ||
| } | ||
| else if (tupleType.IsClassTuple()) | ||
| { | ||
| PopulateReferenceTupleElements(tupleType, elements, offset); | ||
| } | ||
| } | ||
|
|
||
| private static void PopulateValueTupleElements([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicFields)] Type tupleType, List<(string, Type, Func<T, object?>)> elements, int offset) | ||
| { | ||
| FieldInfo[] fields = tupleType.GetFields(BindingFlags.Public | BindingFlags.Instance); | ||
|
|
||
| foreach (FieldInfo field in fields) | ||
| { | ||
| if (field.Name == "Rest") | ||
| { | ||
| // Handle long tuple (> 7 elements) - recursively flatten the Rest field | ||
| [UnconditionalSuppressMessage("ReflectionAnalysis", "IL2072:UnrecognizedReflectionPattern", | ||
| Justification = "Tuple Rest field types are well-known tuple types.")] | ||
| static void ProcessRest(FieldInfo field, List<(string, Type, Func<T, object?>)> elements, int offset) | ||
| { | ||
| Type restType = field.FieldType; | ||
| PopulateTupleElements(restType, elements, offset); | ||
| } | ||
| ProcessRest(field, elements, offset); | ||
| } | ||
| else if (field.Name.StartsWith("Item", StringComparison.Ordinal)) | ||
| { | ||
| string itemName = $"Item{offset + elements.Count + 1}"; | ||
| Type fieldType = field.FieldType; | ||
| Func<T, object?> getter = (T tuple) => field.GetValue(tuple); | ||
| elements.Add((itemName, fieldType, getter)); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private static void PopulateReferenceTupleElements([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] Type tupleType, List<(string, Type, Func<T, object?>)> elements, int offset) | ||
| { | ||
| PropertyInfo[] properties = tupleType.GetProperties(BindingFlags.Public | BindingFlags.Instance); | ||
|
|
||
| foreach (PropertyInfo property in properties) | ||
| { | ||
| if (property.Name == "Rest") | ||
| { | ||
| // Handle long tuple (> 7 elements) - recursively flatten the Rest property | ||
| [UnconditionalSuppressMessage("ReflectionAnalysis", "IL2072:UnrecognizedReflectionPattern", | ||
| Justification = "Tuple Rest property types are well-known tuple types.")] | ||
| static void ProcessRest(PropertyInfo property, List<(string, Type, Func<T, object?>)> elements, int offset) | ||
| { | ||
| Type restType = property.PropertyType; | ||
| PropertyInfo restProp = property; | ||
|
|
||
| // For System.Tuple, we need to handle Rest differently since it's accessed through properties | ||
| if (restType.IsValueTuple() || restType.IsClassTuple()) | ||
| { | ||
| // Create nested getters for Rest elements | ||
| PopulateNestedReferenceTupleElements(restType, elements, offset, restProp); | ||
| } | ||
| } | ||
| ProcessRest(property, elements, offset); | ||
| } | ||
| else if (property.Name.StartsWith("Item", StringComparison.Ordinal)) | ||
| { | ||
| string itemName = $"Item{offset + elements.Count + 1}"; | ||
| Type propertyType = property.PropertyType; | ||
| Func<T, object?> getter = (T tuple) => property.GetValue(tuple); | ||
| elements.Add((itemName, propertyType, getter)); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private static void PopulateNestedReferenceTupleElements([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] Type tupleType, List<(string, Type, Func<T, object?>)> elements, int offset, PropertyInfo restProperty) | ||
| { | ||
| PropertyInfo[] properties = tupleType.GetProperties(BindingFlags.Public | BindingFlags.Instance); | ||
|
|
||
| foreach (PropertyInfo property in properties) | ||
| { | ||
| if (property.Name == "Rest") | ||
| { | ||
| // Further nesting | ||
| [UnconditionalSuppressMessage("ReflectionAnalysis", "IL2072:UnrecognizedReflectionPattern", | ||
| Justification = "Tuple Rest property types are well-known tuple types.")] | ||
| static void ProcessNestedRest(PropertyInfo property, PropertyInfo restProperty, List<(string, Type, Func<T, object?>)> elements, int offset) | ||
| { | ||
| Type nestedRestType = property.PropertyType; | ||
| if (nestedRestType.IsValueTuple() || nestedRestType.IsClassTuple()) | ||
| { | ||
| // Chain the getters | ||
| PropertyInfo innerRestProp = property; | ||
| Func<T, object?> baseGetter = (T tuple) => restProperty.GetValue(tuple); | ||
| PopulateNestedReferenceTupleElementsChained(nestedRestType, elements, offset, baseGetter, innerRestProp); | ||
| } | ||
| } | ||
| ProcessNestedRest(property, restProperty, elements, offset); | ||
| } | ||
| else if (property.Name.StartsWith("Item", StringComparison.Ordinal)) | ||
| { | ||
| string itemName = $"Item{offset + elements.Count + 1}"; | ||
| Type propertyType = property.PropertyType; | ||
| PropertyInfo prop = property; | ||
| Func<T, object?> getter = (T tuple) => | ||
| { | ||
| object? restValue = restProperty.GetValue(tuple); | ||
| return restValue is not null ? prop.GetValue(restValue) : null; | ||
| }; | ||
| elements.Add((itemName, propertyType, getter)); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private static void PopulateNestedReferenceTupleElementsChained([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] Type tupleType, List<(string, Type, Func<T, object?>)> elements, int offset, Func<T, object?> baseGetter, PropertyInfo currentRestProp) | ||
| { | ||
| PropertyInfo[] properties = tupleType.GetProperties(BindingFlags.Public | BindingFlags.Instance); | ||
|
|
||
| foreach (PropertyInfo property in properties) | ||
| { | ||
| if (property.Name == "Rest") | ||
| { | ||
| // Further nesting - would be very rare | ||
| [UnconditionalSuppressMessage("ReflectionAnalysis", "IL2072:UnrecognizedReflectionPattern", | ||
| Justification = "Tuple Rest property types are well-known tuple types.")] | ||
| static void ProcessChainedRest(PropertyInfo property, PropertyInfo currentRestProp, Func<T, object?> baseGetter, List<(string, Type, Func<T, object?>)> elements, int offset) | ||
| { | ||
| Type nestedRestType = property.PropertyType; | ||
| if (nestedRestType.IsValueTuple() || nestedRestType.IsClassTuple()) | ||
| { | ||
| PropertyInfo innerRestProp = property; | ||
| Func<T, object?> chainedGetter = (T tuple) => | ||
| { | ||
| object? restValue = baseGetter(tuple); | ||
| return restValue is not null ? currentRestProp.GetValue(restValue) : null; | ||
| }; | ||
| PopulateNestedReferenceTupleElementsChained(nestedRestType, elements, offset, chainedGetter, innerRestProp); | ||
| } | ||
| } | ||
| ProcessChainedRest(property, currentRestProp, baseGetter, elements, offset); | ||
| } | ||
| else if (property.Name.StartsWith("Item", StringComparison.Ordinal)) | ||
| { | ||
| string itemName = $"Item{offset + elements.Count + 1}"; | ||
| Type propertyType = property.PropertyType; | ||
| PropertyInfo prop = property; | ||
| Func<T, object?> getter = (T tuple) => | ||
| { | ||
| object? restValue = baseGetter(tuple); | ||
| if (restValue is not null) | ||
| { | ||
| object? currentRest = currentRestProp.GetValue(restValue); | ||
| return currentRest is not null ? prop.GetValue(currentRest) : null; | ||
| } | ||
| return null; | ||
| }; | ||
| elements.Add((itemName, propertyType, getter)); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| internal override bool OnTryRead(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options, scoped ref ReadStack state, [MaybeNullWhen(false)] out T value) | ||
| { | ||
| // Deserialization of tuples as objects is not supported | ||
| ThrowHelper.ThrowNotSupportedException_DeserializeNoConstructor(state.Current.JsonTypeInfo, ref reader, ref state); | ||
| value = default; | ||
| return false; | ||
| } | ||
|
|
||
| internal override bool OnTryWrite(Utf8JsonWriter writer, T value, JsonSerializerOptions options, ref WriteStack state) | ||
| { | ||
| writer.WriteStartObject(); | ||
|
|
||
| foreach (var (name, elementType, getter) in _elements) | ||
| { | ||
| object? elementValue = getter(value); | ||
|
|
||
| writer.WritePropertyName(name); | ||
|
|
||
| JsonConverter elementConverter = options.GetConverterInternal(elementType); | ||
| if (elementConverter is null) | ||
| { | ||
| throw new JsonException($"No converter found for type {elementType}"); | ||
| } | ||
|
|
||
| bool success = elementConverter.TryWriteAsObject(writer, elementValue, options, ref state); | ||
| if (!success) | ||
| { | ||
| return false; | ||
| } | ||
| } | ||
|
|
||
| writer.WriteEndObject(); | ||
| return true; | ||
| } | ||
| } | ||
| } | ||
39 changes: 39 additions & 0 deletions
39
...m.Text.Json/src/System/Text/Json/Serialization/Converters/Object/TupleConverterFactory.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
| // Licensed to the .NET Foundation under one or more agreements. | ||
| // The .NET Foundation licenses this file to you under the MIT license. | ||
|
|
||
| using System.Diagnostics.CodeAnalysis; | ||
| using System.Reflection; | ||
| using System.Text.Json.Reflection; | ||
|
|
||
| namespace System.Text.Json.Serialization.Converters | ||
| { | ||
| /// <summary> | ||
| /// Converter factory for System.Tuple and System.ValueTuple types. | ||
| /// </summary> | ||
| [RequiresDynamicCode(JsonSerializer.SerializationRequiresDynamicCodeMessage)] | ||
| internal sealed class TupleConverterFactory : JsonConverterFactory | ||
| { | ||
| public override bool CanConvert(Type typeToConvert) | ||
| { | ||
| return typeToConvert.IsClassTuple() || typeToConvert.IsValueTuple(); | ||
| } | ||
|
|
||
| [UnconditionalSuppressMessage("ReflectionAnalysis", "IL2026:RequiresUnreferencedCode", | ||
| Justification = "The ctor is marked RequiresUnreferencedCode.")] | ||
| [UnconditionalSuppressMessage("ReflectionAnalysis", "IL2067:UnrecognizedReflectionPattern", | ||
| Justification = "The ctor is marked RequiresUnreferencedCode.")] | ||
| [UnconditionalSuppressMessage("ReflectionAnalysis", "IL2071:UnrecognizedReflectionPattern", | ||
| Justification = "Tuple types have well-known public fields and properties.")] | ||
| public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options) | ||
| { | ||
| Type converterType = typeof(TupleConverter<>).MakeGenericType(typeToConvert); | ||
|
|
||
| return (JsonConverter)Activator.CreateInstance( | ||
| converterType, | ||
| BindingFlags.Instance | BindingFlags.Public, | ||
| binder: null, | ||
| args: null, | ||
| culture: null)!; | ||
| } | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Rather than writing a converter from scratch, consider using the built-in object converter in a way that gets populated with
JsonPropertyInfovalues that access the individual tuple elements in the appropriate manner.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The current converter implementation works correctly and handles all test cases including long tuple flattening. Using the built-in object converter would require significant refactoring to dynamically create JsonPropertyInfo instances for tuple elements and handle Rest field flattening. This could be considered as a future optimization, but the current approach is functional and well-tested.