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

[6.x] Add exclude_if validation rule #30835

Merged
merged 7 commits into from Dec 27, 2019
Merged

[6.x] Add exclude_if validation rule #30835

merged 7 commits into from Dec 27, 2019

Conversation

SjorsO
Copy link
Contributor

@SjorsO SjorsO commented Dec 13, 2019

The goal of this PR is to make it easy to exclude attributes from a request based on the value of other attributes. This is useful when having to validate data from a form where certain checkboxes hide or show other inputs.

For example, imagine a form with an has_doctor_appointment checkbox, that when checked, toggles an appointment_date and doctor_name input. A user can check the checkbox, fill in a date, and then uncheck the checkbox. The date input is no longer visible, but still contains a value. When using Redux Form, even when the input is not visible, the value still gets posted.

Usually, you would validate the form above something like this:

// Post data
{"has_appointment": false, "appointment_date": "2019-12-13"}
public function post(Request $request)
{
    $data = $request->validate([
        'has_doctor_appointment' => 'required|bool',
        'appointment_date' => 'required_if:has_doctor_appointment,true|date',
        'doctor_name' => 'required_if:has_doctor_appointment,true|string'
    ]);

    // $data === ['has_doctor_appointment' => false, 'appointment_date' => '2019-12-13']

    SomeModel::create($data);
}

The database now contains a record where has_appointment is false, but that has an appointment_date. This is not right. As far as i know, the only way to ensure you never store data like this is by adding statements like this to your controller/validator:

if (! $data['has_doctor_appointment') {
    $data['appointment_date'] = null;
    $data['doctor_name'] = null;
}

With the exclude_if validation rule, you can validate like this instead:

// Post data:
{"has_appointment": false, "appointment_date": "2019-12-13"}
public function post(Request $request)
{
    $data = $request->validate([
        'has_doctor_appointment' => 'required|bool',
        'appointment_date' => 'exclude_if:has_appointment,false|required|date',
        'doctor_name' => 'exclude_if:has_appointment,false|required|string',
    ]);

    // $data === ['has_appointment' => false]

    SomeModel::create($data);
}

The idea for this validation rule comes from a project we are working on at the office. It has multiple large forms, that all have many checkboxes that toggle other inputs. The way we solve this now is by making sure the inputs are reset when they are hidden. When they are reset Redux Form doesn't include them in the post data. Making sure the fields are properly reset has been troublesome in the past. This approach also does not prevent users from theoretically posting their own incorrect data to the endpoint.

With this new validation rule, the front-end doesn't have to reset any of the fields, since the back-end can easily exclude values.


This PR is still a work in progress. I would like to get some feedback on it before i spend more time on it.

Todo:

  • More tests
  • Nested attributes 1 level deep
  • exclude_unless rule

Not tested:
Nested attributes 2+ levels deep (I'm not sure if this is supported at all)

@GrahamCampbell GrahamCampbell changed the title [6.x] WIP - add exclude_if validation rule [6.x] [WIP] Add exclude_if validation rule Dec 13, 2019
@taylorotwell
Copy link
Member

I could see the utility here. How hard would it be to support nested attributes?

@SjorsO
Copy link
Contributor Author

SjorsO commented Dec 13, 2019

@taylorotwell Less difficult than i thought it would be. The ValidationRuleParser makes it a lot easier by exploding all the rules. It also helps that parent attributes always appear before any nested attributes.

Working example 1:

// Post data
{"has_appointments": false, "appointments": ["2019-05-15", "invalid date"]}
public function post(Request $request)
{
    $data = $request->validate([
        'has_appointments' => ['required', 'bool'],
        'appointments.*' => ['exclude_if:has_appointments,false', 'required', 'date'],
    ]);

    // $data === ['has_appointments' => false]
}

Working example 2:

// Post data
{
    "has_appointments": false,
    "appointments": [
        {"date": "2019-05-15", "name": "Bob"},
        {"date": "invalid date", "name": "Bob"},
    ]
}
public function post(Request $request)
{
    $data = $request->validate([
        'has_appointments' => ['required', 'bool'],
        'appointments' => ['exclude_if:has_appointments,false', 'required', 'array'],
        'appointments.*.date' => ['required', 'date'],
        'appointments.*.name' => ['required', 'string'],
    ]);

    // $data === ['has_appointments' => false]
}

I tried to write some tests for even deeper nesting, but they were behaving very strangely. It seemed to be completely ignoring validation rules for attributes nested twice. I was either doing something wrong or that is a problem not related to this PR.

@SjorsO
Copy link
Contributor Author

SjorsO commented Dec 18, 2019

@taylorotwell I think this is ready to merge

@SjorsO SjorsO changed the title [6.x] [WIP] Add exclude_if validation rule [6.x] Add exclude_if validation rule Dec 19, 2019
@taylorotwell
Copy link
Member

What about this?

I tried to write some tests for even deeper nesting, but they were behaving very strangely. It seemed to be completely ignoring validation rules for attributes nested twice. I was either doing something wrong or that is a problem not related to this PR.

@taylorotwell taylorotwell merged commit f9ea321 into laravel:6.x Dec 27, 2019
@SjorsO
Copy link
Contributor Author

SjorsO commented Dec 28, 2019

What about this?

I tried to write some tests for even deeper nesting, but they were behaving very strangely. It seemed to be completely ignoring validation rules for attributes nested twice. I was either doing something wrong or that is a problem not related to this PR.

I'll create a PR in the next few that show off the weird behavior

@baceto90
Copy link

baceto90 commented Jan 3, 2020

This is very useful, but required_if and exclude_if validations is very similar. Why not just change the current behavior of required_if validation rule?

@martinbean
Copy link
Contributor

martinbean commented Jan 7, 2020

@SjorsO This is amazing and perfect for an issue I have where a user can optionally make a product rentable.

As you say: I have a checkbox in the UI to determine if the product should be available for rent, and then separate inputs for the rental price and period. Thank you for contributing this!

@spawnia
Copy link
Contributor

spawnia commented Jan 21, 2020

I think this feature doesn't really fit within validation. It rather seems like it is sanitization of inputs.

It might be better to keep those separate.

@hrkristian
Copy link

Agree completely @spawnia

This isn't a validation rule at all, it does no validation, it performs an action.

I'm surprised this passed and was included as a validation rule.
If anything it should be a part of an expansion of the FormRequest system, as for isntance a part of a "sanitation" ruleset.

@martinbean
Copy link
Contributor

This isn't a validation rule at all, it does no validation, it performs an action.

@hrkristian So what about things like bail? That’s not “true” validation, either.

@hrkristian
Copy link

@martinbean Bail is simply "preceding validation rules decide whether or not ensuing rules are evaluated"
At no point does bail mutate data, which is the issue.

In any case I'm not sure why such a comparison is relevant.
The argument is "validation rules should not mutate data", do you disagree with this?

@SjorsO
Copy link
Contributor Author

SjorsO commented Aug 12, 2020

Depending on how you look at it, the exclude_if rule doesn't mutate data. It causes the validator to ignore specific data. Ignoring data isn't something new. The validator already ignores all the data you haven't specified validation rules for when using $validator->validated().

In some situations, using exclude_if is the only way to to get a valid set of data out of $validator->validated(). The validated data can then directly be used in a model create/update statement.

For example, with exclude_if you can write controllers like this:

public function post(Request $request, Document $document)
{
    $data = $request->validate([
        'is_approved' => 'required|bool',
        'rejection_reason' => 'exclude_if:is_approved,true|required|string|max:255',
    ]);

    $document->update($data);
}

Instead of like this:

public function post(Request $request, Document $document)
{
    $data = $request->validate([
        'is_approved' => 'required|bool',
        'rejection_reason' => 'present|nullable|string|max:255',
    ]);

    if (! $data['is_approved']) {
        unset($data['rejection_reason']);
    }

    $document->update($data);
}

@hrkristian
Copy link

Depending on how you look at it, the exclude_if rule doesn't mutate data

It's a mutator. A mutator doesn't have to mutate data, but that is its purpose.

As for your code snippet, what's wrong with it?
I am continuously baffled by how common it is to work towards lower LOC count by any means necessary.
Here you have a clear situation, you have data you need to validate, you then want to use all or some of said data. You're introducing a bit more code, but you're also introducing readability.

By adding an "exclude_if", you're introducing data mutation in a place where you wouldn't normally look for it: in a validator.

Lastly, I'd just like to add. We program as best we can by best practices and patterns.
Validation is a very specific function, we validate a dataset. We check if it's viable to be passed on.
Passing validation, we then act on said data.
Now, in your example you have a situation where you're just passing data to a model, suposedly for database access of some sort.
What then, when the situation grows more complex? What happens when you need to mutate the data given more, when a simple exclude_ directive isn't sufficient?
What happens then is you first mutate data in the validation block, then mutate it later, meaning you now have two places where you mutate data. That's messy. That's against best practices. It's patternless. Unless the pattern is "chaos".

I realise this is a bit of a harsh "attack", and I don't mean to offend.
As a concept I like "exclude_*". My objection is strictly about where it was included.

@spawnia
Copy link
Contributor

spawnia commented Aug 13, 2020

The exclude_* rules are the only ones that mutate the given input.

This has caused users of one of my packages grief - see nuwave/lighthouse#1165 (comment) - because we assumed that Laravel validation is fire-and-forget. I still think it should be. Our solution was to simply tell users not to use those rules.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

6 participants