From d238bf3974a2d26aa70337a8ca1eef7563039b54 Mon Sep 17 00:00:00 2001 From: evgenovalov Date: Wed, 2 Sep 2026 15:43:58 +0200 Subject: [PATCH 1/3] fix(orm): type parameterized computed field args from the field's params metadata `ComputedFieldArgs` read the args type off the generated `computedFields` stub, whose non-scalar params (enums, type defs, models, Json) were emitted as `unknown`. A wrong enum value or type-def shape therefore compiled in `select`/`where`/`orderBy`, the aggregate inputs and the generated `input.ts` types, and the implementation callback saw `unknown` too, while zod already validated the args precisely at runtime. - derive `ComputedFieldArgs` from the field's `params` metadata with the same mapping procedures use (enums -> value union, type defs -> object shape, optional -> optional key), and make `ComputedFieldsOptions` read the same source so implementation and query typing can't drift - generator: name the stub param `_args` (the old name tripped `noUnusedParameters` in consuming projects) and emit enum params as the enum value union read off the schema's own `enums` member - tests: runtime + compile-time e2e coverage for enum and type-def params, and a parameterized enum-param field in the typing schema Co-Authored-By: Claude Fable 5.1 --- packages/orm/src/client/crud-types.ts | 49 ++--- packages/orm/src/client/options.ts | 20 +- packages/sdk/src/ts-schema-generator.ts | 72 +++++-- .../orm/client-api/computed-fields.test.ts | 193 ++++++++++++++++++ tests/e2e/orm/schemas/typing/schema.ts | 16 ++ tests/e2e/orm/schemas/typing/schema.zmodel | 1 + tests/e2e/orm/schemas/typing/typecheck.ts | 38 ++++ 7 files changed, 339 insertions(+), 50 deletions(-) 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..382eb1e41 100644 --- a/tests/e2e/orm/client-api/computed-fields.test.ts +++ b/tests/e2e/orm/client-api/computed-fields.test.ts @@ -1067,4 +1067,197 @@ 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 }, + ], + }, + }, + }); + + await expect( + db.user.findFirst({ + select: { + postCountByStatus: { args: { status: 'INACTIVE' } }, + popularPostCount: { args: { filter: { minViews: 100 } } }, + }, + }), + ).resolves.toEqual({ postCountByStatus: 2, popularPostCount: 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: { From bd2c2e136808d97d599905d434d15836f526dba1 Mon Sep 17 00:00:00 2001 From: evgenovalov Date: Thu, 3 Sep 2026 11:25:05 +0200 Subject: [PATCH 2/3] test(orm): normalize bigint count in parameterized computed field test `count(*)` is a bigint on Postgres, which the `pg` driver returns as a string, so the plain-select assertion compared `'2'` with `2` on the postgresql CI matrix. Normalize via `Number()` before comparing. Co-Authored-By: Claude Fable 5.1 --- .../orm/client-api/computed-fields.test.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/tests/e2e/orm/client-api/computed-fields.test.ts b/tests/e2e/orm/client-api/computed-fields.test.ts index 382eb1e41..21e9f8a05 100644 --- a/tests/e2e/orm/client-api/computed-fields.test.ts +++ b/tests/e2e/orm/client-api/computed-fields.test.ts @@ -1131,14 +1131,17 @@ model Post { }, }); - await expect( - db.user.findFirst({ - select: { - postCountByStatus: { args: { status: 'INACTIVE' } }, - popularPostCount: { args: { filter: { minViews: 100 } } }, - }, - }), - ).resolves.toEqual({ postCountByStatus: 2, popularPostCount: 2 }); + // `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 } } }), From 80b2d4fff753e294c099bb3ccbae7e73f2842bbd Mon Sep 17 00:00:00 2001 From: evgenovalov Date: Mon, 7 Sep 2026 10:55:59 +0200 Subject: [PATCH 3/3] refactor: retire the generated `computedFields` stub The return type was the last thing read off the generated stub, and it is available on the field definition too (`type`/`optional`/`array`), so drop the stub from the generated schema entirely and derive everything about a computed field from its field def: - `ComputedFieldsOptions` keys off fields with `computed: true` (excluding delegate-inherited ones, which are configured on the base model) and maps the implementation's return type from the field's declared type with the same mapping the stub used (scalars to JS types, Decimal as number, everything else `unknown`, `| null` for optional, `[]` for lists) - the runtime config validation and the "computed fields need an explicit select" join check read the field defs instead of `modelDef.computedFields` - `ModelDef.computedFields` is removed from the schema type and the generator no longer emits the stub (nor the enum-typed `_args` for it) - regenerate the checked-in schemas that carried the stub - e2e: assert the implementation's return type is still enforced Co-Authored-By: Claude Fable 5.1 --- packages/orm/src/client/client-impl.ts | 42 ++--- .../src/client/crud/dialects/base-dialect.ts | 4 +- packages/orm/src/client/options.ts | 94 ++++++++--- packages/orm/test/schema/schema.ts | 7 - packages/schema/src/schema.ts | 1 - packages/sdk/src/ts-schema-generator.ts | 155 +----------------- packages/zod/test/schema/schema-lite.ts | 7 - packages/zod/test/schema/schema.ts | 7 - samples/orm/zenstack/schema.ts | 7 - samples/taskforge/zenstack/schema.ts | 14 -- .../orm/client-api/computed-fields.test.ts | 12 ++ tests/e2e/orm/schemas/typing/schema.ts | 14 -- 12 files changed, 109 insertions(+), 255 deletions(-) diff --git a/packages/orm/src/client/client-impl.ts b/packages/orm/src/client/client-impl.ts index 74b6305a5..844412af7 100644 --- a/packages/orm/src/client/client-impl.ts +++ b/packages/orm/src/client/client-impl.ts @@ -185,26 +185,28 @@ export class ClientImpl { 'computedFields' in options ? (options.computedFields as Record | undefined) : undefined; for (const [modelName, modelDef] of Object.entries(this.$schema.models)) { - if (modelDef.computedFields) { - for (const fieldName of Object.keys(modelDef.computedFields)) { - // check both uncapitalized (current) and original (backward compat) model name - const modelConfig = - computedFieldsConfig?.[lowerCaseFirst(modelName)] ?? computedFieldsConfig?.[modelName]; - const fieldConfig = modelConfig?.[fieldName]; - // Check if the computed field has a configuration - if (fieldConfig === null || fieldConfig === undefined) { - throw createConfigError( - `Computed field "${fieldName}" in model "${modelName}" does not have a configuration. ` + - `Please provide an implementation in the computedFields option.`, - ); - } - // Check that the configuration is a function - if (typeof fieldConfig !== 'function') { - throw createConfigError( - `Computed field "${fieldName}" in model "${modelName}" has an invalid configuration: ` + - `expected a function but received ${typeof fieldConfig}.`, - ); - } + for (const [fieldName, fieldDef] of Object.entries(modelDef.fields)) { + // a computed field inherited from a delegate base is configured on the base model + if (!fieldDef.computed || fieldDef.originModel) { + continue; + } + // check both uncapitalized (current) and original (backward compat) model name + const modelConfig = + computedFieldsConfig?.[lowerCaseFirst(modelName)] ?? computedFieldsConfig?.[modelName]; + const fieldConfig = modelConfig?.[fieldName]; + // Check if the computed field has a configuration + if (fieldConfig === null || fieldConfig === undefined) { + throw createConfigError( + `Computed field "${fieldName}" in model "${modelName}" does not have a configuration. ` + + `Please provide an implementation in the computedFields option.`, + ); + } + // Check that the configuration is a function + if (typeof fieldConfig !== 'function') { + throw createConfigError( + `Computed field "${fieldName}" in model "${modelName}" has an invalid configuration: ` + + `expected a function but received ${typeof fieldConfig}.`, + ); } } } diff --git a/packages/orm/src/client/crud/dialects/base-dialect.ts b/packages/orm/src/client/crud/dialects/base-dialect.ts index 5d71fe78f..4210f99cf 100644 --- a/packages/orm/src/client/crud/dialects/base-dialect.ts +++ b/packages/orm/src/client/crud/dialects/base-dialect.ts @@ -1700,8 +1700,8 @@ export abstract class BaseCrudDialect { modelDef: ModelDef, payload: boolean | FindArgs, any, true>, ) { - if (modelDef.computedFields) { - // computed fields requires explicit select + if (Object.values(modelDef.fields).some((f) => f.computed)) { + // computed fields require explicit select return false; } diff --git a/packages/orm/src/client/options.ts b/packages/orm/src/client/options.ts index b7f330cd3..64f018211 100644 --- a/packages/orm/src/client/options.ts +++ b/packages/orm/src/client/options.ts @@ -1,4 +1,14 @@ -import type { GetModel, GetModelFields, GetModels, ProcedureDef, ScalarFields, SchemaDef } from '@zenstackhq/schema'; +import type { + FieldIsArray, + GetModelField, + GetModelFields, + GetModelFieldType, + GetModels, + ModelFieldIsOptional, + ProcedureDef, + ScalarFields, + SchemaDef, +} from '@zenstackhq/schema'; import type { Dialect, Expression, ExpressionBuilder, KyselyConfig, OperandExpression } from 'kysely'; import type { FilterPropertyToKind } from './constants'; import type { ClientContract, CRUD_EXT } from './contract'; @@ -7,6 +17,7 @@ import type { BaseCrudDialect } from './crud/dialects/base-dialect'; import type { AllCrudOperations } from './crud/operations/base'; import type { AnyPlugin } from './plugin'; import type { ToKyselySchema } from './query-builder'; +import type { WrapType } from '../utils/type-utils'; export type ZModelFunctionContext = { /** @@ -299,24 +310,34 @@ export type ComputedFieldContext = { client: ClientContract; }; +/** + * The computed fields a model declares itself, keyed by name. A computed field inherited from a + * delegate base is excluded: it's configured once, on the base model. + */ +type OwnComputedFields> = keyof { + [Field in GetModelFields as GetModelField extends { computed: true } + ? GetModelField extends { originModel: string } + ? never + : Field + : never]: Field; +}; + +/** + * Implementations of the schema's computed fields, keyed by (uncapitalized) model name and then + * by field name. Everything is derived from the field definitions: which fields need an + * implementation, the query-time `args` of a parameterized field (from its `params` metadata, + * the same source the query input types use), and the value type the expression must produce. + */ export type ComputedFieldsOptions = { - [Model in GetModels as 'computedFields' extends keyof GetModel - ? Uncapitalize - : never]: { - [Field in keyof Schema['models'][Model]['computedFields']]: Schema['models'][Model]['computedFields'][Field] extends infer Func - ? 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, 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; + [Model in GetModels as [OwnComputedFields] extends [never] ? never : Uncapitalize]: { + [Field in OwnComputedFields]: ( + // inject a first parameter for expression builder + p: ExpressionBuilder, Model>, + // runtime-provided context + context: ComputedFieldContext, + // query-time args of a parameterized field + ...args: ComputedFieldImplArgs + ) => OperandExpression>; }; }; @@ -324,12 +345,37 @@ export type ComputedFieldsOptions = { * 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] - : [] - : []; +type ComputedFieldImplArgs< + Schema extends SchemaDef, + Model extends GetModels, + Field extends GetModelFields, +> = FieldHasComputedArgs extends true ? [args: ComputedFieldArgs] : []; + +/** + * The value type a computed field's expression must produce, from the field's declared type. + * Scalars map to their JS types (`Decimal` is accepted as `number`); `DateTime`, `Json`, + * `Bytes`, enums and type defs are `unknown`, since their database-level representation differs + * from the ORM result type. An optional field also accepts `null`, a list field an array. + */ +type ComputedFieldResultType< + Schema extends SchemaDef, + Model extends GetModels, + Field extends GetModelFields, +> = WrapType< + ComputedFieldBaseType>, + ModelFieldIsOptional, + FieldIsArray +>; + +type ComputedFieldBaseType = T extends 'String' + ? string + : T extends 'Boolean' + ? boolean + : T extends 'Int' | 'Float' | 'Decimal' + ? number + : T extends 'BigInt' + ? bigint + : unknown; export type HasComputedFields = string extends GetModels ? false : keyof ComputedFieldsOptions extends never ? false : true; diff --git a/packages/orm/test/schema/schema.ts b/packages/orm/test/schema/schema.ts index e0dff2a49..c5d6e1f65 100644 --- a/packages/orm/test/schema/schema.ts +++ b/packages/orm/test/schema/schema.ts @@ -185,13 +185,6 @@ export class SchemaType implements SchemaDef { idFields: ["id"], uniqueFields: { id: { type: "String" } - }, - computedFields: { - finalPrice(_context: { - modelAlias: string; - }): number { - throw new Error("This is a stub for computed field"); - } } }, Asset: { diff --git a/packages/schema/src/schema.ts b/packages/schema/src/schema.ts index 3953ec724..b6689dace 100644 --- a/packages/schema/src/schema.ts +++ b/packages/schema/src/schema.ts @@ -31,7 +31,6 @@ export type ModelDef = { attributes?: readonly AttributeApplication[]; uniqueFields: Record; idFields: readonly string[]; - computedFields?: Record; isDelegate?: boolean; subModels?: readonly string[]; isView?: boolean; diff --git a/packages/sdk/src/ts-schema-generator.ts b/packages/sdk/src/ts-schema-generator.ts index 8c88681f7..b3c70eaca 100644 --- a/packages/sdk/src/ts-schema-generator.ts +++ b/packages/sdk/src/ts-schema-generator.ts @@ -11,7 +11,6 @@ import { DataModelAttribute, Enum, Expression, - FunctionParamType, InvocationExpr, isArrayExpr, isBinaryExpr, @@ -469,14 +468,6 @@ export class TsSchemaGenerator { ...(dm.isView ? [ts.factory.createPropertyAssignment('isView', ts.factory.createTrue())] : []), ]; - const computedFields = allFields.filter((f) => hasAttribute(f, '@computed') && !getDelegateOriginModel(f, dm)); - - if (computedFields.length > 0) { - fields.push( - ts.factory.createPropertyAssignment('computedFields', this.createComputedFieldsObject(computedFields)), - ); - } - return ts.factory.createObjectLiteralExpression(fields, true); } @@ -553,85 +544,10 @@ export class TsSchemaGenerator { return ts.factory.createObjectLiteralExpression(fields, true); } - private createComputedFieldsObject(fields: DataField[]) { - return ts.factory.createObjectLiteralExpression( - fields.map((field) => { - const params: ts.ParameterDeclaration[] = [ - // parameter: `_context: { modelAlias: string }` - ts.factory.createParameterDeclaration( - undefined, - undefined, - '_context', - undefined, - ts.factory.createTypeLiteralNode([ - ts.factory.createPropertySignature( - undefined, - 'modelAlias', - undefined, - ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), - ), - ]), - undefined, - ), - ]; - - // 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', - undefined, - ts.factory.createTypeLiteralNode( - field.params.map((param) => - ts.factory.createPropertySignature( - undefined, - param.name, - param.optional - ? ts.factory.createToken(ts.SyntaxKind.QuestionToken) - : undefined, - this.createFunctionParamTypeNode(param.type), - ), - ), - ), - undefined, - ), - ); - } - - return ts.factory.createMethodDeclaration( - undefined, - undefined, - field.name, - undefined, - undefined, - params, - ts.factory.createTypeReferenceNode(this.mapFieldTypeToTSType(field.type)), - ts.factory.createBlock( - [ - ts.factory.createThrowStatement( - ts.factory.createNewExpression(ts.factory.createIdentifier('Error'), undefined, [ - ts.factory.createStringLiteral('This is a stub for computed field'), - ]), - ), - ], - true, - ), - ); - }), - true, - ); - } - // Emits the `params` metadata for a parameterized computed field. Shape mirrors - // `ProcedureParam` (`Record`) and is - // read at runtime (to forward args) and by the zod input-validation factory. + // `ProcedureParam` (`Record`). It is read at + // runtime (to forward args), by the zod input-validation factory, and by the ORM types that + // derive the query-time `args` and the implementation signature from it. private createFieldParamsObject(params: DataFieldParam[]) { return ts.factory.createObjectLiteralExpression( params.map((param) => @@ -656,53 +572,6 @@ export class TsSchemaGenerator { ); } - // 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 = 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( @@ -716,24 +585,6 @@ export class TsSchemaGenerator { ]); } - private mapFieldTypeToTSType(type: DataFieldType) { - let result = match(type.type) - .with('String', () => 'string') - .with('Boolean', () => 'boolean') - .with('Int', () => 'number') - .with('Float', () => 'number') - .with('BigInt', () => 'bigint') - .with('Decimal', () => 'number') - .otherwise(() => 'unknown'); - if (type.array) { - result = `${result}[]`; - } - if (type.optional) { - result = `${result} | null`; - } - return result; - } - private createDataFieldObject(field: DataField, contextModel: DataModel | undefined, lite: boolean) { const objectFields = [ // name diff --git a/packages/zod/test/schema/schema-lite.ts b/packages/zod/test/schema/schema-lite.ts index c1f44d019..753890d04 100644 --- a/packages/zod/test/schema/schema-lite.ts +++ b/packages/zod/test/schema/schema-lite.ts @@ -195,13 +195,6 @@ export class SchemaType implements SchemaDef { idFields: ["id"], uniqueFields: { id: { type: "String" } - }, - computedFields: { - finalPrice(_context: { - modelAlias: string; - }): number { - throw new Error("This is a stub for computed field"); - } } }, Asset: { diff --git a/packages/zod/test/schema/schema.ts b/packages/zod/test/schema/schema.ts index fa7bc045c..dd4281776 100644 --- a/packages/zod/test/schema/schema.ts +++ b/packages/zod/test/schema/schema.ts @@ -202,13 +202,6 @@ export class SchemaType implements SchemaDef { idFields: ["id"], uniqueFields: { id: { type: "String" } - }, - computedFields: { - finalPrice(_context: { - modelAlias: string; - }): number { - throw new Error("This is a stub for computed field"); - } } }, Asset: { diff --git a/samples/orm/zenstack/schema.ts b/samples/orm/zenstack/schema.ts index 6b7df5b25..d20cd0dd6 100644 --- a/samples/orm/zenstack/schema.ts +++ b/samples/orm/zenstack/schema.ts @@ -77,13 +77,6 @@ export class SchemaType implements SchemaDef { uniqueFields: { id: { type: "String" }, email: { type: "String" } - }, - computedFields: { - postCount(_context: { - modelAlias: string; - }): number { - throw new Error("This is a stub for computed field"); - } } }, Profile: { diff --git a/samples/taskforge/zenstack/schema.ts b/samples/taskforge/zenstack/schema.ts index 0effd9be0..d1491c9cb 100644 --- a/samples/taskforge/zenstack/schema.ts +++ b/samples/taskforge/zenstack/schema.ts @@ -970,13 +970,6 @@ export class SchemaType implements SchemaDef { uniqueFields: { id: { type: "String" }, organizationId_slug: { organizationId: { type: "String" }, slug: { type: "String" } } - }, - computedFields: { - openIssueCount(_context: { - modelAlias: string; - }): number { - throw new Error("This is a stub for computed field"); - } } }, ProjectMember: { @@ -1261,13 +1254,6 @@ export class SchemaType implements SchemaDef { uniqueFields: { id: { type: "String" }, projectId_number: { projectId: { type: "String" }, number: { type: "Int" } } - }, - computedFields: { - commentCount(_context: { - modelAlias: string; - }): number { - throw new Error("This is a stub for computed field"); - } } }, Label: { diff --git a/tests/e2e/orm/client-api/computed-fields.test.ts b/tests/e2e/orm/client-api/computed-fields.test.ts index 21e9f8a05..89873f797 100644 --- a/tests/e2e/orm/client-api/computed-fields.test.ts +++ b/tests/e2e/orm/client-api/computed-fields.test.ts @@ -1224,6 +1224,18 @@ const client = new ZenStackClient(schema, { }, }); +// the expression must produce the field's declared type +new ZenStackClient(schema, { + dialect: {} as any, + computedFields: { + user: { + // @ts-expect-error an Int field needs a number expression + postCountByStatus: (eb) => eb.val('not a number'), + popularPostCount: (eb) => eb.lit(0), + }, + }, +}); + async function main() { // valid args compile everywhere the field can be used await client.user.findMany({ diff --git a/tests/e2e/orm/schemas/typing/schema.ts b/tests/e2e/orm/schemas/typing/schema.ts index c504fe6b6..bd5d1e73b 100644 --- a/tests/e2e/orm/schemas/typing/schema.ts +++ b/tests/e2e/orm/schemas/typing/schema.ts @@ -92,20 +92,6 @@ export class SchemaType implements SchemaDef { uniqueFields: { id: { type: "Int" }, email: { type: "String" } - }, - computedFields: { - postCount(_context: { - 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"); - } } }, Post: {