diff --git a/package.json b/package.json index 791dc17..b43be35 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "benchmark": "^2.1.4", "c8": "^10.1.2", "del-cli": "^6.0.0", - "eslint": "^9.15.0", + "eslint": "^9.16.0", "joi": "^17.13.3", "prettier": "^3.4.1", "release-it": "^17.10.0", @@ -66,7 +66,7 @@ "dependencies": { "@poppinss/macroable": "^1.0.3", "@types/validator": "^13.12.2", - "@vinejs/compiler": "^2.5.1", + "@vinejs/compiler": "^3.0.0", "camelcase": "^8.0.0", "dayjs": "^1.11.13", "dlv": "^1.1.3", diff --git a/src/schema/base/main.ts b/src/schema/base/main.ts index 68ad994..4e54e42 100644 --- a/src/schema/base/main.ts +++ b/src/schema/base/main.ts @@ -7,7 +7,7 @@ * file that was distributed with this source code. */ -import type { RefsStore } from '@vinejs/compiler/types' +import type { FieldContext, RefsStore } from '@vinejs/compiler/types' import { ITYPE, OTYPE, COTYPE, PARSE, VALIDATION } from '../../symbols.js' import type { @@ -18,8 +18,13 @@ import type { CompilerNodes, ParserOptions, ConstructableSchema, + ComparisonOperators, + ArrayComparisonOperators, + NumericComparisonOperators, } from '../../types.js' import Macroable from '@poppinss/macroable' +import { requiredWhen } from './rules.js' +import { helpers } from '../../vine/helpers.js' /** * Base schema type with only modifiers applicable on all the schema types. @@ -119,9 +124,186 @@ export class OptionalModifier< 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) => { + return 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)) + ) + }) + ) } /** @@ -129,7 +311,7 @@ export class OptionalModifier< * 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 } /** @@ -139,6 +321,7 @@ export class OptionalModifier< const output = this.#parent[PARSE](propertyName, refs, options) if (output.type !== 'union') { output.isOptional = true + output.validations = output.validations.concat(this.compileValidations(refs)) } return output diff --git a/tests/integration/schema/conditional_required.spec.ts b/tests/integration/schema/conditional_required.spec.ts index f5e19be..416beda 100644 --- a/tests/integration/schema/conditional_required.spec.ts +++ b/tests/integration/schema/conditional_required.spec.ts @@ -10,7 +10,7 @@ import { test } from '@japa/runner' import vine from '../../../index.js' -test.group('requiredIfExists', () => { +test.group('requiredIfExists | literal', () => { test('fail when value is missing but other field exists', async ({ assert }) => { const schema = vine.object({ email: vine.string().optional(), @@ -468,3 +468,191 @@ test.group('requiredWhen', () => { ]) }) }) + +test.group('requiredIfExists | array', () => { + test('fail when value is missing but other field exists', async ({ assert }) => { + const schema = vine.object({ + participated: vine.boolean(), + scores: vine.array(vine.string()).optional().requiredIfExists('participated'), + }) + + const data = { + participated: true, + } + + await assert.validationErrors(vine.validate({ schema, data }), [ + { + field: 'scores', + message: 'The scores field must be defined', + rule: 'required', + }, + ]) + }) + + test('pass when value is missing but other field does not exist', async ({ assert }) => { + const schema = vine.object({ + participated: vine.boolean().optional(), + scores: vine.array(vine.string()).optional().requiredIfExists('participated'), + }) + + const data = {} + + await assert.validationOutput(vine.validate({ schema, data }), {}) + }) + + test('pass when value is defined but other field does not exist', async ({ assert }) => { + const schema = vine.object({ + participated: vine.boolean().optional(), + scores: vine.array(vine.string()).optional().requiredIfExists('participated'), + }) + + const data = { + scores: [], + } + + await assert.validationOutput(vine.validate({ schema, data }), { + scores: [], + }) + }) +}) + +test.group('requiredIfExists | object', () => { + test('fail when value is missing but other field exists', async ({ assert }) => { + const schema = vine.object({ + participated: vine.boolean(), + scores: vine.object({}).optional().requiredIfExists('participated'), + }) + + const data = { + participated: true, + } + + await assert.validationErrors(vine.validate({ schema, data }), [ + { + field: 'scores', + message: 'The scores field must be defined', + rule: 'required', + }, + ]) + }) + + test('pass when value is missing but other field does not exist', async ({ assert }) => { + const schema = vine.object({ + participated: vine.boolean().optional(), + scores: vine.object({}).optional().requiredIfExists('participated'), + }) + + const data = {} + + await assert.validationOutput(vine.validate({ schema, data }), {}) + }) + + test('pass when value is defined but other field does not exist', async ({ assert }) => { + const schema = vine.object({ + participated: vine.boolean().optional(), + scores: vine.object({}).optional().requiredIfExists('participated'), + }) + + const data = { + scores: {}, + } + + await assert.validationOutput(vine.validate({ schema, data }), { + scores: {}, + }) + }) +}) + +test.group('requiredIfExists | tuple', () => { + test('fail when value is missing but other field exists', async ({ assert }) => { + const schema = vine.object({ + participated: vine.boolean(), + scores: vine.tuple([vine.string()]).optional().requiredIfExists('participated'), + }) + + const data = { + participated: true, + } + + await assert.validationErrors(vine.validate({ schema, data }), [ + { + field: 'scores', + message: 'The scores field must be defined', + rule: 'required', + }, + ]) + }) + + test('pass when value is missing but other field does not exist', async ({ assert }) => { + const schema = vine.object({ + participated: vine.boolean().optional(), + scores: vine.tuple([vine.string()]).optional().requiredIfExists('participated'), + }) + + const data = {} + + await assert.validationOutput(vine.validate({ schema, data }), {}) + }) + + test('pass when value is defined but other field does not exist', async ({ assert }) => { + const schema = vine.object({ + participated: vine.boolean().optional(), + scores: vine.tuple([vine.string()]).optional().requiredIfExists('participated'), + }) + + const data = { + scores: ['1'], + } + + await assert.validationOutput(vine.validate({ schema, data }), { + scores: ['1'], + }) + }) +}) + +test.group('requiredIfExists | record', () => { + test('fail when value is missing but other field exists', async ({ assert }) => { + const schema = vine.object({ + participated: vine.boolean(), + scores: vine.record(vine.string()).optional().requiredIfExists('participated'), + }) + + const data = { + participated: true, + } + + await assert.validationErrors(vine.validate({ schema, data }), [ + { + field: 'scores', + message: 'The scores field must be defined', + rule: 'required', + }, + ]) + }) + + test('pass when value is missing but other field does not exist', async ({ assert }) => { + const schema = vine.object({ + participated: vine.boolean().optional(), + scores: vine.record(vine.string()).optional().requiredIfExists('participated'), + }) + + const data = {} + + await assert.validationOutput(vine.validate({ schema, data }), {}) + }) + + test('pass when value is defined but other field does not exist', async ({ assert }) => { + const schema = vine.object({ + participated: vine.boolean().optional(), + scores: vine.record(vine.string()).optional().requiredIfExists('participated'), + }) + + const data = { + scores: {}, + } + + await assert.validationOutput(vine.validate({ schema, data }), { + scores: {}, + }) + }) +})