Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Make CollectionModelBinder better extensible for custom model binders #60605

Open
dmorawetz opened this issue Feb 25, 2025 · 0 comments
Open
Labels
api-suggestion Early API idea and discussion, it is NOT ready for implementation area-mvc Includes: MVC, Actions and Controllers, Localization, CORS, most templates

Comments

@dmorawetz
Copy link

dmorawetz commented Feb 25, 2025

Background and Motivation

I came across the need to bind a collection with non-sequential indices in my project. While researching I found that I am not alone with this, and that this behavior seems to bother people at least since 2008.

Our use case is, to have a list of objects in a form. Via HTMX new lines can be appended to the end, and rows in the middle can be removed. Thus we can guarantee, that indices are unique, but might have gaps between them.

I looked at the CollectionModelBinder and found, that our desired behavior could be achieved by changing the break here to a continue.

However, since the BindComplexCollectionFromIndexes method is internal, I can not derive a custom model binder and overwrite just this one part.

Furthermore, to reuse as much as possible from the CollectionModelBinderProvider, I suggest adding an optional model binder type parameter to the constructor.

I do not propose adding the NonSequentialIndexCollectionModelBinder to ASP.NET core. I am fine if this lives in our project code. However, with these few changes, we could reuse most of the standard code.

Proposed API

I suggest simple changes in three classes. First, the CollectionModelBinder should allow BindComplexCollectionFromIndexes to be overridden and make the needed _maxModelBindingCollectionSize available to derived classes:

namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders;

public partial class CollectionModelBinder<TElement> : ICollectionModelBinder
{
-  private readonly int _maxModelBindingCollectionSize = MvcOptions.DefaultMaxModelBindingCollectionSize;
+  protected readonly int _maxModelBindingCollectionSize = MvcOptions.DefaultMaxModelBindingCollectionSize;

-    internal async Task<CollectionResult> BindComplexCollectionFromIndexes(
+    protected internal virtual async Task<CollectionResult> BindComplexCollectionFromIndexes(
+        ModelBindingContext bindingContext,
+        IEnumerable<string>? indexNames);
}

Second, the CollectionModelBinderProvider should gain a type parameter, to be able to provide custom collection model binders:

namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders;

-  public class CollectionModelBinderProvider : IModelBinderProvider
+ public class CollectionModelBinderProvider(Type? modelBinderType = null) : IModelBinderProvider
{
+    private readonly Type _modelBinderType = modelBinderType ?? typeof(CollectionModelBinder<>);
+
-    private static IModelBinder CreateInstance(ModelBinderProviderContext context, Type collectionType)
+    private IModelBinder CreateInstance(ModelBinderProviderContext context, Type collectionType)
    {
-       var binderType = typeof(CollectionModelBinder<>).MakeGenericType(collectionType.GenericTypeArguments);
+       var binderType = _modelBinderType.MakeGenericType(collectionType.GenericTypeArguments);
        var elementType = collectionType.GenericTypeArguments[0];
        var elementBinder = context.CreateBinder(context.MetadataProvider.GetMetadataForType(elementType));

        var loggerFactory = context.Services.GetRequiredService<ILoggerFactory>();
        var mvcOptions = context.Services.GetRequiredService<IOptions<MvcOptions>>().Value;
        var binder = (IModelBinder)Activator.CreateInstance(
            binderType,
            elementBinder,
            loggerFactory,
            true /* allowValidatingTopLevelNodes */,
            mvcOptions)!;

        return binder;
    }
}

Third, the ExplicitIndexCollectionValidationStrategy should become public, so that it can be used in custom model binders:

namespace Microsoft.AspNetCore.Mvc.ModelBinding.Validation;

-  internal sealed class ExplicitIndexCollectionValidationStrategy : IValidationStrategy
+  public sealed class ExplicitIndexCollectionValidationStrategy : IValidationStrategy

Usage Examples

Then we can derive a simple deviating model binder, that allows gaps and non-zero based indices:

public class NonSequentialIndexCollectionModelBinder<TElement> : CollectionModelBinder<TElement>
{
    // ... other ctors

    [ActivatorUtilitiesConstructorAttribute]
    public NonSequentialIndexCollectionModelBinder(IModelBinder elementBinder, ILoggerFactory loggerFactory, bool allowValidatingTopLevelNodes, MvcOptions mvcOptions) : base(elementBinder, loggerFactory, allowValidatingTopLevelNodes, mvcOptions)
    {
    }

    protected override async Task<CollectionResult> BindComplexCollectionFromIndexes(
            ModelBindingContext bindingContext,
            IEnumerable<string>? indexNames)
    {
        bool indexNamesIsFinite;
        if (indexNames != null)
        {
            indexNamesIsFinite = true;
        }
        else
        {
            indexNamesIsFinite = false;
            var limit = _maxModelBindingCollectionSize == int.MaxValue ? int.MaxValue : _maxModelBindingCollectionSize + 1;
            indexNames = Enumerable
                .Range(0, limit)
                .Select(i => i.ToString(CultureInfo.InvariantCulture));
        }

        var elementMetadata = bindingContext.ModelMetadata.ElementMetadata!;

        var boundCollection = new List<TElement?>();
        var foundIndexNames = new List<string>();

        foreach (var indexName in indexNames)
        {
            var fullChildName = ModelNames.CreateIndexModelName(bindingContext.ModelName, indexName);

            ModelBindingResult? result;
            using (bindingContext.EnterNestedScope(
                       elementMetadata,
                       fieldName: indexName,
                       modelName: fullChildName,
                       model: null))
            {
                await ElementBinder.BindModelAsync(bindingContext);
                result = bindingContext.Result;
            }

            var didBind = false;
            object? boundValue = null;
            if (result != null && result.Value.IsModelSet)
            {
                didBind = true;
                boundValue = result.Value.Model;
            }

            // infinite size collection stops on first bind failure
            if (!didBind && !indexNamesIsFinite)
            {
                continue;
            }

            boundCollection.Add(CastOrDefault<TElement>(boundValue));
            foundIndexNames.Add(indexName);
        }

        // Did the collection grow larger than the limit?
        if (boundCollection.Count > _maxModelBindingCollectionSize)
        {
            throw new InvalidOperationException("Exceeded maximum number of elements per model.");
        }

        return new CollectionResult(boundCollection)
        {
            ValidationStrategy = indexNamesIsFinite
                ? new ExplicitIndexCollectionValidationStrategy(indexNames)
                : new ExplicitIndexCollectionValidationStrategy(foundIndexNames), // this is needed for validation to work properly
        };
    }
}
public class NonSequentialIndexCollectionModelBinderProvider : CollectionModelBinderProvider(typeof(NonSequentialIndexCollectionModelBinder<>))
{
    
}

Alternative Designs

The most suggested workaround seems to be to add hidden fields for the Index, but this feels clunky.

Risks

As this change only allows to reuse ASP.NET code as much as possible in a custom model binder and not change any behavior or usage, I see no risks.

@dmorawetz dmorawetz added the api-suggestion Early API idea and discussion, it is NOT ready for implementation label Feb 25, 2025
@dotnet-issue-labeler dotnet-issue-labeler bot added the area-mvc Includes: MVC, Actions and Controllers, Localization, CORS, most templates label Feb 25, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
api-suggestion Early API idea and discussion, it is NOT ready for implementation area-mvc Includes: MVC, Actions and Controllers, Localization, CORS, most templates
Projects
None yet
Development

No branches or pull requests

1 participant