From 4f5a860a36fecad286ec34a8c0fa4477ccb0cb1c Mon Sep 17 00:00:00 2001 From: evgenovalov Date: Tue, 30 Jun 2026 11:01:46 +0200 Subject: [PATCH 1/3] 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 2/3] 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 3/3] 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 () => {