diff --git a/src/schema/base/literal.ts b/src/schema/base/literal.ts index 29d3c7c..5f3ff4c 100644 --- a/src/schema/base/literal.ts +++ b/src/schema/base/literal.ts @@ -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. @@ -51,8 +57,8 @@ abstract class BaseModifiersType * Mark the field under validation as optional. An optional * field allows both null and undefined values. */ - optional(): OptionalModifier { - return new OptionalModifier(this) + optional(validations?: Validation[]): OptionalModifier { + return new OptionalModifier(this, validations) } /** @@ -85,6 +91,7 @@ class NullableModifier> extends BaseM Schema[typeof COTYPE] | null > { #parent: Schema + constructor(parent: Schema) { super() this.#parent = parent @@ -116,9 +123,186 @@ class OptionalModifier> extends BaseM Schema[typeof COTYPE] | undefined > { #parent: Schema - constructor(parent: Schema) { + + /** + * Optional modifier validations list + */ + validations: Validation[] + + constructor(parent: Schema, validations?: Validation[]) { 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[] { + 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 | 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( + 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)) + ) + }) + ) } /** @@ -126,7 +310,7 @@ class OptionalModifier> extends BaseM * 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 } /** @@ -135,6 +319,7 @@ class OptionalModifier> 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 } } diff --git a/src/schema/base/rules.ts b/src/schema/base/rules.ts new file mode 100644 index 0000000..c4182c1 --- /dev/null +++ b/src/schema/base/rules.ts @@ -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, + } +) diff --git a/src/types.ts b/src/types.ts index 1a43a31..4a8f924 100644 --- a/src/types.ts +++ b/src/types.ts @@ -270,3 +270,11 @@ export type ValidationOptions | undefined> * Infers the schema type */ export type Infer = Schema[typeof OTYPE] + +/** + * Comparison operators supported by requiredWhen + * rule + */ +export type NumericComparisonOperators = '>' | '<' | '>=' | '<=' +export type ArrayComparisonOperators = 'in' | 'notIn' +export type ComparisonOperators = ArrayComparisonOperators | NumericComparisonOperators | '=' | '!=' diff --git a/tests/integration/schema/conditional_required.spec.ts b/tests/integration/schema/conditional_required.spec.ts new file mode 100644 index 0000000..f5e19be --- /dev/null +++ b/tests/integration/schema/conditional_required.spec.ts @@ -0,0 +1,470 @@ +/* + * @vinejs/vine + * + * (c) VineJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' +import vine from '../../../index.js' + +test.group('requiredIfExists', () => { + test('fail when value is missing but other field exists', async ({ assert }) => { + const schema = vine.object({ + email: vine.string().optional(), + password: vine.string().optional().requiredIfExists('email'), + }) + + const data = { + email: 'foo@bar.com', + } + + await assert.validationErrors(vine.validate({ schema, data }), [ + { + field: 'password', + message: 'The password field must be defined', + rule: 'required', + }, + ]) + }) + + test('pass when value is missing but other field does not exist', async ({ assert }) => { + const schema = vine.object({ + email: vine.string().optional(), + password: vine.string().optional().requiredIfExists('email'), + }) + + const data = {} + + await assert.validationOutput(vine.validate({ schema, data }), {}) + }) + + test('pass when value exists but other field does not exist', async ({ assert }) => { + const schema = vine.object({ + email: vine.string().optional(), + password: vine.string().optional().requiredIfExists('email'), + }) + + const data = { + password: 'foo', + } + + await assert.validationOutput(vine.validate({ schema, data }), { + password: 'foo', + }) + }) + + test('do not fail until all the other fields exists', async ({ assert }) => { + const schema = vine.object({ + email: vine.string().optional(), + username: vine.string().optional(), + password: vine.string().optional().requiredIfExists(['email', 'username']), + }) + + const data = { + email: 'foo@bar.com', + } + + await assert.validationOutput(vine.validate({ schema, data }), { + email: 'foo@bar.com', + }) + }) + + test('fail if all the other fields exists', async ({ assert }) => { + const schema = vine.object({ + email: vine.string().optional(), + username: vine.string().optional(), + password: vine.string().optional().requiredIfExists(['email', 'username']), + }) + + const data = { + username: 'foo', + email: 'foo@bar.com', + } + + await assert.validationErrors(vine.validate({ schema, data }), [ + { + field: 'password', + message: 'The password field must be defined', + rule: 'required', + }, + ]) + }) +}) + +test.group('requiredIfAnyExists', () => { + test('fail if value is missing and any one of the other field is present', async ({ assert }) => { + const schema = vine.object({ + email: vine.string().optional(), + username: vine.string().optional(), + password: vine.string().optional().requiredIfAnyExists(['email', 'username']), + }) + + const data = { + email: 'foo@bar.com', + } + + await assert.validationErrors(vine.validate({ schema, data }), [ + { + field: 'password', + message: 'The password field must be defined', + rule: 'required', + }, + ]) + }) + + test('pass if value is present', async ({ assert }) => { + const schema = vine.object({ + email: vine.string().optional(), + username: vine.string().optional(), + password: vine.string().optional().requiredIfAnyExists(['email', 'username']), + }) + + const data = { + email: 'foo@bar.com', + password: 'secret', + } + + await assert.validationOutput(vine.validate({ schema, data }), { + email: 'foo@bar.com', + password: 'secret', + }) + }) +}) + +test.group('requiredIfMissing', () => { + test('fail when value is missing and other field is missing as well', async ({ assert }) => { + const schema = vine.object({ + email: vine.string().optional(), + username: vine.string().optional().requiredIfMissing('email'), + }) + + const data = {} + + await assert.validationErrors(vine.validate({ schema, data }), [ + { + field: 'username', + message: 'The username field must be defined', + rule: 'required', + }, + ]) + }) + + test('pass when value is missing but other field is present', async ({ assert }) => { + const schema = vine.object({ + email: vine.string().optional(), + username: vine.string().optional().requiredIfMissing('email'), + }) + + const data = { + email: 'foo@bar.com', + } + + await assert.validationOutput(vine.validate({ schema, data }), { + email: 'foo@bar.com', + }) + }) + + test('pass when value both the fields exist', async ({ assert }) => { + const schema = vine.object({ + email: vine.string().optional(), + username: vine.string().optional().requiredIfMissing('email'), + }) + + const data = { + email: 'foo@bar.com', + username: 'foo', + } + + await assert.validationOutput(vine.validate({ schema, data }), { + email: 'foo@bar.com', + username: 'foo', + }) + }) + + test('do not fail until all other fields are missing', async ({ assert }) => { + const schema = vine.object({ + email: vine.string().optional(), + username: vine.string().optional(), + githubId: vine.string().optional().requiredIfMissing(['email', 'username']), + }) + + const data = { + email: 'foo@bar.com', + } + + await assert.validationOutput(vine.validate({ schema, data }), { + email: 'foo@bar.com', + }) + }) + + test('fail if all the other fields are missing', async ({ assert }) => { + const schema = vine.object({ + email: vine.string().optional(), + username: vine.string().optional(), + githubId: vine.string().optional().requiredIfMissing(['email', 'username']), + }) + + const data = {} + + await assert.validationErrors(vine.validate({ schema, data }), [ + { + field: 'githubId', + message: 'The githubId field must be defined', + rule: 'required', + }, + ]) + }) +}) + +test.group('requiredIfAnyMissing', () => { + test('fail if value is missing and any one of the other field is missing', async ({ assert }) => { + const schema = vine.object({ + email: vine.string().optional(), + username: vine.string().optional(), + githubId: vine.string().optional().requiredIfAnyMissing(['email', 'username']), + }) + + const data = { + email: 'foo@bar.com', + } + + await assert.validationErrors(vine.validate({ schema, data }), [ + { + field: 'githubId', + message: 'The githubId field must be defined', + rule: 'required', + }, + ]) + }) + + test('pass if all other fields are present', async ({ assert }) => { + const schema = vine.object({ + email: vine.string().optional(), + username: vine.string().optional(), + githubId: vine.string().optional().requiredIfAnyMissing(['email', 'username']), + }) + + const data = { + email: 'foo@bar.com', + username: 'foo', + } + + await assert.validationOutput(vine.validate({ schema, data }), { + email: 'foo@bar.com', + username: 'foo', + }) + }) +}) + +test.group('requiredWhen', () => { + test('fail when required field is missing', async ({ assert }) => { + const schema = vine.object({ + game: vine.string().optional(), + teamName: vine.string().optional().requiredWhen('game', '=', 'volleyball'), + }) + + const data = { + game: 'volleyball', + } + + await assert.validationErrors(vine.validate({ schema, data }), [ + { + field: 'teamName', + message: 'The teamName field must be defined', + rule: 'required', + }, + ]) + }) + + test('pass when required condition has not been met', async ({ assert }) => { + const schema = vine.object({ + game: vine.string().optional(), + teamName: vine.string().optional().requiredWhen('game', '=', 'volleyball'), + }) + + const data = { + game: 'handball', + } + + await assert.validationOutput(vine.validate({ schema, data }), { + game: 'handball', + }) + }) + + test('pass when required field is defined', async ({ assert }) => { + const schema = vine.object({ + game: vine.string().optional(), + teamName: vine.string().optional().requiredWhen('game', '=', 'volleyball'), + }) + + const data = { + game: 'volleyball', + teamName: 'foo', + } + + await assert.validationOutput(vine.validate({ schema, data }), { + game: 'volleyball', + teamName: 'foo', + }) + }) + + test('compare using "not equal" operator', async ({ assert }) => { + const schema = vine.object({ + game: vine.string().optional(), + teamName: vine.string().optional().requiredWhen('game', '!=', 'volleyball'), + }) + + const data = { + game: 'handball', + } + + await assert.validationErrors(vine.validate({ schema, data }), [ + { + field: 'teamName', + message: 'The teamName field must be defined', + rule: 'required', + }, + ]) + }) + + test('compare using "in" operator', async ({ assert }) => { + const schema = vine.object({ + game: vine.string().optional(), + teamName: vine.string().optional().requiredWhen('game', 'in', ['volleyball']), + }) + + const data = { + game: 'volleyball', + } + + await assert.validationErrors(vine.validate({ schema, data }), [ + { + field: 'teamName', + message: 'The teamName field must be defined', + rule: 'required', + }, + ]) + }) + + test('compare using "not In" operator', async ({ assert }) => { + const schema = vine.object({ + game: vine.string().optional(), + teamName: vine.string().optional().requiredWhen('game', 'notIn', ['volleyball']), + }) + + const data = { + game: 'handball', + } + + await assert.validationErrors(vine.validate({ schema, data }), [ + { + field: 'teamName', + message: 'The teamName field must be defined', + rule: 'required', + }, + ]) + }) + + test('compare using ">" operator', async ({ assert }) => { + const schema = vine.object({ + age: vine.number(), + guardianName: vine.string().optional().requiredWhen('age', '>', 1), + }) + + const data = { + age: 2, + } + + await assert.validationErrors(vine.validate({ schema, data }), [ + { + field: 'guardianName', + message: 'The guardianName field must be defined', + rule: 'required', + }, + ]) + }) + + test('compare using "<" operator', async ({ assert }) => { + const schema = vine.object({ + age: vine.number(), + guardianName: vine.string().optional().requiredWhen('age', '<', 19), + }) + + const data = { + age: 2, + } + + await assert.validationErrors(vine.validate({ schema, data }), [ + { + field: 'guardianName', + message: 'The guardianName field must be defined', + rule: 'required', + }, + ]) + }) + + test('compare using "<=" operator', async ({ assert }) => { + const schema = vine.object({ + age: vine.number(), + guardianName: vine.string().optional().requiredWhen('age', '<=', 18), + }) + + const data = { + age: 18, + } + + await assert.validationErrors(vine.validate({ schema, data }), [ + { + field: 'guardianName', + message: 'The guardianName field must be defined', + rule: 'required', + }, + ]) + }) + + test('compare using ">=" operator', async ({ assert }) => { + const schema = vine.object({ + age: vine.number(), + guardianName: vine.string().optional().requiredWhen('age', '>=', 1), + }) + + const data = { + age: 1, + } + + await assert.validationErrors(vine.validate({ schema, data }), [ + { + field: 'guardianName', + message: 'The guardianName field must be defined', + rule: 'required', + }, + ]) + }) + + test('compare using custom callback', async ({ assert }) => { + const schema = vine.object({ + game: vine.string().optional(), + teamName: vine + .string() + .optional() + .requiredWhen((field) => { + return field.parent.game === 'volleyball' + }), + }) + + const data = { + game: 'volleyball', + } + + await assert.validationErrors(vine.validate({ schema, data }), [ + { + field: 'teamName', + message: 'The teamName field must be defined', + rule: 'required', + }, + ]) + }) +}) diff --git a/tests/unit/rules/conditional_required.spec.ts b/tests/unit/rules/conditional_required.spec.ts new file mode 100644 index 0000000..103b096 --- /dev/null +++ b/tests/unit/rules/conditional_required.spec.ts @@ -0,0 +1,48 @@ +/* + * @vinejs/vine + * + * (c) VineJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' +import { validator } from '../../../factories/main.js' +import { requiredWhen } from '../../../src/schema/base/rules.js' + +test.group('Required when', () => { + test('report error when field is missing but required', () => { + const boolean = requiredWhen(() => { + return true + }) + const validated = validator.execute(boolean, undefined) + + validated.assertError('The dummy field must be defined') + }) + + test('report error when field is null but required', () => { + const boolean = requiredWhen(() => { + return true + }) + const validated = validator.execute(boolean, null) + + validated.assertError('The dummy field must be defined') + }) + + test('do not report error when field is missing but not required', () => { + const boolean = requiredWhen(() => { + return false + }) + const validated = validator.execute(boolean, undefined) + validated.assertSucceeded() + }) + + test('do not report error when field is null but not required', () => { + const boolean = requiredWhen(() => { + return false + }) + const validated = validator.execute(boolean, null) + validated.assertSucceeded() + }) +}) diff --git a/tests/unit/schema/string.spec.ts b/tests/unit/schema/string.spec.ts index a5acdca..ad7c34b 100644 --- a/tests/unit/schema/string.spec.ts +++ b/tests/unit/schema/string.spec.ts @@ -364,7 +364,7 @@ test.group('VineString | clone', () => { test('apply nullable modifier and clone', ({ assert }) => { const schema = vine.string().nullable() - const schema1 = schema.clone().optional() + const schema1 = schema.clone().optional().requiredIfMissing('bar') assert.deepEqual(schema[PARSE]('*', refsBuilder(), { toCamelCase: false }), { type: 'literal', @@ -396,13 +396,18 @@ test.group('VineString | clone', () => { isAsync: false, ruleFnId: 'ref://1', }, + { + implicit: true, + isAsync: false, + ruleFnId: 'ref://2', + }, ], }) }) test('apply optional modifier and clone', ({ assert }) => { - const schema = vine.string().optional() - const schema1 = schema.clone().nullable() + const schema = vine.string().optional().requiredIfMissing('bar') + const schema1 = schema.clone().requiredIfExists('foo').nullable() assert.deepEqual(schema[PARSE]('*', refsBuilder(), { toCamelCase: false }), { type: 'literal', @@ -418,6 +423,11 @@ test.group('VineString | clone', () => { isAsync: false, ruleFnId: 'ref://1', }, + { + implicit: true, + isAsync: false, + ruleFnId: 'ref://2', + }, ], }) assert.deepEqual(schema1[PARSE]('*', refsBuilder(), { toCamelCase: false }), { @@ -434,6 +444,16 @@ test.group('VineString | clone', () => { isAsync: false, ruleFnId: 'ref://1', }, + { + implicit: true, + isAsync: false, + ruleFnId: 'ref://2', + }, + { + implicit: true, + isAsync: false, + ruleFnId: 'ref://3', + }, ], }) }) diff --git a/tsconfig.json b/tsconfig.json index 9d417c2..ad0cc44 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,6 +2,6 @@ "extends": "@adonisjs/tsconfig/tsconfig.package.json", "compilerOptions": { "rootDir": "./", - "outDir": "./build", - }, + "outDir": "./build" + } }