Skip to content

Commit

Permalink
Fix #633: Add PHP attribute that sets property label for usage in err…
Browse files Browse the repository at this point in the history
…or messages
  • Loading branch information
dood- authored Dec 31, 2023
1 parent e18482f commit 3e6ebb9
Show file tree
Hide file tree
Showing 15 changed files with 267 additions and 10 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## 2.0.0 under development

- New #633: Add PHP attribute that sets property label for usage in error messages (@dood-)
- New #597, #608: Add debug collector for `yiisoft/yii-debug` (@xepozz, @vjik)
- New #610: Add `$escape` parameter to methods `Result::getAttributeErrorMessagesIndexedByPath()` and
`Result::getErrorMessagesIndexedByPath()` that allow change or disable symbol which will be escaped in value path
Expand Down
19 changes: 19 additions & 0 deletions docs/guide/en/configuring-rules-via-php-attributes.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,25 @@ final class User

> **Note:** [readonly properties] are supported only starting from PHP 8.1.
Error messages may include an `{attribute}` placeholder that is replaced with the name of the property. If you would
like the name to be replaced with a custom value, you can specify it using the `Label` attribute:

```php
use Yiisoft\Validator\Label;
use Yiisoft\Validator\Rule\Length;
use Yiisoft\Validator\Rule\Required;

final class User
{
#[Required]
#[Length(min: 1, max: 50)]
#[Label('First Name')]
public readonly string $name;
}
```

> **Note:** [readonly properties] are supported only starting from PHP 8.1.
## Configuring for multiple entities / models with relations

An example of rule set for a blog post configured via arrays only:
Expand Down
14 changes: 13 additions & 1 deletion src/DataSet/ObjectDataSet.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use Yiisoft\Validator\DataSetInterface;
use Yiisoft\Validator\DataWrapperInterface;
use Yiisoft\Validator\Helper\ObjectParser;
use Yiisoft\Validator\LabelsProviderInterface;
use Yiisoft\Validator\RulesProvider\AttributesRulesProvider;
use Yiisoft\Validator\RulesProviderInterface;
use Yiisoft\Validator\ValidatorInterface;
Expand Down Expand Up @@ -148,7 +149,7 @@
*
* @psalm-import-type RawRulesMap from ValidatorInterface
*/
final class ObjectDataSet implements RulesProviderInterface, DataWrapperInterface, AttributeTranslatorProviderInterface
final class ObjectDataSet implements RulesProviderInterface, DataWrapperInterface, LabelsProviderInterface, AttributeTranslatorProviderInterface
{
/**
* @var bool Whether an {@see $object} provided a data set by implementing {@see DataSetInterface}.
Expand Down Expand Up @@ -299,4 +300,15 @@ public function getAttributeTranslator(): ?AttributeTranslatorInterface
{
return $this->parser->getAttributeTranslator();
}

public function getValidationPropertyLabels(): array
{
if ($this->object instanceof LabelsProviderInterface) {
/** @var LabelsProviderInterface $object */
$object = $this->object;
return $object->getValidationPropertyLabels();
}

return $this->parser->getLabels();
}
}
36 changes: 33 additions & 3 deletions src/Helper/ObjectParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
use Yiisoft\Validator\AfterInitAttributeEventInterface;
use Yiisoft\Validator\AttributeTranslatorInterface;
use Yiisoft\Validator\AttributeTranslatorProviderInterface;
use Yiisoft\Validator\Label;
use Yiisoft\Validator\RuleInterface;

use function array_key_exists;
Expand Down Expand Up @@ -134,6 +135,7 @@ final class ObjectParser
'rules' => 'array',
'reflectionAttributes' => 'array',
'reflectionSource' => 'object',
'labels' => 'array',
],
])]
private static array $cache = [];
Expand Down Expand Up @@ -235,6 +237,34 @@ public function getRules(): array
return $this->prepareRules($rules);
}

/**
* Parses labels specified via {@see Label} attributes attached to class properties.
*
* @return array<string, string>
*/
public function getLabels(): array
{
if ($this->hasCacheItem('labels')) {
/** @var array<string, string> */
return $this->getCacheItem('labels');
}

$labels = [];

foreach ($this->getReflectionProperties() as $property) {
$attributes = $property->getAttributes(Label::class, ReflectionAttribute::IS_INSTANCEOF);
foreach ($attributes as $attribute) {
/** @var Label $instance */
$instance = $attribute->newInstance();
$labels[$property->getName()] = $instance->getLabel();
}
}

$this->setCacheItem('labels', $labels);

return $labels;
}

/**
* Returns a property value of the parsed object.
*
Expand Down Expand Up @@ -415,7 +445,7 @@ private function prepareRule(RuleInterface $rule, int $target): RuleInterface
* @return bool `true` if an item exists, `false` - if it does not or the cache is disabled in {@see $useCache}.
*/
private function hasCacheItem(
#[ExpectedValues(['rules', 'reflectionProperties', 'reflectionSource'])]
#[ExpectedValues(['rules', 'reflectionProperties', 'reflectionSource', 'labels'])]
string $name,
): bool {
if (!$this->useCache()) {
Expand All @@ -437,7 +467,7 @@ private function hasCacheItem(
* @return mixed Cache item value.
*/
private function getCacheItem(
#[ExpectedValues(['rules', 'reflectionProperties', 'reflectionSource'])]
#[ExpectedValues(['rules', 'reflectionProperties', 'reflectionSource', 'labels'])]
string $name,
): mixed {
/** @psalm-suppress PossiblyNullArrayOffset */
Expand All @@ -451,7 +481,7 @@ private function getCacheItem(
* @param mixed $value A new value.
*/
private function setCacheItem(
#[ExpectedValues(['rules', 'reflectionProperties', 'reflectionSource'])]
#[ExpectedValues(['rules', 'reflectionProperties', 'reflectionSource', 'labels'])]
string $name,
mixed $value,
): void {
Expand Down
21 changes: 21 additions & 0 deletions src/Label.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

namespace Yiisoft\Validator;

use Attribute;

#[Attribute(Attribute::TARGET_PROPERTY)]
final class Label
{
public function __construct(
private string $label,
) {
}

public function getLabel(): string
{
return $this->label;
}
}
16 changes: 16 additions & 0 deletions src/LabelsProviderInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

declare(strict_types=1);

namespace Yiisoft\Validator;

/**
* Provides data attribute labels.
*/
interface LabelsProviderInterface
{
/**
* @return array<string, string> A set of attribute labels.
*/
public function getValidationPropertyLabels(): array;
}
21 changes: 18 additions & 3 deletions src/ValidationContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ final class ValidationContext
*/
private ?string $attribute = null;

private ?string $attributeLabel = null;

/**
* @var AttributeTranslatorInterface|null Default attribute translator to use if attribute translator is not set.
*/
Expand Down Expand Up @@ -125,6 +127,12 @@ public function setAttributeTranslator(?AttributeTranslatorInterface $attributeT
return $this;
}

public function setAttributeLabel(?string $label): self
{
$this->attributeLabel = $label;
return $this;
}

/**
* Validate data in current context.
*
Expand Down Expand Up @@ -227,6 +235,11 @@ public function getAttribute(): ?string
return $this->attribute;
}

public function getAttributeLabel(): ?string
{
return $this->attributeLabel;
}

/**
* Get translated attribute name.
*
Expand All @@ -239,15 +252,17 @@ public function getTranslatedAttribute(): ?string
return null;
}

$label = $this->attributeLabel ?? $this->attribute;

if ($this->attributeTranslator !== null) {
return $this->attributeTranslator->translate($this->attribute);
return $this->attributeTranslator->translate($label);
}

if ($this->defaultAttributeTranslator !== null) {
return $this->defaultAttributeTranslator->translate($this->attribute);
return $this->defaultAttributeTranslator->translate($label);
}

return $this->attribute;
return $label;
}

/**
Expand Down
6 changes: 6 additions & 0 deletions src/Validator.php
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,12 @@ public function validate(

$result = new Result();
foreach ($rules as $attribute => $attributeRules) {
$context->setAttributeLabel(
$dataSet instanceof LabelsProviderInterface
? $dataSet->getValidationPropertyLabels()[$attribute] ?? null
: null,
);

if (is_int($attribute)) {
/** @psalm-suppress MixedAssignment */
$validatedData = $originalData;
Expand Down
31 changes: 31 additions & 0 deletions tests/DataSet/ObjectDataSetTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use stdClass;
use Traversable;
use Yiisoft\Validator\DataSet\ObjectDataSet;
use Yiisoft\Validator\Label;
use Yiisoft\Validator\Rule\Callback;
use Yiisoft\Validator\Rule\Equal;
use Yiisoft\Validator\Rule\Length;
Expand All @@ -21,6 +22,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\ObjectWithLabelsProvider;
use Yiisoft\Validator\Tests\Support\Data\ObjectWithRulesProvider;
use Yiisoft\Validator\Tests\Support\Data\Post;
use Yiisoft\Validator\Tests\Support\Data\TitleTrait;
Expand Down Expand Up @@ -373,4 +375,33 @@ public function testHasAttributeWithDataSetProvided(): void
$this->assertTrue($objectDataSet->hasAttribute('key1'));
$this->assertFalse($objectDataSet->hasAttribute('non-existing-key'));
}

public function objectWithLabelsProvider(): array
{
$dataSet = new ObjectDataSet(new ObjectWithLabelsProvider());
$expectedResult = ['name' => 'Имя', 'age' => 'Возраст'];

return [
[new ObjectDataSet(new ObjectWithLabelsProvider()), $expectedResult],
[new ObjectDataSet(new ObjectWithLabelsProvider()), $expectedResult], // Not a duplicate. Used to test caching.
[$dataSet, $expectedResult],
[$dataSet, $expectedResult], // Not a duplicate. Used to test caching.
[
new ObjectDataSet(new class() {
#[Required]
#[Label('Test label')]
public string $property;
}),
['property' => 'Test label'],
],
];
}

/**
* @dataProvider objectWithLabelsProvider
*/
public function testObjectWithLabelsProvider(ObjectDataSet $dataSet, array $expected): void
{
$this->assertSame($expected, $dataSet->getValidationPropertyLabels());
}
}
8 changes: 8 additions & 0 deletions tests/Helper/ObjectParserTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -116,14 +116,18 @@ public function testCache(): void
'b' => [new Number(min: 1)],
'c' => [new Number(max: 2)],
];
$expectedLabels1 = ['c' => 'd'];
$rules1 = $parser1->getRules();
$labels1 = $parser1->getLabels();
$this->assertEquals($expectedRules1, $rules1);
$cache = $cacheProperty->getValue();
$this->assertArrayHasKey($cacheKey1, $cache);
$this->assertArrayHasKey('rules', $cache[$cacheKey1]);
$this->assertArrayHasKey('reflectionProperties', $cache[$cacheKey1]);
$this->assertArrayHasKey('reflectionSource', $cache[$cacheKey1]);
$this->assertArrayHasKey('labels', $cache[$cacheKey1]);
$this->assertSame($rules1, $parser1->getRules());
$this->assertSame($labels1, $expectedLabels1);

$parser2 = new ObjectParser(new ObjectForTestingCache2());
$cacheKey2 = 'Yiisoft\Validator\Tests\Support\Data\ObjectForTestingCache2_7_0';
Expand Down Expand Up @@ -170,10 +174,14 @@ public function testDisabledCache(): void
'b' => [new Number(min: 1)],
'c' => [new Number(max: 2)],
];
$expectedLabels = ['c' => 'label'];
$rules = $parser->getRules();
$labels = $parser->getLabels();
$this->assertEquals($expectedRules, $rules);
$this->assertSame($expectedLabels, $labels);
$this->assertArrayNotHasKey($cacheKey, $cacheProperty->getValue());
$this->assertEquals($expectedRules, $parser->getRules());
$this->assertSame($expectedLabels, $parser->getLabels());
$this->assertNotSame($rules, $parser->getRules());
$this->assertArrayNotHasKey($cacheKey, $cacheProperty->getValue());
}
Expand Down
2 changes: 2 additions & 0 deletions tests/Support/Data/ObjectForTestingCache1.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace Yiisoft\Validator\Tests\Support\Data;

use Yiisoft\Validator\Label;
use Yiisoft\Validator\Rule\Number;
use Yiisoft\Validator\Rule\Required;
use Yiisoft\Validator\Tests\Helper\ObjectParserTest;
Expand All @@ -19,6 +20,7 @@ final class ObjectForTestingCache1
#[Number(min: 1)]
protected int $b = 3;

#[Label('d')]
#[Number(max: 2)]
private int $c = 4;
}
2 changes: 2 additions & 0 deletions tests/Support/Data/ObjectForTestingDisabledCache.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace Yiisoft\Validator\Tests\Support\Data;

use Yiisoft\Validator\Label;
use Yiisoft\Validator\Rule\Number;
use Yiisoft\Validator\Rule\Required;
use Yiisoft\Validator\Tests\Helper\ObjectParserTest;
Expand All @@ -20,5 +21,6 @@ final class ObjectForTestingDisabledCache
protected int $b = 3;

#[Number(max: 2)]
#[Label('label')]
private int $c = 4;
}
32 changes: 32 additions & 0 deletions tests/Support/Data/ObjectWithLabelsProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

declare(strict_types=1);

namespace Yiisoft\Validator\Tests\Support\Data;

use Yiisoft\Validator\Label;
use Yiisoft\Validator\LabelsProviderInterface;
use Yiisoft\Validator\Rule\Number;
use Yiisoft\Validator\Rule\Required;

final class ObjectWithLabelsProvider implements LabelsProviderInterface
{
#[Required(message: '{attribute} cannot be blank.')]
public string $name = '';

#[Number(min: 21, lessThanMinMessage: '{attribute} must be no less than {min}.')]
#[Label('test age')]
protected int $age = 17;

#[Number(max: 100)]
#[Label('test')]
private int $number = 42;

public function getValidationPropertyLabels(): array
{
return [
'name' => 'Имя',
'age' => 'Возраст',
];
}
}
Loading

0 comments on commit 3e6ebb9

Please sign in to comment.