JeremySkinner / Validot

Tiny lib for advanced model validation. With performance in mind.

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool



Validot

Tiny lib for advanced model validation. With performance in mind.


🔥⚔️ Validot vs FluentValidation ⚔️🔥


Built with 🤘🏻by Bartosz Lenar

Quickstart

Add the Validot nuget package to your project using dotnet CLI:

dotnet add package Validot

All the features are accessible after referencing single namespace:

using Validot;

And you're good to go! At first, create a specification for your model with the fluent api.

Specification<UserModel> specification = _ => _
    .Member(m => m.Email, m => m
        .Email().WithExtraCode("ERR_EMAIL")
        .MaxLength(100)
    )
    .Member(m => m.Name, m => m
        .Optional()
        .LengthBetween(8, 100)
        .Rule(name => name.All(char.IsLetterOrDigit)).WithMessage("Must contain only letter or digits")
    )
    .Rule(m => m.Age >= 18 || m.Name != null)
        .WithPath("Name")
        .WithMessage("Required for underaged user")
        .WithExtraCode("ERR_NAME");

The next step is to create a validator. As its name stands - it validates objects according to the specification. It's also thread-safe so you can seamlessly register it as a singleton in your DI container.

var validator = Validator.Factory.Create(specification);

Validate the object!

var model = new UserModel(email: "inv@lidv@lue", age: 14);

var result = validator.Validate(model);

The result object contains all information about the errors. Without retriggering the validation process, you can extract the desired form of output.

result.AnyErrors; // bool flag:
// true

result.MessageMap["Email"] // collection of messages for "Email":
// [ "Must be a valid email address" ]

result.Codes; // collection of all the codes from the model:
// [ "ERR_EMAIL", "ERR_NAME" ]

result.ToString(); // compact printing of codes and messages:
// ERR_EMAIL, ERR_NAME
//
// Email: Must be a valid email address
// Name: Required for underaged user

Features

Advanced fluent API, inline

No more obligatory if-ology around input models or separate classes wrapping just validation logic. Write specifications inline with simple, human-readable fluent API. Native support for properties and fields, structs and classes, nullables, collections, nested members, and possible combinations.

Specification<string> nameSpecification = s => s
    .LengthBetween(5, 50)
    .SingleLine()
    .Rule(name => name.All(char.IsLetterOrDigit));

Specification<string> emailSpecification = s => s
    .Email()
    .Rule(email => email.All(char.IsLower)).WithMessage("Must contain only lower case characters");

Specification<UserModel> userSpecification = s => s
    .Member(m => m.Name, nameSpecification).WithMessage("Must comply with name rules")
    .Member(m => m.PrimaryEmail, emailSpecification)
    .Member(m => m.AlternativeEmails, m => m
        .Optional()
        .MaxCollectionSize(3).WithMessage("Must not contain more than 3 addresses")
        .AsCollection(emailSpecification)
    )
    .Rule(user => {

        return user.PrimaryEmail == null || user.AlternativeEmails?.Contains(user.PrimaryEmail) == false;

    }).WithMessage("Alternative emails must not contain the primary email address");

Validators

Compact, highly optimized, and thread-safe objects to handle the validation.

Specification<BookModel> bookSpecification = s => s
    .Optional()
    .Member(m => m.AuthorEmail, m => m.Optional().Email())
    .Member(m => m.Title, m => m.NotEmpty().LengthBetween(1, 100))
    .Member(m => m.Price, m => m.NonNegative());

var bookValidator =  Validator.Factory.Create(bookSpecification);

services.AddSingleton<IValidator<BookModel>>(bookValidator);
var bookModel = new BookModel() { AuthorEmail = "inv@lid_em@il", Price = 10 };

bookValidator.IsValid(bookModel);
// false

bookValidator.Validate(bookModel).ToString();
// AuthorEmail: Must be a valid email address
// Title: Required

bookValidator.Validate(bookModel, failFast: true).ToString();
// AuthorEmail: Must be a valid email address

bookValidator.Template.ToString(); // Template contains all of the possible errors:
// AuthorEmail: Must be a valid email address
// Title: Required
// Title: Must not be empty
// Title: Must be between 1 and 100 characters in length
// Price: Must not be negative

Results

Whatever you want. Error flag, compact list of codes, or detailed maps of messages and codes. With sugar on top: friendly ToString() printing that contains everything, nicely formatted.

var validationResult = validator.Validate(signUpModel);

if (validationResult.AnyErrors)
{
    // check if a specific code has been recorded for Email property:
    if (validationResult.CodeMap["Email"].Contains("DOMAIN_BANNED"))
    {
        _actions.NotifyAboutDomainBanned(signUpModel.Email);
    }

    var errorsPrinting = validationResult.ToString();

    // save all messages and codes printing into the logs
    _logger.LogError("Errors in incoming SignUpModel: {errors}", errorsPrinting);

    // return all error codes to the frontend
    return new SignUpActionResult
    {
        Success = false,
        ErrorCodes = validationResult.Codes,
    };
}

Rules

Tons of rules available out of the box. Plus, an easy way to define your own with the full support of Validot internal features like formattable message arguments.

public static IRuleOut<string> ExactLinesCount(this IRuleIn<string> @this, int count)
{
    return @this.RuleTemplate(
        value => value.Split(Environment.NewLine).Length == count,
        "Must contain exactly {count} lines",
        Arg.Number("count", count)
    );
}
.ExactLinesCount(4)
// Must contain exactly 4 lines

.ExactLinesCount(4).WithMessage("Required lines count: {count}")
// Required lines count: 4

.ExactLinesCount(4).WithMessage("Required lines count: {count|format=000.00|culture=pl-PL}")
// Required lines count: 004,00

Translations

Pass errors directly to the end-users in the language of your application.

Specification<UserModel> specification = s => s
    .Member(m => m.PrimaryEmail, m => m.Email())
    .Member(m => m.Name, m => m.LengthBetween(3, 50));

var validator =  Validator.Factory.Create(specification, settings => settings.WithPolishTranslation());

var model = new UserModel() { PrimaryEmail = "in@lid_em@il", Name = "X" };

var result = validator.Validate(model);

result.ToString();
// Email: Must be a valid email address
// Name: Must be between 3 and 50 characters in length

result.ToString(translationName: "Polish");
// Email: Musi być poprawnym adresem email
// Name: Musi być długości pomiędzy 3 a 50 znaków

Validot vs FluentValidation

A short statement to start with - @JeremySkinner's FluentValidation is an excellent piece of work and has been a huge inspiration for this project. True, you can call Validot a direct competitor, but it differs in some fundamental decisions, and lot of attention has been focused on entirely different aspects. If - after reading this section - you think you can bear another approach, api and limitations, at least give Validot a try. You might be positively surprised. Otherwise, FluentValidation is a good, safe choice, as Validot is certainly less hackable, and achieving some particular goals might be either difficult or impossible.

Validot is faster and consumes less memory

This document shows oversimplified results of BenchmarkDotNet execution, but the intention is to present the general trend only. To have truly reliable numbers, I highly encourage you to run the benchmarks yourself.

There are three data sets, 10k models each; ManyErrors (every model has many errors), HalfErrors (around 60% have errors, the rest are valid), NoErrors (all are valid) and the rules reflect each other as much as technically possible. I did my best to make sure that the tests are just and adequate, but I'm a human being and I make mistakes. Really, if you spot errors in the code, framework usage, applied methodology... or if you can provide any counterexample proving that Validot struggles with some particular scenarios - I'd be very very very happy to accept a PR and/or discuss it on GitHub Issues.

To the point; the statement in the header is true, but it doesn't come for free. Wherever possible and justified, Validot chooses performance and less allocations over flexibility and extra features. Fine with that kind of trade-off? Good, because the validation process in Validot might be ~2.5x faster while consuming ~3.5x less memory. Especially when it comes to memory consumption, Validot is usually far, far better than that (depending on the use case it might be even ~15x more efficient comparing to FluentValidation):

Test Data set Library Mean [ms] Allocated [MB]
Validate ManyErrors FluentValidation 747.66 686.80
Validate ManyErrors Validot 321.00 183.19
FailFast ManyErrors FluentValidation 748.11 686.80
FailFast ManyErrors Validot 14.20 31.9
Validate HalfErrors FluentValidation 658.10 684.60
Validate HalfErrors Validot 273.51 85.10
FailFast HalfErrors FluentValidation 666.12 684.60
FailFast HalfErrors Validot 185.19 64.96

FluentValidation's IsValid is a property that wraps a simple check whether the validation result contains errors or not. Validot has AnyErrors that act exactly the same way, and IsValid is a special mode that doesn't care about anything else but the first rule predicate that fails. If the mission is only to verify the incoming model whether it complies with the rules (discarding all of the details), this approach proves to be better up to one order of magnitude:

Test Data set Library Mean [ms] Allocated [MB]
IsValid ManyErrors FluentValidation 750.33 686.80
IsValid ManyErrors Validot 14.43 31.21
IsValid HalfErrors FluentValidation 647.11 684.60
IsValid HalfErrors Validot 181.90 64.57
IsValid NoErrors FluentValidation 652.64 668.51
IsValid NoErrors Validot 266.63 78.82
  • IsValid benchmark - objects are validated, but only to check if they are valid or not.

Combining these two methods in most cases could be quite beneficial. At first, IsValid quickly verifies the object, and if it contains errors - only then Validate is executed to report the details. Of course in some extreme cases (megabyte-size data? millions of items in the collection? dozens of nested levels with loops in reference graphs?) traversing through the object twice could neglect the profit. Still, for the regular web api input validation, it will undoubtedly serve its purpose:

if (!validator.IsValid(model))
{
    errorMessages = validator.Validate(model).ToString();
}
Test Data set Library Mean [ms] Allocated [MB]
Reporting ManyErrors FluentValidation 753.50 721.01
Reporting ManyErrors Validot 419.60 335.99
Reporting HalfErrors FluentValidation 651.90 685.22
Reporting HalfErrors Validot 364.80 123.74
  • Reporting benchmark:
    • FluentValidation validates model, and ToString() is called if errors are detected.
    • Validot processes the model twice - at first, with its special mode, IsValid. Secondly - in case of errors detected - with the standard method, gathering all errors and printing them with ToString().

Benchmarks environment: Validot 1.0.0, FluentValidation 8.6.2, .NET Core 3.1.4, i7-9750H (2.60GHz, 1 CPU, 12 logical and 6 physical cores), X64 RyuJIT, macOS Catalina.

Validot handles nulls on its own

In Validot, null is a special case handled by the core engine. You don't need to secure the validation logic from null as your predicate will never receive it.

Member(m => m.LastName, m => m
    .Rule(lastName => lastName.Length < 50) // 'lastName' is never null
    .Rule(lastName => lastName.All(char.IsLetter)) // 'lastName' is never null
)

Validot treats null as an error by default

All values are marked as required by default. In the above example, if LastName member were null, the validation process would exit LastName scope immediately only with this single error message:

LastName: Required

The content of the message is, of course, customizable).

If null should be allowed, place Optional command at the beginning:

Member(m => m.LastName, m => m
    .Optional()
    .Rule(lastName => lastName.Length < 50) // 'lastName' is never null
    .Rule(lastName => lastName.All(char.IsLetter)) // 'lastName' is never null
)

Again, no rule predicate is triggered. Also, null LastName member doesn't result with errors.

Validot's Validator is immutable

Once validator instance is created, you can't modify its internal state or settings. If you need the process to fail fast (FluentValidation's CascadeMode.StopOnFirstFailure), use the flag:

validator.Validate(model, failFast: true);

FluentValidation's features that Validot is missing

Features that might be in the scope and are technically possible to implement in the future:

Features that are very unlikely to be in the scope as they contradict the project's principles, and/or would have a very negative impact on performance, and/or are impossible to implement:

  • Access to any stateful context in the rule condition predicate:
    • It implicates a lack of support for dynamic message content and/or amount.
  • Integration with ASP.NET or other frameworks:
    • Making such a thing wouldn't be a difficult task at all, but Validot tries to remain a single-purpose library, and all integrations need to be done individually
  • Callbacks:
  • Pre-validation:
    • All cases can be handled by additional validation and a proper if-else.
    • Also, the problem of the root being null doesn't exist in Validot (it's a regular case, covered entirely with fluent api)
  • Rule sets
    • workaround; multiple validators for different parts of the object.

Project info

Requirements

Validot is a dotnet class library targeting .NET Standard 2.0. There are no extra dependencies.

Please check the official Microsoft document that lists all the platforms that can use it on.

Versioning

Semantic versioning is being used very strictly. The major version is updated only when there is a breaking change, no matter how small it might be (e.g., adding extra method to the public interface). On the other hand, a huge pack of new features will bump the minor version only.

Before every major version update, at least one preview version is published.

Reliability

Unit tests coverage hits 100% very close, and it can be detaily verified on codecov.io.

Before publishing, each release is tested on the latest versions of operating systems:

  • macOS
  • Ubuntu
  • Windows Server

using the LTS versions of the underlying frameworks:

  • .NET Core 3.1
  • .NET Core 2.1
  • .NET Framework 4.8 (Windows only)

Performance

Benchmarks exist in the form of the console app project based on BenchmarkDotNet. Also, you can trigger performance tests from the build script.

Documentation

The documentation is hosted alongside the source code, in the git repository, as a single markdown file: DOCUMENTATION.md.

Code examples from the documentation live as functional tests.

Development

The entire project (source code, issue tracker, documentation, and CI workflows) is hosted here on github.com.

Any contribution is more than welcome. If you'd like to help, please don't forget to check out the CONTRIBUTING file and issues page.

Licencing

Validot uses the MIT license. Long story short; you are more than welcome to use it anywhere you like, completely free of charge and without oppressive obligations.

About

Tiny lib for advanced model validation. With performance in mind.

License:MIT License


Languages

Language:C# 99.7%Language:PowerShell 0.2%Language:Shell 0.1%Language:Dockerfile 0.0%Language:Batchfile 0.0%