Jossean Yamil
Jossean Yamil

Reputation: 1070

EF Core - How to avoid a custom RequiredAttribute to set a database column to be not nullable

In my ASP.NET Core web application, I have classes with some fields that are required if another field has a specific value. For example, I have a class Person with fields regarding employment, such as job title, employer name, and work starting date. These fields are required only if the enum field Person.EmployementStatus is equal to Employed. In order to do this, I created my own RequiredIfAttribute where it will set the selected property to be invalid if it has a default value and the parent property is equal to a conditional value. In the case of the Person class, the fields JobTitle, Employer, and WorkStartingDate have the [RequiredIf] attribute where the parent property is EmployementStatus and the conditional value is Employed. Here's the person model class:

[DisplayColumn(nameof(PersonName))]
public class Person
{
    [Key]
    [Display(Name = "ID")]
    public int PersonId { get; set; }

    [Required]
    [StringLength(128)]
    [Display(Name = "Person Name", ShortName = "Name")]
    public string PersonName { get; set; }

    [Required]
    [Display(Name = "Employment Status")]
    public PersonEmploymentStatus EmploymentStatus { get; set; }

    //  This field is required if employment status equals employed
    [RequiredIf(nameof(EmploymentStatus), PersonEmploymentStatus.Employed)]
    [Display(Name = "Job Title")]
    [StringLength(128)]
    public string JobTitle { get; set; }

    //  This field is required if employment status equals employed
    [RequiredIf(nameof(EmploymentStatus), PersonEmploymentStatus.Employed)]
    [Display(Name = "Employer")]
    [StringLength(128)]
    public string Employer { get; set; }

    //  This field is required if employment status equals employed
    [RequiredIf(nameof(EmploymentStatus), PersonEmploymentStatus.Employed)]
    [Display(Name = "Work Starting Date")]
    public DateTime? WorkStartingDate { get; set; }
}

Here's the definition of the RequiredIfAttribute:

using System;
using System.ComponentModel.DataAnnotations;
using System.Reflection;

/// <summary>
/// Required attribute that depends on a specific value of another property in the same instance
/// </summary>
[AttributeUsage(AttributeTargets.Property)]
public class RequiredIfAttribute : RequiredAttribute
{
    //  Value of the property that will make the selected property to be required
    private object _propertyConditionalValue;

    /// <summary>
    /// Name of the property to check its value
    /// </summary>
    public string ParentPropertyName { get; set; }

    /// <summary>
    /// Initializes attribute with the parent property and value to check if the selected property is populated or not
    /// </summary>
    /// <param name="propertyName">Name of the parent property</param>
    /// <param name="propertyConditionalValue">Value to check if the parent property is equal to this</param>
    public RequiredIfAttribute(string propertyName, object propertyConditionalValue) =>
        (ParentPropertyName, _propertyConditionalValue) = (propertyName, propertyConditionalValue);

    /// <inheritdoc />
    protected override ValidationResult IsValid(object value, ValidationContext context)
    {
        //  Get the parent property
        PropertyInfo parentProp = context.ObjectType.GetProperty(ParentPropertyName);

        //  Get the value of the parent property
        object parentPropertyValue = parentProp?.GetValue(context.ObjectInstance);

        //  Check if the value of the parent property is equal to the conditional value that will require
        //  the selected property to be populated, and if the selected property is not populated, then return invalid result
        if (_propertyConditionalValue.Equals(parentPropertyValue) && value == default)
        {
            //  Display name of the parent property
            string parentPropDisplayName = parentProp.Name;

            //  Try to get the display attribute from the parent property, if it has any
            DisplayAttribute displayAttribute = parentProp.GetCustomAttribute<DisplayAttribute>();

            if (displayAttribute != null)
            {
                //  Use the name from the display attribute instead
                parentPropDisplayName = displayAttribute.Name ?? displayAttribute.ShortName ?? parentPropDisplayName;
            }

            //  Calculate error message
            string errorMessage = $"When {parentPropDisplayName} is {_propertyConditionalValue}, {context.DisplayName} is required.";

            //  Return invalid result
            return new ValidationResult(errorMessage);
        }

        //  Otherwise, return a valid result
        return ValidationResult.Success;
    }
}

This works on the ASP.NET Core web application. If the user selects "Employed" in the form and leaves the rest of the fields empty, an error message displays in the UI like "When Employment Status is Employed, Job Title is required".

Create person screenshot

However, these fields should be nullable in the database. In case the user in unemployed or self-employed, fields like the employer should have a null value in the database. The problem is that when I add a migration using the Add-Migration PowerShell script, it sets those fields as non-nullables. This is what the migration looks like:

...
            migrationBuilder.CreateTable(
                name: "Person",
                columns: table => new
                {
                    PersonId = table.Column<int>(nullable: false)
                        .Annotation("SqlServer:Identity", "1, 1"),
                    PersonName = table.Column<string>(maxLength: 128, nullable: false),
                    EmploymentStatus = table.Column<byte>(nullable: false),
                    JobTitle = table.Column<string>(maxLength: 128, nullable: false),
                    Employer = table.Column<string>(maxLength: 128, nullable: false),
                    WorkStartingDate = table.Column<DateTime>(nullable: false)
                },
                constraints: table =>
                {
                    table.PrimaryKey("PK_Person", x => x.PersonId);
                });
...

Fields like the Job Title have the parameter "nullable" equal to false when I need them to be equal to true JobTitle = table.Column<string>(maxLength: 128, nullable: false). This makes the application crash with a SqlException when the fields are null and the employment status is unemployed or self-employed. It says "Cannot insert the value NULL into column 'Employer', table 'RequiredCascadingAttributeTestContext-ef4bfd77-387d-4cb1-b197-58f1999c04c7.dbo.Person'; column does not allow nulls. INSERT fails. The statement has been terminated."

I know I can just change the code of the migration, but I have a lot of fields that are using the custom [RequiredIf] attribute. And every time I add a new migration, a bunch of Alter column statements show up to make the fields non-nullables. So, How can I make EF Core avoid setting fields with the [RequiredIf] attribute to non-nullables in the migrations? I can't really find a way to accomplish this.

Thanks.

Upvotes: 0

Views: 1035

Answers (1)

Andrej Dobeš
Andrej Dobeš

Reputation: 253

With RequiredIfAttribute you need to implement ValidationAttribute rather than RequiredAtrribute since for EF that has already some behaviour which you do not want to use (in this case setting the field as non-nullable).

Therefore it should look like

public class RequiredIfAttribute : ValidationAttribute

Validations are being called before saving changes to actual database so it is possible to check it there, which you're already doing in your code

Upvotes: 1

Related Questions