feat: parameterized computed fields in where / select / include / aggregate / groupBy#2762
Conversation
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>`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 zenstackhq#2743
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…unt) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- 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) <noreply@anthropic.com>
- 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) <noreply@anthropic.com>
…lude
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) <noreply@anthropic.com>
`_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) <noreply@anthropic.com>
`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) <noreply@anthropic.com>
…oupBy 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) <noreply@anthropic.com>
📝 WalkthroughWalkthroughParameterized computed fields now support typed declarations, generated metadata, argument-aware query validation, ORM execution, selection, ordering, filtering, aggregation, grouping, and nested relation results. ChangesParameterized computed fields
Estimated code review effort: 4 (Complex) | ~60 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
packages/orm/src/client/crud/dialects/lateral-join-dialect-base.ts (1)
215-216: 🎯 Functional Correctness | 🔴 Critical | ⚡ Quick winPass query-time
argstofieldReffor parameterized computed fields during relation sort.When sorting scalar fields natively within an array aggregation (e.g., PostgreSQL lateral join
json_agg), the extraction of the sorting expression must forwardcomputedArgsif the field is a parameterized computed field.
Currently,this.fieldRefis invoked withoutcomputedArgs. This will evaluate the field's expression without its query-time parameters, leading to functionally incorrect queries or runtime crashes during array aggregations.Extract
argsfromvalueto ensure parity with the logic inapplyScalarOrderBy(frombase-dialect.ts).🐛 Proposed fix
- const expr = this.fieldRef(model, field, modelAlias); + const computedArgs = value && typeof value === 'object' && 'args' in value ? (value as any).args : undefined; + const expr = this.fieldRef(model, field, modelAlias, true, computedArgs); let sort = typeof value === 'string' ? value : value.sort;🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/orm/src/client/crud/dialects/lateral-join-dialect-base.ts` around lines 215 - 216, Update the relation-sort logic around fieldRef in the lateral-join dialect to extract query-time args from value and pass them as computedArgs to this.fieldRef(model, field, modelAlias). Match the argument handling used by applyScalarOrderBy, while preserving the existing sort selection behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@packages/orm/src/client/crud/operations/aggregate.ts`:
- Around line 36-37: Update the aggregation field-processing logic around
selectedFields and computedArgsByField to validate repeated fields before
assigning args: when an existing field has args, require the new args to be
deeply identical; otherwise throw an error. Preserve the existing
single-projection behavior and assignment for first-time fields or matching
args.
In `@packages/schema/src/schema.ts`:
- Around line 85-90: Update the documentation for the params property in the
parameterized computed-field schema to remove the orderBy-only restriction and
describe that its arguments are supplied at query time wherever the field is
used, including filtering, selection, aggregation, and grouping contexts.
---
Outside diff comments:
In `@packages/orm/src/client/crud/dialects/lateral-join-dialect-base.ts`:
- Around line 215-216: Update the relation-sort logic around fieldRef in the
lateral-join dialect to extract query-time args from value and pass them as
computedArgs to this.fieldRef(model, field, modelAlias). Match the argument
handling used by applyScalarOrderBy, while preserving the existing sort
selection behavior.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: a86edbaf-c798-481d-b4dd-4bb098d06740
⛔ Files ignored due to path filters (2)
packages/language/src/generated/ast.tsis excluded by!**/generated/**packages/language/src/generated/grammar.tsis excluded by!**/generated/**
📒 Files selected for processing (15)
packages/language/src/validators/datamodel-validator.tspackages/language/src/zmodel.langiumpackages/language/test/parameterized-computed-field.test.tspackages/orm/src/client/crud-types.tspackages/orm/src/client/crud/dialects/base-dialect.tspackages/orm/src/client/crud/dialects/lateral-join-dialect-base.tspackages/orm/src/client/crud/dialects/sqlite.tspackages/orm/src/client/crud/operations/aggregate.tspackages/orm/src/client/crud/operations/base.tspackages/orm/src/client/crud/operations/count.tspackages/orm/src/client/crud/operations/group-by.tspackages/orm/src/client/zod/factory.tspackages/schema/src/schema.tspackages/sdk/src/ts-schema-generator.tstests/e2e/orm/client-api/computed-fields.test.ts
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) <noreply@anthropic.com>
Addresses review feedback on zenstackhq#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) <noreply@anthropic.com>
`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) <noreply@anthropic.com>
What
Follow-up to #2744 (per your review) — exposes parameterized
@computedfields in the remaining read contexts, not justorderBy.Everything flows through the single channel #2744 added —
fieldRef(..., computedArgs)→computer(eb, ctx, args). Each context is the same recipe: input type + zod (reusingmakeFieldArgsSchema) + a runtime seam that extractsargsand forwards it.Contexts added
where(andhaving, which reusesWhereInput) — args alongside the filter operators:select/include—include: { field: { args } }, andselect: { field: { args } }for free (becauseSelectInput = { …boolean } & IncludeInput):_count/_sum/_avg/_min/_max(inaggregateandgroupBy) and count'sselect—{ field: { args } }instead of the baretrue.groupByby— a keyed{ field, args }entry:include— a parameterized computed field on a related model, inlined with its args in both dialect JSON assemblers (SQLite + lateral-join).Field selection (your note)
You suggested driving selection via
include: { computedField: { args } }since these fields can't be auto-selected. Done — and becauseSelectInputintersectsIncludeInput, the same entry also works underselect. They stay excluded from default / auto-selection at every layer.Commits
where(+having) +select/include_count/_sum/_avg/_min/_max(aggregate + groupBy) + countselectgroupByby(keyed shape)include+ a PostgresgroupByfixLimitation
Grouping by a computed field backed by a correlated subquery is subject to the database's own rules for correlated
GROUP BY(Postgres rejects it; SQLite allows it) — the same constraint as any correlatedGROUP BYexpression. Row-local computed fields group fine on all dialects. A general fix would need a materialize-then-group subquery ingroup-by.ts— happy to add it if you'd like.Testing
Extended
tests/e2e/orm/client-api/computed-fields.test.tswith focused cases per context, each proving different args → different results. Green on SQLite and Postgres:computed-fields.test.ts: 19 testsorm/client-apisuite: 625 passed (SQLite) / 551 passed (Postgres)mysql-timezonetests, which need a live MySQL server (unrelated to this change)Checklist
dev(stacked on feat: support parameterized computed fields #2744)pnpm buildgreen (language,orm)🤖 Generated with Claude Code
Summary by CodeRabbit
{ args: ... }in supported contexts and reject parameter declarations on non-computed fields.