Skip to content

Commit f0b6560

Browse files
authored
Fix #223: Add method support for Callback rule (#321)
1 parent 4ef9bd7 commit f0b6560

13 files changed

Lines changed: 282 additions & 73 deletions

README.md

Lines changed: 26 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -463,9 +463,10 @@ $data = [
463463

464464
##### Basic usage
465465

466-
Common flow is the same as you would use usual classes:
467-
1. Declare property
468-
2. Add rules to it
466+
Common flow is the same as you would use usual classes:
467+
468+
1. Declare property.
469+
2. Add rules to it.
469470

470471
```php
471472
use Yiisoft\Validator\Rule\Count;
@@ -536,75 +537,48 @@ final class Post
536537
}
537538
```
538539

539-
##### Limitations
540-
541-
###### `Callback` rule and `callable` type
540+
##### Callbacks
542541

543-
`Callback` rule is not supported, also you can't use `callable` type with attributes. Use custom rule instead.
542+
`Callback::$callback` property is not supported, also you can't use `callable` type with attributes. However,
543+
`Callback::$method` can be set instead:
544544

545545
```php
546-
use Attribute;
547-
use Yiisoft\Validator\Exception\UnexpectedRuleException;
546+
<?php
547+
548+
declare(strict_types=1);
549+
550+
namespace Yiisoft\Validator\Tests\Stub;
551+
548552
use Yiisoft\Validator\Result;
549-
use Yiisoft\Validator\Rule\Number;
550-
use Yiisoft\Validator\RuleHandlerInterface;
551-
use Yiisoft\Validator\RuleInterface;
553+
use Yiisoft\Validator\Rule\Callback;
552554
use Yiisoft\Validator\ValidationContext;
553555

554-
#[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)]
555-
final class ValidateXRule implements RuleInterface
556+
final class Author
556557
{
557-
public function __construct()
558-
{
559-
}
560-
}
558+
#[Callback(method: 'validateName')]
559+
private string $name;
561560

562-
final class ValidateXRuleHandler implements RuleHandlerInterface
563-
{
564-
public function validate(mixed $value, object $rule, ?ValidationContext $context = null): Result
561+
public static function validateName(mixed $value, object $rule, ValidationContext $context): Result
565562
{
566-
if (!$rule instanceof ValidateXRule) {
567-
throw new UnexpectedRuleException(ValidateXRule::class, $rule);
568-
}
569-
570563
$result = new Result();
571-
$result->addError('Custom error.');
564+
if ($value !== 'foo') {
565+
$result->addError('Value must be "foo"!');
566+
}
572567

573568
return $result;
574569
}
575570
}
576-
577-
final class Coordinates
578-
{
579-
#[Number(min: -10, max: 10)]
580-
#[ValidateXRule()]
581-
private int $x;
582-
#[Number(min: -10, max: 10)]
583-
private int $y;
584-
}
585571
```
586572

587-
###### `GroupRule`
588-
589-
`GroupRule` is not supported, but it's unnecessary since multiple attributes can be used for one property.
573+
Note that the method must exist and have public and static modifiers.
590574

591-
```php
592-
use Yiisoft\Validator\Rule\HasLength;
593-
use Yiisoft\Validator\Rule\Regex;
594-
595-
final class UserData
596-
{
597-
#[HasLength(min: 2, max: 20)]
598-
#[Regex('~[a-z_\-]~i')]
599-
private string $name;
600-
}
601-
```
575+
##### Limitations
602576

603577
###### Nested attributes
604578

605579
PHP 8.0 supports attributes, but nested declaration is allowed only in PHP 8.1 and above.
606580

607-
So such attributes as `Each`, `Nested` and `Composite` are not allowed in PHP 8.0.
581+
So attributes such as `Each`, `Nested` and `Composite` are not allowed in PHP 8.0.
608582

609583
The following example is not allowed in PHP 8.0:
610584

@@ -659,8 +633,8 @@ final class Color
659633

660634
###### Function / method calls
661635

662-
You can't use a function / method call result with attributes. Like with `Callback` rule and callable, this problem can
663-
be overcome with custom rule.
636+
You can't use a function / method call result with attributes. This problem can be overcome either with custom rule or
637+
`Callback::$method` property. An example of custom rule:
664638

665639
```php
666640
use Attribute;

src/AttributeEventInterface.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Yiisoft\Validator;
6+
7+
interface AttributeEventInterface
8+
{
9+
public function afterInitAttribute(DataSetInterface $dataSet): void;
10+
}

src/DataSet/ObjectDataSet.php

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use ReflectionAttribute;
88
use ReflectionObject;
99
use ReflectionProperty;
10+
use Yiisoft\Validator\AttributeEventInterface;
1011
use Yiisoft\Validator\DataSetInterface;
1112
use Yiisoft\Validator\RuleInterface;
1213
use Yiisoft\Validator\RulesProviderInterface;
@@ -41,6 +42,14 @@ public function __construct(
4142
$this->parseObject();
4243
}
4344

45+
/**
46+
* @return object
47+
*/
48+
public function getObject(): object
49+
{
50+
return $this->object;
51+
}
52+
4453
public function getRules(): iterable
4554
{
4655
return $this->rules;
@@ -73,11 +82,10 @@ public function getData(): array
7382
// TODO: use Generator to collect attributes
7483
private function parseObject(): void
7584
{
76-
$objectProvidedRules = $this->object instanceof RulesProviderInterface;
77-
$this->dataSetProvided = $this->object instanceof DataSetInterface;
78-
79-
$this->rules = $objectProvidedRules ? $this->object->getRules() : [];
85+
$objectHasRules = $this->object instanceof RulesProviderInterface;
86+
$this->rules = $objectHasRules ? $this->object->getRules() : [];
8087

88+
$this->dataSetProvided = $this->object instanceof DataSetInterface;
8189
if ($this->dataSetProvided) {
8290
return;
8391
}
@@ -89,10 +97,17 @@ private function parseObject(): void
8997
}
9098
$this->reflectionProperties[$property->getName()] = $property;
9199

92-
if (!$objectProvidedRules) {
93-
$attributes = $property->getAttributes(RuleInterface::class, ReflectionAttribute::IS_INSTANCEOF);
94-
foreach ($attributes as $attribute) {
95-
$this->rules[$property->getName()][] = $attribute->newInstance();
100+
if ($objectHasRules === true) {
101+
continue;
102+
}
103+
104+
$attributes = $property->getAttributes(RuleInterface::class, ReflectionAttribute::IS_INSTANCEOF);
105+
foreach ($attributes as $attribute) {
106+
$rule = $attribute->newInstance();
107+
$this->rules[$property->getName()][] = $rule;
108+
109+
if ($rule instanceof AttributeEventInterface) {
110+
$rule->afterInitAttribute($this);
96111
}
97112
}
98113
}

src/Rule/Callback.php

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,26 +4,40 @@
44

55
namespace Yiisoft\Validator\Rule;
66

7+
use Attribute;
78
use Closure;
9+
use InvalidArgumentException;
10+
use TypeError;
11+
use Yiisoft\Validator\AttributeEventInterface;
812
use Yiisoft\Validator\BeforeValidationInterface;
13+
use Yiisoft\Validator\DataSet\ObjectDataSet;
14+
use Yiisoft\Validator\DataSetInterface;
915
use Yiisoft\Validator\Rule\Trait\BeforeValidationTrait;
1016
use Yiisoft\Validator\Rule\Trait\RuleNameTrait;
1117
use Yiisoft\Validator\Rule\Trait\SkipOnEmptyTrait;
1218
use Yiisoft\Validator\SerializableRuleInterface;
1319
use Yiisoft\Validator\SkipOnEmptyInterface;
1420
use Yiisoft\Validator\ValidationContext;
1521

16-
final class Callback implements SerializableRuleInterface, BeforeValidationInterface, SkipOnEmptyInterface
22+
use function get_class;
23+
24+
#[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)]
25+
final class Callback implements
26+
SerializableRuleInterface,
27+
BeforeValidationInterface,
28+
SkipOnEmptyInterface,
29+
AttributeEventInterface
1730
{
1831
use BeforeValidationTrait;
1932
use RuleNameTrait;
2033
use SkipOnEmptyTrait;
2134

2235
public function __construct(
2336
/**
24-
* @var callable
37+
* @var callable|null
2538
*/
26-
private $callback,
39+
private $callback = null,
40+
private ?string $method = null,
2741

2842
/**
2943
* @var bool|callable|null
@@ -35,16 +49,41 @@ public function __construct(
3549
*/
3650
private ?Closure $when = null,
3751
) {
52+
if ($this->callback === null && $this->method === null) {
53+
throw new InvalidArgumentException('Either "$callback" or "$method" must be specified.');
54+
}
55+
56+
if ($this->callback !== null && $this->method !== null) {
57+
throw new InvalidArgumentException('"$callback" and "$method" are mutually exclusive.');
58+
}
3859
}
3960

4061
/**
41-
* @return callable
62+
* @return callable|null
4263
*/
43-
public function getCallback(): callable
64+
public function getCallback(): ?callable
4465
{
4566
return $this->callback;
4667
}
4768

69+
public function getMethod(): ?string
70+
{
71+
return $this->method;
72+
}
73+
74+
public function afterInitAttribute(DataSetInterface $dataSet): void
75+
{
76+
if (!$dataSet instanceof ObjectDataSet) {
77+
return;
78+
}
79+
80+
try {
81+
$this->callback = Closure::fromCallable([get_class($dataSet->getObject()), $this->method]);
82+
} catch (TypeError) {
83+
throw new InvalidArgumentException('Method must exist and have public and static modifers.');
84+
}
85+
}
86+
4887
public function getOptions(): array
4988
{
5089
return [

src/Rule/CallbackHandler.php

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
namespace Yiisoft\Validator\Rule;
66

7+
use InvalidArgumentException;
78
use Yiisoft\Translator\TranslatorInterface;
89
use Yiisoft\Validator\Exception\InvalidCallbackReturnTypeException;
910
use Yiisoft\Validator\Exception\UnexpectedRuleException;
@@ -24,7 +25,11 @@ public function validate(mixed $value, object $rule, ValidationContext $context)
2425
}
2526

2627
$callback = $rule->getCallback();
27-
$callbackResult = $callback($value, $context);
28+
if ($callback === null) {
29+
throw new InvalidArgumentException('Using method outside of attribute scope is prohibited.');
30+
}
31+
32+
$callbackResult = $callback($value, $rule, $context);
2833

2934
if (!$callbackResult instanceof Result) {
3035
throw new InvalidCallbackReturnTypeException($callbackResult);

tests/DataSet/PHP80/ObjectDataSet80Test.php

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,22 @@
44

55
namespace Yiisoft\Validator\Tests\DataSet\PHP80;
66

7+
use InvalidArgumentException;
78
use PHPUnit\Framework\TestCase;
89
use Traversable;
910
use Yiisoft\Validator\DataSet\ObjectDataSet;
11+
use Yiisoft\Validator\Rule\Callback;
1012
use Yiisoft\Validator\Rule\HasLength;
1113
use Yiisoft\Validator\Rule\Required;
1214
use Yiisoft\Validator\RuleInterface;
15+
use Yiisoft\Validator\Tests\Data\Post;
1316
use Yiisoft\Validator\Tests\Data\TitleTrait;
17+
use Yiisoft\Validator\Tests\Stub\FakeValidatorFactory;
1418
use Yiisoft\Validator\Tests\Stub\NotRuleAttribute;
19+
use Yiisoft\Validator\Tests\Stub\ObjectWithCallbackMethod;
20+
use Yiisoft\Validator\Tests\Stub\ObjectWithNonExistingCallbackMethod;
21+
use Yiisoft\Validator\Tests\Stub\ObjectWithNonPublicCallbackMethod;
22+
use Yiisoft\Validator\Tests\Stub\ObjectWithNonStaticCallbackMethod;
1523

1624
final class ObjectDataSet80Test extends TestCase
1725
{
@@ -109,4 +117,53 @@ public function dataProvider(): array
109117
],
110118
];
111119
}
120+
121+
/**
122+
* @link https://github.com/yiisoft/validator/issues/198
123+
*/
124+
public function testGetRulesViaTraits(): void
125+
{
126+
$dataSet = new ObjectDataSet(new Post());
127+
$expectedRules = ['title' => [new HasLength(max: 255)]];
128+
129+
$this->assertEquals($expectedRules, $dataSet->getRules());
130+
}
131+
132+
/**
133+
* @link https://github.com/yiisoft/validator/issues/223
134+
*/
135+
public function testValidateWithCallbackMethod(): void
136+
{
137+
$dataSet = new ObjectDataSet(new ObjectWithCallbackMethod());
138+
$validator = FakeValidatorFactory::make();
139+
140+
/** @var array $rules */
141+
$rules = $dataSet->getRules();
142+
$this->assertSame(['name'], array_keys($rules));
143+
$this->assertCount(1, $rules['name']);
144+
$this->assertInstanceOf(Callback::class, $rules['name'][0]);
145+
146+
$result = $validator->validate(['name' => 'bar'], $rules);
147+
$this->assertSame(['name' => ['Value must be "foo"!']], $result->getErrorMessagesIndexedByPath());
148+
}
149+
150+
public function validateWithWrongCallbackMethodDataProvider(): array
151+
{
152+
return [
153+
[new ObjectWithNonExistingCallbackMethod()],
154+
[new ObjectWithNonPublicCallbackMethod()],
155+
[new ObjectWithNonStaticCallbackMethod()],
156+
];
157+
}
158+
159+
/**
160+
* @link https://github.com/yiisoft/validator/issues/223
161+
* @dataProvider validateWithWrongCallbackMethodDataProvider
162+
*/
163+
public function testValidateWithWrongCallbackMethod(object $object): void
164+
{
165+
$this->expectException(InvalidArgumentException::class);
166+
$this->expectExceptionMessage('Method must exist and have public and static modifers.');
167+
new ObjectDataSet($object);
168+
}
112169
}

0 commit comments

Comments
 (0)