Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[9.x] Patch regex rule parsing due to Rule::forEach() #40941

Merged
merged 12 commits into from
Feb 11, 2022
Merged

[9.x] Patch regex rule parsing due to Rule::forEach() #40941

merged 12 commits into from
Feb 11, 2022

Conversation

stevebauman
Copy link
Contributor

Description

This PR fixes #40924.

This also allows the ability to supply a regex validation rule inside of a single string-based rule, instead of requiring it to be an array.

Before:

This would previously fail in Laravel:

$data = ['items' => [['type' => 'admin']]]

// Fails. preg_match throws malformed regex exception.
$rules = ['items.*' => 'regex:/^(super|admin)$/i'];

// Passes.
$rules = ['items.*' => ['regex:/^(super|admin)$/i']];

Validator::make($data, $rules)->passes();

After:

$data = ['items' => [['type' => 'admin']]]

// Passes.
$rules = ['items.*' => 'regex:/^(super|admin)$/i'];

// Passes.
$rules = ['items.*' => ['regex:/^(super|admin)$/i']];

Validator::make($data, $rules)->passes();

Notes

I've added various test cases to ensure parsing is done properly. However, maybe we should introduce an exception when preg_match() throws an exception due to a malformed regex which would indicate to the developer they must wrap the regex rule in an array when using multiple rules?

Let me know your thoughts. If you like the idea, I will update my PR to include this exception.

} elseif (is_object($rule)) {
[$name] = static::parseStringRule($rule);

return explode('|', $rule, ...array_filter([static::ruleIsRegex($name)]));
Copy link
Contributor Author

@stevebauman stevebauman Feb 10, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've used the spread operator to completely omit the third ($limit) parameter if the rule is not regex.

If the rule is not a regex:

  1. false will be returned from ruleIsRegex()
  2. array_filter will then clear false out of the array, resulting in an empty array
  3. Spread operator will do nothing, as there are no elements of the array

I've added this, because explode() will take a null, false, or 0 parameter and set the $limit to 1.

We want $limit to be 1 when the rule is a regular expression, or $limit to be completely omitted when the rule is not.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we adjust this code to actually explicitly pass an integer to explode?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes absolutely, one moment.

Copy link
Contributor Author

@stevebauman stevebauman Feb 10, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've simplified it to:

return static::ruleIsRegex($name) ? [$rule] : explode('|', $rule);

Since the explode('|', $rule, 1) will simply wrap the rule in an array. Let me know if you want me to change this. If I wanted to explicitly pass in an integer value, I'd have to use PHP_INT_MAX for the default value (which looked a bit off -- not sure of your opinion on this):

return explode('|', $rule, static::ruleIsRegex($name) ? 1 : PHP_INT_MAX);

@stevebauman
Copy link
Contributor Author

Hmm, a random test is failing. If we execute another run, we should be green 👍

@taylorotwell
Copy link
Member

I'm guessing this still doesn't work?

'attribute' => 'required|string|regex:/^(super|admin)$/i|another_rule|final_rule`,

@stevebauman
Copy link
Contributor Author

Yes that's correct -- the rule will fail to parse properly. I've added a test case so we have some visibility on this:

https://github.com/stevebauman/framework/blob/ae6b101abbf4665396a860e52537c2ea7c3fd6c8/tests/Validation/ValidationRuleParserTest.php#L127-L138

@taylorotwell
Copy link
Member

@stevebauman can you explain how your original PR actually introduced this bug? I'm not sure I quite understand how and what broke.

@stevebauman
Copy link
Contributor Author

stevebauman commented Feb 11, 2022

@taylorotwell

I introduced this bug by using Arr::flatten() on this line:

foreach (Arr::flatten((array) $rules) as $rule) {

The structure of $rules given to explodeExplicitRule() was changed:

// Previously:
^ array:1 [
  0 => array:2 [
    0 => "in:foo"
    1 => "regex:/^(foo|bar)$/i"
  ]
]

// With Arr::flatten()
^ array:2 [
  0 => "in:foo"
  1 => "regex:/^(foo|bar)$/i"
]

The arrays were previously multi-dimensional and are now single dimensional.

This means, the first is_string() condition is now being hit on explodeExplicitRule(), instead of the last prepareRule array map, since this method will be called for each rule in the array:

protected function explodeExplicitRule($rule, $attribute)
{
if (is_string($rule)) {
return explode('|', $rule);
} elseif (is_object($rule)) {
return Arr::wrap($this->prepareRule($rule, $attribute));
}
return array_map(
[$this, 'prepareRule'],
$rule,
array_fill(array_key_first($rule), count($rule), $attribute)
);

This causes the explode to inadvertently target the regex rules, when it did not previously.

For Rule::forEach() to work, I had to flatten the attributes $rules so that I could check each rule if it's an instance of NestedRules. Without flatten(), we would have to cycle over the rules again to determine if they contain any Rule::forEach() instances, and would not be able to parse nested Rule::forEach()'s without recursion.

@taylorotwell taylorotwell merged commit a26c7cc into laravel:9.x Feb 11, 2022
stevebauman added a commit to stevebauman/framework that referenced this pull request Jan 7, 2023
taylorotwell pushed a commit that referenced this pull request Jan 9, 2023
…e 45520 (#45555)

* Revert #40941 changes

* Fix test

* Update test ensuring default behaviour

* Add issue test case
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
4 participants