bartoszlenar / Validot

Validot is a performance-first, compact library for advanced model validation. Using a simple declarative fluent interface, it efficiently handles classes, structs, nested members, collections, nullables, plus any relation or combination of them. It also supports translations, custom logic extensions with tests, and DI containers.
MIT License
310 stars 19 forks source link
fluent-api model-validation specification validation validation-library validator



Validot

Validot is a performance-first, compact library for advanced model validation. Using a simple declarative fluent interface, it efficiently handles classes, structs, nested members, collections, nullables, plus any relation or combination of them. It also supports translations, custom logic extensions with tests, and DI containers.


Quickstart | Features | Project info | Documentation | Changelog

🔥⚔️ Validot vs FluentValidation ⚔️🔥

📜 Blogged: Validot's performance explained
📜 Blogged: Crafting model specifications using Validot

Built with 🤘🏻by Bartosz Lenar

</br>

Announcement! Version 3.0 is coming!

Brief update on the current state of the project:

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")
        .And()
        .MaxLength(100)
    )
    .Member(m => m.Name, m => m
        .Optional()
        .And()
        .LengthBetween(8, 100)
        .And()
        .Rule(name => name.All(char.IsLetterOrDigit))
        .WithMessage("Must contain only letter or digits")
    )
    .And()
    .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 an 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()
    .And()
    .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")
    .And()
    .Member(m => m.PrimaryEmail, emailSpecification)
    .And()
    .Member(m => m.AlternativeEmails, m => m
        .Optional()
        .And()
        .MaxCollectionSize(3)
        .WithMessage("Must not contain more than 3 addresses")
        .And()
        .AsCollection(emailSpecification)
    )
    .And()
    .Rule(user => {

        return user.PrimaryEmail is 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

At the moment Validot delivers the following translations out of the box: Polish, Spanish, Russian, Portuguese and German.

Dependency injection

Although Validot doesn't contain direct support for the dependency injection containers (because it aims to rely solely on the .NET Standard 2.0), it includes helpers that can be used with any DI/IoC system.

For example, if you're working with ASP.NET Core and looking for an easy way to register all of your validators with a single call (something like services.AddValidators()), wrap your specifications in the specification holders, and use the following snippet:

public void ConfigureServices(IServiceCollection services)
{
    // ... registering other dependencies ...

    // Registering Validot's validators from the current domain's loaded assemblies
    var holderAssemblies = AppDomain.CurrentDomain.GetAssemblies();
    var holders = Validator.Factory.FetchHolders(holderAssemblies)
        .GroupBy(h => h.SpecifiedType)
        .Select(s => new
        {
            ValidatorType = s.First().ValidatorType,
            ValidatorInstance = s.First().CreateValidator()
        });
    foreach (var holder in holders)
    {
        services.AddSingleton(holder.ValidatorType, holder.ValidatorInstance);
    }

    // ... registering other dependencies ...
}

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 (circa 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 ~1.6x faster while consuming ~4.7x less memory (in the most representational, Validate tests using HalfErrors data set). Especially when it comes to memory consumption, Validot could be even 13.3x better than FluentValidation (IsValid tests with HalfErrors data set) . What's the secret? Read my blog post: Validot's performance explained.

Test Data set Library Mean [ms] Allocated [MB]
Validate ManyErrors FluentValidation 703.83 453
Validate ManyErrors Validot 307.04 173
FailFast ManyErrors FluentValidation 21.63 21
FailFast ManyErrors Validot 16.76 32
Validate HalfErrors FluentValidation 563.92 362
Validate HalfErrors Validot 271.62 81
FailFast HalfErrors FluentValidation 374.90 249
FailFast HalfErrors Validot 173.41 62
Validate NoErrors FluentValidation 559.77 354
Validate NoErrors Validot 260.99 75

FluentValidation's IsValid is a property that wraps a simple check whether the validation result contains errors or not. Validot has AnyErrors that acts 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 20.91 21
IsValid ManyErrors Validot 8.21 6
IsValid HalfErrors FluentValidation 367.59 249
IsValid HalfErrors Validot 106.77 20
IsValid NoErrors FluentValidation 513.12 354
IsValid NoErrors Validot 136.22 24

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 768.00 464
Reporting ManyErrors Validot 379.50 294
Reporting HalfErrors FluentValidation 592.50 363
Reporting HalfErrors Validot 294.60 76

Benchmarks environment: Validot 2.3.0, FluentValidation 11.2.0, .NET 6.0.7, i7-9750H (2.60GHz, 1 CPU, 12 logical and 6 physical cores), X64 RyuJIT, macOS Monterey.

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.Stop), 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:

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" version of the following operating systems:

using the upcoming, the current and all also the supported LTS versions of the underlying frameworks:

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.