hapijs / joi

The most powerful data validation library for JS
Other
20.95k stars 1.51k forks source link

Are complex conditions (and/or/and) supported? #2938

Open JasonTheAdams opened 1 year ago

JasonTheAdams commented 1 year ago

Support plan

Context

How can we help?

We're building a form builder in our application with support for conditional fields. We've built the back-end API of our system and are now working on the front-end piece. Namely, if a field's conditions are false, then the field is not visible; if it is not visible, then it doesn't need to be validated.

I used .when to get a simple version of it working. But now that I'm looking through the docs, I'm realizing there doesn't seem to be a way of chaining complex conditions — if A AND B OR C. It looks like it's implicitly all AND conditions. I found alternatives.match, but it seems like that only supports "any", "all", or "one" modes — all of which apply to the whole chain.

It seems like Joi simply isn't capable of complex condition chains, but I wanted to reach out to confirm this.

Thank you!

Marsup commented 1 year ago

It's possible, but I think you'll have to deal with operator priorities yourself. I'm not too familiar with your code, but it might look like this:

Joi.when(property, {
  switch: [
    { is: A AND B, then: ... },
    { is: C, then: ... }
  ]
})

Btw you should use number.min()/max() for your >= <= conditions.

JasonTheAdams commented 1 year ago

Hey @Marsup!

Thank you for the quick and helpful response! What you're suggesting is close, but not quite what we're aiming for. The condition set in the example you're providing is a condition for a single property. In our case it would be across multiple properties; as psuedo-code:

Joi
    .when(fieldA, {is: 'foo', then: ... }),
    .when(fieldB, {is: 'bar', then: ... }),
    .Orwhen(fieldC, {is: 'baz', then: ... });

It would either need to be a single .when() which allows for multiple rules across multiple properties, and each property has a conditional operator (and/or) to indicate how it affects the chain. You can see how I'm doing this recursively here in our code.

As a note, in that code it is being done recursively to handle nested conditions — A AND (B OR C) — but I'd be happy if Joi was able to at least validate a flat list of conditions: A AND B OR C

I was also trying to see if I could figure out a way to write a custom validation rule to do this, but it seems like when is a special case.

Thanks again!

Marsup commented 1 year ago

Would that work for you? https://runkit.com/embed/d92dw3kq780u

It checks your condition, if it's truthy returns self so that d === d which activates the then, otherwise null so that it doesn't match.

I can think of other ways by composing schemas, but it seems like a better solution to your problem. You can also go custom as you mentioned, which would probably imply more work on your end. Let me know if you need help with that.

JasonTheAdams commented 1 year ago

Thank you, @Marsup! Oh wow, that's an interesting approach! I did't know there was a way to resolve arbitrary expressions in Joi. I'll test it out.

Admittedly, I'd prefer a more declarative approach as a string expression feels rather fragile and, I suspect, doesn't scale particularly well. If you have ideas as how to compose it with a schema, or a custom validation, I'd appreciate the direction. The biggest thing I'm not sure how to do in a custom validation rule is access sibling data.

JasonTheAdams commented 1 year ago

As a note, this isn't working as I would expect:

Joi.object({
    a: Joi.number().required(),
    b: Joi.number().required(),
    c: Joi.number().required(),
    d: Joi.number().required().when(Joi.valid(Joi.x('{ if(a >= 10 && b < 5 || c == 2, d, null) }')), {
        otherwise: Joi.optional(),
    })
})

{ // validated values 
    a: 1,
    b: 7,
    c: 5,
}

This works if I use the chained-when method (minus, of course, the or operator) and the d parameter is only available if the expression is true.

That said, this still wouldn't be my preferred method for the aforementioned reasons, but I figured I'd share my results.

Marsup commented 1 year ago

That was my mistake, it should have been (notice the required):

Joi.object({
    a: Joi.number().required(),
    b: Joi.number().required(),
    c: Joi.number().required(),
    d: Joi.number().required().when(Joi.valid(Joi.x('{ if(a >= 10 && b < 5 || c == 2, d, null) }')).required(), {
        otherwise: Joi.optional(),
    })
})

I don't know what you meant by scale, but performance should not be an issue. I'll try to craft another solution, just give me a few days.

JasonTheAdams commented 1 year ago

Woah, that's wild looking. Why is it necessary to have .required() twice? And the second time is inside the .when(). 🤯

I say "scale" here because we're evaluating an expression. Clearly Joi is parsing the expression in order to evaluate it, injecting values and such. The longer the string gets the more there is to parse. As opposed to a non-parsed solution such as .when('foo', {is: 'bar'}), which takes a negligible amount of time.

Please pardon my critique. I'm not trying to pick on your solutions, but being careful about what I introduce to our system. I really appreciate your help on this. I'm hoping it would be good to document this as I can't imagine I'm the only one to want to conditionally validate based on field visibility.

Marsup commented 1 year ago

The other required is there for the match, it behaves like any other joi schema, if you don't set it, undefined is a valid match, so without d, then would apply. You have to consider that when transforms your schema into an alternative, so this:

Joi.number().required().when(p, {
  then: schema1,
  otherwise: schema2
})

Is actually more or less equivalent to this:

Joi.when(p, {
  then: merge(number().required(), schema1),
  otherwise: merge(number().required(), schema2)
})

The expression is parsed once as AST, and evaluated pretty much like you would expect, so as long as you keep a reference to the same schema and unless you're processing a massive amount of information, I wouldn't expect it to be a performance problem.

No offense taken, I know documentation is lacking in some areas, I will take all the help I can't get 🙂

JasonTheAdams commented 1 year ago

Thanks for the explanation! Yeah, I understand that when appends to the initial schema, which is great. What confused me most is that that the .required() you added was to the condition, not the otherwise property:

Joi
    .valid(
        Joi.x('{ if(a >= 10 && b < 5 || c == 2, d, null) }')
    ).required()

So I can document it in my code, would you mind breaking this down?

JasonTheAdams commented 1 year ago

Also, it looks like only basic operators work? Doesn't seem like String.includes() works:

Joi.object({
    a: Joi.number().required(),
    b: Joi.number().required(),
    c: Joi.required(),
    d: Joi.number().required().when(Joi.valid(Joi.x('{ if(a >= 10 && b < 5 || c.includes("green"), d, null) }')).required(), {
        otherwise: Joi.optional(),
    })
})
Marsup commented 1 year ago

The documentation is rather complete on this topic, joi could support more functions or even custom ones, but that's not planned right now. x "returns" d or null, and required was explained above.

So here's a version with pure schema use, no expression: https://runkit.com/embed/of7nzd4t8ewj This might require some explanations to grasp the process.

The when goes for .., which is the object above itself, and tries to match it with the is schema set up with the unknown flag to only validate the properties you want to check (it's very similar to pattern matching if you're familiar). So considering && and || and their precedence, the pattern to match is an alternative (for the ||) composed of 2 parts, the a >= 10 && b < 5 and the c == 2 materialized by the 2 sub-schemas.

I've only made schema2 to show you how you could gradually concat schemas, but you can probably also go straight for schema1 if you build objects directly.

It's probably a bit more work, because you have to establish precedence, you can't just pile up schemas and have joi automagically figure it out for you. Or at least, I personally don't see a way to do that, but I may be wrong, joi is flexible enough that you might find other tricks to achieve it.

JasonTheAdams commented 1 year ago

Hi @Marsup!

Looping back on this after being pulled to something else. I ended up going the route of a custom rule and was happy to find that it pretty much does everything I was hoping it would do. I'm now just running into one edge-case scenario that's driving me crazy.

Let's say I have a field that displays if an amount field is $100. And I do this:

  1. Set the amount to $100 (field displays)
  2. Set the amount to $50 (field disappears)
  3. Submit the form

Because the field rendered the Joi validation rules were added for that field. Now, I would think that my custom rule would fire and I'd be able to return undefined, unsetting the value and ignoring it. But what I learned from this is that custom rules only fire if there's a value. Furthermore, it seems like Joi considers a field required by default unless it's marked optional.

Here's my code:

    if (rules.required) {
        if (rules.hasOwnProperty('excludeUnless')) {
            /**
             * This applies requirements to a field if the field is not being excluded by its conditions. It only
             * supports basic conditions at this time, but could be expanded to support more complex conditions.
             *
             * Note that this unsets the value if the field conditions are not met.
             */
            joiRules = joiRules.custom((value, helpers) => {
                const formValues = helpers.state.ancestors[0];

                const passesConditions = rules.excludeUnless.every((condition: BasicCondition) => {
                    return conditionOperatorFunctions[condition.comparisonOperator](
                        formValues[condition.field],
                        condition.value
                    );
                });

                if (passesConditions && (value === '' || value === null)) {
                    return helpers.error('any.required');
                } else if (!passesConditions) {
                    return undefined;
                }

                return value;
            }, 'exclude unless validation');
        } else {
            joiRules = joiRules.required();
        }
    } else {
        joiRules = joiRules.optional().allow('', null);
    }

I really wish custom rules fired all the time, not just when there's a value. If they did I believe this would work. Is there any way to get a custom rule to fire no matter what?

JasonTheAdams commented 1 year ago

It turns out my issue was rather fundamental. Upstream from this I was applying the Joi.string() rule as a default for rules that don't specify Joi.number() or Joi.boolean(). Since the Joi.string() doesn't allow empty strings, it was throwing the error before the custom validation rule was firing. So it's not the custom rules don't fire when empty, it's that the rules were failing before reaching that point.

So I basically need to prevent all validation rules (Joi.string(), Joi.required, etc.) from firing if the conditions aren't meant. I believe that means it needs to be the first rule added. I'll give that a go.