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-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/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 29f60281f..64f018211 100644 --- a/packages/orm/src/client/options.ts +++ b/packages/orm/src/client/options.ts @@ -1,12 +1,23 @@ -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'; -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'; import type { ToKyselySchema } from './query-builder'; +import type { WrapType } from '../utils/type-utils'; export type ZModelFunctionContext = { /** @@ -299,26 +310,73 @@ 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: infer Params) => 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 : [] - ) => 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>; }; }; +/** + * The trailing parameter list of a computed field implementation: `[args]` for a parameterized + * field, empty otherwise. + */ +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 1ab6befbc..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: { : }`. - // 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. - 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, - ts.factory.createTypeReferenceNode( - this.mapFunctionParamTypeToTSType(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,25 +572,6 @@ 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'); - if (type.array) { - result = `${result}[]`; - } - return result; - } - private createUpdatedAtObject(ignoreArg: AttributeArg) { return ts.factory.createObjectLiteralExpression([ ts.factory.createPropertyAssignment( @@ -688,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 01f74c662..89873f797 100644 --- a/tests/e2e/orm/client-api/computed-fields.test.ts +++ b/tests/e2e/orm/client-api/computed-fields.test.ts @@ -1067,4 +1067,212 @@ 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); + }, + }, + }, +}); + +// 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({ + 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..bd5d1e73b 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", @@ -83,13 +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"); - } } }, Post: { 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: {