File: ModelBinding\Binders\DictionaryModelBinder.cs
Web Access
Project: src\src\Mvc\Mvc.Core\src\Microsoft.AspNetCore.Mvc.Core.csproj (Microsoft.AspNetCore.Mvc.Core)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
#nullable enable
 
using System.Diagnostics;
using System.Linq;
using Microsoft.AspNetCore.Mvc.ModelBinding.Validation;
using Microsoft.Extensions.Logging;
 
namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders;
 
/// <summary>
/// <see cref="IModelBinder"/> implementation for binding dictionary values.
/// </summary>
/// <typeparam name="TKey">Type of keys in the dictionary.</typeparam>
/// <typeparam name="TValue">Type of values in the dictionary.</typeparam>
public partial class DictionaryModelBinder<TKey, TValue> : CollectionModelBinder<KeyValuePair<TKey, TValue?>> where TKey : notnull
{
    private readonly IModelBinder _valueBinder;
 
    /// <summary>
    /// Creates a new <see cref="DictionaryModelBinder{TKey, TValue}"/>.
    /// </summary>
    /// <param name="keyBinder">The <see cref="IModelBinder"/> for <typeparamref name="TKey"/>.</param>
    /// <param name="valueBinder">The <see cref="IModelBinder"/> for <typeparamref name="TValue"/>.</param>
    /// <param name="loggerFactory">The <see cref="ILoggerFactory"/>.</param>
    public DictionaryModelBinder(IModelBinder keyBinder, IModelBinder valueBinder, ILoggerFactory loggerFactory)
        : base(new KeyValuePairModelBinder<TKey, TValue>(keyBinder, valueBinder, loggerFactory), loggerFactory)
    {
        ArgumentNullException.ThrowIfNull(valueBinder);
 
        _valueBinder = valueBinder;
    }
 
    /// <summary>
    /// Creates a new <see cref="DictionaryModelBinder{TKey, TValue}"/>.
    /// </summary>
    /// <param name="keyBinder">The <see cref="IModelBinder"/> for <typeparamref name="TKey"/>.</param>
    /// <param name="valueBinder">The <see cref="IModelBinder"/> for <typeparamref name="TValue"/>.</param>
    /// <param name="loggerFactory">The <see cref="ILoggerFactory"/>.</param>
    /// <param name="allowValidatingTopLevelNodes">
    /// Indication that validation of top-level models is enabled. If <see langword="true"/> and
    /// <see cref="ModelMetadata.IsBindingRequired"/> is <see langword="true"/> for a top-level model, the binder
    /// adds a <see cref="ModelStateDictionary"/> error when the model is not bound.
    /// </param>
    /// <remarks>
    /// The <paramref name="allowValidatingTopLevelNodes"/> parameter is currently ignored.
    /// <see cref="CollectionModelBinder{TElement}.AllowValidatingTopLevelNodes"/> is always
    /// <see langword="false"/> in <see cref="DictionaryModelBinder{TKey, TValue}"/>. This class ignores that
    /// property and unconditionally checks for unbound top-level models with
    /// <see cref="ModelMetadata.IsBindingRequired"/>.
    /// </remarks>
    public DictionaryModelBinder(
        IModelBinder keyBinder,
        IModelBinder valueBinder,
        ILoggerFactory loggerFactory,
        bool allowValidatingTopLevelNodes)
        : base(
            new KeyValuePairModelBinder<TKey, TValue>(keyBinder, valueBinder, loggerFactory),
            loggerFactory,
            // CollectionModelBinder should not check IsRequired, done in this model binder.
            allowValidatingTopLevelNodes: false)
    {
        ArgumentNullException.ThrowIfNull(valueBinder);
 
        _valueBinder = valueBinder;
    }
 
    /// <summary>
    /// Creates a new <see cref="DictionaryModelBinder{TKey, TValue}"/>.
    /// </summary>
    /// <param name="keyBinder">The <see cref="IModelBinder"/> for <typeparamref name="TKey"/>.</param>
    /// <param name="valueBinder">The <see cref="IModelBinder"/> for <typeparamref name="TValue"/>.</param>
    /// <param name="loggerFactory">The <see cref="ILoggerFactory"/>.</param>
    /// <param name="allowValidatingTopLevelNodes">
    /// Indication that validation of top-level models is enabled. If <see langword="true"/> and
    /// <see cref="ModelMetadata.IsBindingRequired"/> is <see langword="true"/> for a top-level model, the binder
    /// adds a <see cref="ModelStateDictionary"/> error when the model is not bound.
    /// </param>
    /// <param name="mvcOptions">The <see cref="MvcOptions"/>.</param>
    /// <remarks>
    /// <para>This is the preferred <see cref="DictionaryModelBinder{TKey, TValue}"/> constructor.</para>
    /// <para>
    /// The <paramref name="allowValidatingTopLevelNodes"/> parameter is currently ignored.
    /// <see cref="CollectionModelBinder{TElement}.AllowValidatingTopLevelNodes"/> is always
    /// <see langword="false"/> in <see cref="DictionaryModelBinder{TKey, TValue}"/>. This class ignores that
    /// property and unconditionally checks for unbound top-level models with
    /// <see cref="ModelMetadata.IsBindingRequired"/>.
    /// </para>
    /// </remarks>
    public DictionaryModelBinder(
        IModelBinder keyBinder,
        IModelBinder valueBinder,
        ILoggerFactory loggerFactory,
        bool allowValidatingTopLevelNodes,
        MvcOptions mvcOptions)
        : base(
              new KeyValuePairModelBinder<TKey, TValue>(keyBinder, valueBinder, loggerFactory),
              loggerFactory,
              allowValidatingTopLevelNodes: false,
              mvcOptions)
    {
        ArgumentNullException.ThrowIfNull(valueBinder);
 
        _valueBinder = valueBinder;
    }
 
    /// <inheritdoc />
    public override async Task BindModelAsync(ModelBindingContext bindingContext)
    {
        ArgumentNullException.ThrowIfNull(bindingContext);
 
        await base.BindModelAsync(bindingContext);
        var result = bindingContext.Result;
 
        if (result.IsModelSet)
        {
            Debug.Assert(result.Model != null);
            if (result.Model is IDictionary<TKey, TValue?> { Count: > 0 })
            {
                // ICollection<KeyValuePair<TKey, TValue>> approach was successful.
                return;
            }
        }
 
        Log.NoKeyValueFormatForDictionaryModelBinder(Logger, bindingContext);
 
        if (bindingContext.ValueProvider is not IEnumerableValueProvider enumerableValueProvider)
        {
            // No IEnumerableValueProvider available for the fallback approach. For example the user may have
            // replaced the ValueProvider with something other than a CompositeValueProvider.
            if (bindingContext.IsTopLevelObject)
            {
                AddErrorIfBindingRequired(bindingContext);
            }
 
            // No match for the prefix at all.
            return;
        }
 
        // Attempt to bind dictionary from a set of prefix[key]=value entries. Get the short and long keys first.
        var prefix = bindingContext.ModelName;
        var keys = enumerableValueProvider.GetKeysFromPrefix(prefix);
        if (keys.Count == 0)
        {
            // No entries with the expected keys.
            if (bindingContext.IsTopLevelObject)
            {
                AddErrorIfBindingRequired(bindingContext);
            }
 
            return;
        }
 
        // Update the existing successful but empty ModelBindingResult.
        var model = (IDictionary<TKey, TValue?>)(result.Model ?? CreateEmptyCollection(bindingContext.ModelType));
        var elementMetadata = bindingContext.ModelMetadata.ElementMetadata!;
        var valueMetadata = elementMetadata.Properties[nameof(KeyValuePair<TKey, TValue>.Value)]!;
 
        var keyMappings = new Dictionary<string, TKey>(StringComparer.Ordinal);
        foreach (var kvp in keys)
        {
            // Use InvariantCulture to convert the key since ExpressionHelper.GetExpressionText() would use
            // that culture when rendering a form.
            TKey? convertedKey;
            try
            {
                convertedKey = ModelBindingHelper.ConvertTo<TKey>(kvp.Key, culture: null);
            }
            catch (Exception ex)
            {
                bindingContext.Result = ModelBindingResult.Failed();
                bindingContext.ModelState.AddModelError(bindingContext.ModelName, ex.Message);
                return;
            }
 
            using (bindingContext.EnterNestedScope(
                modelMetadata: valueMetadata,
                fieldName: bindingContext.FieldName,
                modelName: kvp.Value,
                model: null))
            {
                await _valueBinder.BindModelAsync(bindingContext);
 
                var valueResult = bindingContext.Result;
                if (!valueResult.IsModelSet)
                {
                    // Factories for IKeyRewriterValueProvider implementations are not all-or-nothing i.e.
                    // "[key][propertyName]" may be rewritten as ".key.propertyName" or "[key].propertyName". Try
                    // again in case this scope is binding a complex type and rewriting
                    // landed on ".key.propertyName" or in case this scope is binding another collection and an
                    // IKeyRewriterValueProvider implementation was first (hiding the original "[key][next key]").
                    if (kvp.Value.EndsWith(']'))
                    {
                        bindingContext.ModelName = ModelNames.CreatePropertyModelName(prefix, kvp.Key);
                    }
                    else
                    {
                        bindingContext.ModelName = ModelNames.CreateIndexModelName(prefix, kvp.Key);
                    }
 
                    await _valueBinder.BindModelAsync(bindingContext);
                    valueResult = bindingContext.Result;
                }
 
                // Always add an entry to the dictionary but validate only if binding was successful.
                model[convertedKey] = ModelBindingHelper.CastOrDefault<TValue>(valueResult.Model);
                keyMappings.Add(bindingContext.ModelName, convertedKey);
            }
        }
 
        bindingContext.Result = ModelBindingResult.Success(model);
        bindingContext.ValidationState.Add(model, new ValidationStateEntry()
        {
            Strategy = new ShortFormDictionaryValidationStrategy<TKey, TValue?>(keyMappings, valueMetadata),
        });
    }
 
    /// <inheritdoc />
    protected override object? ConvertToCollectionType(
        Type targetType,
        IEnumerable<KeyValuePair<TKey, TValue?>> collection)
    {
        if (collection == null)
        {
            return null;
        }
 
        if (targetType.IsAssignableFrom(typeof(Dictionary<TKey, TValue?>)))
        {
            // Collection is a List<KeyValuePair<TKey, TValue>>, never already a Dictionary<TKey, TValue>.
            return collection.ToDictionary();
        }
 
        return base.ConvertToCollectionType(targetType, collection);
    }
 
    /// <inheritdoc />
    protected override object CreateEmptyCollection(Type targetType)
    {
        if (targetType.IsAssignableFrom(typeof(Dictionary<TKey, TValue>)))
        {
            // Simple case such as IDictionary<TKey, TValue>.
            return new Dictionary<TKey, TValue>();
        }
 
        return base.CreateEmptyCollection(targetType);
    }
 
    /// <inheritdoc/>
    public override bool CanCreateInstance(Type targetType)
    {
        if (targetType.IsAssignableFrom(typeof(Dictionary<TKey, TValue>)))
        {
            // Simple case such as IDictionary<TKey, TValue>.
            return true;
        }
 
        return base.CanCreateInstance(targetType);
    }
 
    private static partial class Log
    {
        public static void NoKeyValueFormatForDictionaryModelBinder(ILogger logger, ModelBindingContext bindingContext)
            => NoKeyValueFormatForDictionaryModelBinder(logger, bindingContext.ModelName);
 
        [LoggerMessage(33, LogLevel.Debug, "Attempting to bind model with name '{ModelName}' using the format {ModelName}[key1]=value1&{ModelName}[key2]=value2", EventName = "NoKeyValueFormatForDictionaryModelBinder")]
        private static partial void NoKeyValueFormatForDictionaryModelBinder(ILogger logger, string modelName);
    }
}