Inspired by this question by t3chb0t and as an elaboration of my own answer, I have written the following solution. My goal was to reduce complexity both in implementation and use. Eventually - I have to admit - the implementation ended up being rather complex - but in my flavor; but in terms of ease of use, I think I succeeded. My original idea was inspired by Railway Oriented Programming, but I don't think I can claim to conform to that in the following.
The use case is as follows:
private static void ValidationTest()
{
var validator = Validator.For<Person>(ValidationStopConditions.RunAll)
.WarnIfTrue(p => p.Age > 50, "Person is older than 50")
.WarnIfFalse(p => p.Age < 50, "Person is older than 50")
.NotNull(p => p.LastName, "LastName is null")
.MustBeNull(p => p.LastName, "LastName should be null")
.IsTrue(p => p.FirstName.Length > 3, "First Name is too short")
.IsFalse(p => p.FirstName.StartsWith("Cos"), "First Name starts with Coo")
.Match(p => p.Address.Street, @"^Sesa(m|n)e Street$", "Street Name doesn't conform to the pattern");
DoTheValidation(validator, Tester);
}
private static void ValidationTestDefaultErrorMessages()
{
var validator = Validator.For<Person>(ValidationStopConditions.RunAll)
.WarnIfTrue(p => p.Age < 50, null)
.WarnIfFalse(p => p.Age < 50, null)
.NotNull(p => p.LastName, null)
.MustBeNull(p => p.LastName, null)
.IsTrue(p => p.FirstName.Length < 3, null)
.IsFalse(p => p.FirstName.StartsWith("Coo"), null)
.Match(p => p.Address.Street, @"^Sesa(m|n)e Street$", null);
DoTheValidation(validator, Tester);
}
private static void DoTheValidation<T>(Validator<T> validator, T source)
{
var result = source.ValidateWith(validator);
Console.WriteLine("The following Errors were found: ");
foreach (ValidateResult<T> failure in result.Where(r => (r as Success<T>) is null))
{
Console.WriteLine(failure);
}
}
private class Person
{
public string FirstName { get; set; }
public string LastName { get; set; }
public Address Address { get; set; }
public int Age { get; set; }
}
private class Address
{
public string Street { get; set; }
}
private static readonly Person Tester = new Person
{
FirstName = "Cookie",
LastName = "Monster",
Age = 45,
Address = new Address
{
Street = "Sesame Street"
}
};
As shown, it's possible to add validation rules in an easy fluent manner.
The ValidationStopConditions is defined as:
public enum ValidationStopConditions
{
RunAll = 1,
StopOnFailure = 2,
StopOnWarning = 3
}
and determines if all rules should be run no matter what happens or if the validation stops on first failure or warning.
The Validator class looks like:
public static class Validator
{
public static Validator<TSource> For<TSource>(ValidationStopConditions stopCondition = ValidationStopConditions.RunAll) => new Validator<TSource>(stopCondition);
}
public class Validator<T>
{
List<Func<T, ValidateResult<T>>> m_rules = new List<Func<T, ValidateResult<T>>>();
public Validator(ValidationStopConditions stopCondition)
{
StopCondition = stopCondition;
}
public ValidationStopConditions StopCondition { get; }
public IReadOnlyList<ValidateResult<T>> Validate(T source)
{
if (source == null) return Enumerable.Empty<ValidateResult<T>>().ToList();
switch (StopCondition)
{
case ValidationStopConditions.RunAll:
return m_rules.Select(rule => rule(source)).ToList();
case ValidationStopConditions.StopOnFailure:
{
List<ValidateResult<T>> results = new List<ValidateResult<T>>();
foreach (var rule in m_rules)
{
var result = rule(source);
results.Add(result);
if (result is Failure<T>)
return results;
}
return results;
}
case ValidationStopConditions.StopOnWarning:
{
List<ValidateResult<T>> results = new List<ValidateResult<T>>();
foreach (var rule in m_rules)
{
var result = rule(source);
results.Add(result);
if (result is Warning<T>)
return results;
}
return results;
}
default:
throw new InvalidOperationException($"Invalid Stop Condition: {StopCondition}");
}
}
internal void AddRule(Predicate<T> predicate, string errorMessage)
{
Func<T, ValidateResult<T>> rule = source =>
{
if (predicate(source))
return new Success<T>(source);
return new Failure<T>(source, errorMessage);
};
m_rules.Add(rule);
}
internal void AddWarning(Predicate<T> predicate, string warningMessage)
{
Func<T, ValidateResult<T>> rule = source =>
{
if (predicate(source))
return new Success<T>(source);
return new Warning<T>(source, warningMessage);
};
m_rules.Add(rule);
}
}
And the rules are defined as extension methods as:
public static class ValidationRules
{
// Helper method - not a rule
private static string GetDefaultMessage(this Expression expression, string format)
{
ValidateExpressionVisitor visitor = new ValidateExpressionVisitor();
visitor.Visit(expression);
return string.Format(format, visitor.Message);
}
public static Validator<T> NotNull<T, TMember>(this Validator<T> validator, Expression<Func<T, TMember>> expression, string errorMessage)
{
errorMessage = errorMessage ?? expression.GetDefaultMessage("{0} is null");
var getter = expression.Compile();
Predicate<T> predicate = source => getter(source) != null;
validator.AddRule(predicate, errorMessage);
return validator;
}
public static Validator<T> MustBeNull<T, TMember>(this Validator<T> validator, Expression<Func<T, TMember>> expression, string errorMessage)
{
errorMessage = errorMessage ?? expression.GetDefaultMessage("{0} is not null");
var getter = expression.Compile();
Predicate<T> predicate = source => getter(source) == null;
validator.AddRule(predicate, errorMessage);
return validator;
}
public static Validator<T> IsTrue<T>(this Validator<T> validator, Expression<Predicate<T>> predicate, string errorMessage)
{
errorMessage = errorMessage ?? predicate.GetDefaultMessage("{0} is not true");
validator.AddRule(predicate.Compile(), errorMessage);
return validator;
}
public static Validator<T> WarnIfTrue<T>(this Validator<T> validator, Expression<Predicate<T>> predicate, string message)
{
message = message ?? predicate.GetDefaultMessage("{0} is true");
validator.AddWarning(src => !predicate.Compile()(src), message);
return validator;
}
public static Validator<T> IsFalse<T>(this Validator<T> validator, Expression<Predicate<T>> predicate, string errorMessage)
{
errorMessage = errorMessage ?? predicate.GetDefaultMessage("{0} is not false");
validator.AddRule(src => !predicate.Compile()(src), errorMessage);
return validator;
}
public static Validator<T> WarnIfFalse<T>(this Validator<T> validator, Expression<Predicate<T>> predicate, string message)
{
message = message ?? predicate.GetDefaultMessage("{0} is false");
validator.AddWarning(predicate.Compile(), message);
return validator;
}
public static Validator<T> Match<T>(this Validator<T> validator, Expression<Func<T, string>> expression, string pattern, string errorMessage)
{
errorMessage = errorMessage ?? $@"{expression.GetDefaultMessage("")} doesn't match pattern: ""{pattern}""";
var getter = expression.Compile();
Predicate<T> predicate = source => Regex.IsMatch(getter(source), pattern);
validator.AddRule(predicate, errorMessage);
return validator;
}
}
New rules can easily be added when needed.
The result of each validation can either be Success<T>, Warning<T> or Failure<T>:
public abstract class ValidateResult<T>
{
public ValidateResult(T source)
{
Source = source;
}
public T Source { get; }
}
public class Success<T> : ValidateResult<T>
{
public Success(T source) : base(source)
{
}
public override string ToString()
{
return "Everything is OK";
}
}
public class Failure<T> : ValidateResult<T>
{
public Failure(T source, string message) : base(source)
{
Message = message;
}
public string Message { get; }
public override string ToString()
{
return $"Error: {Message}";
}
}
public class Warning<T> : ValidateResult<T>
{
public Warning(T source, string message) : base(source)
{
Message = message;
}
public string Message { get; }
public override string ToString()
{
return $"Warning: {Message}";
}
}
The message member of Warning and Failure will be either the provided message argument to the rule or an auto generated default.
A convenient api:
public static class ValidationExtensions
{
public static IReadOnlyList<ValidateResult<T>> ValidateWith<T>(this T source, Validator<T> validator)
{
if (source == null) throw new ArgumentNullException(nameof(source));
if (validator == null) throw new ArgumentNullException(nameof(validator));
return validator.Validate(source);
}
}
The default error/warning messages are found using a simple ExpressionVisitor:
internal class ValidateExpressionVisitor : ExpressionVisitor
{
public ValidateExpressionVisitor()
{
}
public string Message { get; private set; }
protected override Expression VisitLambda<T>(Expression<T> node)
{
Message = node.Body.ToString();
return base.VisitLambda(node);
}
}
This is very basic, and is intended only for test, development and debugging.
Any comments are welcome.
StopOnWarningencounters an error? Currently it continues. That's not what I would have expected. \$\endgroup\$