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

feat(validation): link two or more properties' validation #2015

Open
wants to merge 13 commits into
base: master
Choose a base branch
from

Conversation

XmasCarola
Copy link

Pull Request

📖 Description

Possibility to connect properties if they are related to each other, so that the validation of one property acts as a trigger for the other(s). This is useful when two variables are interconnected, for example start and end date that are supposed to function as comparison values. Providing multiple checks with subsequent highlights can point out all the values that need to be modified to properly fill out a form.

🎫 Issues

👩‍💻 Reviewer Notes

📑 Test Plan

⏭ Next Steps

* possibility to connect properties if they are related to each other
Copy link

codecov bot commented Jul 28, 2024

Codecov Report

Attention: Patch coverage is 96.96970% with 3 lines in your changes missing coverage. Please review.

Project coverage is 88.99%. Comparing base (d4ab396) to head (ff51484).
Report is 1 commits behind head on master.

Files with missing lines Patch % Lines
packages/validation/src/serialization.ts 89.65% 3 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #2015      +/-   ##
==========================================
+ Coverage   88.97%   88.99%   +0.02%     
==========================================
  Files         280      280              
  Lines       23232    23317      +85     
  Branches     5350     5379      +29     
==========================================
+ Hits        20670    20752      +82     
- Misses       2562     2565       +3     
Flag Coverage Δ
?

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

Copy link
Member

@Sayan751 Sayan751 left a comment

Choose a reason for hiding this comment

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

Hi @XmasCarola, thanks for the contribution. This is an interesting change. Left a couple of suggestions.

Coming to bike-shading now :D
The method name linkProperties seems a bit ambiguous to me. The purpose is not immediately clear whether the validation of the current property is dependent on the other properties, or is it the other way around. As your test shows, it is the other way around. From that perspective, a .dependsOn method will be more intuitive.

As an example from your tests

 const rules = validationRules
  .on(defineRuleOnClass ? Person : obj)

    .ensure('name')
    .required()

    .ensure('age')
    .dependsOn('name')
    .required()

    .ensure('address.line1')
    .dependsOn('name')
    .required()
    .withMessage('Address is required.')

It don't have to be dependsOn, any other intuitive name should also work.

if (propertyRule !== void 0 && propertyRule.linkedProperties.length > 0) {
const additionalRules = propertyRule.linkedProperties.map(lp => rules.find(r => r.property.name === lp)).filter(Boolean) as PropertyRule[];
return (await Promise.all([propertyRule?.validate(object, propertyTag, scope), ...additionalRules.map(async (rule) => rule.validate(object, propertyTag, scope))])).flat();
}
return (await rules.find((r) => r.property.name === propertyName)?.validate(object, propertyTag, scope)) ?? [];
Copy link
Member

Choose a reason for hiding this comment

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

propertyRule should be reused here.

const propertyRule: PropertyRule | undefined = rules.find((r) => r.property.name === propertyName);
if (propertyRule !== void 0 && propertyRule.linkedProperties.length > 0) {
const additionalRules = propertyRule.linkedProperties.map(lp => rules.find(r => r.property.name === lp)).filter(Boolean) as PropertyRule[];
return (await Promise.all([propertyRule?.validate(object, propertyTag, scope), ...additionalRules.map(async (rule) => rule.validate(object, propertyTag, scope))])).flat();
Copy link
Member

Choose a reason for hiding this comment

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

Why the .filter is needed? Are we expecting the rules to be falsy?

Copy link
Author

Choose a reason for hiding this comment

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

There could be undefined values in case .find() does not detect a match.

.ensure('name')
.linkProperties(['age', 'address']);
```

Copy link
Member

Choose a reason for hiding this comment

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

Maybe add a bit more details. To me at least, after reading the docs, the purpose was not clear.

@@ -123,6 +123,7 @@ export class PropertyRule<TObject extends IValidateable = IValidateable, TValue
private latestRule?: IValidationRule;
/** @internal */
public readonly l: IServiceLocator;
public linkedProperties: PropertyKey[] = [];
Copy link
Member

Choose a reason for hiding this comment

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

I think that it would be great if these linked properties can be serialized; refer model based validation.

To that end, the accept method(s) and the rule hydrators needs to be refactored.

* Links the PropertyRule to other key values.
* @param properties - Array of property keys to link.
*/
public linkProperties(properties: PropertyKey[]) {
Copy link
Member

Choose a reason for hiding this comment

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

I think that we should also support lambda expressions as input along with PropertyKey, like we also do for the .ensure.

@Sayan751 Sayan751 requested a review from bigopon July 29, 2024 06:48
@@ -57,6 +57,11 @@ export class StandardValidator implements IValidator {
const scope = Scope.create({ [rootObjectSymbol]: object });

if (propertyName !== void 0) {
const propertyRule: PropertyRule | undefined = rules.find((r) => r.property.name === propertyName);
Copy link
Member

Choose a reason for hiding this comment

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

When validating multiple properties, we might want to do that when the validation instruction involves validating a single property or a single binding. We might want to avoid repetitive executions of the same rules when the validation is triggered for the entire object or for all bindings.

@Sayan751
Copy link
Member

Maybe slightly differently, we can mark a set of properties as one group. When one of those properties is validated, all the properties in the set are validated.

For example:

validationRules
  .on(defineRuleOnClass ? Person : obj)
  .group('name', 'age', 'address.line1') // <-- validate all 3 properties when one of those are validated.

    .ensure('name')
    .required()

    .ensure('age')
    .required()

    .ensure('address.line1')
    .required()
    .withMessage('Address is required.')

@bigopon What do you think about that?

@bigopon
Copy link
Member

bigopon commented Jul 30, 2024

... When one of those properties is validated, all the properties in the set are validated.

If we had a group of

  • [radio input]: 1 way / 2 way flight
  • [date input]: departure date
  • [date input]: return date

I think I want to express something like this

  .ensureGroup('direction', 'departureDate', 'returnDate', (direction, departureDate, returnDate) => {
    if (direction === '1 way') {
      if (returnDate != null) { throw new Error('1 way flight has no return date'); }
      if (startDate < Date.now()) { throw new Error('No time travel possible'); }
    } else {
      if (startDate > returnDate) { throw new Error('not possible to go back in time'); }
      if (!hasFlight(returnDate)) { throw new Error('no flights on the date back, boat?'); }
    }
  });

and the trigger point will probably be after the radio and a date (either departure or return) has been selected. I wouldn't want to trigger any date independently I think.

@Sayan751
Copy link
Member

@bigopon I like your example a lot. Only instead of throwing error, it needs to return false. Or we can utilize the newly introduced state rule in background for multiple error messages.

@XmasCarola
Copy link
Author

XmasCarola commented Jul 30, 2024

Hi guys, thank you for your input! :)

@bigopon
Copy link
Member

bigopon commented Aug 9, 2024

Just an idea, maybe a serializable version of the ensureGroup above would be something like

.ensureGroup(
  ['direction', 'departureDate', 'returnDate'],
  `$direction === '1-way' && $returnDate == null && $departureDate > Date.now()
    || $returnDate > $departureDate
  `
)

@XmasCarola XmasCarola force-pushed the validation-linked-props branch 2 times, most recently from 1353ece to 84ebba7 Compare August 23, 2024 17:54
@XmasCarola
Copy link
Author

Hi guys, I've made some additional integrations to the code. After analyzing the structure of ensureGroup(), I realized that what @bigopon is aiming for can be achieved with the current setup. As a result, I designed a test to emulate his request (you can refer to "can validate linked properties w/ custom logic" inside validator.spec.ts). Let me know your thoughts. Thanks!

@XmasCarola XmasCarola force-pushed the validation-linked-props branch 2 times, most recently from 79b0f79 to 6b8044a Compare August 23, 2024 20:43
const propertyRule: PropertyRule | undefined = rules.find((r) => r.property.name === propertyName);
if (propertyRule !== void 0 && propertyRule.linkedProperties.length > 0) {
const additionalRules = propertyRule.linkedProperties.map(lp => rules.find(r => r.property.name === lp)).filter(Boolean) as PropertyRule[];
return (await Promise.all([propertyRule.validate(object, propertyTag, scope), ...additionalRules.map(async (rule) => rule.validate(object, propertyTag, scope))])).flat();
Copy link
Member

Choose a reason for hiding this comment

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

is the .flat() necessary?

Copy link
Member

Choose a reason for hiding this comment

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

I think that's needed to flatten the validation results, because there might be multiple validation results from a single property. This depends on how the rules are defined (if .then is used).

@@ -57,7 +57,12 @@ export class StandardValidator implements IValidator {
const scope = Scope.create({ [rootObjectSymbol]: object });

if (propertyName !== void 0) {
return (await rules.find((r) => r.property.name === propertyName)?.validate(object, propertyTag, scope)) ?? [];
const propertyRule: PropertyRule | undefined = rules.find((r) => r.property.name === propertyName);
if (propertyRule !== void 0 && propertyRule.linkedProperties.length > 0) {
Copy link
Member

Choose a reason for hiding this comment

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

I think with this addition, what we have is the ability to trigger a set of rules together with another rule. I also can't answer whether the depended rules will run first or in parallel with the dependent rule. For those reasons, I think the .dependsOn() is inappropriate. Maybe let's try to come up with a more accurate name?
What about:

  • afterRules(...)
  • withRules(...)
  • whenRules(...)
  • with(...)
  • useRules(...)
  • withGroup(...)

Copy link
Member

Choose a reason for hiding this comment

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

After revisiting the code, I also agree that the .dependsOn might not be a correct name for this. If we have the following:

rules
  .on(object)
  .ensure('prop1')
  .rule1()
  .dependsOn(['prop2', 'prop3'])

then the rules associated with prop2 and prop3 are executed when the prop1 is validated. So, in other words, the validation of prop2 and prop3 are triggered by the validation of prop1, making prop2 and prop3 dependent on prop1 and not the other way around. I am unsure if that is the goal? If yes, then .dependsOn for that might be a confusing name.

I was under the impression that the goal is to validate a set of properties as a group. Therefore, I am inclined towards the .ensureGroup idea from @bigopon. @XmasCarola Do you think, implementing that satisfies your use-case as well?

Copy link
Member

Choose a reason for hiding this comment

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

This is not a blocking comment, but on this note, we should also evaluate the UX aspect for this. My preference is that when one property is changed and therefore validated, only the rules associated with that property are executed, and the errors are shown accordingly.
However, with the introduction of this change, many properties will potentially be validated when the value of one property is changed, showing validation errors for potentially many properties. IMO, that increases the cognitive load for the end user.

Copy link
Author

Choose a reason for hiding this comment

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

The reason for my PR is that validating multiple properties simultaneously is sometimes necessary. It happened to me to encounter interdependent properties, such as booleans that need adjustment when one changes, or dates, like start and end date. Implementing date comparisons using Aurelia 1.0's validation plugin was challenging and led me to search online, where I found some related issues => aurelia/validation#279 (see the comment at the bottom). When two dates must follow a sequence, it’s helpful to indicate that adjusting either value can resolve the error.

Other frameworks validate all 'touched' fields whenever one is modified, which is another way to address this problem. However, your main solution - @Sayan751 - offers developers more flexibility, as some find it frustrating when parts of a form are validated unnecessarily. We could have related grouped properties with a specific validation order, as you both suggested. ;)

Copy link
Member

Choose a reason for hiding this comment

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

@XmasCarola I too have experience such need for validating multiple properties as a group. Hence, there is no question about the legitimate need of this feature. The point on the UX is more or less an emphasis on seeing it in action and evaluating how it is affecting the UX for a legitimate use-case. I appreciate you working on it!

@bigopon
Copy link
Member

bigopon commented Sep 1, 2024

btw, the benchmark jobs can be fixed by simply updating this branch with latest master @XmasCarola

* Links the PropertyRule to other key values.
* @param properties - Array of properties to link (accepts strings, object keys as well as property accessor functions).
*/
public dependsOn(properties: string[] | PropertyAccessor[]) {
Copy link
Member

Choose a reason for hiding this comment

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

I think that the intellisense might not work as expected with this. We might need something like PropertyAccessor<TObject, TValue>, like it is done for .ensure.

const propertyRule: PropertyRule | undefined = rules.find((r) => r.property.name === propertyName);
if (propertyRule !== void 0 && propertyRule.linkedProperties.length > 0) {
const additionalRules = propertyRule.linkedProperties.map(lp => rules.find(r => r.property.name === lp)).filter(Boolean) as PropertyRule[];
return (await Promise.all([propertyRule.validate(object, propertyTag, scope), ...additionalRules.map(async (rule) => rule.validate(object, propertyTag, scope))])).flat();
Copy link
Member

Choose a reason for hiding this comment

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

I think that's needed to flatten the validation results, because there might be multiple validation results from a single property. This depends on how the rules are defined (if .then is used).

@@ -57,7 +57,12 @@ export class StandardValidator implements IValidator {
const scope = Scope.create({ [rootObjectSymbol]: object });

if (propertyName !== void 0) {
return (await rules.find((r) => r.property.name === propertyName)?.validate(object, propertyTag, scope)) ?? [];
const propertyRule: PropertyRule | undefined = rules.find((r) => r.property.name === propertyName);
if (propertyRule !== void 0 && propertyRule.linkedProperties.length > 0) {
Copy link
Member

Choose a reason for hiding this comment

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

After revisiting the code, I also agree that the .dependsOn might not be a correct name for this. If we have the following:

rules
  .on(object)
  .ensure('prop1')
  .rule1()
  .dependsOn(['prop2', 'prop3'])

then the rules associated with prop2 and prop3 are executed when the prop1 is validated. So, in other words, the validation of prop2 and prop3 are triggered by the validation of prop1, making prop2 and prop3 dependent on prop1 and not the other way around. I am unsure if that is the goal? If yes, then .dependsOn for that might be a confusing name.

I was under the impression that the goal is to validate a set of properties as a group. Therefore, I am inclined towards the .ensureGroup idea from @bigopon. @XmasCarola Do you think, implementing that satisfies your use-case as well?

@bigopon
Copy link
Member

bigopon commented Sep 2, 2024

... I also can't answer whether the depended rules will run first or in parallel with the dependent rule. ...

One thing that is possible if we declare all properties of a group together, is the ability to explicitly declare the sequence. Similar to the way Gulp enable sequence in it task running. For example:

// all at once
.group(['direction', 'returnDate', 'departureDate'], ([direction, returnDate, departureDate]) => ...)

// direction then departure and return dates at once
.group('direction', ['departureDate', 'returnDate'], (direction, [departureDate, returnDate]) => ...)

// direction then departure date then return date
.group('direction', 'departureDate', 'returnDate', (direction, departureDate, returnDate) => ...)

We can also allow .group to only run after all individual properties with validation have validated before validating the whole as a group

@Sayan751
Copy link
Member

Sayan751 commented Sep 2, 2024

@XmasCarola @bigopon I think that if we agree on the .ensureGroup thing, then I propose to keep it simple for the first version. Then, depending on how the devs are using it, we can always make it more complicated (read fine-tuning :)). My proposal is as follows:

.ensureGroup(
  ['property', or => accessor],
  (value1, value2, object) => boolean | Promise<boolean>
)

The rule is executed when any one of the property value is changed. The order of the rule execution is determined by how the rule is registered. I mean that that is the default order of executing the rules. We should maintain that here as well, without treating this rule too specially.

My mental model is as follows: when we encounter an .ensureGroup, we attach the same rule for all the given properties. That way when one of those property changes, the rule will automatically be executed, with a small tweak that using the property accessors or expression, the current value of all properties will be fetched and supplied to the lambda for the evaluation of the rule. When generating an error from this rule, the error needs to be generated for all the properties (needs to be replicated for alll properties), so that the information can be shown in the UI via the subscribers (this makes me a bit uncomfortable; maybe we need smart out-the-box subscribers that can show only one error for a single UI component; however, this might be a secondary issue, as showing errors is very much context-specific, and one might need custom subscriber anyway).

@bigopon
Copy link
Member

bigopon commented Sep 3, 2024

... the error needs to be generated for all the properties ...

I don't feel like this is an acceptable default at all. Adding a group validation and suddenly I won't be able to figure out where it went wrong in my tests is an undesirable scenario to be in.

In a group validation, issues may not always be with a single property. Like in my example, sometimes it's the direction, sometimes it's the departure, sometimes it's the return date. Do we want to always notify all those direction departure and return dates when only one of those fails?
If we don't want that, and want to be precise where to notify, where should be have that information? To me, maybe the lambda that is responsible for validation should also be responsible for signaling: maybe it should return more than just a boolean:

.ensureGroup(
  ['property', or => accessor],
  (value1, value2, object) =>
    | { valid: true }
    | Promise<{ valid: true }>
+   | { valid: false; property: string }
+   | Promise<{ valid: false; property: string }>
)

@Sayan751
Copy link
Member

Sayan751 commented Sep 3, 2024

@bigopon Like the idea. I would suggest an extension of that to the following:

.ensureGroup(
  ['property', or => accessor],
  (value1, value2, object) =>
+    | { valid: boolean, { property: string, valid: boolean, messageOrMessageKey?: string }[] }
+    | Promise<{ valid: boolean, { property: string, valid: boolean, messageOrMessageKey?: string }[] }>
)

If no message or message key is provided in the result, then the usual workflow of resolving the message should take over the task. We need to remember that it should work with i18n as well.

@XmasCarola
Copy link
Author

@Sayan751 @bigopon Sorry guys, I committed by mistake on master.

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.

4 participants