Skip to content

feat: support parameterized computed fields#2744

Open
evgenovalov wants to merge 4 commits into
zenstackhq:devfrom
evgenovalov:feat/parameterized-computed-fields
Open

feat: support parameterized computed fields#2744
evgenovalov wants to merge 4 commits into
zenstackhq:devfrom
evgenovalov:feat/parameterized-computed-fields

Conversation

@evgenovalov

@evgenovalov evgenovalov commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

What

Update (review addressed): dropped the : before the return type so a parameterized field reads like a regular one (name(params) Type). Also closed a consistency gap the feature introduced — parameterized computed fields are now excluded at the zod and aggregate-input layers too (not just the TS types), so _sum/_count/by/distinct no longer typecheck-then-crash. Follow-up PR extending this capability to where/select/include/aggregate/groupBy: #2762.

Feature request: #2743

Lets a @computed field declare typed parameters, with the arguments supplied at query time
wherever the field is used. This first cut wires it end-to-end for orderBy.

model User {
  id    Int    @id
  posts Post[]
  recentPostCount(since: DateTime) Int @computed
}
// the implementation receives the args as a 3rd parameter (after eb + context)
const db = new ZenStackClient(schema, {
  dialect,
  computedFields: {
    User: {
      recentPostCount: (eb, ctx, args) =>
        eb.selectFrom('Post')
          .whereRef('Post.authorId', '=', sql.ref(`${ctx.modelAlias}.id`))
          .where('Post.createdAt', '>=', args.since)
          .select(({ fn }) => fn.countAll().as('cnt')),
    },
  },
});

// `args` is plain data, so the whole orderBy can come from a client
await db.user.findMany({
  orderBy: { recentPostCount: { args: { since }, sort: 'desc' } },
});

The motivating case from #2743 — sort products by their tag name in a chosen category:

model ProductSite {
  id   Int @id
  tags ProductTag[]
  tagNameInCategory(categoryId: Int) String? @computed
}
// implementation receives the args as a 3rd parameter (after eb + context)
const db = new ZenStackClient(schema, {
  dialect,
  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 the whole orderBy can come from a client
await db.productSite.findMany({
  orderBy: { tagNameInCategory: { args: { categoryId: 5 }, sort: 'asc', nulls: 'last' } },
});

Why

Closes the gap raised in #2743. A @computed field is evaluated in SQL and is usable in
orderBy/where/select, but it takes no arguments — so you can't express a DB-side sort
that depends on a runtime value (e.g. "sort products by their tag name in a chosen category"). The
only workarounds today ($qb/raw SQL, or an onKyselyQuery plugin) give up access policies,
select-narrowed result types, and/or single-query execution.

Because the arguments are plain data (not a function like where.$expr), they serialize over
the wire, so a frontend can drive the sort through the auto-CRUD API while the query stays one
policy-checked, typed statement.

How

  • ZModel grammar (zmodel.langium): a DataField may declare a (params): Type signature
    (reusing the existing FunctionParam shape). A validator rejects parameters on non-@computed
    fields. Langium AST/grammar regenerated.
  • Schema codegen (ts-schema-generator.ts): the declared params flow into the generated
    computed-field stub signature, so the implementation type (ComputedFieldsOptions) and the query
    input types both derive the args type from a single source and can't drift. Params are also
    emitted as FieldDef.params metadata (shape mirrors ProcedureParam) for the runtime + zod.
  • Runtime (base-dialect.ts): query-time args are forwarded to the implementation as a third
    argument through the single fieldRef chokepoint; extracted from the orderBy value in
    applyScalarOrderBy.
  • Types & zod (crud-types.ts, zod/factory.ts): orderBy accepts
    { args, sort, nulls? } for a parameterized computed field. Since these fields require args,
    they're excluded from default selection (also at runtime, so a plain findMany() is safe),
    explicit select, and where.

Scope / follow-ups

Intentionally scoped to orderBy (the motivating use case). where and select with args are
natural extensions on the same mechanism (the fieldRef chokepoint already forwards args; the
input/result types would lift the same exclusions) and can follow in a separate PR.

Testing

  • Two new e2e tests in tests/e2e/orm/client-api/computed-fields.test.ts — one with an Int
    param, one with a DateTime param (recentPostCount) — each verifying that different args
    produce different orderings
    (proving the arg reaches the SQL), that ascending/descending
    behave, and that the field is not auto-returned.
  • Full suites green locally: @zenstackhq/language (84), orm client-api e2e (618 passed; the only
    failures are the mysql-timezone tests, which need a live MySQL server unavailable in my sandbox
    and are unrelated to this change). No type errors reported by the type-tests.
  • Tested in a real app on a large database (not just the SQLite test fixtures). I built this branch as local tarballs, linked it in with pnpm overrides, and added the [Feature request]: parameterized computed fields — accept arguments in orderBy/where/select #2743 field for real — tagNameInCategory(categoryId: Int): String?, which combines a row's tag names in the given category. Then I sorted a list by it, with the orderBy sent from the frontend, over a table of ~9.5k rows where the chosen category only tags ~150 of them:
    • asc/desc ordered by the tag name, and nulls: 'last' put the untagged rows at the end;
    • the list still showed all ~9.5k rows — it sorts, it doesn't filter (the count with the same where didn't change);
    • I double-checked the order against a separate SQL query;
    • it ran as one query, with access policies and select narrowing still applied (no raw SQL).

Checklist

Summary by CodeRabbit

Summary by CodeRabbit

  • New Features

    • Added support for computed fields with query-time parameters, including schema parsing and argument metadata.
    • Enabled parameterized computed fields in orderBy, validating required args and supporting sort/nulls.
  • Bug Fixes

    • Prevented parameterized computed fields from appearing in regular select/include results, scalar-only where filters, distinct, omit, and aggregation/groupBy inputs.
  • Tests

    • Added end-to-end coverage for orderBy using different argument values and ensured cursor pagination cannot combine with parameterized computed-field sorting.

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>
@coderabbitai

coderabbitai Bot commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Adds support for parameterized @computed fields across parsing, validation, schema and TypeScript generation, CRUD typing, Zod query validation, runtime ordering, and end-to-end tests.

Changes

Parameterized Computed Fields

Layer / File(s) Summary
Grammar and validator changes
packages/language/src/zmodel.langium, packages/language/src/validators/datamodel-validator.ts, packages/language/test/parameterized-computed-field.test.ts
DataField accepts parameter lists, parameters reuse function parameter types, and non-@computed fields with parameters are rejected and tested.
Schema type and TS codegen for params
packages/schema/src/schema.ts, packages/sdk/src/ts-schema-generator.ts
FieldDef and generated schema metadata include computed-field parameters, while generated computed-field stubs emit typed args.
CRUD type-level exclusions and OrderBy shape
packages/orm/src/client/crud-types.ts
Parameterized computed fields receive an args-bearing OrderBy shape and are excluded from select, where, distinct, aggregation, and groupBy inputs that cannot provide args.
Zod query-shape validation
packages/orm/src/client/zod/factory.ts
OrderBy args are validated against declared parameter metadata, while unsupported query contexts omit parameterized computed fields.
Runtime dialect: args forwarding and auto-select skip
packages/orm/src/client/crud/dialects/base-dialect.ts
OrderBy extracts and forwards computed args, cursor pagination rejects these order keys, and automatic field selection skips parameterized computed fields.
Parameterized computed field query coverage
tests/e2e/orm/client-api/computed-fields.test.ts
End-to-end tests cover numeric and date-based ordering, sort direction, default selection, cursor rejection, and unsupported query contexts.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related issues

Suggested reviewers: ymc9, lsmith77

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: adding support for parameterized computed fields.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

…unt)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (1)
packages/schema/src/schema.ts (1)

85-90: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Align this JSDoc with the current API surface.

The new CRUD types intentionally exclude parameterized computed fields from where and select, so this comment is advertising entry points that the type system now rejects. Tightening it to orderBy only would keep the exported contract accurate.

🤖 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/schema/src/schema.ts` around lines 85 - 90, Update the JSDoc on the
computed field params property in schema.ts so it matches the current API
surface: the comment should no longer mention where or select as supported
query-time entry points. Keep the documentation aligned with the exported types
by describing parameterized computed fields as usable in orderBy only, and
ensure the wording around ProcedureParam and params reflects that restriction.
🤖 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/language/src/zmodel.langium`:
- Around line 189-194: The optional parameter group in the DataField grammar
currently allows empty parentheses, so field(): String parses even when it
should not. Update the zmodel.langium rule around
DataField/RegularIDWithTypeNames to require at least one DataFieldParam when
parentheses are present, and keep empty () invalid for non-@computed fields.
Make sure the validator and grammar stay aligned by using the existing
DataFieldParam and DataFieldAttribute symbols to locate the affected rule.

In `@packages/orm/src/client/crud/dialects/base-dialect.ts`:
- Around line 1234-1238: The cursor path in buildCursorFilter is not handling
args-bearing computed orderBy entries correctly, so a cursor against
parameterized computed fields can compare the wrong sort direction and reference
a non-column field. Update buildCursorFilter to recognize the new { args, sort }
shape used by base-dialect.ts, extract the actual sort value, and block or
special-case cursor filtering for computed fields that require args so the
cursor subquery uses a valid field reference.

In `@packages/sdk/src/ts-schema-generator.ts`:
- Around line 647-656: The computed-field parameter type mapping in
mapFunctionParamTypeToTSType should not emit bare referenced names that may be
out of scope in schema.ts. Update the generator logic so referenced
FunctionParamType values are resolved to in-scope TypeScript types by importing
or qualifying the referenced symbol before returning it, and ensure
model/enum/type-def refs used by mapFunctionParamTypeToTSType are declared in
the generated file’s context rather than returning type.reference?.ref?.name
directly.

---

Nitpick comments:
In `@packages/schema/src/schema.ts`:
- Around line 85-90: Update the JSDoc on the computed field params property in
schema.ts so it matches the current API surface: the comment should no longer
mention where or select as supported query-time entry points. Keep the
documentation aligned with the exported types by describing parameterized
computed fields as usable in orderBy only, and ensure the wording around
ProcedureParam and params reflects that restriction.
🪄 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: 2c24331c-f8d9-4bcd-8c5d-0b84e466a410

📥 Commits

Reviewing files that changed from the base of the PR and between 53e9165 and 4f5a860.

⛔ Files ignored due to path filters (2)
  • packages/language/src/generated/ast.ts is excluded by !**/generated/**
  • packages/language/src/generated/grammar.ts is excluded by !**/generated/**
📒 Files selected for processing (8)
  • packages/language/src/validators/datamodel-validator.ts
  • packages/language/src/zmodel.langium
  • packages/orm/src/client/crud-types.ts
  • packages/orm/src/client/crud/dialects/base-dialect.ts
  • packages/orm/src/client/zod/factory.ts
  • packages/schema/src/schema.ts
  • packages/sdk/src/ts-schema-generator.ts
  • tests/e2e/orm/client-api/computed-fields.test.ts

Comment thread packages/language/src/zmodel.langium Outdated
Comment thread packages/orm/src/client/crud/dialects/base-dialect.ts
Comment thread packages/sdk/src/ts-schema-generator.ts Outdated
- 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>
@evgenovalov

Copy link
Copy Markdown
Contributor Author

Thanks for the review — all four addressed in ef09757:

  • Grammar (empty ()): DataField now requires at least one DataFieldParam when parentheses are present, so field(): String no longer parses. Grammar + AST regenerated.
  • Cursor + args-bearing computed orderBy (major): good catch. Extended the existing offendingKey guard (which already blocks _fuzzyRelevance/_ftsRelevance) to detect the { args, sort } shape and throw cursor pagination cannot be combined with "<field>" ordering. Added a test asserting this.
  • Referenced param types (major): mapFunctionParamTypeToTSType now falls back to unknown for model/enum/type-def references instead of emitting a bare, out-of-scope name — same convention mapFieldTypeToTSType uses for computed-field return types. Runtime zod still validates these values precisely (via makeScalarSchema, incl. enums).
  • FieldDef.params JSDoc (nitpick): tightened to mention orderBy only, matching the type-level exclusions from where/select.

@zenstackhq/language (84) and the computed-fields e2e (now 13, incl. the cursor-block assertion) pass with no type errors.

@evgenovalov

Copy link
Copy Markdown
Contributor Author

Besides the tests, I tried this in a real app on a large database, to be sure the parameterized orderBy works outside the test fixtures.

I built this branch as local tarballs, linked it in with pnpm overrides, and added the exact #2743 field:

tagNameInCategory(categoryId: Int): String? @computed
// combines a row's tag names in the chosen category

then sorted a list by it, with the orderBy sent straight from the frontend:

orderBy: { tagNameInCategory: { args: { categoryId }, sort: 'asc' | 'desc', nulls: 'last' } }

The table has ~9.5k rows, and the chosen category only tags ~150 of them — so it's easy to tell sorting from filtering. What I saw:

  • asc/desc ordered by the tag name, and nulls: 'last' put the untagged rows at the end;
  • the list still showed all ~9.5k rows — it sorts, it does not filter (the count with the same where didn't change);
  • the order matched a separate SQL query on the same data;
  • it ran as one query, with access policies and select narrowing still applied — no raw SQL.

So sending the whole orderBy from the client as plain data works on real data and real volume, not just the test fixtures.

@evgenovalov

Copy link
Copy Markdown
Contributor Author

@olup @ymc9 hi guys, would be nice to get your feedback on this PR/feature request

I see many use-cases already and would be happy to see the feature in the next version. Feature is not usage-specific but very wide in terms of flexibility.

Let me know if you see some remarks.

Thank you!

@ymc9 ymc9 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @evgenovalov ,

Thanks for working on this PR and my apologies for the delayed review.

I think it's a very powerful feature, and the PR looks good go me. To release it as a feature, it probably makes more sense to expose the capability in other common contexts like where, select, _count, etc., and other read operations like aggregate. Would you like to make a follow up PR? If so, this can be a candidate for the v3.9 release.

Field selection is a bit tricky because the old convention is scalar fields are selected by default, but this can't happen automatically for parameterized fields as args need to be provided. Maybe let it be controlled by include is an acceptable solution?

await db.user.findMany({ include: { computedField: { args: { ... } } } });

Comment thread packages/language/src/zmodel.langium Outdated
DataField:
(comments+=TRIPLE_SLASH_COMMENT)*
name=RegularIDWithTypeNames type=DataFieldType (attributes+=DataFieldAttribute)*;
name=RegularIDWithTypeNames ('(' params+=DataFieldParam (',' params+=DataFieldParam)* ')' ':')? type=DataFieldType (attributes+=DataFieldAttribute)*;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need the ":" token here? I think dropping it will make parameterized fields more consistent with regular ones.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for the feedback @ymc9 !

I updated the syntax you mentioned. Also there is a new PR for where, select, groupBy etc:
#2762

And docs:
zenstackhq/zenstack-docs#630

Glad to help and would be nice to see the feature in the next release 🤞

- 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants