File: FormMapping\Converters\CollectionConverter.cs
Web Access
Project: src\src\Components\Endpoints\src\Microsoft.AspNetCore.Components.Endpoints.csproj (Microsoft.AspNetCore.Components.Endpoints)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Runtime.CompilerServices;
 
namespace Microsoft.AspNetCore.Components.Endpoints.FormMapping;
 
// The algorithm for collections is always the same. There are two main cases:
// The collection can be modified, so we can add items to it.
// The collection cannot be modified, so we need to use a buffer and copy the items to it once we are done.
// When adding to the buffer, there are two cases:
// The buffer implements ICollection<T>, so we can add items to it.
// The buffer does not implement ICollection<T>, so we need to use custom code to add items to it.
// These aspects are captured in the TCollectionPolicy type parameter.
// Instead of creating a hierachy with virtual members, we are using generics and virtual interface dispatch to achieve the same result.
// This allows us to avoid virtual dispatch at runtime, and enables us to easily adapt to different types of collections.
 
internal abstract class CollectionConverter<TCollection> : FormDataConverter<TCollection>
{
}
 
internal class CollectionConverter<TCollection, TCollectionPolicy, TBuffer, TElement> : CollectionConverter<TCollection>
    where TCollectionPolicy : ICollectionBufferAdapter<TCollection, TBuffer, TElement>
{
    private static readonly Type _elementType = typeof(TElement);
 
    // Indexes up to 100 are pre-allocated to avoid allocations for common cases.
    private static readonly string[] Indexes = new string[] {
        "[0]", "[1]", "[2]", "[3]", "[4]", "[5]", "[6]", "[7]", "[8]", "[9]",
        "[10]", "[11]", "[12]", "[13]", "[14]", "[15]", "[16]", "[17]", "[18]", "[19]",
        "[20]", "[21]", "[22]", "[23]", "[24]", "[25]", "[26]", "[27]", "[28]", "[29]",
        "[30]", "[31]", "[32]", "[33]", "[34]", "[35]", "[36]", "[37]", "[38]", "[39]",
        "[40]", "[41]", "[42]", "[43]", "[44]", "[45]", "[46]", "[47]", "[48]", "[49]",
        "[50]", "[51]", "[52]", "[53]", "[54]", "[55]", "[56]", "[57]", "[58]", "[59]",
        "[60]", "[61]", "[62]", "[63]", "[64]", "[65]", "[66]", "[67]", "[68]", "[69]",
        "[70]", "[71]", "[72]", "[73]", "[74]", "[75]", "[76]", "[77]", "[78]", "[79]",
        "[80]", "[81]", "[82]", "[83]", "[84]", "[85]", "[86]", "[87]", "[88]", "[89]",
        "[90]", "[91]", "[92]", "[93]", "[94]", "[95]", "[96]", "[97]", "[98]", "[99]",
    };
 
    private readonly FormDataConverter<TElement> _elementConverter;
 
    public CollectionConverter(FormDataConverter<TElement> elementConverter)
    {
        ArgumentNullException.ThrowIfNull(elementConverter);
        _elementConverter = elementConverter;
    }
 
    [RequiresDynamicCode(FormMappingHelpers.RequiresDynamicCodeMessage)]
    [RequiresUnreferencedCode(FormMappingHelpers.RequiresUnreferencedCodeMessage)]
    internal override bool TryRead(
        ref FormDataReader context,
        Type type,
        FormDataMapperOptions options,
        [NotNullWhen(true)] out TCollection? result,
        out bool found)
    {
        TElement currentElement;
        TBuffer? buffer = default;
        bool foundCurrentElement;
        bool currentElementSuccess;
        bool succeded;
        // Even though we have indexes, we special case 0 an 1 and use literals directly. We leave them in the indexes
        // collection because it makes other indexes align.
        try
        {
            context.PushPrefix("[0]");
            succeded = _elementConverter.TryRead(ref context, _elementType, options, out currentElement!, out found);
        }
        finally
        {
            context.PopPrefix("[0]");
        }
 
        if (!found)
        {
            return TryReadSingleValueCollection(ref context, out result, ref found, ref buffer, ref succeded);
        }
 
        // We already know we found an element;
        found = true;
        // At this point we have at least found one element, we can create the collection and assign it to it.
        buffer = TCollectionPolicy.CreateBuffer();
        buffer = TCollectionPolicy.Add(ref buffer, currentElement!);
 
        // Read element 1 and set conditions to enter the loop
        try
        {
            context.PushPrefix("[1]");
            currentElementSuccess = _elementConverter.TryRead(ref context, _elementType, options, out currentElement!, out foundCurrentElement);
            succeded = succeded && currentElementSuccess;
        }
        catch
        {
            if (buffer != null)
            {
                // Ensure the buffer is cleaned up if we fail.
                result = TCollectionPolicy.ToResult(buffer);
            }
            throw;
        }
        finally
        {
            context.PopPrefix("[1]");
        }
 
        var maxCollectionSize = options.MaxCollectionSize;
 
        // We need to iterate while we keep finding values, even if some of them have errors. This is because we don't want data to be lost just
        // because we are not able to parse it. For example, if [5] = "asdf", we don't want to loose the values for [6], [7], etc. that can be
        // valid.
        // There will be a limit to how many errors we collect, at which point we will stop capturing errors.
        // Similarly, over 100 elements, we'll start doing more work to compute the index prefix. We chose 100 because that's the default
        // max collection size that we will support.
        var index = 2;
        var lastElementWithComputedIndex = 100 < maxCollectionSize ? 100 : maxCollectionSize;
        for (; index < lastElementWithComputedIndex && foundCurrentElement; index++)
        {
            // Add the current element
            buffer = TCollectionPolicy.Add(ref buffer, currentElement!);
 
            // Get the precomputed prefix and try and bind the element.
            var prefix = Indexes[index];
            try
            {
                context.PushPrefix(prefix);
                currentElementSuccess = _elementConverter.TryRead(ref context, _elementType, options, out currentElement!, out foundCurrentElement);
                succeded = succeded && currentElementSuccess;
            }
            catch
            {
                if (buffer != null)
                {
                    // Ensure the buffer is cleaned up if we fail.
                    result = TCollectionPolicy.ToResult(buffer);
                }
                throw;
            }
            finally
            {
                context.PopPrefix(prefix);
            }
        }
 
        if (!foundCurrentElement)
        {
            result = TCollectionPolicy.ToResult(buffer);
            if (!succeded)
            {
                context.AttachInstanceToErrors(result!);
            }
            return succeded;
        }
 
        // We need to compute the prefix for the index, since it's not precomputed.
        // The biggest UInt32 representation is 4294967295, which is 10 characters, so 16 chars is more than enough to
        // hold the representation.
        Span<char> computedPrefix = stackalloc char[16];
        computedPrefix[0] = '[';
 
        // index is 100 here.
        // We want to go 1 element over of max collection size, so we can report an error if we find it.
        for (; index <= maxCollectionSize && foundCurrentElement; index++)
        {
            // Add the current element
            buffer = TCollectionPolicy.Add(ref buffer, currentElement!);
 
            // We need to compute the prefix for the index, since it's not precomputed.
            if (!index.TryFormat(computedPrefix[1..], out var charsWritten, provider: CultureInfo.InvariantCulture))
            {
                succeded = false;
                break;
            }
 
            try
            {
                computedPrefix[charsWritten + 1] = ']';
                context.PushPrefix(computedPrefix[..(charsWritten + 2)]);
                currentElementSuccess = _elementConverter.TryRead(ref context, _elementType, options, out currentElement!, out foundCurrentElement);
                succeded = succeded && currentElementSuccess;
            }
            catch
            {
                if (buffer != null)
                {
                    // Ensure the buffer is cleaned up if we fail.
                    result = TCollectionPolicy.ToResult(buffer);
                }
                throw;
            }
            finally
            {
                context.PopPrefix(computedPrefix[..(charsWritten + 2)]);
            }
        }
 
        result = TCollectionPolicy.ToResult(buffer);
        if (index > maxCollectionSize && foundCurrentElement)
        {
            // Signal failure because we have stopped binding.
            context.AddMappingError(
                FormattableStringFactory.Create(FormDataResources.MaxCollectionSizeReached, "collection", maxCollectionSize),
                null);
 
            context.AttachInstanceToErrors(result!);
            return false;
        }
        else
        {
            if (!succeded)
            {
                context.AttachInstanceToErrors(result!);
            }
            return succeded;
        }
    }
 
    private bool TryReadSingleValueCollection(ref FormDataReader context, out TCollection? result, ref bool found, ref TBuffer? buffer, ref bool succeded)
    {
        if (_elementConverter is ISingleValueConverter<TElement> singleValueConverter &&
            singleValueConverter.CanConvertSingleValue() &&
            context.TryGetValues(out var values))
        {
            found = true;
            buffer = TCollectionPolicy.CreateBuffer();
 
            for (var i = 0; i < values.Count; i++)
            {
                var value = values[i];
                try
                {
                    if (!singleValueConverter.TryConvertValue(ref context, value!, out var elementValue))
                    {
                        succeded = false;
                    }
                    else
                    {
                        buffer = TCollectionPolicy.Add(ref buffer, elementValue);
                    }
                }
                catch (Exception ex)
                {
                    succeded = false;
                    context.AddMappingError(ex, value);
                }
            };
 
            result = TCollectionPolicy.ToResult(buffer);
        }
        else
        {
            result = default;
        }
 
        return succeded;
    }
}