Skip to content

Commit 94d53df

Browse files
committed
Allow to use certain rules as class attributes
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.
1 parent 848724e commit 94d53df

17 files changed

+100
-69
lines changed

Diff for: docs/rules/Attributes.md

+38-28
Original file line numberDiff line numberDiff line change
@@ -7,20 +7,24 @@ Validates the PHP attributes defined in the properties of the input.
77
Example of object:
88

99
```php
10-
use Respect\Validation\Rules;
10+
use Respect\Validation\Rules as Rule;
1111

12+
#[Rule\AnyOf(
13+
new Rule\Property('email', new Rule\NotUndef()),
14+
new Rule\Property('phone', new Rule\NotUndef()),
15+
)]
1216
final class Person
1317
{
1418
public function __construct(
15-
#[Rules\NotEmpty]
16-
public readonly string $name,
17-
#[Rules\Email]
18-
public readonly string $email,
19-
#[Rules\Date('Y-m-d')]
20-
#[Rules\DateTimeDiff('years', new Rules\LessThanOrEqual(25))]
21-
public readonly string $birthdate,
22-
#[Rules\Phone]
23-
public readonly ?string $phone
19+
#[Rule\NotEmpty]
20+
public string $name,
21+
#[Rule\Date('Y-m-d')]
22+
#[Rule\DateTimeDiff('years', new Rule\LessThanOrEqual(25))]
23+
public string $birthdate,
24+
#[Rule\Email]
25+
public ?string $email = null,
26+
#[Rule\Phone]
27+
public ?string $phone = null,
2428
) {
2529
}
2630
}
@@ -29,33 +33,39 @@ final class Person
2933
Here is how you can validate the attributes of the object:
3034

3135
```php
32-
v::attributes()->assert(new Person('John Doe', '[email protected]', '2020-06-23'));
36+
v::attributes()->assert(new Person('John Doe', '2020-06-23', '[email protected]'));
3337
// No exception
3438

35-
v::attributes()->assert(new Person('John Doe', '[email protected]', '2020-06-23', '+31 20 624 1111'));
39+
v::attributes()->assert(new Person('John Doe', '2020-06-23', '[email protected]', '+12024561111'));
3640
// No exception
3741

38-
v::attributes()->assert(new Person('', '[email protected]', '2020-06-23', '+1234567890'));
39-
// Message: name must not be empty
42+
v::attributes()->assert(new Person('', '2020-06-23', '[email protected]', '+12024561111'));
43+
// Message: `.name` must not be empty
4044

41-
v::attributes()->assert(new Person('John Doe', 'not an email', '2020-06-23', '+1234567890'));
42-
// Message: email must be a valid email address
45+
v::attributes()->assert(new Person('John Doe', 'not a date', '[email protected]', '+12024561111'));
46+
// Message: `.birthdate` must be a valid date in the format "2005-12-30"
4347

44-
v::attributes()->assert(new Person('John Doe', '[email protected]', 'not a date', '+1234567890'));
45-
// Message: birthdate must be a valid date in the format "2005-12-30"
48+
v::attributes()->assert(new Person('John Doe', '2020-06-23', 'not an email', '+12024561111'));
49+
// Message: `.email` must be a valid email address or must be null
4650

47-
v::attributes()->assert(new Person('John Doe', '[email protected]', '2020-06-23', 'not a phone number'));
48-
// Message: phone must be a valid telephone number or must be null
51+
v::attributes()->assert(new Person('John Doe', '2020-06-23', '[email protected]', 'not a phone number'));
52+
// Message: `.phone` must be a valid telephone number or must be null
4953

50-
v::attributes()->assert(new Person('', 'not an email', 'not a date', 'not a phone number'));
54+
v::attributes()->assert(new Person('John Doe', '2020-06-23'));
5155
// Full message:
52-
// - `Person { +$name="" +$email="not an email" +$birthdate="not a date" +$phone="not a phone number" }` must pass all the rules
53-
// - name must not be empty
54-
// - email must be a valid email address
55-
// - birthdate must pass all the rules
56-
// - birthdate must be a valid date in the format "2005-12-30"
57-
// - For comparison with now, birthdate must be a valid datetime
58-
// - phone must be a valid telephone number or must be null
56+
// - `Person { +$name="John Doe" +$birthdate="2020-06-23" +$email=null +$phone=null +$address=null }` must pass at least one of the rules
57+
// - `.email` must be defined
58+
// - `.phone` must be defined
59+
60+
v::attributes()->assert(new Person('', 'not a date', 'not an email', 'not a phone number'));
61+
// Full message:
62+
// - `Person { +$name="" +$birthdate="not a date" +$email="not an email" +$phone="not a phone number" +$address=null }` must pass the rules
63+
// - `.name` must not be empty
64+
// - `.birthdate` must pass all the rules
65+
// - `.birthdate` must be a valid date in the format "2005-12-30"
66+
// - For comparison with now, `.birthdate` must be a valid datetime
67+
// - `.email` must be a valid email address or must be null
68+
// - `.phone` must be a valid telephone number or must be null
5969
```
6070

6171
## Caveats

Diff for: library/Rules/AllOf.php

+1-1
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
use function array_reduce;
2121
use function count;
2222

23-
#[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)]
23+
#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)]
2424
#[Template(
2525
'{{name}} must pass the rules',
2626
'{{name}} must pass the rules',

Diff for: library/Rules/AnyOf.php

+1-1
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
use function array_map;
1919
use function array_reduce;
2020

21-
#[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)]
21+
#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)]
2222
#[Template(
2323
'{{name}} must pass at least one of the rules',
2424
'{{name}} must pass at least one of the rules',

Diff for: library/Rules/Attributes.php

+5-1
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,11 @@ public function evaluate(mixed $input): Result
2828
}
2929

3030
$rules = [];
31-
foreach ((new ReflectionObject($input))->getProperties() as $property) {
31+
$reflection = new ReflectionObject($input);
32+
foreach ($reflection->getAttributes(Rule::class, ReflectionAttribute::IS_INSTANCEOF) as $attribute) {
33+
$rules[] = $attribute->newInstance();
34+
}
35+
foreach ($reflection->getProperties() as $property) {
3236
$childrenRules = [];
3337
foreach ($property->getAttributes(Rule::class, ReflectionAttribute::IS_INSTANCEOF) as $attribute) {
3438
$childrenRules[] = $attribute->newInstance();

Diff for: library/Rules/Call.php

+1-1
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
use function restore_error_handler;
2222
use function set_error_handler;
2323

24-
#[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)]
24+
#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)]
2525
#[Template(
2626
'{{input}} must be a suitable argument for {{callable}}',
2727
'{{input}} must not be a suitable argument for {{callable}}',

Diff for: library/Rules/Circuit.php

+1-1
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
use Respect\Validation\Result;
1414
use Respect\Validation\Rules\Core\Composite;
1515

16-
#[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)]
16+
#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)]
1717
final class Circuit extends Composite
1818
{
1919
public function evaluate(mixed $input): Result

Diff for: library/Rules/Lazy.php

+1-1
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717

1818
use function call_user_func;
1919

20-
#[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)]
20+
#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)]
2121
final class Lazy extends Standard
2222
{
2323
/** @var callable(mixed): Rule */

Diff for: library/Rules/Named.php

+1-1
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
use Respect\Validation\Rules\Core\Nameable;
1616
use Respect\Validation\Rules\Core\Wrapper;
1717

18-
#[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)]
18+
#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)]
1919
final class Named extends Wrapper implements Nameable
2020
{
2121
public function __construct(

Diff for: library/Rules/NoneOf.php

+1-1
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
use function array_reduce;
2121
use function count;
2222

23-
#[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)]
23+
#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)]
2424
#[Template(
2525
'{{name}} must pass the rules',
2626
'{{name}} must pass the rules',

Diff for: library/Rules/Not.php

+1-1
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
use Respect\Validation\Result;
1414
use Respect\Validation\Rules\Core\Wrapper;
1515

16-
#[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)]
16+
#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)]
1717
final class Not extends Wrapper
1818
{
1919
public function evaluate(mixed $input): Result

Diff for: library/Rules/OneOf.php

+1-1
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
use function count;
2222
use function usort;
2323

24-
#[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)]
24+
#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)]
2525
#[Template(
2626
'{{name}} must pass one of the rules',
2727
'{{name}} must pass one of the rules',

Diff for: library/Rules/Templated.php

+1-1
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
use Respect\Validation\Rule;
1515
use Respect\Validation\Rules\Core\Wrapper;
1616

17-
#[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)]
17+
#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)]
1818
final class Templated extends Wrapper
1919
{
2020
/** @param array<string, mixed> $parameters */

Diff for: library/Rules/When.php

+1-1
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
use Respect\Validation\Rule;
1515
use Respect\Validation\Rules\Core\Standard;
1616

17-
#[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)]
17+
#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)]
1818
final class When extends Standard
1919
{
2020
private readonly Rule $else;

Diff for: tests/feature/Rules/AttributesTest.php

+26-9
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,14 @@
1010
use Respect\Validation\Test\Stubs\WithAttributes;
1111

1212
test('Default', expectAll(
13-
fn() => v::attributes()->assert(new WithAttributes('', '[email protected]', '2024-06-23')),
13+
fn() => v::attributes()->assert(new WithAttributes('', '2024-06-23', '[email protected]')),
1414
'`.name` must not be empty',
1515
'- `.name` must not be empty',
1616
['name' => '`.name` must not be empty'],
1717
));
1818

1919
test('Inverted', expectAll(
20-
fn() => v::attributes()->assert(new WithAttributes('John Doe', '[email protected]', '2024-06-23', '+1234567890')),
20+
fn() => v::attributes()->assert(new WithAttributes('John Doe', '2024-06-23', '[email protected]', '+1234567890')),
2121
'`.phone` must be a valid telephone number or must be null',
2222
'- `.phone` must be a valid telephone number or must be null',
2323
['phone' => '`.phone` must be a valid telephone number or must be null'],
@@ -31,39 +31,56 @@
3131
));
3232

3333
test('Nullable', expectAll(
34-
fn() => v::attributes()->assert(new WithAttributes('John Doe', '[email protected]', '2024-06-23', 'not a phone number')),
34+
fn() => v::attributes()->assert(new WithAttributes('John Doe', '2024-06-23', '[email protected]', 'not a phone number')),
3535
'`.phone` must be a valid telephone number or must be null',
3636
'- `.phone` must be a valid telephone number or must be null',
3737
['phone' => '`.phone` must be a valid telephone number or must be null'],
3838
));
3939

4040
test('Multiple attributes, all failed', expectAll(
41-
fn() => v::attributes()->assert(new WithAttributes('', 'not an email', 'not a date', 'not a phone number')),
41+
fn() => v::attributes()->assert(new WithAttributes('', 'not a date', 'not an email', 'not a phone number')),
4242
'`.name` must not be empty',
4343
<<<'FULL_MESSAGE'
44-
- `Respect\Validation\Test\Stubs\WithAttributes { +$name="" +$email="not an email" +$birthdate="not a date" +$phone ... }` must pass all the rules
44+
- `Respect\Validation\Test\Stubs\WithAttributes { +$name="" +$birthdate="not a date" +$email="not an email" +$phone ... }` must pass the rules
4545
- `.name` must not be empty
46-
- `.email` must be a valid email address
4746
- `.birthdate` must pass all the rules
4847
- `.birthdate` must be a valid date in the format "2005-12-30"
4948
- For comparison with now, `.birthdate` must be a valid datetime
49+
- `.email` must be a valid email address or must be null
5050
- `.phone` must be a valid telephone number or must be null
5151
FULL_MESSAGE,
5252
[
53-
'__root__' => '`Respect\Validation\Test\Stubs\WithAttributes { +$name="" +$email="not an email" +$birthdate="not a date" +$phone ... }` must pass all the rules',
53+
'__root__' => '`Respect\Validation\Test\Stubs\WithAttributes { +$name="" +$birthdate="not a date" +$email="not an email" +$phone ... }` must pass the rules',
5454
'name' => '`.name` must not be empty',
55-
'email' => '`.email` must be a valid email address',
5655
'birthdate' => [
5756
'__root__' => '`.birthdate` must pass all the rules',
5857
'date' => '`.birthdate` must be a valid date in the format "2005-12-30"',
5958
'dateTimeDiffLessThanOrEqual' => 'For comparison with now, `.birthdate` must be a valid datetime',
6059
],
60+
'email' => '`.email` must be a valid email address or must be null',
6161
'phone' => '`.phone` must be a valid telephone number or must be null',
6262
],
6363
));
6464

65+
test('Failed attributes on the class', expectAll(
66+
fn() => v::attributes()->assert(new WithAttributes('John Doe', '2024-06-23')),
67+
'`.email` must be defined',
68+
<<<'FULL_MESSAGE'
69+
- `Respect\Validation\Test\Stubs\WithAttributes { +$name="John Doe" +$birthdate="2024-06-23" +$email=null +$phone=n ... }` must pass at least one of the rules
70+
- `.email` must be defined
71+
- `.phone` must be defined
72+
FULL_MESSAGE,
73+
[
74+
'anyOf' => [
75+
'__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',
76+
'email' => '`.email` must be defined',
77+
'phone' => '`.phone` must be defined',
78+
],
79+
],
80+
));
81+
6582
test('Multiple attributes, one failed', expectAll(
66-
fn() => v::attributes()->assert(new WithAttributes('John Doe', '[email protected]', '22 years ago')),
83+
fn() => v::attributes()->assert(new WithAttributes('John Doe', '22 years ago', '[email protected]')),
6784
'`.birthdate` must be a valid date in the format "2005-12-30"',
6885
'- `.birthdate` must be a valid date in the format "2005-12-30"',
6986
['birthdate' => '`.birthdate` must be a valid date in the format "2005-12-30"'],

Diff for: tests/fixtures/data-provider.php

+1-1
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@
140140
'tags' => ['objectType', 'withoutAttributes'],
141141
],
142142
'object with Rule attributes' => [
143-
'value' => [new WithAttributes('John Doe', '[email protected]', '1912-06-23')],
143+
'value' => [new WithAttributes('John Doe', '1912-06-23', '[email protected]')],
144144
'tags' => ['objectType', 'withAttributes'],
145145
],
146146
'anonymous class' => [

Diff for: tests/library/Stubs/WithAttributes.php

+11-12
Original file line numberDiff line numberDiff line change
@@ -9,24 +9,23 @@
99

1010
namespace Respect\Validation\Test\Stubs;
1111

12-
use Respect\Validation\Rules\Date;
13-
use Respect\Validation\Rules\DateTimeDiff;
14-
use Respect\Validation\Rules\Email;
15-
use Respect\Validation\Rules\LessThanOrEqual;
16-
use Respect\Validation\Rules\NotEmpty;
17-
use Respect\Validation\Rules\Phone;
12+
use Respect\Validation\Rules as Rule;
1813

14+
#[Rule\AnyOf(
15+
new Rule\Property('email', new Rule\NotUndef()),
16+
new Rule\Property('phone', new Rule\NotUndef()),
17+
)]
1918
final class WithAttributes
2019
{
2120
public function __construct(
22-
#[NotEmpty]
21+
#[Rule\NotEmpty]
2322
public string $name,
24-
#[Email]
25-
public string $email,
26-
#[Date('Y-m-d')]
27-
#[DateTimeDiff('years', new LessThanOrEqual(25))]
23+
#[Rule\Date('Y-m-d')]
24+
#[Rule\DateTimeDiff('years', new Rule\LessThanOrEqual(25))]
2825
public string $birthdate,
29-
#[Phone]
26+
#[Rule\Email]
27+
public ?string $email = null,
28+
#[Rule\Phone]
3029
public ?string $phone = null,
3130
public ?string $address = null,
3231
) {

Diff for: tests/unit/Rules/AttributesTest.php

+8-7
Original file line numberDiff line numberDiff line change
@@ -55,25 +55,26 @@ public static function providerForObjectsWithValidPropertyValues(): array
5555
'All' => [
5656
new WithAttributes(
5757
'John Doe',
58-
5958
'2020-06-23',
59+
6060
'+31206241111',
6161
'Amstel 1 1011 PN AMSTERDAM Noord-Holland'
6262
),
6363
],
64-
'Only required' => [new WithAttributes('Jane Doe', '[email protected]', '2017-11-30')],
64+
'Only required' => [new WithAttributes('Jane Doe', '2017-11-30', '[email protected]')],
6565
];
6666
}
6767

6868
/** @return array<array{object}> */
6969
public static function providerForObjectsWithInvalidPropertyValues(): array
7070
{
7171
return [
72-
[new WithAttributes('', 'not an email', 'not a date', 'not a phone number')],
73-
[new WithAttributes('', '[email protected]', '1912-06-23', '+1234567890')],
74-
[new WithAttributes('John Doe', 'not an email', '1912-06-23', '+1234567890')],
75-
[new WithAttributes('John Doe', '[email protected]', 'not a date', '+1234567890')],
76-
[new WithAttributes('John Doe', '[email protected]', '1912-06-23', 'not a phone number')],
72+
[new WithAttributes('Jane Doe', '2017-11-30')],
73+
[new WithAttributes('', 'not a date', 'not an email', 'not a phone number')],
74+
[new WithAttributes('', '1912-06-23', '[email protected]', '+1234567890')],
75+
[new WithAttributes('John Doe', '1912-06-23', 'not an email', '+1234567890')],
76+
[new WithAttributes('John Doe', 'not a date', '[email protected]', '+1234567890')],
77+
[new WithAttributes('John Doe', '1912-06-23', '[email protected]', 'not a phone number')],
7778
];
7879
}
7980
}

0 commit comments

Comments
 (0)