From cc5ae02b7d639a7c5b95fc7fa5692664178a8c72 Mon Sep 17 00:00:00 2001 From: Doug Bunting Date: Thu, 12 Apr 2018 21:31:32 -0700 Subject: [PATCH] Fix binding & validating dictionaries of non-simple types in jQuery requests - #7423 - retry failed inner bindings with alternate syntax in `ModelStateDictionary` - use property syntax if first attempt tried index syntax and visa versa - instantiate `ShortFormDictionaryValidationStrategy` with full `ModelState` keys - can now provide exact `ModelState` keys that `ModelStateDictionary` used in inner bindings - normalize model names without a leading period in `JQueryKeyValuePairNormalizer` nits: - take a few VS suggestions --- .../ShortFormDictionaryValidationStrategy.cs | 24 +- .../Binders/DictionaryModelBinder.cs | 27 +- .../JQueryKeyValuePairNormalizer.cs | 7 +- ...ortFormDictionaryValidationStrategyTest.cs | 23 +- .../Binders/DictionaryModelBinderTest.cs | 9 +- .../JQueryFormValueProviderFactoryTest.cs | 8 +- ...ueryQueryStringValueProviderFactoryTest.cs | 14 +- .../DictionaryModelBinderIntegrationTest.cs | 429 +++++++++++++++++- 8 files changed, 484 insertions(+), 57 deletions(-) diff --git a/src/Microsoft.AspNetCore.Mvc.Core/Internal/ShortFormDictionaryValidationStrategy.cs b/src/Microsoft.AspNetCore.Mvc.Core/Internal/ShortFormDictionaryValidationStrategy.cs index 0da31c0cbe..31d1db97bf 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/Internal/ShortFormDictionaryValidationStrategy.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/Internal/ShortFormDictionaryValidationStrategy.cs @@ -17,14 +17,14 @@ namespace Microsoft.AspNetCore.Mvc.Internal /// /// This implementation handles cases like: /// - /// Model: IDictionary<string, Student> + /// Model: IDictionary<string, Student> /// Query String: ?students[Joey].Age=8&students[Katherine].Age=9 - /// + /// /// In this case, 'Joey' and 'Katherine' are the keys of the dictionary, used to bind two 'Student' /// objects. The enumerator returned from this class will yield two 'Student' objects with corresponding /// keys 'students[Joey]' and 'students[Katherine]' /// - /// + /// /// Using this key format, the enumerator enumerates model objects of type . The /// keys of the dictionary are not validated as they must be simple types. /// @@ -35,7 +35,9 @@ namespace Microsoft.AspNetCore.Mvc.Internal /// /// Creates a new . /// - /// The mapping from model prefix key to dictionary key. + /// + /// The mapping from key to dictionary key. + /// /// /// The associated with . /// @@ -48,7 +50,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal } /// - /// Gets the mapping from model prefix key to dictionary key. + /// Gets the mapping from key to dictionary key. /// public IEnumerable> KeyMappings { get; } @@ -58,12 +60,12 @@ namespace Microsoft.AspNetCore.Mvc.Internal string key, object model) { - return new Enumerator(_valueMetadata, key, KeyMappings, (IDictionary)model); + // key is not needed because KeyMappings maps from full ModelState keys to dictionary keys. + return new Enumerator(_valueMetadata, KeyMappings, (IDictionary)model); } private class Enumerator : IEnumerator { - private readonly string _key; private readonly ModelMetadata _metadata; private readonly IDictionary _model; private readonly IEnumerator> _keyMappingEnumerator; @@ -72,14 +74,11 @@ namespace Microsoft.AspNetCore.Mvc.Internal public Enumerator( ModelMetadata metadata, - string key, IEnumerable> keyMappings, IDictionary model) { _metadata = metadata; - _key = key; _model = model; - _keyMappingEnumerator = keyMappings.GetEnumerator(); } @@ -104,10 +103,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal } } - var key = ModelNames.CreateIndexModelName(_key, _keyMappingEnumerator.Current.Key); - var model = value; - - _entry = new ValidationEntry(_metadata, key, model); + _entry = new ValidationEntry(_metadata, _keyMappingEnumerator.Current.Key, value); return true; } diff --git a/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Binders/DictionaryModelBinder.cs b/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Binders/DictionaryModelBinder.cs index dc967fefdf..fbb0c53db1 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Binders/DictionaryModelBinder.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Binders/DictionaryModelBinder.cs @@ -81,8 +81,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders Logger.NoKeyValueFormatForDictionaryModelBinder(bindingContext); - var enumerableValueProvider = bindingContext.ValueProvider as IEnumerableValueProvider; - if (enumerableValueProvider == null) + if (!(bindingContext.ValueProvider is IEnumerableValueProvider enumerableValueProvider)) { // No IEnumerableValueProvider available for the fallback approach. For example the user may have // replaced the ValueProvider with something other than a CompositeValueProvider. @@ -90,7 +89,8 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders } // Attempt to bind dictionary from a set of prefix[key]=value entries. Get the short and long keys first. - var keys = enumerableValueProvider.GetKeysFromPrefix(bindingContext.ModelName); + var prefix = bindingContext.ModelName; + var keys = enumerableValueProvider.GetKeysFromPrefix(prefix); if (keys.Count == 0) { // No entries with the expected keys. @@ -117,10 +117,29 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders 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(valueResult.Model); - keyMappings.Add(kvp.Key, convertedKey); + keyMappings.Add(bindingContext.ModelName, convertedKey); } } diff --git a/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/JQueryKeyValuePairNormalizer.cs b/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/JQueryKeyValuePairNormalizer.cs index b9fcc2520f..b0c1c28c0f 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/JQueryKeyValuePairNormalizer.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/JQueryKeyValuePairNormalizer.cs @@ -86,7 +86,12 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding else { // Field name. Convert to dot notation. - builder.Append('.'); + if (builder.Length != 0) + { + // Was x[field], not [field] or [][field]. + builder.Append('.'); + } + builder.Append(key, indexOpen + 1, indexClose - indexOpen - 1); } diff --git a/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/ShortFormDictionaryValidationStrategyTest.cs b/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/ShortFormDictionaryValidationStrategyTest.cs index 582b2800ea..9ef857f0a1 100644 --- a/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/ShortFormDictionaryValidationStrategyTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/ShortFormDictionaryValidationStrategyTest.cs @@ -7,7 +7,6 @@ using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.ModelBinding.Validation; using Xunit; - namespace Microsoft.AspNetCore.Mvc.Internal { public class ShortFormDictionaryValidationStrategyTest @@ -28,14 +27,14 @@ namespace Microsoft.AspNetCore.Mvc.Internal var valueMetadata = metadataProvider.GetMetadataForType(typeof(string)); var strategy = new ShortFormDictionaryValidationStrategy(new Dictionary() { - { "2", 2 }, - { "3", 3 }, - { "5", 5 }, + { "prefix[2]", 2 }, + { "prefix[3]", 3 }, + { "prefix[5]", 5 }, }, valueMetadata); // Act - var enumerator = strategy.GetChildren(metadata, "prefix", model); + var enumerator = strategy.GetChildren(metadata, "ignored prefix", model); // Assert Assert.Collection( @@ -76,13 +75,13 @@ namespace Microsoft.AspNetCore.Mvc.Internal var valueMetadata = metadataProvider.GetMetadataForType(typeof(string)); var strategy = new ShortFormDictionaryValidationStrategy(new Dictionary() { - { "2", 2 }, - { "3", 3 }, + { "prefix[2]", 2 }, + { "prefix[3]", 3 }, }, valueMetadata); // Act - var enumerator = strategy.GetChildren(metadata, "prefix", model); + var enumerator = strategy.GetChildren(metadata, "ignored prefix", model); // Assert Assert.Collection( @@ -116,14 +115,14 @@ namespace Microsoft.AspNetCore.Mvc.Internal var valueMetadata = metadataProvider.GetMetadataForType(typeof(string)); var strategy = new ShortFormDictionaryValidationStrategy(new Dictionary() { - { "2", 2 }, - { "3", 3 }, - { "5", 5 }, + { "prefix[2]", 2 }, + { "prefix[3]", 3 }, + { "prefix[5]", 5 }, }, valueMetadata); // Act - var enumerator = strategy.GetChildren(metadata, "prefix", model); + var enumerator = strategy.GetChildren(metadata, "ignored prefix", model); // Assert Assert.Collection( diff --git a/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Binders/DictionaryModelBinderTest.cs b/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Binders/DictionaryModelBinderTest.cs index c155ed8b03..2beb2a33f9 100644 --- a/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Binders/DictionaryModelBinderTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Binders/DictionaryModelBinderTest.cs @@ -302,8 +302,8 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders Assert.Equal( new KeyValuePair[] { - new KeyValuePair("23", 23), - new KeyValuePair("27", 27), + new KeyValuePair("prefix[23]", 23), + new KeyValuePair("prefix[27]", 27), }.OrderBy(kvp => kvp.Key), strategy.KeyMappings.OrderBy(kvp => kvp.Key)); } @@ -532,15 +532,14 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders public override bool Equals(object obj) { - var other = obj as ModelWithProperties; - return other != null && + return obj is ModelWithProperties other && Id == other.Id && string.Equals(Name, other.Name, StringComparison.Ordinal); } public override int GetHashCode() { - int nameCode = Name == null ? 0 : Name.GetHashCode(); + var nameCode = Name == null ? 0 : Name.GetHashCode(); return nameCode ^ Id.GetHashCode(); } diff --git a/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/JQueryFormValueProviderFactoryTest.cs b/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/JQueryFormValueProviderFactoryTest.cs index b968a92f19..d940134a8d 100644 --- a/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/JQueryFormValueProviderFactoryTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/JQueryFormValueProviderFactoryTest.cs @@ -89,12 +89,12 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Test "[14]property1[15]property2", "prefix.property1[13]", "prefix[14][15]", - ".property5", - ".property6Value", + "property5", + "property6Value", "prefix.property2", "prefix.propertyValue", - ".property7.property8", - ".property9.property10Value", + "property7.property8", + "property9.property10Value", }; } } diff --git a/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/JQueryQueryStringValueProviderFactoryTest.cs b/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/JQueryQueryStringValueProviderFactoryTest.cs index a3133261c3..70c3edc6a2 100644 --- a/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/JQueryQueryStringValueProviderFactoryTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/JQueryQueryStringValueProviderFactoryTest.cs @@ -17,6 +17,12 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Test { private static readonly Dictionary _backingStore = new Dictionary { + // Reviewers: A fair number of cases in the following dataset seem impossible for jQuery.ajax() to send + // given how $.ajax() and $.param() are defined i.e. $.param() won't add a bare name after ']'. Also, the + // names we generate for elements are always valid. Should we remove these weird / invalid cases + // e.g. normalizing "property[]Value"? (That normalizes to "propertyValue".) See also + // https://github.com/jquery/jquery/blob/1ea092a54b00aa4d902f4e22ada3854d195d4a18/src/serialize.js#L13-L92 + // The alternative is to handle "[]name" and "[index]name" cases while normalizing keys. { "[]", new[] { "found" } }, { "[]property1", new[] { "found" } }, { "property2[]", new[] { "found" } }, @@ -57,12 +63,12 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Test "[14]property1[15]property2", "prefix.property1[13]", "prefix[14][15]", - ".property5", - ".property6Value", + "property5", + "property6Value", "prefix.property2", "prefix.propertyValue", - ".property7.property8", - ".property9.property10Value", + "property7.property8", + "property9.property10Value", }; } } diff --git a/test/Microsoft.AspNetCore.Mvc.IntegrationTests/DictionaryModelBinderIntegrationTest.cs b/test/Microsoft.AspNetCore.Mvc.IntegrationTests/DictionaryModelBinderIntegrationTest.cs index 79a77e57f3..c19e7cfac5 100644 --- a/test/Microsoft.AspNetCore.Mvc.IntegrationTests/DictionaryModelBinderIntegrationTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.IntegrationTests/DictionaryModelBinderIntegrationTest.cs @@ -450,15 +450,26 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests } } + public static TheoryData ComplexType_ImpliedPrefixData + { + get + { + return new TheoryData + { + "?[key0].Id=10", + "?[0].Key=key0&[0].Value.Id=10", + "?index=low&[low].Key=key0&[low].Value.Id=10", + "?parameter[key0].Id=10", + "?parameter[0].Key=key0¶meter[0].Value.Id=10", + "?parameter.index=low¶meter[low].Key=key0¶meter[low].Value.Id=10", + "?parameter.index=index¶meter[index].Key=key0¶meter[index].Value.Id=10", + }; + } + } + [Theory] - [InlineData("?[key0].Id=10")] - [InlineData("?[0].Key=key0&[0].Value.Id=10")] - [InlineData("?index=low&[low].Key=key0&[low].Value.Id=10")] - [InlineData("?parameter[key0].Id=10")] - [InlineData("?parameter[0].Key=key0¶meter[0].Value.Id=10")] - [InlineData("?parameter.index=low¶meter[low].Key=key0¶meter[low].Value.Id=10")] - [InlineData("?parameter.index=index¶meter[index].Key=key0¶meter[index].Value.Id=10")] - public async Task DictionaryModelBinder_BindsDictionaryOfComplexType_ImpliedPrefix_Success(string queryString) + [MemberData(nameof(ComplexType_ImpliedPrefixData))] + public async Task DictionaryModelBinder_BindsDictionaryOfComplexType_WithImpliedPrefix(string queryString) { // Arrange var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); @@ -490,11 +501,168 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests } [Theory] - [InlineData("?prefix[key0].Id=10")] - [InlineData("?prefix[0].Key=key0&prefix[0].Value.Id=10")] - [InlineData("?prefix.index=low&prefix[low].Key=key0&prefix[low].Value.Id=10")] - [InlineData("?prefix.index=index&prefix[index].Key=key0&prefix[index].Value.Id=10")] - public async Task DictionaryModelBinder_BindsDictionaryOfComplexType_ExplicitPrefix_Success( + [InlineData("?[key0][Id]=10")] // Both key segments will be rewritten. + [InlineData("?[0][Key]=key0&[0][Value][Id]=10")] + [InlineData("?parameter[key0][Id]=10")] + [InlineData("?parameter[0][Key]=key0¶meter[0][Value][Id]=10")] + [MemberData(nameof(ComplexType_ImpliedPrefixData))] + public async Task DictionaryModelBinder_BindsDictionaryOfComplexType_WithImpliedPrefixAndJQuery( + string queryString) + { + // Arrange + var expectedDictionary = new Dictionary { { "key0", new Person { Id = 10 } } }; + var testContext = ModelBindingTestHelper.GetTestContext( + request => request.QueryString = new QueryString(queryString), + // Add JQueryQueryStringValueProviderFactory after default factories. + options => options.ValueProviderFactories.Add(new JQueryQueryStringValueProviderFactory())); + + var modelState = testContext.ModelState; + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext.HttpContext.RequestServices); + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Dictionary) + }; + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync(parameter, testContext); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType>(modelBindingResult.Model); + Assert.Equal(expectedDictionary, model); + + Assert.NotEmpty(modelState); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + } + + [Theory] + [InlineData("?[key0][Id]=10")] // Both key segments will be rewritten. + [InlineData("?[0][Key]=key0&[0][Value][Id]=10")] + [InlineData("?parameter[key0][Id]=10")] + [InlineData("?parameter[0][Key]=key0¶meter[0][Value][Id]=10")] + [MemberData(nameof(ComplexType_ImpliedPrefixData))] + public async Task DictionaryModelBinder_BindsDictionaryOfComplexType_WithImpliedPrefixAndJQueryFirst( + string queryString) + { + // Arrange + var expectedDictionary = new Dictionary { { "key0", new Person { Id = 10 } } }; + var testContext = ModelBindingTestHelper.GetTestContext( + request => request.QueryString = new QueryString(queryString), + // Add JQueryQueryStringValueProviderFactory before default factories. + options => options.ValueProviderFactories.Insert(0, new JQueryQueryStringValueProviderFactory())); + + var modelState = testContext.ModelState; + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext.HttpContext.RequestServices); + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Dictionary) + }; + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync(parameter, testContext); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType>(modelBindingResult.Model); + Assert.Equal(expectedDictionary, model); + + Assert.NotEmpty(modelState); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + } + + [Theory] + [InlineData("?[42][Id]=10")] // Only Id segment will be rewritten. + [InlineData("?parameter[42][Id]=10")] + public async Task DictionaryModelBinder_BindsDictionaryOfComplexType_WithImpliedPrefixIntegralKeysAndJQuery( + string queryString) + { + // Arrange + var expectedDictionary = new Dictionary { { "42", new Person { Id = 10 } } }; + var testContext = ModelBindingTestHelper.GetTestContext( + request => request.QueryString = new QueryString(queryString), + // Add JQueryQueryStringValueProviderFactory after default factories. + options => options.ValueProviderFactories.Add(new JQueryQueryStringValueProviderFactory())); + + var modelState = testContext.ModelState; + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext.HttpContext.RequestServices); + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Dictionary) + }; + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync(parameter, testContext); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType>(modelBindingResult.Model); + Assert.Equal(expectedDictionary, model); + + Assert.NotEmpty(modelState); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + } + + [Theory] + [InlineData("?[42][Id]=10")] // Only Id segment will be rewritten. + [InlineData("?parameter[42][Id]=10")] + public async Task DictionaryModelBinder_BindsDictionaryOfComplexType_WithImpliedPrefixIntegralKeysAndJQueryFirst( + string queryString) + { + // Arrange + var expectedDictionary = new Dictionary { { "42", new Person { Id = 10 } } }; + var testContext = ModelBindingTestHelper.GetTestContext( + request => request.QueryString = new QueryString(queryString), + // Add JQueryQueryStringValueProviderFactory before default factories. + options => options.ValueProviderFactories.Insert(0, new JQueryQueryStringValueProviderFactory())); + + var modelState = testContext.ModelState; + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext.HttpContext.RequestServices); + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Dictionary) + }; + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync(parameter, testContext); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType>(modelBindingResult.Model); + Assert.Equal(expectedDictionary, model); + + Assert.NotEmpty(modelState); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + } + + public static TheoryData ComplexType_ExplicitPrefixData + { + get + { + return new TheoryData + { + "?prefix[key0].Id=10", + "?prefix[0].Key=key0&prefix[0].Value.Id=10", + "?prefix.index=low&prefix[low].Key=key0&prefix[low].Value.Id=10", + "?prefix.index=index&prefix[index].Key=key0&prefix[index].Value.Id=10", + }; + } + } + + [Theory] + [MemberData(nameof(ComplexType_ExplicitPrefixData))] + public async Task DictionaryModelBinder_BindsDictionaryOfComplexType_WithExplicitPrefix( string queryString) { // Arrange @@ -530,6 +698,45 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests Assert.True(modelState.IsValid); } + [Theory] + [InlineData("?prefix[key0][Id]=10")] + [MemberData(nameof(ComplexType_ExplicitPrefixData))] + public async Task DictionaryModelBinder_BindsDictionaryOfComplexType_WithExplicitPrefixAndJQuery( + string queryString) + { + // Arrange + var testContext = ModelBindingTestHelper.GetTestContext( + request => request.QueryString = new QueryString(queryString), + // Add JQueryQueryStringValueProviderFactory after default factories. + options => options.ValueProviderFactories.Add(new JQueryQueryStringValueProviderFactory())); + + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext.HttpContext.RequestServices); + var parameter = new ParameterDescriptor() + { + Name = "parameter", + BindingInfo = new BindingInfo() + { + BinderModelName = "prefix", + }, + ParameterType = typeof(Dictionary) + }; + + var modelState = testContext.ModelState; + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync(parameter, testContext); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType>(modelBindingResult.Model); + Assert.Equal(new Dictionary { { "key0", new Person { Id = 10 } }, }, model); + + Assert.NotEmpty(modelState); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + } + [Theory] [InlineData("?[key0].Id=100")] [InlineData("?[0].Key=key0&[0].Value.Id=100")] @@ -610,6 +817,202 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests Assert.True(modelState.IsValid); } + public static TheoryData CollectionType_ImpliedPrefixData + { + get + { + return new TheoryData + { + "?[key0]=10&[key0]=11", + "?[key0][0]=10&[key0][1]=11", + "?[0].Key=key0&[0].Value[0]=10&[0].Value[1]=11", + "?index=low&[low].Key=key0&[low].Value[0]=10&[low].Value[1]=11", + "?parameter[key0]=10¶meter[key0]=11", + "?parameter[key0][0]=10¶meter[key0][1]=11", + "?parameter[0].Key=key0¶meter[0].Value[0]=10¶meter[0].Value[1]=11", + "?parameter.index=low¶meter[low].Key=key0¶meter[low].Value[0]=10¶meter[low].Value[1]=11", + "?parameter.index=index¶meter[index].Key=key0¶meter[index].Value[0]=10¶meter[index].Value[1]=11", + }; + } + } + + [Theory] + [MemberData(nameof(CollectionType_ImpliedPrefixData))] + public async Task DictionaryModelBinder_BindsDictionaryOfCollectionType_WithImpliedPrefix(string queryString) + { + // Arrange + var expectedDictionary = new Dictionary { { "key0", new[] { "10", "11" } } }; + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Dictionary) + }; + + var testContext = ModelBindingTestHelper.GetTestContext( + request => request.QueryString = new QueryString(queryString)); + var modelState = testContext.ModelState; + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync(parameter, testContext); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType>(modelBindingResult.Model); + Assert.Equal(expectedDictionary, model); + + Assert.NotEmpty(modelState); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + } + + [Theory] + [MemberData(nameof(CollectionType_ImpliedPrefixData))] + public async Task DictionaryModelBinder_BindsDictionaryOfCollectionType_WithImpliedPrefixAndJQuery( + string queryString) + { + // Arrange + var expectedDictionary = new Dictionary { { "key0", new[] { "10", "11" } } }; + var testContext = ModelBindingTestHelper.GetTestContext( + request => request.QueryString = new QueryString(queryString), + // Add JQueryQueryStringValueProviderFactory after default factories. + options => options.ValueProviderFactories.Add(new JQueryQueryStringValueProviderFactory())); + + var modelState = testContext.ModelState; + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext.HttpContext.RequestServices); + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Dictionary) + }; + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync(parameter, testContext); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType>(modelBindingResult.Model); + Assert.Equal(expectedDictionary, model); + + Assert.NotEmpty(modelState); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + } + + [Theory] + [MemberData(nameof(CollectionType_ImpliedPrefixData))] + public async Task DictionaryModelBinder_BindsDictionaryOfCollectionType_WithImpliedPrefixAndJQueryFirst( + string queryString) + { + // Arrange + var expectedDictionary = new Dictionary { { "key0", new[] { "10", "11" } } }; + var testContext = ModelBindingTestHelper.GetTestContext( + request => request.QueryString = new QueryString(queryString), + // Add JQueryQueryStringValueProviderFactory before default factories. + options => options.ValueProviderFactories.Insert(0, new JQueryQueryStringValueProviderFactory())); + + var modelState = testContext.ModelState; + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext.HttpContext.RequestServices); + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Dictionary) + }; + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync(parameter, testContext); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType>(modelBindingResult.Model); + Assert.Equal(expectedDictionary, model); + + Assert.NotEmpty(modelState); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + } + + [Theory] + [InlineData("?[42]=10&[42]=11")] + [InlineData("?[42][]=10&[42][]=11")] + [InlineData("?[42][0]=10&[42][1]=11")] + [InlineData("?parameter[42]=10¶meter[42]=11")] + [InlineData("?parameter[42][]=10¶meter[42][]=11")] + [InlineData("?parameter[42][0]=10¶meter[42][1]=11")] + public async Task DictionaryModelBinder_BindsDictionaryOfCollectionType_WithImpliedPrefixIntegralKeysAndJQuery( + string queryString) + { + // Arrange + var expectedDictionary = new Dictionary { { "42", new[] { "10", "11" } } }; + var testContext = ModelBindingTestHelper.GetTestContext( + request => request.QueryString = new QueryString(queryString), + // Add JQueryQueryStringValueProviderFactory after default factories. + options => options.ValueProviderFactories.Add(new JQueryQueryStringValueProviderFactory())); + + var modelState = testContext.ModelState; + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext.HttpContext.RequestServices); + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Dictionary) + }; + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync(parameter, testContext); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType>(modelBindingResult.Model); + Assert.Equal(expectedDictionary, model); + + Assert.NotEmpty(modelState); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + } + + [Theory] + [InlineData("?[42]=10&[42]=11")] + [InlineData("?[42][]=10&[42][]=11")] + [InlineData("?[42][0]=10&[42][1]=11")] + [InlineData("?parameter[42]=10¶meter[42]=11")] + [InlineData("?parameter[42][]=10¶meter[42][]=11")] + [InlineData("?parameter[42][0]=10¶meter[42][1]=11")] + public async Task DictionaryModelBinder_BindsDictionaryOfCollectionType_WithImpliedPrefixIntegralKeysAndJQueryFirst( + string queryString) + { + // Arrange + var expectedDictionary = new Dictionary { { "42", new[] { "10", "11" } } }; + var testContext = ModelBindingTestHelper.GetTestContext( + request => request.QueryString = new QueryString(queryString), + // Add JQueryQueryStringValueProviderFactory before default factories. + options => options.ValueProviderFactories.Insert(0, new JQueryQueryStringValueProviderFactory())); + + var modelState = testContext.ModelState; + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext.HttpContext.RequestServices); + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Dictionary) + }; + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync(parameter, testContext); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType>(modelBindingResult.Model); + Assert.Equal(expectedDictionary, model); + + Assert.NotEmpty(modelState); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + } + // parameter type, query string, expected type public static TheoryData DictionaryTypeData {