From 5ab42a9aaff4a22f70bfb489cdad0b4526f3b080 Mon Sep 17 00:00:00 2001 From: Jason Quense Date: Tue, 28 Dec 2021 13:44:48 -0500 Subject: [PATCH 1/2] WIP --- src/Condition.ts | 85 ++++++++++++++++++++++++--------------------- src/schema.ts | 38 ++++++++++++++++---- test/array.ts | 4 +-- test/object.ts | 18 +++++----- test/types/types.ts | 33 +++++++++++++++++- test/yup.js | 4 +-- 6 files changed, 122 insertions(+), 60 deletions(-) diff --git a/src/Condition.ts b/src/Condition.ts index 3feb5afc4..cc7168048 100644 --- a/src/Condition.ts +++ b/src/Condition.ts @@ -2,73 +2,80 @@ import isSchema from './util/isSchema'; import Reference from './Reference'; import type { ISchema } from './util/types'; -export interface ConditionBuilder> { - (this: T, value: any, schema: T): ISchema | void; - (v1: any, v2: any, schema: T): ISchema | void; - (v1: any, v2: any, v3: any, schema: T): ISchema | void; - (v1: any, v2: any, v3: any, v4: any, schema: T): ISchema | void; -} - -export type ConditionConfig> = { +export type ConditionBuilder< + T extends ISchema, + U extends ISchema = T, +> = (values: any[], schema: T, options: ResolveOptions) => U; + +export type ConditionConfig< + T extends ISchema, + TThen extends ISchema = T, + TOtherwise extends ISchema = T, +> = { is: any | ((...values: any[]) => boolean); - then?: (schema: T) => ISchema; - otherwise?: (schema: T) => ISchema; + then?: (schema: T) => TThen; + otherwise?: (schema: T) => TOtherwise; }; -export type ConditionOptions> = - | ConditionBuilder - | ConditionConfig; - export type ResolveOptions = { value?: any; parent?: any; context?: TContext; }; -class Condition = ISchema> { - fn: ConditionBuilder; - - constructor(public refs: Reference[], options: ConditionOptions) { - this.refs = refs; - - if (typeof options === 'function') { - this.fn = options; - return; - } - - if (!('is' in options)) - throw new TypeError('`is:` is required for `when()` conditions'); - - if (!options.then && !options.otherwise) +class Condition< + TIn extends ISchema = ISchema, + TOut extends ISchema = TIn, +> { + fn: ConditionBuilder; + + static fromOptions< + TIn extends ISchema, + TThen extends ISchema, + TOtherwise extends ISchema, + >(refs: Reference[], config: ConditionConfig) { + if (!config.then && !config.otherwise) throw new TypeError( 'either `then:` or `otherwise:` is required for `when()` conditions', ); - let { is, then, otherwise } = options; + let { is, then, otherwise } = config; let check = typeof is === 'function' ? is : (...values: any[]) => values.every((value) => value === is); - this.fn = function (...args: any[]) { - let _opts = args.pop(); - let schema = args.pop(); - let branch = check(...args) ? then : otherwise; + return new Condition( + refs, + (values, schema: any) => { + let branch = check(...values) ? then : otherwise; - return branch?.(schema) ?? schema; - }; + return branch?.(schema) ?? schema; + }, + ); } - resolve(base: T, options: ResolveOptions) { + constructor(public refs: Reference[], builder: ConditionBuilder) { + this.refs = refs; + this.fn = builder; + } + + resolve(base: TIn, options: ResolveOptions) { let values = this.refs.map((ref) => // TODO: ? operator here? ref.getValue(options?.value, options?.parent, options?.context), ); - let schema = this.fn.apply(base, values.concat(base, options) as any); + let schema = this.fn(values, base, options); - if (schema === undefined || schema === base) return base; + if ( + schema === undefined || + // @ts-ignore this can be base + schema === base + ) { + return base; + } if (!isSchema(schema)) throw new TypeError('conditions must return a schema object'); diff --git a/src/schema.ts b/src/schema.ts index fc1cbe652..af0998d4d 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -2,7 +2,11 @@ import cloneDeep from 'nanoclone'; import { mixed as locale } from './locale'; -import Condition, { ConditionOptions, ResolveOptions } from './Condition'; +import Condition, { + ConditionBuilder, + ConditionConfig, + ResolveOptions, +} from './Condition'; import runTests from './util/runTests'; import createValidation, { TestFunction, @@ -670,11 +674,29 @@ export default abstract class BaseSchema< return next; } - when(options: ConditionOptions): this; - when(keys: string | string[], options: ConditionOptions): this; + when = this>(builder: ConditionBuilder): U; + when = this>( + keys: string | string[], + builder: ConditionBuilder, + ): U; + when< + UThen extends ISchema = this, + UOtherwise extends ISchema = this, + >(options: ConditionConfig): UThen | UOtherwise; + when< + UThen extends ISchema = this, + UOtherwise extends ISchema = this, + >( + keys: string | string[], + options: ConditionConfig, + ): UThen | UOtherwise; when( - keys: string | string[] | ConditionOptions, - options?: ConditionOptions, + keys: + | string + | string[] + | ConditionBuilder + | ConditionConfig, + options?: ConditionBuilder | ConditionConfig, ) { if (!Array.isArray(keys) && typeof keys !== 'string') { options = keys; @@ -689,7 +711,11 @@ export default abstract class BaseSchema< if (dep.isSibling) next.deps.push(dep.key); }); - next.conditions.push(new Condition(deps, options!) as Condition); + next.conditions.push( + typeof options === 'function' + ? new Condition(deps, options!) + : Condition.fromOptions(deps, options!), + ); return next; } diff --git a/test/array.ts b/test/array.ts index a59af9944..fd5442695 100644 --- a/test/array.ts +++ b/test/array.ts @@ -181,8 +181,8 @@ describe('Array types', () => { let value = ['2', '3']; let expectedPaths = ['[0]', '[1]']; - let itemSchema = string().when([], function (_, context) { - let path = context.path; + let itemSchema = string().when([], function (_, _s, opts: any) { + let path = opts.path; expect(expectedPaths).toContain(path); return string().required(); }); diff --git a/test/object.ts b/test/object.ts index 2afb9b139..3e516fd7a 100644 --- a/test/object.ts +++ b/test/object.ts @@ -675,9 +675,7 @@ describe('Object types', () => { let inst = object().shape({ noteDate: number() .when('stats.isBig', { is: true, then: (s) => s.min(5) }) - .when('other', function (v) { - if (v === 4) return this.max(6); - }), + .when('other', ([v], schema) => (v === 4 ? schema.max(6) : schema)), stats: object({ isBig: bool() }), other: number() .min(1) @@ -780,11 +778,11 @@ describe('Object types', () => { it('should allow opt out of topo sort on specific edges', () => { expect(() => { object().shape({ - orgID: number().when('location', (v, schema) => { - if (v == null) return schema.required(); + orgID: number().when('location', ([v], schema) => { + return v == null ? schema.required() : schema; }), location: string().when('orgID', (v, schema) => { - if (v == null) return schema.required(); + return v == null ? schema.required() : schema; }), }); }).toThrowError('Cyclic dependency, node was:"location"'); @@ -792,11 +790,11 @@ describe('Object types', () => { expect(() => { object().shape( { - orgID: number().when('location', function (v) { - if (v == null) return this.required(); + orgID: number().when('location', ([v], schema) => { + return v == null ? schema.required() : schema; }), - location: string().when('orgID', function (v) { - if (v == null) return this.required(); + location: string().when('orgID', ([v], schema) => { + return v == null ? schema.required() : schema; }), }, [['location', 'orgID']], diff --git a/test/types/types.ts b/test/types/types.ts index 3807dffea..2c3a5fe7c 100644 --- a/test/types/types.ts +++ b/test/types/types.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-unused-expressions */ /* eslint-disable no-unused-labels */ -import { array, number, string, date, ref, mixed } from '../../src'; +import { array, number, string, date, ref, mixed, bool } from '../../src'; import { create as lazy } from '../../src/Lazy'; import ObjectSchema, { create as object } from '../../src/object'; @@ -720,3 +720,34 @@ Object: { deepPartial.validateSync({})!.address!.line1; } } + +Conditions: { + // $ExpectType StringSchema | NumberSchema + string().when('foo', ([foo], schema) => (foo ? schema.required() : number())); + + // $ExpectType StringSchema + string().when('foo', ([foo], schema) => (foo ? schema.required() : schema)); + + // $ExpectType StringSchema | NumberSchema + string().when('foo', { + is: true, + then: () => number(), + otherwise: (s) => s.required(), + }); + + const result = object({ + foo: bool().defined(), + polyField: mixed().when('foo', { + is: true, + then: () => number(), + otherwise: (s) => s.required(), + }), + }).cast({ foo: true, polyField: '1' }); + + // $ExpectType { polyField?: string | number | undefined; foo: boolean; } + result; + + mixed() + .when('foo', ([foo]) => (foo ? string() : number())) + .min(1); +} diff --git a/test/yup.js b/test/yup.js index 4621f7151..02624c64a 100644 --- a/test/yup.js +++ b/test/yup.js @@ -118,13 +118,13 @@ describe('Yup', function () { inst = object().shape({ num: number().max(4), nested: object().shape({ - arr: array().when('$bar', function (bar) { + arr: array().when('$bar', function ([bar]) { return bar !== 3 ? array().of(number()) : array().of( object().shape({ foo: number(), - num: number().when('foo', (foo) => { + num: number().when('foo', ([foo]) => { if (foo === 5) return num; }), }), From ccf6936f248f04c47d1c509a6c35e9510e7ce98c Mon Sep 17 00:00:00 2001 From: Jason Quense Date: Tue, 28 Dec 2021 13:59:06 -0500 Subject: [PATCH 2/2] docs: when --- README.md | 12 ++++++------ test/mixed.ts | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index fa3623711..9f8d6cda6 100644 --- a/README.md +++ b/README.md @@ -466,7 +466,7 @@ let schema = object({ loose: boolean(), bar: string().when('loose', { is: true, - otherwise: (s) => s.strict(), + otherwise: (schema) => schema.strict(), }), }), ), @@ -634,7 +634,7 @@ await schema.isValid(42); // => false await schema.isValid(new Date()); // => true ``` -#### `mixed.when(keys: string | Array, builder: object | (value, schema)=> Schema): Schema` +#### `mixed.when(keys: string | string[], builder: object | (values: any[], schema) => Schema): Schema` Adjust the schema based on a sibling or sibling children fields. You can provide an object literal where the key `is` is value or a matcher function, `then` provides the true schema and/or @@ -652,8 +652,8 @@ let schema = object({ count: number() .when('isBig', { is: true, // alternatively: (val) => val == true - then: yup.number().min(5), - otherwise: yup.number().min(0), + then: (schema) => schema..min(5), + otherwise: (schema) => schema..min(0), }) .when('$other', (other, schema) => (other === 4 ? schema.max(6) : schema)), }); @@ -669,8 +669,8 @@ let schema = object({ isBig: boolean(), count: number().when(['isBig', 'isSpecial'], { is: true, // alternatively: (isBig, isSpecial) => isBig && isSpecial - then: yup.number().min(5), - otherwise: yup.number().min(0), + then: (schema) => schema..min(5), + otherwise: (schema) => schema..min(0), }), }); diff --git a/test/mixed.ts b/test/mixed.ts index 167dea4be..784f396ff 100644 --- a/test/mixed.ts +++ b/test/mixed.ts @@ -764,12 +764,12 @@ describe('Mixed Types ', () => { it('should handle multiple conditionals', function () { let called = false; - let inst = mixed().when(['$prop', '$other'], (prop, other) => { + let inst = mixed().when(['$prop', '$other'], ([prop, other], schema) => { expect(other).toBe(true); expect(prop).toBe(1); called = true; - return mixed(); + return schema; }); inst.cast({}, { context: { prop: 1, other: true } });