Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "zenstack-monorepo",
"version": "2.6.1",
"version": "2.6.2",
"description": "",
"scripts": {
"build": "pnpm -r build",
Expand Down
2 changes: 1 addition & 1 deletion packages/ide/jetbrains/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ plugins {
}

group = "dev.zenstack"
version = "2.6.1"
version = "2.6.2"

repositories {
mavenCentral()
Expand Down
2 changes: 1 addition & 1 deletion packages/ide/jetbrains/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "jetbrains",
"version": "2.6.1",
"version": "2.6.2",
"displayName": "ZenStack JetBrains IDE Plugin",
"description": "ZenStack JetBrains IDE plugin",
"homepage": "https://zenstack.dev",
Expand Down
2 changes: 1 addition & 1 deletion packages/language/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@zenstackhq/language",
"version": "2.6.1",
"version": "2.6.2",
"displayName": "ZenStack modeling language compiler",
"description": "ZenStack modeling language compiler",
"homepage": "https://zenstack.dev",
Expand Down
2 changes: 1 addition & 1 deletion packages/misc/redwood/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@zenstackhq/redwood",
"displayName": "ZenStack RedwoodJS Integration",
"version": "2.6.1",
"version": "2.6.2",
"description": "CLI and runtime for integrating ZenStack with RedwoodJS projects.",
"repository": {
"type": "git",
Expand Down
2 changes: 1 addition & 1 deletion packages/plugins/openapi/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@zenstackhq/openapi",
"displayName": "ZenStack Plugin and Runtime for OpenAPI",
"version": "2.6.1",
"version": "2.6.2",
"description": "ZenStack plugin and runtime supporting OpenAPI",
"main": "index.js",
"repository": {
Expand Down
2 changes: 1 addition & 1 deletion packages/plugins/swr/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@zenstackhq/swr",
"displayName": "ZenStack plugin for generating SWR hooks",
"version": "2.6.1",
"version": "2.6.2",
"description": "ZenStack plugin for generating SWR hooks",
"main": "index.js",
"repository": {
Expand Down
2 changes: 1 addition & 1 deletion packages/plugins/tanstack-query/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@zenstackhq/tanstack-query",
"displayName": "ZenStack plugin for generating tanstack-query hooks",
"version": "2.6.1",
"version": "2.6.2",
"description": "ZenStack plugin for generating tanstack-query hooks",
"main": "index.js",
"exports": {
Expand Down
2 changes: 1 addition & 1 deletion packages/plugins/trpc/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@zenstackhq/trpc",
"displayName": "ZenStack plugin for tRPC",
"version": "2.6.1",
"version": "2.6.2",
"description": "ZenStack plugin for tRPC",
"main": "index.js",
"repository": {
Expand Down
8 changes: 6 additions & 2 deletions packages/runtime/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@zenstackhq/runtime",
"displayName": "ZenStack Runtime Library",
"version": "2.6.1",
"version": "2.6.2",
"description": "Runtime of ZenStack for both client-side and server-side environments.",
"repository": {
"type": "git",
Expand Down Expand Up @@ -76,6 +76,10 @@
"./models": {
"types": "./models.d.ts"
},
"./zod-utils": {
"types": "./zod-utils.d.ts",
"default": "./zod-utils.js"
},
"./package.json": {
"default": "./package.json"
}
Expand Down Expand Up @@ -107,7 +111,7 @@
"zod-validation-error": "^1.5.0"
},
"peerDependencies": {
"@prisma/client": "5.0.0 - 5.19.x"
"@prisma/client": "5.0.0 - 5.20.x"
},
"author": {
"name": "ZenStack Team"
Expand Down
121 changes: 121 additions & 0 deletions packages/runtime/src/zod-utils.ts
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;
}
109 changes: 109 additions & 0 deletions packages/runtime/tests/zod/smart-union.test.ts
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 });
});
});
6 changes: 3 additions & 3 deletions packages/schema/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"publisher": "zenstack",
"displayName": "ZenStack Language Tools",
"description": "FullStack enhancement for Prisma ORM: seamless integration from database to UI",
"version": "2.6.1",
"version": "2.6.2",
"author": {
"name": "ZenStack Team"
},
Expand Down Expand Up @@ -123,10 +123,10 @@
"zod-validation-error": "^1.5.0"
},
"peerDependencies": {
"prisma": "5.0.0 - 5.19.x"
"prisma": "5.0.0 - 5.20.x"
},
"devDependencies": {
"@prisma/client": "5.19.x",
"@prisma/client": "5.20.x",
"@types/async-exit-hook": "^2.0.0",
"@types/pluralize": "^0.0.29",
"@types/semver": "^7.3.13",
Expand Down
Loading