Skip to content

Commit

Permalink
Allow to use certain rules as class attributes
Browse files Browse the repository at this point in the history
There are a few cases in which we want to validate the object as a
whole, and that validation could be attached to the class as a PHP
attribute. This commit enables that capability and changes a few rules
to be class attributes.
  • Loading branch information
henriquemoody committed Jan 16, 2025
1 parent 848724e commit 94d53df
Show file tree
Hide file tree
Showing 17 changed files with 100 additions and 69 deletions.
66 changes: 38 additions & 28 deletions docs/rules/Attributes.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,24 @@ Validates the PHP attributes defined in the properties of the input.
Example of object:

```php
use Respect\Validation\Rules;
use Respect\Validation\Rules as Rule;

#[Rule\AnyOf(
new Rule\Property('email', new Rule\NotUndef()),
new Rule\Property('phone', new Rule\NotUndef()),
)]
final class Person
{
public function __construct(
#[Rules\NotEmpty]
public readonly string $name,
#[Rules\Email]
public readonly string $email,
#[Rules\Date('Y-m-d')]
#[Rules\DateTimeDiff('years', new Rules\LessThanOrEqual(25))]
public readonly string $birthdate,
#[Rules\Phone]
public readonly ?string $phone
#[Rule\NotEmpty]
public string $name,
#[Rule\Date('Y-m-d')]
#[Rule\DateTimeDiff('years', new Rule\LessThanOrEqual(25))]
public string $birthdate,
#[Rule\Email]
public ?string $email = null,
#[Rule\Phone]
public ?string $phone = null,
) {
}
}
Expand All @@ -29,33 +33,39 @@ final class Person
Here is how you can validate the attributes of the object:

```php
v::attributes()->assert(new Person('John Doe', 'john.doe@gmail.com', '2020-06-23'));
v::attributes()->assert(new Person('John Doe', '2020-06-23', 'john.doe@gmail.com'));
// No exception

v::attributes()->assert(new Person('John Doe', 'john.doe@gmail.com', '2020-06-23', '+31 20 624 1111'));
v::attributes()->assert(new Person('John Doe', '2020-06-23', 'john.doe@gmail.com', '+12024561111'));
// No exception

v::attributes()->assert(new Person('', 'john.doe@gmail.com', '2020-06-23', '+1234567890'));
// Message: name must not be empty
v::attributes()->assert(new Person('', '2020-06-23', 'john.doe@gmail.com', '+12024561111'));
// Message: `.name` must not be empty

v::attributes()->assert(new Person('John Doe', 'not an email', '2020-06-23', '+1234567890'));
// Message: email must be a valid email address
v::attributes()->assert(new Person('John Doe', 'not a date', 'john.doe@gmail.com', '+12024561111'));
// Message: `.birthdate` must be a valid date in the format "2005-12-30"

v::attributes()->assert(new Person('John Doe', 'john.doe@gmail.com', 'not a date', '+1234567890'));
// Message: birthdate must be a valid date in the format "2005-12-30"
v::attributes()->assert(new Person('John Doe', '2020-06-23', 'not an email', '+12024561111'));
// Message: `.email` must be a valid email address or must be null

v::attributes()->assert(new Person('John Doe', 'john.doe@gmail.com', '2020-06-23', 'not a phone number'));
// Message: phone must be a valid telephone number or must be null
v::attributes()->assert(new Person('John Doe', '2020-06-23', 'john.doe@gmail.com', 'not a phone number'));
// Message: `.phone` must be a valid telephone number or must be null

v::attributes()->assert(new Person('', 'not an email', 'not a date', 'not a phone number'));
v::attributes()->assert(new Person('John Doe', '2020-06-23'));
// Full message:
// - `Person { +$name="" +$email="not an email" +$birthdate="not a date" +$phone="not a phone number" }` must pass all the rules
// - name must not be empty
// - email must be a valid email address
// - birthdate must pass all the rules
// - birthdate must be a valid date in the format "2005-12-30"
// - For comparison with now, birthdate must be a valid datetime
// - phone must be a valid telephone number or must be null
// - `Person { +$name="John Doe" +$birthdate="2020-06-23" +$email=null +$phone=null +$address=null }` must pass at least one of the rules
// - `.email` must be defined
// - `.phone` must be defined

v::attributes()->assert(new Person('', 'not a date', 'not an email', 'not a phone number'));
// Full message:
// - `Person { +$name="" +$birthdate="not a date" +$email="not an email" +$phone="not a phone number" +$address=null }` must pass the rules
// - `.name` must not be empty
// - `.birthdate` must pass all the rules
// - `.birthdate` must be a valid date in the format "2005-12-30"
// - For comparison with now, `.birthdate` must be a valid datetime
// - `.email` must be a valid email address or must be null
// - `.phone` must be a valid telephone number or must be null
```

## Caveats
Expand Down
2 changes: 1 addition & 1 deletion library/Rules/AllOf.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
use function array_reduce;
use function count;

#[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)]
#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)]
#[Template(
'{{name}} must pass the rules',
'{{name}} must pass the rules',
Expand Down
2 changes: 1 addition & 1 deletion library/Rules/AnyOf.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
use function array_map;
use function array_reduce;

#[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)]
#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)]
#[Template(
'{{name}} must pass at least one of the rules',
'{{name}} must pass at least one of the rules',
Expand Down
6 changes: 5 additions & 1 deletion library/Rules/Attributes.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,11 @@ public function evaluate(mixed $input): Result
}

$rules = [];
foreach ((new ReflectionObject($input))->getProperties() as $property) {
$reflection = new ReflectionObject($input);
foreach ($reflection->getAttributes(Rule::class, ReflectionAttribute::IS_INSTANCEOF) as $attribute) {
$rules[] = $attribute->newInstance();
}
foreach ($reflection->getProperties() as $property) {
$childrenRules = [];
foreach ($property->getAttributes(Rule::class, ReflectionAttribute::IS_INSTANCEOF) as $attribute) {
$childrenRules[] = $attribute->newInstance();
Expand Down
2 changes: 1 addition & 1 deletion library/Rules/Call.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
use function restore_error_handler;
use function set_error_handler;

#[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)]
#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)]
#[Template(
'{{input}} must be a suitable argument for {{callable}}',
'{{input}} must not be a suitable argument for {{callable}}',
Expand Down
2 changes: 1 addition & 1 deletion library/Rules/Circuit.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
use Respect\Validation\Result;
use Respect\Validation\Rules\Core\Composite;

#[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)]
#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)]
final class Circuit extends Composite
{
public function evaluate(mixed $input): Result
Expand Down
2 changes: 1 addition & 1 deletion library/Rules/Lazy.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@

use function call_user_func;

#[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)]
#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)]
final class Lazy extends Standard
{
/** @var callable(mixed): Rule */
Expand Down
2 changes: 1 addition & 1 deletion library/Rules/Named.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
use Respect\Validation\Rules\Core\Nameable;
use Respect\Validation\Rules\Core\Wrapper;

#[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)]
#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)]
final class Named extends Wrapper implements Nameable
{
public function __construct(
Expand Down
2 changes: 1 addition & 1 deletion library/Rules/NoneOf.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
use function array_reduce;
use function count;

#[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)]
#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)]
#[Template(
'{{name}} must pass the rules',
'{{name}} must pass the rules',
Expand Down
2 changes: 1 addition & 1 deletion library/Rules/Not.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
use Respect\Validation\Result;
use Respect\Validation\Rules\Core\Wrapper;

#[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)]
#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)]
final class Not extends Wrapper
{
public function evaluate(mixed $input): Result
Expand Down
2 changes: 1 addition & 1 deletion library/Rules/OneOf.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
use function count;
use function usort;

#[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)]
#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)]
#[Template(
'{{name}} must pass one of the rules',
'{{name}} must pass one of the rules',
Expand Down
2 changes: 1 addition & 1 deletion library/Rules/Templated.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
use Respect\Validation\Rule;
use Respect\Validation\Rules\Core\Wrapper;

#[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)]
#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)]
final class Templated extends Wrapper
{
/** @param array<string, mixed> $parameters */
Expand Down
2 changes: 1 addition & 1 deletion library/Rules/When.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
use Respect\Validation\Rule;
use Respect\Validation\Rules\Core\Standard;

#[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)]
#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)]
final class When extends Standard
{
private readonly Rule $else;
Expand Down
35 changes: 26 additions & 9 deletions tests/feature/Rules/AttributesTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,14 @@
use Respect\Validation\Test\Stubs\WithAttributes;

test('Default', expectAll(
fn() => v::attributes()->assert(new WithAttributes('', 'john.doe@gmail.com', '2024-06-23')),
fn() => v::attributes()->assert(new WithAttributes('', '2024-06-23', 'john.doe@gmail.com')),
'`.name` must not be empty',
'- `.name` must not be empty',
['name' => '`.name` must not be empty'],
));

test('Inverted', expectAll(
fn() => v::attributes()->assert(new WithAttributes('John Doe', 'john.doe@gmail.com', '2024-06-23', '+1234567890')),
fn() => v::attributes()->assert(new WithAttributes('John Doe', '2024-06-23', 'john.doe@gmail.com', '+1234567890')),
'`.phone` must be a valid telephone number or must be null',
'- `.phone` must be a valid telephone number or must be null',
['phone' => '`.phone` must be a valid telephone number or must be null'],
Expand All @@ -31,39 +31,56 @@
));

test('Nullable', expectAll(
fn() => v::attributes()->assert(new WithAttributes('John Doe', 'john.doe@gmail.com', '2024-06-23', 'not a phone number')),
fn() => v::attributes()->assert(new WithAttributes('John Doe', '2024-06-23', 'john.doe@gmail.com', 'not a phone number')),
'`.phone` must be a valid telephone number or must be null',
'- `.phone` must be a valid telephone number or must be null',
['phone' => '`.phone` must be a valid telephone number or must be null'],
));

test('Multiple attributes, all failed', expectAll(
fn() => v::attributes()->assert(new WithAttributes('', 'not an email', 'not a date', 'not a phone number')),
fn() => v::attributes()->assert(new WithAttributes('', 'not a date', 'not an email', 'not a phone number')),
'`.name` must not be empty',
<<<'FULL_MESSAGE'
- `Respect\Validation\Test\Stubs\WithAttributes { +$name="" +$email="not an email" +$birthdate="not a date" +$phone ... }` must pass all the rules
- `Respect\Validation\Test\Stubs\WithAttributes { +$name="" +$birthdate="not a date" +$email="not an email" +$phone ... }` must pass the rules
- `.name` must not be empty
- `.email` must be a valid email address
- `.birthdate` must pass all the rules
- `.birthdate` must be a valid date in the format "2005-12-30"
- For comparison with now, `.birthdate` must be a valid datetime
- `.email` must be a valid email address or must be null
- `.phone` must be a valid telephone number or must be null
FULL_MESSAGE,
[
'__root__' => '`Respect\Validation\Test\Stubs\WithAttributes { +$name="" +$email="not an email" +$birthdate="not a date" +$phone ... }` must pass all the rules',
'__root__' => '`Respect\Validation\Test\Stubs\WithAttributes { +$name="" +$birthdate="not a date" +$email="not an email" +$phone ... }` must pass the rules',
'name' => '`.name` must not be empty',
'email' => '`.email` must be a valid email address',
'birthdate' => [
'__root__' => '`.birthdate` must pass all the rules',
'date' => '`.birthdate` must be a valid date in the format "2005-12-30"',
'dateTimeDiffLessThanOrEqual' => 'For comparison with now, `.birthdate` must be a valid datetime',
],
'email' => '`.email` must be a valid email address or must be null',
'phone' => '`.phone` must be a valid telephone number or must be null',
],
));

test('Failed attributes on the class', expectAll(
fn() => v::attributes()->assert(new WithAttributes('John Doe', '2024-06-23')),
'`.email` must be defined',
<<<'FULL_MESSAGE'
- `Respect\Validation\Test\Stubs\WithAttributes { +$name="John Doe" +$birthdate="2024-06-23" +$email=null +$phone=n ... }` must pass at least one of the rules
- `.email` must be defined
- `.phone` must be defined
FULL_MESSAGE,
[
'anyOf' => [
'__root__' => '`Respect\Validation\Test\Stubs\WithAttributes { +$name="John Doe" +$birthdate="2024-06-23" +$email=null +$phone=n ... }` must pass at least one of the rules',
'email' => '`.email` must be defined',
'phone' => '`.phone` must be defined',
],
],
));

test('Multiple attributes, one failed', expectAll(
fn() => v::attributes()->assert(new WithAttributes('John Doe', 'john.doe@gmail.com', '22 years ago')),
fn() => v::attributes()->assert(new WithAttributes('John Doe', '22 years ago', 'john.doe@gmail.com')),
'`.birthdate` must be a valid date in the format "2005-12-30"',
'- `.birthdate` must be a valid date in the format "2005-12-30"',
['birthdate' => '`.birthdate` must be a valid date in the format "2005-12-30"'],
Expand Down
2 changes: 1 addition & 1 deletion tests/fixtures/data-provider.php
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@
'tags' => ['objectType', 'withoutAttributes'],
],
'object with Rule attributes' => [
'value' => [new WithAttributes('John Doe', 'john.doe@gmail.com', '1912-06-23')],
'value' => [new WithAttributes('John Doe', '1912-06-23', 'john.doe@gmail.com')],
'tags' => ['objectType', 'withAttributes'],
],
'anonymous class' => [
Expand Down
23 changes: 11 additions & 12 deletions tests/library/Stubs/WithAttributes.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,24 +9,23 @@

namespace Respect\Validation\Test\Stubs;

use Respect\Validation\Rules\Date;
use Respect\Validation\Rules\DateTimeDiff;
use Respect\Validation\Rules\Email;
use Respect\Validation\Rules\LessThanOrEqual;
use Respect\Validation\Rules\NotEmpty;
use Respect\Validation\Rules\Phone;
use Respect\Validation\Rules as Rule;

#[Rule\AnyOf(
new Rule\Property('email', new Rule\NotUndef()),
new Rule\Property('phone', new Rule\NotUndef()),
)]
final class WithAttributes
{
public function __construct(
#[NotEmpty]
#[Rule\NotEmpty]
public string $name,
#[Email]
public string $email,
#[Date('Y-m-d')]
#[DateTimeDiff('years', new LessThanOrEqual(25))]
#[Rule\Date('Y-m-d')]
#[Rule\DateTimeDiff('years', new Rule\LessThanOrEqual(25))]
public string $birthdate,
#[Phone]
#[Rule\Email]
public ?string $email = null,
#[Rule\Phone]
public ?string $phone = null,
public ?string $address = null,
) {
Expand Down
15 changes: 8 additions & 7 deletions tests/unit/Rules/AttributesTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -55,25 +55,26 @@ public static function providerForObjectsWithValidPropertyValues(): array
'All' => [
new WithAttributes(
'John Doe',
'john.doe@gmail.com',
'2020-06-23',
'john.doe@gmail.com',
'+31206241111',
'Amstel 1 1011 PN AMSTERDAM Noord-Holland'
),
],
'Only required' => [new WithAttributes('Jane Doe', 'janedoe@yahoo.com', '2017-11-30')],
'Only required' => [new WithAttributes('Jane Doe', '2017-11-30', 'janedoe@yahoo.com')],
];
}

/** @return array<array{object}> */
public static function providerForObjectsWithInvalidPropertyValues(): array
{
return [
[new WithAttributes('', 'not an email', 'not a date', 'not a phone number')],
[new WithAttributes('', 'john.doe@gmail.com', '1912-06-23', '+1234567890')],
[new WithAttributes('John Doe', 'not an email', '1912-06-23', '+1234567890')],
[new WithAttributes('John Doe', 'john.doe@gmail.com', 'not a date', '+1234567890')],
[new WithAttributes('John Doe', 'john.doe@gmail.com', '1912-06-23', 'not a phone number')],
[new WithAttributes('Jane Doe', '2017-11-30')],
[new WithAttributes('', 'not a date', 'not an email', 'not a phone number')],
[new WithAttributes('', '1912-06-23', 'john.doe@gmail.com', '+1234567890')],
[new WithAttributes('John Doe', '1912-06-23', 'not an email', '+1234567890')],
[new WithAttributes('John Doe', 'not a date', 'john.doe@gmail.com', '+1234567890')],
[new WithAttributes('John Doe', '1912-06-23', 'john.doe@gmail.com', 'not a phone number')],
];
}
}

0 comments on commit 94d53df

Please sign in to comment.