1
\$\begingroup\$

This comes from an answer I provided to a question on stackoverflow here: https://stackoverflow.com/a/71482194/3258131

To make c# record parameter validation concise and maintain all the benefits of the positional record definition I created the following solution.

The first part is using a "dummy" private validation field and some extension methods to facilitate the validations.

I created the following validation class (removed other validations for brevity):

public static class Validation
{
    public static bool IsValid<T>(this T _)
    {
        return true;
    }
    public static T NotNull<T>(T @value, [CallerArgumentExpression("value")] string? thisExpression = default)
    {
        if (value == null) throw new ArgumentNullException(thisExpression);
        return value;
    }

    public static string LengthBetween(this string @value, int min, int max, [CallerArgumentExpression("value")] string? thisExpression = default)
    {
        if (value.Length < min) throw new ArgumentOutOfRangeException(thisExpression, $"Can't have less than {min} items");
        if (value.Length > max) throw new ArgumentOutOfRangeException(thisExpression, $"Can't have more than {max} items");
        return value;
    }

    public static IComparable<T> RangeWithin<T>(this IComparable<T> @value, T min, T max, [CallerArgumentExpression("value")] string? thisExpression = default)
    {
        if (value.CompareTo(min) < 0) throw new ArgumentOutOfRangeException(thisExpression, $"Can't have less than {min} items");
        if (value.CompareTo(max) > 0) throw new ArgumentOutOfRangeException(thisExpression, $"Can't have more than {max} items");
        return value;
    }
}

Then you can use it with the following:

// FirstName may not be null and must be between 1 and 5
// LastName may be null, but when it is defined it must be between 3 and 10
// Age must be positive and below 200
record Person(string FirstName, string? LastName, int Age, Guid Id)
{
    private readonly bool _valid = Validation.NotNull(FirstName).LengthBetween(1, 5).IsValid() &&
        (LastName?.LengthBetween(2, 10).IsValid() ?? true) &&
        Age.RangeWithin(0, 200).IsValid();
        
}

The ?? true is VERY important, it is to ensure validation continues in case the nullable LastName was indeed null, otherwise it would short-circuit. Perhaps it would be better (safer) to use another static AllowNull method to wrap the entire validation of that variable in, like so:

public static class Validation
{
    public static bool IsValid<T>(this T _)
    {
        return true;
    }
    public static bool AllowNull<T>(T? _)
    {
        return true;
    }
    public static T NotNull<T>(T @value, [CallerArgumentExpression("value")] string? thisExpression = default)
    {
        if (value == null) throw new ArgumentNullException(thisExpression);
        return value;
    }

    public static string LengthBetween(this string @value, int min, int max, [CallerArgumentExpression("value")] string? thisExpression = default)
    {
        if (value.Length < min) throw new ArgumentOutOfRangeException(thisExpression, $"Can't have less than {min} items");
        if (value.Length > max) throw new ArgumentOutOfRangeException(thisExpression, $"Can't have more than {max} items");
        return value;
    }

    public static IComparable<T> RangeWithin<T>(this IComparable<T> @value, T min, T max, [CallerArgumentExpression("value")] string? thisExpression = default)
    {
        if (value.CompareTo(min) < 0) throw new ArgumentOutOfRangeException(thisExpression, $"Can't have less than {min} items");
        if (value.CompareTo(max) > 0) throw new ArgumentOutOfRangeException(thisExpression, $"Can't have more than {max} items");
        return value;
    }
}

record Person(string FirstName, string? LastName, int Age, Guid Id)
{
    private readonly bool _valid = Validation.NotNull(FirstName).LengthBetween(1, 5).IsValid() &&
        Validation.AllowNull(LastName?.LengthBetween(2, 10)) &&
        Age.RangeWithin(0, 200).IsValid();
}

Any comments would be appreciated. I am still not very happy with either of the options for handling the nullable properties, any advice?

\$\endgroup\$
2
  • \$\begingroup\$ Which C# version are you using? \$\endgroup\$ Mar 18, 2022 at 8:27
  • \$\begingroup\$ @PeterCsala I'm using C#10 \$\endgroup\$
    – Adriaan
    Mar 18, 2022 at 13:08

3 Answers 3

1
\$\begingroup\$

Let me suggest a slightly different approach

public class ValidatorBuilder<T>
{
    private List<Exception> exceptions = new();
    private List<Action<T>> validators = new();

    public ValidatorBuilder<T> NotNull(Expression<Func<T, object>> propertySelector)
    {
        validators.Add((T input) => NotNull(propertySelector, input));
        return this;
    }

    public ValidatorBuilder<T> LengthBetween(Expression<Func<T, string>> propertySelector, int min, int max)
    {
        validators.Add((T input) => LengthBetween(propertySelector, min, max, input));
        return this;
    }

    public ValidatorBuilder<T> RangeWithin<P>(Expression<Func<T, P>> propertySelector, P min, P max) where P : IComparable
    {
        validators.Add((T input) => RangeWithin(propertySelector, min, max, input));
        return this;
    }

    public bool IsValid(T input, bool shouldThrowException = false)
    {
        foreach (var validator in validators)
            validator(input);
        
        return exceptions.Count == 0 ? true
            : !shouldThrowException ? false
            : throw new AggregateException(exceptions);
    }

    private void NotNull(Expression<Func<T, object>> propertySelector, T input)
    {
        var propertyName = ((MemberExpression)propertySelector.Body).Member.Name;
        if (propertySelector.Compile()(input) == null)
            exceptions.Add(new ArgumentNullException(propertyName));
    }

    private void LengthBetween(Expression<Func<T, string>> propertySelector, int min, int max, T input)
    {
        var propertyName = ((MemberExpression)propertySelector.Body).Member.Name;
        var value = propertySelector.Compile()(input);
        if (value.Length < min) exceptions.Add(new ArgumentOutOfRangeException(propertyName, $"Can't have less than {min} items"));
        if (value.Length > max) exceptions.Add(new ArgumentOutOfRangeException(propertyName, $"Can't have more than {max} items"));
    }

    private void RangeWithin<P>(Expression<Func<T, P>> propertySelector, P min, P max, T input) where P: IComparable
    {
        var propertyName = ((MemberExpression)propertySelector.Body).Member.Name;
        var value = propertySelector.Compile()(input);
        if (value.CompareTo(min) < 0) exceptions.Add(new ArgumentOutOfRangeException(propertyName, $"Can't have less than {min} items"));
        if (value.CompareTo(max) > 0) exceptions.Add(new ArgumentOutOfRangeException(propertyName, $"Can't have more than {max} items"));
    }        
}
  • This approach follows the builder pattern (For the sake of simplicity I have combined the builder and the product. If you have more validation rules than these then I suggest to separate components)
    • The IsValid is the build method of the builder
  • The public methods allow you to register validation rules against any arbitrary property
    • The validators collection contains the registered rules
  • The private methods implements the actual validation logic
    • If the rule is violated then we add a new exception to the exceptions collection
  • The IsValid method receives the to be validated input and a flag whether or not should throw exception
    • If the flag is set then it will throw an AggregateException where the InnerExceptions collection will contain all violated rules

The usage looks like this:

record Person(string FirstName, string? LastName, int Age, Guid Id)
{
    private readonly bool _valid = new ValidatorBuilder<Person>()
        .NotNull(p => p.FirstName)
        .LengthBetween(p => p.FirstName, 1, 5)
        .RangeWithin(p => p.Age, 0, 200)
        .IsValid();
}

UPDATE #1 Fixing IsValid As it was pointed out the IsValid has been called in my sample without passing the this (I did not try out my code, I just made it compile). Due to C# limitations this can be referenced inside properties, methods, or constructors.

  • We can't use this inside a field initialiser.
  • Also we can't use this inside a read-only property with initializer method.

So, if you want to stick with the positional record then you have to do something like this:

record Person(string FirstName, string? LastName, int Age, Guid Id)
{
    private bool _valid => new ValidatorBuilder<Person>()
        .NotNull(p => p.FirstName)
        .LengthBetween(p => p.FirstName, 1, 5)
        .RangeWithin(p => p.Age, 0, 200)
        .IsValid(this, true);

    public Person(string firstName, string? lastName, int age, Guid id, object validation)
        : this(firstName, lastName, age, id)
        => _ = _valid;
}
  • _valid become a read-only property
  • There is a new ctor (an overload) which calls the positional (generated) ctor and enforces validation

The drawback of this approach is that you can not enforce the caller to call the overload

new Person("John", "Doe", 1000, Guid.NewGuid(), default);
\$\endgroup\$
12
  • 1
    \$\begingroup\$ This is great! I love the fact that you also get a list of all the failed validations. It also has a much more standard and uniform interface, very nice... now how do I "accept" this answer? \$\endgroup\$
    – Adriaan
    Mar 18, 2022 at 19:14
  • \$\begingroup\$ @Adriaan I am glad that you like it. Bellow of the score panel there is a gray tick logo. If you click on that it will become green and you have accepted the answer. :) \$\endgroup\$ Mar 18, 2022 at 21:34
  • 1
    \$\begingroup\$ I see, upvoting requires less than downvoting! \$\endgroup\$
    – Adriaan
    Mar 19, 2022 at 11:25
  • 1
    \$\begingroup\$ I just created a new implementation that's kinda a hybrid of my solution and yours. I feel it took all the best parts of your solution and simplified it quite a bit. I think the concept of having a list of validator actions that will only be run once isValid is called doesn't make a lot of sense in this context since the validation is happening before the constructor would be called. I am adding an answer to my own question with what I ended up doing. \$\endgroup\$
    – Adriaan
    Mar 19, 2022 at 15:48
  • 1
    \$\begingroup\$ Nice workaround, I couldn't think of any way around it! Not such a clean solution but not too bad considering. I created a new answer with the implementation I ended up going with, just finished it and converted a few records to using it and it's working very nicely and the syntax (and generated errors) are very clear. I can probably play a bit with the Exception messages. I was wondering, is it bad style to put the actual value of the variable that caused the exception in the message? \$\endgroup\$
    – Adriaan
    Mar 19, 2022 at 18:28
2
\$\begingroup\$

My only criticism here is the IsValid() method here. As is, it neither does actual validation nor gives a real action of consequence by calling it. Its "actual" role here it to combine predicates. The real validation happens in any of the extension methods before itself.

If you went as far to make this a NuGet package, I would say the api is lying about which functions do what.

\$\endgroup\$
1
  • \$\begingroup\$ The only reason for the IsValid() method at the end is to convert the fluent calls to a bool so it can be joined with the validations of the other variables. I named it as IsValid() because if the fluent api calls all succeeded to the point where IsValid() is actually called, the validation must have passed, so effectively it is an accurate name. I couldn't think of a better way to combine validations, if there is an improvement to be made I would appreciate help in either improving the naming or the way the predicates are combined. \$\endgroup\$
    – Adriaan
    Mar 18, 2022 at 3:21
1
\$\begingroup\$

Based on the answer provided by Peter I implemented the following validation classes:

public class PropertyValidatorBuilder<T> : ValidatorBuilder
{
    internal string? PropertyName { get; init; }
    internal T Property { get; init; }
    public PropertyValidatorBuilder(T property, [CallerArgumentExpression("property")] string? propertyName = default) : base()
    {
        Property = property;
        PropertyName = propertyName;
    }
    // For creation from other ValidatorBuilder or other PropertyValidatorBuilder
    public PropertyValidatorBuilder(List<Exception> exceptions, T property, string? propertyName) : base(exceptions)
    {
        Property = property;
        PropertyName = propertyName;
    }
    public PropertyValidatorBuilder<P> AndFor<P>(P property, [CallerArgumentExpression("property")] string? propertyName = default)
    {
        return new(_exceptions, property, propertyName);
    }
    public PropertyValidatorBuilder<T> NotNull()
    {
        if (Property == null)
            _exceptions.Add(new ArgumentNullException(PropertyName));
        return this;
    }
    public PropertyValidatorBuilder<T> IsOneOf(IEnumerable<T> options)
    {
        if (!options.Contains(Property)) _exceptions.Add(new ArgumentOutOfRangeException(PropertyName, $"Is not a valid option"));
        return this;
    }
    internal void Add(ArgumentOutOfRangeException argumentOutOfRangeException)
    {
        _exceptions.Add(argumentOutOfRangeException);
    }
}

public class ValidatorBuilder
{
    protected List<Exception> _exceptions;
    public ValidatorBuilder()
    {
        _exceptions = new();
    }
    public ValidatorBuilder(List<Exception> exceptions)
    {
        _exceptions = exceptions;
    }
    public PropertyValidatorBuilder<T> For<T>(T property, [CallerArgumentExpression("property")] string? propertyName = default)
    {
        return new(_exceptions, property, propertyName);
    }
    public ValidatorBuilder NotNull<T>(T property, [CallerArgumentExpression("property")] string? propertyName = default)
    {
        if (property == null)
            _exceptions.Add(new ArgumentNullException(propertyName));
        return this;
    }
    public ValidatorBuilder LengthBetween(string property, int min, int max, [CallerArgumentExpression("property")] string? propertyName = default) 
    {
        if (property.Length < min) _exceptions.Add(new ArgumentOutOfRangeException(propertyName, $"Can't have less than {min} items"));
        if (property.Length > max) _exceptions.Add(new ArgumentOutOfRangeException(propertyName, $"Can't have more than {max} items"));
        return this;
    }
    public ValidatorBuilder RangeWithin<P>(P property, P min, P max, [CallerArgumentExpression("property")] string? propertyName = default) where P : IComparable
    {
        if (property.CompareTo(min) < 0) _exceptions.Add(new ArgumentOutOfRangeException(propertyName, $"Can't have less than {min} items"));
        if (property.CompareTo(max) > 0) _exceptions.Add(new ArgumentOutOfRangeException(propertyName, $"Can't have more than {max} items"));
        return this;
    }
    public ValidatorBuilder IsOneOf<P>(P property, IEnumerable<P> options, [CallerArgumentExpression("property")] string? propertyName = default)
    {
        if (!options.Contains(property)) _exceptions.Add(new ArgumentOutOfRangeException(propertyName, $"Is not a valid option"));
        return this;
    }
    public ValidatorBuilder IsHex(string property, [CallerArgumentExpression("property")] string? propertyName = default)
    {
        if (string.IsNullOrEmpty(property)) throw new ArgumentOutOfRangeException(propertyName, "Hex String Empty");
        for (int i = 0; i < property.Length; i++)
        {
            if (!char.IsDigit(property.ToCharArray()[i]))
            {
                if ((property.ToCharArray()[i] < 'A') && (property.ToCharArray()[i] > 'F'))
                    _exceptions.Add(new ArgumentOutOfRangeException(propertyName, "Invalid Hex Character: " + property.ToCharArray()[i]));
            }
        }
        return this;
    }
    public bool IsValid(bool shouldThrowException = true)
    {
        return _exceptions.Count == 0 || 
            (!shouldThrowException ? false : throw new AggregateException(_exceptions));
    }
}

public static class ValidatorExtensions
{
    public static PropertyValidatorBuilder<string> LengthBetween(this PropertyValidatorBuilder<string> value, int min, int max)
    {
        if (value.Property.Length < min) value.Add(new ArgumentOutOfRangeException(value.PropertyName, $"Can't have less than {min} items"));
        if (value.Property.Length > max) value.Add(new ArgumentOutOfRangeException(value.PropertyName, $"Can't have more than {max} items"));
        return value;
    }
    public static PropertyValidatorBuilder<string> IsHex(this PropertyValidatorBuilder<string> value)
    {
        if (string.IsNullOrEmpty(value.Property)) throw new ArgumentOutOfRangeException(value.PropertyName, "Hex String Empty");
        for (int i = 0; i < value.Property.Length; i++)
        {
            if (!Char.IsDigit(value.Property.ToCharArray()[i]))
            {
                if ((value.Property.ToCharArray()[i] < 'A') && (value.Property.ToCharArray()[i] > 'F'))
                    value.Add(new ArgumentOutOfRangeException(value.PropertyName, "Invalid Hex Character: " + value.Property.ToCharArray()[i]));
            }
        }
        return value;
    }
    public static PropertyValidatorBuilder<P> RangeWithin<P>(this PropertyValidatorBuilder<P> value, P min, P max) where P : IComparable<P>
    {
        if (value.Property.CompareTo(max) > 0) value.Add(new ArgumentOutOfRangeException(value.PropertyName, $"Can't have more than {max} items"));
        return value;
    }
}

Usage:

record Person(string FirstName, string? LastName, int Age, Guid Id)
{
    private readonly bool _valid = new ValidatorBuilder()
        .For(FirstName).NotNull().LengthBetween(1, 5)
        .For(Age).RangeWithin(0, 200)
        .IsValid();
}
\$\endgroup\$
1
  • 2
    \$\begingroup\$ Welcome to Code Review! Thanks for posting this code. It's a good idea to summarise which changes you made, and why - a self-answer ought to review the code, just like any other answer. \$\endgroup\$ Mar 19, 2022 at 19:37

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.