marcosh / php-validation-dsl
A DSL for validating data in a functional fashion
Installs: 3 867
Dependents: 0
Suggesters: 0
Security: 0
Stars: 48
Watchers: 4
Forks: 2
Open Issues: 3
Requires
- php: ^7.4|^8.0
- webmozart/assert: ^1.10
Requires (Dev)
- ext-json: *
- kahlan/kahlan: ^5.1
- maglnet/composer-require-checker: ^3.3
- squizlabs/php_codesniffer: ^3.6
- vimeo/psalm: ^4.10|dev-master
This package is auto-updated.
Last update: 2024-10-17 21:51:28 UTC
README
A library for validating generic data in a functional fashion.
Basic idea
The idea is pretty simple. All goes around the following interface
interface Validation { public function validate($data): ValidationResult; }
where some $data
comes in and a ValidationResult
comes out.
A ValidationResult
is a sum type which could be either valid, containing some
valid $data
, or invalid, containing some error messages.
This means that a validation could either succeed, and it that case you have the valid result at your disposition, or if fails, and you have the error messages to handle.
Immutability
Everything is immutable, so once you have created a validator you can not modify it, you can just create a new one.
On the other hand, immutability implies statelessness, and therefore you can reuse safely the same validator multiple times with different data.
Compositionality
The library provides two different mechanisms, which could be combined, which allow creating complex validators just putting simple ones together.
It goes without saying that you can create new validators to be used together with the existing ones.
Basic validators
The library provides several basic validators to validate native data
structures. They all implement the Validation
interface described above.
Context
How can you use a runtime value to validate some data, while you are creating
your validators at build time? Well, that's what the context
is there for.
Let's provide a motivating example for this: suppose we want to write a validator to check whether we are violating a uniqueness condition while updating a member in a collection. We will need to check it the data already exist in our collection, excluding the record we are currently updating. So we will need, beyond the data themselves, an identifier of the record which we are currently updating. We can pass this information in the context. Then the validator could look like
class CheckDuplicateExceptCurrentRecord { private $recordRepository; public function __construct($recordRepository) { $this->recordRepository = $recordRepository; } public function validate($data, $context) { if ($recordRepository->containsDataExcludingRecord($data, $context['id'])) { return ValidationResult::errors(['DUPLICATE RECORD']); } return ValidationResult::valid($data); } }
Then we can use our validator as follows
$validator = new CheckDuplicateExceptCurrentRecord($recordRepository); $validator->validate($data, ['id' => $currentRecordId]);
Custom error formatters
The library itself does not want to impose how error messages should be structured and formatted. Therefore it allows the user to define his own error messages and his own error messages structure.
To do this every validator included in the library may be built using a custom error formatter.
The error formatter is nothing else but a callable which receives as input all the data known by the validator. Often this arguments will be just the data which need to be validated, but sometimes the error formatter could receive also the configuration parameters of the validator.
For example, the IsInstanceOf
validator has a named constructor
public static function withClassNameAndFormatter( string $className, callable $errorFormatter ):
where the $errorFormatter
needs to be a callable receiving as parameters the
$className
and the validation $data
. For example we could use it as follows
$myValidator = IsInstanceOf::withClassNameAndFormatter( Foo::class, function ($className, $data) { return [ sprintf( 'The data %s is not an instance of %s', json_encode($data), $className ) ]; } );
Translators
One specific usage of custom error formatters it translating the error messages.
Every validator has also a named constructor receiving a Translator
to
translate the library-defined error messages.
If you don't want to specify the translator for every single validator you
define, there is also a TranslateErrors
combinators which translates all the
strings present in the return message.
Creating more complex validators
We mentioned above that compositionality is one one the main ideas beyond this library. In fact, we provide not one but two mechanisms to create complex validators starting from basic ones:
- using combinators; a combinator is simply a function which creates a new validator starting from simpler ones in a very well defined way. The combinators this library provides are
- using the
lift
, thesdo
and thefdo
functions provided in Result/functions.php to mimic applicative and monadic validation used in functional programming.
In the following sections we provide examples on how to use these two approaches.
Using combinators
Suppose you want to validate some data with the following format
[ 'name' => ... // non empty string 'age' => ... // non-negative integer ]
To describe what we would like to check in plain text, we need to verify that:
- the data we receive are an array
- we have a
name
field and it should be a non-empty string - we have an
age
field and it should be a positive integer
The validator which does this check should look like:
Sequence::validations([ new IsArray(), All::validations([ Sequence::validations([ HasKey::withKey('name'), Focus::on( function ($data) { return $data['name']; }, Sequence::validations([ new IsString(), new NonEmpty() ]) ) ]), Sequence::validations([ HasKey::withKey('age'), Focus::on( function ($data) { return $data['age']; }, Sequence::validations([ new IsInteger(), IsGreaterThan::withBound(0) ]) ) ]) ]) ]);
Let's go through it step by step to understand every single piece.
We start with a Sequence
validator. Its semantic is that it is going to
perform a series of validation sequentially, one after the other, returning an
error as soon as one fails.
In our case we start by checking that our data are an array, with the IsArray
validator. Once we know we have an array, we can check if the two required
fields exist. We can do these two operations independently, and, if they both
fail, we want both the error messages. We use the All
validator exactly for
this, to say that all the listed conditions need to be verified and that we
want all the error messages.
At this point we have the validations for name
and the validations for age
.
For the former, we first check that the name
key is present, with the
HasKey
validator. Then we want to validate the value of the name
key; to do
this we need to focus our attention not on the whole data structure, but just
on the single value. We use the Focus
validator to specify a callable which
allows to inspect the specific value. At this point we use Sequence
again to
check that the value is a string, using the IsString
validator, and to assert
that it is not empty, with the NonEmpty
validator.
For the age
field, we do something similar. The only difference is that we
check if it is an integer with the IsInteger
validator and we use the
IsAsAsserted
validator, built with a user defined callable, to check that the
value is a positive integer.
This example explains how to use several combinators to create complex validators. Actually, the specific validator we are considering here could be written more simply as
Associative::validations([ 'name' => Sequence::validations([ new IsString(), new NonEmpty() ]), 'age' => Sequence::validations([ new IsInteger(), IsGreaterThan::withBound(0) ]) ]);
removing a lot of boilerplate form the client code and allowing to concentrate on what is specific for the validation at hand.
Applicative and monadic validation
Applicative and monadic validations are common techniques in functional programming. Both consists in having several validators, applying them, and combining their results. The main difference between them is how failure is handled.
With the applicative style, all validators are computed independently and, in case of failure, all errors are returned. This is useful when you need to validate independent data and retrieve all the errors encountered.
With the monadic style, validators are applied sequentially, and the result of a validator is provided as input for the next one. This is needed whenever a validation depends on the result of a previous validation.
Probably in this case some code says more that a lot of vague words. You can
check yourself how applicative and monadic validation work looking at the
ApplicativeMonadicSpec
tests.
How to use this library
This library is not build to be a ready-made artifact that you can install and start using immediately. On the contrary it provides just some basic elements which you can use to easily build your own validators.
The idea is that everyone could create his own library of validators, specific for his own domain and use case, composing the basic validators and custom ones with the help of the provided combinators.