From 4f5a860a36fecad286ec34a8c0fa4477ccb0cb1c Mon Sep 17 00:00:00 2001 From: evgenovalov Date: Tue, 30 Jun 2026 11:01:46 +0200 Subject: [PATCH 01/13] feat: support parameterized computed fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `@computed` field can now declare typed parameters, with the arguments supplied at query time wherever the field is used. Because the arguments are plain data, they serialize over the wire, so a client can drive a DB-side computed sort through the auto-generated CRUD API — no custom endpoint, no raw SQL, one query, with access policies and result types intact. model ProductSite { id Int @id tags ProductTag[] tagNameInCategory(categoryId: Int): String? @computed } // implementation receives the args as a 3rd parameter computedFields: { ProductSite: { tagNameInCategory: (eb, ctx, args) => eb.selectFrom('tag') .innerJoin('product_tag', 'product_tag.tag_id', 'tag.id') .whereRef('product_tag.product_site_id', '=', sql.ref(`${ctx.modelAlias}.id`)) .where('tag.category_id', '=', args.categoryId) .select(sql`string_agg(tag.name, ', ' order by tag.name)`.as('v')), }, }, // `args` is plain data, so this whole object can come from a client db.productSite.findMany({ orderBy: { tagNameInCategory: { args: { categoryId: 5 }, sort: 'asc', nulls: 'last' } }, }); This wires the feature end-to-end for `orderBy`: - ZModel grammar: a field may declare a `(params): Type` signature; a validator rejects parameters on non-`@computed` fields. - Schema codegen: the declared params flow into the generated computed-field stub signature, so the implementation type (`ComputedFieldsOptions`) and the query input types derive the args type from a single source and can't drift. The params are also emitted as `FieldDef.params` metadata for the runtime and the zod input-validation factory. - Runtime: query-time args are forwarded to the implementation as a third argument through the single `fieldRef` chokepoint. - Types & zod: `orderBy` accepts `{ args, sort, nulls? }` for a parameterized computed field. Such fields require args, so they are excluded from default selection, explicit `select`, and `where` (usable via `orderBy` for now); `where`/`select` support are natural follow-ups using the same mechanism. Refs #2743 Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/language/src/generated/ast.ts | 44 ++- packages/language/src/generated/grammar.ts | 320 ++++++++++++------ .../src/validators/datamodel-validator.ts | 6 + packages/language/src/zmodel.langium | 7 +- packages/orm/src/client/crud-types.ts | 100 ++++-- .../src/client/crud/dialects/base-dialect.ts | 27 +- packages/orm/src/client/zod/factory.ts | 30 +- packages/schema/src/schema.ts | 6 + packages/sdk/src/ts-schema-generator.ts | 121 ++++++- .../orm/client-api/computed-fields.test.ts | 67 ++++ 10 files changed, 577 insertions(+), 151 deletions(-) diff --git a/packages/language/src/generated/ast.ts b/packages/language/src/generated/ast.ts index 8609927f4..545fdb944 100644 --- a/packages/language/src/generated/ast.ts +++ b/packages/language/src/generated/ast.ts @@ -355,6 +355,7 @@ export interface DataField extends langium.AstNode { attributes: Array; comments: Array; name: RegularIDWithTypeNames; + params: Array; type: DataFieldType; } @@ -363,6 +364,7 @@ export const DataField = { attributes: 'attributes', comments: 'comments', name: 'name', + params: 'params', type: 'type' } as const; @@ -387,6 +389,25 @@ export function isDataFieldAttribute(item: unknown): item is DataFieldAttribute return reflection.isInstance(item, DataFieldAttribute.$type); } +export interface DataFieldParam extends langium.AstNode { + readonly $container: DataField; + readonly $type: 'DataFieldParam'; + name: RegularID; + optional: boolean; + type: FunctionParamType; +} + +export const DataFieldParam = { + $type: 'DataFieldParam', + name: 'name', + optional: 'optional', + type: 'type' +} as const; + +export function isDataFieldParam(item: unknown): item is DataFieldParam { + return reflection.isInstance(item, DataFieldParam.$type); +} + export interface DataFieldType extends langium.AstNode { readonly $container: DataField; readonly $type: 'DataFieldType'; @@ -589,7 +610,7 @@ export function isFunctionParam(item: unknown): item is FunctionParam { } export interface FunctionParamType extends langium.AstNode { - readonly $container: FunctionDecl | FunctionParam | Procedure | ProcedureParam; + readonly $container: DataFieldParam | FunctionDecl | FunctionParam | Procedure | ProcedureParam; readonly $type: 'FunctionParamType'; array: boolean; reference?: langium.Reference; @@ -1016,6 +1037,7 @@ export type ZModelAstType = { ConfigInvocationExpr: ConfigInvocationExpr DataField: DataField DataFieldAttribute: DataFieldAttribute + DataFieldParam: DataFieldParam DataFieldType: DataFieldType DataModel: DataModel DataModelAttribute: DataModelAttribute @@ -1261,6 +1283,10 @@ export class ZModelAstReflection extends langium.AbstractAstReflection { name: { name: DataField.name }, + params: { + name: DataField.params, + defaultValue: [] + }, type: { name: DataField.type } @@ -1281,6 +1307,22 @@ export class ZModelAstReflection extends langium.AbstractAstReflection { }, superTypes: [] }, + DataFieldParam: { + name: DataFieldParam.$type, + properties: { + name: { + name: DataFieldParam.name + }, + optional: { + name: DataFieldParam.optional, + defaultValue: false + }, + type: { + name: DataFieldParam.type + } + }, + superTypes: [] + }, DataFieldType: { name: DataFieldType.$type, properties: { diff --git a/packages/language/src/generated/grammar.ts b/packages/language/src/generated/grammar.ts index 27385931e..327eb9e81 100644 --- a/packages/language/src/generated/grammar.ts +++ b/packages/language/src/generated/grammar.ts @@ -67,7 +67,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@70" + "$ref": "#/rules@71" }, "arguments": [] } @@ -120,35 +120,35 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@43" + "$ref": "#/rules@44" }, "arguments": [] }, { "$type": "RuleCall", "rule": { - "$ref": "#/rules@45" + "$ref": "#/rules@46" }, "arguments": [] }, { "$type": "RuleCall", "rule": { - "$ref": "#/rules@47" + "$ref": "#/rules@48" }, "arguments": [] }, { "$type": "RuleCall", "rule": { - "$ref": "#/rules@54" + "$ref": "#/rules@55" }, "arguments": [] }, { "$type": "RuleCall", "rule": { - "$ref": "#/rules@51" + "$ref": "#/rules@52" }, "arguments": [] } @@ -167,7 +167,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@72" + "$ref": "#/rules@73" }, "arguments": [], "cardinality": "*" @@ -183,7 +183,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@52" + "$ref": "#/rules@53" }, "arguments": [] } @@ -224,7 +224,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@72" + "$ref": "#/rules@73" }, "arguments": [], "cardinality": "*" @@ -240,7 +240,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@52" + "$ref": "#/rules@53" }, "arguments": [] } @@ -281,7 +281,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@72" + "$ref": "#/rules@73" }, "arguments": [], "cardinality": "*" @@ -293,7 +293,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@52" + "$ref": "#/rules@53" }, "arguments": [] } @@ -329,7 +329,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@72" + "$ref": "#/rules@73" }, "arguments": [], "cardinality": "*" @@ -345,7 +345,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@52" + "$ref": "#/rules@53" }, "arguments": [] } @@ -386,7 +386,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@72" + "$ref": "#/rules@73" }, "arguments": [], "cardinality": "*" @@ -398,7 +398,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@52" + "$ref": "#/rules@53" }, "arguments": [] } @@ -468,7 +468,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@71" + "$ref": "#/rules@72" }, "arguments": [] } @@ -487,7 +487,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@70" + "$ref": "#/rules@71" }, "arguments": [] } @@ -506,7 +506,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@64" + "$ref": "#/rules@65" }, "arguments": [] } @@ -621,7 +621,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@69" + "$ref": "#/rules@70" }, "arguments": [] } @@ -713,7 +713,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@69" + "$ref": "#/rules@70" }, "arguments": [] } @@ -907,7 +907,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@53" + "$ref": "#/rules@54" }, "arguments": [] }, @@ -1001,7 +1001,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@69" + "$ref": "#/rules@70" }, "arguments": [] } @@ -1109,14 +1109,14 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@52" + "$ref": "#/rules@53" }, "arguments": [] }, { "$type": "RuleCall", "rule": { - "$ref": "#/rules@70" + "$ref": "#/rules@71" }, "arguments": [] } @@ -1158,7 +1158,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "CrossReference", "type": { - "$ref": "#/rules@47" + "$ref": "#/rules@48" }, "deprecatedSyntax": false, "isMulti": false @@ -1391,7 +1391,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@52" + "$ref": "#/rules@53" }, "arguments": [] } @@ -1841,7 +1841,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@72" + "$ref": "#/rules@73" }, "arguments": [] }, @@ -1864,7 +1864,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@52" + "$ref": "#/rules@53" }, "arguments": [] } @@ -1948,7 +1948,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@52" + "$ref": "#/rules@53" }, "arguments": [] } @@ -1983,7 +1983,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@58" + "$ref": "#/rules@59" }, "arguments": [] } @@ -2019,7 +2019,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "CrossReference", "type": { - "$ref": "#/rules@43" + "$ref": "#/rules@44" }, "deprecatedSyntax": false, "isMulti": false @@ -2040,7 +2040,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "CrossReference", "type": { - "$ref": "#/rules@43" + "$ref": "#/rules@44" }, "deprecatedSyntax": false, "isMulti": false @@ -2096,7 +2096,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@72" + "$ref": "#/rules@73" }, "arguments": [] }, @@ -2109,11 +2109,69 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@53" + "$ref": "#/rules@54" }, "arguments": [] } }, + { + "$type": "Group", + "elements": [ + { + "$type": "Keyword", + "value": "(" + }, + { + "$type": "Group", + "elements": [ + { + "$type": "Assignment", + "feature": "params", + "operator": "+=", + "terminal": { + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@42" + }, + "arguments": [] + } + }, + { + "$type": "Group", + "elements": [ + { + "$type": "Keyword", + "value": "," + }, + { + "$type": "Assignment", + "feature": "params", + "operator": "+=", + "terminal": { + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@42" + }, + "arguments": [] + } + } + ], + "cardinality": "*" + } + ], + "cardinality": "?" + }, + { + "$type": "Keyword", + "value": ")" + }, + { + "$type": "Keyword", + "value": ":" + } + ], + "cardinality": "?" + }, { "$type": "Assignment", "feature": "type", @@ -2121,7 +2179,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@42" + "$ref": "#/rules@43" }, "arguments": [] } @@ -2133,7 +2191,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@57" + "$ref": "#/rules@58" }, "arguments": [] }, @@ -2145,6 +2203,64 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "fragment": false, "parameters": [] }, + { + "$type": "ParserRule", + "name": "DataFieldParam", + "definition": { + "$type": "Group", + "elements": [ + { + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@73" + }, + "arguments": [], + "cardinality": "*" + }, + { + "$type": "Assignment", + "feature": "name", + "operator": "=", + "terminal": { + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@53" + }, + "arguments": [] + } + }, + { + "$type": "Keyword", + "value": ":" + }, + { + "$type": "Assignment", + "feature": "type", + "operator": "=", + "terminal": { + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@50" + }, + "arguments": [] + } + }, + { + "$type": "Assignment", + "feature": "optional", + "operator": "?=", + "terminal": { + "$type": "Keyword", + "value": "?" + }, + "cardinality": "?" + } + ] + }, + "entry": false, + "fragment": false, + "parameters": [] + }, { "$type": "ParserRule", "name": "DataFieldType", @@ -2161,7 +2277,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@63" + "$ref": "#/rules@64" }, "arguments": [] } @@ -2173,7 +2289,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@44" + "$ref": "#/rules@45" }, "arguments": [] } @@ -2190,7 +2306,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@52" + "$ref": "#/rules@53" }, "arguments": [] }, @@ -2248,7 +2364,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@72" + "$ref": "#/rules@73" }, "arguments": [] }, @@ -2265,7 +2381,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@52" + "$ref": "#/rules@53" }, "arguments": [] } @@ -2304,7 +2420,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@58" + "$ref": "#/rules@59" }, "arguments": [] } @@ -2371,7 +2487,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@72" + "$ref": "#/rules@73" }, "arguments": [] }, @@ -2388,7 +2504,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@52" + "$ref": "#/rules@53" }, "arguments": [] } @@ -2407,7 +2523,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@46" + "$ref": "#/rules@47" }, "arguments": [] } @@ -2419,7 +2535,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@58" + "$ref": "#/rules@59" }, "arguments": [] } @@ -2450,7 +2566,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@72" + "$ref": "#/rules@73" }, "arguments": [] }, @@ -2463,7 +2579,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@53" + "$ref": "#/rules@54" }, "arguments": [] } @@ -2475,7 +2591,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@57" + "$ref": "#/rules@58" }, "arguments": [] }, @@ -2496,7 +2612,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@72" + "$ref": "#/rules@73" }, "arguments": [], "cardinality": "*" @@ -2512,7 +2628,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@52" + "$ref": "#/rules@53" }, "arguments": [] } @@ -2531,7 +2647,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@48" + "$ref": "#/rules@49" }, "arguments": [] } @@ -2550,7 +2666,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@48" + "$ref": "#/rules@49" }, "arguments": [] } @@ -2576,7 +2692,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@49" + "$ref": "#/rules@50" }, "arguments": [] } @@ -2609,7 +2725,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@59" + "$ref": "#/rules@60" }, "arguments": [] }, @@ -2630,7 +2746,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@72" + "$ref": "#/rules@73" }, "arguments": [], "cardinality": "*" @@ -2642,7 +2758,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@52" + "$ref": "#/rules@53" }, "arguments": [] } @@ -2658,7 +2774,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@49" + "$ref": "#/rules@50" }, "arguments": [] } @@ -2695,7 +2811,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@62" + "$ref": "#/rules@63" }, "arguments": [] } @@ -2712,7 +2828,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@52" + "$ref": "#/rules@53" }, "arguments": [] }, @@ -2756,7 +2872,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@72" + "$ref": "#/rules@73" }, "arguments": [], "cardinality": "*" @@ -2768,7 +2884,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@52" + "$ref": "#/rules@53" }, "arguments": [] } @@ -2784,7 +2900,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@49" + "$ref": "#/rules@50" }, "arguments": [] } @@ -2814,7 +2930,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@72" + "$ref": "#/rules@73" }, "arguments": [], "cardinality": "*" @@ -2840,7 +2956,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@52" + "$ref": "#/rules@53" }, "arguments": [] } @@ -2859,7 +2975,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@50" + "$ref": "#/rules@51" }, "arguments": [] } @@ -2878,7 +2994,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@50" + "$ref": "#/rules@51" }, "arguments": [] } @@ -2904,7 +3020,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@49" + "$ref": "#/rules@50" }, "arguments": [] } @@ -2916,7 +3032,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@59" + "$ref": "#/rules@60" }, "arguments": [] }, @@ -2938,7 +3054,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@69" + "$ref": "#/rules@70" }, "arguments": [] }, @@ -2998,7 +3114,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@52" + "$ref": "#/rules@53" }, "arguments": [] }, @@ -3077,7 +3193,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@72" + "$ref": "#/rules@73" }, "arguments": [] }, @@ -3097,21 +3213,21 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@66" + "$ref": "#/rules@67" }, "arguments": [] }, { "$type": "RuleCall", "rule": { - "$ref": "#/rules@67" + "$ref": "#/rules@68" }, "arguments": [] }, { "$type": "RuleCall", "rule": { - "$ref": "#/rules@68" + "$ref": "#/rules@69" }, "arguments": [] } @@ -3132,7 +3248,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@55" + "$ref": "#/rules@56" }, "arguments": [] } @@ -3151,7 +3267,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@55" + "$ref": "#/rules@56" }, "arguments": [] } @@ -3173,7 +3289,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@59" + "$ref": "#/rules@60" }, "arguments": [] }, @@ -3198,7 +3314,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@72" + "$ref": "#/rules@73" }, "arguments": [] }, @@ -3221,7 +3337,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@52" + "$ref": "#/rules@53" }, "arguments": [] } @@ -3237,7 +3353,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@56" + "$ref": "#/rules@57" }, "arguments": [] } @@ -3249,7 +3365,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@59" + "$ref": "#/rules@60" }, "arguments": [] }, @@ -3280,7 +3396,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@62" + "$ref": "#/rules@63" }, "arguments": [] }, @@ -3311,7 +3427,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@52" + "$ref": "#/rules@53" }, "arguments": [] }, @@ -3369,12 +3485,12 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "CrossReference", "type": { - "$ref": "#/rules@54" + "$ref": "#/rules@55" }, "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@68" + "$ref": "#/rules@69" }, "arguments": [] }, @@ -3392,7 +3508,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@60" + "$ref": "#/rules@61" }, "arguments": [], "cardinality": "?" @@ -3419,7 +3535,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@72" + "$ref": "#/rules@73" }, "arguments": [], "cardinality": "*" @@ -3431,12 +3547,12 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "CrossReference", "type": { - "$ref": "#/rules@54" + "$ref": "#/rules@55" }, "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@67" + "$ref": "#/rules@68" }, "arguments": [] }, @@ -3454,7 +3570,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@60" + "$ref": "#/rules@61" }, "arguments": [], "cardinality": "?" @@ -3485,12 +3601,12 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "CrossReference", "type": { - "$ref": "#/rules@54" + "$ref": "#/rules@55" }, "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@66" + "$ref": "#/rules@67" }, "arguments": [] }, @@ -3508,7 +3624,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@60" + "$ref": "#/rules@61" }, "arguments": [], "cardinality": "?" @@ -3540,7 +3656,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@61" + "$ref": "#/rules@62" }, "arguments": [] } @@ -3559,7 +3675,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@61" + "$ref": "#/rules@62" }, "arguments": [] } @@ -3588,7 +3704,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@52" + "$ref": "#/rules@53" }, "arguments": [] } @@ -3882,7 +3998,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "SimpleType", "typeRef": { - "$ref": "#/rules@48" + "$ref": "#/rules@49" } }, { @@ -3894,7 +4010,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "SimpleType", "typeRef": { - "$ref": "#/rules@46" + "$ref": "#/rules@47" } }, { @@ -3931,13 +4047,13 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "SimpleType", "typeRef": { - "$ref": "#/rules@43" + "$ref": "#/rules@44" } }, { "$type": "SimpleType", "typeRef": { - "$ref": "#/rules@45" + "$ref": "#/rules@46" } } ] diff --git a/packages/language/src/validators/datamodel-validator.ts b/packages/language/src/validators/datamodel-validator.ts index 5fc093da1..c7963bf3c 100644 --- a/packages/language/src/validators/datamodel-validator.ts +++ b/packages/language/src/validators/datamodel-validator.ts @@ -109,6 +109,12 @@ export default class DataModelValidator implements AstValidator { accept('error', 'Optional lists are not supported. Use either `Type[]` or `Type?`', { node: field.type }); } + // Only computed fields may declare parameters; the arguments are supplied at + // query time and forwarded to the computed-field implementation. + if (field.params.length > 0 && !hasAttribute(field, '@computed')) { + accept('error', 'Only `@computed` fields can declare parameters', { node: field }); + } + if (field.type.unsupported && !isStringLiteral(field.type.unsupported.value)) { accept('error', 'Unsupported type argument must be a string literal', { node: field.type.unsupported }); } diff --git a/packages/language/src/zmodel.langium b/packages/language/src/zmodel.langium index e7c33ad2a..cfeb5c6bf 100644 --- a/packages/language/src/zmodel.langium +++ b/packages/language/src/zmodel.langium @@ -186,7 +186,12 @@ fragment ExtendsClause: DataField: (comments+=TRIPLE_SLASH_COMMENT)* - name=RegularIDWithTypeNames type=DataFieldType (attributes+=DataFieldAttribute)*; + name=RegularIDWithTypeNames ('(' (params+=DataFieldParam (',' params+=DataFieldParam)*)? ')' ':')? type=DataFieldType (attributes+=DataFieldAttribute)*; + +// Parameters of a parameterized field (only meaningful on `@computed` fields). +// Shares the shape of FunctionParam/ProcedureParam. +DataFieldParam: + TRIPLE_SLASH_COMMENT* name=RegularID ':' type=FunctionParamType (optional?='?')?; DataFieldType: (type=BuiltinType | unsupported=UnsupportedFieldType | reference=[TypeDeclaration:RegularID]) (array?='[' ']')? (optional?='?')?; diff --git a/packages/orm/src/client/crud-types.ts b/packages/orm/src/client/crud-types.ts index 1d6542a99..403bb6793 100644 --- a/packages/orm/src/client/crud-types.ts +++ b/packages/orm/src/client/crud-types.ts @@ -143,7 +143,11 @@ type FlatModelResult< > = { [Key in NonRelationFields as ShouldOmitField extends true ? never - : Key]: MapModelFieldType; + : // parameterized computed fields require query-time args, so they are not + // auto-returned (only usable in `orderBy`) + FieldHasComputedArgs extends true + ? never + : Key]: MapModelFieldType; }; // Builds a discriminated union from a delegate model's direct sub-models. Recursion depth @@ -336,11 +340,15 @@ export type WhereInput< ScalarOnly extends boolean = false, WithAggregations extends boolean = false, > = { - [Key in GetModelFields as ScalarOnly extends true - ? Key extends RelationFields - ? never - : Key - : Key]?: FieldFilter; + // parameterized computed fields are excluded here — filtering them would require + // query-time args; they are currently only usable in `orderBy` + [Key in GetModelFields as FieldHasComputedArgs extends true + ? never + : ScalarOnly extends true + ? Key extends RelationFields + ? never + : Key + : Key]?: FieldFilter; } & { $expr?: (eb: ExpressionBuilder, Model>) => OperandExpression; } & { @@ -1128,27 +1136,73 @@ 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. + */ +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; + +/** + * Whether `Field` is a parameterized computed field (its args object is not `never`). + */ +export type FieldHasComputedArgs< + Schema extends SchemaDef, + Model extends GetModels, + Field extends GetModelFields, +> = [ComputedFieldArgs] extends [never] ? false : true; + export type OrderBy< Schema extends SchemaDef, Model extends GetModels, WithRelation extends boolean, WithAggregation extends boolean, > = { - [Key in NonRelationFields]?: ModelFieldIsOptional extends true - ? - | SortOrder - | { - /** - * Sort order - */ - sort: SortOrder; + [Key in NonRelationFields]?: FieldHasComputedArgs extends true + ? { + /** + * Arguments for the parameterized computed field. + */ + args: ComputedFieldArgs; - /** - * Treatment of null values - */ - nulls?: NullsOrder; - } - : SortOrder; + /** + * Sort order + */ + sort: SortOrder; + + /** + * Treatment of null values + */ + nulls?: NullsOrder; + } + : ModelFieldIsOptional extends true + ? + | SortOrder + | { + /** + * Sort order + */ + sort: SortOrder; + + /** + * Treatment of null values + */ + nulls?: NullsOrder; + } + : SortOrder; } & (WithRelation extends true ? { [Key in RelationFields]?: FieldIsArray extends true @@ -1259,7 +1313,11 @@ export type SelectInput< AllowRelation extends boolean = true, ExtResult extends ExtResultBase = {}, > = { - [Key in NonRelationFields]?: boolean; + // parameterized computed fields are excluded — selecting them would require + // query-time args; they are currently only usable in `orderBy` + [Key in NonRelationFields as FieldHasComputedArgs extends true + ? never + : Key]?: boolean; } & (AllowRelation extends true ? IncludeInput : {}); type SelectCount, Options extends QueryOptions> = diff --git a/packages/orm/src/client/crud/dialects/base-dialect.ts b/packages/orm/src/client/crud/dialects/base-dialect.ts index 4068f5ccf..b4c6429ff 100644 --- a/packages/orm/src/client/crud/dialects/base-dialect.ts +++ b/packages/orm/src/client/crud/dialects/base-dialect.ts @@ -1113,11 +1113,11 @@ export abstract class BaseCrudDialect { let result = query; - const buildFieldRef = (model: string, field: string, modelAlias: string) => { + const buildFieldRef = (model: string, field: string, modelAlias: string, computedArgs?: unknown) => { const fieldDef = requireField(this.schema, model, field); return fieldDef.originModel - ? this.fieldRef(fieldDef.originModel, field, fieldDef.originModel) - : this.fieldRef(model, field, modelAlias); + ? this.fieldRef(fieldDef.originModel, field, fieldDef.originModel, true, computedArgs) + : this.fieldRef(model, field, modelAlias, true, computedArgs); }; enumerate(orderBy).forEach((orderBy, index) => { @@ -1231,9 +1231,11 @@ export abstract class BaseCrudDialect { field: string, value: any, negated: boolean, - buildFieldRef: (model: string, field: string, modelAlias: string) => Expression, + buildFieldRef: (model: string, field: string, modelAlias: string, computedArgs?: unknown) => Expression, ): SelectQueryBuilder { - const fieldRef = buildFieldRef(model, field, modelAlias); + // a parameterized computed field carries its query-time args alongside `sort`/`nulls` + const computedArgs = value && typeof value === 'object' && 'args' in value ? value.args : undefined; + const fieldRef = buildFieldRef(model, field, modelAlias, computedArgs); if (value === 'asc' || value === 'desc') { return query.orderBy(fieldRef, this.negateSort(value, negated)); } @@ -1372,6 +1374,11 @@ export abstract class BaseCrudDialect { if (this.shouldOmitField(omit, model, fieldDef.name)) { continue; } + // parameterized computed fields can't be auto-selected — they require + // query-time args, so they're only usable in `orderBy` + if (fieldDef.computed && fieldDef.params) { + continue; + } result = this.buildSelectField(result, model, modelAlias, fieldDef.name); } @@ -1385,6 +1392,10 @@ export abstract class BaseCrudDialect { if (this.shouldOmitField(omit, subModel.name, fieldDef.name)) { continue; } + // parameterized computed fields require query-time args; not auto-selected + if (fieldDef.computed && fieldDef.params) { + continue; + } jsonObject[fieldDef.name] = this.fieldRef(subModel.name, fieldDef.name, subModel.name); } return this.buildJsonObject(jsonObject).as(`${DELEGATE_JOINED_FIELD_PREFIX}${subModel.name}`); @@ -1586,7 +1597,7 @@ export abstract class BaseCrudDialect { return this.eb.not(this.and(...args)); } - fieldRef(model: string, field: string, modelAlias?: string, inlineComputedField = true) { + fieldRef(model: string, field: string, modelAlias?: string, inlineComputedField = true, computedArgs?: unknown) { const fieldDef = requireField(this.schema, model, field); if (!fieldDef.computed) { @@ -1609,7 +1620,9 @@ export abstract class BaseCrudDialect { if (!computer) { throw createConfigError(`Computed field "${field}" implementation not provided for model "${model}"`); } - return computer(this.eb, { modelAlias }); + // `computedArgs` is the query-time args object for a parameterized computed + // field (undefined otherwise); forwarded as the implementation's 3rd argument. + return computer(this.eb, { modelAlias }, computedArgs); } } diff --git a/packages/orm/src/client/zod/factory.ts b/packages/orm/src/client/zod/factory.ts index 42b0b8fb3..f03712b82 100644 --- a/packages/orm/src/client/zod/factory.ts +++ b/packages/orm/src/client/zod/factory.ts @@ -407,6 +407,25 @@ export class ZodSchemaFactory< } } + // Builds the validation schema for a parameterized computed field's `args` object, + // keyed by the field's declared params (e.g. `{ categoryId: number }`). + private makeFieldArgsSchema(params: NonNullable) { + return z.strictObject( + Object.fromEntries( + Object.entries(params).map(([name, param]) => { + let paramSchema: ZodType = this.makeScalarSchema(param.type); + if (param.array) { + paramSchema = paramSchema.array(); + } + if (param.optional) { + paramSchema = paramSchema.optional(); + } + return [name, paramSchema]; + }), + ), + ); + } + @cache() private makeEnumSchema(_enum: string) { const enumDef = getEnum(this.schema, _enum); @@ -1386,7 +1405,16 @@ export class ZodSchemaFactory< } } else { // scalars - if (fieldDef.optional) { + if (fieldDef.computed && fieldDef.params) { + // parameterized computed field: `{ args, sort, nulls? }` + fields[field] = z + .strictObject({ + args: this.makeFieldArgsSchema(fieldDef.params), + sort, + nulls: z.union([z.literal('first'), z.literal('last')]).optional(), + }) + .optional(); + } else if (fieldDef.optional) { fields[field] = z .union([ sort, diff --git a/packages/schema/src/schema.ts b/packages/schema/src/schema.ts index b3cbc0bcc..9b5c28e1a 100644 --- a/packages/schema/src/schema.ts +++ b/packages/schema/src/schema.ts @@ -82,6 +82,12 @@ export type FieldDef = { relation?: RelationInfo; foreignKeyFor?: readonly string[]; computed?: boolean; + /** + * For a parameterized computed field, the parameters it declares (keyed by name). + * The corresponding arguments are supplied at query time wherever the field is + * referenced (in `where`, `orderBy`, or `select`). + */ + params?: Record; originModel?: string; isDiscriminator?: boolean; }; diff --git a/packages/sdk/src/ts-schema-generator.ts b/packages/sdk/src/ts-schema-generator.ts index 4a5dc8acd..cfe54af36 100644 --- a/packages/sdk/src/ts-schema-generator.ts +++ b/packages/sdk/src/ts-schema-generator.ts @@ -5,11 +5,13 @@ import { BinaryExpr, DataField, DataFieldAttribute, + DataFieldParam, DataFieldType, DataModel, DataModelAttribute, Enum, Expression, + FunctionParamType, InvocationExpr, isArrayExpr, isBinaryExpr, @@ -541,31 +543,63 @@ export class TsSchemaGenerator { private createComputedFieldsObject(fields: DataField[]) { return ts.factory.createObjectLiteralExpression( - fields.map((field) => - ts.factory.createMethodDeclaration( - undefined, - undefined, - field.name, - undefined, - undefined, - [ - // parameter: `context: { modelAlias: string }` + 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, - '_context', + 'args', undefined, - ts.factory.createTypeLiteralNode([ - ts.factory.createPropertySignature( - undefined, - 'modelAlias', - undefined, - ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), + 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( [ @@ -577,12 +611,55 @@ export class TsSchemaGenerator { ], 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. + private createFieldParamsObject(params: DataFieldParam[]) { + return ts.factory.createObjectLiteralExpression( + params.map((param) => + ts.factory.createPropertyAssignment( + param.name, + ts.factory.createObjectLiteralExpression([ + ts.factory.createPropertyAssignment('name', ts.factory.createStringLiteral(param.name)), + ...(param.optional + ? [ts.factory.createPropertyAssignment('optional', ts.factory.createTrue())] + : []), + ...(param.type.array + ? [ts.factory.createPropertyAssignment('array', ts.factory.createTrue())] + : []), + ts.factory.createPropertyAssignment( + 'type', + ts.factory.createStringLiteral(param.type.type ?? param.type.reference!.$refText), + ), + ]), ), ), true, ); } + 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') + .otherwise(() => type.reference?.ref?.name ?? 'unknown'); + if (type.array) { + result = `${result}[]`; + } + return result; + } + private createUpdatedAtObject(ignoreArg: AttributeArg) { return ts.factory.createObjectLiteralExpression([ ts.factory.createPropertyAssignment( @@ -739,6 +816,14 @@ export class TsSchemaGenerator { objectFields.push(ts.factory.createPropertyAssignment('computed', ts.factory.createTrue())); } + // parameterized computed field: emit the declared params so the runtime can + // forward query-time args and the zod factory can validate them + if (field.params.length > 0) { + objectFields.push( + ts.factory.createPropertyAssignment('params', this.createFieldParamsObject(field.params)), + ); + } + if (isDataModel(field.type.reference?.ref)) { objectFields.push( ts.factory.createPropertyAssignment('relation', this.createRelationObject(field, contextModel)), diff --git a/tests/e2e/orm/client-api/computed-fields.test.ts b/tests/e2e/orm/client-api/computed-fields.test.ts index d0a75c3e2..fac5a1e9f 100644 --- a/tests/e2e/orm/client-api/computed-fields.test.ts +++ b/tests/e2e/orm/client-api/computed-fields.test.ts @@ -180,6 +180,73 @@ model User { ]); }); + it('works with parameterized computed fields in orderBy', async () => { + const db = await createTestClient( + ` +model User { + id Int @id @default(autoincrement()) + name String + posts Post[] + popularPostCount(minViews: Int): Int @computed +} + +model Post { + id Int @id @default(autoincrement()) + viewCount Int @default(0) + author User @relation(fields: [authorId], references: [id]) + authorId Int +} +`, + { + computedFields: { + User: { + // counts the user's posts whose viewCount >= the query-time `minViews` arg + popularPostCount: (eb: any, ctx: any, args: any) => + eb + .selectFrom('Post') + .whereRef('Post.authorId', '=', sql.ref(`${ctx.modelAlias}.id`)) + .where('Post.viewCount', '>=', args.minViews) + .select(({ fn }: any) => fn.countAll().as('cnt')), + }, + }, + } as any, + ); + + // Alice: posts [300, 50, 50] → (>=100)=1, (>=250)=1 + // Bob: posts [120, 120, 10] → (>=100)=2, (>=250)=0 + await db.user.create({ + data: { id: 1, name: 'Alice', posts: { create: [{ viewCount: 300 }, { viewCount: 50 }, { viewCount: 50 }] } }, + }); + await db.user.create({ + data: { id: 2, name: 'Bob', posts: { create: [{ viewCount: 120 }, { viewCount: 120 }, { viewCount: 10 }] } }, + }); + + // minViews=100: Alice=1, Bob=2 → desc ⇒ [Bob, Alice] + await expect( + db.user.findMany({ + orderBy: [{ popularPostCount: { args: { minViews: 100 }, sort: 'desc' } }, { id: 'asc' }], + }), + ).resolves.toMatchObject([{ id: 2 }, { id: 1 }]); + + // minViews=250: Alice=1, Bob=0 → desc ⇒ [Alice, Bob] (different arg ⇒ different order) + await expect( + db.user.findMany({ + orderBy: [{ popularPostCount: { args: { minViews: 250 }, sort: 'desc' } }, { id: 'asc' }], + }), + ).resolves.toMatchObject([{ id: 1 }, { id: 2 }]); + + // ascending flips the minViews=100 ordering + await expect( + db.user.findMany({ + orderBy: [{ popularPostCount: { args: { minViews: 100 }, sort: 'asc' } }, { id: 'asc' }], + }), + ).resolves.toMatchObject([{ id: 1 }, { id: 2 }]); + + // a parameterized computed field is not auto-returned (it needs args) + const plain = await db.user.findFirstOrThrow({ where: { id: 1 } }); + expect(plain).not.toHaveProperty('popularPostCount'); + }); + it('is typed correctly for non-optional fields', async () => { await createTestClient( ` From 34fd0254c97bd1ef50de8c01817bbecfcd6aad11 Mon Sep 17 00:00:00 2001 From: evgenovalov Date: Tue, 30 Jun 2026 11:08:47 +0200 Subject: [PATCH 02/13] test: add DateTime-parameterized computed field example (recentPostCount) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../orm/client-api/computed-fields.test.ts | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/tests/e2e/orm/client-api/computed-fields.test.ts b/tests/e2e/orm/client-api/computed-fields.test.ts index fac5a1e9f..1df8abbf9 100644 --- a/tests/e2e/orm/client-api/computed-fields.test.ts +++ b/tests/e2e/orm/client-api/computed-fields.test.ts @@ -247,6 +247,73 @@ model Post { expect(plain).not.toHaveProperty('popularPostCount'); }); + it('works with a DateTime-parameterized computed field', async () => { + const db = await createTestClient( + ` +model User { + id Int @id @default(autoincrement()) + name String + posts Post[] + recentPostCount(since: DateTime): Int @computed +} + +model Post { + id Int @id @default(autoincrement()) + createdAt DateTime + author User @relation(fields: [authorId], references: [id]) + authorId Int +} +`, + { + computedFields: { + User: { + // counts the user's posts created on/after the query-time `since` arg. + // (a computed-field impl writes raw Kysely, so it binds the dialect's + // native value — SQLite stores DateTime as an ISO string) + recentPostCount: (eb: any, ctx: any, args: any) => + eb + .selectFrom('Post') + .whereRef('Post.authorId', '=', sql.ref(`${ctx.modelAlias}.id`)) + .where('Post.createdAt', '>=', args.since.toISOString()) + .select(({ fn }: any) => fn.countAll().as('cnt')), + }, + }, + } as any, + ); + + // Alice: 3 posts in early 2024; Bob: 1 post in Aug 2024 + await db.user.create({ + data: { + id: 1, + name: 'Alice', + posts: { + create: [ + { createdAt: new Date('2024-01-01') }, + { createdAt: new Date('2024-02-01') }, + { createdAt: new Date('2024-03-01') }, + ], + }, + }, + }); + await db.user.create({ + data: { id: 2, name: 'Bob', posts: { create: [{ createdAt: new Date('2024-08-01') }] } }, + }); + + // since 2024-01-01: Alice=3, Bob=1 → desc ⇒ [Alice, Bob] + await expect( + db.user.findMany({ + orderBy: [{ recentPostCount: { args: { since: new Date('2024-01-01') }, sort: 'desc' } }, { id: 'asc' }], + }), + ).resolves.toMatchObject([{ id: 1 }, { id: 2 }]); + + // since 2024-07-01: Alice=0, Bob=1 → desc ⇒ [Bob, Alice] (later `since` flips the order) + await expect( + db.user.findMany({ + orderBy: [{ recentPostCount: { args: { since: new Date('2024-07-01') }, sort: 'desc' } }, { id: 'asc' }], + }), + ).resolves.toMatchObject([{ id: 2 }, { id: 1 }]); + }); + it('is typed correctly for non-optional fields', async () => { await createTestClient( ` From ef097573e380af98152b784ce240b978e3a046f2 Mon Sep 17 00:00:00 2001 From: evgenovalov Date: Tue, 30 Jun 2026 11:34:40 +0200 Subject: [PATCH 03/13] fix: address review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - grammar: require at least one parameter when a field declares `(...)`, so `field(): T` no longer parses (empty param lists are meaningless) - runtime: cursor pagination now rejects a parameterized computed field in `orderBy` (its sort key is not a real column), matching the existing relevance-ordering guard - codegen: a param typed with a model/enum/type-def reference maps to `unknown` (those names aren't in scope in the generated schema) — same convention as computed-field return types; zod still validates the value precisely - schema: tighten the FieldDef.params doc to mention `orderBy` only - test: assert cursor + parameterized computed sort is rejected Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/language/src/generated/grammar.ts | 40 ++++++++----------- packages/language/src/zmodel.langium | 2 +- .../src/client/crud/dialects/base-dialect.ts | 6 +++ packages/schema/src/schema.ts | 4 +- packages/sdk/src/ts-schema-generator.ts | 5 ++- .../orm/client-api/computed-fields.test.ts | 9 +++++ 6 files changed, 39 insertions(+), 27 deletions(-) diff --git a/packages/language/src/generated/grammar.ts b/packages/language/src/generated/grammar.ts index 327eb9e81..2fd8992aa 100644 --- a/packages/language/src/generated/grammar.ts +++ b/packages/language/src/generated/grammar.ts @@ -2121,9 +2121,25 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "$type": "Keyword", "value": "(" }, + { + "$type": "Assignment", + "feature": "params", + "operator": "+=", + "terminal": { + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@42" + }, + "arguments": [] + } + }, { "$type": "Group", "elements": [ + { + "$type": "Keyword", + "value": "," + }, { "$type": "Assignment", "feature": "params", @@ -2135,31 +2151,9 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel }, "arguments": [] } - }, - { - "$type": "Group", - "elements": [ - { - "$type": "Keyword", - "value": "," - }, - { - "$type": "Assignment", - "feature": "params", - "operator": "+=", - "terminal": { - "$type": "RuleCall", - "rule": { - "$ref": "#/rules@42" - }, - "arguments": [] - } - } - ], - "cardinality": "*" } ], - "cardinality": "?" + "cardinality": "*" }, { "$type": "Keyword", diff --git a/packages/language/src/zmodel.langium b/packages/language/src/zmodel.langium index cfeb5c6bf..b01f379ce 100644 --- a/packages/language/src/zmodel.langium +++ b/packages/language/src/zmodel.langium @@ -186,7 +186,7 @@ fragment ExtendsClause: DataField: (comments+=TRIPLE_SLASH_COMMENT)* - name=RegularIDWithTypeNames ('(' (params+=DataFieldParam (',' params+=DataFieldParam)*)? ')' ':')? type=DataFieldType (attributes+=DataFieldAttribute)*; + name=RegularIDWithTypeNames ('(' params+=DataFieldParam (',' params+=DataFieldParam)* ')' ':')? type=DataFieldType (attributes+=DataFieldAttribute)*; // Parameters of a parameterized field (only meaningful on `@computed` fields). // Shares the shape of FunctionParam/ProcedureParam. diff --git a/packages/orm/src/client/crud/dialects/base-dialect.ts b/packages/orm/src/client/crud/dialects/base-dialect.ts index b4c6429ff..180405f31 100644 --- a/packages/orm/src/client/crud/dialects/base-dialect.ts +++ b/packages/orm/src/client/crud/dialects/base-dialect.ts @@ -177,6 +177,12 @@ export abstract class BaseCrudDialect { if (typeof ob !== 'object' || ob === null) return undefined; if ('_fuzzyRelevance' in ob) return '_fuzzyRelevance'; if ('_ftsRelevance' in ob) return '_ftsRelevance'; + // a parameterized computed field orders by `{ args, sort }`; its sort + // key is not a real column, so it can't participate in a cursor compare + const argsEntry = Object.entries(ob).find( + ([, v]) => v !== null && typeof v === 'object' && 'args' in (v as object), + ); + if (argsEntry) return argsEntry[0]; return undefined; }) .find((k) => k !== undefined); diff --git a/packages/schema/src/schema.ts b/packages/schema/src/schema.ts index 9b5c28e1a..d00c5ab4f 100644 --- a/packages/schema/src/schema.ts +++ b/packages/schema/src/schema.ts @@ -84,8 +84,8 @@ export type FieldDef = { computed?: boolean; /** * For a parameterized computed field, the parameters it declares (keyed by name). - * The corresponding arguments are supplied at query time wherever the field is - * referenced (in `where`, `orderBy`, or `select`). + * The corresponding arguments are supplied at query time when the field is used in + * `orderBy`. */ params?: Record; originModel?: string; diff --git a/packages/sdk/src/ts-schema-generator.ts b/packages/sdk/src/ts-schema-generator.ts index cfe54af36..e6d3e5222 100644 --- a/packages/sdk/src/ts-schema-generator.ts +++ b/packages/sdk/src/ts-schema-generator.ts @@ -653,7 +653,10 @@ export class TsSchemaGenerator { .with('BigInt', () => 'bigint') .with('Decimal', () => 'number') .with('DateTime', () => 'Date') - .otherwise(() => type.reference?.ref?.name ?? 'unknown'); + // 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}[]`; } diff --git a/tests/e2e/orm/client-api/computed-fields.test.ts b/tests/e2e/orm/client-api/computed-fields.test.ts index 1df8abbf9..bbcd9b552 100644 --- a/tests/e2e/orm/client-api/computed-fields.test.ts +++ b/tests/e2e/orm/client-api/computed-fields.test.ts @@ -245,6 +245,15 @@ model Post { // a parameterized computed field is not auto-returned (it needs args) const plain = await db.user.findFirstOrThrow({ where: { id: 1 } }); expect(plain).not.toHaveProperty('popularPostCount'); + + // cursor pagination can't be combined with a parameterized computed sort + // (its sort key is not a real column) + await expect( + db.user.findMany({ + orderBy: { popularPostCount: { args: { minViews: 100 }, sort: 'desc' } }, + cursor: { id: 1 }, + } as any), + ).rejects.toThrow(/cursor pagination cannot be combined/); }); it('works with a DateTime-parameterized computed field', async () => { From ad7b6c329527e65def4c910d3709b6ef101dfc7b Mon Sep 17 00:00:00 2001 From: evgenovalov Date: Mon, 20 Jul 2026 10:47:23 +0200 Subject: [PATCH 04/13] =?UTF-8?q?fix:=20address=20review=20=E2=80=94=20dro?= =?UTF-8?q?p=20`:`=20before=20return=20type;=20close=20arg-context=20gap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - grammar: a parameterized field is now `name(params) Type` (no `:` before the return type), consistent with regular fields; Langium grammar regenerated - types + zod: a parameterized computed field is now excluded from every read context that can't supply `args` (where/select/omit, `_count`/`_sum`/`_avg`/ `_min`/`_max`, groupBy `by`, `distinct`) — it previously typechecked but hit `computer(eb, ctx, undefined)` at runtime; it remains usable via `orderBy` - test: language grammar test (no-colon parses, colon rejected, params require `@computed`) + e2e regression asserting the excluded contexts reject it Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/language/src/generated/grammar.ts | 4 -- packages/language/src/zmodel.langium | 2 +- .../test/parameterized-computed-field.test.ts | 49 ++++++++++++++++++ packages/orm/src/client/crud-types.ts | 31 +++++++++-- packages/orm/src/client/zod/factory.ts | 36 ++++++++++--- .../orm/client-api/computed-fields.test.ts | 51 ++++++++++++++++++- 6 files changed, 155 insertions(+), 18 deletions(-) create mode 100644 packages/language/test/parameterized-computed-field.test.ts diff --git a/packages/language/src/generated/grammar.ts b/packages/language/src/generated/grammar.ts index 2fd8992aa..0f7f341a4 100644 --- a/packages/language/src/generated/grammar.ts +++ b/packages/language/src/generated/grammar.ts @@ -2158,10 +2158,6 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "Keyword", "value": ")" - }, - { - "$type": "Keyword", - "value": ":" } ], "cardinality": "?" diff --git a/packages/language/src/zmodel.langium b/packages/language/src/zmodel.langium index b01f379ce..4f39ea28e 100644 --- a/packages/language/src/zmodel.langium +++ b/packages/language/src/zmodel.langium @@ -186,7 +186,7 @@ fragment ExtendsClause: DataField: (comments+=TRIPLE_SLASH_COMMENT)* - name=RegularIDWithTypeNames ('(' params+=DataFieldParam (',' params+=DataFieldParam)* ')' ':')? type=DataFieldType (attributes+=DataFieldAttribute)*; + name=RegularIDWithTypeNames ('(' params+=DataFieldParam (',' params+=DataFieldParam)* ')')? type=DataFieldType (attributes+=DataFieldAttribute)*; // Parameters of a parameterized field (only meaningful on `@computed` fields). // Shares the shape of FunctionParam/ProcedureParam. diff --git a/packages/language/test/parameterized-computed-field.test.ts b/packages/language/test/parameterized-computed-field.test.ts new file mode 100644 index 000000000..a1d4b3e7f --- /dev/null +++ b/packages/language/test/parameterized-computed-field.test.ts @@ -0,0 +1,49 @@ +import { describe, it } from 'vitest'; +import { loadSchema, loadSchemaWithError } from './utils'; + +const datasource = ` +datasource db { + provider = 'sqlite' + url = 'file:./dev.db' +} +`; + +describe('Parameterized computed field', () => { + it('parses a parameterized computed field (no colon before the return type)', async () => { + await loadSchema( + ` +${datasource} +model User { + id Int @id + tagName(categoryId: Int) String? @computed +} + `, + ); + }); + + it('rejects the old colon syntax before the return type', async () => { + await loadSchemaWithError( + ` +${datasource} +model User { + id Int @id + tagName(categoryId: Int): String? @computed +} + `, + /expecting|unexpected|parsing error|mismatch|no viable/i, + ); + }); + + it('rejects parameters on a non-computed field', async () => { + await loadSchemaWithError( + ` +${datasource} +model User { + id Int @id + tagName(categoryId: Int) String? +} + `, + /Only .*@computed.* fields can declare parameters/i, + ); + }); +}); diff --git a/packages/orm/src/client/crud-types.ts b/packages/orm/src/client/crud-types.ts index 403bb6793..704205ea7 100644 --- a/packages/orm/src/client/crud-types.ts +++ b/packages/orm/src/client/crud-types.ts @@ -1165,6 +1165,16 @@ export type FieldHasComputedArgs< Field extends GetModelFields, > = [ComputedFieldArgs] extends [never] ? false : true; +/** + * `NonRelationFields` with parameterized computed fields removed. Used where a field can only be + * referenced by name, with no slot to carry `args` (groupBy `by`, `distinct`). + */ +export type NonParamComputedNonRelationFields> = keyof { + [Key in NonRelationFields as FieldHasComputedArgs extends true + ? never + : Key]: 0; +}; + export type OrderBy< Schema extends SchemaDef, Model extends GetModels, @@ -1562,7 +1572,7 @@ export type FindArgs< /** * Distinct fields. Only supported by providers that natively support SQL "DISTINCT ON". */ - distinct?: OrArray>; + distinct?: OrArray>; } : {}) : {}) & @@ -2267,7 +2277,10 @@ export type CountArgs< } & ExtractExtQueryArgs; type CountAggregateInput> = { - [Key in NonRelationFields]?: true; + // a parameterized computed field has no `args` slot in `_count`, so it is excluded + [Key in NonRelationFields as FieldHasComputedArgs extends true + ? never + : Key]?: true; } & { _all?: true }; export type CountResult, Args> = Args extends { @@ -2346,7 +2359,10 @@ type NumericFields> = | 'Decimal' ? FieldIsArray extends true ? never - : Key + : // a parameterized computed field has no `args` slot in `_sum`/`_avg`, so it is excluded + FieldHasComputedArgs extends true + ? never + : Key : never]: GetModelField; }; @@ -2359,7 +2375,10 @@ type MinMaxInput, Valu ? never : FieldIsRelation extends true ? never - : Key]?: ValueType; + : // a parameterized computed field has no `args` slot in `_min`/`_max`, so it is excluded + FieldHasComputedArgs extends true + ? never + : Key]?: ValueType; }; export type AggregateResult, Args> = (Args extends { @@ -2442,7 +2461,9 @@ export type GroupByArgs< /** * Fields to group by */ - by: NonRelationFields | NonEmptyArray>; + by: + | NonParamComputedNonRelationFields + | NonEmptyArray>; /** * Filter conditions for the grouped records diff --git a/packages/orm/src/client/zod/factory.ts b/packages/orm/src/client/zod/factory.ts index f03712b82..74f14d8ee 100644 --- a/packages/orm/src/client/zod/factory.ts +++ b/packages/orm/src/client/zod/factory.ts @@ -407,6 +407,14 @@ export class ZodSchemaFactory< } } + // A parameterized computed field declares query-time `params`. Unlike a plain computed + // field, it can't be evaluated without an `args` object, so it is excluded from contexts + // that have no slot to carry `args` (where/select/omit, the aggregate inputs, and + // groupBy `by`). It is currently only usable in `orderBy`. + private isParameterizedComputedField(fieldDef: FieldDef): boolean { + return !!(fieldDef.computed && fieldDef.params); + } + // Builds the validation schema for a parameterized computed field's `args` object, // keyed by the field's declared params (e.g. `{ categoryId: number }`). private makeFieldArgsSchema(params: NonNullable) { @@ -495,6 +503,11 @@ export class ZodSchemaFactory< const fields: Record = {}; for (const [field, fieldDef] of this.getModelFields(model)) { + // a parameterized computed field can't be filtered without `args`; excluded from `where` + if (this.isParameterizedComputedField(fieldDef)) { + continue; + } + let fieldSchema: ZodType | undefined; if (fieldDef.relation) { @@ -1163,6 +1176,10 @@ export class ZodSchemaFactory< private makeSelectSchema(model: string, options?: CreateSchemaOptions) { const fields: Record = {}; for (const [field, fieldDef] of this.getModelFields(model)) { + // a parameterized computed field can't be selected without `args`; excluded from `select` + if (this.isParameterizedComputedField(fieldDef)) { + continue; + } if (fieldDef.relation) { if (!this.shouldIncludeRelations(options)) { continue; @@ -1308,6 +1325,10 @@ export class ZodSchemaFactory< private makeOmitSchema(model: string) { const fields: Record = {}; for (const [field, fieldDef] of this.getModelFields(model)) { + // a parameterized computed field is never auto-selected, so omitting it is meaningless + if (this.isParameterizedComputedField(fieldDef)) { + continue; + } if (!fieldDef.relation) { if (this.options.allowQueryTimeOmitOverride !== false) { // if override is allowed, use boolean @@ -1488,7 +1509,7 @@ export class ZodSchemaFactory< @cache() private makeDistinctSchema(model: string) { const nonRelationFields = this.getModelFields(model) - .filter(([, def]) => !def.relation) + .filter(([, def]) => !def.relation && !this.isParameterizedComputedField(def)) .map(([name]) => name); const schema = nonRelationFields.length > 0 ? this.orArray(z.enum(nonRelationFields as any), true) : z.never(); this.registerSchema(`${model}DistinctInput`, schema); @@ -2132,8 +2153,11 @@ export class ZodSchemaFactory< z.strictObject({ _all: z.literal(true).optional(), ...this.getModelFields(model).reduce( - (acc, [field]) => { - acc[field] = z.literal(true).optional(); + (acc, [field, fieldDef]) => { + // a parameterized computed field has no `args` slot in `_count` + if (!this.isParameterizedComputedField(fieldDef)) { + acc[field] = z.literal(true).optional(); + } return acc; }, {} as Record, @@ -2176,7 +2200,7 @@ export class ZodSchemaFactory< const schema = z.strictObject( this.getModelFields(model).reduce( (acc, [field, fieldDef]) => { - if (this.isNumericField(fieldDef)) { + if (this.isNumericField(fieldDef) && !this.isParameterizedComputedField(fieldDef)) { acc[field] = z.literal(true).optional(); } return acc; @@ -2193,7 +2217,7 @@ export class ZodSchemaFactory< const schema = z.strictObject( this.getModelFields(model).reduce( (acc, [field, fieldDef]) => { - if (!fieldDef.relation && !fieldDef.array) { + if (!fieldDef.relation && !fieldDef.array && !this.isParameterizedComputedField(fieldDef)) { acc[field] = z.literal(true).optional(); } return acc; @@ -2215,7 +2239,7 @@ export class ZodSchemaFactory< options?: CreateSchemaOptions, ): ZodType> { const nonRelationFields = this.getModelFields(model) - .filter(([, def]) => !def.relation) + .filter(([, def]) => !def.relation && !this.isParameterizedComputedField(def)) .map(([name]) => name); const bySchema = nonRelationFields.length > 0 diff --git a/tests/e2e/orm/client-api/computed-fields.test.ts b/tests/e2e/orm/client-api/computed-fields.test.ts index bbcd9b552..1205e5750 100644 --- a/tests/e2e/orm/client-api/computed-fields.test.ts +++ b/tests/e2e/orm/client-api/computed-fields.test.ts @@ -187,7 +187,7 @@ model User { id Int @id @default(autoincrement()) name String posts Post[] - popularPostCount(minViews: Int): Int @computed + popularPostCount(minViews: Int) Int @computed } model Post { @@ -263,7 +263,7 @@ model User { id Int @id @default(autoincrement()) name String posts Post[] - recentPostCount(since: DateTime): Int @computed + recentPostCount(since: DateTime) Int @computed } model Post { @@ -323,6 +323,53 @@ model Post { ).resolves.toMatchObject([{ id: 2 }, { id: 1 }]); }); + it('excludes parameterized computed fields from contexts that cannot supply args', async () => { + const db = await createTestClient( + ` +model User { + id Int @id @default(autoincrement()) + name String + posts Post[] + popularPostCount(minViews: Int) Int @computed +} + +model Post { + id Int @id @default(autoincrement()) + viewCount Int @default(0) + author User @relation(fields: [authorId], references: [id]) + authorId Int +} +`, + { + computedFields: { + User: { + popularPostCount: (eb: any, ctx: any, args: any) => + eb + .selectFrom('Post') + .whereRef('Post.authorId', '=', sql.ref(`${ctx.modelAlias}.id`)) + .where('Post.viewCount', '>=', args.minViews) + .select(({ fn }: any) => fn.countAll().as('cnt')), + }, + }, + } as any, + ); + + await db.user.create({ data: { id: 1, name: 'Alice' } }); + + // none of these contexts can carry `args`, so a parameterized computed field is rejected + // by input validation (it remains usable only via `orderBy`). `as any` bypasses the + // matching compile-time exclusions in the query input types. + await expect(db.user.findMany({ where: { popularPostCount: 1 } as any })).toBeRejectedByValidation(); + await expect(db.user.findMany({ select: { popularPostCount: true } as any })).toBeRejectedByValidation(); + await expect(db.user.findMany({ distinct: ['popularPostCount'] as any })).toBeRejectedByValidation(); + await expect(db.user.findMany({ omit: { popularPostCount: true } as any })).toBeRejectedByValidation(); + await expect(db.user.aggregate({ _count: { popularPostCount: true } as any })).toBeRejectedByValidation(); + await expect(db.user.aggregate({ _sum: { popularPostCount: true } as any })).toBeRejectedByValidation(); + await expect( + db.user.groupBy({ by: ['popularPostCount'], _count: true } as any), + ).toBeRejectedByValidation(); + }); + it('is typed correctly for non-optional fields', async () => { await createTestClient( ` From 4fac34e730f406786fc62b57fe8ae975454ba083 Mon Sep 17 00:00:00 2001 From: evgenovalov Date: Mon, 20 Jul 2026 11:20:19 +0200 Subject: [PATCH 05/13] feat: support parameterized computed fields in where, select, and include MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends parameterized `@computed` fields (previously orderBy-only) to more read contexts, threading query-time `args` through the single `fieldRef` channel. - where (+ having, which reuses WhereInput): `where: { field: { args, ...ops } }` — args supplied alongside the filter operators. `addComputedArgsToFilter` lifts the operator components from the field's existing filter schema and requires `args` (dropping the bare-value shorthand); `buildFilter` strips `args` and forwards it to the implementation. - select / include: `include: { field: { args } }`, and `select: { field: { args } }` via the `SelectInput = {…boolean} & IncludeInput` intersection. Result types surface the field as its scalar return type (new additive term in ModelResult's include branch; ModelSelectResult already mapped it). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/orm/src/client/crud-types.ts | 33 ++-- .../src/client/crud/dialects/base-dialect.ts | 25 ++- .../orm/src/client/crud/operations/base.ts | 10 +- packages/orm/src/client/zod/factory.ts | 29 +++- .../orm/client-api/computed-fields.test.ts | 143 +++++++++++++++++- 5 files changed, 213 insertions(+), 27 deletions(-) diff --git a/packages/orm/src/client/crud-types.ts b/packages/orm/src/client/crud-types.ts index 704205ea7..01db6575e 100644 --- a/packages/orm/src/client/crud-types.ts +++ b/packages/orm/src/client/crud-types.ts @@ -269,6 +269,14 @@ export type ModelResult< FieldIsArray, ExtResult >; + } & { + // an included parameterized computed field surfaces as its scalar return type + // (it is not auto-returned, so it only appears when explicitly included) + [Key in keyof I & NonRelationFields as I[Key] extends false | undefined + ? never + : FieldHasComputedArgs extends true + ? Key + : never]: MapModelFieldType; } & ('_count' extends keyof I ? I['_count'] extends false | undefined ? {} @@ -340,15 +348,15 @@ export type WhereInput< ScalarOnly extends boolean = false, WithAggregations extends boolean = false, > = { - // parameterized computed fields are excluded here — filtering them would require - // query-time args; they are currently only usable in `orderBy` - [Key in GetModelFields as FieldHasComputedArgs extends true - ? never - : ScalarOnly extends true - ? Key extends RelationFields - ? never - : Key - : Key]?: FieldFilter; + [Key in GetModelFields as ScalarOnly extends true + ? Key extends RelationFields + ? never + : Key + : Key]?: FieldHasComputedArgs extends true + ? // a parameterized computed field carries its query-time `args` alongside the filter + // operators; the intersection drops the bare-value shorthand (args must be supplied) + { args: ComputedFieldArgs } & FieldFilter + : FieldFilter; } & { $expr?: (eb: ExpressionBuilder, Model>) => OperandExpression; } & { @@ -1372,6 +1380,13 @@ export type IncludeInput< : false, ExtResult >; +} & { + // a parameterized computed field can be selected by supplying its query-time `args` (mirrors + // the relation per-key-object shape). Because `SelectInput` intersects `IncludeInput`, this + // also makes the field selectable via `select: { field: { args } }`. + [Key in NonRelationFields as FieldHasComputedArgs extends true + ? Key + : never]?: { args: ComputedFieldArgs }; } & (AllowCount extends true ? // _count is only allowed if the model has to-many relations HasToManyRelations extends true diff --git a/packages/orm/src/client/crud/dialects/base-dialect.ts b/packages/orm/src/client/crud/dialects/base-dialect.ts index 180405f31..f0196bb7e 100644 --- a/packages/orm/src/client/crud/dialects/base-dialect.ts +++ b/packages/orm/src/client/crud/dialects/base-dialect.ts @@ -236,12 +236,27 @@ export abstract class BaseCrudDialect { if (fieldDef.relation) { result = this.and(result, this.buildRelationFilter(model, modelAlias, key, fieldDef, payload)); } else { + // a parameterized computed field carries its query-time `args` alongside the filter + // operators; pull them out so they reach the implementation, and filter on the rest + let computedArgs: unknown; + let filterPayload = payload; + if (fieldDef.computed && fieldDef.params && payload && typeof payload === 'object' && 'args' in payload) { + computedArgs = (payload as any).args; + const { args: _args, ...rest } = payload as any; + filterPayload = rest; + } // if the field is from a base model, build a reference from that model - const fieldRef = this.fieldRef(fieldDef.originModel ?? model, key, fieldDef.originModel ?? modelAlias); + const fieldRef = this.fieldRef( + fieldDef.originModel ?? model, + key, + fieldDef.originModel ?? modelAlias, + true, + computedArgs, + ); if (fieldDef.array) { - result = this.and(result, this.buildArrayFilter(fieldRef, fieldDef, payload)); + result = this.and(result, this.buildArrayFilter(fieldRef, fieldDef, filterPayload)); } else { - result = this.and(result, this.buildPrimitiveFilter(fieldRef, fieldDef, payload)); + result = this.and(result, this.buildPrimitiveFilter(fieldRef, fieldDef, filterPayload)); } } } @@ -1460,6 +1475,7 @@ export abstract class BaseCrudDialect { model: string, modelAlias: string, field: string, + computedArgs?: unknown, ): SelectQueryBuilder { const fieldDef = requireField(this.schema, model, field); @@ -1468,7 +1484,8 @@ export abstract class BaseCrudDialect { const fieldModel = fieldDef.originModel ?? model; const alias = fieldDef.originModel ?? modelAlias; - return query.select(() => this.fieldRef(fieldModel, field, alias).as(field)); + // `computedArgs` is set for a parameterized computed field selected via `select`/`include` + return query.select(() => this.fieldRef(fieldModel, field, alias, true, computedArgs).as(field)); } buildDelegateJoin( diff --git a/packages/orm/src/client/crud/operations/base.ts b/packages/orm/src/client/crud/operations/base.ts index a5ef4d6e7..8e16031e9 100644 --- a/packages/orm/src/client/crud/operations/base.ts +++ b/packages/orm/src/client/crud/operations/base.ts @@ -344,8 +344,12 @@ export abstract class BaseOperationHandler { const fieldDef = this.requireField(model, field); if (!fieldDef.relation) { - // scalar field - result = this.dialect.buildSelectField(result, model, parentAlias, field); + // scalar field — a parameterized computed field carries its query-time `args` + const computedArgs = + fieldDef.computed && fieldDef.params && typeof payload === 'object' && 'args' in payload + ? payload.args + : undefined; + result = this.dialect.buildSelectField(result, model, parentAlias, field, computedArgs); } else { if (!fieldDef.array && !fieldDef.optional && payload.where) { throw createInternalError(`Field "${field}" does not support filtering`, model); @@ -2091,7 +2095,7 @@ export abstract class BaseOperationHandler { // read parent's fk const fromEntity = await this.readUnique(kysely, fromRelation.model, { where: fromRelation.ids, - select: fieldsToSelectObject(keyPairs.map(({ fk }) => fk)), + select: fieldsToSelectObject(keyPairs.map(({ fk }) => fk)) as any, }); if (!fromEntity || keyPairs.some(({ fk }) => fromEntity[fk] == null)) { return; diff --git a/packages/orm/src/client/zod/factory.ts b/packages/orm/src/client/zod/factory.ts index 74f14d8ee..0d10b8723 100644 --- a/packages/orm/src/client/zod/factory.ts +++ b/packages/orm/src/client/zod/factory.ts @@ -434,6 +434,18 @@ export class ZodSchemaFactory< ); } + // Wraps a field's normal filter schema so a parameterized computed field carries its + // query-time `args` alongside the filter operators. `args` is required and the bare-value + // shorthand is dropped (the field can't be filtered without args). Works uniformly across + // field types: the operator components are lifted from the existing filter object(s) (the + // filter schema is either `value | { ...ops }` or a bare `{ ...ops }`). + private addComputedArgsToFilter(filterSchema: ZodType, params: NonNullable): ZodType { + const members: any[] = filterSchema instanceof z.ZodUnion ? (filterSchema.options as any[]) : [filterSchema]; + const objectMembers = members.filter((m) => m instanceof z.ZodObject); + const components: Record = Object.assign({}, ...objectMembers.map((o) => o.shape)); + return z.strictObject({ args: this.makeFieldArgsSchema(params), ...components }); + } + @cache() private makeEnumSchema(_enum: string) { const enumDef = getEnum(this.schema, _enum); @@ -503,11 +515,6 @@ export class ZodSchemaFactory< const fields: Record = {}; for (const [field, fieldDef] of this.getModelFields(model)) { - // a parameterized computed field can't be filtered without `args`; excluded from `where` - if (this.isParameterizedComputedField(fieldDef)) { - continue; - } - let fieldSchema: ZodType | undefined; if (fieldDef.relation) { @@ -586,6 +593,12 @@ export class ZodSchemaFactory< } } + if (fieldSchema && this.isParameterizedComputedField(fieldDef)) { + // a parameterized computed field carries its query-time `args` alongside the + // filter operators (args required; the bare-value shorthand is dropped) + fieldSchema = this.addComputedArgsToFilter(fieldSchema, fieldDef.params!); + } + if (fieldSchema) { fields[field] = fieldSchema.optional(); } @@ -1176,8 +1189,9 @@ export class ZodSchemaFactory< private makeSelectSchema(model: string, options?: CreateSchemaOptions) { const fields: Record = {}; for (const [field, fieldDef] of this.getModelFields(model)) { - // a parameterized computed field can't be selected without `args`; excluded from `select` if (this.isParameterizedComputedField(fieldDef)) { + // a parameterized computed field is selected by supplying its query-time `args` + fields[field] = z.strictObject({ args: this.makeFieldArgsSchema(fieldDef.params!) }).optional(); continue; } if (fieldDef.relation) { @@ -1375,6 +1389,9 @@ export class ZodSchemaFactory< if (this.isModelAllowed(fieldDef.type)) { fields[field] = this.makeRelationSelectIncludeSchema(model, field, options).optional(); } + } else if (this.isParameterizedComputedField(fieldDef)) { + // a parameterized computed field is included by supplying its query-time `args` + fields[field] = z.strictObject({ args: this.makeFieldArgsSchema(fieldDef.params!) }).optional(); } } diff --git a/tests/e2e/orm/client-api/computed-fields.test.ts b/tests/e2e/orm/client-api/computed-fields.test.ts index 1205e5750..f72c38801 100644 --- a/tests/e2e/orm/client-api/computed-fields.test.ts +++ b/tests/e2e/orm/client-api/computed-fields.test.ts @@ -323,6 +323,141 @@ model Post { ).resolves.toMatchObject([{ id: 2 }, { id: 1 }]); }); + it('works with parameterized computed fields in where (args alongside operators)', async () => { + const db = await createTestClient( + ` +model User { + id Int @id @default(autoincrement()) + name String + posts Post[] + popularPostCount(minViews: Int) Int @computed +} + +model Post { + id Int @id @default(autoincrement()) + viewCount Int @default(0) + author User @relation(fields: [authorId], references: [id]) + authorId Int +} +`, + { + computedFields: { + User: { + popularPostCount: (eb: any, ctx: any, args: any) => + eb + .selectFrom('Post') + .whereRef('Post.authorId', '=', sql.ref(`${ctx.modelAlias}.id`)) + .where('Post.viewCount', '>=', args.minViews) + .select(({ fn }: any) => fn.countAll().as('cnt')), + }, + }, + } as any, + ); + + // Alice: posts [300, 50, 50] → (>=100)=1, (>=40)=3 + // Bob: posts [120, 120, 10] → (>=100)=2, (>=40)=2 + await db.user.create({ + data: { id: 1, name: 'Alice', posts: { create: [{ viewCount: 300 }, { viewCount: 50 }, { viewCount: 50 }] } }, + }); + await db.user.create({ + data: { id: 2, name: 'Bob', posts: { create: [{ viewCount: 120 }, { viewCount: 120 }, { viewCount: 10 }] } }, + }); + + // minViews=100, count >= 2 ⇒ only Bob (Alice=1, Bob=2) + await expect( + db.user.findMany({ + where: { popularPostCount: { args: { minViews: 100 }, gte: 2 } }, + orderBy: { id: 'asc' }, + }), + ).resolves.toMatchObject([{ id: 2 }]); + + // minViews=40, count >= 3 ⇒ only Alice (Alice=3, Bob=2) — a different arg selects a different row + await expect( + db.user.findMany({ + where: { popularPostCount: { args: { minViews: 40 }, gte: 3 } }, + orderBy: { id: 'asc' }, + }), + ).resolves.toMatchObject([{ id: 1 }]); + + // composes with AND and other fields + await expect( + db.user.findMany({ + where: { AND: [{ popularPostCount: { args: { minViews: 100 }, gte: 1 } }, { name: 'Alice' }] }, + }), + ).resolves.toMatchObject([{ id: 1 }]); + + // the bare-value shorthand is rejected — args must be supplied + await expect(db.user.findMany({ where: { popularPostCount: 2 } as any })).toBeRejectedByValidation(); + }); + + it('works with parameterized computed fields in select and include', async () => { + const db = await createTestClient( + ` +model User { + id Int @id @default(autoincrement()) + name String + posts Post[] + popularPostCount(minViews: Int) Int @computed +} + +model Post { + id Int @id @default(autoincrement()) + viewCount Int @default(0) + author User @relation(fields: [authorId], references: [id]) + authorId Int +} +`, + { + computedFields: { + User: { + popularPostCount: (eb: any, ctx: any, args: any) => + eb + .selectFrom('Post') + .whereRef('Post.authorId', '=', sql.ref(`${ctx.modelAlias}.id`)) + .where('Post.viewCount', '>=', args.minViews) + .select(({ fn }: any) => fn.countAll().as('cnt')), + }, + }, + } as any, + ); + + // Alice: posts [300, 50, 50] → (>=100)=1, (>=40)=3 + await db.user.create({ + data: { id: 1, name: 'Alice', posts: { create: [{ viewCount: 300 }, { viewCount: 50 }, { viewCount: 50 }] } }, + }); + + // `select` narrows to the computed value; different args ⇒ different values + await expect( + db.user.findUniqueOrThrow({ + where: { id: 1 }, + select: { id: true, popularPostCount: { args: { minViews: 100 } } }, + }), + ).resolves.toEqual({ id: 1, popularPostCount: 1 }); + await expect( + db.user.findUniqueOrThrow({ + where: { id: 1 }, + select: { popularPostCount: { args: { minViews: 40 } } }, + }), + ).resolves.toEqual({ popularPostCount: 3 }); + + // `include` returns it alongside the auto-selected scalar fields + await expect( + db.user.findUniqueOrThrow({ + where: { id: 1 }, + include: { popularPostCount: { args: { minViews: 100 } } }, + }), + ).resolves.toMatchObject({ id: 1, name: 'Alice', popularPostCount: 1 }); + + // still not auto-returned when no args are supplied + const plain = await db.user.findUniqueOrThrow({ where: { id: 1 } }); + expect(plain).not.toHaveProperty('popularPostCount'); + + // the boolean shorthand is rejected — args must be supplied + await expect( + db.user.findUniqueOrThrow({ where: { id: 1 }, select: { popularPostCount: true } as any }), + ).toBeRejectedByValidation(); + }); + it('excludes parameterized computed fields from contexts that cannot supply args', async () => { const db = await createTestClient( ` @@ -356,11 +491,9 @@ model Post { await db.user.create({ data: { id: 1, name: 'Alice' } }); - // none of these contexts can carry `args`, so a parameterized computed field is rejected - // by input validation (it remains usable only via `orderBy`). `as any` bypasses the - // matching compile-time exclusions in the query input types. - await expect(db.user.findMany({ where: { popularPostCount: 1 } as any })).toBeRejectedByValidation(); - await expect(db.user.findMany({ select: { popularPostCount: true } as any })).toBeRejectedByValidation(); + // these contexts can't carry `args`, so a parameterized computed field is rejected by + // input validation (it's usable via `orderBy`, `where`, `select`/`include`). `as any` + // bypasses the matching compile-time exclusions in the query input types. await expect(db.user.findMany({ distinct: ['popularPostCount'] as any })).toBeRejectedByValidation(); await expect(db.user.findMany({ omit: { popularPostCount: true } as any })).toBeRejectedByValidation(); await expect(db.user.aggregate({ _count: { popularPostCount: true } as any })).toBeRejectedByValidation(); From b13d6a251198c89ce60a4b16cf6db24c9f120b76 Mon Sep 17 00:00:00 2001 From: evgenovalov Date: Mon, 20 Jul 2026 11:27:01 +0200 Subject: [PATCH 06/13] feat: support parameterized computed fields in _count/aggregate/groupBy `_count`, `_sum`, `_avg`, `_min`, `_max` (in aggregate and groupBy) and count's `select` now take `{ field: { args } }` for a parameterized computed field instead of the bare `true`. count/aggregate materialize the field into the `$sub` subquery with its args; groupBy re-inlines it via `fieldRef` (passing the grouped-table alias so a computed field referencing `ctx.modelAlias` resolves). Result types map the keys to numbers as before. Reverses the 1b exclusions on CountAggregateInput / SumAvgInput / MinMaxInput and their zod builders. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/orm/src/client/crud-types.ts | 28 ++++---- .../src/client/crud/operations/aggregate.ts | 26 +++++-- .../orm/src/client/crud/operations/count.ts | 15 +++- .../src/client/crud/operations/group-by.ts | 16 +++-- packages/orm/src/client/zod/factory.ts | 22 +++--- .../orm/client-api/computed-fields.test.ts | 69 +++++++++++++++++-- 6 files changed, 137 insertions(+), 39 deletions(-) diff --git a/packages/orm/src/client/crud-types.ts b/packages/orm/src/client/crud-types.ts index 01db6575e..ddd564cd6 100644 --- a/packages/orm/src/client/crud-types.ts +++ b/packages/orm/src/client/crud-types.ts @@ -2292,10 +2292,10 @@ export type CountArgs< } & ExtractExtQueryArgs; type CountAggregateInput> = { - // a parameterized computed field has no `args` slot in `_count`, so it is excluded - [Key in NonRelationFields as FieldHasComputedArgs extends true - ? never - : Key]?: true; + // a parameterized computed field is counted by supplying its query-time `args` + [Key in NonRelationFields]?: FieldHasComputedArgs extends true + ? { args: ComputedFieldArgs } + : true; } & { _all?: true }; export type CountResult, Args> = Args extends { @@ -2374,15 +2374,17 @@ type NumericFields> = | 'Decimal' ? FieldIsArray extends true ? never - : // a parameterized computed field has no `args` slot in `_sum`/`_avg`, so it is excluded - FieldHasComputedArgs extends true - ? never - : Key + : Key : never]: GetModelField; }; type SumAvgInput, ValueType> = { - [Key in NumericFields]?: ValueType; + // a parameterized computed field is aggregated by supplying its query-time `args` + [Key in NumericFields]?: Key extends GetModelFields + ? FieldHasComputedArgs extends true + ? { args: ComputedFieldArgs } + : ValueType + : ValueType; }; type MinMaxInput, ValueType> = { @@ -2390,10 +2392,10 @@ type MinMaxInput, Valu ? never : FieldIsRelation extends true ? never - : // a parameterized computed field has no `args` slot in `_min`/`_max`, so it is excluded - FieldHasComputedArgs extends true - ? never - : Key]?: ValueType; + : Key]?: // a parameterized computed field is aggregated by supplying its query-time `args` + FieldHasComputedArgs extends true + ? { args: ComputedFieldArgs } + : ValueType; }; export type AggregateResult, Args> = (Args extends { diff --git a/packages/orm/src/client/crud/operations/aggregate.ts b/packages/orm/src/client/crud/operations/aggregate.ts index 035a19d1e..02d6286a3 100644 --- a/packages/orm/src/client/crud/operations/aggregate.ts +++ b/packages/orm/src/client/crud/operations/aggregate.ts @@ -19,22 +19,34 @@ export class AggregateOperationHandler extends BaseOpe .buildSelectModel(this.model, this.model) .where(() => this.dialect.buildFilter(this.model, this.model, parsedArgs?.where)); - // select fields: collect fields from aggregation body + // select fields: collect fields (and any parameterized-computed `args`) from the + // aggregation bodies. The field is materialized once into `$sub`; a parameterized + // computed field carries a `{ args }` value instead of `true`. const selectedFields: string[] = []; + const computedArgsByField: Record = {}; for (const [key, value] of Object.entries(parsedArgs)) { if (key.startsWith('_') && value && typeof value === 'object') { - // select fields Object.entries(value) .filter(([field]) => field !== '_all') - .filter(([, val]) => val === true) - .forEach(([field]) => { + .forEach(([field, val]) => { + const args = val && typeof val === 'object' && 'args' in val ? (val as any).args : undefined; + if (val !== true && args === undefined) { + return; + } if (!selectedFields.includes(field)) selectedFields.push(field); + if (args !== undefined) computedArgsByField[field] = args; }); } } if (selectedFields.length > 0) { for (const field of selectedFields) { - subQuery = this.dialect.buildSelectField(subQuery, this.model, this.model, field); + subQuery = this.dialect.buildSelectField( + subQuery, + this.model, + this.model, + field, + computedArgsByField[field], + ); } } else { // if no field is explicitly selected, just do a `select 1` so `_count` works @@ -72,7 +84,7 @@ export class AggregateOperationHandler extends BaseOpe query = query.select((eb) => this.dialect.castInt(eb.fn.countAll()).as('_count')); } else { Object.entries(value).forEach(([field, val]) => { - if (val === true) { + if (val === true || (val && typeof val === 'object' && 'args' in val)) { if (field === '_all') { query = query.select((eb) => this.dialect.castInt(eb.fn.countAll()).as(`_count._all`), @@ -95,7 +107,7 @@ export class AggregateOperationHandler extends BaseOpe case '_max': case '_min': { Object.entries(value).forEach(([field, val]) => { - if (val === true) { + if (val === true || (val && typeof val === 'object' && 'args' in val)) { query = query.select((eb) => { const fn = match(key) .with('_sum', () => eb.fn.sum) diff --git a/packages/orm/src/client/crud/operations/count.ts b/packages/orm/src/client/crud/operations/count.ts index c70b69f1e..b51d5e9e5 100644 --- a/packages/orm/src/client/crud/operations/count.ts +++ b/packages/orm/src/client/crud/operations/count.ts @@ -18,10 +18,21 @@ export class CountOperationHandler extends BaseOperati .where(() => this.dialect.buildFilter(this.model, this.model, parsedArgs?.where)); if (parsedArgs?.select && typeof parsedArgs.select === 'object') { - // select fields + // select fields (a parameterized computed field carries its query-time `args`) for (const [key, value] of Object.entries(parsedArgs.select)) { - if (key !== '_all' && value === true) { + if (key === '_all') { + continue; + } + if (value === true) { subQuery = this.dialect.buildSelectField(subQuery, this.model, this.model, key); + } else if (value && typeof value === 'object' && 'args' in value) { + subQuery = this.dialect.buildSelectField( + subQuery, + this.model, + this.model, + key, + (value as any).args, + ); } } } else { diff --git a/packages/orm/src/client/crud/operations/group-by.ts b/packages/orm/src/client/crud/operations/group-by.ts index e4ea01439..920d16427 100644 --- a/packages/orm/src/client/crud/operations/group-by.ts +++ b/packages/orm/src/client/crud/operations/group-by.ts @@ -15,7 +15,11 @@ export class GroupByOperationHandler extends BaseOpera .selectFrom(this.model as string) .where(() => this.dialect.buildFilter(this.model, this.model, parsedArgs?.where)); - const fieldRef = (field: string) => this.dialect.fieldRef(this.model, field); + // `computedArgs` is set when aggregating a parameterized computed field; the model name + // is passed as the alias so a computed field that references `ctx.modelAlias` resolves + // against the grouped table + const fieldRef = (field: string, computedArgs?: unknown) => + this.dialect.fieldRef(this.model, field, this.model, true, computedArgs); // groupBy const bys = typeof parsedArgs.by === 'string' ? [parsedArgs.by] : (parsedArgs.by as string[]); @@ -52,14 +56,15 @@ export class GroupByOperationHandler extends BaseOpera query = query.select((eb) => this.dialect.castInt(eb.fn.countAll()).as('_count')); } else { Object.entries(value).forEach(([field, val]) => { - if (val === true) { + const args = val && typeof val === 'object' && 'args' in val ? (val as any).args : undefined; + if (val === true || args !== undefined) { if (field === '_all') { query = query.select((eb) => this.dialect.castInt(eb.fn.countAll()).as(`_count._all`), ); } else { query = query.select((eb) => - this.dialect.castInt(eb.fn.count(fieldRef(field))).as(`${key}.${field}`), + this.dialect.castInt(eb.fn.count(fieldRef(field, args))).as(`${key}.${field}`), ); } } @@ -73,8 +78,9 @@ export class GroupByOperationHandler extends BaseOpera case '_max': case '_min': { Object.entries(value).forEach(([field, val]) => { - if (val === true) { - query = query.select((eb) => aggregate(eb, fieldRef(field), key).as(`${key}.${field}`)); + const args = val && typeof val === 'object' && 'args' in val ? (val as any).args : undefined; + if (val === true || args !== undefined) { + query = query.select((eb) => aggregate(eb, fieldRef(field, args), key).as(`${key}.${field}`)); } }); break; diff --git a/packages/orm/src/client/zod/factory.ts b/packages/orm/src/client/zod/factory.ts index 0d10b8723..6335a3855 100644 --- a/packages/orm/src/client/zod/factory.ts +++ b/packages/orm/src/client/zod/factory.ts @@ -2171,10 +2171,10 @@ export class ZodSchemaFactory< _all: z.literal(true).optional(), ...this.getModelFields(model).reduce( (acc, [field, fieldDef]) => { - // a parameterized computed field has no `args` slot in `_count` - if (!this.isParameterizedComputedField(fieldDef)) { - acc[field] = z.literal(true).optional(); - } + // a parameterized computed field is counted by supplying its `args` + acc[field] = this.isParameterizedComputedField(fieldDef) + ? z.strictObject({ args: this.makeFieldArgsSchema(fieldDef.params!) }).optional() + : z.literal(true).optional(); return acc; }, {} as Record, @@ -2217,8 +2217,11 @@ export class ZodSchemaFactory< const schema = z.strictObject( this.getModelFields(model).reduce( (acc, [field, fieldDef]) => { - if (this.isNumericField(fieldDef) && !this.isParameterizedComputedField(fieldDef)) { - acc[field] = z.literal(true).optional(); + if (this.isNumericField(fieldDef)) { + // a parameterized computed field is aggregated by supplying its `args` + acc[field] = this.isParameterizedComputedField(fieldDef) + ? z.strictObject({ args: this.makeFieldArgsSchema(fieldDef.params!) }).optional() + : z.literal(true).optional(); } return acc; }, @@ -2234,8 +2237,11 @@ export class ZodSchemaFactory< const schema = z.strictObject( this.getModelFields(model).reduce( (acc, [field, fieldDef]) => { - if (!fieldDef.relation && !fieldDef.array && !this.isParameterizedComputedField(fieldDef)) { - acc[field] = z.literal(true).optional(); + if (!fieldDef.relation && !fieldDef.array) { + // a parameterized computed field is aggregated by supplying its `args` + acc[field] = this.isParameterizedComputedField(fieldDef) + ? z.strictObject({ args: this.makeFieldArgsSchema(fieldDef.params!) }).optional() + : z.literal(true).optional(); } return acc; }, diff --git a/tests/e2e/orm/client-api/computed-fields.test.ts b/tests/e2e/orm/client-api/computed-fields.test.ts index f72c38801..97e5351bc 100644 --- a/tests/e2e/orm/client-api/computed-fields.test.ts +++ b/tests/e2e/orm/client-api/computed-fields.test.ts @@ -458,6 +458,69 @@ model Post { ).toBeRejectedByValidation(); }); + it('works with parameterized computed fields in aggregate (_sum/_max/_count)', async () => { + const db = await createTestClient( + ` +model User { + id Int @id @default(autoincrement()) + name String + posts Post[] + popularPostCount(minViews: Int) Int @computed +} + +model Post { + id Int @id @default(autoincrement()) + viewCount Int @default(0) + author User @relation(fields: [authorId], references: [id]) + authorId Int +} +`, + { + computedFields: { + User: { + popularPostCount: (eb: any, ctx: any, args: any) => + eb + .selectFrom('Post') + .whereRef('Post.authorId', '=', sql.ref(`${ctx.modelAlias}.id`)) + .where('Post.viewCount', '>=', args.minViews) + .select(({ fn }: any) => fn.countAll().as('cnt')), + }, + }, + } as any, + ); + + // Alice: [300, 50, 50] → (>=100)=1, (>=40)=3 ; Bob: [120, 120, 10] → (>=100)=2, (>=40)=2 + await db.user.create({ + data: { id: 1, name: 'Alice', posts: { create: [{ viewCount: 300 }, { viewCount: 50 }, { viewCount: 50 }] } }, + }); + await db.user.create({ + data: { id: 2, name: 'Bob', posts: { create: [{ viewCount: 120 }, { viewCount: 120 }, { viewCount: 10 }] } }, + }); + + // minViews=100 ⇒ Alice=1, Bob=2 ⇒ sum=3, max=2, count(non-null)=2 + await expect( + db.user.aggregate({ + _sum: { popularPostCount: { args: { minViews: 100 } } }, + _max: { popularPostCount: { args: { minViews: 100 } } }, + _count: { popularPostCount: { args: { minViews: 100 } } }, + }), + ).resolves.toMatchObject({ + _sum: { popularPostCount: 3 }, + _max: { popularPostCount: 2 }, + _count: { popularPostCount: 2 }, + }); + + // minViews=40 ⇒ Alice=3, Bob=2 ⇒ sum=5 (a different arg produces a different aggregate) + await expect( + db.user.aggregate({ _sum: { popularPostCount: { args: { minViews: 40 } } } }), + ).resolves.toMatchObject({ _sum: { popularPostCount: 5 } }); + + // the bare-`true` shorthand is rejected — args must be supplied + await expect( + db.user.aggregate({ _sum: { popularPostCount: true } as any }), + ).toBeRejectedByValidation(); + }); + it('excludes parameterized computed fields from contexts that cannot supply args', async () => { const db = await createTestClient( ` @@ -492,12 +555,10 @@ model Post { await db.user.create({ data: { id: 1, name: 'Alice' } }); // these contexts can't carry `args`, so a parameterized computed field is rejected by - // input validation (it's usable via `orderBy`, `where`, `select`/`include`). `as any` - // bypasses the matching compile-time exclusions in the query input types. + // input validation (it's usable via `orderBy`, `where`, `select`/`include`, and the + // aggregate inputs). `as any` bypasses the matching compile-time exclusions. await expect(db.user.findMany({ distinct: ['popularPostCount'] as any })).toBeRejectedByValidation(); await expect(db.user.findMany({ omit: { popularPostCount: true } as any })).toBeRejectedByValidation(); - await expect(db.user.aggregate({ _count: { popularPostCount: true } as any })).toBeRejectedByValidation(); - await expect(db.user.aggregate({ _sum: { popularPostCount: true } as any })).toBeRejectedByValidation(); await expect( db.user.groupBy({ by: ['popularPostCount'], _count: true } as any), ).toBeRejectedByValidation(); From 5928e5720a057b789b84b3a9b38b441c4bcf8c3e Mon Sep 17 00:00:00 2001 From: evgenovalov Date: Mon, 20 Jul 2026 11:31:19 +0200 Subject: [PATCH 07/13] feat: support parameterized computed fields in groupBy `by` `by` now accepts a keyed `{ field, args }` entry for a parameterized computed field (alongside plain field names), so groups can be formed on the query-time-parameterized value. The having/orderBy membership refinements normalize `by` entries to field names; the runtime groups and selects via `fieldRef(field, args)`; `GroupByResult` projects the field name out of the keyed entry. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/orm/src/client/crud-types.ts | 21 ++++++- .../src/client/crud/operations/group-by.ts | 15 +++-- packages/orm/src/client/zod/factory.ts | 30 +++++++-- .../orm/client-api/computed-fields.test.ts | 61 ++++++++++++++++++- 4 files changed, 111 insertions(+), 16 deletions(-) diff --git a/packages/orm/src/client/crud-types.ts b/packages/orm/src/client/crud-types.ts index ddd564cd6..d9d1742a9 100644 --- a/packages/orm/src/client/crud-types.ts +++ b/packages/orm/src/client/crud-types.ts @@ -2459,6 +2459,20 @@ type GroupByHaving< out Options extends QueryOptions = QueryOptions, > = Omit, '$expr'>; +// A `groupBy` `by` entry for a parameterized computed field: the field name plus its query-time +// `args` (a plain field name can't carry args). The map is keyed by the parameterized computed +// fields; indexing it yields the union of `{ field, args }` entries (or `never` when there are none). +type ParamComputedByEntryMap> = { + [Key in NonRelationFields as FieldHasComputedArgs extends true + ? Key + : never]: { field: Key; args: ComputedFieldArgs }; +}; +type GroupByComputedByEntry> = + ParamComputedByEntryMap[keyof ParamComputedByEntryMap]; + +// Extracts the grouped field name from a `by` entry (a plain name or a `{ field, args }` object). +type ByFieldName = By extends { field: infer F } ? F : By; + export type GroupByArgs< Schema extends SchemaDef, Model extends GetModels, @@ -2480,7 +2494,10 @@ export type GroupByArgs< */ by: | NonParamComputedNonRelationFields - | NonEmptyArray>; + | GroupByComputedByEntry + | NonEmptyArray< + NonParamComputedNonRelationFields | GroupByComputedByEntry + >; /** * Filter conditions for the grouped records @@ -2532,7 +2549,7 @@ export type GroupByResult< Args extends { by: unknown }, > = Array< { - [Key in NonRelationFields as Key extends ValueOfPotentialTuple + [Key in NonRelationFields as Key extends ByFieldName> ? Key : never]: MapModelFieldType; } & (Args extends { _count: infer Count } diff --git a/packages/orm/src/client/crud/operations/group-by.ts b/packages/orm/src/client/crud/operations/group-by.ts index 920d16427..5ab7b750d 100644 --- a/packages/orm/src/client/crud/operations/group-by.ts +++ b/packages/orm/src/client/crud/operations/group-by.ts @@ -21,9 +21,14 @@ export class GroupByOperationHandler extends BaseOpera const fieldRef = (field: string, computedArgs?: unknown) => this.dialect.fieldRef(this.model, field, this.model, true, computedArgs); - // groupBy - const bys = typeof parsedArgs.by === 'string' ? [parsedArgs.by] : (parsedArgs.by as string[]); - query = query.groupBy(bys.map((by) => fieldRef(by))); + // groupBy — a parameterized computed field is a `{ field, args }` entry; others are names + const rawBys = Array.isArray(parsedArgs.by) ? parsedArgs.by : [parsedArgs.by]; + const byEntries = rawBys.map((by: any) => + typeof by === 'string' + ? { field: by as string, args: undefined as unknown } + : { field: by.field as string, args: by.args as unknown }, + ); + query = query.groupBy(byEntries.map((e) => fieldRef(e.field, e.args))); // skip & take const skip = parsedArgs?.skip; @@ -44,8 +49,8 @@ export class GroupByOperationHandler extends BaseOpera } // select all by fields - for (const by of bys) { - query = query.select(() => fieldRef(by).as(by)); + for (const e of byEntries) { + query = query.select(() => fieldRef(e.field, e.args).as(e.field)); } // aggregations diff --git a/packages/orm/src/client/zod/factory.ts b/packages/orm/src/client/zod/factory.ts index 6335a3855..61d5ebf5e 100644 --- a/packages/orm/src/client/zod/factory.ts +++ b/packages/orm/src/client/zod/factory.ts @@ -2264,10 +2264,26 @@ export class ZodSchemaFactory< const nonRelationFields = this.getModelFields(model) .filter(([, def]) => !def.relation && !this.isParameterizedComputedField(def)) .map(([name]) => name); - const bySchema = - nonRelationFields.length > 0 - ? this.orArray(z.enum(nonRelationFields as [string, ...string[]]), true) - : z.never(); + const byMembers: ZodType[] = []; + if (nonRelationFields.length > 0) { + byMembers.push(z.enum(nonRelationFields as [string, ...string[]])); + } + // a parameterized computed field is grouped by a `{ field, args }` entry (it can't be + // named on its own — it needs query-time args) + for (const [field, fieldDef] of this.getModelFields(model)) { + if (this.isParameterizedComputedField(fieldDef)) { + byMembers.push( + z.strictObject({ field: z.literal(field), args: this.makeFieldArgsSchema(fieldDef.params!) }), + ); + } + } + const byElement = + byMembers.length === 0 + ? undefined + : byMembers.length === 1 + ? byMembers[0]! + : z.union(byMembers as [ZodType, ZodType, ...ZodType[]]); + const bySchema = byElement ? this.orArray(byElement, true) : z.never(); const baseSchema = z.strictObject({ where: this.makeWhereSchema(model, false, false, false, options).optional(), @@ -2287,7 +2303,8 @@ export class ZodSchemaFactory< // fields used in `having` must be either in the `by` list, or aggregations schema = schema.refine((value: any) => { - const bys = enumerate(value.by); + // normalize `by` entries to field names (a parameterized computed field is a `{ field, args }` entry) + const bys = enumerate(value.by).map((b: any) => (typeof b === 'string' ? b : b?.field)); if (value.having && typeof value.having === 'object') { for (const [key, val] of Object.entries(value.having)) { if (AggregateOperators.includes(key as any)) { @@ -2314,7 +2331,8 @@ export class ZodSchemaFactory< // fields used in `orderBy` must be either in the `by` list, or aggregations schema = schema.refine((value: any) => { - const bys = enumerate(value.by); + // normalize `by` entries to field names (a parameterized computed field is a `{ field, args }` entry) + const bys = enumerate(value.by).map((b: any) => (typeof b === 'string' ? b : b?.field)); for (const orderBy of enumerate(value.orderBy)) { if ( orderBy && diff --git a/tests/e2e/orm/client-api/computed-fields.test.ts b/tests/e2e/orm/client-api/computed-fields.test.ts index 97e5351bc..6e41bba1b 100644 --- a/tests/e2e/orm/client-api/computed-fields.test.ts +++ b/tests/e2e/orm/client-api/computed-fields.test.ts @@ -521,6 +521,60 @@ model Post { ).toBeRejectedByValidation(); }); + it('works with parameterized computed fields in groupBy by (keyed entry)', async () => { + const db = await createTestClient( + ` +model User { + id Int @id @default(autoincrement()) + name String + posts Post[] + popularPostCount(minViews: Int) Int @computed +} + +model Post { + id Int @id @default(autoincrement()) + viewCount Int @default(0) + author User @relation(fields: [authorId], references: [id]) + authorId Int +} +`, + { + computedFields: { + User: { + popularPostCount: (eb: any, ctx: any, args: any) => + eb + .selectFrom('Post') + .whereRef('Post.authorId', '=', sql.ref(`${ctx.modelAlias}.id`)) + .where('Post.viewCount', '>=', args.minViews) + .select(({ fn }: any) => fn.countAll().as('cnt')), + }, + }, + } as any, + ); + + // popularPostCount(minViews=100): Alice=1, Bob=2, Carol=1, Dave=0 + await db.user.create({ + data: { id: 1, name: 'Alice', posts: { create: [{ viewCount: 300 }, { viewCount: 50 }, { viewCount: 50 }] } }, + }); + await db.user.create({ + data: { id: 2, name: 'Bob', posts: { create: [{ viewCount: 120 }, { viewCount: 120 }, { viewCount: 10 }] } }, + }); + await db.user.create({ data: { id: 3, name: 'Carol', posts: { create: [{ viewCount: 200 }] } } }); + await db.user.create({ data: { id: 4, name: 'Dave' } }); + + // group by the computed value: buckets 0→{Dave}, 1→{Alice,Carol}, 2→{Bob} + const groups = await db.user.groupBy({ + by: [{ field: 'popularPostCount', args: { minViews: 100 } }], + _count: { _all: true }, + orderBy: { popularPostCount: { args: { minViews: 100 }, sort: 'asc' } }, + }); + expect(groups).toEqual([ + { popularPostCount: 0, _count: { _all: 1 } }, + { popularPostCount: 1, _count: { _all: 2 } }, + { popularPostCount: 2, _count: { _all: 1 } }, + ]); + }); + it('excludes parameterized computed fields from contexts that cannot supply args', async () => { const db = await createTestClient( ` @@ -554,11 +608,12 @@ model Post { await db.user.create({ data: { id: 1, name: 'Alice' } }); - // these contexts can't carry `args`, so a parameterized computed field is rejected by - // input validation (it's usable via `orderBy`, `where`, `select`/`include`, and the - // aggregate inputs). `as any` bypasses the matching compile-time exclusions. + // `distinct` and `omit` have no `args` slot, so a parameterized computed field is rejected + // by input validation there (it's usable via orderBy, where, select/include, the aggregate + // inputs, and groupBy `by`). `as any` bypasses the matching compile-time exclusions. await expect(db.user.findMany({ distinct: ['popularPostCount'] as any })).toBeRejectedByValidation(); await expect(db.user.findMany({ omit: { popularPostCount: true } as any })).toBeRejectedByValidation(); + // groupBy `by` requires the keyed `{ field, args }` entry — the bare name is rejected await expect( db.user.groupBy({ by: ['popularPostCount'], _count: true } as any), ).toBeRejectedByValidation(); From fffbe7754d4dea9dd97762e12850511a17476b48 Mon Sep 17 00:00:00 2001 From: evgenovalov Date: Mon, 20 Jul 2026 11:54:29 +0200 Subject: [PATCH 08/13] feat: support parameterized computed fields in nested include; fix groupBy on Postgres - nested include/select: a parameterized computed field on a related model is now inlined with its args in both dialect JSON assemblers (sqlite + lateral-join), and excluded from the relation select-all (never auto-returned). Types/zod already recursed via 2b. - groupBy `by` a computed field: group by the SELECT output alias instead of re-inlining, so GROUP BY and the projected expression stay identical (Postgres treats a re-inlined parameterized computer as a distinct expression). Grouping by a correlated-subquery computed field remains subject to the DB's own correlated-GROUP-BY rules. - tests: nested include/select cases; groupBy uses a row-local computed field; computed-field count expressions cast to integer so results are numbers on Postgres (bigint) too. Verified on both SQLite and Postgres (19 tests). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../dialects/lateral-join-dialect-base.ts | 17 ++- .../orm/src/client/crud/dialects/sqlite.ts | 31 +++++ .../src/client/crud/operations/group-by.ts | 11 +- .../orm/client-api/computed-fields.test.ts | 118 +++++++++++++----- 4 files changed, 145 insertions(+), 32 deletions(-) diff --git a/packages/orm/src/client/crud/dialects/lateral-join-dialect-base.ts b/packages/orm/src/client/crud/dialects/lateral-join-dialect-base.ts index cb5035d40..efe0ae3fc 100644 --- a/packages/orm/src/client/crud/dialects/lateral-join-dialect-base.ts +++ b/packages/orm/src/client/crud/dialects/lateral-join-dialect-base.ts @@ -261,6 +261,8 @@ export abstract class LateralJoinDialectBase extends B objArgs, ...Object.entries(relationModelDef.fields) .filter(([, value]) => !value.relation) + // a parameterized computed field is never auto-returned (it needs args) + .filter(([, value]) => !(value.computed && value.params)) .filter(([name]) => !this.shouldOmitField(omit, relationModel, name)) .map(([field]) => ({ [field]: this.fieldRef(relationModel, field, relationModelAlias, false), @@ -286,8 +288,12 @@ export abstract class LateralJoinDialectBase extends B const fieldValue = fieldDef.relation ? // reference the synthesized JSON field eb.ref(`${parentResultName}$${field}.$data`) - : // reference a plain field - this.fieldRef(relationModel, field, relationModelAlias, false); + : fieldDef.computed && fieldDef.params + ? // parameterized computed field: inline the computer with its + // query-time args (correlates to the relation's real columns) + this.fieldRef(relationModel, field, relationModelAlias, true, (value as any).args) + : // reference a plain field + this.fieldRef(relationModel, field, relationModelAlias, false); return { [field]: fieldValue }; } }), @@ -312,6 +318,13 @@ export abstract class LateralJoinDialectBase extends B ), }; } + const fieldDef = requireField(this.schema, relationModel, field); + if (fieldDef.computed && fieldDef.params) { + // parameterized computed field included on the relation: inline with args + return { + [field]: this.fieldRef(relationModel, field, relationModelAlias, true, (value as any).args), + }; + } return { [field]: eb.ref(`${parentResultName}$${field}.$data`) }; }), ); diff --git a/packages/orm/src/client/crud/dialects/sqlite.ts b/packages/orm/src/client/crud/dialects/sqlite.ts index 52a0a315d..00927f510 100644 --- a/packages/orm/src/client/crud/dialects/sqlite.ts +++ b/packages/orm/src/client/crud/dialects/sqlite.ts @@ -266,6 +266,8 @@ export class SqliteCrudDialect extends BaseCrudDialect objArgs.push( ...Object.entries(relationModelDef.fields) .filter(([, value]) => !value.relation) + // a parameterized computed field is never auto-returned (it needs args) + .filter(([, value]) => !(value.computed && value.params)) .filter(([name]) => !this.shouldOmitField(omit, relationModel, name)) .map(([field]) => [sql.lit(field), this.fieldRef(relationModel, field, subQueryName, false)]) .flatMap((v) => v), @@ -295,6 +297,20 @@ export class SqliteCrudDialect extends BaseCrudDialect value, ); return [sql.lit(field), subJson]; + } else if (fieldDef.computed && fieldDef.params) { + // parameterized computed field on the relation: inline the + // computer with its query-time args (correlates to the + // relation's real columns, which are materialized) + return [ + sql.lit(field), + this.fieldRef( + relationModel, + field, + subQueryName, + true, + (value as any).args, + ) as ArgsType, + ]; } else { return [ sql.lit(field), @@ -326,6 +342,21 @@ export class SqliteCrudDialect extends BaseCrudDialect ); return [sql.lit(field), subJson]; } + const fieldDef = requireField(this.schema, relationModel, field); + if (fieldDef.computed && fieldDef.params) { + // parameterized computed field included on the relation: inline + // the computer with its query-time args + return [ + sql.lit(field), + this.fieldRef( + relationModel, + field, + subQueryName, + true, + (value as any).args, + ) as ArgsType, + ]; + } const subJson = this.buildRelationJSON( relationModel, eb, diff --git a/packages/orm/src/client/crud/operations/group-by.ts b/packages/orm/src/client/crud/operations/group-by.ts index 5ab7b750d..23d77185b 100644 --- a/packages/orm/src/client/crud/operations/group-by.ts +++ b/packages/orm/src/client/crud/operations/group-by.ts @@ -1,4 +1,5 @@ import type { SchemaDef } from '@zenstackhq/schema'; +import { sql } from 'kysely'; import { match } from 'ts-pattern'; import { aggregate, getField } from '../../query-utils'; import { BaseOperationHandler } from './base'; @@ -28,7 +29,15 @@ export class GroupByOperationHandler extends BaseOpera ? { field: by as string, args: undefined as unknown } : { field: by.field as string, args: by.args as unknown }, ); - query = query.groupBy(byEntries.map((e) => fieldRef(e.field, e.args))); + query = query.groupBy( + byEntries.map((e) => { + const fieldDef = getField(this.schema, this.model, e.field); + // group a computed field by its SELECT output alias so GROUP BY and the projected + // expression stay identical (re-inlining a parameterized computer binds its args as + // separate placeholders, which Postgres treats as distinct expressions) + return fieldDef?.computed ? sql.ref(e.field) : fieldRef(e.field, e.args); + }), + ); // skip & take const skip = parsedArgs?.skip; diff --git a/tests/e2e/orm/client-api/computed-fields.test.ts b/tests/e2e/orm/client-api/computed-fields.test.ts index 6e41bba1b..8eaa8562b 100644 --- a/tests/e2e/orm/client-api/computed-fields.test.ts +++ b/tests/e2e/orm/client-api/computed-fields.test.ts @@ -206,7 +206,9 @@ model Post { .selectFrom('Post') .whereRef('Post.authorId', '=', sql.ref(`${ctx.modelAlias}.id`)) .where('Post.viewCount', '>=', args.minViews) - .select(({ fn }: any) => fn.countAll().as('cnt')), + // cast so the count comes back as a number on all dialects + // (postgres `count(*)` is bigint → otherwise a string) + .select(() => sql`cast(count(*) as integer)`.as('cnt')), }, }, } as any, @@ -284,7 +286,9 @@ model Post { .selectFrom('Post') .whereRef('Post.authorId', '=', sql.ref(`${ctx.modelAlias}.id`)) .where('Post.createdAt', '>=', args.since.toISOString()) - .select(({ fn }: any) => fn.countAll().as('cnt')), + // cast so the count comes back as a number on all dialects + // (postgres `count(*)` is bigint → otherwise a string) + .select(() => sql`cast(count(*) as integer)`.as('cnt')), }, }, } as any, @@ -348,7 +352,9 @@ model Post { .selectFrom('Post') .whereRef('Post.authorId', '=', sql.ref(`${ctx.modelAlias}.id`)) .where('Post.viewCount', '>=', args.minViews) - .select(({ fn }: any) => fn.countAll().as('cnt')), + // cast so the count comes back as a number on all dialects + // (postgres `count(*)` is bigint → otherwise a string) + .select(() => sql`cast(count(*) as integer)`.as('cnt')), }, }, } as any, @@ -415,7 +421,9 @@ model Post { .selectFrom('Post') .whereRef('Post.authorId', '=', sql.ref(`${ctx.modelAlias}.id`)) .where('Post.viewCount', '>=', args.minViews) - .select(({ fn }: any) => fn.countAll().as('cnt')), + // cast so the count comes back as a number on all dialects + // (postgres `count(*)` is bigint → otherwise a string) + .select(() => sql`cast(count(*) as integer)`.as('cnt')), }, }, } as any, @@ -483,7 +491,9 @@ model Post { .selectFrom('Post') .whereRef('Post.authorId', '=', sql.ref(`${ctx.modelAlias}.id`)) .where('Post.viewCount', '>=', args.minViews) - .select(({ fn }: any) => fn.countAll().as('cnt')), + // cast so the count comes back as a number on all dialects + // (postgres `count(*)` is bigint → otherwise a string) + .select(() => sql`cast(count(*) as integer)`.as('cnt')), }, }, } as any, @@ -522,13 +532,53 @@ model Post { }); it('works with parameterized computed fields in groupBy by (keyed entry)', async () => { + // NOTE: grouping by a *row-local* parameterized computed field works on all dialects. + // A computed field backed by a *correlated subquery* is subject to the database's own + // rules for grouping by a correlated expression (Postgres rejects it, SQLite allows it) — + // that constraint is orthogonal to this feature and applies to any correlated GROUP BY. + const db = await createTestClient( + ` +model Product { + id Int @id @default(autoincrement()) + price Int + priceTier(threshold: Int) Int @computed +} +`, + { + computedFields: { + Product: { + // price tier via a row-local CASE expression (deterministic integer, no + // float-division / cast-rounding differences across dialects) + priceTier: (_eb: any, ctx: any, args: any) => + sql`case when ${sql.ref(`${ctx.modelAlias}.price`)} >= ${args.threshold} then 1 else 0 end`, + }, + }, + } as any, + ); + + // prices [10, 25, 30, 55]; priceTier(threshold=30) = [0, 0, 1, 1] + await db.product.createMany({ + data: [{ price: 10 }, { price: 25 }, { price: 30 }, { price: 55 }], + }); + + // group by the computed tier: 0→{10,25}, 1→{30,55} + const groups = await db.product.groupBy({ + by: [{ field: 'priceTier', args: { threshold: 30 } }], + _count: { _all: true }, + }); + expect(groups.sort((a: any, b: any) => a.priceTier - b.priceTier)).toEqual([ + { priceTier: 0, _count: { _all: 2 } }, + { priceTier: 1, _count: { _all: 2 } }, + ]); + }); + + it('works with parameterized computed fields in a nested include/select', async () => { const db = await createTestClient( ` model User { id Int @id @default(autoincrement()) name String posts Post[] - popularPostCount(minViews: Int) Int @computed } model Post { @@ -536,42 +586,50 @@ model Post { viewCount Int @default(0) author User @relation(fields: [authorId], references: [id]) authorId Int + weightedViews(factor: Int) Int @computed } `, { computedFields: { - User: { - popularPostCount: (eb: any, ctx: any, args: any) => - eb - .selectFrom('Post') - .whereRef('Post.authorId', '=', sql.ref(`${ctx.modelAlias}.id`)) - .where('Post.viewCount', '>=', args.minViews) - .select(({ fn }: any) => fn.countAll().as('cnt')), + Post: { + // viewCount * factor, correlated to the row via ctx.modelAlias + weightedViews: (_eb: any, ctx: any, args: any) => + sql`${sql.ref(`${ctx.modelAlias}.viewCount`)} * ${args.factor}`, }, }, } as any, ); - // popularPostCount(minViews=100): Alice=1, Bob=2, Carol=1, Dave=0 await db.user.create({ - data: { id: 1, name: 'Alice', posts: { create: [{ viewCount: 300 }, { viewCount: 50 }, { viewCount: 50 }] } }, + data: { + id: 1, + name: 'Alice', + posts: { create: [{ id: 1, viewCount: 300 }, { id: 2, viewCount: 50 }] }, + }, }); - await db.user.create({ - data: { id: 2, name: 'Bob', posts: { create: [{ viewCount: 120 }, { viewCount: 120 }, { viewCount: 10 }] } }, + + // nested `include` of a parameterized computed field on the related model + const u1 = await db.user.findFirstOrThrow({ + where: { id: 1 }, + include: { posts: { include: { weightedViews: { args: { factor: 10 } } }, orderBy: { id: 'asc' } } }, }); - await db.user.create({ data: { id: 3, name: 'Carol', posts: { create: [{ viewCount: 200 }] } } }); - await db.user.create({ data: { id: 4, name: 'Dave' } }); + expect(u1.posts.map((p: any) => p.weightedViews)).toEqual([3000, 500]); - // group by the computed value: buckets 0→{Dave}, 1→{Alice,Carol}, 2→{Bob} - const groups = await db.user.groupBy({ - by: [{ field: 'popularPostCount', args: { minViews: 100 } }], - _count: { _all: true }, - orderBy: { popularPostCount: { args: { minViews: 100 }, sort: 'asc' } }, + // a different arg yields different nested values + const u2 = await db.user.findFirstOrThrow({ + where: { id: 1 }, + include: { posts: { include: { weightedViews: { args: { factor: 2 } } }, orderBy: { id: 'asc' } } }, + }); + expect(u2.posts.map((p: any) => p.weightedViews)).toEqual([600, 100]); + + // nested `select` narrows to the computed value + const u3 = await db.user.findFirstOrThrow({ + where: { id: 1 }, + include: { posts: { select: { id: true, weightedViews: { args: { factor: 2 } } }, orderBy: { id: 'asc' } } }, }); - expect(groups).toEqual([ - { popularPostCount: 0, _count: { _all: 1 } }, - { popularPostCount: 1, _count: { _all: 2 } }, - { popularPostCount: 2, _count: { _all: 1 } }, + expect(u3.posts).toEqual([ + { id: 1, weightedViews: 600 }, + { id: 2, weightedViews: 100 }, ]); }); @@ -600,7 +658,9 @@ model Post { .selectFrom('Post') .whereRef('Post.authorId', '=', sql.ref(`${ctx.modelAlias}.id`)) .where('Post.viewCount', '>=', args.minViews) - .select(({ fn }: any) => fn.countAll().as('cnt')), + // cast so the count comes back as a number on all dialects + // (postgres `count(*)` is bigint → otherwise a string) + .select(() => sql`cast(count(*) as integer)`.as('cnt')), }, }, } as any, From 1499adb0b988ec08f24c4a65a51e1c2f5c2b7297 Mon Sep 17 00:00:00 2001 From: evgenovalov Date: Mon, 20 Jul 2026 12:48:30 +0200 Subject: [PATCH 09/13] fix: preserve excess-property checking on include/select inputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The parameterized-computed-field entry in `IncludeInput` was added as a separate intersection member, which disabled excess-property checking on `include`/`select` literals — invalid keys (a sliced-out relation, or `_count` on a model with no to-many relations) stopped producing type errors. Fold it into the single relations mapped type so excess-property checking is preserved. Fixes the tests/e2e `tsc` build (TS2578 unused '@ts-expect-error' in find.test.ts / slicing.test.ts). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/orm/src/client/crud-types.ts | 55 ++++++++++++++------------- 1 file changed, 28 insertions(+), 27 deletions(-) diff --git a/packages/orm/src/client/crud-types.ts b/packages/orm/src/client/crud-types.ts index d9d1742a9..328a2bda2 100644 --- a/packages/orm/src/client/crud-types.ts +++ b/packages/orm/src/client/crud-types.ts @@ -1360,33 +1360,34 @@ export type IncludeInput< AllowCount extends boolean = true, ExtResult extends ExtResultBase = {}, > = { - [Key in RelationFields as RelationFieldType extends GetSlicedModels< - Schema, - Options - > - ? Key - : never]?: - | boolean - | FindArgs< - Schema, - RelationFieldType, - Options, - FieldIsArray, - // where clause is allowed only if the relation is array or optional - FieldIsArray extends true - ? true - : ModelFieldIsOptional extends true - ? true - : false, - ExtResult - >; -} & { - // a parameterized computed field can be selected by supplying its query-time `args` (mirrors - // the relation per-key-object shape). Because `SelectInput` intersects `IncludeInput`, this - // also makes the field selectable via `select: { field: { args } }`. - [Key in NonRelationFields as FieldHasComputedArgs extends true - ? Key - : never]?: { args: ComputedFieldArgs }; + // A single mapped type over relations + parameterized computed fields. Keeping this as one + // object member (rather than intersecting a separate mapped type) preserves excess-property + // checking on `include`/`select` literals. A parameterized computed field is selectable by + // supplying its query-time `args` (mirrors the relation per-key-object shape); because + // `SelectInput` intersects `IncludeInput`, this also enables `select: { field: { args } }`. + [Key in GetModelFields as Key extends RelationFields + ? RelationFieldType extends GetSlicedModels + ? Key + : never + : FieldHasComputedArgs extends true + ? Key + : never]?: Key extends RelationFields + ? + | boolean + | FindArgs< + Schema, + RelationFieldType, + Options, + FieldIsArray, + // where clause is allowed only if the relation is array or optional + FieldIsArray extends true + ? true + : ModelFieldIsOptional extends true + ? true + : false, + ExtResult + > + : { args: ComputedFieldArgs }; } & (AllowCount extends true ? // _count is only allowed if the model has to-many relations HasToManyRelations extends true From caf88e5b0557864db2178c8dab2b1edc3b6231ed Mon Sep 17 00:00:00 2001 From: evgenovalov Date: Mon, 20 Jul 2026 12:48:31 +0200 Subject: [PATCH 10/13] fix: reject conflicting aggregate args; broaden params doc comment Addresses review feedback on #2762: - aggregate: a computed field is materialized once into `$sub`, so aggregating the same field with different `args` in one query would silently use whichever `args` was seen last. Throw an input-validation error instead, with a regression test. - schema: `FieldDef.params` doc no longer says the args are `orderBy`-only. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/client/crud/operations/aggregate.ts | 18 +++++++++++++++++- packages/schema/src/schema.ts | 6 +++--- .../e2e/orm/client-api/computed-fields.test.ts | 8 ++++++++ 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/packages/orm/src/client/crud/operations/aggregate.ts b/packages/orm/src/client/crud/operations/aggregate.ts index 02d6286a3..9eabfa2ca 100644 --- a/packages/orm/src/client/crud/operations/aggregate.ts +++ b/packages/orm/src/client/crud/operations/aggregate.ts @@ -1,8 +1,12 @@ import type { SchemaDef } from '@zenstackhq/schema'; import { match } from 'ts-pattern'; +import { createInvalidInputError } from '../../errors'; import { getField } from '../../query-utils'; import { BaseOperationHandler } from './base'; +// stable key for comparing two `args` payloads (bigint-safe) +const argsKey = (v: unknown) => JSON.stringify(v, (_k, val) => (typeof val === 'bigint' ? `${val}n` : val)); + export class AggregateOperationHandler extends BaseOperationHandler { async handle(_operation: 'aggregate', args: unknown | undefined) { // normalize args to strip `undefined` fields @@ -34,7 +38,19 @@ export class AggregateOperationHandler extends BaseOpe return; } if (!selectedFields.includes(field)) selectedFields.push(field); - if (args !== undefined) computedArgsByField[field] = args; + if (args !== undefined) { + // the field is materialized once into `$sub`, so every aggregation + // of it must agree on `args` — otherwise the result would silently + // use whichever `args` was seen last + const prev = computedArgsByField[field]; + if (prev !== undefined && argsKey(prev) !== argsKey(args)) { + throw createInvalidInputError( + `computed field "${field}" is aggregated with conflicting "args"; ` + + `the same field can only be aggregated with one set of arguments per query`, + ); + } + computedArgsByField[field] = args; + } }); } } diff --git a/packages/schema/src/schema.ts b/packages/schema/src/schema.ts index d00c5ab4f..62e892203 100644 --- a/packages/schema/src/schema.ts +++ b/packages/schema/src/schema.ts @@ -83,9 +83,9 @@ export type FieldDef = { foreignKeyFor?: readonly string[]; computed?: boolean; /** - * For a parameterized computed field, the parameters it declares (keyed by name). - * The corresponding arguments are supplied at query time when the field is used in - * `orderBy`. + * For a parameterized computed field, the parameters it declares (keyed by name). The + * corresponding arguments are supplied at query time wherever the field is used — `orderBy`, + * `where`/`having`, `select`/`include`, the aggregate inputs, and `groupBy`. */ params?: Record; originModel?: string; diff --git a/tests/e2e/orm/client-api/computed-fields.test.ts b/tests/e2e/orm/client-api/computed-fields.test.ts index 8eaa8562b..36db190c6 100644 --- a/tests/e2e/orm/client-api/computed-fields.test.ts +++ b/tests/e2e/orm/client-api/computed-fields.test.ts @@ -529,6 +529,14 @@ model Post { await expect( db.user.aggregate({ _sum: { popularPostCount: true } as any }), ).toBeRejectedByValidation(); + + // the field is materialized once, so aggregating it with conflicting args is rejected + await expect( + db.user.aggregate({ + _sum: { popularPostCount: { args: { minViews: 100 } } }, + _avg: { popularPostCount: { args: { minViews: 40 } } }, + }), + ).rejects.toThrow(/conflicting "args"/); }); it('works with parameterized computed fields in groupBy by (keyed entry)', async () => { From 1f23370ce6da6aecc56a9e3aedc14da394084582 Mon Sep 17 00:00:00 2001 From: evgenovalov Date: Mon, 20 Jul 2026 15:23:49 +0200 Subject: [PATCH 11/13] fix(test): use portable SQL in computed-field tests (MySQL CI) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `cast(count(*) as integer)` is invalid MySQL syntax (MySQL uses SIGNED, and no CAST target works across sqlite/pg/mysql), which broke the count-based computed-field tests on the MySQL CI job. Revert to plain `count(*)` for the tests that don't assert the raw count (they sort/filter/aggregate, and aggregate post-processes to a number). The select/include test — the only one asserting a returned computed value — now uses a row-local arithmetic field (`price * factor`), which returns a plain integer on every dialect (no bigint string, no cast). Verified: computed-fields 19/19 and the full orm/client-api suite green on SQLite, PostgreSQL, and MySQL. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../orm/client-api/computed-fields.test.ts | 76 +++++++------------ 1 file changed, 26 insertions(+), 50 deletions(-) diff --git a/tests/e2e/orm/client-api/computed-fields.test.ts b/tests/e2e/orm/client-api/computed-fields.test.ts index 36db190c6..30ff361e7 100644 --- a/tests/e2e/orm/client-api/computed-fields.test.ts +++ b/tests/e2e/orm/client-api/computed-fields.test.ts @@ -206,9 +206,7 @@ model Post { .selectFrom('Post') .whereRef('Post.authorId', '=', sql.ref(`${ctx.modelAlias}.id`)) .where('Post.viewCount', '>=', args.minViews) - // cast so the count comes back as a number on all dialects - // (postgres `count(*)` is bigint → otherwise a string) - .select(() => sql`cast(count(*) as integer)`.as('cnt')), + .select(({ fn }: any) => fn.countAll().as('cnt')), }, }, } as any, @@ -286,9 +284,7 @@ model Post { .selectFrom('Post') .whereRef('Post.authorId', '=', sql.ref(`${ctx.modelAlias}.id`)) .where('Post.createdAt', '>=', args.since.toISOString()) - // cast so the count comes back as a number on all dialects - // (postgres `count(*)` is bigint → otherwise a string) - .select(() => sql`cast(count(*) as integer)`.as('cnt')), + .select(({ fn }: any) => fn.countAll().as('cnt')), }, }, } as any, @@ -352,9 +348,7 @@ model Post { .selectFrom('Post') .whereRef('Post.authorId', '=', sql.ref(`${ctx.modelAlias}.id`)) .where('Post.viewCount', '>=', args.minViews) - // cast so the count comes back as a number on all dialects - // (postgres `count(*)` is bigint → otherwise a string) - .select(() => sql`cast(count(*) as integer)`.as('cnt')), + .select(({ fn }: any) => fn.countAll().as('cnt')), }, }, } as any, @@ -399,70 +393,56 @@ model Post { it('works with parameterized computed fields in select and include', async () => { const db = await createTestClient( ` -model User { +model Product { id Int @id @default(autoincrement()) name String - posts Post[] - popularPostCount(minViews: Int) Int @computed -} - -model Post { - id Int @id @default(autoincrement()) - viewCount Int @default(0) - author User @relation(fields: [authorId], references: [id]) - authorId Int + price Int + priceTimes(factor: Int) Int @computed } `, { computedFields: { - User: { - popularPostCount: (eb: any, ctx: any, args: any) => - eb - .selectFrom('Post') - .whereRef('Post.authorId', '=', sql.ref(`${ctx.modelAlias}.id`)) - .where('Post.viewCount', '>=', args.minViews) - // cast so the count comes back as a number on all dialects - // (postgres `count(*)` is bigint → otherwise a string) - .select(() => sql`cast(count(*) as integer)`.as('cnt')), + Product: { + // a row-local arithmetic expression (price * factor); returns a plain integer + // on every dialect, so the selected value is a number (not a bigint string) + priceTimes: (_eb: any, ctx: any, args: any) => + sql`${sql.ref(`${ctx.modelAlias}.price`)} * ${args.factor}`, }, }, } as any, ); - // Alice: posts [300, 50, 50] → (>=100)=1, (>=40)=3 - await db.user.create({ - data: { id: 1, name: 'Alice', posts: { create: [{ viewCount: 300 }, { viewCount: 50 }, { viewCount: 50 }] } }, - }); + await db.product.create({ data: { id: 1, name: 'Widget', price: 100 } }); // `select` narrows to the computed value; different args ⇒ different values await expect( - db.user.findUniqueOrThrow({ + db.product.findUniqueOrThrow({ where: { id: 1 }, - select: { id: true, popularPostCount: { args: { minViews: 100 } } }, + select: { id: true, priceTimes: { args: { factor: 2 } } }, }), - ).resolves.toEqual({ id: 1, popularPostCount: 1 }); + ).resolves.toEqual({ id: 1, priceTimes: 200 }); await expect( - db.user.findUniqueOrThrow({ + db.product.findUniqueOrThrow({ where: { id: 1 }, - select: { popularPostCount: { args: { minViews: 40 } } }, + select: { priceTimes: { args: { factor: 3 } } }, }), - ).resolves.toEqual({ popularPostCount: 3 }); + ).resolves.toEqual({ priceTimes: 300 }); // `include` returns it alongside the auto-selected scalar fields await expect( - db.user.findUniqueOrThrow({ + db.product.findUniqueOrThrow({ where: { id: 1 }, - include: { popularPostCount: { args: { minViews: 100 } } }, + include: { priceTimes: { args: { factor: 2 } } }, }), - ).resolves.toMatchObject({ id: 1, name: 'Alice', popularPostCount: 1 }); + ).resolves.toMatchObject({ id: 1, name: 'Widget', price: 100, priceTimes: 200 }); // still not auto-returned when no args are supplied - const plain = await db.user.findUniqueOrThrow({ where: { id: 1 } }); - expect(plain).not.toHaveProperty('popularPostCount'); + const plain = await db.product.findUniqueOrThrow({ where: { id: 1 } }); + expect(plain).not.toHaveProperty('priceTimes'); // the boolean shorthand is rejected — args must be supplied await expect( - db.user.findUniqueOrThrow({ where: { id: 1 }, select: { popularPostCount: true } as any }), + db.product.findUniqueOrThrow({ where: { id: 1 }, select: { priceTimes: true } as any }), ).toBeRejectedByValidation(); }); @@ -491,9 +471,7 @@ model Post { .selectFrom('Post') .whereRef('Post.authorId', '=', sql.ref(`${ctx.modelAlias}.id`)) .where('Post.viewCount', '>=', args.minViews) - // cast so the count comes back as a number on all dialects - // (postgres `count(*)` is bigint → otherwise a string) - .select(() => sql`cast(count(*) as integer)`.as('cnt')), + .select(({ fn }: any) => fn.countAll().as('cnt')), }, }, } as any, @@ -666,9 +644,7 @@ model Post { .selectFrom('Post') .whereRef('Post.authorId', '=', sql.ref(`${ctx.modelAlias}.id`)) .where('Post.viewCount', '>=', args.minViews) - // cast so the count comes back as a number on all dialects - // (postgres `count(*)` is bigint → otherwise a string) - .select(() => sql`cast(count(*) as integer)`.as('cnt')), + .select(({ fn }: any) => fn.countAll().as('cnt')), }, }, } as any, From b66166069917402918a338676bfa1da5cb83a006 Mon Sep 17 00:00:00 2001 From: evgenovalov Date: Wed, 22 Jul 2026 10:18:51 +0200 Subject: [PATCH 12/13] refactor: use NonParamComputedNonRelationFields in select/result types Replace inline "exclude parameterized computed field" mapped-type exclusions with the named NonParamComputedNonRelationFields type in SelectInput and FlatModelResult, per PR review feedback. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/orm/src/client/crud-types.ts | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/packages/orm/src/client/crud-types.ts b/packages/orm/src/client/crud-types.ts index 328a2bda2..f4ceb4515 100644 --- a/packages/orm/src/client/crud-types.ts +++ b/packages/orm/src/client/crud-types.ts @@ -141,13 +141,17 @@ type FlatModelResult< Omit, Options extends QueryOptions, > = { - [Key in NonRelationFields as ShouldOmitField extends true + // parameterized computed fields require query-time args, so they are not + // auto-returned (only usable in `orderBy`) + [Key in NonParamComputedNonRelationFields as ShouldOmitField< + Schema, + Model, + Options, + Key, + Omit + > extends true ? never - : // parameterized computed fields require query-time args, so they are not - // auto-returned (only usable in `orderBy`) - FieldHasComputedArgs extends true - ? never - : Key]: MapModelFieldType; + : Key]: MapModelFieldType; }; // Builds a discriminated union from a delegate model's direct sub-models. Recursion depth @@ -1333,9 +1337,7 @@ export type SelectInput< > = { // parameterized computed fields are excluded — selecting them would require // query-time args; they are currently only usable in `orderBy` - [Key in NonRelationFields as FieldHasComputedArgs extends true - ? never - : Key]?: boolean; + [Key in NonParamComputedNonRelationFields]?: boolean; } & (AllowRelation extends true ? IncludeInput : {}); type SelectCount, Options extends QueryOptions> = From 26aeb6ba704118d7664f35aba73862ae1f646ed3 Mon Sep 17 00:00:00 2001 From: evgenovalov Date: Mon, 27 Jul 2026 17:08:11 +0200 Subject: [PATCH 13/13] refactor: drop dead computed args in groupBy field ref Only parameterized computed fields carry `args` in `by` entries, and those always take the `sql.ref` branch, so passing `e.args` to `fieldRef` in the non-computed branch was dead. Co-Authored-By: Claude Opus 5 (1M context) --- packages/orm/src/client/crud/operations/group-by.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/orm/src/client/crud/operations/group-by.ts b/packages/orm/src/client/crud/operations/group-by.ts index 23d77185b..aef96feee 100644 --- a/packages/orm/src/client/crud/operations/group-by.ts +++ b/packages/orm/src/client/crud/operations/group-by.ts @@ -35,7 +35,8 @@ export class GroupByOperationHandler extends BaseOpera // group a computed field by its SELECT output alias so GROUP BY and the projected // expression stay identical (re-inlining a parameterized computer binds its args as // separate placeholders, which Postgres treats as distinct expressions) - return fieldDef?.computed ? sql.ref(e.field) : fieldRef(e.field, e.args); + // (only parameterized computed fields carry `args`, and they take the branch above) + return fieldDef?.computed ? sql.ref(e.field) : fieldRef(e.field); }), );