Skip to content

Commit 3b0402a

Browse files
committed
Add formatter shorthand aliases using formattedAs* prefix
Introduce FormattedPrefixTransformer to support shorthand method names like formattedAsMask(), formattedAsPattern(), and formattedAsDate(). These expand internally to formatted(FormatterBuilder::mask(...), validator), removing the need to manually construct FormatterBuilder instances. The formattedAs* prefix pattern avoids conflicts with validators that share names with formatters (e.g., date validator vs date formatter). The transformer handles both direct calls (formattedAsMask) and prefixed calls (notFormattedAsMask) where the prefix transformer capitalizes the remainder to FormattedAsMask. The LintMixinCommand scans formatter classes from the string-formatter package and generates corresponding alias methods across all mixin interfaces, ensuring the first parameter is always mandatory and including docblocks from formatter constructors. Assisted-by: Claude Code (claude-opus-4-5-20251101)
1 parent b0fbc08 commit 3b0402a

18 files changed

Lines changed: 708 additions & 14 deletions

docs/migrating-from-v2-to-v3.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -674,6 +674,20 @@ v::formatted(f::pattern('#### #### #### ####'), v::creditCard())->assert('123412
674674
// → "1234 1234 1234 1234" must be a credit card number
675675
```
676676

677+
Shorthand aliases are available that combine formatter creation with the validator:
678+
679+
```php
680+
v::formattedAsMask('1-4', v::email())->assert('not an email');
681+
v::formattedAsPattern('#### #### #### ####', v::creditCard())->assert('1234123412341234');
682+
```
683+
684+
These aliases work with all prefixes (`not`, `nullOr`, `key`, `property`, etc.):
685+
686+
```php
687+
v::notFormattedAsMask('1-4', v::email())->assert('user@example.com');
688+
v::keyFormattedAsPattern('cc', '#### #### #### ####', v::creditCard())->assert($data);
689+
```
690+
677691
#### ShortCircuit
678692

679693
Validates input against a series of validators, stopping at the first failure. Useful for dependent validations:
@@ -866,6 +880,10 @@ v::lengthBetween(5, 10)->assert('hello'); // passes
866880
v::minGreaterThan(0)->assert([1, 2, 3]); // passes
867881
v::maxLessThan(10)->assert([1, 2, 3]); // passes
868882

883+
// Formatted shortcuts
884+
v::formattedAsMask('1-4', v::email())->assert('invalid'); // formats as "****lid"
885+
v::formattedAsPattern('##/##', v::date())->assert('invalid'); // formats with pattern
886+
869887
// Negation prefix
870888
v::notBlank()->assert('hello'); // passes
871889
```

docs/validators/Formatted.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,49 @@ This validator is useful for displaying formatted values in error messages, maki
3030

3131
It uses [respect/string-formatter](https://github.com/Respect/StringFormatter) as the underlying formatting engine. See the [StringFormatter documentation](https://github.com/Respect/StringFormatter) for available formatters.
3232

33+
## Shorthand aliases
34+
35+
For convenience, shorthand methods are available that combine the formatter creation with the `Formatted` validator:
36+
37+
```php
38+
v::formatted(f::mask('1-@'), v::email())->assert('not an email');
39+
// → "not an email" must be an email address
40+
41+
v::formattedAsMask('1-@', v::email())->assert('not an email');
42+
// → "not an email" must be an email address
43+
44+
v::formattedAsPattern('#### #### #### ####', v::creditCard())->assert('1234123412341234');
45+
// → "1234 1234 1234 1234" must be a credit card number
46+
```
47+
48+
These aliases follow the pattern `formattedAs{FormatterName}`. The validator position varies depending on the formatter:
49+
50+
Available shorthand aliases:
51+
- `formattedAsDate(Validator $validator, string $format = 'Y-m-d H:i:s')` - validator first, all args optional
52+
- `formattedAsMask(string $range, Validator $validator, string $replacement = '*')` - validator after required `$range`
53+
- `formattedAsNumber(Validator $validator, int $decimals = 0, string $decimalSeparator = '.', string $thousandsSeparator = ',')` - validator first, all args optional
54+
- `formattedAsPattern(string $pattern, Validator $validator)` - validator after required `$pattern`
55+
56+
Examples with different validator positions:
57+
58+
```php
59+
v::formattedAsMask('1-4', v::email())->assert('not an email');
60+
// → "****an email" must be an email address
61+
62+
v::formattedAsDate(v::equals('2020-01-01'), 'd/m/Y')->assert('2024-12-25');
63+
// → "25/12/2024" must be equal to "2020-01-01"
64+
65+
v::formattedAsNumber(v::intVal(), 2, ',', '.')->assert('1234.56');
66+
// → "1.234,56" must be an integer
67+
```
68+
69+
The aliases also work with prefixes like `not`, `nullOr`, `key`, etc.:
70+
71+
```php
72+
v::notFormattedAsMask('1-@', v::email())->assert('user@example.com');
73+
// → "****@example.com" must not be an email address
74+
```
75+
3376
## Behavior
3477

3578
The validator first ensures the input is a valid string using `StringVal`. If the input passes string validation, it validates the original input using the inner validator. The formatted version is only used for display in error messages.

src-dev/Commands/LintMixinCommand.php

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
use Respect\Validation\Mixins\PropertyChain;
4242
use Respect\Validation\Mixins\UndefOrBuilder;
4343
use Respect\Validation\Mixins\UndefOrChain;
44+
use Respect\Validation\Transformers\FormattedPrefixTransformer;
4445
use Respect\Validation\Validator;
4546
use Respect\Validation\ValidatorBuilder;
4647
use Symfony\Component\Console\Attribute\AsCommand;
@@ -208,6 +209,30 @@ protected function execute(InputInterface $input, OutputInterface $output): int
208209
);
209210
}
210211

212+
$formatters = $this->scanFormatters();
213+
foreach ($formatters as [$formatterMethodName, $formatterReflection, $validatorPosition]) {
214+
$this->addFormatterAliasToInterface(
215+
$staticNamespace,
216+
$formatterMethodName,
217+
$staticInterface,
218+
$formatterReflection,
219+
$validatorPosition,
220+
$prefix,
221+
$allowList,
222+
$denyList,
223+
);
224+
$this->addFormatterAliasToInterface(
225+
$chainedNamespace,
226+
$formatterMethodName,
227+
$chainedInterface,
228+
$formatterReflection,
229+
$validatorPosition,
230+
$prefix,
231+
$allowList,
232+
$denyList,
233+
);
234+
}
235+
211236
$printer = new Printer();
212237
$printer->wrapLength = 300;
213238

@@ -274,6 +299,94 @@ private function scanValidators(string $directory): array
274299
return $names;
275300
}
276301

302+
/** @return array<array{string, ReflectionClass, int}> */
303+
private function scanFormatters(): array
304+
{
305+
$formatters = [];
306+
307+
foreach (FormattedPrefixTransformer::FORMATTERS as $formatterName => $validatorPosition) {
308+
$className = 'Respect\\StringFormatter\\' . ucfirst($formatterName) . 'Formatter';
309+
$reflection = new ReflectionClass($className);
310+
311+
// e.g. mask -> formattedAsMask
312+
$methodName = 'formattedAs' . ucfirst($formatterName);
313+
314+
$formatters[] = [$methodName, $reflection, $validatorPosition];
315+
}
316+
317+
return $formatters;
318+
}
319+
320+
/**
321+
* @param array<string> $allowList
322+
* @param array<string> $denyList
323+
*/
324+
private function addFormatterAliasToInterface(
325+
PhpNamespace $namespace,
326+
string $methodName,
327+
InterfaceType $interfaceType,
328+
ReflectionClass $formatterReflection,
329+
int $validatorPosition,
330+
string|null $prefix,
331+
array $allowList,
332+
array $denyList,
333+
): void {
334+
// Apply same allow/deny logic as the Formatted validator
335+
if ($allowList !== [] && !in_array('Formatted', $allowList, true)) {
336+
return;
337+
}
338+
339+
if ($denyList !== [] && in_array('Formatted', $denyList, true)) {
340+
return;
341+
}
342+
343+
$name = $prefix ? $prefix . ucfirst($methodName) : $methodName;
344+
$method = $interfaceType->addMethod($name)->setPublic()->setReturnType(Chain::class);
345+
346+
if (str_contains($interfaceType->getName(), 'Builder')) {
347+
$method->setStatic();
348+
}
349+
350+
if ($prefix === 'key') {
351+
$method->addParameter('key')->setType('int|string');
352+
}
353+
354+
if ($prefix === 'property') {
355+
$method->addParameter('propertyName')->setType('string');
356+
}
357+
358+
$reflectionConstructor = $formatterReflection->getConstructor();
359+
360+
$comment = $reflectionConstructor->getDocComment();
361+
if ($comment) {
362+
$method->addComment(preg_replace('@(/\*\* *| +\* +| +\*/)@', '', $comment));
363+
}
364+
365+
$formatterParams = $reflectionConstructor->getParameters();
366+
367+
// Add formatter constructor params, inserting Validator at the specified position
368+
$validatorInserted = false;
369+
foreach ($formatterParams as $index => $reflectionParameter) {
370+
if ($index === $validatorPosition && !$validatorInserted) {
371+
$namespace->addUse(Validator::class);
372+
$method->addParameter('validator')->setType(Validator::class);
373+
$validatorInserted = true;
374+
}
375+
376+
// Make parameters before the validator position mandatory
377+
$forceMandatory = $index < $validatorPosition;
378+
$this->addParameterToMethod($method, $reflectionParameter, $namespace, $forceMandatory);
379+
}
380+
381+
if ($validatorInserted) {
382+
return;
383+
}
384+
385+
// Validator goes at position 0 or after all formatter params
386+
$namespace->addUse(Validator::class);
387+
$method->addParameter('validator')->setType(Validator::class);
388+
}
389+
277390
/**
278391
* @param array<string> $allowList
279392
* @param array<string> $denyList
@@ -329,6 +442,7 @@ private function addParameterToMethod(
329442
Method $method,
330443
ReflectionParameter $reflectionParameter,
331444
PhpNamespace $namespace,
445+
bool $forceMandatory = false,
332446
): void {
333447
if ($reflectionParameter->isVariadic()) {
334448
$method->setVariadic();
@@ -364,6 +478,10 @@ private function addParameterToMethod(
364478
$parameter = $method->addParameter($reflectionParameter->getName());
365479
$parameter->setType(implode('|', $types));
366480

481+
if ($forceMandatory) {
482+
return;
483+
}
484+
367485
if (!$reflectionParameter->isDefaultValueAvailable()) {
368486
$parameter->setNullable($reflectionParameter->isOptional());
369487
}

src/ContainerRegistry.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@
3838
use Respect\Validation\Message\Parameters\PathHandler;
3939
use Respect\Validation\Message\Parameters\ResultHandler;
4040
use Respect\Validation\Message\Renderer;
41-
use Respect\Validation\Transformers\Prefix;
41+
use Respect\Validation\Transformers\FormattedPrefixTransformer;
42+
use Respect\Validation\Transformers\PrefixTransformer;
4243
use Respect\Validation\Transformers\Transformer;
4344
use Symfony\Contracts\Translation\TranslatorInterface;
4445

@@ -55,7 +56,7 @@ public static function createContainer(array $definitions = []): Container
5556
{
5657
return new Container($definitions + [
5758
PhoneNumberUtil::class => factory(static fn() => PhoneNumberUtil::getInstance()),
58-
Transformer::class => create(Prefix::class),
59+
Transformer::class => factory(static fn() => new FormattedPrefixTransformer(new PrefixTransformer())),
5960
TemplateResolver::class => create(TemplateResolver::class),
6061
TranslatorInterface::class => autowire(BypassTranslator::class),
6162
Renderer::class => autowire(InterpolationRenderer::class),

src/Mixins/Builder.php

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

src/Mixins/Chain.php

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

src/Mixins/NotBuilder.php

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

0 commit comments

Comments
 (0)