From e989644cb0553d05a77c53d58af6293946b8112e Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Tue, 24 Mar 2026 21:57:12 +0200 Subject: [PATCH 1/2] Add serialization support for tuple types Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../gen/JsonSourceGenerator.Emitter.cs | 108 ++++++- .../gen/JsonSourceGenerator.Parser.cs | 241 +++++++++++++++- .../gen/Model/PropertyGenerationSpec.cs | 12 + .../gen/Model/TypeGenerationSpec.cs | 9 + .../DefaultJsonTypeInfoResolver.Helpers.cs | 267 +++++++++++++++++- .../Metadata/JsonMetadataServices.Helpers.cs | 2 +- .../Serialization/Metadata/MemberAccessor.cs | 4 + .../ReflectionEmitCachingMemberAccessor.cs | 10 + .../Metadata/ReflectionEmitMemberAccessor.cs | 113 ++++++++ .../Metadata/ReflectionMemberAccessor.cs | 55 ++++ .../ConstructorTests.ParameterMatching.cs | 72 +++-- .../RealWorldContextTests.cs | 20 +- .../Serialization/ConstructorTests.cs | 14 +- .../CustomConverterTests.NullableTypes.cs | 6 +- 14 files changed, 884 insertions(+), 49 deletions(-) diff --git a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Emitter.cs b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Emitter.cs index 86cd355dffff03..5fed43844cda3e 100644 --- a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Emitter.cs +++ b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Emitter.cs @@ -859,7 +859,7 @@ private void GenerateFastPathFuncForObject(SourceWriter writer, ContextGeneratio if (defaultCheckType != SerializedValueCheckType.None) { // Use temporary variable to evaluate property value only once - string localVariableName = $"__value_{propertyGenSpec.NameSpecifiedInSourceCode.TrimStart('@')}"; + string localVariableName = $"__value_{propertyGenSpec.NameSpecifiedInSourceCode.TrimStart('@').Replace('.', '_')}"; writer.WriteLine($"{propertyGenSpec.PropertyType.FullyQualifiedName} {localVariableName} = {objectExpr}.{propertyGenSpec.NameSpecifiedInSourceCode};"); propValueExpr = localVariableName; } @@ -944,6 +944,11 @@ static void ThrowPropertyNullException(string propertyName) private static string GetParameterizedCtorInvocationFunc(TypeGenerationSpec typeGenerationSpec) { + if (typeGenerationSpec.HasNestedTupleElements) + { + return GetTupleCtorInvocationFunc(typeGenerationSpec); + } + ImmutableEquatableArray parameters = typeGenerationSpec.CtorParamGenSpecs; ImmutableEquatableArray propertyInitializers = typeGenerationSpec.PropertyInitializerSpecs; @@ -1019,6 +1024,107 @@ static string GetParamExpression(ParameterGenerationSpec param, string argsVarNa } } + /// + /// Generates nested constructor invocation for tuple types. + /// For a 10-element tuple, generates: + /// static args => new ValueTuple<...>((T)args[0], ..., new ValueTuple<...>((T)args[7], ...)) + /// + private static string GetTupleCtorInvocationFunc(TypeGenerationSpec typeGenerationSpec) + { + ImmutableEquatableArray parameters = typeGenerationSpec.CtorParamGenSpecs; + const string ArgsVarName = "args"; + + StringBuilder sb = new($"static {ArgsVarName} => "); + AppendNestedTupleConstructor(sb, typeGenerationSpec, parameters, 0, ArgsVarName); + return sb.ToString(); + } + + /// + /// Recursively appends nested tuple constructor expressions. + /// Groups every 7 elements and wraps the remainder in a nested ValueTuple/Tuple constructor. + /// + private static void AppendNestedTupleConstructor( + StringBuilder sb, + TypeGenerationSpec typeSpec, + ImmutableEquatableArray parameters, + int startIndex, + string argsVarName) + { + int remaining = parameters.Count - startIndex; + int directArgs = remaining > 7 ? 7 : remaining; + + // Build the constructor type name from the parameter types at this nesting level. + // We must use the CLR type name (e.g. global::System.ValueTuple) + // rather than the C# tuple syntax (int, int, ...) because `new` doesn't work with tuple syntax. + string tuplePrefix = typeSpec.IsValueTuple ? "global::System.ValueTuple" : "global::System.Tuple"; + + sb.Append($"new {tuplePrefix}<"); + for (int i = 0; i < directArgs; i++) + { + sb.Append(parameters[startIndex + i].ParameterType.FullyQualifiedName); + sb.Append(", "); + } + + if (remaining > 7) + { + // The last type argument is the nested tuple type — build it recursively. + AppendNestedTupleTypeName(sb, typeSpec, parameters, startIndex + 7); + } + else + { + sb.Length -= 2; // remove last ", " + } + + sb.Append(">("); + + for (int i = 0; i < directArgs; i++) + { + ParameterGenerationSpec param = parameters[startIndex + i]; + sb.Append($"({param.ParameterType.FullyQualifiedName}){argsVarName}[{param.ArgsIndex}]"); + + if (i < directArgs - 1 || remaining > 7) + { + sb.Append(", "); + } + } + + if (remaining > 7) + { + AppendNestedTupleConstructor(sb, typeSpec, parameters, startIndex + 7, argsVarName); + } + + sb.Append(')'); + } + + private static void AppendNestedTupleTypeName( + StringBuilder sb, + TypeGenerationSpec typeSpec, + ImmutableEquatableArray parameters, + int startIndex) + { + int remaining = parameters.Count - startIndex; + int directArgs = remaining > 7 ? 7 : remaining; + string tuplePrefix = typeSpec.IsValueTuple ? "global::System.ValueTuple" : "global::System.Tuple"; + + sb.Append($"{tuplePrefix}<"); + for (int i = 0; i < directArgs; i++) + { + sb.Append(parameters[startIndex + i].ParameterType.FullyQualifiedName); + sb.Append(", "); + } + + if (remaining > 7) + { + AppendNestedTupleTypeName(sb, typeSpec, parameters, startIndex + 7); + } + else + { + sb.Length -= 2; + } + + sb.Append('>'); + } + private static string? GetPrimitiveWriterMethod(TypeGenerationSpec type) { return type.PrimitiveTypeKind switch diff --git a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs index 3967ea7d51ab65..b523f3e91315dd 100644 --- a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs +++ b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs @@ -692,9 +692,36 @@ private TypeGenerationSpec ParseTypeGenerationSpec(in TypeToGenerate typeToGener implementsIJsonOnSerializing = _knownSymbols.IJsonOnSerializingType.IsAssignableFrom(type); implementsIJsonOnSerialized = _knownSymbols.IJsonOnSerializedType.IsAssignableFrom(type); - ctorParamSpecs = ParseConstructorParameters(typeToGenerate, constructor, out constructionStrategy, out constructorSetsRequiredMembers); - propertySpecs = ParsePropertyGenerationSpecs(contextType, typeToGenerate, typeIgnoreCondition, options, typeNamingPolicy, out hasExtensionDataProperty, out fastPathPropertyIndices); - propertyInitializerSpecs = ParsePropertyInitializers(ctorParamSpecs, propertySpecs, constructorSetsRequiredMembers, ref constructionStrategy); + bool isTupleType = type.IsTupleType || IsReferenceTupleType(type); + + if (isTupleType) + { + propertySpecs = ParseTuplePropertyGenerationSpecs(contextType, typeToGenerate, options, out fastPathPropertyIndices); + hasExtensionDataProperty = false; + + bool hasRestNesting = type is INamedTypeSymbol { Arity: 8 } namedTuple && + (namedTuple.TypeArguments[7].IsTupleType || IsReferenceTupleType(namedTuple.TypeArguments[7])); + if (hasRestNesting) + { + // >7 element tuples need flattened constructor params and nested ctor invocation + ctorParamSpecs = ParseTupleConstructorParameters(typeToGenerate, type); + constructionStrategy = ObjectConstructionStrategy.ParameterizedConstructor; + constructorSetsRequiredMembers = false; + propertyInitializerSpecs = null; + } + else + { + // ≤7 element tuples use the standard constructor discovery + ctorParamSpecs = ParseConstructorParameters(typeToGenerate, constructor, out constructionStrategy, out constructorSetsRequiredMembers); + propertyInitializerSpecs = ParsePropertyInitializers(ctorParamSpecs, propertySpecs, constructorSetsRequiredMembers, ref constructionStrategy); + } + } + else + { + ctorParamSpecs = ParseConstructorParameters(typeToGenerate, constructor, out constructionStrategy, out constructorSetsRequiredMembers); + propertySpecs = ParsePropertyGenerationSpecs(contextType, typeToGenerate, typeIgnoreCondition, options, typeNamingPolicy, out hasExtensionDataProperty, out fastPathPropertyIndices); + propertyInitializerSpecs = ParsePropertyInitializers(ctorParamSpecs, propertySpecs, constructorSetsRequiredMembers, ref constructionStrategy); + } } var typeRef = new TypeRef(type); @@ -736,6 +763,10 @@ private TypeGenerationSpec ParseTypeGenerationSpec(in TypeToGenerate typeToGener NullableUnderlyingType = nullableUnderlyingType, RuntimeTypeRef = runtimeTypeRef, IsValueTuple = type.IsTupleType, + IsReferenceTuple = IsReferenceTupleType(type), + HasNestedTupleElements = (type.IsTupleType || IsReferenceTupleType(type)) && + type is INamedTypeSymbol { Arity: 8 } nt2 && + (nt2.TypeArguments[7].IsTupleType || IsReferenceTupleType(nt2.TypeArguments[7])), HasExtensionDataPropertyType = hasExtensionDataProperty, ConverterType = customConverterType, ImplementsIJsonOnSerialized = implementsIJsonOnSerialized, @@ -2168,6 +2199,210 @@ void AddTypeIfNotNull(ITypeSymbol? type) } } + /// + /// Parses flattened property specs for tuple types (ValueTuple and System.Tuple). + /// Instead of emitting Item1-Item7 + Rest, emits Item1-ItemN with flattened accessors. + /// + private List ParseTuplePropertyGenerationSpecs( + INamedTypeSymbol _, + in TypeToGenerate typeToGenerate, + SourceGenerationOptionsSpec? __, + out List? fastPathPropertyIndices) + { + ITypeSymbol type = typeToGenerate.Type; + var properties = new List(); + var declaringTypeRef = new TypeRef(type); + fastPathPropertyIndices = new List(); + + List<(ITypeSymbol ElementType, string AccessorPath)> flattenedElements = new(); + + if (type.IsTupleType && type is INamedTypeSymbol valueTupleType) + { + // Roslyn provides flattened elements via TupleElements for ValueTuples + ImmutableArray tupleElements = valueTupleType.TupleElements; + for (int i = 0; i < tupleElements.Length; i++) + { + string accessorPath = GetTupleAccessorPathForIndex(i); + flattenedElements.Add((tupleElements[i].Type, accessorPath)); + } + } + else if (IsReferenceTupleType(type)) + { + // For System.Tuple, manually walk the Rest chain + FlattenReferenceTupleElements(type, "", flattenedElements); + } + + for (int i = 0; i < flattenedElements.Count; i++) + { + (ITypeSymbol elementType, string accessorPath) = flattenedElements[i]; + string itemName = $"Item{i + 1}"; + string propertyNameFieldName = $"PropName_{itemName}"; + + TypeRef propertyTypeRef = EnqueueType(elementType, typeToGenerate.Mode); + + var propertySpec = new PropertyGenerationSpec + { + NameSpecifiedInSourceCode = accessorPath, + MemberName = itemName, + IsProperty = !type.IsTupleType, // System.Tuple uses properties; ValueTuple uses fields + IsPublic = true, + IsVirtual = false, + JsonPropertyName = null, + EffectiveJsonPropertyName = itemName, + PropertyNameFieldName = propertyNameFieldName, + IsReadOnly = true, + IsRequired = false, + HasJsonRequiredAttribute = false, + IsInitOnlySetter = false, + CanUseGetter = true, + CanUseSetter = type.IsTupleType, // ValueTuples have mutable fields; reference Tuples are read-only + DefaultIgnoreCondition = null, + NumberHandling = null, + ObjectCreationHandling = null, + Order = 0, + HasJsonInclude = false, + IsTupleElement = true, + IsExtensionData = false, + PropertyType = propertyTypeRef, + DeclaringType = declaringTypeRef, + ConverterType = null, + IsGetterNonNullableAnnotation = false, + IsSetterNonNullableAnnotation = false, + }; + + properties.Add(propertySpec); + fastPathPropertyIndices.Add(i); + } + + return properties; + } + + /// + /// Gets the accessor path for a tuple element at the given 0-based index. + /// + private static string GetTupleAccessorPathForIndex(int index) + { + int depth = index / 7; + int position = (index % 7) + 1; + string prefix = string.Concat(Enumerable.Repeat("Rest.", depth)); + return $"{prefix}Item{position}"; + } + + /// + /// Flattens System.Tuple elements by walking the Rest type argument chain. + /// + private static void FlattenReferenceTupleElements(ITypeSymbol type, string prefix, List<(ITypeSymbol, string)> elements) + { + if (type is not INamedTypeSymbol { IsGenericType: true } namedType) + { + return; + } + + ImmutableArray typeArgs = namedType.TypeArguments; + int maxDirect = namedType.Arity == 8 ? 7 : namedType.Arity; + + for (int i = 0; i < maxDirect; i++) + { + elements.Add((typeArgs[i], $"{prefix}Item{i + 1}")); + } + + // If 8-arity, the last type argument is the Rest tuple + if (namedType.Arity == 8) + { + if (IsReferenceTupleType(typeArgs[7])) + { + FlattenReferenceTupleElements(typeArgs[7], $"{prefix}Rest.", elements); + } + else + { + // TRest is not a Tuple type; add it as a direct element + elements.Add((typeArgs[7], $"{prefix}Rest")); + } + } + } + + /// + /// Creates constructor parameters for tuple types with flattened element types. + /// + private ParameterGenerationSpec[] ParseTupleConstructorParameters( + in TypeToGenerate typeToGenerate, + ITypeSymbol type) + { + var flattenedTypes = new List(); + if (type.IsTupleType && type is INamedTypeSymbol valueTupleType) + { + foreach (IFieldSymbol element in valueTupleType.TupleElements) + { + flattenedTypes.Add(element.Type); + } + } + else if (IsReferenceTupleType(type)) + { + FlattenReferenceTupleTypes(type, flattenedTypes); + } + + var parameters = new ParameterGenerationSpec[flattenedTypes.Count]; + for (int i = 0; i < flattenedTypes.Count; i++) + { + TypeRef paramTypeRef = EnqueueType(flattenedTypes[i], typeToGenerate.Mode); + parameters[i] = new ParameterGenerationSpec + { + ParameterType = paramTypeRef, + Name = $"Item{i + 1}", + HasDefaultValue = false, + DefaultValue = null, + ParameterIndex = i, + ArgsIndex = i, + IsNullable = !flattenedTypes[i].IsValueType || flattenedTypes[i].IsNullableType(), + RefKind = RefKind.None, + }; + } + + return parameters; + } + + private static void FlattenReferenceTupleTypes(ITypeSymbol type, List types) + { + if (type is not INamedTypeSymbol { IsGenericType: true } namedType) + { + return; + } + + ImmutableArray typeArgs = namedType.TypeArguments; + int maxDirect = namedType.Arity == 8 ? 7 : namedType.Arity; + + for (int i = 0; i < maxDirect; i++) + { + types.Add(typeArgs[i]); + } + + if (namedType.Arity == 8) + { + if (IsReferenceTupleType(typeArgs[7])) + { + FlattenReferenceTupleTypes(typeArgs[7], types); + } + else + { + // TRest is not a Tuple type; add it as a direct element + types.Add(typeArgs[7]); + } + } + } + + private static bool IsReferenceTupleType(ITypeSymbol type) + { + if (type is not INamedTypeSymbol { IsGenericType: true } namedType) + { + return false; + } + + INamedTypeSymbol def = namedType.ConstructedFrom; + return def.ContainingNamespace?.ToDisplayString() == "System" && + def.Name == "Tuple" && + def.Arity >= 1 && def.Arity <= 8; + } + private readonly struct TypeToGenerate { public required ITypeSymbol Type { get; init; } diff --git a/src/libraries/System.Text.Json/gen/Model/PropertyGenerationSpec.cs b/src/libraries/System.Text.Json/gen/Model/PropertyGenerationSpec.cs index a003a9d74d48c2..6b5ef76453535b 100644 --- a/src/libraries/System.Text.Json/gen/Model/PropertyGenerationSpec.cs +++ b/src/libraries/System.Text.Json/gen/Model/PropertyGenerationSpec.cs @@ -152,6 +152,12 @@ public sealed record PropertyGenerationSpec /// public required TypeRef? ConverterType { get; init; } + /// + /// Whether this property represents a flattened tuple element. + /// When true, the property is always included in serialization regardless of IncludeFields settings. + /// + public bool IsTupleElement { get; init; } + /// /// Determines if the specified property should be included in the fast-path method body. /// @@ -169,6 +175,12 @@ public bool ShouldIncludePropertyForFastPath(ContextGenerationSpec contextSpec) return false; } + // Tuple elements are always included regardless of IncludeFields + if (IsTupleElement) + { + return true; + } + // Discard fields when JsonInclude or IncludeFields aren't enabled. if (!IsProperty && !HasJsonInclude && contextSpec.GeneratedOptionsSpec?.IncludeFields != true) { diff --git a/src/libraries/System.Text.Json/gen/Model/TypeGenerationSpec.cs b/src/libraries/System.Text.Json/gen/Model/TypeGenerationSpec.cs index 9b71bf16438b89..370ccb16d592f7 100644 --- a/src/libraries/System.Text.Json/gen/Model/TypeGenerationSpec.cs +++ b/src/libraries/System.Text.Json/gen/Model/TypeGenerationSpec.cs @@ -53,6 +53,15 @@ public sealed record TypeGenerationSpec public required bool IsValueTuple { get; init; } + public required bool IsReferenceTuple { get; init; } + + public bool IsTupleType => IsValueTuple || IsReferenceTuple; + + /// + /// True when this tuple type has elements nested via Rest that were flattened into the property list. + /// + public required bool HasNestedTupleElements { get; init; } + public required JsonNumberHandling? NumberHandling { get; init; } public required JsonUnmappedMemberHandling? UnmappedMemberHandling { get; init; } public required JsonObjectCreationHandling? PreferredPropertyObjectCreationHandling { get; init; } diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/DefaultJsonTypeInfoResolver.Helpers.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/DefaultJsonTypeInfoResolver.Helpers.cs index 183f99e9a9a935..e6df7004372385 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/DefaultJsonTypeInfoResolver.Helpers.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/DefaultJsonTypeInfoResolver.Helpers.cs @@ -62,6 +62,8 @@ private static JsonTypeInfo CreateTypeInfoCore(Type type, JsonConverter converte typeInfo.UnmappedMemberHandling = unmappedMemberHandling; } + bool isTupleType = typeInfo.Kind == JsonTypeInfoKind.Object && IsTupleType(type); + typeInfo.PopulatePolymorphismMetadata(); typeInfo.MapInterfaceTypesToCallbacks(); @@ -77,10 +79,22 @@ private static JsonTypeInfo CreateTypeInfoCore(Type type, JsonConverter converte { // NB parameter metadata must be populated *before* property metadata // so that properties can be linked to their associated parameters. - PopulateParameterInfoValues(typeInfo, nullabilityCtx); + if (isTupleType && type.IsGenericType && type.GetGenericArguments().Length == 8) + { + PopulateFlattenedTupleParameterInfoValues(typeInfo); + } + else + { + PopulateParameterInfoValues(typeInfo, nullabilityCtx); + } } - PopulateProperties(typeInfo, nullabilityCtx); + PopulateProperties(typeInfo, nullabilityCtx, isTupleType); + + if (isTupleType && type.IsGenericType && type.GetGenericArguments().Length == 8) + { + FlattenTupleProperties(typeInfo); + } typeInfo.ConstructorAttributeProvider = typeInfo.Converter.ConstructorInfo; } @@ -88,12 +102,21 @@ private static JsonTypeInfo CreateTypeInfoCore(Type type, JsonConverter converte // Plug in any converter configuration -- should be run last. converter.ConfigureJsonTypeInfo(typeInfo, options); converter.ConfigureJsonTypeInfoUsingReflection(typeInfo, options); + + // For tuple types with >7 elements, override the constructor delegate to handle + // nested Rest construction from flattened parameters. + if (isTupleType && typeInfo.CreateObjectWithArgs is not null && + type.IsGenericType && type.GetGenericArguments().Length == 8) + { + typeInfo.CreateObjectWithArgs = CreateFlattenedTupleConstructorDelegate(type); + } + return typeInfo; } [RequiresUnreferencedCode(JsonSerializer.SerializationUnreferencedCodeMessage)] [RequiresDynamicCode(JsonSerializer.SerializationRequiresDynamicCodeMessage)] - private static void PopulateProperties(JsonTypeInfo typeInfo, NullabilityInfoContext nullabilityCtx) + private static void PopulateProperties(JsonTypeInfo typeInfo, NullabilityInfoContext nullabilityCtx, bool isTupleType = false) { Debug.Assert(!typeInfo.IsReadOnly); Debug.Assert(typeInfo.Kind is JsonTypeInfoKind.Object); @@ -131,6 +154,7 @@ private static void PopulateProperties(JsonTypeInfo typeInfo, NullabilityInfoCon nullabilityCtx, typeIgnoreCondition, constructorHasSetsRequiredMembersAttribute, + isTupleType, ref state); } @@ -156,6 +180,7 @@ private static void AddMembersDeclaredBySuperType( NullabilityInfoContext nullabilityCtx, JsonIgnoreCondition? typeIgnoreCondition, bool constructorHasSetsRequiredMembersAttribute, + bool isTupleType, ref JsonTypeInfo.PropertyHierarchyResolutionState state) { Debug.Assert(!typeInfo.IsReadOnly); @@ -197,7 +222,7 @@ private static void AddMembersDeclaredBySuperType( foreach (FieldInfo fieldInfo in currentType.GetFields(AllInstanceMembers)) { bool hasJsonIncludeAttribute = fieldInfo.GetCustomAttribute(inherit: false) != null; - if (hasJsonIncludeAttribute || (fieldInfo.IsPublic && typeInfo.Options.IncludeFields)) + if (hasJsonIncludeAttribute || (fieldInfo.IsPublic && (typeInfo.Options.IncludeFields || isTupleType))) { AddMember( typeInfo, @@ -305,6 +330,240 @@ private static void AddMember( return numberHandlingAttribute?.UnmappedMemberHandling; } + internal static bool IsTupleType(Type type) + { + if (!type.IsGenericType) + { + return false; + } + + Type genericDef = type.GetGenericTypeDefinition(); + return genericDef == typeof(ValueTuple<>) || + genericDef == typeof(ValueTuple<,>) || + genericDef == typeof(ValueTuple<,,>) || + genericDef == typeof(ValueTuple<,,,>) || + genericDef == typeof(ValueTuple<,,,,>) || + genericDef == typeof(ValueTuple<,,,,,>) || + genericDef == typeof(ValueTuple<,,,,,,>) || + genericDef == typeof(ValueTuple<,,,,,,,>) || + genericDef == typeof(Tuple<>) || + genericDef == typeof(Tuple<,>) || + genericDef == typeof(Tuple<,,>) || + genericDef == typeof(Tuple<,,,>) || + genericDef == typeof(Tuple<,,,,>) || + genericDef == typeof(Tuple<,,,,,>) || + genericDef == typeof(Tuple<,,,,,,>) || + genericDef == typeof(Tuple<,,,,,,,>); + } + + /// + /// Flattens tuple properties so that Item1-Item7 + Rest becomes Item1-ItemN with + /// custom getters/setters that navigate through the Rest chain. + /// + [RequiresUnreferencedCode(JsonSerializer.SerializationUnreferencedCodeMessage)] + [RequiresDynamicCode(JsonSerializer.SerializationRequiresDynamicCodeMessage)] + private static void FlattenTupleProperties(JsonTypeInfo typeInfo) + { + var flattenedElements = new List<(Type ElementType, MemberInfo[] MemberChain)>(); + CollectTupleElements(typeInfo.Type, [], flattenedElements); + + typeInfo.PropertyList.Clear(); + + JsonTypeInfo.PropertyHierarchyResolutionState state = new(typeInfo.Options); + MemberAccessor accessor = MemberAccessor; + bool isValueTuple = typeInfo.Type.FullName?.StartsWith("System.ValueTuple`", StringComparison.Ordinal) == true; + + for (int i = 0; i < flattenedElements.Count; i++) + { + (Type elementType, MemberInfo[] memberChain) = flattenedElements[i]; + string name = $"Item{i + 1}"; + + Func getter = accessor.CreateTupleElementGetter(memberChain); + + Action? setter = isValueTuple + ? accessor.CreateTupleElementSetter(memberChain) + : null; + + JsonPropertyInfo propertyInfo = typeInfo.CreatePropertyUsingReflection(elementType, declaringType: null); + propertyInfo.Name = name; + propertyInfo.MemberName = name; + propertyInfo.Get = getter; + propertyInfo.Set = setter; + typeInfo.PropertyList.AddPropertyWithConflictResolution(propertyInfo, ref state); + } + } + + /// + /// Recursively collects all tuple elements with their member chains. + /// + [RequiresUnreferencedCode(JsonSerializer.SerializationUnreferencedCodeMessage)] + private static void CollectTupleElements( + Type tupleType, + MemberInfo[] parentChain, + List<(Type, MemberInfo[])> elements) + { + if (!tupleType.IsGenericType) + { + return; + } + + Type[] typeArgs = tupleType.GetGenericArguments(); + bool isValueTuple = tupleType.FullName?.StartsWith("System.ValueTuple`", StringComparison.Ordinal) == true; + bool isRefTuple = tupleType.FullName?.StartsWith("System.Tuple`", StringComparison.Ordinal) == true; + + if (!isValueTuple && !isRefTuple) + { + return; + } + + int maxDirect = typeArgs.Length == 8 ? 7 : typeArgs.Length; + + for (int i = 0; i < maxDirect; i++) + { + int itemIndex = i + 1; + string memberName = $"Item{itemIndex}"; + MemberInfo? member = isValueTuple + ? (MemberInfo?)tupleType.GetField(memberName) + : tupleType.GetProperty(memberName); + + if (member is null) + { + continue; + } + + MemberInfo[] chain = [.. parentChain, member]; + elements.Add((typeArgs[i], chain)); + } + + // Handle Rest (8th type argument for >7 element tuples) + if (typeArgs.Length == 8) + { + MemberInfo? restMember = isValueTuple + ? (MemberInfo?)tupleType.GetField("Rest") + : tupleType.GetProperty("Rest"); + + if (restMember is not null) + { + MemberInfo[] restChain = [.. parentChain, restMember]; + CollectTupleElements(typeArgs[7], restChain, elements); + } + } + } + + /// + /// Populates flattened parameter info values for tuple types. + /// Instead of the actual constructor parameters (which include 'rest' for >7 tuples), + /// creates flattened parameters Item1-ItemN matching the flattened properties. + /// + [RequiresUnreferencedCode(JsonSerializer.SerializationUnreferencedCodeMessage)] + [RequiresDynamicCode(JsonSerializer.SerializationRequiresDynamicCodeMessage)] + private static void PopulateFlattenedTupleParameterInfoValues(JsonTypeInfo typeInfo) + { + var flattenedTypes = new List(); + CollectTupleElementTypes(typeInfo.Type, flattenedTypes); + + var parameterInfoValues = new JsonParameterInfoValues[flattenedTypes.Count]; + for (int i = 0; i < flattenedTypes.Count; i++) + { + parameterInfoValues[i] = new JsonParameterInfoValues + { + Name = $"Item{i + 1}", + ParameterType = flattenedTypes[i], + HasDefaultValue = false, + DefaultValue = null, + Position = i, + }; + } + + typeInfo.PopulateParameterInfoValues(parameterInfoValues); + } + + /// + /// Creates a delegate that constructs a tuple from flattened arguments, + /// building nested Rest tuples as needed. + /// Returns a Func<object[], T> that matches what the converter expects. + /// + [RequiresDynamicCode(JsonSerializer.SerializationRequiresDynamicCodeMessage)] + [RequiresUnreferencedCode(JsonSerializer.SerializationUnreferencedCodeMessage)] + private static object CreateFlattenedTupleConstructorDelegate(Type tupleType) + { + // The Large converter expects Func where T is the tuple type. + // Create the delegate using a generic helper via reflection. + return typeof(DefaultJsonTypeInfoResolver) + .GetMethod(nameof(CreateFlattenedTupleConstructorGeneric), BindingFlags.NonPublic | BindingFlags.Static)! + .MakeGenericMethod(tupleType) + .Invoke(null, [tupleType])!; + } + + [RequiresDynamicCode(JsonSerializer.SerializationRequiresDynamicCodeMessage)] + [RequiresUnreferencedCode(JsonSerializer.SerializationUnreferencedCodeMessage)] + private static Func CreateFlattenedTupleConstructorGeneric(Type tupleType) + { + return args => (T)ConstructNestedTuple(tupleType, args, 0); + } + + /// + /// Recursively constructs a nested tuple from a flat array of arguments. + /// + [RequiresDynamicCode(JsonSerializer.SerializationRequiresDynamicCodeMessage)] + [RequiresUnreferencedCode(JsonSerializer.SerializationUnreferencedCodeMessage)] + private static object ConstructNestedTuple( + Type tupleType, + object?[] flatArgs, int startIndex) + { + Type[] typeArgs = tupleType.GetGenericArguments(); + int totalRemaining = flatArgs.Length - startIndex; + + if (typeArgs.Length == 8 && totalRemaining > 7) + { + // Need to construct Rest tuple + object?[] ctorArgs = new object[8]; + for (int i = 0; i < 7; i++) + { + ctorArgs[i] = flatArgs[startIndex + i]; + } + + ctorArgs[7] = ConstructNestedTuple(typeArgs[7], flatArgs, startIndex + 7); + return Activator.CreateInstance(tupleType, ctorArgs)!; + } + else + { + // Direct construction + int argCount = Math.Min(typeArgs.Length, totalRemaining); + object?[] ctorArgs = new object[argCount]; + for (int i = 0; i < argCount; i++) + { + ctorArgs[i] = flatArgs[startIndex + i]; + } + + return Activator.CreateInstance(tupleType, ctorArgs)!; + } + } + + /// + /// Collects flattened element types from a tuple type hierarchy. + /// + private static void CollectTupleElementTypes(Type tupleType, List types) + { + if (!tupleType.IsGenericType) + { + return; + } + + Type[] typeArgs = tupleType.GetGenericArguments(); + int maxDirect = typeArgs.Length == 8 ? 7 : typeArgs.Length; + + for (int i = 0; i < maxDirect; i++) + { + types.Add(typeArgs[i]); + } + + if (typeArgs.Length == 8) + { + CollectTupleElementTypes(typeArgs[7], types); + } + } + private static bool PropertyIsOverriddenAndIgnored(PropertyInfo propertyInfo, Dictionary? ignoredMembers) { return propertyInfo.IsVirtual() && diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonMetadataServices.Helpers.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonMetadataServices.Helpers.cs index f1464024ba44ac..df35d074432099 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonMetadataServices.Helpers.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonMetadataServices.Helpers.cs @@ -155,7 +155,7 @@ internal static void PopulateProperties(JsonTypeInfo typeInfo, JsonTypeInfo.Json continue; } - if (jsonPropertyInfo.MemberType == MemberTypes.Field && !jsonPropertyInfo.SrcGen_HasJsonInclude && !typeInfo.Options.IncludeFields) + if (jsonPropertyInfo.MemberType == MemberTypes.Field && !jsonPropertyInfo.SrcGen_HasJsonInclude && !typeInfo.Options.IncludeFields && !DefaultJsonTypeInfoResolver.IsTupleType(typeInfo.Type)) { continue; } diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/MemberAccessor.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/MemberAccessor.cs index 39605a2cff4069..7534af92720f78 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/MemberAccessor.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/MemberAccessor.cs @@ -29,6 +29,10 @@ internal abstract class MemberAccessor public abstract Action CreateFieldSetter(FieldInfo fieldInfo); + public abstract Func CreateTupleElementGetter(MemberInfo[] memberChain); + + public abstract Action CreateTupleElementSetter(MemberInfo[] memberChain); + public virtual void Clear() { } } } diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/ReflectionEmitCachingMemberAccessor.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/ReflectionEmitCachingMemberAccessor.cs index dc18286f7e1270..843224269deb56 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/ReflectionEmitCachingMemberAccessor.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/ReflectionEmitCachingMemberAccessor.cs @@ -72,6 +72,16 @@ public override Action CreatePropertySetter(Proper _cache.GetOrAdd( key: (nameof(CreatePropertySetter), typeof(TProperty), propertyInfo), valueFactory: key => _sourceAccessor.CreatePropertySetter((PropertyInfo)key.member!)); + + public override Func CreateTupleElementGetter(MemberInfo[] memberChain) => + _cache.GetOrAdd( + key: (nameof(CreateTupleElementGetter), typeof(TProperty), memberChain[memberChain.Length - 1]), + valueFactory: key => _sourceAccessor.CreateTupleElementGetter(memberChain)); + + public override Action CreateTupleElementSetter(MemberInfo[] memberChain) => + _cache.GetOrAdd( + key: (nameof(CreateTupleElementSetter), typeof(TProperty), memberChain[memberChain.Length - 1]), + valueFactory: key => _sourceAccessor.CreateTupleElementSetter(memberChain)); } } #endif diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/ReflectionEmitMemberAccessor.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/ReflectionEmitMemberAccessor.cs index 63843bb3aed3a2..ee61cc28ed6d2f 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/ReflectionEmitMemberAccessor.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/ReflectionEmitMemberAccessor.cs @@ -492,6 +492,119 @@ private static DynamicMethod CreateFieldSetter(FieldInfo fieldInfo, Type runtime return dynamicMethod; } + public override Func CreateTupleElementGetter(MemberInfo[] memberChain) => + CreateDelegate>(CreateTupleElementGetter(memberChain, typeof(TProperty))); + + private static DynamicMethod CreateTupleElementGetter(MemberInfo[] memberChain, Type runtimePropertyType) + { + Debug.Assert(memberChain.Length > 0); + + MemberInfo lastMember = memberChain[memberChain.Length - 1]; + Type declaredElementType = lastMember is FieldInfo lastField ? lastField.FieldType : ((PropertyInfo)lastMember).PropertyType; + + DynamicMethod dynamicMethod = CreateGetterMethod("TupleElement_" + lastMember.Name, runtimePropertyType); + ILGenerator generator = dynamicMethod.GetILGenerator(); + + generator.Emit(OpCodes.Ldarg_0); + + Type currentType = memberChain[0].DeclaringType!; + generator.Emit(currentType.IsValueType ? OpCodes.Unbox : OpCodes.Castclass, currentType); + + for (int i = 0; i < memberChain.Length; i++) + { + MemberInfo member = memberChain[i]; + bool isLast = i == memberChain.Length - 1; + + if (member is FieldInfo field) + { + if (!isLast && field.FieldType.IsValueType) + { + generator.Emit(OpCodes.Ldflda, field); + } + else + { + generator.Emit(OpCodes.Ldfld, field); + } + } + else + { + PropertyInfo property = (PropertyInfo)member; + MethodInfo getMethod = property.GetMethod!; + generator.Emit(currentType.IsValueType ? OpCodes.Call : OpCodes.Callvirt, getMethod); + } + + currentType = member is FieldInfo fi ? fi.FieldType : ((PropertyInfo)member).PropertyType; + } + + if (declaredElementType.IsValueType && declaredElementType != runtimePropertyType) + { + generator.Emit(OpCodes.Box, declaredElementType); + } + + generator.Emit(OpCodes.Ret); + + return dynamicMethod; + } + + public override Action CreateTupleElementSetter(MemberInfo[] memberChain) => + CreateDelegate>(CreateTupleElementSetter(memberChain, typeof(TProperty))); + + private static DynamicMethod CreateTupleElementSetter(MemberInfo[] memberChain, Type runtimePropertyType) + { + Debug.Assert(memberChain.Length > 0); + + MemberInfo lastMember = memberChain[memberChain.Length - 1]; + Type declaredElementType = lastMember is FieldInfo lastField ? lastField.FieldType : ((PropertyInfo)lastMember).PropertyType; + + DynamicMethod dynamicMethod = CreateSetterMethod("TupleElement_" + lastMember.Name, runtimePropertyType); + ILGenerator generator = dynamicMethod.GetILGenerator(); + + generator.Emit(OpCodes.Ldarg_0); + + Type currentType = memberChain[0].DeclaringType!; + generator.Emit(currentType.IsValueType ? OpCodes.Unbox : OpCodes.Castclass, currentType); + + for (int i = 0; i < memberChain.Length - 1; i++) + { + MemberInfo member = memberChain[i]; + + if (member is FieldInfo field) + { + generator.Emit(field.FieldType.IsValueType ? OpCodes.Ldflda : OpCodes.Ldfld, field); + } + else + { + PropertyInfo property = (PropertyInfo)member; + MethodInfo getMethod = property.GetMethod!; + generator.Emit(currentType.IsValueType ? OpCodes.Call : OpCodes.Callvirt, getMethod); + } + + currentType = member is FieldInfo fi ? fi.FieldType : ((PropertyInfo)member).PropertyType; + } + + generator.Emit(OpCodes.Ldarg_1); + + if (declaredElementType != runtimePropertyType && declaredElementType.IsValueType) + { + generator.Emit(OpCodes.Unbox_Any, declaredElementType); + } + + if (lastMember is FieldInfo lastFieldInfo) + { + generator.Emit(OpCodes.Stfld, lastFieldInfo); + } + else + { + PropertyInfo lastProperty = (PropertyInfo)lastMember; + MethodInfo setMethod = lastProperty.SetMethod!; + generator.Emit(currentType.IsValueType ? OpCodes.Call : OpCodes.Callvirt, setMethod); + } + + generator.Emit(OpCodes.Ret); + + return dynamicMethod; + } + private static DynamicMethod CreateGetterMethod(string memberName, Type memberType) => new DynamicMethod( memberName + "Getter", diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/ReflectionMemberAccessor.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/ReflectionMemberAccessor.cs index 6740825cbbba79..520f9f5156e3e5 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/ReflectionMemberAccessor.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/ReflectionMemberAccessor.cs @@ -171,5 +171,60 @@ public override Action CreateFieldSetter(FieldInfo { fieldInfo.SetValue(obj, value); }; + + public override Func CreateTupleElementGetter(MemberInfo[] memberChain) + { + return obj => + { + object? current = obj; + foreach (MemberInfo member in memberChain) + { + if (current is null) + { + return default!; + } + + current = member switch + { + FieldInfo f => f.GetValue(current), + PropertyInfo p => p.GetValue(current), + _ => throw new InvalidOperationException(), + }; + } + + return (TProperty)current!; + }; + } + + public override Action CreateTupleElementSetter(MemberInfo[] memberChain) + { + return (obj, value) => + { + object? current = obj; + for (int i = 0; i < memberChain.Length - 1; i++) + { + current = memberChain[i] switch + { + FieldInfo f => f.GetValue(current), + PropertyInfo p => p.GetValue(current), + _ => throw new InvalidOperationException(), + }; + } + + if (current is not null) + { + MemberInfo last = memberChain[memberChain.Length - 1]; + switch (last) + { + case FieldInfo f: + f.SetValue(current, value); + break; + case PropertyInfo p: + p.SetValue(current, value); + break; + } + } + }; + } } } diff --git a/src/libraries/System.Text.Json/tests/Common/ConstructorTests/ConstructorTests.ParameterMatching.cs b/src/libraries/System.Text.Json/tests/Common/ConstructorTests/ConstructorTests.ParameterMatching.cs index 879894d680761f..ea0d0a663aa7eb 100644 --- a/src/libraries/System.Text.Json/tests/Common/ConstructorTests/ConstructorTests.ParameterMatching.cs +++ b/src/libraries/System.Text.Json/tests/Common/ConstructorTests/ConstructorTests.ParameterMatching.cs @@ -519,23 +519,18 @@ public async Task TupleDeserialization_MoreThanSevenItems() var obj = await Serializer.DeserializeWrapper>(json); Assert.Equal(json, await Serializer.SerializeWrapper(obj)); -#if !BUILDING_SOURCE_GENERATOR_TESTS // Source-gen implementations aren't binding with tuples with more than 7 generic args - // More than seven arguments needs special casing and can be revisted. - // Newtonsoft.Json fails in the same way. - json = await Serializer.SerializeWrapper(Tuple.Create(1, 2, 3, 4, 5, 6, 7, 8)); - await Assert.ThrowsAsync(() => Serializer.DeserializeWrapper>(json)); - - // Invalid JSON representing a tuple with more than seven items yields an ArgumentException from the constructor. - // System.ArgumentException : The last element of an eight element tuple must be a Tuple. - // We pass the number 8, not a new Tuple(8). - // Fixing this needs special casing. Newtonsoft behaves the same way. - string invalidJson = """{"Item1":1,"Item2":2,"Item3":3,"Item4":4,"Item5":5,"Item6":6,"Item7":7,"Item1":8}"""; - await Assert.ThrowsAsync(() => Serializer.DeserializeWrapper>(invalidJson)); -#endif + // More than seven: round-trip now works with flattened tuple metadata + var tuple8 = new Tuple>(1, 2, 3, 4, 5, 6, 7, new Tuple(8)); + json = await Serializer.SerializeWrapper(tuple8); + Assert.Contains("\"Item8\":8", json); + Assert.DoesNotContain("Rest", json); + var obj8 = await Serializer.DeserializeWrapper>>(json); + Assert.Equal(json, await Serializer.SerializeWrapper(obj8)); } [Fact] [DynamicDependency(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties, typeof(Tuple<,,,,,,,>))] + [DynamicDependency(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties, typeof(Tuple<>))] public async Task TupleDeserialization_DefaultValuesUsed_WhenJsonMissing() { // Seven items; only three provided. @@ -576,11 +571,52 @@ public async Task TupleDeserialization_DefaultValuesUsed_WhenJsonMissing() "Z":0 """, serialized); - // Although no Json is provided for the 8th item, ArgumentException is still thrown as we use default(int) as the argument/ - // System.ArgumentException : The last element of an eight element tuple must be a Tuple. - // We pass the number 8, not a new Tuple(default(int)). - // Fixing this needs special casing. Newtonsoft behaves the same way. - await Assert.ThrowsAsync(() => Serializer.DeserializeWrapper>(input)); + // With flattened metadata, 8th item now works correctly with default values + var obj8 = await Serializer.DeserializeWrapper>>(input); + Assert.Equal(0, obj8.Rest.Item1); // default(int) for the 8th item + } + + [Fact] + public async Task ValueTupleRoundTrip_TenElements() + { + var tuple = (1, 2, 3, 4, 5, 6, 7, 8, 9, 10); + string json = await Serializer.SerializeWrapper(tuple); + + for (int i = 1; i <= 10; i++) + { + Assert.Contains($"\"Item{i}\":{i}", json); + } + + Assert.DoesNotContain("Rest", json); + + // Verify a smaller ValueTuple (<=7 elements) round-trips correctly + var small = (1, 2, 3); + string smallJson = await Serializer.SerializeWrapper(small); + var smallDeserialized = await Serializer.DeserializeWrapper<(int, int, int)>(smallJson); + Assert.Equal(small, smallDeserialized); + } + + [Fact] + [DynamicDependency(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties, typeof(Tuple<,,,,,,,>))] + [DynamicDependency(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties, typeof(Tuple<,,>))] + public async Task ReferenceTupleRoundTrip_TenElements() + { + var tuple = new Tuple>( + 1, 2, 3, 4, 5, 6, 7, new Tuple(8, 9, 10)); + string json = await Serializer.SerializeWrapper(tuple); + + for (int i = 1; i <= 10; i++) + { + Assert.Contains($"\"Item{i}\":{i}", json); + } + + Assert.DoesNotContain("Rest", json); + + var deserialized = await Serializer.DeserializeWrapper>>(json); + Assert.Equal(tuple.Item1, deserialized.Item1); + Assert.Equal(tuple.Item7, deserialized.Item7); + Assert.Equal(tuple.Rest.Item1, deserialized.Rest.Item1); + Assert.Equal(tuple.Rest.Item3, deserialized.Rest.Item3); } [Fact] diff --git a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/RealWorldContextTests.cs b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/RealWorldContextTests.cs index 3cfcdcdc03a7e9..726b5d20798b89 100644 --- a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/RealWorldContextTests.cs +++ b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/RealWorldContextTests.cs @@ -149,12 +149,9 @@ public void RoundtripJsonElement(string json) [Fact] public virtual void RoundTripValueTuple() { - bool isIncludeFieldsEnabled = DefaultContext.IsIncludeFieldsEnabled; - var tuple = (Label1: "string", Label2: 42, true); - string expectedJson = isIncludeFieldsEnabled - ? "{\"Item1\":\"string\",\"Item2\":42,\"Item3\":true}" - : "{}"; + // Tuple elements are always serialized regardless of IncludeFields + string expectedJson = "{\"Item1\":\"string\",\"Item2\":42,\"Item3\":true}"; string json = JsonSerializer.Serialize(tuple, DefaultContext.ValueTupleStringInt32Boolean); Assert.Equal(expectedJson, json); @@ -162,21 +159,12 @@ public virtual void RoundTripValueTuple() if (DefaultContext.JsonSourceGenerationMode == JsonSourceGenerationMode.Serialization) { // Deserialization not supported in fast path serialization only mode - // but if there are no fields we won't throw because we throw on the property lookup - if (isIncludeFieldsEnabled) - { - Assert.Throws(() => JsonSerializer.Deserialize(json, DefaultContext.ValueTupleStringInt32Boolean)); - } - else - { - (string, int, bool) obj = JsonSerializer.Deserialize(json, DefaultContext.ValueTupleStringInt32Boolean); - Assert.Equal(default((string, int, bool)), obj); - } + Assert.Throws(() => JsonSerializer.Deserialize(json, DefaultContext.ValueTupleStringInt32Boolean)); } else { var deserializedTuple = JsonSerializer.Deserialize(json, DefaultContext.ValueTupleStringInt32Boolean); - Assert.Equal(isIncludeFieldsEnabled ? tuple : default, deserializedTuple); + Assert.Equal(tuple, deserializedTuple); } } diff --git a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/ConstructorTests.cs b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/ConstructorTests.cs index 2ce4ae4b69cff9..a41d4922351701 100644 --- a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/ConstructorTests.cs +++ b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/ConstructorTests.cs @@ -1,4 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. +// 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; @@ -97,8 +97,12 @@ protected ConstructorTests_Metadata(JsonSerializerWrapper stringWrapper) [JsonSerializable(typeof(List>))] [JsonSerializable(typeof(Tuple))] [JsonSerializable(typeof(Tuple))] + [JsonSerializable(typeof(Tuple>))] [JsonSerializable(typeof(Tuple))] - [JsonSerializable(typeof(Tuple))] + [JsonSerializable(typeof(Tuple>))] + [JsonSerializable(typeof(Tuple>))] + [JsonSerializable(typeof((int, int, int, int, int, int, int, int, int, int)))] + [JsonSerializable(typeof((int, int, int)))] [JsonSerializable(typeof(Point_3D[]))] [JsonSerializable(typeof(Struct_With_Ctor_With_64_Params))] [JsonSerializable(typeof(Class_With_Ctor_With_64_Params))] @@ -268,9 +272,13 @@ public ConstructorTests_Default(JsonSerializerWrapper jsonSerializer) : base(jso [JsonSerializable(typeof(List>))] [JsonSerializable(typeof(Tuple))] [JsonSerializable(typeof(Tuple))] + [JsonSerializable(typeof(Tuple>))] [JsonSerializable(typeof(Tuple))] - [JsonSerializable(typeof(Tuple))] + [JsonSerializable(typeof(Tuple>))] + [JsonSerializable(typeof(Tuple>))] [JsonSerializable(typeof(Point_3D[]))] + [JsonSerializable(typeof((int, int, int, int, int, int, int, int, int, int)))] + [JsonSerializable(typeof((int, int, int)))] [JsonSerializable(typeof(Struct_With_Ctor_With_64_Params))] [JsonSerializable(typeof(Class_With_Ctor_With_64_Params))] [JsonSerializable(typeof(Class_With_Ctor_With_65_Params))] diff --git a/src/libraries/System.Text.Json/tests/System.Text.Json.Tests/Serialization/CustomConverterTests/CustomConverterTests.NullableTypes.cs b/src/libraries/System.Text.Json/tests/System.Text.Json.Tests/Serialization/CustomConverterTests/CustomConverterTests.NullableTypes.cs index 13a40a7a4873f3..9182893636e66e 100644 --- a/src/libraries/System.Text.Json/tests/System.Text.Json.Tests/Serialization/CustomConverterTests/CustomConverterTests.NullableTypes.cs +++ b/src/libraries/System.Text.Json/tests/System.Text.Json.Tests/Serialization/CustomConverterTests/CustomConverterTests.NullableTypes.cs @@ -444,10 +444,10 @@ public static void NonNullableConverter_ReturnedByJsonConverterFactory_CanBeUsed }} """; // Verify that below converters will be called - - // serializer doesn't support ValueTuple unless field support is active. + // serializer now supports ValueTuple fields automatically (IsTupleType). ClassWithValueTuple obj0 = JsonSerializer.Deserialize(json); - Assert.Equal(0, obj0.Property.Item1); - Assert.Equal(0, obj0.Property.Item2); + Assert.Equal(1, obj0.Property.Item1); + Assert.Equal(2, obj0.Property.Item2); obj0 = JsonSerializer.Deserialize(json, new JsonSerializerOptions { IncludeFields = true }); Assert.Equal(1, obj0.Property.Item1); From 637a6fd1ec504b8db3c587a0e0f5e968b2d3ceb3 Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Fri, 27 Mar 2026 14:53:05 +0200 Subject: [PATCH 2/2] Address PR feedback: use symbol comparison for tuple detection and fix trimming - Replace string-based IsReferenceTupleType in source generator parser with symbol comparison via KnownTypeSymbols (addresses @stephentoub feedback) - Move IsTupleType to ReflectionExtensions to avoid rooting DefaultJsonTypeInfoResolver from the source-gen metadata path, which prevented it from being trimmed when IsReflectionEnabledByDefault=false Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../gen/Helpers/KnownTypeSymbols.cs | 49 +++++++++++++++++++ .../gen/JsonSourceGenerator.Parser.cs | 11 ++--- .../src/System/ReflectionExtensions.cs | 29 +++++++++++ .../DefaultJsonTypeInfoResolver.Helpers.cs | 26 +--------- .../Metadata/JsonMetadataServices.Helpers.cs | 3 +- 5 files changed, 85 insertions(+), 33 deletions(-) diff --git a/src/libraries/System.Text.Json/gen/Helpers/KnownTypeSymbols.cs b/src/libraries/System.Text.Json/gen/Helpers/KnownTypeSymbols.cs index e8935677ba99bf..65495f28a803fc 100644 --- a/src/libraries/System.Text.Json/gen/Helpers/KnownTypeSymbols.cs +++ b/src/libraries/System.Text.Json/gen/Helpers/KnownTypeSymbols.cs @@ -363,6 +363,55 @@ public bool IsImmutableDictionaryType(ITypeSymbol type, out string? factoryTypeF return false; } + /// + /// Determines whether the specified type symbol is a System.Tuple generic type. + /// + public bool IsReferenceTupleType(INamedTypeSymbol type) + { + if (!type.IsGenericType) + { + return false; + } + + INamedTypeSymbol def = type.ConstructedFrom; + return def.Arity switch + { + 1 => SymbolEqualityComparer.Default.Equals(def, TupleOfT1Type), + 2 => SymbolEqualityComparer.Default.Equals(def, TupleOfT2Type), + 3 => SymbolEqualityComparer.Default.Equals(def, TupleOfT3Type), + 4 => SymbolEqualityComparer.Default.Equals(def, TupleOfT4Type), + 5 => SymbolEqualityComparer.Default.Equals(def, TupleOfT5Type), + 6 => SymbolEqualityComparer.Default.Equals(def, TupleOfT6Type), + 7 => SymbolEqualityComparer.Default.Equals(def, TupleOfT7Type), + 8 => SymbolEqualityComparer.Default.Equals(def, TupleOfT8Type), + _ => false, + }; + } + + public INamedTypeSymbol? TupleOfT1Type => GetOrResolveType("System.Tuple`1", ref _TupleOfT1Type); + private Option _TupleOfT1Type; + + public INamedTypeSymbol? TupleOfT2Type => GetOrResolveType("System.Tuple`2", ref _TupleOfT2Type); + private Option _TupleOfT2Type; + + public INamedTypeSymbol? TupleOfT3Type => GetOrResolveType("System.Tuple`3", ref _TupleOfT3Type); + private Option _TupleOfT3Type; + + public INamedTypeSymbol? TupleOfT4Type => GetOrResolveType("System.Tuple`4", ref _TupleOfT4Type); + private Option _TupleOfT4Type; + + public INamedTypeSymbol? TupleOfT5Type => GetOrResolveType("System.Tuple`5", ref _TupleOfT5Type); + private Option _TupleOfT5Type; + + public INamedTypeSymbol? TupleOfT6Type => GetOrResolveType("System.Tuple`6", ref _TupleOfT6Type); + private Option _TupleOfT6Type; + + public INamedTypeSymbol? TupleOfT7Type => GetOrResolveType("System.Tuple`7", ref _TupleOfT7Type); + private Option _TupleOfT7Type; + + public INamedTypeSymbol? TupleOfT8Type => GetOrResolveType("System.Tuple`8", ref _TupleOfT8Type); + private Option _TupleOfT8Type; + private INamedTypeSymbol? GetOrResolveType(Type type, ref Option field) => GetOrResolveType(type.FullName!, ref field); diff --git a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs index b523f3e91315dd..350f26819cdb8c 100644 --- a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs +++ b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs @@ -2291,7 +2291,7 @@ private static string GetTupleAccessorPathForIndex(int index) /// /// Flattens System.Tuple elements by walking the Rest type argument chain. /// - private static void FlattenReferenceTupleElements(ITypeSymbol type, string prefix, List<(ITypeSymbol, string)> elements) + private void FlattenReferenceTupleElements(ITypeSymbol type, string prefix, List<(ITypeSymbol, string)> elements) { if (type is not INamedTypeSymbol { IsGenericType: true } namedType) { @@ -2361,7 +2361,7 @@ private ParameterGenerationSpec[] ParseTupleConstructorParameters( return parameters; } - private static void FlattenReferenceTupleTypes(ITypeSymbol type, List types) + private void FlattenReferenceTupleTypes(ITypeSymbol type, List types) { if (type is not INamedTypeSymbol { IsGenericType: true } namedType) { @@ -2390,17 +2390,14 @@ private static void FlattenReferenceTupleTypes(ITypeSymbol type, List= 1 && def.Arity <= 8; + return _knownSymbols.IsReferenceTupleType(namedType); } private readonly struct TypeToGenerate diff --git a/src/libraries/System.Text.Json/src/System/ReflectionExtensions.cs b/src/libraries/System.Text.Json/src/System/ReflectionExtensions.cs index f2e9945b49c477..221be216885ebb 100644 --- a/src/libraries/System.Text.Json/src/System/ReflectionExtensions.cs +++ b/src/libraries/System.Text.Json/src/System/ReflectionExtensions.cs @@ -164,5 +164,34 @@ public static MemberInfo GetGenericMemberDefinition(this MemberInfo member) return member; } + + /// + /// Determines whether the specified type is a ValueTuple or System.Tuple generic type. + /// + public static bool IsTupleType(this Type type) + { + if (!type.IsGenericType) + { + return false; + } + + Type genericDef = type.GetGenericTypeDefinition(); + return genericDef == typeof(ValueTuple<>) || + genericDef == typeof(ValueTuple<,>) || + genericDef == typeof(ValueTuple<,,>) || + genericDef == typeof(ValueTuple<,,,>) || + genericDef == typeof(ValueTuple<,,,,>) || + genericDef == typeof(ValueTuple<,,,,,>) || + genericDef == typeof(ValueTuple<,,,,,,>) || + genericDef == typeof(ValueTuple<,,,,,,,>) || + genericDef == typeof(Tuple<>) || + genericDef == typeof(Tuple<,>) || + genericDef == typeof(Tuple<,,>) || + genericDef == typeof(Tuple<,,,>) || + genericDef == typeof(Tuple<,,,,>) || + genericDef == typeof(Tuple<,,,,,>) || + genericDef == typeof(Tuple<,,,,,,>) || + genericDef == typeof(Tuple<,,,,,,,>); + } } } diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/DefaultJsonTypeInfoResolver.Helpers.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/DefaultJsonTypeInfoResolver.Helpers.cs index e6df7004372385..4d1e61da780e38 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/DefaultJsonTypeInfoResolver.Helpers.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/DefaultJsonTypeInfoResolver.Helpers.cs @@ -330,31 +330,7 @@ private static void AddMember( return numberHandlingAttribute?.UnmappedMemberHandling; } - internal static bool IsTupleType(Type type) - { - if (!type.IsGenericType) - { - return false; - } - - Type genericDef = type.GetGenericTypeDefinition(); - return genericDef == typeof(ValueTuple<>) || - genericDef == typeof(ValueTuple<,>) || - genericDef == typeof(ValueTuple<,,>) || - genericDef == typeof(ValueTuple<,,,>) || - genericDef == typeof(ValueTuple<,,,,>) || - genericDef == typeof(ValueTuple<,,,,,>) || - genericDef == typeof(ValueTuple<,,,,,,>) || - genericDef == typeof(ValueTuple<,,,,,,,>) || - genericDef == typeof(Tuple<>) || - genericDef == typeof(Tuple<,>) || - genericDef == typeof(Tuple<,,>) || - genericDef == typeof(Tuple<,,,>) || - genericDef == typeof(Tuple<,,,,>) || - genericDef == typeof(Tuple<,,,,,>) || - genericDef == typeof(Tuple<,,,,,,>) || - genericDef == typeof(Tuple<,,,,,,,>); - } + internal static bool IsTupleType(Type type) => type.IsTupleType(); /// /// Flattens tuple properties so that Item1-Item7 + Rest becomes Item1-ItemN with diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonMetadataServices.Helpers.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonMetadataServices.Helpers.cs index df35d074432099..bad7f170fd767a 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonMetadataServices.Helpers.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonMetadataServices.Helpers.cs @@ -3,6 +3,7 @@ using System.Diagnostics; using System.Reflection; +using System.Text.Json.Reflection; using System.Text.Json.Serialization.Converters; namespace System.Text.Json.Serialization.Metadata @@ -155,7 +156,7 @@ internal static void PopulateProperties(JsonTypeInfo typeInfo, JsonTypeInfo.Json continue; } - if (jsonPropertyInfo.MemberType == MemberTypes.Field && !jsonPropertyInfo.SrcGen_HasJsonInclude && !typeInfo.Options.IncludeFields && !DefaultJsonTypeInfoResolver.IsTupleType(typeInfo.Type)) + if (jsonPropertyInfo.MemberType == MemberTypes.Field && !jsonPropertyInfo.SrcGen_HasJsonInclude && !typeInfo.Options.IncludeFields && !typeInfo.Type.IsTupleType()) { continue; }