Handle broader range of collection types in model binding

- #2793
- add `ICollectionModelBinder`, allowing `GenericModelBinder` to call `CreateEmptyCollection()`
- adjust `CollectionModelBinder` and `DictionaryModelBinder` to activate model if default types are incompatible
 - do not create default (empty) top-level collection in fallback case if Model already non-`null`
- change type checks in `GenericModelBinder` to align with `CollectionModelBinder` capabilities
 - add special case for `IEnumerable<T>`
- correct `ModelMetadata` of a few tests that previously did not need that information
This commit is contained in:
Doug Bunting 2015-08-10 09:50:01 -07:00
parent b6a109e2a3
commit d45e2ee3f5
10 changed files with 475 additions and 109 deletions

View File

@ -1,7 +1,9 @@
// Copyright (c) .NET Foundation. All rights reserved. // Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.Framework.Internal; using Microsoft.Framework.Internal;
@ -25,15 +27,27 @@ namespace Microsoft.AspNet.Mvc.ModelBinding
return base.BindModelAsync(bindingContext); return base.BindModelAsync(bindingContext);
} }
protected override object CreateEmptyCollection() /// <inheritdoc />
public override bool CanCreateInstance(Type targetType)
{ {
return targetType == typeof(TElement[]);
}
/// <inheritdoc />
protected override object CreateEmptyCollection(Type targetType)
{
Debug.Assert(targetType == typeof(TElement[]), "GenericModelBinder only creates this binder for arrays.");
return new TElement[0]; return new TElement[0];
} }
/// <inheritdoc /> /// <inheritdoc />
protected override object GetModel(IEnumerable<TElement> newCollection) protected override object ConvertToCollectionType(Type targetType, IEnumerable<TElement> collection)
{ {
return newCollection?.ToArray(); Debug.Assert(targetType == typeof(TElement[]), "GenericModelBinder only creates this binder for arrays.");
// If non-null, collection is a List<TElement>, never already a TElement[].
return collection?.ToArray();
} }
/// <inheritdoc /> /// <inheritdoc />

View File

@ -7,6 +7,9 @@ using System.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
using System.Globalization; using System.Globalization;
using System.Linq; using System.Linq;
#if DNXCORE50
using System.Reflection;
#endif
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.Framework.Internal; using Microsoft.Framework.Internal;
@ -16,22 +19,24 @@ namespace Microsoft.AspNet.Mvc.ModelBinding
/// <see cref="IModelBinder"/> implementation for binding collection values. /// <see cref="IModelBinder"/> implementation for binding collection values.
/// </summary> /// </summary>
/// <typeparam name="TElement">Type of elements in the collection.</typeparam> /// <typeparam name="TElement">Type of elements in the collection.</typeparam>
public class CollectionModelBinder<TElement> : IModelBinder public class CollectionModelBinder<TElement> : ICollectionModelBinder
{ {
/// <inheritdoc /> /// <inheritdoc />
public virtual async Task<ModelBindingResult> BindModelAsync([NotNull] ModelBindingContext bindingContext) public virtual async Task<ModelBindingResult> BindModelAsync([NotNull] ModelBindingContext bindingContext)
{ {
ModelBindingHelper.ValidateBindingContext(bindingContext); ModelBindingHelper.ValidateBindingContext(bindingContext);
object model; var model = bindingContext.Model;
if (!await bindingContext.ValueProvider.ContainsPrefixAsync(bindingContext.ModelName)) if (!await bindingContext.ValueProvider.ContainsPrefixAsync(bindingContext.ModelName))
{ {
// If this is the fallback case and we failed to find data for a top-level model, then generate a // If this is the fallback case and we failed to find data for a top-level model, then generate a
// default 'empty' model and return it. // default 'empty' model (or use existing Model) and return it.
if (!bindingContext.IsFirstChanceBinding && bindingContext.IsTopLevelObject) if (!bindingContext.IsFirstChanceBinding && bindingContext.IsTopLevelObject)
{ {
model = CreateEmptyCollection(); if (model == null)
{
model = CreateEmptyCollection(bindingContext.ModelType);
}
var validationNode = new ModelValidationNode( var validationNode = new ModelValidationNode(
bindingContext.ModelName, bindingContext.ModelName,
@ -50,12 +55,10 @@ namespace Microsoft.AspNet.Mvc.ModelBinding
var valueProviderResult = await bindingContext.ValueProvider.GetValueAsync(bindingContext.ModelName); var valueProviderResult = await bindingContext.ValueProvider.GetValueAsync(bindingContext.ModelName);
IEnumerable<TElement> boundCollection;
CollectionResult result; CollectionResult result;
if (valueProviderResult == null) if (valueProviderResult == null)
{ {
result = await BindComplexCollection(bindingContext); result = await BindComplexCollection(bindingContext);
boundCollection = result.Model;
} }
else else
{ {
@ -63,13 +66,12 @@ namespace Microsoft.AspNet.Mvc.ModelBinding
bindingContext, bindingContext,
valueProviderResult.RawValue, valueProviderResult.RawValue,
valueProviderResult.Culture); valueProviderResult.Culture);
boundCollection = result.Model;
} }
model = bindingContext.Model; var boundCollection = result.Model;
if (model == null) if (model == null)
{ {
model = GetModel(boundCollection); model = ConvertToCollectionType(bindingContext.ModelType, boundCollection);
} }
else else
{ {
@ -84,14 +86,50 @@ namespace Microsoft.AspNet.Mvc.ModelBinding
validationNode: result?.ValidationNode); validationNode: result?.ValidationNode);
} }
// Called when we're creating a default 'empty' model for a top level bind. /// <inheritdoc />
protected virtual object CreateEmptyCollection() public virtual bool CanCreateInstance(Type targetType)
{ {
return new List<TElement>(); return CreateEmptyCollection(targetType) != null;
}
/// <summary>
/// Create an <see cref="object"/> assignable to <paramref name="targetType"/>.
/// </summary>
/// <param name="targetType"><see cref="Type"/> of the model.</param>
/// <returns>An <see cref="object"/> assignable to <paramref name="targetType"/>.</returns>
/// <remarks>Called when creating a default 'empty' model for a top level bind.</remarks>
protected virtual object CreateEmptyCollection(Type targetType)
{
if (targetType.IsAssignableFrom(typeof(List<TElement>)))
{
// Simple case such as ICollection<TElement>, IEnumerable<TElement> and IList<TElement>.
return new List<TElement>();
}
return CreateInstance(targetType);
}
/// <summary>
/// Create an instance of <paramref name="targetType"/>.
/// </summary>
/// <param name="targetType"><see cref="Type"/> of the model.</param>
/// <returns>An instance of <paramref name="targetType"/>.</returns>
protected object CreateInstance(Type targetType)
{
try
{
return Activator.CreateInstance(targetType);
}
catch (Exception)
{
// Details of exception are not important.
return null;
}
} }
// Used when the ValueProvider contains the collection to be bound as a single element, e.g. the raw value // Used when the ValueProvider contains the collection to be bound as a single element, e.g. the raw value
// is [ "1", "2" ] and needs to be converted to an int[]. // is [ "1", "2" ] and needs to be converted to an int[].
// Internal for testing.
internal async Task<CollectionResult> BindSimpleCollection( internal async Task<CollectionResult> BindSimpleCollection(
ModelBindingContext bindingContext, ModelBindingContext bindingContext,
object rawValue, object rawValue,
@ -156,6 +194,7 @@ namespace Microsoft.AspNet.Mvc.ModelBinding
return await BindComplexCollectionFromIndexes(bindingContext, indexNames); return await BindComplexCollectionFromIndexes(bindingContext, indexNames);
} }
// Internal for testing.
internal async Task<CollectionResult> BindComplexCollectionFromIndexes( internal async Task<CollectionResult> BindComplexCollectionFromIndexes(
ModelBindingContext bindingContext, ModelBindingContext bindingContext,
IEnumerable<string> indexNames) IEnumerable<string> indexNames)
@ -219,6 +258,7 @@ namespace Microsoft.AspNet.Mvc.ModelBinding
}; };
} }
// Internal for testing.
internal class CollectionResult internal class CollectionResult
{ {
public ModelValidationNode ValidationNode { get; set; } public ModelValidationNode ValidationNode { get; set; }
@ -227,23 +267,37 @@ namespace Microsoft.AspNet.Mvc.ModelBinding
} }
/// <summary> /// <summary>
/// Gets an <see cref="object"/> assignable to the collection property. /// Gets an <see cref="object"/> assignable to <paramref name="targetType"/> that contains members from
/// <paramref name="collection"/>.
/// </summary> /// </summary>
/// <param name="newCollection"> /// <param name="targetType"><see cref="Type"/> of the model.</param>
/// <param name="collection">
/// Collection of values retrieved from value providers. Or <c>null</c> if nothing was bound. /// Collection of values retrieved from value providers. Or <c>null</c> if nothing was bound.
/// </param> /// </param>
/// <returns> /// <returns>
/// <see cref="object"/> assignable to the collection property. Or <c>null</c> if nothing was bound. /// An <see cref="object"/> assignable to <paramref name="targetType"/>. Or <c>null</c> if nothing was bound.
/// </returns> /// </returns>
/// <remarks> /// <remarks>
/// Extensibility point that allows the bound collection to be manipulated or transformed before being /// Extensibility point that allows the bound collection to be manipulated or transformed before being
/// returned from the binder. /// returned from the binder.
/// </remarks> /// </remarks>
protected virtual object GetModel(IEnumerable<TElement> newCollection) protected virtual object ConvertToCollectionType(Type targetType, IEnumerable<TElement> collection)
{ {
// Depends on fact BindSimpleCollection() and BindComplexCollection() always return a List<TElement> if (collection == null)
// instance or null. In addition GenericModelBinder confirms a List<TElement> is assignable to the {
// property prior to instantiating this binder and subclass binders do not call this method. return null;
}
if (targetType.IsAssignableFrom(typeof(List<TElement>)))
{
// Depends on fact BindSimpleCollection() and BindComplexCollection() always return a List<TElement>
// instance or null.
return collection;
}
var newCollection = CreateInstance(targetType);
CopyToModel(newCollection, collection);
return newCollection; return newCollection;
} }
@ -254,11 +308,10 @@ namespace Microsoft.AspNet.Mvc.ModelBinding
/// <param name="sourceCollection"> /// <param name="sourceCollection">
/// Collection of values retrieved from value providers. Or <c>null</c> if nothing was bound. /// Collection of values retrieved from value providers. Or <c>null</c> if nothing was bound.
/// </param> /// </param>
/// <remarks>Called only in TryUpdateModelAsync(collection, ...) scenarios.</remarks>
protected virtual void CopyToModel([NotNull] object target, IEnumerable<TElement> sourceCollection) protected virtual void CopyToModel([NotNull] object target, IEnumerable<TElement> sourceCollection)
{ {
var targetCollection = target as ICollection<TElement>; var targetCollection = target as ICollection<TElement>;
Debug.Assert(targetCollection != null); // This binder is instantiated only for ICollection model types. Debug.Assert(targetCollection != null, "This binder is instantiated only for ICollection<T> model types.");
if (sourceCollection != null && targetCollection != null && !targetCollection.IsReadOnly) if (sourceCollection != null && targetCollection != null && !targetCollection.IsReadOnly)
{ {
@ -270,7 +323,7 @@ namespace Microsoft.AspNet.Mvc.ModelBinding
} }
} }
internal static object[] RawValueToObjectArray(object rawValue) private static object[] RawValueToObjectArray(object rawValue)
{ {
// precondition: rawValue is not null // precondition: rawValue is not null

View File

@ -1,9 +1,13 @@
// Copyright (c) .NET Foundation. All rights reserved. // Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
using System.Linq; using System.Linq;
#if DNXCORE50
using System.Reflection;
#endif
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.Framework.Internal; using Microsoft.Framework.Internal;
@ -27,7 +31,7 @@ namespace Microsoft.AspNet.Mvc.ModelBinding
} }
Debug.Assert(result.Model != null); Debug.Assert(result.Model != null);
var model = (Dictionary<TKey, TValue>)result.Model; var model = (IDictionary<TKey, TValue>)result.Model;
if (model.Count != 0) if (model.Count != 0)
{ {
// ICollection<KeyValuePair<TKey, TValue>> approach was successful. // ICollection<KeyValuePair<TKey, TValue>> approach was successful.
@ -80,15 +84,37 @@ namespace Microsoft.AspNet.Mvc.ModelBinding
} }
/// <inheritdoc /> /// <inheritdoc />
protected override object GetModel(IEnumerable<KeyValuePair<TKey, TValue>> newCollection) protected override object ConvertToCollectionType(
Type targetType,
IEnumerable<KeyValuePair<TKey, TValue>> collection)
{ {
return newCollection?.ToDictionary(kvp => kvp.Key, kvp => kvp.Value); 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(kvp => kvp.Key, kvp => kvp.Value);
}
var newCollection = CreateInstance(targetType);
CopyToModel(newCollection, collection);
return newCollection;
} }
/// <inheritdoc /> /// <inheritdoc />
protected override object CreateEmptyCollection() protected override object CreateEmptyCollection(Type targetType)
{ {
return new Dictionary<TKey, TValue>(); if (targetType.IsAssignableFrom(typeof(Dictionary<TKey, TValue>)))
{
// Simple case such as IDictionary<TKey, TValue>.
return new Dictionary<TKey, TValue>();
}
return CreateInstance(targetType);
} }
private static TKey ConvertFromString(string keyString) private static TKey ConvertFromString(string keyString)

View File

@ -13,12 +13,21 @@ namespace Microsoft.AspNet.Mvc.ModelBinding
{ {
public async Task<ModelBindingResult> BindModelAsync(ModelBindingContext bindingContext) public async Task<ModelBindingResult> BindModelAsync(ModelBindingContext bindingContext)
{ {
var binderType = ResolveBinderType(bindingContext.ModelType); var binderType = ResolveBinderType(bindingContext);
if (binderType != null) if (binderType != null)
{ {
var binder = (IModelBinder)Activator.CreateInstance(binderType); var binder = (IModelBinder)Activator.CreateInstance(binderType);
var result = await binder.BindModelAsync(bindingContext);
var collectionBinder = binder as ICollectionModelBinder;
if (collectionBinder != null &&
bindingContext.Model == null &&
!collectionBinder.CanCreateInstance(bindingContext.ModelType))
{
// Able to resolve a binder type but need a new model instance and that binder cannot create it.
return null;
}
var result = await binder.BindModelAsync(bindingContext);
var modelBindingResult = result != null ? var modelBindingResult = result != null ?
result : result :
new ModelBindingResult(model: null, key: bindingContext.ModelName, isModelSet: false); new ModelBindingResult(model: null, key: bindingContext.ModelName, isModelSet: false);
@ -31,12 +40,15 @@ namespace Microsoft.AspNet.Mvc.ModelBinding
return null; return null;
} }
private static Type ResolveBinderType(Type modelType) private static Type ResolveBinderType(ModelBindingContext context)
{ {
var modelType = context.ModelType;
return GetArrayBinder(modelType) ?? return GetArrayBinder(modelType) ??
GetCollectionBinder(modelType) ?? GetCollectionBinder(modelType) ??
GetDictionaryBinder(modelType) ?? GetDictionaryBinder(modelType) ??
GetKeyValuePairBinder(modelType); GetEnumerableBinder(context) ??
GetKeyValuePairBinder(modelType);
} }
private static Type GetArrayBinder(Type modelType) private static Type GetArrayBinder(Type modelType)
@ -52,19 +64,50 @@ namespace Microsoft.AspNet.Mvc.ModelBinding
private static Type GetCollectionBinder(Type modelType) private static Type GetCollectionBinder(Type modelType)
{ {
return GetGenericBinderType( return GetGenericBinderType(
typeof(ICollection<>), typeof(ICollection<>),
typeof(List<>), typeof(CollectionModelBinder<>),
typeof(CollectionModelBinder<>), modelType);
modelType);
} }
private static Type GetDictionaryBinder(Type modelType) private static Type GetDictionaryBinder(Type modelType)
{ {
return GetGenericBinderType( return GetGenericBinderType(
typeof(IDictionary<,>), typeof(IDictionary<,>),
typeof(Dictionary<,>), typeof(DictionaryModelBinder<,>),
typeof(DictionaryModelBinder<,>), modelType);
modelType); }
private static Type GetEnumerableBinder(ModelBindingContext context)
{
var modelTypeArguments = GetGenericBinderTypeArgs(typeof(IEnumerable<>), context.ModelType);
if (modelTypeArguments == null)
{
return null;
}
if (context.Model == null)
{
// GetCollectionBinder has already confirmed modelType is not compatible with ICollection<T>. Can a
// List<T> (the default CollectionModelBinder type) instance be used instead of that exact type?
// Likely this will succeed only if the property type is exactly IEnumerable<T>.
var closedListType = typeof(List<>).MakeGenericType(modelTypeArguments);
if (!context.ModelType.IsAssignableFrom(closedListType))
{
return null;
}
}
else
{
// A non-null instance must be updated in-place. For that the instance must also implement
// ICollection<T>. For example an IEnumerable<T> property may have a List<T> default value.
var closedCollectionType = typeof(ICollection<>).MakeGenericType(modelTypeArguments);
if (!closedCollectionType.IsAssignableFrom(context.Model.GetType()))
{
return null;
}
}
return typeof(CollectionModelBinder<>).MakeGenericType(modelTypeArguments);
} }
private static Type GetKeyValuePairBinder(Type modelType) private static Type GetKeyValuePairBinder(Type modelType)
@ -79,52 +122,46 @@ namespace Microsoft.AspNet.Mvc.ModelBinding
return null; return null;
} }
/// <remarks> // Example: GetGenericBinderType(typeof(IList<T>), typeof(ListBinder<T>), ...) means that the ListBinder<T>
/// Example: <c>GetGenericBinderType(typeof(IList&lt;T&gt;), typeof(List&lt;T&gt;), // type can update models that implement IList<T>. This method will return
/// typeof(ListBinder&lt;T&gt;), ...)</c> means that the <c>ListBinder&lt;T&gt;</c> type can update models that // ListBinder<T> or null, depending on whether the type checks succeed.
/// implement <see cref="IList{T}"/>, and if for some reason the existing model instance is not updatable the private static Type GetGenericBinderType(Type supportedInterfaceType, Type openBinderType, Type modelType)
/// binder will create a <see cref="List{T}"/> object and bind to that instead. This method will return
/// <c>ListBinder&lt;T&gt;</c> or <c>null</c>, depending on whether the type and updatability checks succeed.
/// </remarks>
private static Type GetGenericBinderType(Type supportedInterfaceType,
Type newInstanceType,
Type openBinderType,
Type modelType)
{ {
Debug.Assert(supportedInterfaceType != null);
Debug.Assert(openBinderType != null); Debug.Assert(openBinderType != null);
Debug.Assert(modelType != null);
var modelTypeArguments = GetGenericBinderTypeArgs(supportedInterfaceType, modelType); var modelTypeArguments = GetGenericBinderTypeArgs(supportedInterfaceType, modelType);
if (modelTypeArguments == null) if (modelTypeArguments == null)
{ {
return null; return null;
} }
var closedNewInstanceType = newInstanceType.MakeGenericType(modelTypeArguments);
if (!modelType.GetTypeInfo().IsAssignableFrom(closedNewInstanceType.GetTypeInfo()))
{
return null;
}
return openBinderType.MakeGenericType(modelTypeArguments); return openBinderType.MakeGenericType(modelTypeArguments);
} }
// Get the generic arguments for the binder, based on the model type. Or null if not compatible. // Get the generic arguments for the binder, based on the model type. Or null if not compatible.
private static Type[] GetGenericBinderTypeArgs(Type supportedInterfaceType, Type modelType) private static Type[] GetGenericBinderTypeArgs(Type supportedInterfaceType, Type modelType)
{ {
Debug.Assert(supportedInterfaceType != null);
Debug.Assert(modelType != null);
var modelTypeInfo = modelType.GetTypeInfo(); var modelTypeInfo = modelType.GetTypeInfo();
if (!modelTypeInfo.IsGenericType || modelTypeInfo.IsGenericTypeDefinition) if (!modelTypeInfo.IsGenericType || modelTypeInfo.IsGenericTypeDefinition)
{ {
// not a closed generic type // modelType is not a closed generic type.
return null; return null;
} }
var modelTypeArguments = modelTypeInfo.GenericTypeArguments; var modelTypeArguments = modelTypeInfo.GenericTypeArguments;
if (modelTypeArguments.Length != supportedInterfaceType.GetTypeInfo().GenericTypeParameters.Length) if (modelTypeArguments.Length != supportedInterfaceType.GetTypeInfo().GenericTypeParameters.Length)
{ {
// wrong number of generic type arguments // Wrong number of generic type arguments.
return null;
}
var closedInstanceType = supportedInterfaceType.MakeGenericType(modelTypeArguments);
if (!closedInstanceType.IsAssignableFrom(modelType))
{
// modelType is not compatible with supportedInterfaceType.
return null; return null;
} }

View File

@ -0,0 +1,27 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
namespace Microsoft.AspNet.Mvc.ModelBinding
{
/// <summary>
/// Interface for model binding collections.
/// </summary>
public interface ICollectionModelBinder : IModelBinder
{
/// <summary>
/// Gets an indication whether or not this <see cref="ICollectionModelBinder"/> implementation can create
/// an <see cref="object"/> assignable to <paramref name="targetType"/>.
/// </summary>
/// <param name="targetType"><see cref="Type"/> of the model.</param>
/// <returns>
/// <c>true</c> if this <see cref="ICollectionModelBinder"/> implementation can create an <see cref="object"/>
/// assignable to <paramref name="targetType"/>; <c>false</c> otherwise.
/// </returns>
/// <remarks>
/// A <c>true</c> return value is necessary for successful model binding if model is initially <c>null</c>.
/// </remarks>
bool CanCreateInstance(Type targetType);
}
}

View File

@ -1,8 +1,9 @@
// Copyright (c) .NET Foundation. All rights reserved. // Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
#if DNX451 using System;
using System.Collections.Generic; using System.Collections.Generic;
#if DNX451
using System.Globalization; using System.Globalization;
using System.Linq; using System.Linq;
#endif #endif
@ -275,6 +276,44 @@ namespace Microsoft.AspNet.Mvc.ModelBinding.Test
Assert.Same(result.ValidationNode.ModelMetadata, context.ModelMetadata); Assert.Same(result.ValidationNode.ModelMetadata, context.ModelMetadata);
} }
// Setup like CollectionModelBinder_CreatesEmptyCollection_IfIsTopLevelObjectAndNotIsFirstChanceBinding except
// Model already has a value.
[Fact]
public async Task CollectionModelBinder_DoesNotCreateEmptyCollection_IfModelNonNull()
{
// Arrange
var binder = new CollectionModelBinder<string>();
var context = CreateContext();
context.IsTopLevelObject = true;
var list = new List<string>();
context.Model = list;
// Lack of prefix and non-empty model name both ignored.
context.ModelName = "modelName";
var metadataProvider = context.OperationBindingContext.MetadataProvider;
context.ModelMetadata = metadataProvider.GetMetadataForType(typeof(List<string>));
context.ValueProvider = new TestValueProvider(new Dictionary<string, object>());
// Act
var result = await binder.BindModelAsync(context);
// Assert
Assert.NotNull(result);
Assert.Same(list, result.Model);
Assert.Empty(list);
Assert.Equal("modelName", result.Key);
Assert.True(result.IsModelSet);
Assert.Same(result.ValidationNode.Model, result.Model);
Assert.Same(result.ValidationNode.Key, result.Key);
Assert.Same(result.ValidationNode.ModelMetadata, context.ModelMetadata);
}
[Theory] [Theory]
[InlineData("")] [InlineData("")]
[InlineData("param")] [InlineData("param")]
@ -300,6 +339,39 @@ namespace Microsoft.AspNet.Mvc.ModelBinding.Test
Assert.Null(result); Assert.Null(result);
} }
// Model type -> can create instance.
public static TheoryData<Type, bool> CanCreateInstanceData
{
get
{
return new TheoryData<Type, bool>
{
{ typeof(IEnumerable<int>), true },
{ typeof(ICollection<int>), true },
{ typeof(IList<int>), true },
{ typeof(List<int>), true },
{ typeof(LinkedList<int>), true },
{ typeof(ISet<int>), false },
{ typeof(ListWithInternalConstructor<int>), false },
{ typeof(ListWithThrowingConstructor<int>), false },
};
}
}
[Theory]
[MemberData(nameof(CanCreateInstanceData))]
public void CanCreateInstance_ReturnsExpectedValue(Type modelType, bool expectedResult)
{
// Arrange
var binder = new CollectionModelBinder<int>();
// Act
var result = binder.CanCreateInstance(modelType);
// Assert
Assert.Equal(expectedResult, result);
}
#if DNX451 #if DNX451
[Fact] [Fact]
public async Task BindSimpleCollection_SubBindingSucceeds() public async Task BindSimpleCollection_SubBindingSucceeds()
@ -335,7 +407,7 @@ namespace Microsoft.AspNet.Mvc.ModelBinding.Test
var bindingContext = new ModelBindingContext var bindingContext = new ModelBindingContext
{ {
ModelMetadata = metadataProvider.GetMetadataForType(typeof(int)), ModelMetadata = metadataProvider.GetMetadataForType(typeof(IList<int>)),
ModelName = "someName", ModelName = "someName",
ValueProvider = valueProvider, ValueProvider = valueProvider,
OperationBindingContext = new OperationBindingContext OperationBindingContext = new OperationBindingContext
@ -387,5 +459,29 @@ namespace Microsoft.AspNet.Mvc.ModelBinding.Test
{ {
public List<string> ListProperty { get; set; } public List<string> ListProperty { get; set; }
} }
private class ModelWithSimpleProperties
{
public int Id { get; set; }
public string Name { get; set; }
}
private class ListWithInternalConstructor<T> : List<T>
{
internal ListWithInternalConstructor()
: base()
{
}
}
private class ListWithThrowingConstructor<T> : List<T>
{
public ListWithThrowingConstructor()
: base()
{
throw new ApplicationException("No, don't do this.");
}
}
} }
} }

View File

@ -112,8 +112,8 @@ namespace Microsoft.AspNet.Mvc.ModelBinding.Test
var metadataProvider = context.OperationBindingContext.MetadataProvider; var metadataProvider = context.OperationBindingContext.MetadataProvider;
context.ModelMetadata = metadataProvider.GetMetadataForProperty( context.ModelMetadata = metadataProvider.GetMetadataForProperty(
typeof(ModelWithDictionaryProperty), typeof(ModelWithDictionaryProperties),
nameof(ModelWithDictionaryProperty.DictionaryProperty)); nameof(ModelWithDictionaryProperties.DictionaryProperty));
// Act // Act
var result = await binder.BindModelAsync(context); var result = await binder.BindModelAsync(context);
@ -150,8 +150,8 @@ namespace Microsoft.AspNet.Mvc.ModelBinding.Test
var metadataProvider = context.OperationBindingContext.MetadataProvider; var metadataProvider = context.OperationBindingContext.MetadataProvider;
context.ModelMetadata = metadataProvider.GetMetadataForProperty( context.ModelMetadata = metadataProvider.GetMetadataForProperty(
typeof(ModelWithDictionaryProperty), typeof(ModelWithDictionaryProperties),
nameof(ModelWithDictionaryProperty.DictionaryProperty)); nameof(ModelWithDictionaryProperties.DictionaryProperty));
// Act // Act
var result = await binder.BindModelAsync(context); var result = await binder.BindModelAsync(context);
@ -202,8 +202,8 @@ namespace Microsoft.AspNet.Mvc.ModelBinding.Test
var metadataProvider = context.OperationBindingContext.MetadataProvider; var metadataProvider = context.OperationBindingContext.MetadataProvider;
context.ModelMetadata = metadataProvider.GetMetadataForProperty( context.ModelMetadata = metadataProvider.GetMetadataForProperty(
typeof(ModelWithDictionaryProperty), typeof(ModelWithDictionaryProperties),
nameof(ModelWithDictionaryProperty.DictionaryProperty)); nameof(ModelWithDictionaryProperties.DictionaryWithValueTypesProperty));
// Act // Act
var result = await binder.BindModelAsync(context); var result = await binder.BindModelAsync(context);
@ -244,8 +244,8 @@ namespace Microsoft.AspNet.Mvc.ModelBinding.Test
var metadataProvider = context.OperationBindingContext.MetadataProvider; var metadataProvider = context.OperationBindingContext.MetadataProvider;
context.ModelMetadata = metadataProvider.GetMetadataForProperty( context.ModelMetadata = metadataProvider.GetMetadataForProperty(
typeof(ModelWithDictionaryProperty), typeof(ModelWithDictionaryProperties),
nameof(ModelWithDictionaryProperty.DictionaryProperty)); nameof(ModelWithDictionaryProperties.DictionaryWithComplexValuesProperty));
// Act // Act
var result = await binder.BindModelAsync(context); var result = await binder.BindModelAsync(context);
@ -261,6 +261,41 @@ namespace Microsoft.AspNet.Mvc.ModelBinding.Test
Assert.Equal(dictionary, resultDictionary); Assert.Equal(dictionary, resultDictionary);
} }
[Theory]
[MemberData(nameof(StringToStringData))]
public async Task BindModel_FallsBackToBindingValues_WithCustomDictionary(
string modelName,
string keyFormat,
IDictionary<string, string> dictionary)
{
// Arrange
var expectedDictionary = new SortedDictionary<string, string>(dictionary);
var binder = new DictionaryModelBinder<string, string>();
var context = CreateContext();
context.ModelName = modelName;
context.OperationBindingContext.ModelBinder = CreateCompositeBinder();
context.OperationBindingContext.ValueProvider = CreateEnumerableValueProvider(keyFormat, dictionary);
context.ValueProvider = context.OperationBindingContext.ValueProvider;
var metadataProvider = context.OperationBindingContext.MetadataProvider;
context.ModelMetadata = metadataProvider.GetMetadataForProperty(
typeof(ModelWithDictionaryProperties),
nameof(ModelWithDictionaryProperties.CustomDictionaryProperty));
// Act
var result = await binder.BindModelAsync(context);
// Assert
Assert.NotNull(result);
Assert.False(result.IsFatalError);
Assert.True(result.IsModelSet);
Assert.Equal(modelName, result.Key);
Assert.NotNull(result.ValidationNode);
var resultDictionary = Assert.IsAssignableFrom<SortedDictionary<string, string>>(result.Model);
Assert.Equal(expectedDictionary, resultDictionary);
}
[Fact] [Fact]
public async Task DictionaryModelBinder_DoesNotCreateCollection_IfIsTopLevelObjectAndIsFirstChanceBinding() public async Task DictionaryModelBinder_DoesNotCreateCollection_IfIsTopLevelObjectAndIsFirstChanceBinding()
{ {
@ -332,8 +367,8 @@ namespace Microsoft.AspNet.Mvc.ModelBinding.Test
var metadataProvider = context.OperationBindingContext.MetadataProvider; var metadataProvider = context.OperationBindingContext.MetadataProvider;
context.ModelMetadata = metadataProvider.GetMetadataForProperty( context.ModelMetadata = metadataProvider.GetMetadataForProperty(
typeof(ModelWithDictionaryProperty), typeof(ModelWithDictionaryProperties),
nameof(ModelWithDictionaryProperty.DictionaryProperty)); nameof(ModelWithDictionaryProperties.DictionaryProperty));
context.ValueProvider = new TestValueProvider(new Dictionary<string, object>()); context.ValueProvider = new TestValueProvider(new Dictionary<string, object>());
@ -344,6 +379,39 @@ namespace Microsoft.AspNet.Mvc.ModelBinding.Test
Assert.Null(result); Assert.Null(result);
} }
// Model type -> can create instance.
public static TheoryData<Type, bool> CanCreateInstanceData
{
get
{
return new TheoryData<Type, bool>
{
{ typeof(IEnumerable<KeyValuePair<int, int>>), true },
{ typeof(ICollection<KeyValuePair<int, int>>), true },
{ typeof(IDictionary<int, int>), true },
{ typeof(Dictionary<int, int>), true },
{ typeof(SortedDictionary<int, int>), true },
{ typeof(IList<KeyValuePair<int, int>>), false },
{ typeof(DictionaryWithInternalConstructor<int, int>), false },
{ typeof(DictionaryWithThrowingConstructor<int, int>), false },
};
}
}
[Theory]
[MemberData(nameof(CanCreateInstanceData))]
public void CanCreateInstance_ReturnsExpectedValue(Type modelType, bool expectedResult)
{
// Arrange
var binder = new DictionaryModelBinder<int, int>();
// Act
var result = binder.CanCreateInstance(modelType);
// Assert
Assert.Equal(expectedResult, result);
}
private static ModelBindingContext CreateContext() private static ModelBindingContext CreateContext()
{ {
var modelBindingContext = new ModelBindingContext() var modelBindingContext = new ModelBindingContext()
@ -401,7 +469,7 @@ namespace Microsoft.AspNet.Mvc.ModelBinding.Test
private static ModelBindingContext GetModelBindingContext(bool isReadOnly) private static ModelBindingContext GetModelBindingContext(bool isReadOnly)
{ {
var metadataProvider = new TestModelMetadataProvider(); var metadataProvider = new TestModelMetadataProvider();
metadataProvider.ForType<List<int>>().BindingDetails(bd => bd.IsReadOnly = isReadOnly); metadataProvider.ForType<IDictionary<int, string>>().BindingDetails(bd => bd.IsReadOnly = isReadOnly);
var valueProvider = new SimpleHttpValueProvider var valueProvider = new SimpleHttpValueProvider
{ {
{ "someName[0]", new KeyValuePair<int, string>(42, "forty-two") }, { "someName[0]", new KeyValuePair<int, string>(42, "forty-two") },
@ -441,9 +509,16 @@ namespace Microsoft.AspNet.Mvc.ModelBinding.Test
return mockKvpBinder.Object; return mockKvpBinder.Object;
} }
private class ModelWithDictionaryProperty private class ModelWithDictionaryProperties
{ {
// A Dictionary<string, string> instance cannot be assigned to this property.
public SortedDictionary<string, string> CustomDictionaryProperty { get; set; }
public Dictionary<string, string> DictionaryProperty { get; set; } public Dictionary<string, string> DictionaryProperty { get; set; }
public Dictionary<int, ModelWithProperties> DictionaryWithComplexValuesProperty { get; set; }
public Dictionary<long, int> DictionaryWithValueTypesProperty { get; set; }
} }
private class ModelWithProperties private class ModelWithProperties
@ -471,6 +546,23 @@ namespace Microsoft.AspNet.Mvc.ModelBinding.Test
return $"{{{ Id }, '{ Name }'}}"; return $"{{{ Id }, '{ Name }'}}";
} }
} }
private class DictionaryWithInternalConstructor<TKey, TValue> : Dictionary<TKey, TValue>
{
internal DictionaryWithInternalConstructor()
: base()
{
}
}
private class DictionaryWithThrowingConstructor<TKey, TValue> : Dictionary<TKey, TValue>
{
public DictionaryWithThrowingConstructor()
: base()
{
throw new ApplicationException("No, don't do this.");
}
}
} }
} }
#endif #endif

View File

@ -92,9 +92,7 @@ namespace Microsoft.AspNet.Mvc.FunctionalTests
var server = TestHelper.CreateServer(_app, SiteName, _configureServices); var server = TestHelper.CreateServer(_app, SiteName, _configureServices);
var client = server.CreateClient(); var client = server.CreateClient();
var url = var url = "http://localhost/FromQueryAttribute_Company/CreateDepartment?TestEmployees[0].EmployeeId=1234";
"http://localhost/FromQueryAttribute_Company/CreateDepartment?TestEmployees[0].EmployeeId=1234";
// Act // Act
var response = await client.GetAsync(url); var response = await client.GetAsync(url);
@ -118,7 +116,6 @@ namespace Microsoft.AspNet.Mvc.FunctionalTests
var url = var url =
"http://localhost/FromQueryAttribute_Company/ValidateDepartment?TestEmployees[0].Department=contoso"; "http://localhost/FromQueryAttribute_Company/ValidateDepartment?TestEmployees[0].Department=contoso";
// Act // Act
var response = await client.GetAsync(url); var response = await client.GetAsync(url);

View File

@ -77,7 +77,7 @@ namespace Microsoft.AspNet.Mvc.IntegrationTests
public CustomReadOnlyCollection<Address> Address { get; set; } public CustomReadOnlyCollection<Address> Address { get; set; }
} }
[Fact(Skip = "Concrete Collection types don't work with GenericModelBinder #2793")] [Fact]
public async Task ActionParameter_ReadOnlyCollectionModel_EmptyPrefix_DoesNotGetBound() public async Task ActionParameter_ReadOnlyCollectionModel_EmptyPrefix_DoesNotGetBound()
{ {
// Arrange // Arrange
@ -106,12 +106,17 @@ namespace Microsoft.AspNet.Mvc.IntegrationTests
Assert.NotNull(boundModel); Assert.NotNull(boundModel);
Assert.NotNull(boundModel.Address); Assert.NotNull(boundModel.Address);
// Arrays should not be updated. // Read-only collection should not be updated.
Assert.Equal(0, boundModel.Address.Count()); Assert.Empty(boundModel.Address);
// ModelState // ModelState (data is valid but is not copied into Address).
Assert.True(modelState.IsValid); Assert.True(modelState.IsValid);
Assert.Empty(modelState.Keys); var entry = Assert.Single(modelState);
Assert.Equal("Address[0].Street", entry.Key);
var state = entry.Value;
Assert.NotNull(state);
Assert.Equal(ModelValidationState.Valid, state.ValidationState);
Assert.Equal("SomeStreet", state.Value.RawValue);
} }
private class Person4 private class Person4
@ -252,7 +257,7 @@ namespace Microsoft.AspNet.Mvc.IntegrationTests
Assert.Equal(ModelValidationState.Valid, modelState[key].ValidationState); Assert.Equal(ModelValidationState.Valid, modelState[key].ValidationState);
} }
[Fact(Skip = "Concrete Collection types don't work with GenericModelBinder #2793")] [Fact]
public async Task ActionParameter_ReadOnlyCollectionModel_WithPrefix_DoesNotGetBound() public async Task ActionParameter_ReadOnlyCollectionModel_WithPrefix_DoesNotGetBound()
{ {
// Arrange // Arrange
@ -285,12 +290,17 @@ namespace Microsoft.AspNet.Mvc.IntegrationTests
Assert.NotNull(boundModel); Assert.NotNull(boundModel);
Assert.NotNull(boundModel.Address); Assert.NotNull(boundModel.Address);
// Arrays should not be updated. // Read-only collection should not be updated.
Assert.Equal(0, boundModel.Address.Count()); Assert.Empty(boundModel.Address);
// ModelState // ModelState (data is valid but is not copied into Address).
Assert.True(modelState.IsValid); Assert.True(modelState.IsValid);
Assert.Empty(modelState.Keys); var entry = Assert.Single(modelState);
Assert.Equal("prefix.Address[0].Street", entry.Key);
var state = entry.Value;
Assert.NotNull(state);
Assert.Equal(ModelValidationState.Valid, state.ValidationState);
Assert.Equal("SomeStreet", state.Value.RawValue);
} }
[Fact] [Fact]
@ -380,11 +390,12 @@ namespace Microsoft.AspNet.Mvc.IntegrationTests
Assert.Empty(modelState.Keys); Assert.Empty(modelState.Keys);
} }
private class CustomReadOnlyCollection<T> : ICollection<T>, IReadOnlyCollection<T> private class CustomReadOnlyCollection<T> : ICollection<T>
{ {
private ICollection<T> _original; private ICollection<T> _original;
public CustomReadOnlyCollection() : this(new List<T>()) public CustomReadOnlyCollection()
: this(new List<T>())
{ {
} }

View File

@ -173,7 +173,7 @@ namespace Microsoft.AspNet.Mvc.IntegrationTests
public CustomReadOnlyCollection<Address> Address { get; set; } public CustomReadOnlyCollection<Address> Address { get; set; }
} }
[Fact(Skip = "Concrete Collection types don't work with GenericModelBinder #2793")] [Fact(Skip = "Validation incorrect for collections when using TryUpdateModel, #2941")]
public async Task TryUpdateModel_ReadOnlyCollectionModel_EmptyPrefix_DoesNotGetBound() public async Task TryUpdateModel_ReadOnlyCollectionModel_EmptyPrefix_DoesNotGetBound()
{ {
// Arrange // Arrange
@ -184,6 +184,7 @@ namespace Microsoft.AspNet.Mvc.IntegrationTests
var modelState = new ModelStateDictionary(); var modelState = new ModelStateDictionary();
var model = new Person6(); var model = new Person6();
// Act // Act
var result = await TryUpdateModel(model, string.Empty, operationContext, modelState); var result = await TryUpdateModel(model, string.Empty, operationContext, modelState);
@ -193,12 +194,17 @@ namespace Microsoft.AspNet.Mvc.IntegrationTests
// Model // Model
Assert.NotNull(model.Address); Assert.NotNull(model.Address);
// Arrays should not be updated. // Read-only collection should not be updated.
Assert.Equal(0, model.Address.Count()); Assert.Empty(model.Address);
// ModelState // ModelState (data is valid but is not copied into Address).
Assert.True(modelState.IsValid); Assert.True(modelState.IsValid);
Assert.Empty(modelState.Keys); var entry = Assert.Single(modelState);
Assert.Equal("Address[0].Street", entry.Key);
var state = entry.Value;
Assert.NotNull(state);
Assert.Equal(ModelValidationState.Valid, state.ValidationState);
Assert.Equal("SomeStreet", state.Value.RawValue);
} }
private class Person4 private class Person4
@ -409,7 +415,7 @@ namespace Microsoft.AspNet.Mvc.IntegrationTests
Assert.Equal(ModelValidationState.Valid, modelState[key].ValidationState); Assert.Equal(ModelValidationState.Valid, modelState[key].ValidationState);
} }
[Fact(Skip = "Concrete Collection types don't work with GenericModelBinder #2793")] [Fact(Skip = "Validation incorrect for collections when using TryUpdateModel, #2941")]
public async Task TryUpdateModel_ReadOnlyCollectionModel_WithPrefix_DoesNotGetBound() public async Task TryUpdateModel_ReadOnlyCollectionModel_WithPrefix_DoesNotGetBound()
{ {
// Arrange // Arrange
@ -420,6 +426,7 @@ namespace Microsoft.AspNet.Mvc.IntegrationTests
var modelState = new ModelStateDictionary(); var modelState = new ModelStateDictionary();
var model = new Person6(); var model = new Person6();
// Act // Act
var result = await TryUpdateModel(model, "prefix", operationContext, modelState); var result = await TryUpdateModel(model, "prefix", operationContext, modelState);
@ -429,12 +436,17 @@ namespace Microsoft.AspNet.Mvc.IntegrationTests
// Model // Model
Assert.NotNull(model.Address); Assert.NotNull(model.Address);
// Arrays should not be updated. // Read-only collection should not be updated.
Assert.Equal(0, model.Address.Count()); Assert.Empty(model.Address);
// ModelState // ModelState (data is valid but is not copied into Address).
Assert.True(modelState.IsValid); Assert.True(modelState.IsValid);
Assert.Empty(modelState.Keys); var entry = Assert.Single(modelState);
Assert.Equal("prefix.Address[0].Street", entry.Key);
var state = entry.Value;
Assert.NotNull(state);
Assert.Equal(ModelValidationState.Valid, state.ValidationState);
Assert.Equal("SomeStreet", state.Value.RawValue);
} }
[Fact] [Fact]
@ -499,11 +511,12 @@ namespace Microsoft.AspNet.Mvc.IntegrationTests
Assert.Empty(modelState.Keys); Assert.Empty(modelState.Keys);
} }
private class CustomReadOnlyCollection<T> : ICollection<T>, IReadOnlyCollection<T> private class CustomReadOnlyCollection<T> : ICollection<T>
{ {
private ICollection<T> _original; private ICollection<T> _original;
public CustomReadOnlyCollection() : this(new List<T>()) public CustomReadOnlyCollection()
: this(new List<T>())
{ {
} }