Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

ASP.NET MVC binding decimal value

I'm trying to figure out why framework refuses to bind "1,234.00" value to decimal. What can be the reason for it?

Values like "123.00" or "123.0000" bind successfully.

I have the following code setting my culture config in Global.asax

    public void Application_AcquireRequestState(object sender, EventArgs e)
    {
        var culture = (CultureInfo)Thread.CurrentThread.CurrentCulture.Clone();
        culture.NumberFormat.NumberDecimalSeparator = culture.NumberFormat.CurrencyDecimalSeparator = culture.NumberFormat.PercentDecimalSeparator = ".";
        culture.NumberFormat.NumberGroupSeparator = culture.NumberFormat.CurrencyGroupSeparator = culture.NumberFormat.PercentGroupSeparator = ",";
        Thread.CurrentThread.CurrentCulture = culture;
    }

French culture is set as default culture in Web.Config

  <globalization uiCulture="fr-FR" culture="fr-FR" />

I've dived into sources of System.Web.Mvc.dll's ValueProviderResult class. It is using System.ComponentModel.DecimalConverter.

converter.ConvertFrom((ITypeDescriptorContext) null, culture, value)

Here is where the message "1,234.0000 is not a valid value for Decimal." comes from.

I've tried to run the following code in my playground:

static void Main()
{
    var decConverter = TypeDescriptor.GetConverter(typeof(decimal));
    var culture = new CultureInfo("fr-FR");
    culture.NumberFormat.NumberDecimalSeparator = culture.NumberFormat.CurrencyDecimalSeparator = culture.NumberFormat.PercentDecimalSeparator = ".";
    culture.NumberFormat.NumberGroupSeparator = culture.NumberFormat.CurrencyGroupSeparator = culture.NumberFormat.PercentGroupSeparator = ",";
    Thread.CurrentThread.CurrentCulture = culture;
    var d1 = Decimal.Parse("1,232.000");
    Console.Write("{0}", d1);  // prints  1234.000     
    var d2 = decConverter.ConvertFrom((ITypeDescriptorContext)null, culture, "1,232.000"); // throws "1,234.0000 is not a valid value for Decimal."
    Console.Write("{0}", d2);
}

DecimalConverter throws same exception. Decimal.Parse correctly parses the same string.

like image 402
Ivan Gritsenko Avatar asked Aug 26 '15 20:08

Ivan Gritsenko


4 Answers

The problem is, that DecimalConverter.ConvertFrom does not support the AllowThousands flag of the NumberStyles enumeration when it calls Number.Parse. The good news is, that there exists a way to "teach" it to do so!

Decimal.Parse internally calls Number.Parse with the number style set to Number, for which the AllowThousands flag is set to true.

[__DynamicallyInvokable]
public static decimal Parse(string s)
{
    return Number.ParseDecimal(s, NumberStyles.Number, NumberFormatInfo.CurrentInfo);
}

When you are receiving a type converter from the descriptor, you actually get an instance of DecimalConverter. The ConvertFrom method is a kinda general and large, so I only quote the relevant parts for the current scenario here. The missing parts are implementing support for hex strings and exception handling.1

public override object ConvertFrom(ITypeDescriptorContext context, CultureInfo culture, object value)
{
    if (value is string) 
    {
        // ...

        string text = ((string)value).Trim();

        if (culture == null) 
            culture = CultureInfo.CurrentCulture;

        NumberFormatInfo formatInfo = (NumberFormatInfo)culture.GetFormat(typeof(NumberFormatInfo));
        return FromString(text, formatInfo);

        // ...
    }

    return base.ConvertFrom(context, culture, value);
}

DecimalConverter also overwrites the FromString implementation and there the problem raises:

internal override object FromString(string value, NumberFormatInfo formatInfo) 
{
    return Decimal.Parse(value, NumberStyles.Float, formatInfo);
}

With the number style set to Float, the AllowThousands flag is set to false! However you can write a custom converter with a few lines of code that fixes this issue.

class NumericDecimalConverter : DecimalConverter
{
    public override object ConvertFrom(ITypeDescriptorContext context, CultureInfo culture, object value)
    {
        if (value is string)
        {
            string text = ((string)value).Trim();

            if (culture == null) 
                culture = CultureInfo.CurrentCulture;

            NumberFormatInfo formatInfo = (NumberFormatInfo)culture.GetFormat(typeof(NumberFormatInfo));
            return Decimal.Parse(text, NumberStyles.Number, formatInfo);
        }
        else
        {
            return base.ConvertFrom(value);
        }
    }
}

1Note that the code looks similar to the original implementation. If you need the "unquoted" stuff either delegate it directly to base or implement it on your own. You can view the implementation using ILSpy/DotPeek/etc. or by debugging into them from Visual Studio.

Finally, with a little help from Reflection, you can set the type converter for Decimal to use your new custom one!

TypeDescriptor.AddAttributes(typeof(decimal), new TypeConverterAttribute(typeof(NumericDecimalConverter)));
like image 185
Carsten Avatar answered Nov 14 '22 14:11

Carsten


Based on a comment from an article about decimal model binding by Phil Haack here, I believe part of the answer to the "why" is that culture in browsers is complicated and you can't be guaranteed that your application's culture will be the same culture settings used by the user/ browser for decimals. In any case it is a known "issue" and similar questions have been asked before with a variety of solutions offered, in addition to the so: Accept comma and dot as decimal separator and How to set decimal separators in ASP.NET MVC controllers? for example.

like image 45
stephen.vakil Avatar answered Nov 14 '22 13:11

stephen.vakil


You can try overriding the DefaultModelBinder. Let me know if this doesn't work and I'll delete this post. I didn't actually put together an MVC app and test it, but based on experience this should work:

public class CustomModelBinder : DefaultModelBinder
{
    protected override void BindProperty(ControllerContext controllerContext, ModelBindingContext bindingContext, PropertyDescriptor propertyDescriptor)
    {
        if(propertyDescriptor.PropertyType == typeof(decimal))
        {
            propertyDescriptor.SetValue(bindingContext.Model, double.Parse(propertyDescriptor.GetValue(bindingContext.Model).ToString()));
            base.BindProperty(controllerContext, bindingContext, propertyDescriptor);
        }
        else
        {
            base.BindProperty(controllerContext, bindingContext, propertyDescriptor);
        }
    }
}
like image 2
Mr. B Avatar answered Nov 14 '22 14:11

Mr. B


The issue here appears to be the default Number Styles applied to Decimal.Parse(string).

From MSDN documentation

The remaining individual field flags define style elements that may be, but do not have to be, present in the string representation of a decimal number for the parse operation to succeed.

So this means that both d1 and d2 below successfully parse

                var d1 = Decimal.Parse("1,232.000");

                var d2 = Decimal.Parse("1,232.000", NumberStyles.Any);

However when applying the type convertor it appears that this essentially only allows the allow training spaces, allow decimal point and allow leading sign. As such the d3 express below will throw a runtime error

                var d3 = Decimal.Parse("1,232.000", NumberStyles.AllowLeadingSign | NumberStyles.AllowLeadingWhite | 
                                        NumberStyles.AllowTrailingWhite | NumberStyles.AllowDecimalPoint);
like image 2
Johnv2020 Avatar answered Nov 14 '22 13:11

Johnv2020