Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow to use certain rules as class attributes #1522

Merged
merged 1 commit into from
Jan 16, 2025
Merged
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
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', '[email protected]', '2020-06-23'));
v::attributes()->assert(new Person('John Doe', '2020-06-23', '[email protected]'));
// No exception

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

v::attributes()->assert(new Person('', '[email protected]', '2020-06-23', '+1234567890'));
// Message: name must not be empty
v::attributes()->assert(new Person('', '2020-06-23', '[email protected]', '+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', '[email protected]', '+12024561111'));
// Message: `.birthdate` must be a valid date in the format "2005-12-30"

v::attributes()->assert(new Person('John Doe', '[email protected]', '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', '[email protected]', '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', '[email protected]', '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('', '[email protected]', '2024-06-23')),
fn() => v::attributes()->assert(new WithAttributes('', '2024-06-23', '[email protected]')),
'`.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', '[email protected]', '2024-06-23', '+1234567890')),
fn() => v::attributes()->assert(new WithAttributes('John Doe', '2024-06-23', '[email protected]', '+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', '[email protected]', '2024-06-23', 'not a phone number')),
fn() => v::attributes()->assert(new WithAttributes('John Doe', '2024-06-23', '[email protected]', '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', '[email protected]', '22 years ago')),
fn() => v::attributes()->assert(new WithAttributes('John Doe', '22 years ago', '[email protected]')),
'`.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', '[email protected]', '1912-06-23')],
'value' => [new WithAttributes('John Doe', '1912-06-23', '[email protected]')],
'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',
'[email protected]',
'2020-06-23',
'[email protected]',
'+31206241111',
'Amstel 1 1011 PN AMSTERDAM Noord-Holland'
),
],
'Only required' => [new WithAttributes('Jane Doe', '[email protected]', '2017-11-30')],
'Only required' => [new WithAttributes('Jane Doe', '2017-11-30', '[email protected]')],
];
}

/** @return array<array{object}> */
public static function providerForObjectsWithInvalidPropertyValues(): array
{
return [
[new WithAttributes('', 'not an email', 'not a date', 'not a phone number')],
[new WithAttributes('', '[email protected]', '1912-06-23', '+1234567890')],
[new WithAttributes('John Doe', 'not an email', '1912-06-23', '+1234567890')],
[new WithAttributes('John Doe', '[email protected]', 'not a date', '+1234567890')],
[new WithAttributes('John Doe', '[email protected]', '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', '[email protected]', '+1234567890')],
[new WithAttributes('John Doe', '1912-06-23', 'not an email', '+1234567890')],
[new WithAttributes('John Doe', 'not a date', '[email protected]', '+1234567890')],
[new WithAttributes('John Doe', '1912-06-23', '[email protected]', 'not a phone number')],
];
}
}
Loading