Skip to content

Commit

Permalink
feat: implement requiredIf rules (#42)
Browse files Browse the repository at this point in the history
  • Loading branch information
thetutlage committed Mar 13, 2024
1 parent 81beff7 commit 893d378
Show file tree
Hide file tree
Showing 7 changed files with 768 additions and 9 deletions.
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) => {
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

0 comments on commit 893d378

Please sign in to comment.