diff --git a/packages/orm/src/client/crud-types.ts b/packages/orm/src/client/crud-types.ts index 9541c90b7..b92854807 100644 --- a/packages/orm/src/client/crud-types.ts +++ b/packages/orm/src/client/crud-types.ts @@ -1157,24 +1157,22 @@ export type FtsRelevanceOrderBy R` — the same source - * `ComputedFieldsOptions` reads, so the implementation signature and the query-time args - * can never drift apart. Resolves to `never` for non-parameterized fields. + * The query-time arguments object of a parameterized computed field, derived from the field's + * `params` metadata in the schema — the same source the runtime forwards to the implementation + * and the zod factory validates against, so typing and validation can never drift apart. Param + * types resolve the way procedure params do: scalars to their TS types, enums to their value + * union, type defs to their object shape. `ComputedFieldsOptions` reads this too, so the + * implementation signature always matches the query input. Resolves to `never` for + * non-parameterized fields. */ export type ComputedFieldArgs< Schema extends SchemaDef, Model extends GetModels, Field extends GetModelFields, -> = 'computedFields' extends keyof GetModel - ? Field extends keyof GetModel['computedFields'] - ? GetModel['computedFields'][Field] extends (...args: infer P) => any - ? P extends [any, infer Args] - ? Args - : never - : never - : never - : never; +> = + GetModelField extends { computed: true; params: infer Params } + ? MapParamsObject + : never; /** * Whether `Field` is a parameterized computed field (its args object is not `never`). @@ -2897,22 +2895,25 @@ export type GetProcedure = keyof { +// The `params` metadata record (`{ name, type, array?, optional? }` per key) is shared by +// procedures and parameterized computed fields; these helpers map it to the TS args object. + +type _OptionalParamNames = keyof { [K in keyof Params as Params[K] extends { optional: true } ? K : never]: K; }; -type _RequiredProcedureParamNames = keyof { +type _RequiredParamNames = keyof { [K in keyof Params as Params[K] extends { optional: true } ? never : K]: K; }; -type _HasRequiredProcedureParams = _RequiredProcedureParamNames extends never ? false : true; +type _HasRequiredParams = _RequiredParamNames extends never ? false : true; -type MapProcedureArgsObject = Simplify< +type MapParamsObject = Simplify< Optional< { - [K in keyof Params]: MapProcedureParam; + [K in keyof Params]: MapParam; }, - _OptionalProcedureParamNames + _OptionalParamNames > >; @@ -2923,11 +2924,11 @@ export type ProcedureEnvelope< > = keyof Params extends never ? // no params { args?: Record } - : _HasRequiredProcedureParams extends true + : _HasRequiredParams extends true ? // has required params - { args: MapProcedureArgsObject } + { args: MapParamsObject } : // no required params - { args?: MapProcedureArgsObject }; + { args?: MapParamsObject }; type ProcedureHandlerCtx> = { client: ClientContract; @@ -2937,7 +2938,7 @@ type ProcedureHandlerCtx> = ( - ...args: _HasRequiredProcedureParams> extends true + ...args: _HasRequiredParams> extends true ? [input: ProcedureEnvelope] : [input?: ProcedureEnvelope] ) => MaybePromise>>; @@ -2955,7 +2956,7 @@ type MapProcedureReturn = Proc extends { returnT : MapType : never; -type MapProcedureParam = P extends { type: infer U } +type MapParam = P extends { type: infer U } ? OrUndefinedIf< P extends { array: true } ? Array> : MapType, P extends { optional: true } ? true : false diff --git a/packages/orm/src/client/options.ts b/packages/orm/src/client/options.ts index 29f60281f..b7f330cd3 100644 --- a/packages/orm/src/client/options.ts +++ b/packages/orm/src/client/options.ts @@ -2,7 +2,7 @@ import type { GetModel, GetModelFields, GetModels, ProcedureDef, ScalarFields, S import type { Dialect, Expression, ExpressionBuilder, KyselyConfig, OperandExpression } from 'kysely'; import type { FilterPropertyToKind } from './constants'; import type { ClientContract, CRUD_EXT } from './contract'; -import type { GetProcedureNames, ProcedureHandlerFunc } from './crud-types'; +import type { ComputedFieldArgs, FieldHasComputedArgs, GetProcedureNames, ProcedureHandlerFunc } from './crud-types'; import type { BaseCrudDialect } from './crud/dialects/base-dialect'; import type { AllCrudOperations } from './crud/operations/base'; import type { AnyPlugin } from './plugin'; @@ -304,21 +304,33 @@ export type ComputedFieldsOptions = { ? Uncapitalize : never]: { [Field in keyof Schema['models'][Model]['computedFields']]: Schema['models'][Model]['computedFields'][Field] extends infer Func - ? Func extends (...args: infer Params) => infer R + ? Func extends (...args: any[]) => infer R ? ( // inject a first parameter for expression builder p: ExpressionBuilder, Model>, // runtime-provided context (the generated stub only declares // `modelAlias`; the runtime passes the full context) context: ComputedFieldContext, - // query-time args of a parameterized field, from the stub - ...args: Params extends [any, ...infer Rest] ? Rest : [] + // query-time args of a parameterized field, typed from the field's + // `params` metadata — the same source as the query input types + ...args: ComputedFieldImplArgs ) => OperandExpression // wrap the return type with Kysely `OperandExpression` : never : never; }; }; +/** + * The trailing parameter list of a computed field implementation: `[args]` for a parameterized + * field, empty otherwise. + */ +type ComputedFieldImplArgs, Field> = + Field extends GetModelFields + ? FieldHasComputedArgs extends true + ? [args: ComputedFieldArgs] + : [] + : []; + export type HasComputedFields = string extends GetModels ? false : keyof ComputedFieldsOptions extends never ? false : true; diff --git a/packages/sdk/src/ts-schema-generator.ts b/packages/sdk/src/ts-schema-generator.ts index 1ab6befbc..8c88681f7 100644 --- a/packages/sdk/src/ts-schema-generator.ts +++ b/packages/sdk/src/ts-schema-generator.ts @@ -575,16 +575,18 @@ export class TsSchemaGenerator { ), ]; - // For a parameterized computed field, add `args: { : }`. - // The field's params flow into this stub's signature so that - // `Parameters` carries the args type for both the - // implementation (ComputedFieldsOptions) and the query input types. + // For a parameterized computed field, add `_args: { : }` so the + // stub documents the query-time args. The authoritative typing is the field's + // `params` metadata (see `createFieldParamsObject`), which the ORM maps to the + // args type for both the implementation (`ComputedFieldsOptions`) and the query + // input types. The underscore prefix keeps `noUnusedParameters` quiet in + // consuming projects. if (field.params.length > 0) { params.push( ts.factory.createParameterDeclaration( undefined, undefined, - 'args', + '_args', undefined, ts.factory.createTypeLiteralNode( field.params.map((param) => @@ -594,9 +596,7 @@ export class TsSchemaGenerator { param.optional ? ts.factory.createToken(ts.SyntaxKind.QuestionToken) : undefined, - ts.factory.createTypeReferenceNode( - this.mapFunctionParamTypeToTSType(param.type), - ), + this.createFunctionParamTypeNode(param.type), ), ), ), @@ -656,25 +656,53 @@ export class TsSchemaGenerator { ); } - private mapFunctionParamTypeToTSType(type: FunctionParamType): string { - let result = match(type.type) - .with('String', () => 'string') - .with('Boolean', () => 'boolean') - .with('Int', () => 'number') - .with('Float', () => 'number') - .with('BigInt', () => 'bigint') - .with('Decimal', () => 'number') - .with('DateTime', () => 'Date') - // non-scalar references (enums/type defs/models) aren't in scope in the generated - // schema file, so fall back to `unknown` — same convention as computed-field return - // types (`mapFieldTypeToTSType`). Runtime zod still validates these precisely. - .otherwise(() => 'unknown'); + // Builds the TS type node of a param in the computed-field stub signature. Scalars map to + // their TS types; an enum maps to its value union, read off the schema's own `enums` member + // so it can't drift from the emitted enum. Type defs and models have no TS type in scope in + // the generated schema file, so they fall back to `unknown` — same convention as + // computed-field return types (`mapFieldTypeToTSType`). The ORM's `ComputedFieldArgs` + // resolves all of them precisely from the `params` metadata, and runtime zod validates them. + private createFunctionParamTypeNode(type: FunctionParamType): ts.TypeNode { + let result: ts.TypeNode; + if (type.reference?.ref && isEnum(type.reference.ref)) { + result = this.createEnumValuesTypeNode(type.reference.ref.name); + } else { + const tsType = match(type.type) + .with('String', () => 'string') + .with('Boolean', () => 'boolean') + .with('Int', () => 'number') + .with('Float', () => 'number') + .with('BigInt', () => 'bigint') + .with('Decimal', () => 'number') + .with('DateTime', () => 'Date') + .otherwise(() => 'unknown'); + result = ts.factory.createTypeReferenceNode(tsType); + } if (type.array) { - result = `${result}[]`; + result = ts.factory.createArrayTypeNode(result); } return result; } + // `SchemaType["enums"][""]["values"][keyof SchemaType["enums"][""]["values"]]` + private createEnumValuesTypeNode(enumName: string): ts.TypeNode { + const literal = (text: string) => ts.factory.createLiteralTypeNode(ts.factory.createStringLiteral(text)); + const values = ts.factory.createIndexedAccessTypeNode( + ts.factory.createIndexedAccessTypeNode( + ts.factory.createIndexedAccessTypeNode( + ts.factory.createTypeReferenceNode('SchemaType'), + literal('enums'), + ), + literal(enumName), + ), + literal('values'), + ); + return ts.factory.createIndexedAccessTypeNode( + values, + ts.factory.createTypeOperatorNode(ts.SyntaxKind.KeyOfKeyword, values), + ); + } + private createUpdatedAtObject(ignoreArg: AttributeArg) { return ts.factory.createObjectLiteralExpression([ ts.factory.createPropertyAssignment( diff --git a/tests/e2e/orm/client-api/computed-fields.test.ts b/tests/e2e/orm/client-api/computed-fields.test.ts index 01f74c662..21e9f8a05 100644 --- a/tests/e2e/orm/client-api/computed-fields.test.ts +++ b/tests/e2e/orm/client-api/computed-fields.test.ts @@ -1067,4 +1067,200 @@ model Post { isSpecial: true, }); }); + it('works with enum and type-def parameters on parameterized computed fields', async () => { + const db = await createTestClient( + ` +enum Status { + ACTIVE + INACTIVE +} + +type ViewFilter { + minViews Int +} + +model User { + id Int @id @default(autoincrement()) + name String + posts Post[] + postCountByStatus(status: Status) Int @computed + popularPostCount(filter: ViewFilter) Int @computed +} + +model Post { + id Int @id @default(autoincrement()) + status Status @default(ACTIVE) + viewCount Int @default(0) + author User @relation(fields: [authorId], references: [id]) + authorId Int +} +`, + { + computedFields: { + User: { + // counts the user's posts in the query-time `status` + postCountByStatus: (eb: any, ctx: any, args: any) => + eb + .selectFrom('Post') + .whereRef('Post.authorId', '=', sql.ref(`${ctx.modelAlias}.id`)) + .where('Post.status', '=', args.status) + .select(({ fn }: any) => fn.countAll().as('cnt')), + // counts the user's posts whose viewCount >= the query-time `filter.minViews` + popularPostCount: (eb: any, ctx: any, args: any) => + eb + .selectFrom('Post') + .whereRef('Post.authorId', '=', sql.ref(`${ctx.modelAlias}.id`)) + .where('Post.viewCount', '>=', args.filter.minViews) + .select(({ fn }: any) => fn.countAll().as('cnt')), + }, + }, + } as any, + ); + + await db.user.create({ + data: { + id: 1, + name: 'Alice', + posts: { + create: [ + { status: 'ACTIVE', viewCount: 300 }, + { status: 'INACTIVE', viewCount: 50 }, + { status: 'INACTIVE', viewCount: 120 }, + ], + }, + }, + }); + + // `count(*)` is a bigint on Postgres, which the `pg` driver returns as a string, so + // normalize before comparing + const counts = await db.user.findFirst({ + select: { + postCountByStatus: { args: { status: 'INACTIVE' } }, + popularPostCount: { args: { filter: { minViews: 100 } } }, + }, + }); + expect(Object.keys(counts!).sort()).toEqual(['popularPostCount', 'postCountByStatus']); + expect(Number(counts!.postCountByStatus)).toBe(2); + expect(Number(counts!.popularPostCount)).toBe(2); + + await expect( + db.user.findFirst({ where: { postCountByStatus: { args: { status: 'ACTIVE' }, equals: 1 } } }), + ).resolves.toMatchObject({ id: 1 }); + await expect( + db.user.findFirst({ where: { postCountByStatus: { args: { status: 'ACTIVE' }, gt: 1 } } }), + ).toResolveNull(); + + // `args` are validated against the declared param types: an unknown enum value and a + // type-def payload of the wrong shape are rejected as invalid input (`as any` bypasses + // the matching compile-time checks) + await expect( + db.user.findFirst({ select: { postCountByStatus: { args: { status: 'WRONG' } } } } as any), + ).toBeRejectedByValidation(); + await expect( + db.user.findFirst({ where: { postCountByStatus: { args: { status: 'WRONG' }, gt: 0 } } } as any), + ).toBeRejectedByValidation(); + await expect( + db.user.findFirst({ select: { popularPostCount: { args: { filter: { minViews: 'x' } } } } } as any), + ).toBeRejectedByValidation(); + await expect( + db.user.findFirst({ select: { popularPostCount: { args: { filter: {} } } } } as any), + ).toBeRejectedByValidation(); + }); + + it('is typed correctly for parameterized computed fields with enum and type-def params', async () => { + await createTestClient( + ` +enum Status { + ACTIVE + INACTIVE +} + +type ViewFilter { + minViews Int +} + +model User { + id Int @id @default(autoincrement()) + name String + postCountByStatus(status: Status) Int @computed + popularPostCount(filter: ViewFilter, factor: Int?) Int @computed +} +`, + { + computedFields: { + user: { + postCountByStatus: (eb: any) => eb.lit(0), + popularPostCount: (eb: any) => eb.lit(0), + }, + }, + extraSourceFiles: { + main: ` +import { ZenStackClient } from '@zenstackhq/orm'; +import { schema } from './schema'; +import type { UserSelect, UserWhereInput } from './input'; + +const client = new ZenStackClient(schema, { + dialect: {} as any, + computedFields: { + user: { + postCountByStatus: (eb, _ctx, args) => { + // an enum param is typed as the enum's value union + const status: 'ACTIVE' | 'INACTIVE' = args.status; + // @ts-expect-error not a Status value + const wrong: 'WRONG' = args.status; + void status; + void wrong; + return eb.lit(0); + }, + popularPostCount: (eb, _ctx, args) => { + // a type-def param is typed as its object shape; an optional param is optional + const minViews: number = args.filter.minViews; + const factor: number | undefined = args.factor; + void minViews; + void factor; + return eb.lit(0); + }, + }, + }, +}); + +async function main() { + // valid args compile everywhere the field can be used + await client.user.findMany({ + select: { + postCountByStatus: { args: { status: 'ACTIVE' } }, + popularPostCount: { args: { filter: { minViews: 1 } } }, + }, + where: { postCountByStatus: { args: { status: 'INACTIVE' }, gt: 0 } }, + orderBy: { popularPostCount: { args: { filter: { minViews: 1 }, factor: 2 }, sort: 'desc' } }, + }); + + // @ts-expect-error not a Status value + await client.user.findMany({ select: { postCountByStatus: { args: { status: 'WRONG' } } } }); + // @ts-expect-error not a Status value + await client.user.findMany({ where: { postCountByStatus: { args: { status: 'WRONG' }, gt: 0 } } }); + // @ts-expect-error not a Status value + await client.user.findMany({ orderBy: { postCountByStatus: { args: { status: 'WRONG' }, sort: 'asc' } } }); + // @ts-expect-error wrong type-def field type + await client.user.findMany({ select: { popularPostCount: { args: { filter: { minViews: 'x' } } } } }); + // @ts-expect-error missing required type-def field + await client.user.findMany({ select: { popularPostCount: { args: { filter: {} } } } }); + // @ts-expect-error missing required arg + await client.user.findMany({ select: { popularPostCount: { args: { factor: 1 } } } }); + + // the generated input types carry the same typing + // @ts-expect-error not a Status value + const select: UserSelect = { postCountByStatus: { args: { status: 'WRONG' } } }; + // @ts-expect-error not a Status value + const where: UserWhereInput = { postCountByStatus: { args: { status: 'WRONG' }, gt: 0 } }; + void select; + void where; +} + +void main; +`, + }, + }, + ); + }); }); diff --git a/tests/e2e/orm/schemas/typing/schema.ts b/tests/e2e/orm/schemas/typing/schema.ts index 4c01c01b1..c504fe6b6 100644 --- a/tests/e2e/orm/schemas/typing/schema.ts +++ b/tests/e2e/orm/schemas/typing/schema.ts @@ -72,6 +72,15 @@ export class SchemaType implements SchemaDef { attributes: [{ name: "@computed" }] as readonly AttributeApplication[], computed: true }, + hasStatus: { + name: "hasStatus", + type: "Boolean", + attributes: [{ name: "@computed" }] as readonly AttributeApplication[], + computed: true, + params: { + status: { name: "status", type: "Status" } + } + }, identity: { name: "identity", type: "Identity", @@ -89,6 +98,13 @@ export class SchemaType implements SchemaDef { modelAlias: string; }): number { throw new Error("This is a stub for computed field"); + }, + hasStatus(_context: { + modelAlias: string; + }, _args: { + status: SchemaType["enums"]["Status"]["values"][keyof SchemaType["enums"]["Status"]["values"]]; + }): boolean { + throw new Error("This is a stub for computed field"); } } }, diff --git a/tests/e2e/orm/schemas/typing/schema.zmodel b/tests/e2e/orm/schemas/typing/schema.zmodel index 32209ceb6..2dd204695 100644 --- a/tests/e2e/orm/schemas/typing/schema.zmodel +++ b/tests/e2e/orm/schemas/typing/schema.zmodel @@ -34,6 +34,7 @@ model User { posts Post[] profile Profile? postCount Int @computed + hasStatus(status: Status) Boolean @computed identity Identity? @json } diff --git a/tests/e2e/orm/schemas/typing/typecheck.ts b/tests/e2e/orm/schemas/typing/typecheck.ts index f53221c8f..80f00ccdf 100644 --- a/tests/e2e/orm/schemas/typing/typecheck.ts +++ b/tests/e2e/orm/schemas/typing/typecheck.ts @@ -2,6 +2,7 @@ import { ZenStackClient, type Subset } from '@zenstackhq/orm'; import SQLite from 'better-sqlite3'; import { SqliteDialect } from 'kysely'; import { Role, Status, type Identity, type IdentityProvider } from './models'; +import type { UserSelect, UserWhereInput } from './input'; import { schema } from './schema'; const client = new ZenStackClient(schema, { @@ -13,6 +14,8 @@ const client = new ZenStackClient(schema, { .selectFrom('Post') .whereRef('Post.authorId', '=', 'id') .select(({ fn }) => fn.countAll().as('postCount')), + // typing-only stub: the query-time `status` arg is typed as the `Status` enum + hasStatus: (eb, _ctx, args) => eb.lit(args.status === Status.ACTIVE), }, }, }); @@ -26,6 +29,8 @@ const strictClient = new ZenStackClient(schema, { .selectFrom('Post') .whereRef('Post.authorId', '=', 'id') .select(({ fn }) => fn.countAll().as('postCount')), + // typing-only stub: the query-time `status` arg is typed as the `Status` enum + hasStatus: (eb, _ctx, args) => eb.lit(args.status === Status.ACTIVE), }, }, typing: { exactQueryArgs: true }, @@ -45,6 +50,39 @@ async function main() { } async function find() { + // a parameterized computed field's `args` are typed from its declared params: an enum param + // accepts only the enum's values, and `args` is required wherever the field is used + const withArgs = await client.user.findFirst({ + select: { id: true, hasStatus: { args: { status: Status.ACTIVE } } }, + where: { hasStatus: { args: { status: 'INACTIVE' }, equals: true } }, + orderBy: { hasStatus: { args: { status: Status.BANNED }, sort: 'desc' } }, + }); + const hasStatus: boolean | undefined = withArgs?.hasStatus; + void hasStatus; + await client.user.findMany({ + // @ts-expect-error not a Status value + select: { hasStatus: { args: { status: 'WRONG' } } }, + }); + await client.user.findMany({ + // @ts-expect-error not a Status value + where: { hasStatus: { args: { status: 'WRONG' }, equals: true } }, + }); + await client.user.findMany({ + // @ts-expect-error not a Status value + orderBy: { hasStatus: { args: { status: 'WRONG' }, sort: 'asc' } }, + }); + await client.user.findMany({ + // @ts-expect-error args are required for a parameterized computed field + select: { hasStatus: true }, + }); + // the generated input types carry the same typing + // @ts-expect-error not a Status value + const selectWithWrongArgs: UserSelect = { hasStatus: { args: { status: 'WRONG' } } }; + // @ts-expect-error not a Status value + const whereWithWrongArgs: UserWhereInput = { hasStatus: { args: { status: 'WRONG' }, equals: true } }; + void selectWithWrongArgs; + void whereWithWrongArgs; + await client.user.findMany({ where: { posts: {