Add `FloatingPointTypeModelBinderProvider` and related binders

- #5502
- support thousands separators for `decimal`, `double` and `float`
- add tests demonstrating `SimpleTypeModelBinder` does not support thousands separators for numeric types
- add tests demonstrating use of commas (not thousands separators) with `enum` values
This commit is contained in:
Doug Bunting 2017-07-02 20:25:08 -07:00
parent c50f55d1de
commit c351712419
13 changed files with 1049 additions and 12 deletions

View File

@ -45,6 +45,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal
options.ModelBinderProviders.Add(new ServicesModelBinderProvider());
options.ModelBinderProviders.Add(new BodyModelBinderProvider(options.InputFormatters, _readerFactory, _loggerFactory, options));
options.ModelBinderProviders.Add(new HeaderModelBinderProvider());
options.ModelBinderProviders.Add(new FloatingPointTypeModelBinderProvider());
options.ModelBinderProviders.Add(new SimpleTypeModelBinderProvider());
options.ModelBinderProviders.Add(new CancellationTokenModelBinderProvider());
options.ModelBinderProviders.Add(new ByteArrayModelBinderProvider());

View File

@ -0,0 +1,101 @@
// 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;
using System.Globalization;
using System.Runtime.ExceptionServices;
using System.Threading.Tasks;
namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders
{
/// <summary>
/// An <see cref="IModelBinder"/> for <see cref="decimal"/> and <see cref="Nullable{T}"/> where <c>T</c> is
/// <see cref="decimal"/>.
/// </summary>
public class DecimalModelBinder : IModelBinder
{
private readonly NumberStyles _supportedStyles;
public DecimalModelBinder(NumberStyles supportedStyles)
{
_supportedStyles = supportedStyles;
}
/// <inheritdoc />
public Task BindModelAsync(ModelBindingContext bindingContext)
{
if (bindingContext == null)
{
throw new ArgumentNullException(nameof(bindingContext));
}
var modelName = bindingContext.ModelName;
var valueProviderResult = bindingContext.ValueProvider.GetValue(modelName);
if (valueProviderResult == ValueProviderResult.None)
{
// no entry
return Task.CompletedTask;
}
var modelState = bindingContext.ModelState;
modelState.SetModelValue(modelName, valueProviderResult);
var metadata = bindingContext.ModelMetadata;
var type = metadata.UnderlyingOrModelType;
try
{
var value = valueProviderResult.FirstValue;
var culture = valueProviderResult.Culture;
object model;
if (string.IsNullOrWhiteSpace(value))
{
// Parse() method trims the value (with common NumberStyles) then throws if the result is empty.
model = null;
}
else if (type == typeof(decimal))
{
model = decimal.Parse(value, _supportedStyles, culture);
}
else
{
// unreachable
throw new NotSupportedException();
}
// When converting value, a null model may indicate a failed conversion for an otherwise required
// model (can't set a ValueType to null). This detects if a null model value is acceptable given the
// current bindingContext. If not, an error is logged.
if (model == null && !metadata.IsReferenceOrNullableType)
{
modelState.TryAddModelError(
modelName,
metadata.ModelBindingMessageProvider.ValueMustNotBeNullAccessor(
valueProviderResult.ToString()));
return Task.CompletedTask;
}
else
{
bindingContext.Result = ModelBindingResult.Success(model);
return Task.CompletedTask;
}
}
catch (Exception exception)
{
var isFormatException = exception is FormatException;
if (!isFormatException && exception.InnerException != null)
{
// Unlike TypeConverters, floating point types do not seem to wrap FormatExceptions. Preserve
// this code in case a cursory review of the CoreFx code missed something.
exception = ExceptionDispatchInfo.Capture(exception.InnerException).SourceException;
}
modelState.TryAddModelError(modelName, exception, metadata);
// Conversion failed.
return Task.CompletedTask;
}
}
}
}

View File

@ -0,0 +1,101 @@
// 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;
using System.Globalization;
using System.Runtime.ExceptionServices;
using System.Threading.Tasks;
namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders
{
/// <summary>
/// An <see cref="IModelBinder"/> for <see cref="decimal"/> and <see cref="Nullable{T}"/> where <c>T</c> is
/// <see cref="decimal"/>.
/// </summary>
public class DoubleModelBinder : IModelBinder
{
private readonly NumberStyles _supportedStyles;
public DoubleModelBinder(NumberStyles supportedStyles)
{
_supportedStyles = supportedStyles;
}
/// <inheritdoc />
public Task BindModelAsync(ModelBindingContext bindingContext)
{
if (bindingContext == null)
{
throw new ArgumentNullException(nameof(bindingContext));
}
var modelName = bindingContext.ModelName;
var valueProviderResult = bindingContext.ValueProvider.GetValue(modelName);
if (valueProviderResult == ValueProviderResult.None)
{
// no entry
return Task.CompletedTask;
}
var modelState = bindingContext.ModelState;
modelState.SetModelValue(modelName, valueProviderResult);
var metadata = bindingContext.ModelMetadata;
var type = metadata.UnderlyingOrModelType;
try
{
var value = valueProviderResult.FirstValue;
var culture = valueProviderResult.Culture;
object model;
if (string.IsNullOrWhiteSpace(value))
{
// Parse() method trims the value (with common NumberStyles) then throws if the result is empty.
model = null;
}
else if (type == typeof(double))
{
model = double.Parse(value, _supportedStyles, culture);
}
else
{
// unreachable
throw new NotSupportedException();
}
// When converting value, a null model may indicate a failed conversion for an otherwise required
// model (can't set a ValueType to null). This detects if a null model value is acceptable given the
// current bindingContext. If not, an error is logged.
if (model == null && !metadata.IsReferenceOrNullableType)
{
modelState.TryAddModelError(
modelName,
metadata.ModelBindingMessageProvider.ValueMustNotBeNullAccessor(
valueProviderResult.ToString()));
return Task.CompletedTask;
}
else
{
bindingContext.Result = ModelBindingResult.Success(model);
return Task.CompletedTask;
}
}
catch (Exception exception)
{
var isFormatException = exception is FormatException;
if (!isFormatException && exception.InnerException != null)
{
// Unlike TypeConverters, floating point types do not seem to wrap FormatExceptions. Preserve
// this code in case a cursory review of the CoreFx code missed something.
exception = ExceptionDispatchInfo.Capture(exception.InnerException).SourceException;
}
modelState.TryAddModelError(modelName, exception, metadata);
// Conversion failed.
return Task.CompletedTask;
}
}
}
}

View File

@ -0,0 +1,101 @@
// 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;
using System.Globalization;
using System.Runtime.ExceptionServices;
using System.Threading.Tasks;
namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders
{
/// <summary>
/// An <see cref="IModelBinder"/> for <see cref="decimal"/> and <see cref="Nullable{T}"/> where <c>T</c> is
/// <see cref="decimal"/>.
/// </summary>
public class FloatModelBinder : IModelBinder
{
private readonly NumberStyles _supportedStyles;
public FloatModelBinder(NumberStyles supportedStyles)
{
_supportedStyles = supportedStyles;
}
/// <inheritdoc />
public Task BindModelAsync(ModelBindingContext bindingContext)
{
if (bindingContext == null)
{
throw new ArgumentNullException(nameof(bindingContext));
}
var modelName = bindingContext.ModelName;
var valueProviderResult = bindingContext.ValueProvider.GetValue(modelName);
if (valueProviderResult == ValueProviderResult.None)
{
// no entry
return Task.CompletedTask;
}
var modelState = bindingContext.ModelState;
modelState.SetModelValue(modelName, valueProviderResult);
var metadata = bindingContext.ModelMetadata;
var type = metadata.UnderlyingOrModelType;
try
{
var value = valueProviderResult.FirstValue;
var culture = valueProviderResult.Culture;
object model;
if (string.IsNullOrWhiteSpace(value))
{
// Parse() method trims the value (with common NumberStyles) then throws if the result is empty.
model = null;
}
else if (type == typeof(float))
{
model = float.Parse(value, _supportedStyles, culture);
}
else
{
// unreachable
throw new NotSupportedException();
}
// When converting value, a null model may indicate a failed conversion for an otherwise required
// model (can't set a ValueType to null). This detects if a null model value is acceptable given the
// current bindingContext. If not, an error is logged.
if (model == null && !metadata.IsReferenceOrNullableType)
{
modelState.TryAddModelError(
modelName,
metadata.ModelBindingMessageProvider.ValueMustNotBeNullAccessor(
valueProviderResult.ToString()));
return Task.CompletedTask;
}
else
{
bindingContext.Result = ModelBindingResult.Success(model);
return Task.CompletedTask;
}
}
catch (Exception exception)
{
var isFormatException = exception is FormatException;
if (!isFormatException && exception.InnerException != null)
{
// Unlike TypeConverters, floating point types do not seem to wrap FormatExceptions. Preserve
// this code in case a cursory review of the CoreFx code missed something.
exception = ExceptionDispatchInfo.Capture(exception.InnerException).SourceException;
}
modelState.TryAddModelError(modelName, exception, metadata);
// Conversion failed.
return Task.CompletedTask;
}
}
}
}

View File

@ -0,0 +1,46 @@
// 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;
using System.Globalization;
namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders
{
/// <summary>
/// An <see cref="IModelBinderProvider"/> for binding <see cref="decimal"/>, <see cref="double"/>,
/// <see cref="float"/>, and their <see cref="Nullable{T}"/> wrappers.
/// </summary>
public class FloatingPointTypeModelBinderProvider : IModelBinderProvider
{
// SimpleTypeModelBinder uses DecimalConverter and similar. Those TypeConverters default to NumberStyles.Float.
// Internal for testing.
internal static readonly NumberStyles SupportedStyles = NumberStyles.Float | NumberStyles.AllowThousands;
/// <inheritdoc />
public IModelBinder GetBinder(ModelBinderProviderContext context)
{
if (context == null)
{
throw new ArgumentNullException(nameof(context));
}
var modelType = context.Metadata.UnderlyingOrModelType;
if (modelType == typeof(decimal))
{
return new DecimalModelBinder(SupportedStyles);
}
if (modelType == typeof(double))
{
return new DoubleModelBinder(SupportedStyles);
}
if (modelType == typeof(float))
{
return new FloatModelBinder(SupportedStyles);
}
return null;
}
}
}

View File

@ -0,0 +1,23 @@
// 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.Globalization;
namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders
{
public class DecimalModelBinderTest : FloatingPointTypeModelBinderTest<decimal>
{
protected override decimal Twelve => 12M;
protected override decimal TwelvePointFive => 12.5M;
protected override decimal ThirtyTwoThousand => 32_000M;
protected override decimal ThirtyTwoThousandPointOne => 32_000.1M;
protected override IModelBinder GetBinder(NumberStyles numberStyles)
{
return new DecimalModelBinder(numberStyles);
}
}
}

View File

@ -0,0 +1,23 @@
// 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.Globalization;
namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders
{
public class DoubleModelBinderTest : FloatingPointTypeModelBinderTest<double>
{
protected override double Twelve => 12.0;
protected override double TwelvePointFive => 12.5;
protected override double ThirtyTwoThousand => 32_000.0;
protected override double ThirtyTwoThousandPointOne => 32_000.1;
protected override IModelBinder GetBinder(NumberStyles numberStyles)
{
return new DoubleModelBinder(numberStyles);
}
}
}

View File

@ -0,0 +1,23 @@
// 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.Globalization;
namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders
{
public class FloatModelBinderTest : FloatingPointTypeModelBinderTest<float>
{
protected override float Twelve => 12.0F;
protected override float TwelvePointFive => 12.5F;
protected override float ThirtyTwoThousand => 32_000.0F;
protected override float ThirtyTwoThousandPointOne => 32_000.1F;
protected override IModelBinder GetBinder(NumberStyles numberStyles)
{
return new FloatModelBinder(numberStyles);
}
}
}

View File

@ -0,0 +1,103 @@
// 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;
using System.Collections.Generic;
using System.Globalization;
using Xunit;
namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders
{
public class FloatingPointTypeModelBinderProviderTest
{
[Theory]
[InlineData(typeof(object))]
[InlineData(typeof(Calendar))]
[InlineData(typeof(TestClass))]
[InlineData(typeof(List<int>))]
public void Create_ForCollectionOrComplexTypes_ReturnsNull(Type modelType)
{
// Arrange
var provider = new FloatingPointTypeModelBinderProvider();
var context = new TestModelBinderProviderContext(modelType);
// Act
var result = provider.GetBinder(context);
// Assert
Assert.Null(result);
}
[Theory]
[InlineData(typeof(int))]
[InlineData(typeof(int?))]
[InlineData(typeof(char))]
[InlineData(typeof(string))]
[InlineData(typeof(DateTime))]
[InlineData(typeof(DateTime?))]
public void Create_ForUnsupportedSimpleTypes_ReturnsNull(Type modelType)
{
// Arrange
var provider = new FloatingPointTypeModelBinderProvider();
var context = new TestModelBinderProviderContext(modelType);
// Act
var result = provider.GetBinder(context);
// Assert
Assert.Null(result);
}
[Theory]
[InlineData(typeof(decimal))]
[InlineData(typeof(decimal?))]
public void Create_ForDecimalTypes_ReturnsBinder(Type modelType)
{
// Arrange
var provider = new FloatingPointTypeModelBinderProvider();
var context = new TestModelBinderProviderContext(modelType);
// Act
var result = provider.GetBinder(context);
// Assert
Assert.IsType<DecimalModelBinder>(result);
}
[Theory]
[InlineData(typeof(double))]
[InlineData(typeof(double?))]
public void Create_ForDoubleTypes_ReturnsBinder(Type modelType)
{
// Arrange
var provider = new FloatingPointTypeModelBinderProvider();
var context = new TestModelBinderProviderContext(modelType);
// Act
var result = provider.GetBinder(context);
// Assert
Assert.IsType<DoubleModelBinder>(result);
}
[Theory]
[InlineData(typeof(float))]
[InlineData(typeof(float?))]
public void Create_ForFloatTypes_ReturnsBinder(Type modelType)
{
// Arrange
var provider = new FloatingPointTypeModelBinderProvider();
var context = new TestModelBinderProviderContext(modelType);
// Act
var result = provider.GetBinder(context);
// Assert
Assert.IsType<FloatModelBinder>(result);
}
private class TestClass
{
}
}
}

View File

@ -0,0 +1,387 @@
// 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;
using System.Globalization;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Testing;
using Xunit;
namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders
{
public abstract class FloatingPointTypeModelBinderTest<TFloatingPoint> where TFloatingPoint: struct
{
public static TheoryData<Type> ConvertableTypeData
{
get
{
return new TheoryData<Type>
{
typeof(TFloatingPoint),
typeof(TFloatingPoint?),
};
}
}
protected abstract TFloatingPoint Twelve { get; }
protected abstract TFloatingPoint TwelvePointFive { get; }
protected abstract TFloatingPoint ThirtyTwoThousand { get; }
protected abstract TFloatingPoint ThirtyTwoThousandPointOne { get; }
[Theory]
[MemberData(nameof(ConvertableTypeData))]
public async Task BindModel_ReturnsFailure_IfAttemptedValueCannotBeParsed(Type destinationType)
{
// Arrange
var bindingContext = GetBindingContext(destinationType);
bindingContext.ValueProvider = new SimpleValueProvider
{
{ "theModelName", "some-value" }
};
var binder = GetBinder();
// Act
await binder.BindModelAsync(bindingContext);
// Assert
Assert.False(bindingContext.Result.IsModelSet);
}
[Theory]
[MemberData(nameof(ConvertableTypeData))]
public async Task BindModel_CreatesError_IfAttemptedValueCannotBeParsed(Type destinationType)
{
// Arrange
var message = "The value 'not a number' is not valid.";
var bindingContext = GetBindingContext(destinationType);
bindingContext.ValueProvider = new SimpleValueProvider
{
{ "theModelName", "not a number" },
};
var binder = GetBinder();
// Act
await binder.BindModelAsync(bindingContext);
// Assert
Assert.False(bindingContext.Result.IsModelSet);
Assert.Null(bindingContext.Result.Model);
Assert.False(bindingContext.ModelState.IsValid);
var error = Assert.Single(bindingContext.ModelState["theModelName"].Errors);
Assert.Equal(message, error.ErrorMessage);
}
[Theory]
[MemberData(nameof(ConvertableTypeData))]
public async Task BindModel_CreatesError_IfAttemptedValueCannotBeCompletelyParsed(Type destinationType)
{
// Arrange
var bindingContext = GetBindingContext(destinationType);
bindingContext.ValueProvider = new SimpleValueProvider(new CultureInfo("en-GB"))
{
{ "theModelName", "12_5" }
};
var binder = GetBinder();
// Act
await binder.BindModelAsync(bindingContext);
// Assert
Assert.False(bindingContext.Result.IsModelSet);
Assert.Null(bindingContext.Result.Model);
var error = Assert.Single(bindingContext.ModelState["theModelName"].Errors);
Assert.Equal("The value '12_5' is not valid.", error.ErrorMessage, StringComparer.Ordinal);
Assert.Null(error.Exception);
}
[Theory]
[MemberData(nameof(ConvertableTypeData))]
public async Task BindModel_CreatesError_IfAttemptedValueContainsDisallowedWhitespace(Type destinationType)
{
// Arrange
var bindingContext = GetBindingContext(destinationType);
bindingContext.ValueProvider = new SimpleValueProvider(new CultureInfo("en-GB"))
{
{ "theModelName", " 12" }
};
var binder = GetBinder(NumberStyles.Float & ~NumberStyles.AllowLeadingWhite);
// Act
await binder.BindModelAsync(bindingContext);
// Assert
Assert.False(bindingContext.Result.IsModelSet);
Assert.Null(bindingContext.Result.Model);
var error = Assert.Single(bindingContext.ModelState["theModelName"].Errors);
Assert.Equal("The value ' 12' is not valid.", error.ErrorMessage, StringComparer.Ordinal);
Assert.Null(error.Exception);
}
[Theory]
[MemberData(nameof(ConvertableTypeData))]
public async Task BindModel_CreatesError_IfAttemptedValueContainsDisallowedDecimal(Type destinationType)
{
// Arrange
var bindingContext = GetBindingContext(destinationType);
bindingContext.ValueProvider = new SimpleValueProvider(new CultureInfo("en-GB"))
{
{ "theModelName", "12.5" }
};
var binder = GetBinder(NumberStyles.Float & ~NumberStyles.AllowDecimalPoint);
// Act
await binder.BindModelAsync(bindingContext);
// Assert
Assert.False(bindingContext.Result.IsModelSet);
Assert.Null(bindingContext.Result.Model);
var error = Assert.Single(bindingContext.ModelState["theModelName"].Errors);
Assert.Equal("The value '12.5' is not valid.", error.ErrorMessage, StringComparer.Ordinal);
Assert.Null(error.Exception);
}
[Theory]
[MemberData(nameof(ConvertableTypeData))]
public async Task BindModel_CreatesError_IfAttemptedValueContainsDisallowedThousandsSeparator(Type destinationType)
{
// Arrange
var bindingContext = GetBindingContext(destinationType);
bindingContext.ValueProvider = new SimpleValueProvider(new CultureInfo("en-GB"))
{
{ "theModelName", "32,000" }
};
var binder = GetBinder(NumberStyles.Float);
// Act
await binder.BindModelAsync(bindingContext);
// Assert
Assert.False(bindingContext.Result.IsModelSet);
Assert.Null(bindingContext.Result.Model);
var error = Assert.Single(bindingContext.ModelState["theModelName"].Errors);
Assert.Equal("The value '32,000' is not valid.", error.ErrorMessage, StringComparer.Ordinal);
Assert.Null(error.Exception);
}
[Theory]
[MemberData(nameof(ConvertableTypeData))]
public async Task BindModel_ReturnsFailed_IfValueProviderEmpty(Type destinationType)
{
// Arrange
var bindingContext = GetBindingContext(destinationType);
var binder = GetBinder();
// Act
await binder.BindModelAsync(bindingContext);
// Assert
Assert.Equal(ModelBindingResult.Failed(), bindingContext.Result);
Assert.Empty(bindingContext.ModelState);
}
[Theory]
[InlineData("")]
[InlineData(" \t \r\n ")]
public async Task BindModel_CreatesError_IfTrimmedAttemptedValueIsEmpty_NonNullableDestination(string value)
{
// Arrange
var message = $"The value '{value}' is invalid.";
var bindingContext = GetBindingContext(typeof(TFloatingPoint));
bindingContext.ValueProvider = new SimpleValueProvider
{
{ "theModelName", value },
};
var binder = GetBinder();
// Act
await binder.BindModelAsync(bindingContext);
// Assert
Assert.False(bindingContext.Result.IsModelSet);
Assert.Null(bindingContext.Result.Model);
var error = Assert.Single(bindingContext.ModelState["theModelName"].Errors);
Assert.Equal(message, error.ErrorMessage, StringComparer.Ordinal);
Assert.Null(error.Exception);
}
[Theory]
[InlineData("")]
[InlineData(" \t \r\n ")]
public async Task BindModel_ReturnsNull_IfTrimmedAttemptedValueIsEmpty_NullableDestination(string value)
{
// Arrange
var bindingContext = GetBindingContext(typeof(TFloatingPoint?));
bindingContext.ValueProvider = new SimpleValueProvider
{
{ "theModelName", value }
};
var binder = GetBinder();
// Act
await binder.BindModelAsync(bindingContext);
// Assert
Assert.Null(bindingContext.Result.Model);
var entry = Assert.Single(bindingContext.ModelState);
Assert.Equal("theModelName", entry.Key);
}
[Theory]
[MemberData(nameof(ConvertableTypeData))]
public async Task BindModel_ReturnsModel_IfAttemptedValueIsValid_Twelve(Type destinationType)
{
// Arrange
var bindingContext = GetBindingContext(destinationType);
bindingContext.ValueProvider = new SimpleValueProvider
{
{ "theModelName", "12" }
};
var binder = GetBinder();
// Act
await binder.BindModelAsync(bindingContext);
// Assert
Assert.True(bindingContext.Result.IsModelSet);
Assert.Equal(Twelve, bindingContext.Result.Model);
Assert.True(bindingContext.ModelState.ContainsKey("theModelName"));
}
[Theory]
[MemberData(nameof(ConvertableTypeData))]
[ReplaceCulture("en-GB", "en-GB")]
public async Task BindModel_ReturnsModel_IfAttemptedValueIsValid_TwelvePointFive(Type destinationType)
{
// Arrange
var bindingContext = GetBindingContext(destinationType);
bindingContext.ValueProvider = new SimpleValueProvider
{
{ "theModelName", "12.5" }
};
var binder = GetBinder();
// Act
await binder.BindModelAsync(bindingContext);
// Assert
Assert.True(bindingContext.Result.IsModelSet);
Assert.Equal(TwelvePointFive, bindingContext.Result.Model);
Assert.True(bindingContext.ModelState.ContainsKey("theModelName"));
}
[Theory]
[MemberData(nameof(ConvertableTypeData))]
public async Task BindModel_ReturnsModel_IfAttemptedValueIsValid_FrenchTwelvePointFive(Type destinationType)
{
// Arrange
var bindingContext = GetBindingContext(destinationType);
bindingContext.ValueProvider = new SimpleValueProvider(new CultureInfo("fr-FR"))
{
{ "theModelName", "12,5" }
};
var binder = GetBinder();
// Act
await binder.BindModelAsync(bindingContext);
// Assert
Assert.True(bindingContext.Result.IsModelSet);
Assert.Equal(TwelvePointFive, bindingContext.Result.Model);
Assert.True(bindingContext.ModelState.ContainsKey("theModelName"));
}
[Theory]
[MemberData(nameof(ConvertableTypeData))]
public async Task BindModel_ReturnsModel_IfAttemptedValueIsValid_ThirtyTwoThousand(Type destinationType)
{
// Arrange
var bindingContext = GetBindingContext(destinationType);
bindingContext.ValueProvider = new SimpleValueProvider(new CultureInfo("en-GB"))
{
{ "theModelName", "32,000" }
};
var binder = GetBinder();
// Act
await binder.BindModelAsync(bindingContext);
// Assert
Assert.True(bindingContext.Result.IsModelSet);
Assert.Equal(ThirtyTwoThousand, bindingContext.Result.Model);
Assert.True(bindingContext.ModelState.ContainsKey("theModelName"));
}
[Theory]
[MemberData(nameof(ConvertableTypeData))]
public async Task BindModel_ReturnsModel_IfAttemptedValueIsValid_ThirtyTwoThousandPointOne(Type destinationType)
{
// Arrange
var bindingContext = GetBindingContext(destinationType);
bindingContext.ValueProvider = new SimpleValueProvider(new CultureInfo("en-GB"))
{
{ "theModelName", "32,000.1" }
};
var binder = GetBinder();
// Act
await binder.BindModelAsync(bindingContext);
// Assert
Assert.True(bindingContext.Result.IsModelSet);
Assert.Equal(ThirtyTwoThousandPointOne, bindingContext.Result.Model);
Assert.True(bindingContext.ModelState.ContainsKey("theModelName"));
}
[Theory]
[MemberData(nameof(ConvertableTypeData))]
public async Task BindModel_ReturnsModel_IfAttemptedValueIsValid_FrenchThirtyTwoThousandPointOne(Type destinationType)
{
// Arrange
var bindingContext = GetBindingContext(destinationType);
bindingContext.ValueProvider = new SimpleValueProvider(new CultureInfo("fr-FR"))
{
{ "theModelName", "32 000,1" }
};
var binder = GetBinder();
// Act
await binder.BindModelAsync(bindingContext);
// Assert
Assert.True(bindingContext.Result.IsModelSet);
Assert.Equal(ThirtyTwoThousandPointOne, bindingContext.Result.Model);
Assert.True(bindingContext.ModelState.ContainsKey("theModelName"));
}
protected abstract IModelBinder GetBinder(NumberStyles numberStyles);
private IModelBinder GetBinder()
{
return GetBinder(FloatingPointTypeModelBinderProvider.SupportedStyles);
}
private static DefaultModelBindingContext GetBindingContext(Type modelType)
{
return new DefaultModelBindingContext
{
ModelMetadata = new EmptyModelMetadataProvider().GetMetadataForType(modelType),
ModelName = "theModelName",
ModelState = new ModelStateDictionary(),
ValueProvider = new SimpleValueProvider() // empty
};
}
private sealed class TestClass
{
}
}
}

View File

@ -109,13 +109,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders
}
[Theory]
[InlineData(typeof(byte))]
[InlineData(typeof(short))]
[InlineData(typeof(int))]
[InlineData(typeof(long))]
[InlineData(typeof(Guid))]
[InlineData(typeof(double))]
[InlineData(typeof(DayOfWeek))]
[MemberData(nameof(ConvertableTypeData))]
public async Task BindModel_CreatesError_WhenTypeConversionIsNull(Type destinationType)
{
// Arrange
@ -262,6 +256,55 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders
Assert.True(bindingContext.ModelState.ContainsKey("theModelName"));
}
public static TheoryData<Type> BiggerNumericTypes
{
get
{
// Data set does not include bool, byte, sbyte, or char because they do not need thousands separators.
return new TheoryData<Type>
{
typeof(decimal),
typeof(double),
typeof(float),
typeof(int),
typeof(long),
typeof(short),
typeof(uint),
typeof(ulong),
typeof(ushort),
};
}
}
[Theory]
[MemberData(nameof(BiggerNumericTypes))]
public async Task BindModel_ThousandsSeparators_LeadToErrors(Type type)
{
// Arrange
var bindingContext = GetBindingContext(type);
bindingContext.ValueProvider = new SimpleValueProvider(new CultureInfo("en-GB"))
{
{ "theModelName", "32,000" }
};
var binder = new SimpleTypeModelBinder(type);
// Act
await binder.BindModelAsync(bindingContext);
// Assert
Assert.False(bindingContext.Result.IsModelSet);
var entry = Assert.Single(bindingContext.ModelState);
Assert.Equal("theModelName", entry.Key);
Assert.Equal("32,000", entry.Value.AttemptedValue);
Assert.Equal(ModelValidationState.Invalid, entry.Value.ValidationState);
var error = Assert.Single(entry.Value.Errors);
Assert.Equal("The value '32,000' is not valid.", error.ErrorMessage);
Assert.Null(error.Exception);
}
[Fact]
public async Task BindModel_ValidValueProviderResultWithProvidedCulture_ReturnsModel()
{
@ -349,11 +392,49 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders
Assert.Equal(IntEnum.Value1, boundModel);
}
public static TheoryData<string, int> EnumValues
{
get
{
return new TheoryData<string, int>
{
{ "0", 0 },
{ "1", 1 },
{ "13", 13 },
{ "Value1", 1 },
{ "Value1, Value2", 3 },
// These two values look like big integers but are treated as two separate enum values that are
// or'd together.
{ "32,015", 47 },
{ "32,128", 160 },
};
}
}
[Theory]
[InlineData("0", 0)]
[InlineData("1", 1)]
[InlineData("13", 13)]
[InlineData("Value1", 1)]
[MemberData(nameof(EnumValues))]
public async Task BindModel_BindsIntEnumModels(string flagsEnumValue, int expected)
{
// Arrange
var bindingContext = GetBindingContext(typeof(IntEnum));
bindingContext.ValueProvider = new SimpleValueProvider
{
{ "theModelName", flagsEnumValue }
};
var binder = new SimpleTypeModelBinder(typeof(IntEnum));
// Act
await binder.BindModelAsync(bindingContext);
// Assert
Assert.True(bindingContext.Result.IsModelSet);
var boundModel = Assert.IsType<IntEnum>(bindingContext.Result.Model);
Assert.Equal((IntEnum)expected, boundModel);
}
[Theory]
[MemberData(nameof(EnumValues))]
[InlineData("Value8, Value4", 12)]
public async Task BindModel_BindsFlagsEnumModels(string flagsEnumValue, int expected)
{
@ -396,13 +477,14 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders
Value1 = 1,
Value2 = 2,
Value4 = 4,
Value8 = 8
Value8 = 8,
}
private enum IntEnum
{
Value0 = 0,
Value1 = 1,
Value2 = 2,
MaxValue = int.MaxValue
}
}

View File

@ -9,6 +9,7 @@ using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.Testing;
using Microsoft.Extensions.Primitives;
using Xunit;
@ -152,6 +153,50 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests
Assert.Equal(ModelValidationState.Valid, modelState[key].ValidationState);
}
[Fact]
[ReplaceCulture("en-GB", "en-GB")]
public async Task BindDecimalParameter_WithData_GetsBound()
{
// Arrange
var parameterBinder = ModelBindingTestHelper.GetParameterBinder();
var parameter = new ParameterDescriptor
{
Name = "Parameter1",
BindingInfo = new BindingInfo(),
ParameterType = typeof(decimal),
};
var testContext = ModelBindingTestHelper.GetTestContext(request =>
{
request.QueryString = QueryString.Create("Parameter1", "32,000.99");
});
var modelState = testContext.ModelState;
// Act
var modelBindingResult = await parameterBinder.BindModelAsync(parameter, testContext);
// Assert
// ModelBindingResult
Assert.True(modelBindingResult.IsModelSet);
// Model
var model = Assert.IsType<decimal>(modelBindingResult.Model);
Assert.Equal(32000.99M, model);
// ModelState
Assert.True(modelState.IsValid);
Assert.Equal(1, modelState.Keys.Count());
var key = Assert.Single(modelState.Keys);
Assert.Equal("Parameter1", key);
Assert.Equal("32,000.99", modelState[key].AttemptedValue);
Assert.Equal("32,000.99", modelState[key].RawValue);
Assert.Empty(modelState[key].Errors);
Assert.Equal(ModelValidationState.Valid, modelState[key].ValidationState);
}
[Fact]
public async Task BindParameter_WithMultipleValues_GetsBoundToFirstValue()
{

View File

@ -58,6 +58,7 @@ namespace Microsoft.AspNetCore.Mvc
binder => Assert.IsType<ServicesModelBinderProvider>(binder),
binder => Assert.IsType<BodyModelBinderProvider>(binder),
binder => Assert.IsType<HeaderModelBinderProvider>(binder),
binder => Assert.IsType<FloatingPointTypeModelBinderProvider>(binder),
binder => Assert.IsType<SimpleTypeModelBinderProvider>(binder),
binder => Assert.IsType<CancellationTokenModelBinderProvider>(binder),
binder => Assert.IsType<ByteArrayModelBinderProvider>(binder),