Skip to content

Commit

Permalink
merge dev to main (v2.6.2) (#1752)
Browse files Browse the repository at this point in the history
  • Loading branch information
ymc9 authored Sep 27, 2024
2 parents 6f30022 + 46d6a63 commit be8c1c4
Show file tree
Hide file tree
Showing 26 changed files with 539 additions and 116 deletions.
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

0 comments on commit be8c1c4

Please sign in to comment.