diff --git a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs index a865466d5f40b8..454019db862157 100644 --- a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs +++ b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs @@ -1217,6 +1217,7 @@ private bool IsValidDataExtensionPropertyType(ITypeSymbol type) ProcessMemberCustomAttributes( contextType, memberInfo, + memberType, out bool hasJsonInclude, out string? jsonPropertyName, out JsonIgnoreCondition? ignoreCondition, @@ -1322,6 +1323,7 @@ private bool IsValidDataExtensionPropertyType(ITypeSymbol type) private void ProcessMemberCustomAttributes( INamedTypeSymbol contextType, ISymbol memberInfo, + ITypeSymbol memberType, out bool hasJsonInclude, out string? jsonPropertyName, out JsonIgnoreCondition? ignoreCondition, @@ -1355,7 +1357,7 @@ private void ProcessMemberCustomAttributes( if (converterType is null && _knownSymbols.JsonConverterAttributeType.IsAssignableFrom(attributeType)) { - converterType = GetConverterTypeFromJsonConverterAttribute(contextType, memberInfo, attributeData); + converterType = GetConverterTypeFromJsonConverterAttribute(contextType, memberInfo, attributeData, memberType); } else if (attributeType.ContainingAssembly.Name == SystemTextJsonNamespace) { @@ -1657,7 +1659,7 @@ bool MatchesConstructorParameter(ParameterGenerationSpec paramSpec) return propertyInitializers; } - private TypeRef? GetConverterTypeFromJsonConverterAttribute(INamedTypeSymbol contextType, ISymbol declaringSymbol, AttributeData attributeData) + private TypeRef? GetConverterTypeFromJsonConverterAttribute(INamedTypeSymbol contextType, ISymbol declaringSymbol, AttributeData attributeData, ITypeSymbol? typeToConvert = null) { Debug.Assert(_knownSymbols.JsonConverterAttributeType.IsAssignableFrom(attributeData.AttributeClass)); @@ -1669,12 +1671,33 @@ bool MatchesConstructorParameter(ParameterGenerationSpec paramSpec) Debug.Assert(attributeData.ConstructorArguments.Length == 1 && attributeData.ConstructorArguments[0].Value is null or ITypeSymbol); var converterType = (ITypeSymbol?)attributeData.ConstructorArguments[0].Value; - return GetConverterTypeFromAttribute(contextType, converterType, declaringSymbol, attributeData); + + // If typeToConvert is not provided, try to infer it from declaringSymbol + typeToConvert ??= declaringSymbol as ITypeSymbol; + + return GetConverterTypeFromAttribute(contextType, converterType, declaringSymbol, attributeData, typeToConvert); } - private TypeRef? GetConverterTypeFromAttribute(INamedTypeSymbol contextType, ITypeSymbol? converterType, ISymbol declaringSymbol, AttributeData attributeData) + private TypeRef? GetConverterTypeFromAttribute(INamedTypeSymbol contextType, ITypeSymbol? converterType, ISymbol declaringSymbol, AttributeData attributeData, ITypeSymbol? typeToConvert = null) { - if (converterType is not INamedTypeSymbol namedConverterType || + INamedTypeSymbol? namedConverterType = converterType as INamedTypeSymbol; + + // Check if this is an unbound generic converter type that needs to be constructed. + // For open generics, we construct the closed generic type first and then validate. + if (namedConverterType is { IsUnboundGenericType: true } unboundConverterType && + typeToConvert is INamedTypeSymbol { IsGenericType: true } genericTypeToConvert) + { + // For nested generic types like Container<>.NestedConverter<>, we need to count + // all type parameters from the entire type hierarchy, not just the immediate type. + int totalTypeParameterCount = GetTotalTypeParameterCount(unboundConverterType); + + if (totalTypeParameterCount == genericTypeToConvert.TypeArguments.Length) + { + namedConverterType = ConstructNestedGenericType(unboundConverterType, genericTypeToConvert.TypeArguments); + } + } + + if (namedConverterType is null || !_knownSymbols.JsonConverterType.IsAssignableFrom(namedConverterType) || !namedConverterType.Constructors.Any(c => c.Parameters.Length == 0 && IsSymbolAccessibleWithin(c, within: contextType))) { @@ -1682,12 +1705,105 @@ bool MatchesConstructorParameter(ParameterGenerationSpec paramSpec) return null; } - if (_knownSymbols.JsonStringEnumConverterType.IsAssignableFrom(converterType)) + if (_knownSymbols.JsonStringEnumConverterType.IsAssignableFrom(namedConverterType)) { ReportDiagnostic(DiagnosticDescriptors.JsonStringEnumConverterNotSupportedInAot, attributeData.GetLocation(), declaringSymbol.ToDisplayString()); } - return new TypeRef(converterType); + return new TypeRef(namedConverterType); + } + + /// + /// Gets the total number of type parameters from an unbound generic type, + /// including type parameters from containing types for nested generics. + /// For example, Container<>.NestedConverter<> has a total of 2 type parameters. + /// + private static int GetTotalTypeParameterCount(INamedTypeSymbol unboundType) + { + int count = 0; + INamedTypeSymbol? current = unboundType; + while (current != null) + { + count += current.TypeParameters.Length; + current = current.ContainingType; + } + return count; + } + + /// + /// Constructs a closed generic type from an unbound generic type (potentially nested), + /// using the provided type arguments in the order they should be applied. + /// Returns null if the type cannot be constructed. + /// + private static INamedTypeSymbol? ConstructNestedGenericType(INamedTypeSymbol unboundType, ImmutableArray typeArguments) + { + // Build the chain of containing types from outermost to innermost + var typeChain = new List(); + INamedTypeSymbol? current = unboundType; + while (current != null) + { + typeChain.Add(current); + current = current.ContainingType; + } + + // Reverse to go from outermost to innermost + typeChain.Reverse(); + + // Track which type arguments have been used + int typeArgIndex = 0; + INamedTypeSymbol? constructedContainingType = null; + + foreach (var type in typeChain) + { + int typeParamCount = type.TypeParameters.Length; + INamedTypeSymbol originalDef = type.OriginalDefinition; + + if (typeParamCount > 0) + { + // Get the type arguments for this level + var args = typeArguments.Skip(typeArgIndex).Take(typeParamCount).ToArray(); + typeArgIndex += typeParamCount; + + // Construct this level + if (constructedContainingType == null) + { + constructedContainingType = originalDef.Construct(args); + } + else + { + // Get the nested type from the constructed containing type + var nestedTypeDef = constructedContainingType.GetTypeMembers(originalDef.Name, originalDef.Arity).FirstOrDefault(); + if (nestedTypeDef != null) + { + constructedContainingType = nestedTypeDef.Construct(args); + } + else + { + return null; + } + } + } + else + { + // Non-generic type in the chain + if (constructedContainingType == null) + { + constructedContainingType = originalDef; + } + else + { + // Use arity 0 to avoid ambiguity with nested types of the same name but different arity + var nestedType = constructedContainingType.GetTypeMembers(originalDef.Name, 0).FirstOrDefault(); + if (nestedType == null) + { + return null; + } + constructedContainingType = nestedType; + } + } + } + + return constructedContainingType; } private static string DetermineEffectiveJsonPropertyName(string propertyName, string? jsonPropertyName, SourceGenerationOptionsSpec? options) diff --git a/src/libraries/System.Text.Json/src/Resources/Strings.resx b/src/libraries/System.Text.Json/src/Resources/Strings.resx index 9aef3d3e90ab99..199d59b24afe8f 100644 --- a/src/libraries/System.Text.Json/src/Resources/Strings.resx +++ b/src/libraries/System.Text.Json/src/Resources/Strings.resx @@ -411,6 +411,9 @@ The converter specified on '{0}' is not compatible with the type '{1}'. + + The open generic converter type '{1}' specified on '{0}' cannot be instantiated because the target type is not a generic type with a matching number of type parameters. + The converter specified on '{0}' does not derive from JsonConverter or have a public parameterless constructor. diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/DefaultJsonTypeInfoResolver.Converters.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/DefaultJsonTypeInfoResolver.Converters.cs index c96e2a1c8f5da5..7da28e2d63b84e 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/DefaultJsonTypeInfoResolver.Converters.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/DefaultJsonTypeInfoResolver.Converters.cs @@ -189,6 +189,22 @@ private static JsonConverter GetConverterFromAttribute(JsonConverterAttribute co } else { + // Handle open generic converter types (e.g., OptionConverter<> on Option). + // If the converter type is an open generic and the type to convert is a closed generic + // with matching type arity, construct the closed converter type. + if (converterType.IsGenericTypeDefinition) + { + if (typeToConvert.IsGenericType && + converterType.GetGenericArguments().Length == typeToConvert.GetGenericArguments().Length) + { + converterType = converterType.MakeGenericType(typeToConvert.GetGenericArguments()); + } + else + { + ThrowHelper.ThrowInvalidOperationException_SerializationConverterOnAttributeOpenGenericNotCompatible(declaringType, memberInfo, converterType); + } + } + ConstructorInfo? ctor = converterType.GetConstructor(Type.EmptyTypes); if (!typeof(JsonConverter).IsAssignableFrom(converterType) || ctor == null || !ctor.IsPublic) { diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/ThrowHelper.Serialization.cs b/src/libraries/System.Text.Json/src/System/Text/Json/ThrowHelper.Serialization.cs index 74cc50340aa694..9dfe227ec48620 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/ThrowHelper.Serialization.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/ThrowHelper.Serialization.cs @@ -229,6 +229,18 @@ public static void ThrowInvalidOperationException_SerializationConverterOnAttrib throw new InvalidOperationException(SR.Format(SR.SerializationConverterOnAttributeNotCompatible, location, typeToConvert)); } + [DoesNotReturn] + public static void ThrowInvalidOperationException_SerializationConverterOnAttributeOpenGenericNotCompatible(Type classType, MemberInfo? memberInfo, Type converterType) + { + string location = classType.ToString(); + if (memberInfo != null) + { + location += $".{memberInfo.Name}"; + } + + throw new InvalidOperationException(SR.Format(SR.SerializationConverterOnAttributeOpenGenericNotCompatible, location, converterType)); + } + [DoesNotReturn] public static void ThrowInvalidOperationException_SerializerOptionsReadOnly(JsonSerializerContext? context) { diff --git a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/JsonSerializerContextTests.cs b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/JsonSerializerContextTests.cs index 0502f4d907011f..88f59426a2105e 100644 --- a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/JsonSerializerContextTests.cs +++ b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/JsonSerializerContextTests.cs @@ -1024,5 +1024,145 @@ public static void PartialContextWithAttributesOnMultipleDeclarations_RuntimeBeh Assert.Equal(3.14, deserialized2.Value); Assert.True(deserialized2.IsActive); } + + [Theory] + [InlineData(42, "42")] + [InlineData(0, "0")] + public static void SupportsOpenGenericConverterOnGenericType_Int(int value, string expectedJson) + { + Option option = new Option(value); + string json = JsonSerializer.Serialize(option, OpenGenericConverterContext.Default.OptionInt32); + Assert.Equal(expectedJson, json); + + Option deserialized = JsonSerializer.Deserialize>(json, OpenGenericConverterContext.Default.OptionInt32); + Assert.True(deserialized.HasValue); + Assert.Equal(value, deserialized.Value); + } + + [Fact] + public static void SupportsOpenGenericConverterOnGenericType_NullValue() + { + Option option = default; + string json = JsonSerializer.Serialize(option, OpenGenericConverterContext.Default.OptionInt32); + Assert.Equal("null", json); + + Option deserialized = JsonSerializer.Deserialize>("null", OpenGenericConverterContext.Default.OptionInt32); + Assert.False(deserialized.HasValue); + } + + [Theory] + [InlineData("hello", @"""hello""")] + [InlineData("", @"""""")] + public static void SupportsOpenGenericConverterOnGenericType_String(string value, string expectedJson) + { + Option option = new Option(value); + string json = JsonSerializer.Serialize(option, OpenGenericConverterContext.Default.OptionString); + Assert.Equal(expectedJson, json); + + Option deserialized = JsonSerializer.Deserialize>(json, OpenGenericConverterContext.Default.OptionString); + Assert.True(deserialized.HasValue); + Assert.Equal(value, deserialized.Value); + } + + [Fact] + public static void SupportsOpenGenericConverterOnProperty() + { + var obj = new ClassWithGenericConverterOnProperty { Value = new GenericWrapper(42) }; + string json = JsonSerializer.Serialize(obj, OpenGenericConverterContext.Default.ClassWithGenericConverterOnProperty); + Assert.Equal(@"{""Value"":42}", json); + + var deserialized = JsonSerializer.Deserialize(json, OpenGenericConverterContext.Default.ClassWithGenericConverterOnProperty); + Assert.Equal(42, deserialized.Value.WrappedValue); + } + + [JsonSerializable(typeof(Option))] + [JsonSerializable(typeof(Option))] + [JsonSerializable(typeof(ClassWithOptionProperty))] + [JsonSerializable(typeof(ClassWithGenericConverterOnProperty))] + internal partial class OpenGenericConverterContext : JsonSerializerContext + { + } + + [Fact] + public static void SupportsNestedGenericConverterOnGenericType() + { + var value = new TypeWithNestedConverter { Value1 = 42, Value2 = "hello" }; + string json = JsonSerializer.Serialize(value, NestedGenericConverterContext.Default.TypeWithNestedConverterInt32String); + Assert.Equal(@"{""Value1"":42,""Value2"":""hello""}", json); + + var deserialized = JsonSerializer.Deserialize>(json, NestedGenericConverterContext.Default.TypeWithNestedConverterInt32String); + Assert.Equal(42, deserialized.Value1); + Assert.Equal("hello", deserialized.Value2); + } + + [Fact] + public static void SupportsConstrainedGenericConverterOnGenericType() + { + var value = new TypeWithSatisfiedConstraint { Value = "test" }; + string json = JsonSerializer.Serialize(value, NestedGenericConverterContext.Default.TypeWithSatisfiedConstraintString); + Assert.Equal(@"{""Value"":""test""}", json); + + var deserialized = JsonSerializer.Deserialize>(json, NestedGenericConverterContext.Default.TypeWithSatisfiedConstraintString); + Assert.Equal("test", deserialized.Value); + } + + [Fact] + public static void SupportsGenericWithinNonGenericWithinGenericConverter() + { + var value = new TypeWithDeeplyNestedConverter { Value1 = 99, Value2 = "deep" }; + string json = JsonSerializer.Serialize(value, NestedGenericConverterContext.Default.TypeWithDeeplyNestedConverterInt32String); + Assert.Equal(@"{""Value1"":99,""Value2"":""deep""}", json); + + var deserialized = JsonSerializer.Deserialize>(json, NestedGenericConverterContext.Default.TypeWithDeeplyNestedConverterInt32String); + Assert.Equal(99, deserialized.Value1); + Assert.Equal("deep", deserialized.Value2); + } + + [Fact] + public static void SupportsSingleGenericLevelNestedConverter() + { + var value = new TypeWithSingleLevelNestedConverter { Value = 42 }; + string json = JsonSerializer.Serialize(value, NestedGenericConverterContext.Default.TypeWithSingleLevelNestedConverterInt32); + Assert.Equal(@"{""Value"":42}", json); + + var deserialized = JsonSerializer.Deserialize>(json, NestedGenericConverterContext.Default.TypeWithSingleLevelNestedConverterInt32); + Assert.Equal(42, deserialized.Value); + } + + [Fact] + public static void SupportsAsymmetricNestedConverterWithManyParams() + { + var value = new TypeWithManyParams + { + Value1 = 1, + Value2 = "two", + Value3 = true, + Value4 = 4.0, + Value5 = 5L + }; + string json = JsonSerializer.Serialize(value, NestedGenericConverterContext.Default.TypeWithManyParamsInt32StringBooleanDoubleInt64); + Assert.Equal(@"{""Value1"":1,""Value2"":""two"",""Value3"":true,""Value4"":4,""Value5"":5}", json); + + var deserialized = JsonSerializer.Deserialize>(json, NestedGenericConverterContext.Default.TypeWithManyParamsInt32StringBooleanDoubleInt64); + Assert.Equal(1, deserialized.Value1); + Assert.Equal("two", deserialized.Value2); + Assert.True(deserialized.Value3); + Assert.Equal(4.0, deserialized.Value4); + Assert.Equal(5L, deserialized.Value5); + } + + [JsonSerializable(typeof(TypeWithNestedConverter))] + [JsonSerializable(typeof(TypeWithSatisfiedConstraint))] + [JsonSerializable(typeof(TypeWithDeeplyNestedConverter))] + [JsonSerializable(typeof(TypeWithSingleLevelNestedConverter))] + [JsonSerializable(typeof(TypeWithManyParams))] + [JsonSerializable(typeof(int))] + [JsonSerializable(typeof(string))] + [JsonSerializable(typeof(bool))] + [JsonSerializable(typeof(double))] + [JsonSerializable(typeof(long))] + internal partial class NestedGenericConverterContext : JsonSerializerContext + { + } } } diff --git a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/TestClasses.CustomConverters.cs b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/TestClasses.CustomConverters.cs index 0a4ac1a1904129..a049400c652092 100644 --- a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/TestClasses.CustomConverters.cs +++ b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/TestClasses.CustomConverters.cs @@ -300,4 +300,359 @@ public enum SourceGenSampleEnum One = 1, Two = 2 } + + // Generic converter types for testing open generic converter support + + /// + /// A generic option type that represents an optional value. + /// Uses an open generic converter type. + /// + [JsonConverter(typeof(OptionConverter<>))] + public readonly struct Option + { + public bool HasValue { get; } + public T Value { get; } + + public Option(T value) + { + HasValue = true; + Value = value; + } + + public static implicit operator Option(T value) => new(value); + } + + /// + /// Generic converter for the Option type. + /// + public sealed class OptionConverter : JsonConverter> + { + public override bool HandleNull => true; + + public override Option Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) + { + return default; + } + + return new(JsonSerializer.Deserialize(ref reader, options)!); + } + + public override void Write(Utf8JsonWriter writer, Option value, JsonSerializerOptions options) + { + if (!value.HasValue) + { + writer.WriteNullValue(); + return; + } + + JsonSerializer.Serialize(writer, value.Value, options); + } + } + + /// + /// A class that contains an Option property for testing. + /// + public class ClassWithOptionProperty + { + public string Name { get; set; } + public Option OptionalValue { get; set; } + } + + /// + /// A wrapper type that uses an open generic converter on a property. + /// + public class GenericWrapper + { + public T WrappedValue { get; } + + public GenericWrapper(T value) + { + WrappedValue = value; + } + } + + /// + /// Generic converter for the GenericWrapper type. + /// + public sealed class GenericWrapperConverter : JsonConverter> + { + public override GenericWrapper Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + T value = JsonSerializer.Deserialize(ref reader, options)!; + return new GenericWrapper(value); + } + + public override void Write(Utf8JsonWriter writer, GenericWrapper value, JsonSerializerOptions options) + { + JsonSerializer.Serialize(writer, value.WrappedValue, options); + } + } + + /// + /// A class with a property that uses an open generic converter attribute. + /// + public class ClassWithGenericConverterOnProperty + { + [JsonConverter(typeof(GenericWrapperConverter<>))] + public GenericWrapper Value { get; set; } + } + + // Tests for nested containing class with type parameters + // The converter is nested in a generic container class. + [JsonConverter(typeof(NestedConverterContainer<>.NestedConverter<>))] + public class TypeWithNestedConverter + { + public T1 Value1 { get; set; } + public T2 Value2 { get; set; } + } + + public class NestedConverterContainer + { + public sealed class NestedConverter : JsonConverter> + { + public override TypeWithNestedConverter Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.StartObject) + throw new JsonException(); + + var result = new TypeWithNestedConverter(); + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + break; + + if (reader.TokenType != JsonTokenType.PropertyName) + throw new JsonException(); + + string propertyName = reader.GetString()!; + reader.Read(); + + if (propertyName == "Value1") + result.Value1 = JsonSerializer.Deserialize(ref reader, options)!; + else if (propertyName == "Value2") + result.Value2 = JsonSerializer.Deserialize(ref reader, options)!; + } + + return result; + } + + public override void Write(Utf8JsonWriter writer, TypeWithNestedConverter value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + writer.WritePropertyName("Value1"); + JsonSerializer.Serialize(writer, value.Value1, options); + writer.WritePropertyName("Value2"); + JsonSerializer.Serialize(writer, value.Value2, options); + writer.WriteEndObject(); + } + } + } + + // Tests for type parameters with constraints that are satisfied + [JsonConverter(typeof(ConverterWithClassConstraint<>))] + public class TypeWithSatisfiedConstraint + { + public T Value { get; set; } + } + + public sealed class ConverterWithClassConstraint : JsonConverter> where T : class + { + public override TypeWithSatisfiedConstraint Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.StartObject) + throw new JsonException(); + + var result = new TypeWithSatisfiedConstraint(); + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + break; + + if (reader.TokenType != JsonTokenType.PropertyName) + throw new JsonException(); + + string propertyName = reader.GetString()!; + reader.Read(); + + if (propertyName == "Value") + result.Value = JsonSerializer.Deserialize(ref reader, options)!; + } + + return result; + } + + public override void Write(Utf8JsonWriter writer, TypeWithSatisfiedConstraint value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + writer.WritePropertyName("Value"); + JsonSerializer.Serialize(writer, value.Value, options); + writer.WriteEndObject(); + } + } + + // Tests for generic within non-generic within generic: Outer<>.Middle.Inner<> + [JsonConverter(typeof(OuterGeneric<>.MiddleNonGeneric.InnerConverter<>))] + public class TypeWithDeeplyNestedConverter + { + public T1 Value1 { get; set; } + public T2 Value2 { get; set; } + } + + public class OuterGeneric + { + public class MiddleNonGeneric + { + public sealed class InnerConverter : JsonConverter> + { + public override TypeWithDeeplyNestedConverter Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.StartObject) + throw new JsonException(); + + var result = new TypeWithDeeplyNestedConverter(); + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + break; + + if (reader.TokenType != JsonTokenType.PropertyName) + throw new JsonException(); + + string propertyName = reader.GetString()!; + reader.Read(); + + if (propertyName == "Value1") + result.Value1 = JsonSerializer.Deserialize(ref reader, options)!; + else if (propertyName == "Value2") + result.Value2 = JsonSerializer.Deserialize(ref reader, options)!; + } + + return result; + } + + public override void Write(Utf8JsonWriter writer, TypeWithDeeplyNestedConverter value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + writer.WritePropertyName("Value1"); + JsonSerializer.Serialize(writer, value.Value1, options); + writer.WritePropertyName("Value2"); + JsonSerializer.Serialize(writer, value.Value2, options); + writer.WriteEndObject(); + } + } + } + } + + // Tests for many generic parameters with asymmetric distribution across nesting levels: Level1<,,>.Level2<>.Level3<> + [JsonConverter(typeof(Level1<,,>.Level2<>.Level3Converter<>))] + public class TypeWithManyParams + { + public T1 Value1 { get; set; } + public T2 Value2 { get; set; } + public T3 Value3 { get; set; } + public T4 Value4 { get; set; } + public T5 Value5 { get; set; } + } + + public class Level1 + { + public class Level2 + { + public sealed class Level3Converter : JsonConverter> + { + public override TypeWithManyParams Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.StartObject) + throw new JsonException(); + + var result = new TypeWithManyParams(); + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + break; + + if (reader.TokenType != JsonTokenType.PropertyName) + throw new JsonException(); + + string propertyName = reader.GetString()!; + reader.Read(); + + switch (propertyName) + { + case "Value1": result.Value1 = JsonSerializer.Deserialize(ref reader, options)!; break; + case "Value2": result.Value2 = JsonSerializer.Deserialize(ref reader, options)!; break; + case "Value3": result.Value3 = JsonSerializer.Deserialize(ref reader, options)!; break; + case "Value4": result.Value4 = JsonSerializer.Deserialize(ref reader, options)!; break; + case "Value5": result.Value5 = JsonSerializer.Deserialize(ref reader, options)!; break; + } + } + + return result; + } + + public override void Write(Utf8JsonWriter writer, TypeWithManyParams value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + writer.WritePropertyName("Value1"); + JsonSerializer.Serialize(writer, value.Value1, options); + writer.WritePropertyName("Value2"); + JsonSerializer.Serialize(writer, value.Value2, options); + writer.WritePropertyName("Value3"); + JsonSerializer.Serialize(writer, value.Value3, options); + writer.WritePropertyName("Value4"); + JsonSerializer.Serialize(writer, value.Value4, options); + writer.WritePropertyName("Value5"); + JsonSerializer.Serialize(writer, value.Value5, options); + writer.WriteEndObject(); + } + } + } + } + + // Tests for a single generic type parameter in a nested converter (non-generic containing generic) + [JsonConverter(typeof(NonGenericOuter.SingleLevelGenericConverter<>))] + public class TypeWithSingleLevelNestedConverter + { + public T Value { get; set; } + } + + public class NonGenericOuter + { + public sealed class SingleLevelGenericConverter : JsonConverter> + { + public override TypeWithSingleLevelNestedConverter Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.StartObject) + throw new JsonException(); + + var result = new TypeWithSingleLevelNestedConverter(); + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + break; + + if (reader.TokenType != JsonTokenType.PropertyName) + throw new JsonException(); + + string propertyName = reader.GetString()!; + reader.Read(); + + if (propertyName == "Value") + result.Value = JsonSerializer.Deserialize(ref reader, options)!; + } + + return result; + } + + public override void Write(Utf8JsonWriter writer, TypeWithSingleLevelNestedConverter value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + writer.WritePropertyName("Value"); + JsonSerializer.Serialize(writer, value.Value, options); + writer.WriteEndObject(); + } + } + } } diff --git a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Unit.Tests/CompilationHelper.cs b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Unit.Tests/CompilationHelper.cs index 183ed2f69c2530..ba8ee4c3cc900c 100644 --- a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Unit.Tests/CompilationHelper.cs +++ b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Unit.Tests/CompilationHelper.cs @@ -859,6 +859,327 @@ private static void LogGeneratedCode(SyntaxTree tree, ITestOutputHelper logger) lineWriter.WriteLine(string.Empty); } + public static Compilation CreateTypesWithGenericConverterArityMismatch() + { + string source = """ + using System; + using System.Text.Json; + using System.Text.Json.Serialization; + + namespace HelloWorld + { + [JsonSerializable(typeof(TypeWithArityMismatch))] + internal partial class JsonContext : JsonSerializerContext + { + } + + [JsonConverter(typeof(ConverterWithTwoParams<,>))] + public class TypeWithArityMismatch + { + public T Value { get; set; } + } + + public sealed class ConverterWithTwoParams : JsonConverter> + { + public override TypeWithArityMismatch Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => throw new NotImplementedException(); + + public override void Write(Utf8JsonWriter writer, TypeWithArityMismatch value, JsonSerializerOptions options) + => throw new NotImplementedException(); + } + } + """; + + return CreateCompilation(source); + } + + public static Compilation CreateTypesWithGenericConverterTypeMismatch() + { + string source = """ + using System; + using System.Text.Json; + using System.Text.Json.Serialization; + + namespace HelloWorld + { + [JsonSerializable(typeof(TypeWithConverterMismatch))] + internal partial class JsonContext : JsonSerializerContext + { + } + + [JsonConverter(typeof(DifferentTypeConverter<>))] + public class TypeWithConverterMismatch + { + public T Value { get; set; } + } + + public class DifferentType + { + public T Value { get; set; } + } + + public sealed class DifferentTypeConverter : JsonConverter> + { + public override DifferentType Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => throw new NotImplementedException(); + + public override void Write(Utf8JsonWriter writer, DifferentType value, JsonSerializerOptions options) + => throw new NotImplementedException(); + } + } + """; + + return CreateCompilation(source); + } + + public static Compilation CreateTypesWithNestedGenericConverter() + { + string source = """ + using System; + using System.Text.Json; + using System.Text.Json.Serialization; + + namespace HelloWorld + { + [JsonSerializable(typeof(TypeWithNestedConverter))] + internal partial class JsonContext : JsonSerializerContext + { + } + + [JsonConverter(typeof(Container<>.NestedConverter<>))] + public class TypeWithNestedConverter + { + public T1 Value1 { get; set; } + public T2 Value2 { get; set; } + } + + public class Container + { + public sealed class NestedConverter : JsonConverter> + { + public override TypeWithNestedConverter Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.StartObject) + throw new JsonException(); + + var result = new TypeWithNestedConverter(); + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + break; + + if (reader.TokenType != JsonTokenType.PropertyName) + throw new JsonException(); + + string propertyName = reader.GetString()!; + reader.Read(); + + if (propertyName == "Value1") + result.Value1 = JsonSerializer.Deserialize(ref reader, options)!; + else if (propertyName == "Value2") + result.Value2 = JsonSerializer.Deserialize(ref reader, options)!; + } + return result; + } + + public override void Write(Utf8JsonWriter writer, TypeWithNestedConverter value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + writer.WritePropertyName("Value1"); + JsonSerializer.Serialize(writer, value.Value1, options); + writer.WritePropertyName("Value2"); + JsonSerializer.Serialize(writer, value.Value2, options); + writer.WriteEndObject(); + } + } + } + } + """; + + return CreateCompilation(source); + } + + public static Compilation CreateTypesWithConstrainedGenericConverter() + { + string source = """ + using System; + using System.Text.Json; + using System.Text.Json.Serialization; + + namespace HelloWorld + { + [JsonSerializable(typeof(TypeWithSatisfiedConstraint))] + internal partial class JsonContext : JsonSerializerContext + { + } + + [JsonConverter(typeof(ConverterWithClassConstraint<>))] + public class TypeWithSatisfiedConstraint + { + public T Value { get; set; } + } + + public sealed class ConverterWithClassConstraint : JsonConverter> where T : class + { + public override TypeWithSatisfiedConstraint Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.StartObject) + throw new JsonException(); + + var result = new TypeWithSatisfiedConstraint(); + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + break; + + if (reader.TokenType != JsonTokenType.PropertyName) + throw new JsonException(); + + string propertyName = reader.GetString()!; + reader.Read(); + + if (propertyName == "Value") + result.Value = JsonSerializer.Deserialize(ref reader, options)!; + } + return result; + } + + public override void Write(Utf8JsonWriter writer, TypeWithSatisfiedConstraint value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + writer.WritePropertyName("Value"); + JsonSerializer.Serialize(writer, value.Value, options); + writer.WriteEndObject(); + } + } + } + """; + + return CreateCompilation(source); + } + + public static Compilation CreateTypesWithDeeplyNestedGenericConverter() + { + string source = """ + using System; + using System.Text.Json; + using System.Text.Json.Serialization; + + namespace HelloWorld + { + [JsonSerializable(typeof(TypeWithDeeplyNestedConverter))] + internal partial class JsonContext : JsonSerializerContext + { + } + + [JsonConverter(typeof(OuterGeneric<>.MiddleNonGeneric.InnerConverter<>))] + public class TypeWithDeeplyNestedConverter + { + public T1 Value1 { get; set; } + public T2 Value2 { get; set; } + } + + public class OuterGeneric + { + public class MiddleNonGeneric + { + public sealed class InnerConverter : JsonConverter> + { + public override TypeWithDeeplyNestedConverter Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => throw new NotImplementedException(); + + public override void Write(Utf8JsonWriter writer, TypeWithDeeplyNestedConverter value, JsonSerializerOptions options) + => throw new NotImplementedException(); + } + } + } + } + """; + + return CreateCompilation(source); + } + + public static Compilation CreateTypesWithNonGenericOuterGenericConverter() + { + string source = """ + using System; + using System.Text.Json; + using System.Text.Json.Serialization; + + namespace HelloWorld + { + [JsonSerializable(typeof(TypeWithNonGenericOuterConverter))] + internal partial class JsonContext : JsonSerializerContext + { + } + + [JsonConverter(typeof(NonGenericOuter.SingleLevelConverter<>))] + public class TypeWithNonGenericOuterConverter + { + public T Value { get; set; } + } + + public class NonGenericOuter + { + public sealed class SingleLevelConverter : JsonConverter> + { + public override TypeWithNonGenericOuterConverter Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => throw new NotImplementedException(); + + public override void Write(Utf8JsonWriter writer, TypeWithNonGenericOuterConverter value, JsonSerializerOptions options) + => throw new NotImplementedException(); + } + } + } + """; + + return CreateCompilation(source); + } + + public static Compilation CreateTypesWithManyParamsAsymmetricNestedConverter() + { + string source = """ + using System; + using System.Text.Json; + using System.Text.Json.Serialization; + + namespace HelloWorld + { + [JsonSerializable(typeof(TypeWithManyParams))] + internal partial class JsonContext : JsonSerializerContext + { + } + + [JsonConverter(typeof(Level1<,,>.Level2<>.Level3Converter<>))] + public class TypeWithManyParams + { + public T1 Value1 { get; set; } + public T2 Value2 { get; set; } + public T3 Value3 { get; set; } + public T4 Value4 { get; set; } + public T5 Value5 { get; set; } + } + + public class Level1 + { + public class Level2 + { + public sealed class Level3Converter : JsonConverter> + { + public override TypeWithManyParams Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => throw new NotImplementedException(); + + public override void Write(Utf8JsonWriter writer, TypeWithManyParams value, JsonSerializerOptions options) + => throw new NotImplementedException(); + } + } + } + } + """; + + return CreateCompilation(source); + } + private static readonly string FileSeparator = new string('=', 140); private sealed class NumberedSourceFileWriter : TextWriter diff --git a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Unit.Tests/JsonSourceGeneratorDiagnosticsTests.cs b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Unit.Tests/JsonSourceGeneratorDiagnosticsTests.cs index 5712a52072a110..b40cfb350a7290 100644 --- a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Unit.Tests/JsonSourceGeneratorDiagnosticsTests.cs +++ b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Unit.Tests/JsonSourceGeneratorDiagnosticsTests.cs @@ -784,5 +784,90 @@ public partial class JsonContext : JsonSerializerContext { } Diagnostic diagnostic = Assert.Single(effective, d => d.Id == "SYSLIB1038"); Assert.True(diagnostic.IsSuppressed); } + + [Fact] + public void GenericConverterArityMismatch_WarnsAsExpected() + { + Compilation compilation = CompilationHelper.CreateTypesWithGenericConverterArityMismatch(); + JsonSourceGeneratorResult result = CompilationHelper.RunJsonSourceGenerator(compilation, disableDiagnosticValidation: true); + + Location converterAttrLocation = compilation.GetSymbolsWithName("TypeWithArityMismatch").First().GetAttributes()[0].GetLocation(); + INamedTypeSymbol contextSymbol = (INamedTypeSymbol)compilation.GetSymbolsWithName("JsonContext").First(); + Location jsonSerializableAttrLocation = contextSymbol.GetAttributes()[0].GetLocation(); + + var expectedDiagnostics = new DiagnosticData[] + { + new(DiagnosticSeverity.Warning, jsonSerializableAttrLocation, "Did not generate serialization metadata for type 'HelloWorld.TypeWithArityMismatch'."), + new(DiagnosticSeverity.Warning, converterAttrLocation, "The 'JsonConverterAttribute' type 'HelloWorld.ConverterWithTwoParams<,>' specified on member 'HelloWorld.TypeWithArityMismatch' is not a converter type or does not contain an accessible parameterless constructor."), + }; + + CompilationHelper.AssertEqualDiagnosticMessages(expectedDiagnostics, result.Diagnostics); + } + + [Fact] + public void GenericConverterTypeMismatch_NoSourceGeneratorWarning_FailsAtRuntime() + { + // Note: The source generator cannot detect at compile time that the converter + // converts the wrong type (DifferentType vs TypeWithConverterMismatch). + // The DifferentTypeConverter is a valid JsonConverter with a parameterless constructor, + // so the source generator accepts it. The mismatch will cause a runtime error. + Compilation compilation = CompilationHelper.CreateTypesWithGenericConverterTypeMismatch(); + JsonSourceGeneratorResult result = CompilationHelper.RunJsonSourceGenerator(compilation); + + // Should compile without diagnostics - the converter is technically valid + Assert.Empty(result.Diagnostics); + result.AssertContainsType("global::HelloWorld.TypeWithConverterMismatch"); + } + + [Fact] + public void NestedGenericConverter_CompileSuccessfully() + { + Compilation compilation = CompilationHelper.CreateTypesWithNestedGenericConverter(); + JsonSourceGeneratorResult result = CompilationHelper.RunJsonSourceGenerator(compilation); + + // Should compile without diagnostics + Assert.Empty(result.Diagnostics); + result.AssertContainsType("global::HelloWorld.TypeWithNestedConverter"); + } + + [Fact] + public void ConstrainedGenericConverter_WithSatisfiedConstraint_CompileSuccessfully() + { + Compilation compilation = CompilationHelper.CreateTypesWithConstrainedGenericConverter(); + JsonSourceGeneratorResult result = CompilationHelper.RunJsonSourceGenerator(compilation); + + Assert.Empty(result.Diagnostics); + result.AssertContainsType("global::HelloWorld.TypeWithSatisfiedConstraint"); + } + + [Fact] + public void DeeplyNestedGenericConverter_CompileSuccessfully() + { + Compilation compilation = CompilationHelper.CreateTypesWithDeeplyNestedGenericConverter(); + JsonSourceGeneratorResult result = CompilationHelper.RunJsonSourceGenerator(compilation); + + Assert.Empty(result.Diagnostics); + result.AssertContainsType("global::HelloWorld.TypeWithDeeplyNestedConverter"); + } + + [Fact] + public void NonGenericOuterGenericConverter_CompileSuccessfully() + { + Compilation compilation = CompilationHelper.CreateTypesWithNonGenericOuterGenericConverter(); + JsonSourceGeneratorResult result = CompilationHelper.RunJsonSourceGenerator(compilation); + + Assert.Empty(result.Diagnostics); + result.AssertContainsType("global::HelloWorld.TypeWithNonGenericOuterConverter"); + } + + [Fact] + public void ManyParamsAsymmetricNestedConverter_CompileSuccessfully() + { + Compilation compilation = CompilationHelper.CreateTypesWithManyParamsAsymmetricNestedConverter(); + JsonSourceGeneratorResult result = CompilationHelper.RunJsonSourceGenerator(compilation); + + Assert.Empty(result.Diagnostics); + result.AssertContainsType("global::HelloWorld.TypeWithManyParams"); + } } } diff --git a/src/libraries/System.Text.Json/tests/System.Text.Json.Tests/Serialization/CustomConverterTests/CustomConverterTests.Attribute.cs b/src/libraries/System.Text.Json/tests/System.Text.Json.Tests/Serialization/CustomConverterTests/CustomConverterTests.Attribute.cs index 9977b65875462e..7752b464b5bda4 100644 --- a/src/libraries/System.Text.Json/tests/System.Text.Json.Tests/Serialization/CustomConverterTests/CustomConverterTests.Attribute.cs +++ b/src/libraries/System.Text.Json/tests/System.Text.Json.Tests/Serialization/CustomConverterTests/CustomConverterTests.Attribute.cs @@ -223,5 +223,730 @@ public static void CustomAttributeOnTypeAndRuntime() string jsonSerialized = JsonSerializer.Serialize(point, options); Assert.Equal(json, jsonSerialized); } + + // Tests for open generic converters on generic types + + /// + /// A generic option type that represents an optional value. + /// The converter type is an open generic, which will be constructed + /// to match the type arguments of the Option type. + /// + [JsonConverter(typeof(OptionConverter<>))] + public readonly struct Option + { + public bool HasValue { get; } + public T Value { get; } + + public Option(T value) + { + HasValue = true; + Value = value; + } + + public static implicit operator Option(T value) => new(value); + } + + /// + /// Generic converter for the Option type. Serializes the value if present, + /// or null if not. + /// + public sealed class OptionConverter : JsonConverter> + { + public override bool HandleNull => true; + + public override Option Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) + { + return default; + } + + return new(JsonSerializer.Deserialize(ref reader, options)!); + } + + public override void Write(Utf8JsonWriter writer, Option value, JsonSerializerOptions options) + { + if (!value.HasValue) + { + writer.WriteNullValue(); + return; + } + + JsonSerializer.Serialize(writer, value.Value, options); + } + } + + [Fact] + public static void GenericConverterAttributeOnGenericType_Serialize() + { + // Test serialization with a value + Option option = new Option(42); + string json = JsonSerializer.Serialize(option); + Assert.Equal("42", json); + + // Test serialization without a value + Option emptyOption = default; + json = JsonSerializer.Serialize(emptyOption); + Assert.Equal("null", json); + } + + [Fact] + public static void GenericConverterAttributeOnGenericType_Deserialize() + { + // Test deserialization with a value + Option option = JsonSerializer.Deserialize>("42"); + Assert.True(option.HasValue); + Assert.Equal(42, option.Value); + + // Test deserialization of null + option = JsonSerializer.Deserialize>("null"); + Assert.False(option.HasValue); + } + + [Fact] + public static void GenericConverterAttributeOnGenericType_ComplexType() + { + // Test with a complex type + Option option = new Option("hello"); + string json = JsonSerializer.Serialize(option); + Assert.Equal(@"""hello""", json); + + option = JsonSerializer.Deserialize>(json); + Assert.True(option.HasValue); + Assert.Equal("hello", option.Value); + } + + [Fact] + public static void GenericConverterAttributeOnGenericType_NestedInClass() + { + // Test Option type when used as a property + var obj = new ClassWithOptionProperty { Name = "Test", OptionalValue = 42 }; + string json = JsonSerializer.Serialize(obj); + Assert.Equal(@"{""Name"":""Test"",""OptionalValue"":42}", json); + + var deserialized = JsonSerializer.Deserialize(json); + Assert.Equal("Test", deserialized.Name); + Assert.True(deserialized.OptionalValue.HasValue); + Assert.Equal(42, deserialized.OptionalValue.Value); + } + + private class ClassWithOptionProperty + { + public string Name { get; set; } + public Option OptionalValue { get; set; } + } + + /// + /// A generic result type that represents either a success value or an error. + /// Tests a generic converter with two type parameters. + /// + [JsonConverter(typeof(ResultConverter<,>))] + public readonly struct Result + { + public bool IsSuccess { get; } + public TValue Value { get; } + public TError Error { get; } + + private Result(TValue value, TError error, bool isSuccess) + { + Value = value; + Error = error; + IsSuccess = isSuccess; + } + + public static Result Success(TValue value) => + new(value, default!, true); + + public static Result Failure(TError error) => + new(default!, error, false); + } + + /// + /// Generic converter for the Result type with two type parameters. + /// + public sealed class ResultConverter : JsonConverter> + { + public override Result Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.StartObject) + { + throw new JsonException(); + } + + bool? isSuccess = null; + TValue value = default!; + TError error = default!; + + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + { + break; + } + + if (reader.TokenType != JsonTokenType.PropertyName) + { + throw new JsonException(); + } + + string propertyName = reader.GetString()!; + reader.Read(); + + switch (propertyName) + { + case "IsSuccess": + isSuccess = reader.GetBoolean(); + break; + case "Value": + value = JsonSerializer.Deserialize(ref reader, options)!; + break; + case "Error": + error = JsonSerializer.Deserialize(ref reader, options)!; + break; + } + } + + if (isSuccess == true) + { + return Result.Success(value); + } + + return Result.Failure(error); + } + + public override void Write(Utf8JsonWriter writer, Result value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + writer.WriteBoolean("IsSuccess", value.IsSuccess); + + if (value.IsSuccess) + { + writer.WritePropertyName("Value"); + JsonSerializer.Serialize(writer, value.Value, options); + } + else + { + writer.WritePropertyName("Error"); + JsonSerializer.Serialize(writer, value.Error, options); + } + + writer.WriteEndObject(); + } + } + + [Fact] + public static void GenericConverterAttributeOnGenericType_TwoTypeParameters_Success() + { + var result = Result.Success(42); + string json = JsonSerializer.Serialize(result); + Assert.Equal(@"{""IsSuccess"":true,""Value"":42}", json); + + var deserialized = JsonSerializer.Deserialize>(json); + Assert.True(deserialized.IsSuccess); + Assert.Equal(42, deserialized.Value); + } + + [Fact] + public static void GenericConverterAttributeOnGenericType_TwoTypeParameters_Failure() + { + var result = Result.Failure("error message"); + string json = JsonSerializer.Serialize(result); + Assert.Equal(@"{""IsSuccess"":false,""Error"":""error message""}", json); + + var deserialized = JsonSerializer.Deserialize>(json); + Assert.False(deserialized.IsSuccess); + Assert.Equal("error message", deserialized.Error); + } + + /// + /// Test that an open generic converter can be used on a property with [JsonConverter]. + /// + [Fact] + public static void GenericConverterAttributeOnProperty() + { + var obj = new ClassWithGenericConverterOnProperty { Value = new MyGenericWrapper(42) }; + string json = JsonSerializer.Serialize(obj); + Assert.Equal(@"{""Value"":42}", json); + + var deserialized = JsonSerializer.Deserialize(json); + Assert.Equal(42, deserialized.Value.WrappedValue); + } + + private class ClassWithGenericConverterOnProperty + { + [JsonConverter(typeof(MyGenericWrapperConverter<>))] + public MyGenericWrapper Value { get; set; } + } + + public class MyGenericWrapper + { + public T WrappedValue { get; } + + public MyGenericWrapper(T value) + { + WrappedValue = value; + } + } + + public sealed class MyGenericWrapperConverter : JsonConverter> + { + public override MyGenericWrapper Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + T value = JsonSerializer.Deserialize(ref reader, options)!; + return new MyGenericWrapper(value); + } + + public override void Write(Utf8JsonWriter writer, MyGenericWrapper value, JsonSerializerOptions options) + { + JsonSerializer.Serialize(writer, value.WrappedValue, options); + } + } + + // Tests for type parameter arity mismatch + [Fact] + public static void GenericConverterAttribute_ArityMismatch_ThrowsInvalidOperationException() + { + // The converter has two type parameters but the type has one. + // This throws InvalidOperationException with a contextual error message + // because the arity doesn't match. + Assert.Throws(() => JsonSerializer.Serialize(new TypeWithArityMismatch())); + } + + [JsonConverter(typeof(ConverterWithTwoParams<,>))] + public class TypeWithArityMismatch + { + public T Value { get; set; } + } + + public sealed class ConverterWithTwoParams : JsonConverter> + { + public override TypeWithArityMismatch Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => throw new NotImplementedException(); + + public override void Write(Utf8JsonWriter writer, TypeWithArityMismatch value, JsonSerializerOptions options) + => throw new NotImplementedException(); + } + + // Tests for type constraint violations + [Fact] + public static void GenericConverterAttribute_ConstraintViolation_ThrowsArgumentException() + { + // The converter has a class constraint but int is a value type + Assert.Throws(() => JsonSerializer.Serialize(new TypeWithConstraintViolation())); + } + + [JsonConverter(typeof(ConverterWithClassConstraint<>))] + public class TypeWithConstraintViolation + { + public T Value { get; set; } + } + + public sealed class ConverterWithClassConstraint : JsonConverter> where T : class + { + public override TypeWithConstraintViolation Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => throw new NotImplementedException(); + + public override void Write(Utf8JsonWriter writer, TypeWithConstraintViolation value, JsonSerializerOptions options) + => throw new NotImplementedException(); + } + + // Tests for converter type that doesn't match the target type + [Fact] + public static void GenericConverterAttribute_ConverterTypeMismatch_ThrowsInvalidOperationException() + { + // The converter converts DifferentType but the type is TypeWithConverterMismatch + Assert.Throws(() => JsonSerializer.Serialize(new TypeWithConverterMismatch())); + } + + [JsonConverter(typeof(DifferentTypeConverter<>))] + public class TypeWithConverterMismatch + { + public T Value { get; set; } + } + + public class DifferentType + { + public T Value { get; set; } + } + + public sealed class DifferentTypeConverter : JsonConverter> + { + public override DifferentType Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => throw new NotImplementedException(); + + public override void Write(Utf8JsonWriter writer, DifferentType value, JsonSerializerOptions options) + => throw new NotImplementedException(); + } + + // Tests for open generic converter on a non-generic type + [Fact] + public static void GenericConverterAttribute_OpenGenericOnNonGenericType_ThrowsInvalidOperationException() + { + // The converter is an open generic but the target type is not generic, + // so the converter type cannot be instantiated. We throw InvalidOperationException + // with a contextual error message. + Assert.Throws(() => JsonSerializer.Serialize(new NonGenericTypeWithOpenGenericConverter())); + } + + [JsonConverter(typeof(OpenGenericConverterForNonGenericType<>))] + public class NonGenericTypeWithOpenGenericConverter + { + public string Name { get; set; } + } + + public sealed class OpenGenericConverterForNonGenericType : JsonConverter + { + public override NonGenericTypeWithOpenGenericConverter Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => throw new NotImplementedException(); + + public override void Write(Utf8JsonWriter writer, NonGenericTypeWithOpenGenericConverter value, JsonSerializerOptions options) + => throw new NotImplementedException(); + } + + // Tests for swapped parameter order in converter + [Fact] + public static void GenericConverterAttribute_SwappedParameterOrder_ThrowsInvalidOperationException() + { + // The converter converts TypeWithSwappedParams but the type is TypeWithSwappedParams, + // so MakeGenericType succeeds but CanConvert check fails. + Assert.Throws(() => JsonSerializer.Serialize(new TypeWithSwappedParams())); + } + + [JsonConverter(typeof(SwappedParamsConverter<,>))] + public class TypeWithSwappedParams + { + public T1 First { get; set; } + public T2 Second { get; set; } + } + + public sealed class SwappedParamsConverter : JsonConverter> + { + public override TypeWithSwappedParams Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => throw new NotImplementedException(); + + public override void Write(Utf8JsonWriter writer, TypeWithSwappedParams value, JsonSerializerOptions options) + => throw new NotImplementedException(); + } + + // Tests for nested containing class with type parameters + [Fact] + public static void GenericConverterAttribute_NestedConverter_Works() + { + // Converter is nested in a generic container class + var value = new TypeWithNestedConverter { Value1 = 42, Value2 = "hello" }; + string json = JsonSerializer.Serialize(value); + Assert.Equal(@"{""Value1"":42,""Value2"":""hello""}", json); + + var deserialized = JsonSerializer.Deserialize>(json); + Assert.Equal(42, deserialized.Value1); + Assert.Equal("hello", deserialized.Value2); + } + + [JsonConverter(typeof(Container<>.NestedConverter<>))] + public class TypeWithNestedConverter + { + public T1 Value1 { get; set; } + public T2 Value2 { get; set; } + } + + public class Container + { + public sealed class NestedConverter : JsonConverter> + { + public override TypeWithNestedConverter Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.StartObject) + throw new JsonException(); + + var result = new TypeWithNestedConverter(); + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + break; + + if (reader.TokenType != JsonTokenType.PropertyName) + throw new JsonException(); + + string propertyName = reader.GetString()!; + reader.Read(); + + if (propertyName == "Value1") + result.Value1 = JsonSerializer.Deserialize(ref reader, options)!; + else if (propertyName == "Value2") + result.Value2 = JsonSerializer.Deserialize(ref reader, options)!; + } + return result; + } + + public override void Write(Utf8JsonWriter writer, TypeWithNestedConverter value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + writer.WritePropertyName("Value1"); + JsonSerializer.Serialize(writer, value.Value1, options); + writer.WritePropertyName("Value2"); + JsonSerializer.Serialize(writer, value.Value2, options); + writer.WriteEndObject(); + } + } + } + + // Tests for type parameters with constraints that are satisfied + [Fact] + public static void GenericConverterAttribute_ConstraintSatisfied_Works() + { + var value = new TypeWithSatisfiedConstraint { Value = "test" }; + string json = JsonSerializer.Serialize(value); + Assert.Equal(@"{""Value"":""test""}", json); + + var deserialized = JsonSerializer.Deserialize>(json); + Assert.Equal("test", deserialized.Value); + } + + [JsonConverter(typeof(ConverterWithSatisfiedConstraint<>))] + public class TypeWithSatisfiedConstraint + { + public T Value { get; set; } + } + + public sealed class ConverterWithSatisfiedConstraint : JsonConverter> where T : class + { + public override TypeWithSatisfiedConstraint Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.StartObject) + throw new JsonException(); + + var result = new TypeWithSatisfiedConstraint(); + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + break; + + if (reader.TokenType != JsonTokenType.PropertyName) + throw new JsonException(); + + string propertyName = reader.GetString()!; + reader.Read(); + + if (propertyName == "Value") + result.Value = JsonSerializer.Deserialize(ref reader, options)!; + } + return result; + } + + public override void Write(Utf8JsonWriter writer, TypeWithSatisfiedConstraint value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + writer.WritePropertyName("Value"); + JsonSerializer.Serialize(writer, value.Value, options); + writer.WriteEndObject(); + } + } + + // Tests for generic within non-generic within generic: Outer<>.Middle.Inner<> + [Fact] + public static void GenericConverterAttribute_DeeplyNestedConverter_Works() + { + var value = new TypeWithDeeplyNestedConverter { Value1 = 99, Value2 = "deep" }; + string json = JsonSerializer.Serialize(value); + Assert.Equal(@"{""Value1"":99,""Value2"":""deep""}", json); + + var deserialized = JsonSerializer.Deserialize>(json); + Assert.Equal(99, deserialized.Value1); + Assert.Equal("deep", deserialized.Value2); + } + + [JsonConverter(typeof(OuterGeneric<>.MiddleNonGeneric.InnerConverter<>))] + public class TypeWithDeeplyNestedConverter + { + public T1 Value1 { get; set; } + public T2 Value2 { get; set; } + } + + public class OuterGeneric + { + public class MiddleNonGeneric + { + public sealed class InnerConverter : JsonConverter> + { + public override TypeWithDeeplyNestedConverter Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.StartObject) + throw new JsonException(); + + var result = new TypeWithDeeplyNestedConverter(); + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + break; + + if (reader.TokenType != JsonTokenType.PropertyName) + throw new JsonException(); + + string propertyName = reader.GetString()!; + reader.Read(); + + if (propertyName == "Value1") + result.Value1 = JsonSerializer.Deserialize(ref reader, options)!; + else if (propertyName == "Value2") + result.Value2 = JsonSerializer.Deserialize(ref reader, options)!; + } + return result; + } + + public override void Write(Utf8JsonWriter writer, TypeWithDeeplyNestedConverter value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + writer.WritePropertyName("Value1"); + JsonSerializer.Serialize(writer, value.Value1, options); + writer.WritePropertyName("Value2"); + JsonSerializer.Serialize(writer, value.Value2, options); + writer.WriteEndObject(); + } + } + } + } + + // Tests for converter nested in non-generic class + [Fact] + public static void GenericConverterAttribute_NonGenericOuterConverter_Works() + { + var value = new TypeWithNonGenericOuterConverter { Value = 42 }; + string json = JsonSerializer.Serialize(value); + Assert.Equal(@"{""Value"":42}", json); + + var deserialized = JsonSerializer.Deserialize>(json); + Assert.Equal(42, deserialized.Value); + } + + [JsonConverter(typeof(NonGenericOuter.SingleLevelConverter<>))] + public class TypeWithNonGenericOuterConverter + { + public T Value { get; set; } + } + + public class NonGenericOuter + { + public sealed class SingleLevelConverter : JsonConverter> + { + public override TypeWithNonGenericOuterConverter Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.StartObject) + throw new JsonException(); + + var result = new TypeWithNonGenericOuterConverter(); + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + break; + + if (reader.TokenType != JsonTokenType.PropertyName) + throw new JsonException(); + + string propertyName = reader.GetString()!; + reader.Read(); + + if (propertyName == "Value") + result.Value = JsonSerializer.Deserialize(ref reader, options)!; + } + return result; + } + + public override void Write(Utf8JsonWriter writer, TypeWithNonGenericOuterConverter value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + writer.WritePropertyName("Value"); + JsonSerializer.Serialize(writer, value.Value, options); + writer.WriteEndObject(); + } + } + } + + // Tests for many generic parameters with asymmetric distribution: Level1<,,>.Level2<>.Level3Converter<> + [Fact] + public static void GenericConverterAttribute_ManyParamsAsymmetricNesting_Works() + { + var value = new TypeWithManyParams + { + Value1 = 1, + Value2 = "two", + Value3 = true, + Value4 = 4.0, + Value5 = 5L + }; + string json = JsonSerializer.Serialize(value); + Assert.Equal(@"{""Value1"":1,""Value2"":""two"",""Value3"":true,""Value4"":4,""Value5"":5}", json); + + var deserialized = JsonSerializer.Deserialize>(json); + Assert.Equal(1, deserialized.Value1); + Assert.Equal("two", deserialized.Value2); + Assert.True(deserialized.Value3); + Assert.Equal(4.0, deserialized.Value4); + Assert.Equal(5L, deserialized.Value5); + } + + [JsonConverter(typeof(Level1<,,>.Level2<>.Level3Converter<>))] + public class TypeWithManyParams + { + public T1 Value1 { get; set; } + public T2 Value2 { get; set; } + public T3 Value3 { get; set; } + public T4 Value4 { get; set; } + public T5 Value5 { get; set; } + } + + public class Level1 + { + public class Level2 + { + public sealed class Level3Converter : JsonConverter> + { + public override TypeWithManyParams Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.StartObject) + throw new JsonException(); + + var result = new TypeWithManyParams(); + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + break; + + if (reader.TokenType != JsonTokenType.PropertyName) + throw new JsonException(); + + string propertyName = reader.GetString()!; + reader.Read(); + + switch (propertyName) + { + case "Value1": result.Value1 = JsonSerializer.Deserialize(ref reader, options)!; break; + case "Value2": result.Value2 = JsonSerializer.Deserialize(ref reader, options)!; break; + case "Value3": result.Value3 = JsonSerializer.Deserialize(ref reader, options)!; break; + case "Value4": result.Value4 = JsonSerializer.Deserialize(ref reader, options)!; break; + case "Value5": result.Value5 = JsonSerializer.Deserialize(ref reader, options)!; break; + } + } + + return result; + } + + public override void Write(Utf8JsonWriter writer, TypeWithManyParams value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + writer.WritePropertyName("Value1"); + JsonSerializer.Serialize(writer, value.Value1, options); + writer.WritePropertyName("Value2"); + JsonSerializer.Serialize(writer, value.Value2, options); + writer.WritePropertyName("Value3"); + JsonSerializer.Serialize(writer, value.Value3, options); + writer.WritePropertyName("Value4"); + JsonSerializer.Serialize(writer, value.Value4, options); + writer.WritePropertyName("Value5"); + JsonSerializer.Serialize(writer, value.Value5, options); + writer.WriteEndObject(); + } + } + } + } } }