diff --git a/CHANGELOG.md b/CHANGELOG.md index dcd662471..bceefe4c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ `getRanges()`, `getNetworks()`, `isAllowed()` methods (@vjik) - Enh #746: Use `NEGATION_CHARACTER` constant from `network-utilities` package in `IpHandler` instead of declaring its own constant (@arogachev) +- Chg #747: Merge rules from PHP attributes with rules provided via `getRules()` method (@vjik) ## 2.0.0 August 02, 2024 diff --git a/src/DataSet/ObjectDataSet.php b/src/DataSet/ObjectDataSet.php index c5cfc796b..e1612286c 100644 --- a/src/DataSet/ObjectDataSet.php +++ b/src/DataSet/ObjectDataSet.php @@ -5,16 +5,22 @@ namespace Yiisoft\Validator\DataSet; use ReflectionProperty; +use Traversable; use Yiisoft\Validator\PropertyTranslatorInterface; use Yiisoft\Validator\PropertyTranslatorProviderInterface; use Yiisoft\Validator\DataSetInterface; use Yiisoft\Validator\DataWrapperInterface; use Yiisoft\Validator\Helper\ObjectParser; use Yiisoft\Validator\LabelsProviderInterface; +use Yiisoft\Validator\RuleInterface; use Yiisoft\Validator\RulesProvider\AttributesRulesProvider; use Yiisoft\Validator\RulesProviderInterface; use Yiisoft\Validator\ValidatorInterface; +use function array_unshift; +use function is_int; +use function is_iterable; + /** * A data set for object data. The object passed to this data set can provide rules and data by implementing * {@see RulesProviderInterface} and {@see DataSetInterface}. Alternatively this data set allows getting rules from PHP @@ -181,7 +187,7 @@ public function __construct( /** * @var object An object containing rules and data. */ - private object $object, + private readonly object $object, int $propertyVisibility = ReflectionProperty::IS_PRIVATE | ReflectionProperty::IS_PROTECTED | ReflectionProperty::IS_PUBLIC, @@ -197,10 +203,11 @@ public function __construct( } /** - * Returns {@see $object} rules specified via {@see RulesProviderInterface::getRules()} implementation or parsed + * Returns {@see $object} rules specified via {@see RulesProviderInterface::getRules()} implementation or/and parsed * from attributes attached to class properties and class itself. For the latter case repetitive calls utilize cache - * if it's enabled in {@see $useCache}. Rules provided via separate method have a higher priority over attributes, - * so, when used together, the latter ones will be ignored without exception. + * if it's enabled in {@see $useCache}. Rules provided via separate method have a lower priority over + * PHP attributes, so, when used together, all rules will be merged, but rules from PHP attributes will be applied + * first. * * @return iterable The resulting rules is an array with the following structure: * @@ -218,17 +225,41 @@ public function getRules(): iterable if ($this->rulesProvided) { /** @var RulesProviderInterface $object */ $object = $this->object; - - return $object->getRules(); + $rules = $object->getRules(); + } else { + $rules = []; } - // Providing data set assumes object has its own rules getting logic. So further parsing of rules is skipped - // intentionally. + // Providing data set assumes object has its own rules getting logic. + // So further parsing of rules is skipped intentionally. if ($this->dataSetProvided) { - return []; + return $rules; + } + + // Merge rules from `RulesProviderInterface` implementation and parsed from PHP attributes. + $rules = $rules instanceof Traversable ? iterator_to_array($rules) : $rules; + foreach ($this->parser->getRules() as $key => $value) { + if (is_int($key)) { + array_unshift($rules, $value); + continue; + } + + /** + * @psalm-var list $value If `$key` is string, then `$value` is array of rules + * @see ObjectParser::getRules() + */ + + if (!isset($rules[$key])) { + $rules[$key] = $value; + continue; + } + + $rules[$key] = is_iterable($rules[$key]) + ? [...$value, ...$rules[$key]] + : [...$value, $rules[$key]]; } - return $this->parser->getRules(); + return $rules; } /** diff --git a/tests/DataSet/ObjectDataSetTest.php b/tests/DataSet/ObjectDataSetTest.php index 30ee01cca..29e6b24dc 100644 --- a/tests/DataSet/ObjectDataSetTest.php +++ b/tests/DataSet/ObjectDataSetTest.php @@ -13,7 +13,9 @@ use Yiisoft\Validator\Label; use Yiisoft\Validator\Rule\Callback; use Yiisoft\Validator\Rule\Equal; +use Yiisoft\Validator\Rule\GreaterThan; use Yiisoft\Validator\Rule\Length; +use Yiisoft\Validator\Rule\Number; use Yiisoft\Validator\Rule\Required; use Yiisoft\Validator\RuleInterface; use Yiisoft\Validator\Tests\Support\Data\ObjectWithCallbackMethod\ObjectWithCallbackMethod; @@ -22,6 +24,7 @@ use Yiisoft\Validator\Tests\Support\Data\ObjectWithDataSetAndRulesProvider; use Yiisoft\Validator\Tests\Support\Data\ObjectWithDifferentPropertyVisibility; use Yiisoft\Validator\Tests\Support\Data\ObjectWithDynamicDataSet; +use Yiisoft\Validator\Tests\Support\Data\ObjectWithIterablePropertyRules; use Yiisoft\Validator\Tests\Support\Data\ObjectWithLabelsProvider; use Yiisoft\Validator\Tests\Support\Data\ObjectWithRulesProvider; use Yiisoft\Validator\Tests\Support\Data\Post; @@ -151,10 +154,31 @@ public function testObjectWithRulesProvider(ObjectDataSet $dataSet): void $this->assertSame(42, $dataSet->getPropertyValue('number')); $this->assertNull($dataSet->getPropertyValue('non-exist')); - $this->assertSame(['age'], array_keys($rules)); - $this->assertCount(2, $rules['age']); - $this->assertInstanceOf(Required::class, $rules['age'][0]); - $this->assertInstanceOf(Equal::class, $rules['age'][1]); + $this->assertSame(['age', 'name', 'number'], array_keys($rules)); + $this->assertCount(3, $rules['age']); + $this->assertInstanceOf(Number::class, $rules['age'][0]); + $this->assertInstanceOf(Required::class, $rules['age'][1]); + $this->assertInstanceOf(Equal::class, $rules['age'][2]); + } + + public function testObjectWithIterablePropertyRules(): void + { + $dataSet = (new ObjectDataSet(new ObjectWithIterablePropertyRules())); + $rules = $dataSet->getRules(); + + $this->assertSame(['name' => '', 'age' => 17, 'number' => 42], $dataSet->getData()); + + $this->assertSame('', $dataSet->getPropertyValue('name')); + $this->assertSame(17, $dataSet->getPropertyValue('age')); + $this->assertSame(42, $dataSet->getPropertyValue('number')); + $this->assertNull($dataSet->getPropertyValue('non-exist')); + + $this->assertSame(['age', 'name', 'number'], array_keys($rules)); + $this->assertCount(4, $rules['age']); + $this->assertInstanceOf(GreaterThan::class, $rules['age'][0]); + $this->assertInstanceOf(Number::class, $rules['age'][1]); + $this->assertInstanceOf(Required::class, $rules['age'][2]); + $this->assertInstanceOf(Equal::class, $rules['age'][3]); } public function objectWithDataSetAndRulesProviderDataProvider(): array @@ -391,8 +415,11 @@ public function objectWithLabelsProvider(): array #[Required] #[Label('Test label')] public string $property; + + #[Label('Test label 2')] + public string $property2; }), - ['property' => 'Test label'], + ['property' => 'Test label', 'property2' => 'Test label 2'], ], ]; } diff --git a/tests/Support/Data/ObjectWithIterablePropertyRules.php b/tests/Support/Data/ObjectWithIterablePropertyRules.php new file mode 100644 index 000000000..6ea60770f --- /dev/null +++ b/tests/Support/Data/ObjectWithIterablePropertyRules.php @@ -0,0 +1,32 @@ + new ArrayObject([new Required(), new Equal(25)]), + ]; + } +} diff --git a/tests/ValidatorTest.php b/tests/ValidatorTest.php index d7693a309..440efc418 100644 --- a/tests/ValidatorTest.php +++ b/tests/ValidatorTest.php @@ -127,7 +127,8 @@ public function dataDataAndRulesCombinations(): array ], 'rules-provider-object-and-no-rules' => [ [ - 'age' => ['Age must be equal to "25".'], + 'age' => ['Age must be no less than 21.', 'Age must be equal to "25".'], + 'name' => ['Name cannot be blank.'], ], new ObjectWithRulesProvider(), null,