Skip to content

Commit

Permalink
Fix #223: Add method support for Callback rule (#321)
Browse files Browse the repository at this point in the history
  • Loading branch information
arogachev committed Sep 15, 2022
1 parent 4ef9bd7 commit f0b6560
Show file tree
Hide file tree
Showing 13 changed files with 282 additions and 73 deletions.
78 changes: 26 additions & 52 deletions README.md
Expand Up @@ -463,9 +463,10 @@ $data = [

##### Basic usage

Common flow is the same as you would use usual classes:
1. Declare property
2. Add rules to it
Common flow is the same as you would use usual classes:

1. Declare property.
2. Add rules to it.

```php
use Yiisoft\Validator\Rule\Count;
Expand Down Expand Up @@ -536,75 +537,48 @@ final class Post
}
```

##### Limitations

###### `Callback` rule and `callable` type
##### Callbacks

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

```php
use Attribute;
use Yiisoft\Validator\Exception\UnexpectedRuleException;
<?php

declare(strict_types=1);

namespace Yiisoft\Validator\Tests\Stub;

use Yiisoft\Validator\Result;
use Yiisoft\Validator\Rule\Number;
use Yiisoft\Validator\RuleHandlerInterface;
use Yiisoft\Validator\RuleInterface;
use Yiisoft\Validator\Rule\Callback;
use Yiisoft\Validator\ValidationContext;

#[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)]
final class ValidateXRule implements RuleInterface
final class Author
{
public function __construct()
{
}
}
#[Callback(method: 'validateName')]
private string $name;

final class ValidateXRuleHandler implements RuleHandlerInterface
{
public function validate(mixed $value, object $rule, ?ValidationContext $context = null): Result
public static function validateName(mixed $value, object $rule, ValidationContext $context): Result
{
if (!$rule instanceof ValidateXRule) {
throw new UnexpectedRuleException(ValidateXRule::class, $rule);
}

$result = new Result();
$result->addError('Custom error.');
if ($value !== 'foo') {
$result->addError('Value must be "foo"!');
}

return $result;
}
}

final class Coordinates
{
#[Number(min: -10, max: 10)]
#[ValidateXRule()]
private int $x;
#[Number(min: -10, max: 10)]
private int $y;
}
```

###### `GroupRule`

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

```php
use Yiisoft\Validator\Rule\HasLength;
use Yiisoft\Validator\Rule\Regex;

final class UserData
{
#[HasLength(min: 2, max: 20)]
#[Regex('~[a-z_\-]~i')]
private string $name;
}
```
##### Limitations

###### Nested attributes

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

So such attributes as `Each`, `Nested` and `Composite` are not allowed in PHP 8.0.
So attributes such as `Each`, `Nested` and `Composite` are not allowed in PHP 8.0.

The following example is not allowed in PHP 8.0:

Expand Down Expand Up @@ -659,8 +633,8 @@ final class Color

###### Function / method calls

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

```php
use Attribute;
Expand Down
10 changes: 10 additions & 0 deletions src/AttributeEventInterface.php
@@ -0,0 +1,10 @@
<?php

declare(strict_types=1);

namespace Yiisoft\Validator;

interface AttributeEventInterface
{
public function afterInitAttribute(DataSetInterface $dataSet): void;
}
31 changes: 23 additions & 8 deletions src/DataSet/ObjectDataSet.php
Expand Up @@ -7,6 +7,7 @@
use ReflectionAttribute;
use ReflectionObject;
use ReflectionProperty;
use Yiisoft\Validator\AttributeEventInterface;
use Yiisoft\Validator\DataSetInterface;
use Yiisoft\Validator\RuleInterface;
use Yiisoft\Validator\RulesProviderInterface;
Expand Down Expand Up @@ -41,6 +42,14 @@ public function __construct(
$this->parseObject();
}

/**
* @return object
*/
public function getObject(): object
{
return $this->object;
}

public function getRules(): iterable
{
return $this->rules;
Expand Down Expand Up @@ -73,11 +82,10 @@ public function getData(): array
// TODO: use Generator to collect attributes
private function parseObject(): void
{
$objectProvidedRules = $this->object instanceof RulesProviderInterface;
$this->dataSetProvided = $this->object instanceof DataSetInterface;

$this->rules = $objectProvidedRules ? $this->object->getRules() : [];
$objectHasRules = $this->object instanceof RulesProviderInterface;
$this->rules = $objectHasRules ? $this->object->getRules() : [];

$this->dataSetProvided = $this->object instanceof DataSetInterface;
if ($this->dataSetProvided) {
return;
}
Expand All @@ -89,10 +97,17 @@ private function parseObject(): void
}
$this->reflectionProperties[$property->getName()] = $property;

if (!$objectProvidedRules) {
$attributes = $property->getAttributes(RuleInterface::class, ReflectionAttribute::IS_INSTANCEOF);
foreach ($attributes as $attribute) {
$this->rules[$property->getName()][] = $attribute->newInstance();
if ($objectHasRules === true) {
continue;
}

$attributes = $property->getAttributes(RuleInterface::class, ReflectionAttribute::IS_INSTANCEOF);
foreach ($attributes as $attribute) {
$rule = $attribute->newInstance();
$this->rules[$property->getName()][] = $rule;

if ($rule instanceof AttributeEventInterface) {
$rule->afterInitAttribute($this);
}
}
}
Expand Down
49 changes: 44 additions & 5 deletions src/Rule/Callback.php
Expand Up @@ -4,26 +4,40 @@

namespace Yiisoft\Validator\Rule;

use Attribute;
use Closure;
use InvalidArgumentException;
use TypeError;
use Yiisoft\Validator\AttributeEventInterface;
use Yiisoft\Validator\BeforeValidationInterface;
use Yiisoft\Validator\DataSet\ObjectDataSet;
use Yiisoft\Validator\DataSetInterface;
use Yiisoft\Validator\Rule\Trait\BeforeValidationTrait;
use Yiisoft\Validator\Rule\Trait\RuleNameTrait;
use Yiisoft\Validator\Rule\Trait\SkipOnEmptyTrait;
use Yiisoft\Validator\SerializableRuleInterface;
use Yiisoft\Validator\SkipOnEmptyInterface;
use Yiisoft\Validator\ValidationContext;

final class Callback implements SerializableRuleInterface, BeforeValidationInterface, SkipOnEmptyInterface
use function get_class;

#[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)]
final class Callback implements
SerializableRuleInterface,
BeforeValidationInterface,
SkipOnEmptyInterface,
AttributeEventInterface
{
use BeforeValidationTrait;
use RuleNameTrait;
use SkipOnEmptyTrait;

public function __construct(
/**
* @var callable
* @var callable|null
*/
private $callback,
private $callback = null,
private ?string $method = null,

/**
* @var bool|callable|null
Expand All @@ -35,16 +49,41 @@ public function __construct(
*/
private ?Closure $when = null,
) {
if ($this->callback === null && $this->method === null) {
throw new InvalidArgumentException('Either "$callback" or "$method" must be specified.');
}

if ($this->callback !== null && $this->method !== null) {
throw new InvalidArgumentException('"$callback" and "$method" are mutually exclusive.');
}
}

/**
* @return callable
* @return callable|null
*/
public function getCallback(): callable
public function getCallback(): ?callable
{
return $this->callback;
}

public function getMethod(): ?string
{
return $this->method;
}

public function afterInitAttribute(DataSetInterface $dataSet): void
{
if (!$dataSet instanceof ObjectDataSet) {
return;
}

try {
$this->callback = Closure::fromCallable([get_class($dataSet->getObject()), $this->method]);
} catch (TypeError) {
throw new InvalidArgumentException('Method must exist and have public and static modifers.');
}
}

public function getOptions(): array
{
return [
Expand Down
7 changes: 6 additions & 1 deletion src/Rule/CallbackHandler.php
Expand Up @@ -4,6 +4,7 @@

namespace Yiisoft\Validator\Rule;

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

$callback = $rule->getCallback();
$callbackResult = $callback($value, $context);
if ($callback === null) {
throw new InvalidArgumentException('Using method outside of attribute scope is prohibited.');
}

$callbackResult = $callback($value, $rule, $context);

if (!$callbackResult instanceof Result) {
throw new InvalidCallbackReturnTypeException($callbackResult);
Expand Down
57 changes: 57 additions & 0 deletions tests/DataSet/PHP80/ObjectDataSet80Test.php
Expand Up @@ -4,14 +4,22 @@

namespace Yiisoft\Validator\Tests\DataSet\PHP80;

use InvalidArgumentException;
use PHPUnit\Framework\TestCase;
use Traversable;
use Yiisoft\Validator\DataSet\ObjectDataSet;
use Yiisoft\Validator\Rule\Callback;
use Yiisoft\Validator\Rule\HasLength;
use Yiisoft\Validator\Rule\Required;
use Yiisoft\Validator\RuleInterface;
use Yiisoft\Validator\Tests\Data\Post;
use Yiisoft\Validator\Tests\Data\TitleTrait;
use Yiisoft\Validator\Tests\Stub\FakeValidatorFactory;
use Yiisoft\Validator\Tests\Stub\NotRuleAttribute;
use Yiisoft\Validator\Tests\Stub\ObjectWithCallbackMethod;
use Yiisoft\Validator\Tests\Stub\ObjectWithNonExistingCallbackMethod;
use Yiisoft\Validator\Tests\Stub\ObjectWithNonPublicCallbackMethod;
use Yiisoft\Validator\Tests\Stub\ObjectWithNonStaticCallbackMethod;

final class ObjectDataSet80Test extends TestCase
{
Expand Down Expand Up @@ -109,4 +117,53 @@ public function dataProvider(): array
],
];
}

/**
* @link https://github.com/yiisoft/validator/issues/198
*/
public function testGetRulesViaTraits(): void
{
$dataSet = new ObjectDataSet(new Post());
$expectedRules = ['title' => [new HasLength(max: 255)]];

$this->assertEquals($expectedRules, $dataSet->getRules());
}

/**
* @link https://github.com/yiisoft/validator/issues/223
*/
public function testValidateWithCallbackMethod(): void
{
$dataSet = new ObjectDataSet(new ObjectWithCallbackMethod());
$validator = FakeValidatorFactory::make();

/** @var array $rules */
$rules = $dataSet->getRules();
$this->assertSame(['name'], array_keys($rules));
$this->assertCount(1, $rules['name']);
$this->assertInstanceOf(Callback::class, $rules['name'][0]);

$result = $validator->validate(['name' => 'bar'], $rules);
$this->assertSame(['name' => ['Value must be "foo"!']], $result->getErrorMessagesIndexedByPath());
}

public function validateWithWrongCallbackMethodDataProvider(): array
{
return [
[new ObjectWithNonExistingCallbackMethod()],
[new ObjectWithNonPublicCallbackMethod()],
[new ObjectWithNonStaticCallbackMethod()],
];
}

/**
* @link https://github.com/yiisoft/validator/issues/223
* @dataProvider validateWithWrongCallbackMethodDataProvider
*/
public function testValidateWithWrongCallbackMethod(object $object): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Method must exist and have public and static modifers.');
new ObjectDataSet($object);
}
}

0 comments on commit f0b6560

Please sign in to comment.