Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions docs/migrating-from-v2-to-v3.md
Original file line number Diff line number Diff line change
Expand Up @@ -581,6 +581,7 @@ Version 3.0 introduces several new validators:
| `Lazy` | Creates validators dynamically based on input |
| `Masked` | Masks sensitive input values in error messages |
| `Named` | Customizes the subject name in error messages |
| `Patterned` | Formats input values using a pattern in error messages |
| `PropertyExists` | Checks if an object property exists |
| `PropertyOptional` | Validates an object property only if it exists |
| `Templated` | Attaches custom error message templates |
Expand Down Expand Up @@ -718,6 +719,18 @@ v::masked('6-12', v::creditCard(), 'X')->assert('4111111111111211');
// → "41111XXXXXXX1211" must be a valid credit card number
```

#### Patterned

Decorates a validator to format input values using a pattern in error messages while still validating the original unformatted data. This is useful for displaying formatted values when the original input lacks formatting characters:

```php
v::patterned('0{3}.0{3}.0{3}-0{2}', v::cpf())->assert('12345678900');
// → "123.456.789-00" must be a valid CPF number

v::patterned('$00.00', v::phone())->assert(1297);
// → "$12.97" must be a valid telephone number
Comment on lines +730 to +731
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This example is misleading. The use of $ implies a monterary value, but the validator used is a phone number.

Suggestion: Keep the phone number, and instead add examples for the most popular use cases:

  • Thousands separator
  • Decimals separator
  • Dates

```

#### Named

Customizes the subject name in error messages:
Expand Down
6 changes: 4 additions & 2 deletions docs/validators.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ In this page you will find a list of validators by their category.

**Date and Time**: [Date][] - [DateTime][] - [DateTimeDiff][] - [LeapDate][] - [LeapYear][] - [Time][]

**Display**: [Masked][] - [Named][] - [Templated][]
**Display**: [Masked][] - [Named][] - [Patterned][] - [Templated][]

**File system**: [Directory][] - [Executable][] - [Exists][] - [Extension][] - [File][] - [Image][] - [Mimetype][] - [Readable][] - [Size][] - [SymbolicLink][] - [Writable][]

Expand All @@ -39,7 +39,7 @@ In this page you will find a list of validators by their category.

**Math**: [Factor][] - [Finite][] - [Infinite][] - [Multiple][] - [Negative][] - [Positive][]

**Miscellaneous**: [Blank][] - [Falsy][] - [Masked][] - [Named][] - [Templated][] - [Undef][]
**Miscellaneous**: [Blank][] - [Falsy][] - [Masked][] - [Named][] - [Patterned][] - [Templated][] - [Undef][]

**Nesting**: [AllOf][] - [AnyOf][] - [Call][] - [Circuit][] - [Each][] - [Key][] - [KeySet][] - [Lazy][] - [NoneOf][] - [Not][] - [NullOr][] - [OneOf][] - [Property][] - [PropertyOptional][] - [UndefOr][] - [When][]

Expand Down Expand Up @@ -168,6 +168,7 @@ In this page you will find a list of validators by their category.
- [ObjectType][] - `v::objectType()->assert(new stdClass);`
- [Odd][] - `v::odd()->assert(3);`
- [OneOf][] - `v::oneOf(v::digit(), v::alpha())->assert('AB');`
- [Patterned][] - `v::patterned('0000 0000 0000 0000', v::creditCard())->assert('4111111111111111');`
- [Pesel][] - `v::pesel()->assert('21120209256');`
- [Phone][] - `v::phone()->assert('+1 650 253 00 00');`
- [Pis][] - `v::pis()->assert('120.0340.678-8');`
Expand Down Expand Up @@ -324,6 +325,7 @@ In this page you will find a list of validators by their category.
[ObjectType]: validators/ObjectType.md "Validates whether the input is an object."
[Odd]: validators/Odd.md "Validates whether the input is an odd number or not."
[OneOf]: validators/OneOf.md "Will validate if exactly one inner validator passes."
[Patterned]: validators/Patterned.md "Decorates a validator to format input values using a pattern in error messages while still validating the original unformatted input."
[Pesel]: validators/Pesel.md "Validates PESEL (Polish human identification number)."
[Phone]: validators/Phone.md "Validates whether the input is a valid phone number. This validator requires"
[Pis]: validators/Pis.md "Validates a Brazilian PIS/NIS number ignoring any non-digit char."
Expand Down
1 change: 1 addition & 0 deletions docs/validators/Masked.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,5 @@ The validator first ensures the input is a valid string using `StringVal`. If th
## See Also

- [Named](Named.md)
- [Patterned](Patterned.md)
- [Templated](Templated.md)
1 change: 1 addition & 0 deletions docs/validators/Named.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,4 +53,5 @@ This validator does not have any templates, as it will use the template of the g
- [Attributes](Attributes.md)
- [Masked](Masked.md)
- [Not](Not.md)
- [Patterned](Patterned.md)
- [Templated](Templated.md)
49 changes: 49 additions & 0 deletions docs/validators/Patterned.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<!--
SPDX-FileCopyrightText: (c) Respect Project Contributors
SPDX-License-Identifier: MIT
-->

# Patterned

- `Patterned(string $pattern, Validator $validator)`

Decorates a validator to format input values using a pattern in error messages while still validating the original unformatted input.

```php
v::patterned('0000 0000 0000 0000', v::creditCard())->assert('4111111111111111');
// Validation passes successfully

v::patterned('0000 0000 0000 0000', v::creditCard())->assert('4111111111111112');
// → "4111 1111 1111 1112" must be a valid credit card number

v::patterned('0{3}.0{3}.0{3}-0{2}', v::cpf())->assert('12345678900');
// → "123.456.789-00" must be a valid CPF number

v::patterned('(0{2}) 0{5}-0{4}', v::phone())->assert('11987654321');
// → "(11) 98765-4321" must be a valid telephone number
```

This validator is useful for displaying formatted values in error messages when the original input lacks formatting characters.

It uses [respect/string-formatter](https://github.com/Respect/StringFormatter) as the underlying formatting engine. See the documentation of [PatternFormatter](https://github.com/Respect/StringFormatter/blob/main/docs/PatternFormatter.md) for more information about the pattern syntax.

## Categorization

- Display
- Miscellaneous

## Behavior

The validator first ensures the input is a valid string using `StringVal`. If the input passes string validation, it validates the original unformatted input using the inner validator. If validation fails, it applies the pattern formatting to the input value shown in error messages.

## Changelog

| Version | Description |
| ------: | :---------- |
| 3.0.0 | Created |

## See Also

- [Masked](Masked.md)
- [Named](Named.md)
- [Templated](Templated.md)
1 change: 1 addition & 0 deletions docs/validators/Templated.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,4 @@ This validator does not have any templates, as you must define the templates you
- [Masked](Masked.md)
- [Named](Named.md)
- [Not](Not.md)
- [Patterned](Patterned.md)
2 changes: 2 additions & 0 deletions src/Mixins/AllBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,8 @@ public static function allOdd(): Chain;

public static function allOneOf(Validator $validator1, Validator $validator2, Validator ...$validators): Chain;

public static function allPatterned(string $pattern, Validator $validator): Chain;

public static function allPesel(): Chain;

public static function allPhone(string|null $countryCode = null): Chain;
Expand Down
2 changes: 2 additions & 0 deletions src/Mixins/AllChain.php
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,8 @@ public function allOdd(): Chain;

public function allOneOf(Validator $validator1, Validator $validator2, Validator ...$validators): Chain;

public function allPatterned(string $pattern, Validator $validator): Chain;

public function allPesel(): Chain;

public function allPhone(string|null $countryCode = null): Chain;
Expand Down
2 changes: 2 additions & 0 deletions src/Mixins/Builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,8 @@ public static function odd(): Chain;

public static function oneOf(Validator $validator1, Validator $validator2, Validator ...$validators): Chain;

public static function patterned(string $pattern, Validator $validator): Chain;

public static function pesel(): Chain;

public static function phone(string|null $countryCode = null): Chain;
Expand Down
2 changes: 2 additions & 0 deletions src/Mixins/Chain.php
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,8 @@ public function odd(): Chain;

public function oneOf(Validator $validator1, Validator $validator2, Validator ...$validators): Chain;

public function patterned(string $pattern, Validator $validator): Chain;

public function pesel(): Chain;

public function phone(string|null $countryCode = null): Chain;
Expand Down
2 changes: 2 additions & 0 deletions src/Mixins/KeyBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,8 @@ public static function keyOdd(int|string $key): Chain;

public static function keyOneOf(int|string $key, Validator $validator1, Validator $validator2, Validator ...$validators): Chain;

public static function keyPatterned(int|string $key, string $pattern, Validator $validator): Chain;

public static function keyPesel(int|string $key): Chain;

public static function keyPhone(int|string $key, string|null $countryCode = null): Chain;
Expand Down
2 changes: 2 additions & 0 deletions src/Mixins/KeyChain.php
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,8 @@ public function keyOdd(int|string $key): Chain;

public function keyOneOf(int|string $key, Validator $validator1, Validator $validator2, Validator ...$validators): Chain;

public function keyPatterned(int|string $key, string $pattern, Validator $validator): Chain;

public function keyPesel(int|string $key): Chain;

public function keyPhone(int|string $key, string|null $countryCode = null): Chain;
Expand Down
2 changes: 2 additions & 0 deletions src/Mixins/NotBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,8 @@ public static function notOdd(): Chain;

public static function notOneOf(Validator $validator1, Validator $validator2, Validator ...$validators): Chain;

public static function notPatterned(string $pattern, Validator $validator): Chain;

public static function notPesel(): Chain;

public static function notPhone(string|null $countryCode = null): Chain;
Expand Down
2 changes: 2 additions & 0 deletions src/Mixins/NotChain.php
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,8 @@ public function notOdd(): Chain;

public function notOneOf(Validator $validator1, Validator $validator2, Validator ...$validators): Chain;

public function notPatterned(string $pattern, Validator $validator): Chain;

public function notPesel(): Chain;

public function notPhone(string|null $countryCode = null): Chain;
Expand Down
2 changes: 2 additions & 0 deletions src/Mixins/NullOrBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,8 @@ public static function nullOrOdd(): Chain;

public static function nullOrOneOf(Validator $validator1, Validator $validator2, Validator ...$validators): Chain;

public static function nullOrPatterned(string $pattern, Validator $validator): Chain;

public static function nullOrPesel(): Chain;

public static function nullOrPhone(string|null $countryCode = null): Chain;
Expand Down
2 changes: 2 additions & 0 deletions src/Mixins/NullOrChain.php
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,8 @@ public function nullOrOdd(): Chain;

public function nullOrOneOf(Validator $validator1, Validator $validator2, Validator ...$validators): Chain;

public function nullOrPatterned(string $pattern, Validator $validator): Chain;

public function nullOrPesel(): Chain;

public function nullOrPhone(string|null $countryCode = null): Chain;
Expand Down
2 changes: 2 additions & 0 deletions src/Mixins/PropertyBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,8 @@ public static function propertyOdd(string $propertyName): Chain;

public static function propertyOneOf(string $propertyName, Validator $validator1, Validator $validator2, Validator ...$validators): Chain;

public static function propertyPatterned(string $propertyName, string $pattern, Validator $validator): Chain;

public static function propertyPesel(string $propertyName): Chain;

public static function propertyPhone(string $propertyName, string|null $countryCode = null): Chain;
Expand Down
2 changes: 2 additions & 0 deletions src/Mixins/PropertyChain.php
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,8 @@ public function propertyOdd(string $propertyName): Chain;

public function propertyOneOf(string $propertyName, Validator $validator1, Validator $validator2, Validator ...$validators): Chain;

public function propertyPatterned(string $propertyName, string $pattern, Validator $validator): Chain;

public function propertyPesel(string $propertyName): Chain;

public function propertyPhone(string $propertyName, string|null $countryCode = null): Chain;
Expand Down
2 changes: 2 additions & 0 deletions src/Mixins/UndefOrBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,8 @@ public static function undefOrOdd(): Chain;

public static function undefOrOneOf(Validator $validator1, Validator $validator2, Validator ...$validators): Chain;

public static function undefOrPatterned(string $pattern, Validator $validator): Chain;

public static function undefOrPesel(): Chain;

public static function undefOrPhone(string|null $countryCode = null): Chain;
Expand Down
2 changes: 2 additions & 0 deletions src/Mixins/UndefOrChain.php
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,8 @@ public function undefOrOdd(): Chain;

public function undefOrOneOf(Validator $validator1, Validator $validator2, Validator ...$validators): Chain;

public function undefOrPatterned(string $pattern, Validator $validator): Chain;

public function undefOrPesel(): Chain;

public function undefOrPhone(string|null $countryCode = null): Chain;
Expand Down
46 changes: 46 additions & 0 deletions src/Validators/Patterned.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php

/*
* SPDX-License-Identifier: MIT
* SPDX-FileCopyrightText: (c) Respect Project Contributors
* SPDX-FileContributor: Henrique Moody <henriquemoody@gmail.com>
*/

declare(strict_types=1);

namespace Respect\Validation\Validators;

use Attribute;
use Respect\StringFormatter\InvalidFormatterException;
use Respect\StringFormatter\PatternFormatter;
use Respect\Validation\Exceptions\InvalidValidatorException;
use Respect\Validation\Result;
use Respect\Validation\Validator;

#[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)]
final readonly class Patterned implements Validator
{
private PatternFormatter $patternFormatter;

public function __construct(
private string $pattern,
private Validator $validator,
) {
try {
$this->patternFormatter = new PatternFormatter($this->pattern);
} catch (InvalidFormatterException $exception) {
throw new InvalidValidatorException($exception->getMessage());
}
}

public function evaluate(mixed $input): Result
{
$stringVal = new StringVal();
$stringValResult = $stringVal->evaluate($input);
if (!$stringValResult->hasPassed) {
return $stringValResult->withNameFrom($this->validator)->withIdFrom($this->validator);
}

return $this->validator->evaluate($input)->withInput($this->patternFormatter->format((string) $input));
}
}
25 changes: 25 additions & 0 deletions tests/feature/Validators/PatternedTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

/*
* SPDX-License-Identifier: MIT
* SPDX-FileCopyrightText: (c) Respect Project Contributors
* SPDX-FileContributor: Henrique Moody <henriquemoody@gmail.com>
*/

declare(strict_types=1);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we add at least one test that showcases the behavior when using this together with named() and templated()?

This is also true for the Masked #1652 (already closed, I have not noticed back then) and the Formatted #1657 PRs.


test('input is not a string', catchAll(
fn() => v::patterned('0{3}.0{3}.0{3}-0{2}', v::cpf())->assert(new stdClass()),
fn(string $message, string $fullMessage, array $messages) => expect()
->and($message)->toBe('`stdClass {}` must be a string value')
->and($fullMessage)->toBe('- `stdClass {}` must be a string value')
->and($messages)->toBe(['cpf' => '`stdClass {}` must be a string value']),
));

test('failed validator', catchAll(
fn() => v::patterned('0{3}.0{3}.0{3}-0{2}', v::cpf())->assert('12345678900'),
fn(string $message, string $fullMessage, array $messages) => expect()
->and($message)->toBe('"123.456.789-00" must be a valid CPF number')
->and($fullMessage)->toBe('- "123.456.789-00" must be a valid CPF number')
->and($messages)->toBe(['cpf' => '"123.456.789-00" must be a valid CPF number']),
));
1 change: 1 addition & 0 deletions tests/src/SmokeTestProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ public static function provideValidatorInput(): Generator
yield 'ObjectType' => [new vs\ObjectType(), new stdClass()];
yield 'Odd' => [new vs\Odd(), 3];
yield 'OneOf' => [new vs\OneOf(new vs\Digit(), new vs\Alpha()), 'AB'];
yield 'Patterned' => [new vs\Patterned('\UAAA', new vs\Alpha()), 'abc'];
yield 'Pesel' => [new vs\Pesel(), '21120209256'];
yield 'Phone' => [new vs\Phone(), '+1 650 253 00 00'];
yield 'Pis' => [new vs\Pis(), '120.0340.678-8'];
Expand Down
58 changes: 58 additions & 0 deletions tests/unit/Validators/PatternedTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<?php

/*
* SPDX-License-Identifier: MIT
* SPDX-FileCopyrightText: (c) Respect Project Contributors
* SPDX-FileContributor: Henrique Moody <henriquemoody@gmail.com>
*/

declare(strict_types=1);

namespace Respect\Validation\Validators;

use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use Respect\StringFormatter\PatternFormatter;
use Respect\Validation\Exceptions\InvalidValidatorException;
use Respect\Validation\Test\TestCase;
use Respect\Validation\Test\Validators\Stub;

#[CoversClass(Patterned::class)]
final class PatternedTest extends TestCase
{
#[Test]
public function shouldNotAllowCreatingValidatorWithAnInvalidPattern(): void
{
$pattern = '';

$this->expectException(InvalidValidatorException::class);

new Patterned($pattern, Stub::daze());
}

#[Test]
#[DataProvider('providerForNonStringValues')]
public function shouldNotValidateWhenInputIsNotStringValue(mixed $input): void
{
$this->assertInvalidInput(new Patterned('0{3}.0{3}.0{3}-0{2}', Stub::any(1)), $input);
}

#[Test]
#[DataProvider('providerForStringValues')]
public function shouldFormatTheInputWhenInputIsStringValue(mixed $input): void
{
$patternFormatter = new PatternFormatter('0{3}.0{3}.0{3}-0{2}');

$stub = Stub::pass(2);
$comparableResult = $stub->evaluate($input);

$validator = new Patterned('0{3}.0{3}.0{3}-0{2}', $stub);

$result = $validator->evaluate($input);

self::assertSame($patternFormatter->format((string) $input), $result->input);
self::assertSame($comparableResult->hasPassed, $result->hasPassed);
self::assertSame($comparableResult->validator, $result->validator);
}
}