From f475c9125f3bcebf69c802a8005ea4da1288dc21 Mon Sep 17 00:00:00 2001 From: kj Date: Thu, 3 Sep 2026 15:03:28 -0300 Subject: [PATCH] feat(Validator): Add confirmed, required_with, and required_if rules --- src/Libs/Validator.php | 177 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 170 insertions(+), 7 deletions(-) diff --git a/src/Libs/Validator.php b/src/Libs/Validator.php index fd985e2..4321c1e 100644 --- a/src/Libs/Validator.php +++ b/src/Libs/Validator.php @@ -42,6 +42,16 @@ class Validator */ public static string $lastFailed = ''; + /** + * Rules that need to know the current field name and the whole data set + * (e.g. to look up sibling fields such as the `confirmed` companion). + * For these rules, checkRule() injects $field and $haystack as extra + * arguments before the user-provided ones. + * + * @var string[] + */ + private const DATA_AWARE_RULES = ['confirmed', 'required_with', 'required_if']; + /** * Validates a list of rules against the properties of an object. * @@ -54,8 +64,14 @@ class Validator { foreach ($rulesList as $target => $rules) { $rules = preg_split('/\|/', $rules); + $value = $haystack->{$target}; + + if (in_array('nullable', $rules, true) && static::isEmpty($value)) { + continue; + } + foreach ($rules as $rule) { - if (static::checkRule($haystack->{$target}, $rule)) { + if (static::checkRule($value, $rule, $target, $haystack)) { continue; } static::$lastFailed = $target . '.' . $rule; @@ -93,18 +109,30 @@ class Validator /** * Checks if a rule is met. * - * @param mixed $subject The value to verify. - * @param string $rule The rule to test. + * @param mixed $subject The value to verify. + * @param string $rule The rule to test. + * @param string|null $field Optional name of the field being validated. + * Required by "data-aware" rules to locate sibling fields. + * @param mixed $haystack Optional full data set (Neuron or array) the + * subject belongs to. Also used by data-aware rules. * * @return bool * @throws Exception If the rule is not callable. */ - public static function checkRule(mixed $subject, string $rule): bool + public static function checkRule(mixed $subject, string $rule, ?string $field = null, mixed $haystack = null): bool { [$name, $rawArguments] = static::parseRule($rule); - $method = [static::class, $name]; - $arguments = array_merge([$subject], static::splitArguments($name, $rawArguments)); + $method = [static::class, $name]; + + if (in_array($name, self::DATA_AWARE_RULES, true)) { + $arguments = array_merge( + [$subject, $field, $haystack], + static::splitArguments($name, $rawArguments) + ); + } else { + $arguments = array_merge([$subject], static::splitArguments($name, $rawArguments)); + } if (is_callable($method)) { return call_user_func_array($method, $arguments); @@ -381,6 +409,100 @@ class Validator } } + /** + * Marks a field as allowed to be empty. + * + * As a rule it always passes; the real effect happens in validateList(), + * which skips all the other rules for a field when "nullable" is present + * and the value is empty (see isEmpty()). + * + * @param mixed $subject The value to check. + * + * @return bool Always true. + */ + public static function nullable(mixed $subject): bool + { + return true; + } + + /** + * Checks that the field has a matching companion "_confirmation". + * + * @param mixed $subject The value to check. + * @param string|null $field Name of the field being validated. + * @param mixed $haystack The full data set (Neuron or array). + * + * @return bool + */ + public static function confirmed(mixed $subject, ?string $field = null, mixed $haystack = null): bool + { + if ($field === null || $haystack === null) { + return false; + } + + return static::readField($haystack, $field . '_confirmation') === $subject; + } + + /** + * Requires the field only when at least one of the listed fields is present. + * + * @param mixed $subject The value to check. + * @param string $field Name of the field being validated (unused here). + * @param mixed $haystack The full data set (Neuron or array). + * @param string ...$others Names of the fields whose presence triggers the requirement. + * + * @return bool + */ + public static function required_with( + mixed $subject, + ?string $field = null, + mixed $haystack = null, + string ...$others + ): bool { + if ($haystack === null || empty($others)) { + return true; + } + + foreach ($others as $other) { + if (!static::isEmpty(static::readField($haystack, $other))) { + return isset($subject) && !empty($subject); + } + } + + return true; + } + + /** + * Requires the field only when another field equals a given value. + * + * @param mixed $subject The value to check. + * @param string $field Name of the field being validated (unused here). + * @param mixed $haystack The full data set (Neuron or array). + * @param string|null $other Name of the field to inspect. + * @param mixed $value Expected value that triggers the requirement. + * + * @return bool + */ + public static function required_if( + mixed $subject, + ?string $field = null, + mixed $haystack = null, + ?string $other = null, + mixed $value = null + ): bool { + if ($haystack === null || $other === null) { + return true; + } + + $otherValue = static::readField($haystack, $other); + + if (is_scalar($otherValue) && (string) $otherValue === (string) $value) { + return isset($subject) && !empty($subject); + } + + return true; + } + /** * Checks if the value is a valid email address. * @@ -408,7 +530,7 @@ class Validator /** * Checks if the value is present in a list of allowed values. * - * @param mixed $subject The value to check. + * @param mixed $subject The value to check. * @param mixed ...$values A variable number of allowed values. * * @return bool @@ -417,4 +539,45 @@ class Validator { return in_array($subject, $values); } + + /** + * Reads a field from a data set that may be a Neuron object or an array. + * + * Returns null when the field is not defined. Neuron already yields null + * for undefined properties, so no notice is raised for either source. + * + * @param mixed $haystack The data set (Neuron or array). + * @param string $field The field name to read. + * + * @return mixed + */ + private static function readField(mixed $haystack, string $field): mixed + { + if (is_array($haystack)) { + return $haystack[$field] ?? null; + } + + if (is_object($haystack)) { + return $haystack->{$field}; + } + + return null; + } + + /** + * Determines whether a value counts as "empty" for optionality rules + * (nullable / required_with / required_if). + * + * Matches the conventional definition: null, an empty string or an empty + * array. Note that, unlike PHP's empty(), "0" and 0 are NOT treated as + * empty so they cannot silently bypass a required-style rule. + * + * @param mixed $value The value to test. + * + * @return bool + */ + private static function isEmpty(mixed $value): bool + { + return $value === null || $value === '' || $value === []; + } }