-
-
Notifications
You must be signed in to change notification settings - Fork 90
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
26 changed files
with
539 additions
and
116 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -9,7 +9,7 @@ plugins { | |
} | ||
|
||
group = "dev.zenstack" | ||
version = "2.6.1" | ||
version = "2.6.2" | ||
|
||
repositories { | ||
mavenCentral() | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,121 @@ | ||
/* eslint-disable @typescript-eslint/no-explicit-any */ | ||
import { z as Z } from 'zod'; | ||
|
||
/** | ||
* A smarter version of `z.union` that decide which candidate to use based on how few unrecognized keys it has. | ||
* | ||
* The helper is used to deal with ambiguity in union generated for Prisma inputs when the zod schemas are configured | ||
* to run in "strip" object parsing mode. Since "strip" automatically drops unrecognized keys, it may result in | ||
* accidentally matching a less-ideal schema candidate. | ||
* | ||
* The helper uses a custom schema to find the candidate that results in the fewest unrecognized keys when parsing the data. | ||
*/ | ||
export function smartUnion(z: typeof Z, candidates: Z.ZodSchema[]) { | ||
// strip `z.lazy` | ||
const processedCandidates = candidates.map((candidate) => unwrapLazy(z, candidate)); | ||
|
||
if (processedCandidates.some((c) => !(c instanceof z.ZodObject || c instanceof z.ZodArray))) { | ||
// fall back to plain union if not all candidates are objects or arrays | ||
return z.union(candidates as any); | ||
} | ||
|
||
let resultData: any; | ||
|
||
return z | ||
.custom((data) => { | ||
if (Array.isArray(data)) { | ||
const { data: result, success } = smartArrayUnion( | ||
z, | ||
processedCandidates.filter((c) => c instanceof z.ZodArray), | ||
data | ||
); | ||
if (success) { | ||
resultData = result; | ||
} | ||
return success; | ||
} else { | ||
const { data: result, success } = smartObjectUnion( | ||
z, | ||
processedCandidates.filter((c) => c instanceof z.ZodObject), | ||
data | ||
); | ||
if (success) { | ||
resultData = result; | ||
} | ||
return success; | ||
} | ||
}) | ||
.transform(() => { | ||
// return the parsed data | ||
return resultData; | ||
}); | ||
} | ||
|
||
function smartArrayUnion(z: typeof Z, candidates: Array<Z.ZodArray<Z.ZodObject<Z.ZodRawShape>>>, data: any) { | ||
if (candidates.length === 0) { | ||
return { data: undefined, success: false }; | ||
} | ||
|
||
if (!Array.isArray(data)) { | ||
return { data: undefined, success: false }; | ||
} | ||
|
||
if (data.length === 0) { | ||
return { data, success: true }; | ||
} | ||
|
||
// use the first element to identify the candidate schema to use | ||
const item = data[0]; | ||
const itemSchema = identifyCandidate( | ||
z, | ||
candidates.map((candidate) => candidate.element), | ||
item | ||
); | ||
|
||
// find the matching schema and re-parse the data | ||
const schema = candidates.find((candidate) => candidate.element === itemSchema); | ||
return schema!.safeParse(data); | ||
} | ||
|
||
function smartObjectUnion(z: typeof Z, candidates: Z.ZodObject<Z.ZodRawShape>[], data: any) { | ||
if (candidates.length === 0) { | ||
return { data: undefined, success: false }; | ||
} | ||
const schema = identifyCandidate(z, candidates, data); | ||
return schema.safeParse(data); | ||
} | ||
|
||
function identifyCandidate( | ||
z: typeof Z, | ||
candidates: Array<Z.ZodObject<Z.ZodRawShape> | Z.ZodLazy<Z.ZodObject<Z.ZodRawShape>>>, | ||
data: any | ||
) { | ||
const strictResults = candidates.map((candidate) => { | ||
// make sure to strip `z.lazy` before parsing | ||
const unwrapped = unwrapLazy(z, candidate); | ||
return { | ||
schema: candidate, | ||
// force object schema to run in strict mode to capture unrecognized keys | ||
result: unwrapped.strict().safeParse(data), | ||
}; | ||
}); | ||
|
||
// find the schema with the fewest unrecognized keys | ||
const { schema } = strictResults.sort((a, b) => { | ||
const aCount = countUnrecognizedKeys(a.result.error?.issues ?? []); | ||
const bCount = countUnrecognizedKeys(b.result.error?.issues ?? []); | ||
return aCount - bCount; | ||
})[0]; | ||
return schema; | ||
} | ||
|
||
function countUnrecognizedKeys(issues: Z.ZodIssue[]) { | ||
return issues | ||
.filter((issue) => issue.code === 'unrecognized_keys') | ||
.map((issue) => issue.keys.length) | ||
.reduce((a, b) => a + b, 0); | ||
} | ||
|
||
function unwrapLazy<T extends Z.ZodSchema>(z: typeof Z, schema: T | Z.ZodLazy<T>): T { | ||
return schema instanceof z.ZodLazy ? schema.schema : schema; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,109 @@ | ||
import { z } from 'zod'; | ||
import { smartUnion } from '../../src/zod-utils'; | ||
|
||
describe('Zod smart union', () => { | ||
it('should work with scalar union', () => { | ||
const schema = smartUnion(z, [z.string(), z.number()]); | ||
expect(schema.safeParse('test')).toMatchObject({ success: true, data: 'test' }); | ||
expect(schema.safeParse(1)).toMatchObject({ success: true, data: 1 }); | ||
expect(schema.safeParse(true)).toMatchObject({ success: false }); | ||
}); | ||
|
||
it('should work with non-ambiguous object union', () => { | ||
const schema = smartUnion(z, [z.object({ a: z.string() }), z.object({ b: z.number() }).strict()]); | ||
expect(schema.safeParse({ a: 'test' })).toMatchObject({ success: true, data: { a: 'test' } }); | ||
expect(schema.safeParse({ b: 1 })).toMatchObject({ success: true, data: { b: 1 } }); | ||
expect(schema.safeParse({ a: 'test', b: 1 })).toMatchObject({ success: true }); | ||
expect(schema.safeParse({ b: 1, c: 'test' })).toMatchObject({ success: false }); | ||
expect(schema.safeParse({ c: 'test' })).toMatchObject({ success: false }); | ||
}); | ||
|
||
it('should work with ambiguous object union', () => { | ||
const schema = smartUnion(z, [ | ||
z.object({ a: z.string(), b: z.number() }), | ||
z.object({ a: z.string(), c: z.boolean() }), | ||
]); | ||
expect(schema.safeParse({ a: 'test', b: 1 })).toMatchObject({ success: true, data: { a: 'test', b: 1 } }); | ||
expect(schema.safeParse({ a: 'test', c: true })).toMatchObject({ success: true, data: { a: 'test', c: true } }); | ||
expect(schema.safeParse({ a: 'test', b: 1, z: 'z' })).toMatchObject({ | ||
success: true, | ||
data: { a: 'test', b: 1 }, | ||
}); | ||
expect(schema.safeParse({ a: 'test', c: true, z: 'z' })).toMatchObject({ | ||
success: true, | ||
data: { a: 'test', c: true }, | ||
}); | ||
expect(schema.safeParse({ c: 'test' })).toMatchObject({ success: false }); | ||
}); | ||
|
||
it('should work with non-ambiguous array union', () => { | ||
const schema = smartUnion(z, [ | ||
z.object({ a: z.string() }).array(), | ||
z.object({ b: z.number() }).strict().array(), | ||
]); | ||
|
||
expect(schema.safeParse([{ a: 'test' }])).toMatchObject({ success: true, data: [{ a: 'test' }] }); | ||
expect(schema.safeParse([{ a: 'test' }, { a: 'test1' }])).toMatchObject({ | ||
success: true, | ||
data: [{ a: 'test' }, { a: 'test1' }], | ||
}); | ||
|
||
expect(schema.safeParse([{ b: 1 }])).toMatchObject({ success: true, data: [{ b: 1 }] }); | ||
expect(schema.safeParse([{ a: 'test', b: 1 }])).toMatchObject({ success: true }); | ||
expect(schema.safeParse([{ b: 1, c: 'test' }])).toMatchObject({ success: false }); | ||
expect(schema.safeParse([{ c: 'test' }])).toMatchObject({ success: false }); | ||
|
||
// all items must match the same candidate | ||
expect(schema.safeParse([{ a: 'test' }, { b: 1 }])).toMatchObject({ success: false }); | ||
}); | ||
|
||
it('should work with ambiguous array union', () => { | ||
const schema = smartUnion(z, [ | ||
z.object({ a: z.string(), b: z.number() }).array(), | ||
z.object({ a: z.string(), c: z.boolean() }).array(), | ||
]); | ||
|
||
expect(schema.safeParse([{ a: 'test', b: 1 }])).toMatchObject({ success: true, data: [{ a: 'test', b: 1 }] }); | ||
expect(schema.safeParse([{ a: 'test', c: true }])).toMatchObject({ | ||
success: true, | ||
data: [{ a: 'test', c: true }], | ||
}); | ||
expect(schema.safeParse([{ a: 'test', b: 1, z: 'z' }])).toMatchObject({ | ||
success: true, | ||
data: [{ a: 'test', b: 1 }], | ||
}); | ||
expect(schema.safeParse([{ a: 'test', c: true, z: 'z' }])).toMatchObject({ | ||
success: true, | ||
data: [{ a: 'test', c: true }], | ||
}); | ||
expect(schema.safeParse([{ c: 'test' }])).toMatchObject({ success: false }); | ||
|
||
// all items must match the same candidate | ||
expect(schema.safeParse([{ a: 'test' }, { c: true }])).toMatchObject({ success: false }); | ||
}); | ||
|
||
it('should work with lazy schemas', () => { | ||
const schema = smartUnion(z, [ | ||
z.lazy(() => z.object({ a: z.string(), b: z.number() })), | ||
z.lazy(() => z.object({ a: z.string(), c: z.boolean() })), | ||
]); | ||
expect(schema.safeParse({ a: 'test', b: 1 })).toMatchObject({ success: true, data: { a: 'test', b: 1 } }); | ||
expect(schema.safeParse({ a: 'test', c: true })).toMatchObject({ success: true, data: { a: 'test', c: true } }); | ||
expect(schema.safeParse({ a: 'test', b: 1, z: 'z' })).toMatchObject({ | ||
success: true, | ||
data: { a: 'test', b: 1 }, | ||
}); | ||
}); | ||
|
||
it('should work with mixed object and array unions', () => { | ||
const schema = smartUnion(z, [ | ||
z.object({ a: z.string() }).strict(), | ||
z.object({ b: z.number() }).strict().array(), | ||
]); | ||
|
||
expect(schema.safeParse({ a: 'test' })).toMatchObject({ success: true, data: { a: 'test' } }); | ||
expect(schema.safeParse([{ b: 1 }])).toMatchObject({ success: true, data: [{ b: 1 }] }); | ||
expect(schema.safeParse({ a: 'test', b: 1 })).toMatchObject({ success: false }); | ||
expect(schema.safeParse([{ a: 'test' }])).toMatchObject({ success: false }); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.