File: System\Text\Json\Serialization\Converters\FSharp\FSharpUnionConverter.cs
Web Access
Project: src\src\libraries\System.Text.Json\src\System.Text.Json.csproj (System.Text.Json)
// 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;
using System.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Reflection;
using System.Runtime.CompilerServices;
#if !NET
using System.Runtime.Serialization;
#endif
using System.Text.Json.Reflection;
using System.Text.Json.Serialization.Metadata;
 
namespace System.Text.Json.Serialization.Converters
{
    // Converter for F# discriminated unions.
    // Fieldless cases are serialized as JSON strings.
    // Cases with fields are serialized as JSON objects with a type discriminator and named field properties.
    // The discriminator property name defaults to "$type" but can be customized via [JsonPolymorphic].
    internal sealed class FSharpUnionConverter<T> : JsonConverter<T>
    {
        internal override bool CanHaveMetadata => true;
        private readonly CaseInfo[] _casesByTag;
        private readonly Dictionary<string, CaseInfo> _casesByName;
        private readonly Dictionary<string, CaseInfo>? _casesByNameCaseInsensitive;
        private readonly Func<object, int> _tagReader;
        private readonly string _typeDiscriminatorPropertyName;
        private readonly JsonEncodedText _typeDiscriminatorPropertyNameEncoded;
        private readonly JsonUnmappedMemberHandling _effectiveUnmappedMemberHandling;
 
        [RequiresUnreferencedCode(FSharpCoreReflectionProxy.FSharpCoreUnreferencedCodeMessage)]
        [RequiresDynamicCode(FSharpCoreReflectionProxy.FSharpCoreUnreferencedCodeMessage)]
        public FSharpUnionConverter(
            FSharpCoreReflectionProxy.FSharpUnionCaseInfo[] unionCases,
            Func<object, int> tagReader,
            JsonSerializerOptions options,
            string typeDiscriminatorPropertyName)
        {
            ConverterStrategy = ConverterStrategy.Object;
            SupportsMultipleTokenTypes = true;
            RequiresReadAhead = true;
            _tagReader = tagReader;
            _typeDiscriminatorPropertyName = typeDiscriminatorPropertyName;
            _typeDiscriminatorPropertyNameEncoded = JsonEncodedText.Encode(typeDiscriminatorPropertyName, options.Encoder);
            _effectiveUnmappedMemberHandling = typeof(T).GetUniqueCustomAttribute<JsonUnmappedMemberHandlingAttribute>(inherit: false)?.UnmappedMemberHandling
                ?? options.UnmappedMemberHandling;
 
            _casesByTag = new CaseInfo[unionCases.Length];
            _casesByName = new Dictionary<string, CaseInfo>(unionCases.Length, StringComparer.Ordinal);
 
            Dictionary<string, CaseInfo>? caseInsensitiveMap = options.PropertyNameCaseInsensitive
                ? new Dictionary<string, CaseInfo>(unionCases.Length, StringComparer.OrdinalIgnoreCase)
                : null;
 
            JsonNamingPolicy? namingPolicy = options.PropertyNamingPolicy;
 
            foreach (FSharpCoreReflectionProxy.FSharpUnionCaseInfo uc in unionCases)
            {
                // Case name resolution: JsonPropertyNameAttribute > PropertyNamingPolicy > raw name
                string discriminatorName = uc.JsonPropertyName
                    ?? namingPolicy?.ConvertName(uc.Name)
                    ?? uc.Name;
 
                JsonEncodedText encodedDiscriminatorName = JsonEncodedText.Encode(discriminatorName, options.Encoder);
 
                // Build field info for cases with fields
                CaseFieldInfo[]? fields = null;
                if (!uc.IsFieldless)
                {
                    fields = new CaseFieldInfo[uc.Fields.Length];
                    for (int i = 0; i < uc.Fields.Length; i++)
                    {
                        PropertyInfo prop = uc.Fields[i];
                        string fieldName = prop.GetCustomAttribute<JsonPropertyNameAttribute>()?.Name
                            ?? namingPolicy?.ConvertName(prop.Name)
                            ?? prop.Name;
                        JsonEncodedText encodedFieldName = JsonEncodedText.Encode(fieldName, options.Encoder);
 
                        fields[i] = new CaseFieldInfo(fieldName, encodedFieldName, prop, options);
                    }
 
                    // Validate that no field name conflicts with the discriminator property name.
                    StringComparison conflictComparison = options.PropertyNameCaseInsensitive
                        ? StringComparison.OrdinalIgnoreCase
                        : StringComparison.Ordinal;
 
                    for (int i = 0; i < fields.Length; i++)
                    {
                        if (fields[i].FieldName.Equals(typeDiscriminatorPropertyName, conflictComparison))
                        {
                            throw new InvalidOperationException(SR.Format(SR.FSharpUnionFieldConflictsWithDiscriminator, typeof(T), fields[i].FieldName, typeDiscriminatorPropertyName));
                        }
                    }
                }
 
                // Build default field values for deserialization with missing fields.
                object[]? defaultFieldValues = null;
                if (fields is not null)
                {
                    defaultFieldValues = new object[fields.Length];
                    for (int i = 0; i < fields.Length; i++)
                    {
                        Type fieldType = fields[i].FieldType;
                        defaultFieldValues[i] = fieldType.IsValueType ?
#if NET
                            RuntimeHelpers.GetUninitializedObject(fieldType) :
#else
                            FormatterServices.GetUninitializedObject(fieldType) :
#endif
                            null!;
                    }
                }
 
                var caseInfo = new CaseInfo(
                    discriminatorName,
                    encodedDiscriminatorName,
                    uc.Tag,
                    uc.IsFieldless,
                    fields,
                    defaultFieldValues,
                    fields is not null ? BuildFieldIndexMap(fields, StringComparer.Ordinal, typeof(T), discriminatorName) : null,
                    fields is not null && options.PropertyNameCaseInsensitive ? BuildFieldIndexMap(fields, StringComparer.OrdinalIgnoreCase, typeof(T), discriminatorName) : null,
                    uc.FieldReader,
                    uc.Constructor,
                    typeof(T));
 
                _casesByTag[uc.Tag] = caseInfo;
 
                if (!_casesByName.TryAdd(discriminatorName, caseInfo))
                {
                    throw new InvalidOperationException(SR.Format(SR.FSharpUnionDuplicateCaseName, typeof(T), discriminatorName));
                }
 
                if (caseInsensitiveMap is not null && !caseInsensitiveMap.TryAdd(discriminatorName, caseInfo))
                {
                    throw new InvalidOperationException(SR.Format(SR.FSharpUnionDuplicateCaseName, typeof(T), discriminatorName));
                }
            }
 
            _casesByNameCaseInsensitive = caseInsensitiveMap;
        }
 
        public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
        {
            // Fallback for direct converter invocations. The normal pipeline
            // uses TryRead -> OnTryRead which forwards state automatically.
            ReadStack state = default;
            JsonTypeInfo jsonTypeInfo = options.GetTypeInfoInternal(typeToConvert);
            state.Initialize(jsonTypeInfo);
            state.Push();
 
            OnTryRead(ref reader, typeToConvert, options, ref state, out T? value);
            return value!;
        }
 
        internal override bool OnTryRead(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options, scoped ref ReadStack state, out T? value)
        {
            if (reader.TokenType == JsonTokenType.String)
            {
                value = ReadFromString(ref reader, options);
                return true;
            }
 
            if (reader.TokenType == JsonTokenType.StartObject)
            {
                value = ReadFromObject(ref reader, options, ref state);
                return true;
            }
 
            ThrowHelper.ThrowJsonException_DeserializeUnableToConvertValue(typeof(T));
            value = default;
            return true;
        }
 
        private T ReadFromString(ref Utf8JsonReader reader, JsonSerializerOptions options)
        {
            string? caseName = reader.GetString();
            if (caseName is null)
            {
                ThrowHelper.ThrowJsonException_DeserializeUnableToConvertValue(typeof(T));
            }
 
            CaseInfo caseInfo = LookupCaseByName(caseName!);
 
            if (caseInfo.IsFieldless)
            {
                return (T)caseInfo.Constructor(Array.Empty<object>());
            }
 
            // String form for a case with fields: only allowed when fields are not required.
            if (options.RespectRequiredConstructorParameters)
            {
                ThrowHelper.ThrowJsonException();
            }
 
            return (T)caseInfo.Constructor((object[])caseInfo.DefaultFieldValues!.Clone());
        }
 
        private T ReadFromObject(ref Utf8JsonReader reader, JsonSerializerOptions options, scoped ref ReadStack state)
        {
            Debug.Assert(reader.TokenType == JsonTokenType.StartObject);
 
            // This converter opts into read-ahead buffering (via RequiresReadAhead),
            // so by the time ReadFromObject is called the entire JSON value has been
            // buffered and we can safely scan ahead for the discriminator and restore the reader position.
            Utf8JsonReader checkpoint = reader;
 
            bool preserveReferences = !typeof(T).IsValueType &&
                options.ReferenceHandlingStrategy == JsonKnownReferenceHandler.Preserve;
 
            // Scan for the type discriminator and any reference metadata ($id, $ref).
            string? caseName = null;
            string? referenceId = null;
            bool hasNonMetadataProperties = false;
            while (reader.Read() && reader.TokenType != JsonTokenType.EndObject)
            {
                Debug.Assert(reader.TokenType == JsonTokenType.PropertyName);
 
                if (preserveReferences)
                {
                    switch (JsonSerializer.GetMetadataPropertyName(reader.GetUnescapedSpan(), resolver: null))
                    {
                        case MetadataPropertyName.Ref:
                            if (hasNonMetadataProperties || referenceId is not null)
                            {
                                ThrowHelper.ThrowJsonException_MetadataReferenceObjectCannotContainOtherProperties();
                            }
 
                            reader.Read();
                            if (reader.TokenType != JsonTokenType.String)
                            {
                                ThrowHelper.ThrowJsonException();
                            }
 
                            string refValue = reader.GetString()!;
 
                            // Validate that no other properties follow $ref.
                            while (reader.Read() && reader.TokenType != JsonTokenType.EndObject)
                            {
                                ThrowHelper.ThrowJsonException_MetadataReferenceObjectCannotContainOtherProperties();
                            }
 
                            return (T)state.ReferenceResolver.ResolveReference(refValue);
 
                        case MetadataPropertyName.Id:
                            if (referenceId is not null)
                            {
                                ThrowHelper.ThrowJsonException();
                            }
 
                            reader.Read();
                            if (reader.TokenType != JsonTokenType.String)
                            {
                                ThrowHelper.ThrowJsonException();
                            }
 
                            referenceId = reader.GetString();
                            continue;
                    }
                }
 
                hasNonMetadataProperties = true;
                bool isDiscriminator = reader.ValueTextEquals(_typeDiscriminatorPropertyName);
                reader.Read();
 
                if (isDiscriminator)
                {
                    if (reader.TokenType != JsonTokenType.String)
                    {
                        ThrowHelper.ThrowJsonException();
                    }
 
                    caseName = reader.GetString();
 
                    if (!preserveReferences)
                    {
                        break;
                    }
 
                    continue;
                }
 
                reader.TrySkip();
            }
 
            if (caseName is null)
            {
                throw new JsonException(SR.Format(SR.FSharpUnionMissingDiscriminatorProperty, _typeDiscriminatorPropertyName, typeof(T)));
            }
 
            CaseInfo caseInfo = LookupCaseByName(caseName);
 
            // Second pass: restore reader and process all properties in a single loop.
            reader = checkpoint;
            bool discriminatorSeen = false;
            object[]? fieldValues = caseInfo.IsFieldless ? null : (object[])caseInfo.DefaultFieldValues!.Clone();
            bool trackPopulated = !caseInfo.IsFieldless && (options.RespectRequiredConstructorParameters || !options.AllowDuplicateProperties);
            BitArray? populatedFields = trackPopulated ? new BitArray(caseInfo.Fields!.Length) : null;
 
            while (reader.Read() && reader.TokenType != JsonTokenType.EndObject)
            {
                Debug.Assert(reader.TokenType == JsonTokenType.PropertyName);
 
                // Skip discriminator property, detecting duplicates.
                if (reader.ValueTextEquals(_typeDiscriminatorPropertyName))
                {
                    if (discriminatorSeen)
                    {
                        ThrowHelper.ThrowJsonException_DuplicatePropertyNotAllowed(_typeDiscriminatorPropertyName);
                    }
 
                    discriminatorSeen = true;
                    reader.Read();
                    reader.TrySkip();
                    continue;
                }
 
                // Skip metadata properties ($id, $ref) already processed in the first pass.
                if (preserveReferences &&
                    JsonSerializer.GetMetadataPropertyName(reader.GetUnescapedSpan(), resolver: null) is not MetadataPropertyName.None)
                {
                    reader.Read();
                    reader.TrySkip();
                    continue;
                }
 
                // Try to match a union case field.
                string? fieldName = reader.GetString();
                reader.Read();
 
                if (fieldValues is not null && fieldName is not null && TryGetFieldIndex(fieldName, caseInfo, out int fieldIndex))
                {
                    if (populatedFields is not null)
                    {
                        if (!options.AllowDuplicateProperties && populatedFields[fieldIndex])
                        {
                            ThrowHelper.ThrowJsonException_DuplicatePropertyNotAllowed(fieldName);
                        }
 
                        populatedFields[fieldIndex] = true;
                    }
 
                    CaseFieldInfo field = caseInfo.Fields![fieldIndex];
                    state.Current.JsonPropertyInfo = field.PropertyInfoForTypeInfo;
                    state.Current.NumberHandling = field.NumberHandling;
                    bool success = field.Converter.TryReadAsObject(ref reader, field.FieldType, options, ref state, out object? fieldValue);
                    Debug.Assert(success, "Nested converter should not suspend since the union payload is fully buffered.");
                    fieldValues[fieldIndex] = fieldValue!;
                }
                else
                {
                    if (_effectiveUnmappedMemberHandling is JsonUnmappedMemberHandling.Disallow)
                    {
                        ThrowHelper.ThrowJsonException_UnmappedJsonProperty(typeof(T), fieldName ?? string.Empty);
                    }
 
                    reader.TrySkip();
                }
            }
 
            if (options.RespectRequiredConstructorParameters && populatedFields is not null && !populatedFields.HasAllSet())
            {
                ThrowForMissingRequiredFields(caseInfo, populatedFields);
            }
 
            T result = caseInfo.IsFieldless
                ? (T)caseInfo.Constructor(Array.Empty<object>())
                : (T)caseInfo.Constructor(fieldValues!);
 
            if (referenceId is not null)
            {
                state.ReferenceResolver.AddReference(referenceId, result);
            }
 
            return result;
        }
 
        private static void ThrowForMissingRequiredFields(CaseInfo caseInfo, BitArray populatedFields)
        {
            StringBuilder builder = new();
            for (int i = 0; i < caseInfo.Fields!.Length; i++)
            {
                if (!populatedFields[i])
                {
                    if (!ThrowHelper.AppendMissingProperty(builder, caseInfo.Fields[i].FieldName))
                    {
                        break;
                    }
                }
            }
 
            ThrowHelper.ThrowJsonException_JsonRequiredPropertyMissing(caseInfo.DeclaringType, builder.ToString());
        }
 
        public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options)
        {
            // Fallback for direct converter invocations. The normal pipeline
            // uses TryWrite -> OnTryWrite which forwards state automatically.
            WriteStack state = default;
            JsonTypeInfo typeInfo = options.GetTypeInfoInternal(typeof(T));
            state.Initialize(typeInfo);
            state.Push();
 
            try
            {
                OnTryWrite(writer, value, options, ref state);
            }
            catch
            {
                state.DisposePendingDisposablesOnException();
                throw;
            }
        }
 
        internal override bool OnTryWrite(Utf8JsonWriter writer, T value, JsonSerializerOptions options, ref WriteStack state)
        {
            int tag = _tagReader(value!);
            CaseInfo caseInfo = _casesByTag[tag];
 
            // Fieldless cases serialize as strings when reference tracking is not active.
            if (caseInfo.IsFieldless && state.NewReferenceId is null)
            {
                writer.WriteStringValue(caseInfo.EncodedDiscriminatorName);
                return true;
            }
 
            writer.WriteStartObject();
 
            // Write $id metadata if a new reference was registered by TryWrite.
            if (state.NewReferenceId is not null)
            {
                writer.WriteString(JsonSerializer.s_metadataId, state.NewReferenceId);
                state.NewReferenceId = null;
            }
 
            writer.WriteString(_typeDiscriminatorPropertyNameEncoded, caseInfo.EncodedDiscriminatorName);
 
            if (!caseInfo.IsFieldless)
            {
                object[] fieldValues = caseInfo.FieldReader(value!);
                Debug.Assert(fieldValues.Length == caseInfo.Fields!.Length);
 
                for (int i = 0; i < caseInfo.Fields.Length; i++)
                {
                    CaseFieldInfo field = caseInfo.Fields[i];
                    writer.WritePropertyName(field.EncodedFieldName);
                    state.Current.JsonPropertyInfo = field.PropertyInfoForTypeInfo;
                    state.Current.NumberHandling = field.NumberHandling;
                    bool success = field.Converter.TryWriteAsObject(writer, fieldValues[i], options, ref state);
                    Debug.Assert(success, "Nested converter should not suspend since the union payload is fully buffered.");
                }
            }
 
            writer.WriteEndObject();
            return true;
        }
 
        internal override void ConfigureJsonTypeInfo(JsonTypeInfo jsonTypeInfo, JsonSerializerOptions options)
        {
            // Clear any polymorphism options that PopulatePolymorphismMetadata() may have set
            // from a [JsonPolymorphic] attribute. The F# union converter handles the type
            // discriminator internally and does not use the standard polymorphism pipeline.
            jsonTypeInfo.PolymorphismOptions = null;
        }
 
        private CaseInfo LookupCaseByName(string caseName)
        {
            if (_casesByName.TryGetValue(caseName, out CaseInfo? caseInfo))
            {
                return caseInfo;
            }
 
            if (_casesByNameCaseInsensitive?.TryGetValue(caseName, out caseInfo) == true)
            {
                return caseInfo;
            }
 
            ThrowHelper.ThrowJsonException_DeserializeUnableToConvertValue(typeof(T));
            return default!;
        }
 
        private static bool TryGetFieldIndex(
            string fieldName,
            CaseInfo caseInfo,
            out int fieldIndex)
        {
            if (caseInfo.FieldIndexMap!.TryGetValue(fieldName, out fieldIndex))
            {
                return true;
            }
 
            if (caseInfo.FieldIndexMapCaseInsensitive?.TryGetValue(fieldName, out fieldIndex) == true)
            {
                return true;
            }
 
            fieldIndex = -1;
            return false;
        }
 
        private static Dictionary<string, int> BuildFieldIndexMap(CaseFieldInfo[] fields, StringComparer comparer, Type declaringType, string caseName)
        {
            var map = new Dictionary<string, int>(fields.Length, comparer);
            for (int i = 0; i < fields.Length; i++)
            {
                if (!map.TryAdd(fields[i].FieldName, i))
                {
                    throw new InvalidOperationException(SR.Format(SR.FSharpUnionDuplicateFieldName, declaringType, caseName, fields[i].FieldName));
                }
            }
 
            return map;
        }
 
        private sealed class CaseInfo
        {
            public CaseInfo(
                string discriminatorName,
                JsonEncodedText encodedDiscriminatorName,
                int tag,
                bool isFieldless,
                CaseFieldInfo[]? fields,
                object[]? defaultFieldValues,
                Dictionary<string, int>? fieldIndexMap,
                Dictionary<string, int>? fieldIndexMapCaseInsensitive,
                Func<object, object[]> fieldReader,
                Func<object[], object> constructor,
                Type declaringType)
            {
                DiscriminatorName = discriminatorName;
                EncodedDiscriminatorName = encodedDiscriminatorName;
                Tag = tag;
                IsFieldless = isFieldless;
                Fields = fields;
                DefaultFieldValues = defaultFieldValues;
                FieldIndexMap = fieldIndexMap;
                FieldIndexMapCaseInsensitive = fieldIndexMapCaseInsensitive;
                FieldReader = fieldReader;
                Constructor = constructor;
                DeclaringType = declaringType;
            }
 
            public string DiscriminatorName { get; }
            public JsonEncodedText EncodedDiscriminatorName { get; }
            public int Tag { get; }
            public bool IsFieldless { get; }
            public CaseFieldInfo[]? Fields { get; }
            public object[]? DefaultFieldValues { get; }
            public Dictionary<string, int>? FieldIndexMap { get; }
            public Dictionary<string, int>? FieldIndexMapCaseInsensitive { get; }
            public Func<object, object[]> FieldReader { get; }
            public Func<object[], object> Constructor { get; }
            public Type DeclaringType { get; }
        }
 
        private sealed class CaseFieldInfo
        {
            private readonly JsonSerializerOptions _options;
            private JsonConverter? _converter;
            private JsonPropertyInfo? _propertyInfoForTypeInfo;
 
            [RequiresUnreferencedCode(FSharpCoreReflectionProxy.FSharpCoreUnreferencedCodeMessage)]
            [RequiresDynamicCode(FSharpCoreReflectionProxy.FSharpCoreUnreferencedCodeMessage)]
            public CaseFieldInfo(
                string fieldName,
                JsonEncodedText encodedFieldName,
                PropertyInfo propertyInfo,
                JsonSerializerOptions options)
            {
                FieldName = fieldName;
                EncodedFieldName = encodedFieldName;
                FieldType = propertyInfo.PropertyType;
                _options = options;
 
                // Honor [JsonConverter] on the field PropertyInfo.
                JsonConverterAttribute? converterAttr = propertyInfo.GetCustomAttribute<JsonConverterAttribute>(inherit: false);
                if (converterAttr is not null)
                {
                    _converter = ResolveCustomConverter(converterAttr, FieldType, propertyInfo, options);
                }
 
                // Honor [JsonNumberHandling] on the field PropertyInfo.
                NumberHandling = propertyInfo.GetCustomAttribute<JsonNumberHandlingAttribute>(inherit: false)?.Handling;
            }
 
            public string FieldName { get; }
            public JsonEncodedText EncodedFieldName { get; }
            public Type FieldType { get; }
            public JsonNumberHandling? NumberHandling { get; }
            public JsonConverter Converter => _converter ??= _options.GetConverterInternal(FieldType);
            public JsonPropertyInfo PropertyInfoForTypeInfo => _propertyInfoForTypeInfo ??= _options.GetTypeInfoInternal(FieldType).PropertyInfoForTypeInfo;
 
            [RequiresUnreferencedCode(FSharpCoreReflectionProxy.FSharpCoreUnreferencedCodeMessage)]
            [RequiresDynamicCode(FSharpCoreReflectionProxy.FSharpCoreUnreferencedCodeMessage)]
            private static JsonConverter ResolveCustomConverter(JsonConverterAttribute converterAttribute, Type fieldType, PropertyInfo propertyInfo, JsonSerializerOptions options)
            {
                Type? converterType = converterAttribute.ConverterType;
                JsonConverter? converter;
 
                if (converterType is null)
                {
                    converter = converterAttribute.CreateConverter(fieldType);
                    if (converter is null)
                    {
                        ThrowHelper.ThrowInvalidOperationException_SerializationConverterOnAttributeNotCompatible(propertyInfo.DeclaringType!, propertyInfo, fieldType);
                    }
                }
                else
                {
                    ConstructorInfo? ctor = converterType.GetConstructor(Type.EmptyTypes);
                    if (!typeof(JsonConverter).IsAssignableFrom(converterType) || ctor is null || !ctor.IsPublic)
                    {
                        ThrowHelper.ThrowInvalidOperationException_SerializationConverterOnAttributeInvalid(propertyInfo.DeclaringType!, propertyInfo);
                    }
 
                    converter = (JsonConverter)Activator.CreateInstance(converterType)!;
                }
 
                if (!converter!.CanConvert(fieldType))
                {
                    ThrowHelper.ThrowInvalidOperationException_SerializationConverterOnAttributeNotCompatible(propertyInfo.DeclaringType!, propertyInfo, fieldType);
                }
 
                if (converter is JsonConverterFactory factory)
                {
                    converter = factory.GetConverterInternal(fieldType, options);
                }
 
                return converter!;
            }
        }
    }
}