diff --git a/packages/orm/src/client/contract.ts b/packages/orm/src/client/contract.ts index 4a451e203..4d54c2e49 100644 --- a/packages/orm/src/client/contract.ts +++ b/packages/orm/src/client/contract.ts @@ -28,15 +28,20 @@ import type { FindUniqueArgs, GroupByArgs, GroupByResult, + OmitWhere, ProcedureFunc, SelectSubset, + SelectSubsetWithWhere, SimplifiedPlainResult, Subset, + SubsetWithWhere, TypeDefResult, UpdateArgs, UpdateManyAndReturnArgs, UpdateManyArgs, UpsertArgs, + WhereInput, + WhereUniqueInput, } from './crud-types'; import type { Diagnostics } from './diagnostics'; import type { ClientOptions, QueryOptions } from './options'; @@ -405,8 +410,8 @@ export type AllModelOperations< * }); * ``` */ - updateManyAndReturn>( - args: Subset>, + updateManyAndReturn>>( + args: { where?: WhereInput } & SubsetWithWhere>>, ): ZenStackPromise[]>; }); @@ -498,8 +503,8 @@ type CommonModelOperations< * }); // result: `{ _count: { posts: number } }` * ``` */ - findMany>( - args?: SelectSubset>, + findMany>>( + args?: { where?: WhereInput } & SelectSubset>, ): ZenStackPromise[]>; /** @@ -508,8 +513,8 @@ type CommonModelOperations< * @returns a single entity or null if not found * @see {@link findMany} */ - findUnique>( - args: SelectSubset>, + findUnique>>( + args: { where: WhereUniqueInput } & SelectSubset>, ): ZenStackPromise | null>; /** @@ -518,8 +523,8 @@ type CommonModelOperations< * @returns a single entity * @see {@link findMany} */ - findUniqueOrThrow>( - args: SelectSubset>, + findUniqueOrThrow>>( + args: { where: WhereUniqueInput } & SelectSubset>, ): ZenStackPromise>; /** @@ -528,8 +533,8 @@ type CommonModelOperations< * @returns a single entity or null if not found * @see {@link findMany} */ - findFirst>( - args?: SelectSubset>, + findFirst>>( + args?: { where?: WhereInput } & SelectSubset>, ): ZenStackPromise | null>; /** @@ -538,8 +543,8 @@ type CommonModelOperations< * @returns a single entity * @see {@link findMany} */ - findFirstOrThrow>( - args?: SelectSubset>, + findFirstOrThrow>>( + args?: { where?: WhereInput } & SelectSubset>, ): ZenStackPromise>; /** @@ -744,8 +749,8 @@ type CommonModelOperations< * }); * ``` */ - update>( - args: SelectSubset>, + update>>( + args: { where: WhereUniqueInput } & SelectSubsetWithWhere>>, ): ZenStackPromise>; /** @@ -768,8 +773,8 @@ type CommonModelOperations< * limit: 10 * }); */ - updateMany>( - args: Subset>, + updateMany>>( + args: { where?: WhereInput } & SubsetWithWhere>>, ): ZenStackPromise; /** @@ -792,8 +797,8 @@ type CommonModelOperations< * }); * ``` */ - upsert>( - args: SelectSubset>, + upsert>>( + args: { where: WhereUniqueInput } & SelectSubsetWithWhere>>, ): ZenStackPromise>; /** @@ -815,8 +820,8 @@ type CommonModelOperations< * }); // result: `{ id: string; email: string }` * ``` */ - delete>( - args: SelectSubset>, + delete>>( + args: { where: WhereUniqueInput } & SelectSubset>, ): ZenStackPromise>; /** @@ -838,8 +843,8 @@ type CommonModelOperations< * }); * ``` */ - deleteMany>( - args?: Subset>, + deleteMany>>( + args?: { where?: WhereInput } & Subset>, ): ZenStackPromise; /** diff --git a/packages/orm/src/client/crud-types.ts b/packages/orm/src/client/crud-types.ts index e739575dd..8c8108089 100644 --- a/packages/orm/src/client/crud-types.ts +++ b/packages/orm/src/client/crud-types.ts @@ -1104,6 +1104,42 @@ export type SelectSubset = { ? 'Please either choose `select` or `omit`.' : {}); +/** + * Strips the `where` field from an args type so the remaining fields can be used as + * the generic type parameter `T` in CRUD methods, allowing `where` to be typed directly + * and benefit from TypeScript's excess property checking. + * @internal + */ +export type OmitWhere = Omit; + +/** + * Like {@link Subset} but maps the `where` key to `unknown` (instead of `never`) when + * `where` is not present in `U`. This is used in CRUD method signatures where `where` + * is separately typed as `{ where: WhereXxxInput }`: because TypeScript infers T from + * the full argument object (including the `where` field), a naive `Subset>` + * would produce `where: never` in the mapped result, collapsing the `where` type in the + * intersection to `never`. Mapping to `unknown` instead gives + * `{ where: W } & { where: unknown }` = `{ where: W }`, preserving both the correct type + * and TypeScript's excess-property checking on `where`. + * @internal + */ +export type SubsetWithWhere = { + [key in keyof T]: key extends keyof U ? T[key] : key extends 'where' ? unknown : never; +}; + +/** + * Like {@link SelectSubset} but maps the `where` key to `unknown` (instead of `never`) when + * `where` is not present in `U`. See {@link SubsetWithWhere} for the rationale. + * @internal + */ +export type SelectSubsetWithWhere = { + [key in keyof T]: key extends keyof U ? T[key] : key extends 'where' ? unknown : never; +} & (T extends { select: any; include: any } + ? 'Please either choose `select` or `include`.' + : T extends { select: any; omit: any } + ? 'Please either choose `select` or `omit`.' + : {}); + type ToManyRelationFilter< Schema extends SchemaDef, Model extends GetModels, diff --git a/tests/e2e/orm/schemas/delegate/typecheck.ts b/tests/e2e/orm/schemas/delegate/typecheck.ts index 76e36115f..c17981818 100644 --- a/tests/e2e/orm/schemas/delegate/typecheck.ts +++ b/tests/e2e/orm/schemas/delegate/typecheck.ts @@ -176,11 +176,48 @@ async function queryBuilder() { client.$qb.selectFrom('Video').select(['viewCount']).execute(); } +async function whereEPC() { + // unknown fields in `where` clause should produce a TypeScript error + await client.asset.findMany({ + where: { + viewCount: 1, + // @ts-expect-error notExistsColumn is not a valid field + notExistsColumn: 1, + }, + }); + + await client.asset.findFirst({ + where: { + viewCount: 1, + // @ts-expect-error notExistsColumn is not a valid field + notExistsColumn: 1, + }, + }); + + // valid fields should not produce errors + await client.asset.findMany({ + where: { + viewCount: { gt: 0 }, + }, + }); + + // unknown fields in `where` clause for update should also produce TypeScript errors + await client.asset.update({ + where: { + id: 1, + // @ts-expect-error notExistsColumn is not a valid field + notExistsColumn: 1, + }, + data: { viewCount: 2 }, + }); +} + async function main() { await create(); await update(); await find(); await queryBuilder(); + await whereEPC(); } main(); diff --git a/tests/e2e/orm/schemas/typing/typecheck.ts b/tests/e2e/orm/schemas/typing/typecheck.ts index 9f8b2aa86..d28059153 100644 --- a/tests/e2e/orm/schemas/typing/typecheck.ts +++ b/tests/e2e/orm/schemas/typing/typecheck.ts @@ -220,6 +220,30 @@ async function find() { console.log(u.posts[0]?.author?.role); // @ts-expect-error console.log(u.posts[0]?.author?.email); + + // unknown fields in `where` clause should produce TypeScript errors + await client.user.findMany({ + where: { + email: 'test@test.com', + // @ts-expect-error notExistsColumn is not a valid field + notExistsColumn: 1, + }, + }); + + await client.user.findFirst({ + where: { + email: 'test@test.com', + // @ts-expect-error notExistsColumn is not a valid field + notExistsColumn: 1, + }, + }); + + // valid where fields should not produce errors + await client.user.findMany({ + where: { + email: { contains: '@test.com' }, + }, + }); } async function create() { @@ -554,6 +578,35 @@ async function update() { email: 'alex@zenstack.dev', }, }); + + // unknown fields in `where` clause should produce TypeScript errors for update/upsert/updateMany + await client.user.update({ + where: { + id: 1, + // @ts-expect-error notExistsColumn is not a valid field + notExistsColumn: 1, + }, + data: { name: 'Alex' }, + }); + + await client.user.upsert({ + where: { + id: 1, + // @ts-expect-error notExistsColumn is not a valid field + notExistsColumn: 1, + }, + create: { name: 'Alex', email: 'alex@zenstack.dev' }, + update: { name: 'Alex New' }, + }); + + await client.user.updateMany({ + where: { + email: 'test@test.com', + // @ts-expect-error notExistsColumn is not a valid field + notExistsColumn: 1, + }, + data: { name: 'Alex' }, + }); } async function del() {