Brett Postin
Brett Postin

Reputation: 11375

Model binding space character to char property

I have a simple viewmodel with a char property...

public char Character1 { get; set; }

The default model binding doesn't seem to be converting a space character (" ") into this property, resulting in the following ModelState error...

The Character1 field is required.

The html input element is created in javascript:

var input = $('<input type="password" name="Character' + i + '" id="input-' + i + '" data-val="true" data-val-custom maxlength="1"></input>');

Why is the space character not being bound to the char property?

Update:

Changing the char property to a string binds as expected.

Upvotes: 4

Views: 2116

Answers (4)

PhilAI
PhilAI

Reputation: 11

Hit this issue in .NET Core recently because the SimpleTypeModelBinder has the same check, so added the following:

    using System;

    using Microsoft.AspNetCore.Mvc.ModelBinding;

    public class CharModelBinderProvider : IModelBinderProvider
    {
        /// <inheritdoc />
        public IModelBinder GetBinder(
            ModelBinderProviderContext context)
        {
            if (context == null)
            {
                throw new ArgumentNullException(nameof(context));
            }

            if (context.Metadata.ModelType == typeof(char))
            {
                return new CharModelBinder();
            }

            return null;
        }
    }


    using System;
    using System.ComponentModel;
    using System.Runtime.ExceptionServices;
    using System.Threading.Tasks;

    using Microsoft.AspNetCore.Mvc.ModelBinding;

    /// <inheritdoc />
    /// <summary>
    ///     An <see cref="T:Microsoft.AspNetCore.Mvc.ModelBinding.IModelBinder" /> for char.
    /// </summary>
    /// <remarks>
    ///     Difference here is that we allow for a space as a character which the <see cref="T:Microsoft.AspNetCore.Mvc.ModelBinding.SimpleTypeModelBinder" /> does not.
    /// </remarks>
    public class CharModelBinder : IModelBinder
    {
        private readonly TypeConverter _charConverter;

        public CharModelBinder()
        {
            this._charConverter =  new CharConverter();
        }

        /// <inheritdoc />
        public Task BindModelAsync(
            ModelBindingContext bindingContext)
        {
            if (bindingContext == null)
            {
                throw new ArgumentNullException(nameof(bindingContext));
            }

            var valueProviderResult = bindingContext.ValueProvider.GetValue(bindingContext.ModelName);
            if (valueProviderResult == ValueProviderResult.None)
            {
                // no entry
                return Task.CompletedTask;
            }

            bindingContext.ModelState.SetModelValue(bindingContext.ModelName, valueProviderResult);

            try
            {
                var value = valueProviderResult.FirstValue;
                var model = this._charConverter.ConvertFrom(null, valueProviderResult.Culture, value);
                this.CheckModel(bindingContext, valueProviderResult, model);

                return Task.CompletedTask;
            }
            catch (Exception exception)
            {
                var isFormatException = exception is FormatException;
                if (!isFormatException && exception.InnerException != null)
                {
                    // TypeConverter throws System.Exception wrapping the FormatException, so we capture the inner exception.
                    exception = ExceptionDispatchInfo.Capture(exception.InnerException).SourceException;
                }

                bindingContext.ModelState.TryAddModelError(bindingContext.ModelName, exception, bindingContext.ModelMetadata);

                // Were able to find a converter for the type but conversion failed.
                return Task.CompletedTask;
            }
        }

        protected virtual void CheckModel(
            ModelBindingContext bindingContext,
            ValueProviderResult valueProviderResult,
            object model)
        {
            // When converting newModel a null value 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 && !bindingContext.ModelMetadata.IsReferenceOrNullableType)
            {
                bindingContext.ModelState.TryAddModelError(
                    bindingContext.ModelName,
                    bindingContext.ModelMetadata.ModelBindingMessageProvider.ValueMustNotBeNullAccessor(valueProviderResult.ToString()));
            }
            else
            {
                bindingContext.Result = ModelBindingResult.Success(model);
            }
        }
    }

And within the Startup:

serviceCollection.AddMvc(options => { options.ModelBinderProviders.Insert(0, new CharModelBinderProvider()); })

Upvotes: 1

haim770
haim770

Reputation: 49095

The reason is simple, char is defined as a value-type (struct) while string is defined as a reference type (class). This means that char is non-nullable and must have a value.

That's why the DefaultModelBinder (that you're probably using) is automatically sets the validation metadata for this property as required even though you didn't add the [Required] attribute.

Here's the source for ModelMetaData.cs (line 58):

_isRequired = !TypeHelpers.TypeAllowsNullValue(modelType);

So you end up with the ModelMetaData.Required for your Character1 property set to true.

However, you can explicitly configure the DataAnnotationsModelValidatorProvider not to automatically set value-types as required using the following:

DataAnnotationsModelValidatorProvider.AddImplicitRequiredAttributeForValueTypes = false;

See MSDN

Upvotes: 2

Brett Postin
Brett Postin

Reputation: 11375

Ok I have found the offending code in System.Web.Mvc.ValueProviderResult:

private static object ConvertSimpleType(CultureInfo culture, object value, Type destinationType)
    {
      if (value == null || destinationType.IsInstanceOfType(value))
        return value;
      string str = value as string;
      if (str != null && string.IsNullOrWhiteSpace(str))
        return (object) null;
      ...
}

I'm unsure if this is a bug or not.

Upvotes: 1

Drew Williams
Drew Williams

Reputation: 637

I think its a failing of the DefaultModelBinder. If you use FormCollection in your action, the string comes back as a space.

This IModelBinder implementation shows how the default model binder is behaving, and gives a possible solution:

public class CharModelBinder : IModelBinder
{
    public object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
    {
        var dmb = new DefaultModelBinder();
        var result = dmb.BindModel(controllerContext, bindingContext);
        // ^^ result == null

        var rawValueAsChar = bindingContext.ValueProvider.GetValue(bindingContext.ModelName).ConvertTo(typeof(char));
        // ^^ rawValueAsChar == null

        var rawValueAsString = bindingContext.ValueProvider.GetValue(bindingContext.ModelName).AttemptedValue;
        if(!string.IsNullOrEmpty(rawValueAsString))
            return rawValueAsString.ToCharArray()[0];
        return null;
    }
}

Register it in your Global.asax with:

ModelBinders.Binders.Add(typeof(char), new CharModelBinder());

Upvotes: 2

Related Questions