Skip to content

Commit 2be25cb

Browse files
committed
feat: Alias option
1 parent 6bc93d7 commit 2be25cb

File tree

11 files changed

+328
-26
lines changed

11 files changed

+328
-26
lines changed

composer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
"license": "MIT",
1111
"require": {
1212
"php": "^8.1",
13-
"respect/validation": "^2.3"
13+
"respect/validation": "^2.0"
1414
},
1515
"autoload": {
1616
"psr-4": {

composer.lock

Lines changed: 13 additions & 13 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/ErrorInfo.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525

2626
namespace Attributes\Validation;
2727

28+
use Attributes\Validation\Exceptions\StopValidationException;
2829
use Attributes\Validation\Exceptions\ValidationException;
2930
use Exception;
3031
use Respect\Validation\Exceptions\NestedValidationException as RespectNestedValidationException;
@@ -77,9 +78,9 @@ public function addError(Exception|string $error): void
7778

7879
if ($this->context->get('option.stopFirstError')) {
7980
if (! is_string($error)) {
80-
throw new ValidationException('Invalid data', $this, previous: $error);
81+
throw new StopValidationException('Invalid data', $this, previous: $error);
8182
}
82-
throw new ValidationException('Invalid data', $this);
83+
throw new StopValidationException('Invalid data', $this);
8384
}
8485
}
8586
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Attributes\Validation\Exceptions;
6+
7+
class InvalidOptionException extends ValidationException {}

src/Options/Alias.php

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Attributes\Validation\Options;
6+
7+
use Attribute;
8+
9+
#[Attribute(Attribute::TARGET_PROPERTY)]
10+
class Alias
11+
{
12+
private string $alias;
13+
14+
public function __construct(string $alias)
15+
{
16+
$this->alias = $alias;
17+
}
18+
19+
public function getAlias(): string
20+
{
21+
return $this->alias;
22+
}
23+
}

src/Options/AliasGenerator.php

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Attributes\Validation\Options;
6+
7+
use Attribute;
8+
use Attributes\Validation\Exceptions\InvalidOptionException;
9+
10+
#[Attribute(Attribute::TARGET_CLASS)]
11+
class AliasGenerator
12+
{
13+
private $aliasGenerator;
14+
15+
/**
16+
* @param string|callable $aliasGenerator - The alias generator. Either a callable or a string with either 'camel', 'pascal' or 'snake'
17+
*/
18+
public function __construct(string|callable $aliasGenerator)
19+
{
20+
$this->aliasGenerator = $aliasGenerator;
21+
}
22+
23+
/**
24+
* @throws InvalidOptionException
25+
*/
26+
public function getAliasGenerator(): callable
27+
{
28+
if (is_callable($this->aliasGenerator)) {
29+
return $this->aliasGenerator;
30+
}
31+
32+
switch ($this->aliasGenerator) {
33+
case 'camel':
34+
return $this->toCamel(...);
35+
case 'pascal':
36+
return $this->toPascal(...);
37+
case 'snake':
38+
return $this->toSnake(...);
39+
default:
40+
throw new InvalidOptionException("Invalid alias generator '$this->aliasGenerator'");
41+
}
42+
}
43+
44+
/**
45+
* Converts a string into camelCase
46+
*
47+
* @taken https://github.com/symfony/string/blob/7.3/ByteString.php#camel
48+
*/
49+
public function toCamel(string $propertyName): string
50+
{
51+
$parts = explode(' ', trim(ucwords(preg_replace('/[^a-zA-Z0-9\x7f-\xff]++/', ' ', $propertyName))));
52+
$parts[0] = \strlen($parts[0]) !== 1 && ctype_upper($parts[0]) ? $parts[0] : lcfirst($parts[0]);
53+
54+
return implode('', $parts);
55+
}
56+
57+
/**
58+
* Converts a string into PascalCase
59+
*/
60+
public function toPascal(string $propertyName): string
61+
{
62+
$propertyName = $this->toCamel($propertyName);
63+
64+
return ucfirst($propertyName);
65+
}
66+
67+
/**
68+
* Converts a string into snake_case
69+
*
70+
* @taken https://github.com/symfony/string/blob/7.3/ByteString.php#snake
71+
*/
72+
public function toSnake(string $propertyName): string
73+
{
74+
$propertyName = $this->toCamel($propertyName);
75+
76+
return strtolower(preg_replace(['/([A-Z]+)([A-Z][a-z])/', '/([a-z\d])([A-Z])/'], '\1_\2', $propertyName));
77+
}
78+
}

src/Validator.php

Lines changed: 48 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,14 @@
88
use Attributes\Validation\Exceptions\ContinueValidationException;
99
use Attributes\Validation\Exceptions\StopValidationException;
1010
use Attributes\Validation\Exceptions\ValidationException;
11+
use Attributes\Validation\Options as Options;
1112
use Attributes\Validation\Validators\AttributesValidator;
1213
use Attributes\Validation\Validators\ChainValidator;
1314
use Attributes\Validation\Validators\PropertyValidator;
1415
use Attributes\Validation\Validators\TypeHintValidator;
1516
use ReflectionClass;
1617
use ReflectionException;
18+
use ReflectionProperty;
1719
use Respect\Validation\Exceptions\ValidationException as RespectValidationException;
1820
use Respect\Validation\Factory;
1921

@@ -31,6 +33,7 @@ public function __construct(?PropertyValidator $validator = null, bool $stopFirs
3133
$this->context = $context ?? new Context;
3234
$this->context->set('option.stopFirstError', $stopFirstError);
3335
$this->context->set('option.strict', $strict);
36+
$this->context->set('option.alias.generator', fn (string $name) => $name);
3437
$this->validator = $this->context->getOptional(PropertyValidator::class, $validator) ?? $this->getDefaultPropertyValidator();
3538
$this->context->set(PropertyValidator::class, $this->validator);
3639

@@ -69,32 +72,30 @@ public function validate(array $data, string|object $model): object
6972
$reflectionClass = new ReflectionClass($validModel);
7073
$errorInfo = $this->context->getOptional(ErrorInfo::class) ?: new ErrorInfo($this->context);
7174
$this->context->set(ErrorInfo::class, $errorInfo, override: true);
75+
$defaultAliasGenerator = $this->getDefaultAliasGenerator($reflectionClass);
7276
foreach ($reflectionClass->getProperties() as $reflectionProperty) {
7377
$propertyName = $reflectionProperty->getName();
78+
$aliasName = $this->getAliasName($reflectionProperty, $defaultAliasGenerator);
7479
$this->context->push('internal.currentProperty', $propertyName);
7580

76-
if (! array_key_exists($propertyName, $data)) {
81+
if (! array_key_exists($aliasName, $data)) {
7782
if (! $reflectionProperty->isInitialized($validModel)) {
78-
$errorInfo->addError("Missing required property '$propertyName'");
83+
$errorInfo->addError("Missing required property '$aliasName'");
7984
}
8085

8186
$this->context->pop('internal.currentProperty');
8287

8388
continue;
8489
}
8590

86-
$propertyValue = $data[$propertyName];
91+
$propertyValue = $data[$aliasName];
8792
$property = new Property($reflectionProperty, $propertyValue, $validModel::class);
8893
$this->context->set(Property::class, $property, override: true);
8994

9095
try {
9196
$this->validator->validate($property, $this->context);
9297
$reflectionProperty->setValue($validModel, $property->getValue());
9398
} catch (ValidationException|RespectValidationException $error) {
94-
if ($error->getMessage() == 'Invalid data' && $this->context->get('option.stopFirstError')) {
95-
break;
96-
}
97-
9899
$errorInfo->addError($error);
99100
} catch (ContinueValidationException $error) {
100101
} catch (StopValidationException $error) {
@@ -119,4 +120,44 @@ private function getDefaultPropertyValidator(): PropertyValidator
119120

120121
return $chainRulesExtractor;
121122
}
123+
124+
/**
125+
* Retrieves the default alias generator for a given class
126+
*
127+
* @throws ContextPropertyException
128+
*/
129+
private function getDefaultAliasGenerator(ReflectionClass $reflectionClass): callable
130+
{
131+
$allAttributes = $reflectionClass->getAttributes(Options\AliasGenerator::class);
132+
foreach ($allAttributes as $attribute) {
133+
$instance = $attribute->newInstance();
134+
135+
return $instance->getAliasGenerator();
136+
}
137+
138+
$aliasGenerator = $this->context->get('option.alias.generator');
139+
if (is_callable($aliasGenerator)) {
140+
return $aliasGenerator;
141+
}
142+
143+
$aliasGenerator = new Options\AliasGenerator($aliasGenerator);
144+
145+
return $aliasGenerator->getAliasGenerator();
146+
}
147+
148+
/**
149+
* Retrieves the alias for a given property
150+
*/
151+
private function getAliasName(ReflectionProperty $reflectionProperty, callable $defaultAliasGenerator): string
152+
{
153+
$propertyName = $reflectionProperty->getName();
154+
$allAttributes = $reflectionProperty->getAttributes(Options\Alias::class);
155+
foreach ($allAttributes as $attribute) {
156+
$instance = $attribute->newInstance();
157+
158+
return $instance->getAlias($propertyName);
159+
}
160+
161+
return $defaultAliasGenerator($propertyName);
162+
}
122163
}

src/Validators/AttributesValidator.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
use Attributes\Validation\ErrorInfo;
99
use Attributes\Validation\Exceptions\ContextPropertyException;
1010
use Attributes\Validation\Property;
11+
use ReflectionAttribute;
1112
use ReflectionClass;
1213
use ReflectionException;
1314
use Respect\Validation\Exceptions\ValidationException as RespectValidationException;
@@ -27,15 +28,15 @@ class AttributesValidator implements PropertyValidator
2728
*/
2829
public function validate(Property $property, Context $context): void
2930
{
30-
$allAttributes = $property->getReflection()->getAttributes();
31+
$allAttributes = $property->getReflection()->getAttributes(Validatable::class, ReflectionAttribute::IS_INSTANCEOF);
3132
if (! $allAttributes) {
3233
return;
3334
}
3435

3536
$errorInfo = $context->get(ErrorInfo::class);
3637
foreach ($allAttributes as $attribute) {
3738
$className = $attribute->getName();
38-
if (! is_subclass_of($className, Validatable::class) || $className == Rules\DateTime::class) {
39+
if ($className == Rules\DateTime::class) {
3940
continue;
4041
}
4142

tests/Integration/ErrorHandlingTest.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -241,4 +241,4 @@
241241
}
242242
})
243243
->with([true, false])
244-
->group('validator', 'error-handling', 'nested', 'strict');
244+
->group('validator', 'error-handling', 'nested');

0 commit comments

Comments
 (0)