respect-validation/library/Rules/KeySet.php
Henrique Moody 2aa5e39c54
Improve KeySet rule
After changes in the key-related rules, the KeySet rule became unusable.
Besides, when evaluating an input, it wasn't reporting every single
failure because it would not validate the items in the array if they had
missing or extra keys.

This commit will make several improvements to the rule. It will create
some not(keyExists($key)) rules for the extra keys, which makes the
error reporting much better. A limit of 10 additional keys will show up
when asserting an input with extra keys. I put that limit in place to
prevent the creation of too many rules.
2024-12-02 20:09:47 +01:00

143 lines
4.2 KiB
PHP

<?php
/*
* Copyright (c) Alexandre Gomes Gaigalas <alganet@gmail.com>
* SPDX-License-Identifier: MIT
*/
declare(strict_types=1);
namespace Respect\Validation\Rules;
use Respect\Validation\Exceptions\InvalidRuleConstructorException;
use Respect\Validation\Helpers\CanBindEvaluateRule;
use Respect\Validation\Message\Template;
use Respect\Validation\Result;
use Respect\Validation\Rules\Core\KeyRelated;
use Respect\Validation\Rules\Core\Standard;
use Respect\Validation\Validatable;
use Respect\Validation\Validator;
use function array_diff;
use function array_filter;
use function array_keys;
use function array_map;
use function array_merge;
use function array_reduce;
use function array_slice;
#[Template(
'{{name}} validation failed',
'{{name}} validation passed',
)]
#[Template(
'{{name}} contains both missing and extra keys',
'{{name}} contains no missing or extra keys.',
self::TEMPLATE_BOTH
)]
#[Template(
'{{name}} contains extra keys',
'{{name}} contains no extra keys',
self::TEMPLATE_EXTRA_KEYS
)]
#[Template(
'{{name}} contains missing keys',
'{{name}} contains no missing keys',
self::TEMPLATE_MISSING_KEYS
)]
final class KeySet extends Standard
{
use CanBindEvaluateRule;
public const TEMPLATE_BOTH = '__both__';
public const TEMPLATE_EXTRA_KEYS = '__extra_keys__';
public const TEMPLATE_MISSING_KEYS = '__missing_keys__';
private const MAX_DIFF_KEYS = 10;
/** @var array<KeyRelated> */
private readonly array $rules;
/** @var array<int|string> */
private readonly array $allKeys;
/** @var array<int|string> */
private readonly array $mandatoryKeys;
public function __construct(Validatable $rule, Validatable ...$rules)
{
$this->rules = $this->extractKeyRelatedRules(array_merge([$rule], $rules));
$this->allKeys = array_map(static fn(KeyRelated $rule) => $rule->getKey(), $this->rules);
$this->mandatoryKeys = array_map(
static fn(KeyRelated $rule) => $rule->getKey(),
array_filter($this->rules, static fn(KeyRelated $rule) => !$rule instanceof KeyOptional),
);
}
public function evaluate(mixed $input): Result
{
$result = $this->bindEvaluate(new ArrayType(), $this, $input);
if (!$result->isValid) {
return $result;
}
$keys = array_keys($input);
$children = array_map(
static fn (Validatable $rule) => $rule->evaluate($input),
array_merge($this->rules, array_map(
static fn(string|int $key) => new Not(new KeyExists($key)),
array_slice(array_diff($keys, $this->allKeys), 0, self::MAX_DIFF_KEYS)
))
);
$isValid = array_reduce($children, static fn (bool $carry, Result $result) => $carry && $result->isValid, true);
$template = $this->getTemplateFromKeys($keys);
return (new Result($isValid, $input, $this, [], $template))->withChildren(...$children);
}
/**
* @param array<Validatable> $rules
*
* @return array<KeyRelated>
*/
private function extractKeyRelatedRules(array $rules): array
{
$keyRelatedRules = [];
foreach ($rules as $rule) {
if ($rule instanceof KeyRelated) {
$keyRelatedRules[] = $rule;
continue;
}
if (!$rule instanceof Validator) {
throw new InvalidRuleConstructorException('You must provide only key-related rules');
}
$keyRelatedRules = array_merge($keyRelatedRules, $this->extractKeyRelatedRules($rule->getRules()));
}
return $keyRelatedRules;
}
/** @param array<mixed> $keys */
private function getTemplateFromKeys(array $keys): string
{
$extraKeys = array_diff($keys, $this->allKeys);
$missingKeys = array_diff($this->mandatoryKeys, $keys);
if ($extraKeys !== [] && $missingKeys !== []) {
return self::TEMPLATE_BOTH;
}
if ($extraKeys !== []) {
return self::TEMPLATE_EXTRA_KEYS;
}
if ($missingKeys !== []) {
return self::TEMPLATE_MISSING_KEYS;
}
return self::TEMPLATE_STANDARD;
}
}