commodus / 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.

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool



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.


🔥⚔️ Validot vs FluentValidation ⚔️🔥

📜 Blogged: Validot's performance explained


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")
        .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

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 ~2.5x faster while consuming ~3.5x less memory. Especially when it comes to memory consumption, Validot is usually far, far better than that. Of course it depends on the use case, but it might be even ~25x more efficient comparing to FluentValidation, like in IsValid test using 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 764.82 768.00
Validate ManyErrors Validot 375.13 180.73
FailFast ManyErrors FluentValidation 19.44 26.89
FailFast ManyErrors Validot 14.92 32.07
Validate HalfErrors FluentValidation 624.39 674.39
Validate HalfErrors Validot 285.48 81.40
FailFast HalfErrors FluentValidation 532.18 518.34
FailFast HalfErrors Validot 181.00 62.48
Validate NoErrors FluentValidation 664.57 658.14
Validate NoErrors Validot 234.33 75.10

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 22.96 26.89
IsValid ManyErrors Validot 5.66 6.43
IsValid HalfErrors FluentValidation 509.72 518.34
IsValid HalfErrors Validot 82.76 19.87
IsValid NoErrors FluentValidation 667.80 658.14
IsValid NoErrors Validot 122.61 23.78
  • 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 727.00 779.48
Reporting ManyErrors Validot 448.10 301.43
Reporting HalfErrors FluentValidation 651.20 675.01
Reporting HalfErrors Validot 289.30 76.60
  • 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 2.0.0, FluentValidation 9.4.0, .NET 5.0.2, i7-9750H (2.60GHz, 1 CPU, 12 logical and 6 physical cores), X64 RyuJIT, macOS Big Sur.

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:

  • Full integration with ASP.NET or other frameworks:
  • Access to any stateful context in the rule condition predicate:
    • It implicates a lack of support for dynamic message content and/or amount.
  • 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.
  • await/async support
  • severities (more details on GitHub Issues)
    • workaround: multiple validators for error groups with different severities.

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 current and all the supported LTS versions of the underlying frameworks:

  • .NET 5.0
  • .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

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.

License:MIT License


Languages

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