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: implement requiredIf rules #42

Merged
merged 4 commits into from
Mar 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
193 changes: 189 additions & 4 deletions src/schema/base/literal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,16 @@ import type {
Validation,
RuleBuilder,
Transformer,
FieldContext,
FieldOptions,
ParserOptions,
ConstructableSchema,
ComparisonOperators,
ArrayComparisonOperators,
NumericComparisonOperators,
} from '../../types.js'
import { requiredWhen } from './rules.js'
import { helpers } from '../../vine/helpers.js'

/**
* Base schema type with only modifiers applicable on all the schema types.
Expand Down Expand Up @@ -51,8 +57,8 @@ abstract class BaseModifiersType<Output, CamelCaseOutput>
* Mark the field under validation as optional. An optional
* field allows both null and undefined values.
*/
optional(): OptionalModifier<this> {
return new OptionalModifier(this)
optional(validations?: Validation<any>[]): OptionalModifier<this> {
return new OptionalModifier(this, validations)
}

/**
Expand Down Expand Up @@ -85,6 +91,7 @@ class NullableModifier<Schema extends BaseModifiersType<any, any>> extends BaseM
Schema[typeof COTYPE] | null
> {
#parent: Schema

constructor(parent: Schema) {
super()
this.#parent = parent
Expand Down Expand Up @@ -116,17 +123,194 @@ class OptionalModifier<Schema extends BaseModifiersType<any, any>> extends BaseM
Schema[typeof COTYPE] | undefined
> {
#parent: Schema
constructor(parent: Schema) {

/**
* Optional modifier validations list
*/
validations: Validation<any>[]

constructor(parent: Schema, validations?: Validation<any>[]) {
super()
this.#parent = parent
this.validations = validations || []
}

/**
* Shallow clones the validations. Since, there are no API's to mutate
* the validation options, we can safely copy them by reference.
*/
protected cloneValidations(): Validation<any>[] {
return this.validations.map((validation) => {
Copy link
Member

Choose a reason for hiding this comment

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

Maybe it would be better to directly use structuredClone()?

return structuredClone(this.validations)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ohh did not know about that. Will do in a separate PR

return {
options: validation.options,
rule: validation.rule,
}
})
}

/**
* Compiles validations
*/
protected compileValidations(refs: RefsStore) {
return this.validations.map((validation) => {
return {
ruleFnId: refs.track({
validator: validation.rule.validator,
options: validation.options,
}),
implicit: validation.rule.implicit,
isAsync: validation.rule.isAsync,
}
})
}

/**
* Push a validation to the validations chain.
*/
use(validation: Validation<any> | RuleBuilder): this {
this.validations.push(VALIDATION in validation ? validation[VALIDATION]() : validation)
return this
}

/**
* Define a callback to conditionally require a field at
* runtime.
*
* The callback method should return "true" to mark the
* field as required, or "false" to skip the required
* validation
*/
requiredWhen<Operator extends ComparisonOperators>(
otherField: string,
operator: Operator,
expectedValue: Operator extends ArrayComparisonOperators
? (string | number | boolean)[]
: Operator extends NumericComparisonOperators
? number
: string | number | boolean
): this
requiredWhen(callback: (field: FieldContext) => boolean): this
requiredWhen(
otherField: string | ((field: FieldContext) => boolean),
operator?: ComparisonOperators,
expectedValue?: any
) {
/**
* The equality check if self implemented
*/
if (typeof otherField === 'function') {
return this.use(requiredWhen(otherField))
}

/**
* Creating the checker function based upon the
* operator used for the comparison
*/
let checker: (value: any) => boolean
switch (operator!) {
case '=':
checker = (value) => value === expectedValue
break
case '!=':
checker = (value) => value !== expectedValue
break
case 'in':
checker = (value) => expectedValue.includes(value)
break
case 'notIn':
checker = (value) => !expectedValue.includes(value)
break
case '>':
checker = (value) => value > expectedValue
break
case '<':
checker = (value) => value < expectedValue
break
case '>=':
checker = (value) => value >= expectedValue
break
case '<=':
checker = (value) => value <= expectedValue
}

/**
* Registering rule with custom implementation
*/
return this.use(
requiredWhen((field) => {
const otherFieldValue = helpers.getNestedValue(otherField, field)
return checker(otherFieldValue)
})
)
}

/**
* Mark the field under validation as required when all
* the other fields are present with value other
* than `undefined` or `null`.
*/
requiredIfExists(fields: string | string[]) {
const fieldsToExist = Array.isArray(fields) ? fields : [fields]
return this.use(
requiredWhen((field) => {
return fieldsToExist.every((otherField) =>
helpers.exists(helpers.getNestedValue(otherField, field))
)
})
)
}

/**
* Mark the field under validation as required when any
* one of the other fields are present with non-nullable
* value.
*/
requiredIfAnyExists(fields: string[]) {
return this.use(
requiredWhen((field) => {
return fields.some((otherField) =>
helpers.exists(helpers.getNestedValue(otherField, field))
)
})
)
}

/**
* Mark the field under validation as required when all
* the other fields are missing or their value is
* `undefined` or `null`.
*/
requiredIfMissing(fields: string | string[]) {
const fieldsToExist = Array.isArray(fields) ? fields : [fields]
return this.use(
requiredWhen((field) => {
return fieldsToExist.every((otherField) =>
helpers.isMissing(helpers.getNestedValue(otherField, field))
)
})
)
}

/**
* Mark the field under validation as required when any
* one of the other fields are missing.
*/
requiredIfAnyMissing(fields: string[]) {
return this.use(
requiredWhen((field) => {
return fields.some((otherField) =>
helpers.isMissing(helpers.getNestedValue(otherField, field))
)
})
)
}

/**
* Creates a fresh instance of the underlying schema type
* and wraps it inside the optional modifier
*/
clone(): this {
return new OptionalModifier(this.#parent.clone()) as this
return new OptionalModifier(this.#parent.clone(), this.cloneValidations()) as this
}

/**
Expand All @@ -135,6 +319,7 @@ class OptionalModifier<Schema extends BaseModifiersType<any, any>> extends BaseM
[PARSE](propertyName: string, refs: RefsStore, options: ParserOptions): LiteralNode {
const output = this.#parent[PARSE](propertyName, refs, options)
output.isOptional = true
output.validations = output.validations.concat(this.compileValidations(refs))
return output
}
}
Expand Down
28 changes: 28 additions & 0 deletions src/schema/base/rules.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/*
* @vinejs/vine
*
* (c) VineJS
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

import { messages } from '../../defaults.js'
import type { FieldContext } from '../../types.js'
import { createRule } from '../../vine/create_rule.js'

/**
* Validates the value to be required when a certain condition
* is matched
*/
export const requiredWhen = createRule<(field: FieldContext) => boolean>(
(_, checker, field) => {
const shouldBeRequired = checker(field)
if (!field.isDefined && shouldBeRequired) {
field.report(messages.required, 'required', field)
}
},
{
implicit: true,
}
)
8 changes: 8 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,3 +270,11 @@ export type ValidationOptions<MetaData extends Record<string, any> | undefined>
* Infers the schema type
*/
export type Infer<Schema extends { [OTYPE]: any }> = Schema[typeof OTYPE]

/**
* Comparison operators supported by requiredWhen
* rule
*/
export type NumericComparisonOperators = '>' | '<' | '>=' | '<='
export type ArrayComparisonOperators = 'in' | 'notIn'
export type ComparisonOperators = ArrayComparisonOperators | NumericComparisonOperators | '=' | '!='
Loading
Loading