Skip to content

Custom validator for "at least one required" #131

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
lonix1 opened this issue Oct 25, 2024 · 1 comment
Closed

Custom validator for "at least one required" #131

lonix1 opened this issue Oct 25, 2024 · 1 comment

Comments

@lonix1
Copy link
Contributor

lonix1 commented Oct 25, 2024

@haacked, @dahlbyk

I needed a custom validator that performs an "at least one required" validation. My use case was an email sending form: at least one of "To", "Cc" and "Bcc" must be supplied.

I repurposed my other custom validator for this, so it may not be the most elegant (I used the same approach for handling multiple properties) but it works on both server and client sides.

Posting my solution here in case it helps someone else (and for my own future reference! 😆).

AtLeastOneRequiredAttribute.cs

[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)]
public sealed class AtLeastOneRequiredAttribute : ValidationAttribute, IClientModelValidator
{

  // based on
  // https://github.com/haacked/aspnet-client-validation/blob/062c8433f6c696bc41cd5d6811c840905c63bc9c/README.MD#adding-custom-validation
  // https://learn.microsoft.com/en-us/aspnet/core/mvc/models/validation?view=aspnetcore-7.0#custom-client-side-validation
  // https://github.com/dotnet/runtime/blob/dff486f2d78d3f932d0f9bfa38043f85e358fb8c/src/libraries/System.ComponentModel.Annotations/src/System/ComponentModel/DataAnnotations/CompareAttribute.cs
  // https://github.com/dotnet/aspnetcore/blob/d0ca5a8d20ac50a33d5451e998a5d411a810c8d7/src/Mvc/Mvc.DataAnnotations/src/CompareAttributeAdapter.cs

  private const string _errorMessage = @"At least one of '{0}', '{1}' and '{2}' must be populated";
  public override bool RequiresValidationContext => true;
  public string PropertyName1 { get; }
  public string PropertyName2 { get; }
  public string PropertyName3 { get; }

  public AtLeastOneRequiredAttribute(string propertyName1, string propertyName2, string propertyName3) : base(_errorMessage)
  {
    ArgumentNullException.ThrowIfNullOrWhiteSpace(propertyName1, nameof(propertyName1));
    ArgumentNullException.ThrowIfNullOrWhiteSpace(propertyName2, nameof(propertyName2));
    ArgumentNullException.ThrowIfNullOrWhiteSpace(propertyName3, nameof(propertyName3));
    PropertyName1 = propertyName1;
    PropertyName2 = propertyName2;
    PropertyName3 = propertyName3;
  }

  public override string FormatErrorMessage(string name) =>
    string.Format(CultureInfo.CurrentCulture, ErrorMessageString, PropertyName1, PropertyName2, PropertyName3);

  protected override ValidationResult? IsValid(object? value, ValidationContext validationContext)
  {
    ArgumentNullException.ThrowIfNull(validationContext, nameof(validationContext));

    var property1Info = validationContext.ObjectType.GetRuntimeProperty(PropertyName1);
    var property2Info = validationContext.ObjectType.GetRuntimeProperty(PropertyName2);
    var property3Info = validationContext.ObjectType.GetRuntimeProperty(PropertyName3);

    if (property1Info == null) return new ValidationResult(string.Format("Could not find a property named {0}.", PropertyName1));
    if (property2Info == null) return new ValidationResult(string.Format("Could not find a property named {0}.", PropertyName2));
    if (property3Info == null) return new ValidationResult(string.Format("Could not find a property named {0}.", PropertyName3));
    if (property1Info.GetIndexParameters().Length > 0) throw new ArgumentException(string.Format("The property {0}.{1} could not be found.", validationContext.ObjectType.FullName, PropertyName1));
    if (property2Info.GetIndexParameters().Length > 0) throw new ArgumentException(string.Format("The property {0}.{1} could not be found.", validationContext.ObjectType.FullName, PropertyName2));
    if (property3Info.GetIndexParameters().Length > 0) throw new ArgumentException(string.Format("The property {0}.{1} could not be found.", validationContext.ObjectType.FullName, PropertyName3));

    var property1Value = property1Info.GetValue(validationContext.ObjectInstance, null);
    var property2Value = property2Info.GetValue(validationContext.ObjectInstance, null);
    var property3Value = property3Info.GetValue(validationContext.ObjectInstance, null);

    if (property1Value != null || property2Value != null || property3Value != null)
    {
      return ValidationResult.Success;
    }
    else
    {
      var errorMessage = FormatErrorMessage(validationContext.DisplayName);
      var memberNames  = new[] { PropertyName1, PropertyName2, PropertyName3 };
      return new ValidationResult(errorMessage, memberNames);
    }
  }

  public void AddValidation(ClientModelValidationContext context)
  {
    ArgumentNullException.ThrowIfNull(context, nameof(context));
    MergeAttribute(context.Attributes, "data-val",                           "true");
    MergeAttribute(context.Attributes, "data-val-atleastonerequired",        FormatErrorMessage(context.ModelMetadata.Name!));  // null forgiving: false positive as must be non-null for property
    MergeAttribute(context.Attributes, "data-val-atleastonerequired-propertyname1", $"*.{PropertyName1}");
    MergeAttribute(context.Attributes, "data-val-atleastonerequired-propertyname2", $"*.{PropertyName2}");
    MergeAttribute(context.Attributes, "data-val-atleastonerequired-propertyname3", $"*.{PropertyName3}");
    // allows other property to be nested or not, e.g. `FooModel.User.Name` and `FooModel.UserName`, respectively; https://github.com/dotnet/aspnetcore/blob/d0ca5a8d20ac50a33d5451e998a5d411a810c8d7/src/Mvc/Mvc.DataAnnotations/src/CompareAttributeAdapter.cs#L19
  }

  private static bool MergeAttribute(IDictionary<string, string> attributes, string key, string value)
  {
    if (attributes.ContainsKey(key)) return false;
    attributes.Add(key, value);
    return true;
  }

}

Add to js init scripts:

function getModelPrefix(fieldName) {                           // https://github.com/aspnet/jquery-validation-unobtrusive/blob/a5b50566f8b839177bc7733d67be3a37bca400ff/src/jquery.validate.unobtrusive.js#L44
  return fieldName.substr(0, fieldName.lastIndexOf(".") + 1);
}

function appendModelPrefix(value, prefix) {                    // https://github.com/aspnet/jquery-validation-unobtrusive/blob/a5b50566f8b839177bc7733d67be3a37bca400ff/src/jquery.validate.unobtrusive.js#L48
  return value.indexOf("*.") === 0
    ? value.replace("*.", prefix)
    : value;
}

const v = new aspnetValidation.ValidationService();

v.addProvider('atleastonerequired', (value, element, params) => {
  let prefix   = getModelPrefix(element.name);
  let name1    = appendModelPrefix(params.propertyname1, prefix);
  let name2    = appendModelPrefix(params.propertyname2, prefix);
  let name3    = appendModelPrefix(params.propertyname3, prefix);
  let element1 = Array.from(element.form.querySelectorAll('input')).filter(x => x.name === name1)[0];
  let element2 = Array.from(element.form.querySelectorAll('input')).filter(x => x.name === name2)[0];
  let element3 = Array.from(element.form.querySelectorAll('input')).filter(x => x.name === name3)[0];
  let value1   = element1.value;
  let value2   = element2.value;
  let value3   = element3.value;

  let isValid = !!value1 || !!value2 || !!value3;
  if (isValid) {
    v.removeError(element1);
    v.removeError(element2);
    v.removeError(element3);
  }
  else {
    v.addError(element1, element1.getAttribute('data-val-atleastonerequired'));
    v.addError(element2, element2.getAttribute('data-val-atleastonerequired'));
    v.addError(element3, element3.getAttribute('data-val-atleastonerequired'));
  }

  return isValid;
});

v.bootstrap();

Example:

[AtLeastOneRequired(nameof(To), nameof(Cc), nameof(Bcc))]
public string? To { get; init; }

[AtLeastOneRequired(nameof(To), nameof(Cc), nameof(Bcc))]
public string? Cc { get; init; }

[AtLeastOneRequired(nameof(To), nameof(Cc), nameof(Bcc))]
public string? Bcc { get; init; }

Notes:

  • This validator ignores the "DisplayName" attribute as the error message would be too long; it simply uses the property names "To", "Cc" and "Bcc"
  • It works for three inputs, but can easily be changed to handle two (just add another constructor)

Please close this issue once you've seen it.

@lonix1
Copy link
Contributor Author

lonix1 commented Oct 25, 2024

PS: if you enable the repo's wiki or discussions tab, I'd have posted there. We should have a place where we can collect our custom validators. It would also make the library more appealing to new users.

@lonix1 lonix1 closed this as completed Oct 25, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant