Skip to content

Commit

Permalink
refactor: safeParse logic for pre and post process functions (#28)
Browse files Browse the repository at this point in the history
* refactor: safeParse logic for pre and post process functions
  • Loading branch information
vitalics authored Jan 21, 2024
1 parent 09c54ff commit 3254bc8
Show file tree
Hide file tree
Showing 3 changed files with 81 additions and 42 deletions.
4 changes: 4 additions & 0 deletions .changeset/six-pianos-explode.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,7 @@ const obj = s.object({}).async()

obj.schema // {type: 'object', $async: true}
```

## fixes/refactories

- refactor `safeParse` logic.
105 changes: 64 additions & 41 deletions src/builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Input, Schema, Output | undefined> {
return this.nullable() as never
Expand All @@ -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 }
Expand All @@ -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
Expand All @@ -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<S extends AnySchemaBuilder = AnySchemaBuilder>(fn: (input: Input) => unknown, schema: S): S {
if (typeof fn !== 'function') {
Expand Down Expand Up @@ -333,67 +339,84 @@ 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<Output> {
// 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<Out = unknown>(input: unknown, arr: ({ fn: Function, schema: AnySchemaBuilder } | Function)[] = []): SafeParseResult<Out> {
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<unknown> = { 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<unknown>)
} catch (e) {
return {
success: false,
error: new Error((e as Error).message, { cause: e }),
input,
}
}
} else {
transformed = input
return output as SafeParseResult<Out>
}
output = input
return { data: output as Out, input, success: true }
}

private _safeParseRaw(input: unknown): SafeParseResult<unknown> {
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<Output> {
// 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<Output>(parseResult.data, this.postFns)
return postTransformedResult
}
/**
* Validate your schema.
Expand Down
14 changes: 13 additions & 1 deletion tests/base.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down

0 comments on commit 3254bc8

Please sign in to comment.