Few things to mention:
- The issue has nothing in common with Fluent Validation. I was able to reproduce/fix it with or without Fluent Validation.
- The
DataFormatString
used is incorrect (missing the value placeholder). It should really be "{0:#,##0}"
.
- The
ModelBinder
approach from the link actually works. I guess you forgot that it is written for decimal
data type, while your model is using double?
, so you have to write and register another one for double
and double?
types.
Now on the subject. There are actually two solutions. Both of them use the following helper class for the actual string conversion:
using System;
using System.Collections.Generic;
using System.Globalization;
public static class NumericValueParser
{
static readonly Dictionary<Type, Func<string, CultureInfo, object>> parsers = new Dictionary<Type, Func<string, CultureInfo, object>>
{
{ typeof(byte), (s, c) => byte.Parse(s, NumberStyles.Any, c) },
{ typeof(sbyte), (s, c) => sbyte.Parse(s, NumberStyles.Any, c) },
{ typeof(short), (s, c) => short.Parse(s, NumberStyles.Any, c) },
{ typeof(ushort), (s, c) => ushort.Parse(s, NumberStyles.Any, c) },
{ typeof(int), (s, c) => int.Parse(s, NumberStyles.Any, c) },
{ typeof(uint), (s, c) => uint.Parse(s, NumberStyles.Any, c) },
{ typeof(long), (s, c) => long.Parse(s, NumberStyles.Any, c) },
{ typeof(ulong), (s, c) => ulong.Parse(s, NumberStyles.Any, c) },
{ typeof(float), (s, c) => float.Parse(s, NumberStyles.Any, c) },
{ typeof(double), (s, c) => double.Parse(s, NumberStyles.Any, c) },
{ typeof(decimal), (s, c) => decimal.Parse(s, NumberStyles.Any, c) },
};
public static IEnumerable<Type> Types { get { return parsers.Keys; } }
public static object Parse(string value, Type type, CultureInfo culture)
{
return parsers[type](value, culture);
}
}
Custom IModelBinder
This is a modified version of the linked approach. It's a single class that handles all the numeric types and their respective nullable types:
using System;
using System.Web.Mvc;
public class NumericValueBinder : IModelBinder
{
public static void Register()
{
var binder = new NumericValueBinder();
foreach (var type in NumericValueParser.Types)
{
// Register for both type and nullable type
ModelBinders.Binders.Add(type, binder);
ModelBinders.Binders.Add(typeof(Nullable<>).MakeGenericType(type), binder);
}
}
private NumericValueBinder() { }
public object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
{
var valueResult = bindingContext.ValueProvider.GetValue(bindingContext.ModelName);
var modelState = new ModelState { Value = valueResult };
object actualValue = null;
if (!string.IsNullOrWhiteSpace(valueResult.AttemptedValue))
{
try
{
var type = bindingContext.ModelType;
var underlyingType = Nullable.GetUnderlyingType(type);
var valueType = underlyingType ?? type;
actualValue = NumericValueParser.Parse(valueResult.AttemptedValue, valueType, valueResult.Culture);
}
catch (Exception e)
{
modelState.Errors.Add(e);
}
}
bindingContext.ModelState.Add(bindingContext.ModelName, modelState);
return actualValue;
}
}
All you need is to register it in your Application_Start
:
protected void Application_Start()
{
NumericValueBinder.Register();
// ...
}
Custom TypeConverter
This is not specific to ASP.NET MVC 5, but DefaultModelBinder
delegates string conversion to the associated TypeConverter
(similar to other NET UI frameworks). In fact the issue is caused by the fact that the default TypeConverter
classes for numeric types do not use Convert
class, but Parse
overloads with NumberStyles
passing NumberStyles.Float
which excludes NumberStyles.AllowThousands
.
Fortunately System.ComponentModel
provides extensible Type Descriptor Architecture which allows you to associate a custom TypeConverter
. The plumbing part is a bit complicated (you have to register a custom TypeDescriptionProvider
in order to provide ICustomTypeDescriptor
implementation that finally returns custom TypeConverter
), but with the help of the provided base classes that delegate most of the stuff to the underlying object, the implementation looks like this:
using System;
using System.ComponentModel;
using System.Globalization;
class NumericTypeDescriptionProvider : TypeDescriptionProvider
{
public static void Register()
{
foreach (var type in NumericValueParser.Types)
TypeDescriptor.AddProvider(new NumericTypeDescriptionProvider(type, TypeDescriptor.GetProvider(type)), type);
}
readonly Descriptor descriptor;
private NumericTypeDescriptionProvider(Type type, TypeDescriptionProvider baseProvider)
: base(baseProvider)
{
descriptor = new Descriptor(type, baseProvider.GetTypeDescriptor(type));
}
public override ICustomTypeDescriptor GetTypeDescriptor(Type objectType, object instance)
{
return descriptor;
}
class Descriptor : CustomTypeDescriptor
{
readonly Converter converter;
public Descriptor(Type type, ICustomTypeDescriptor baseDescriptor)
: base(baseDescriptor)
{
converter = new Converter(type, baseDescriptor.GetConverter());
}
public override TypeConverter GetConverter()
{
return converter;
}
}
class Converter : TypeConverter
{
readonly Type type;
readonly TypeConverter baseConverter;
public Converter(Type type, TypeConverter baseConverter)
{
this.type = type;
this.baseConverter = baseConverter;
}
public override bool CanConvertTo(ITypeDescriptorContext context, Type destinationType)
{
return baseConverter.CanConvertTo(context, destinationType);
}
public override object ConvertTo(ITypeDescriptorContext context, CultureInfo culture, object value, Type destinationType)
{
return baseConverter.ConvertTo(context, culture, value, destinationType);
}
public override bool CanConvertFrom(ITypeDescriptorContext context, Type sourceType)
{
return baseConverter.CanConvertFrom(context, sourceType);
}
public override object ConvertFrom(ITypeDescriptorContext context, CultureInfo culture, object value)
{
if (value is string)
{
try { return NumericValueParser.Parse((string)value, type, culture); }
catch { }
}
return baseConverter.ConvertFrom(context, culture, value);
}
}
}
(Yeah, a lot of boilerplate code in order to add one essential line! From the other side, there is no need to handle nullable types because DefaultModelBinder
already does that :)
Similar to the first approach, all you need is to register it:
protected void Application_Start()
{
NumericTypeDescriptionProvider.Register();
// ...
}