From 3254bc8bdcd352eaebf9238f26c15f8d62115511 Mon Sep 17 00:00:00 2001 From: Vitali Haradkou Date: Sun, 21 Jan 2024 16:02:08 +0400 Subject: [PATCH] refactor: safeParse logic for pre and post process functions (#28) * refactor: safeParse logic for pre and post process functions --- .changeset/six-pianos-explode.md | 4 ++ src/builder.ts | 105 +++++++++++++++++++------------ tests/base.test.ts | 14 ++++- 3 files changed, 81 insertions(+), 42 deletions(-) diff --git a/.changeset/six-pianos-explode.md b/.changeset/six-pianos-explode.md index 0b7bc7d..7253794 100644 --- a/.changeset/six-pianos-explode.md +++ b/.changeset/six-pianos-explode.md @@ -11,3 +11,7 @@ const obj = s.object({}).async() obj.schema // {type: 'object', $async: true} ``` + +## fixes/refactories + +- refactor `safeParse` logic. diff --git a/src/builder.ts b/src/builder.ts index 8b571eb..c62d3ff 100644 --- a/src/builder.ts +++ b/src/builder.ts @@ -107,7 +107,7 @@ abstract class SchemaBuilder< /** * Marks your property as nullable (`undefined`) * - * **NOTES**: since in json-schema no difference between null and undefined - it's just for TS types + * **NOTES**: json-schema not accept `undefined` type. It's just `nullable` as typescript `undefined` type. */ optional(): SchemaBuilder { return this.nullable() as never @@ -116,7 +116,7 @@ abstract class SchemaBuilder< /** * Marks your property as nullable (`null`). * - * Update `type` property for your schema. + * Updates `type` property for your schema. * @example * const schemaDef = s.string().nullable() * schemaDef.schema // { type: ['string', 'null'], nullable: true } @@ -136,9 +136,12 @@ abstract class SchemaBuilder< private preFns: Function[] = [] /** - * Add preprocess function for incoming result. + * pre process function for incoming result. Transform input **BEFORE** calling `parse`, `safeParse`, `validate` functions * * **NOTE:** this functions works BEFORE parsing. use it at own risk. (e.g. transform Date object into string) + * @see {@link SchemaBuilder.parse parse method} + * @see {@link SchemaBuilder.safeParse safe parse method} + * @see {@link SchemaBuilder.validate validate method} * @example * const myString = s.string().preprocess(v => { * // if date => transform to ISO string @@ -162,7 +165,10 @@ abstract class SchemaBuilder< private postFns: { fn: Function, schema: AnySchemaBuilder }[] = [] /** - * Post process. Use it when you would like to transform result after parsing is happens + * Post process. Use it when you would like to transform result after parsing is happens. + * + * **NOTE:** this function override your `input` variable for `safeParse` calling. + * @see {@link SchemaBuilder.safeParse safeParse method} */ postprocess(fn: (input: Input) => unknown, schema: S): S { if (typeof fn !== 'function') { @@ -333,20 +339,25 @@ abstract class SchemaBuilder< return not(this) as never } - /** - * Parse you input result. Used `ajv.validate` under the hood - * - * It also applies your `postProcess` functions if parsing was successfull - */ - safeParse(input: unknown): SafeParseResult { - // need to remove schema, or we get precompiled result. It's bad for `extend` and `merge` in object schema - this.ajv.removeSchema(this.schema) - let transformed; - if (Array.isArray(this.preFns) && this.preFns.length > 0) { + private _transform(input: unknown, arr: ({ fn: Function, schema: AnySchemaBuilder } | Function)[] = []): SafeParseResult { + let output; + if (Array.isArray(arr) && arr.length > 0) { try { - transformed = this.preFns.reduce((prevResult, fn) => { - return fn(prevResult) - }, input) + output = arr.reduce((prevResult, el) => { + if (!prevResult.success) { + throw prevResult.error + } + let fnTransform + let result: SafeParseResult = { data: input, input, success: true } + if (typeof el === 'function') { + fnTransform = el(prevResult.data) + result.data = fnTransform + } else { + fnTransform = el.fn(prevResult.data) + result = el.schema.safeParse(fnTransform) + } + return result + }, { input, data: input, success: true } as SafeParseResult) } catch (e) { return { success: false, @@ -354,46 +365,58 @@ abstract class SchemaBuilder< input, } } - } else { - transformed = input + return output as SafeParseResult } + output = input + return { data: output as Out, input, success: true } + } + private _safeParseRaw(input: unknown): SafeParseResult { try { - const valid: boolean = this.ajv.validate(this.schema, transformed) + const valid: boolean = this.ajv.validate(this.schema, input) if (!valid) { const firstError = this.ajv.errors?.at(0) return { error: new Error(firstError?.message, { cause: firstError }), success: false, - input: transformed + input: input } } - if (Array.isArray(this.postFns) && this.postFns.length > 0) { - const output = this.postFns.reduce((prevResult, { fn, schema }) => { - const result = schema.safeParse(fn(prevResult)) - if (result.success) { - return result.data - } - throw result.error - }, transformed) - return { - success: true, - data: output as Output, - input: transformed - } - } - return { - success: true, - data: transformed as Output, - input: transformed - } } catch (e) { return { error: new Error((e as Error).message, { cause: e }), success: false, - input: transformed + input, } } + return { + input, + data: input, + success: true + } + } + + /** + * Parse you input result. Used `ajv.validate` under the hood + * + * It also applies your `postProcess` functions if parsing was successfull + */ + safeParse(input: unknown): SafeParseResult { + // need to remove schema, or we get precompiled result. It's bad for `extend` and `merge` in object schema + this.ajv.removeSchema(this.schema) + let preTransformedResult = this._transform(input, this.preFns); + + if (!preTransformedResult.success) { + return preTransformedResult + } + + const parseResult = this._safeParseRaw(preTransformedResult.data) + if (!parseResult.success) { + return parseResult + } + + const postTransformedResult = this._transform(parseResult.data, this.postFns) + return postTransformedResult } /** * Validate your schema. diff --git a/tests/base.test.ts b/tests/base.test.ts index c3a8e49..e690205 100644 --- a/tests/base.test.ts +++ b/tests/base.test.ts @@ -33,9 +33,21 @@ test('should support AJV custom ajv instance', () => { test('postProcess should should transform output result', () => { const myNum = s.number().postprocess(v => String(v), s.string()) - expect(myNum.parse(1)).toBe('1') + const res = myNum.parse(1) + + expect(res).toBe('1') }) +test('preprocess should transform input result', () => { + const envParsingSchema = s.boolean().preprocess(x => String(x) === 'true' || String(x) === '1') + + expect(envParsingSchema.parse('true')).toBe(true) +}) + +test('preprocess should throw for unconsistant schema', () => { + const numberSchema = s.number().preprocess(x => String(x)) + expect(() => numberSchema.parse('hello')).toThrow(Error) +}) test("test this binding", () => { const callback = (predicate: (val: string) => boolean) => {