}> */ private static array $parseCache = []; private static int $parseCacheMaxSize = 512; /** * The implicit attributes. */ public array $implicitAttributes = []; /** * Create a new validation rule parser. * * @param array $data the data being validated */ public function __construct( public array $data ) { } /** * Parse the human-friendly rules into a full rules array for the validator. */ public function explode(array $rules): stdClass { $this->implicitAttributes = []; $rules = $this->explodeRules($rules); return (object) [ 'rules' => $rules, 'implicitAttributes' => $this->implicitAttributes, ]; } /** * Explode the rules into an array of explicit rules. */ protected function explodeRules(array $rules): array { foreach ($rules as $key => $rule) { $key = (string) $key; if (str_contains($key, '*')) { $rules = $this->explodeWildcardRules($rules, $key, [$rule]); unset($rules[$key]); } else { $rules[$key] = $this->explodeExplicitRule($rule, $key); } } return $rules; } /** * Explode the explicit rule into an array if necessary. */ protected function explodeExplicitRule(mixed $rule, string $attribute): array { if (is_string($rule)) { return explode('|', $rule); } if (is_object($rule)) { if ($rule instanceof Date || $rule instanceof Numeric || $rule instanceof StringRule) { // Composite rules already separate constraints; literal parameters may contain pipes. return $rule->toArray(); } return Arr::wrap($this->prepareRule($rule, $attribute)); } $rules = []; foreach ($rule as $value) { if ($value instanceof Date || $value instanceof Numeric || $value instanceof StringRule) { $rules = array_merge($rules, $value->toArray()); } else { $rules[] = $this->prepareRule($value, $attribute); } } return $rules; } /** * Prepare the given rule for the Validator. */ protected function prepareRule(mixed $rule, string $attribute): mixed { if ($rule instanceof Closure) { $rule = new ClosureValidationRule($rule); } // REMOVED: Laravel's deprecated InvokableRule contract is omitted; // implement ValidationRule::validate() instead. if ($rule instanceof ValidationRule) { $rule = InvokableValidationRule::make($rule); } if (static::ruleReducesToString($rule)) { return (string) $rule; } if ($rule instanceof CompilableRules && ! $rule instanceof RuleContract) { $cached = CoroutineContext::get(Rule::UNDOTTED_DATA_CONTEXT_KEY); $dottedData = $cached !== null && $cached['result'] === $this->data ? $cached['dotted'] : Arr::dot($this->data); return $rule->compile( $attribute, Arr::get($this->data, $attribute), $dottedData, $this->data )->rules[$attribute]; } return $rule; } /** * Determine if the parser reduces a rule object to its string form. */ public static function ruleReducesToString(mixed $rule): bool { return is_object($rule) && ! $rule instanceof Closure && ! $rule instanceof ValidationRule && ! $rule instanceof RuleContract && ! $rule instanceof ParameterizedRule && ! $rule instanceof CompilableRules && ! (($rule instanceof Exists || $rule instanceof Unique) && $rule->queryCallbacks() !== []); } /** * Define a set of rules that apply to each element in an array attribute. * * Uses a direct tree walk instead of flattening the entire data array * with Arr::dot() and regex matching. This is O(n) in the number of * matching keys instead of O(n*m) where m is the total flattened key count. */ protected function explodeWildcardRules(array $results, string $attribute, array|object|string $rules): array { $rulesList = (array) $rules; if ($this->containsCompilableRules($rulesList)) { return $this->explodeWildcardRulesCompilable($results, $attribute, $rules); } $keys = ValidationData::expandWildcardKeys($attribute, $this->data); if ($keys === []) { return $results; } $explodedRules = []; foreach ($rulesList as $rule) { if (is_string($rule)) { $explodedRules = array_merge($explodedRules, explode('|', $rule)); } else { $explodedRules = array_merge($explodedRules, $this->explodeExplicitRule($rule, $attribute)); } } foreach ($keys as $key) { $this->implicitAttributes[$attribute][] = $key; $results[$key] = array_merge( isset($results[$key]) ? $this->explodeExplicitRule($results[$key], $key) : [], $explodedRules, ); } return $results; } /** * Explode wildcard rules that contain compilable rules. * * Compiled rules receive the flattened data, which the tree walk does not build. */ protected function explodeWildcardRulesCompilable(array $results, string $attribute, array|object|string $rules): array { $keys = ValidationData::expandWildcardKeys($attribute, $this->data); if ($keys === []) { return $results; } $data = ValidationData::initializeAndGatherData($attribute, $this->data); // Reuse both data forms across nested compilation within this coroutine. // Restore the enclosing scope when a nested forEach re-enters the parser. $contextKey = Rule::UNDOTTED_DATA_CONTEXT_KEY; $hadPrevious = CoroutineContext::has($contextKey); $previous = $hadPrevious ? CoroutineContext::get($contextKey) : null; $wrappedData = Arr::wrap($data); $undottedData = Arr::undot($wrappedData); $dottedData = Arr::dot($undottedData); // Share equal arrays so cache checks do not compare every entry for each item. if ($dottedData === $wrappedData) { $dottedData = $wrappedData; } CoroutineContext::set($contextKey, [ 'input' => $wrappedData, 'result' => $undottedData, 'dotted' => $dottedData, ]); $compiledImplicitAttributes = []; try { foreach ($keys as $key) { foreach ((array) $rules as $rule) { if ($rule instanceof CompilableRules) { $rule = [$rule]; } // For mixed arrays like ['nullable', Rule::forEach(...)], separate // CompilableRules from normal items. CompilableRules get per-key // compilation. Normal items are merged as a group, // preserving array-form rules like ['required_array_keys', 'foo']. if (is_array($rule)) { $compilableItems = []; $normalItems = []; foreach ($rule as $item) { if ($item instanceof CompilableRules) { $compilableItems[] = $item; } else { $normalItems[] = $item; } } foreach ($compilableItems as $compilable) { $value = Arr::get($this->data, $key); $context = Arr::get($this->data, Str::beforeLast($key, '.')); $compiled = $compilable->compile($key, $value, $data, $context); if ($compiled->implicitAttributes !== []) { $compiledImplicitAttributes[] = $compiled->implicitAttributes; } $this->implicitAttributes[$attribute][] = $key; foreach ($compiled->rules as $compiledAttribute => $compiledRules) { $compiledAttribute = (string) $compiledAttribute; $this->mergeRulesForAttributeInto($results, $compiledAttribute, $compiledRules); } } if ($normalItems !== []) { $this->implicitAttributes[$attribute][] = $key; $this->mergeRulesForAttributeInto($results, $key, $normalItems); } } else { $this->implicitAttributes[$attribute][] = $key; $this->mergeRulesForAttributeInto($results, $key, $rule); } } } if ($compiledImplicitAttributes !== []) { // Later compiled patterns must retain precedence in the validator's reverse lookup. $this->implicitAttributes = array_merge_recursive( array_merge_recursive(...array_reverse($compiledImplicitAttributes)), $this->implicitAttributes, ); } } finally { if ($hadPrevious) { CoroutineContext::set($contextKey, $previous); } else { CoroutineContext::forget($contextKey); } } return $results; } /** * Determine if a rules array contains any CompilableRules at any nesting level. * * Checks both top-level elements and elements inside nested arrays, so * mixed rule arrays like ['nullable', Rule::forEach(...)] are detected. */ protected function containsCompilableRules(array $rules): bool { foreach ($rules as $rule) { if ($rule instanceof CompilableRules) { return true; } if (is_array($rule) && $this->containsCompilableRules($rule)) { return true; } } return false; } /** * Merge additional rules into a given attribute(s). */ public function mergeRules(array $results, array|object|string $attribute, array|object|string $rules = []): array { if (is_array($attribute)) { foreach ((array) $attribute as $innerAttribute => $innerRules) { $innerAttribute = (string) $innerAttribute; $results = $this->mergeRulesForAttribute($results, $innerAttribute, $innerRules); } return $results; } return $this->mergeRulesForAttribute( $results, $attribute, $rules ); } /** * Merge additional rules into a given attribute. */ protected function mergeRulesForAttribute(array $results, string $attribute, array|object|string $rules): array { $this->mergeRulesForAttributeInto($results, $attribute, $rules); return $results; } /** * Merge additional rules into a given attribute by reference. */ private function mergeRulesForAttributeInto(array &$results, string $attribute, array|object|string $rules): void { $merge = head($this->explodeRules([$rules])); $results[$attribute] = array_merge( isset($results[$attribute]) ? $this->explodeExplicitRule($results[$attribute], $attribute) : [], $merge ); } /** * Extract the rule name and parameters from a rule. */ public static function parse(array|object|string $rule): array { if ($rule instanceof ParameterizedRule) { $rule = $rule->toArray(); } if ($rule instanceof RuleContract || $rule instanceof CompilableRules) { return [$rule, []]; } if (is_array($rule)) { $rule = static::parseArrayRule($rule); } else { if (is_string($rule) && array_key_exists($rule, self::$parseCache)) { return self::$parseCache[$rule]; } $parsed = static::parseStringRule($rule); $parsed[0] = static::normalizeRule($parsed[0]); if (is_string($rule)) { if (count(self::$parseCache) >= self::$parseCacheMaxSize) { self::$parseCache = array_slice(self::$parseCache, (int) (self::$parseCacheMaxSize / 2), preserve_keys: true); } self::$parseCache[$rule] = $parsed; } return $parsed; } $rule[0] = static::normalizeRule($rule[0]); return $rule; } /** * Parse an array based rule. */ protected static function parseArrayRule(array $rule): array { return [StrCache::studly(trim(Arr::get($rule, 0, ''))), array_slice($rule, 1)]; } /** * Parse a string based rule. */ protected static function parseStringRule(string|Stringable $rule): array { $rule = (string) $rule; $parameters = []; // The format for specifying validation rules and parameters follows an // easy {rule}:{parameters} formatting convention. For instance the // rule "Max:3" states that the value may only be three letters. if (str_contains($rule, ':')) { [$rule, $parameter] = explode(':', $rule, 2); $parameters = static::parseParameters($rule, $parameter); } return [StrCache::studly(trim($rule)), $parameters]; } /** * Parse a parameter list. */ protected static function parseParameters(string $rule, string $parameter): array { // Builders use doubled quotes; a backslash escape corrupts trailing backslashes. return static::ruleIsRegex($rule) ? [$parameter] : str_getcsv($parameter, escape: ''); } /** * Determine if the rule is a regular expression. */ protected static function ruleIsRegex(string $rule): bool { return in_array(strtolower($rule), ['regex', 'not_regex', 'notregex'], true); } /** * Normalizes a rule so that we can accept short types. */ protected static function normalizeRule(string $rule): string { return match ($rule) { 'Int' => 'Integer', 'Bool' => 'Boolean', default => $rule, }; } /** * Determine if a rule argument names a field rather than stating a date literal. * * Behavioral callers must attempt literal parsing first. Compiler callers may * conservatively delegate because false positives only select the full path. * * @phpstan-assert-if-true string $argument */ public static function looksLikeDateFieldReference(mixed $argument): bool { if (! is_string($argument)) { return false; } if (in_array(strtolower($argument), ['today', 'yesterday', 'tomorrow', 'now'], true)) { return false; } // A name character keeps common numeric and ISO date literals inline while // still allowing digit-leading and hyphenated field path segments. return preg_match( '/^(?=.*[A-Za-z_])[A-Za-z0-9_-]+(\\\?\.[A-Za-z0-9_*-]+)*$/', $argument, ) === 1; } /** * Expand the conditional rules in the given array of rules. */ public static function filterConditionalRules(array $rules, array $data = []): array { return (new Collection($rules))->mapWithKeys(function ($attributeRules, $attribute) use ($data) { if (! is_array($attributeRules) && ! $attributeRules instanceof ConditionalRules ) { return [$attribute => $attributeRules]; } if ($attributeRules instanceof ConditionalRules) { return [$attribute => $attributeRules->passes($data) ? array_filter($attributeRules->rules($data)) : array_filter($attributeRules->defaultRules($data)), ]; } return [$attribute => (new Collection($attributeRules))->map(function ($rule) use ($data) { if (! $rule instanceof ConditionalRules) { return [$rule]; } return $rule->passes($data) ? $rule->rules($data) : $rule->defaultRules($data); })->filter()->flatten(1)->values()->all()]; })->all(); } /** * Flush all static state. */ public static function flushState(): void { self::$parseCache = []; } }