Skip to content

feat: support parameterized computed fields#2744

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

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

Conversation

@evgenovalov

@evgenovalov evgenovalov commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

What

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

  • Targets dev
  • pnpm build green for all touched packages (language, schema, sdk, orm, testtools)
  • Added a test
  • Docs (the docs site lives in a separate repo; happy to open a docs PR if the API shape is accepted)

Summary by CodeRabbit

  • New Features
    • Added support for computed fields with query-time parameters.
    • Enabled parameterized computed fields in orderBy, with argument validation.
  • Bug Fixes
    • Prevented parameterized computed fields from being auto-selected and from appearing in scalar-only where filters unless args are provided.
    • Improved cursor-pagination handling by disallowing cursor ordering with parameterized computed-field sorts.
  • Tests
    • Added end-to-end coverage for orderBy behavior using different parameter values (including direction and cursor restrictions).

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/code generation, CRUD typing, runtime argument forwarding, Zod validation, and e2e coverage.

Changes

Parameterized Computed Fields

Layer / File(s) Summary
Grammar and validator changes
packages/language/src/zmodel.langium, packages/language/src/validators/datamodel-validator.ts
DataField gains optional parameter-list syntax before the type annotation, DataFieldParam is added, and fields with params now error unless they are marked @computed.
Schema type and TS codegen for params
packages/schema/src/schema.ts, packages/sdk/src/ts-schema-generator.ts
FieldDef gains optional params; the TS schema generator emits params metadata and typed computed-field stubs with an args parameter when params are declared.
CRUD type-level exclusions and OrderBy shape
packages/orm/src/client/crud-types.ts
New computed-field type helpers exclude parameterized computed fields from scalar selection, filtering, and selection inputs, while OrderBy gains an args-bearing object form for parameterized computed fields.
Runtime dialect: args forwarding and auto-select skip
packages/orm/src/client/crud/dialects/base-dialect.ts
Auto-selection skips parameterized computed fields, fieldRef accepts and forwards computedArgs, and orderBy handling extracts args from the payload before invoking computed fields.
Zod orderBy validation
packages/orm/src/client/zod/factory.ts
makeFieldArgsSchema builds strict validation for computed-field params, and makeOrderBySchema validates the args, sort, and nulls shape for parameterized computed fields.
E2E test for parameterized computed field orderBy
tests/e2e/orm/client-api/computed-fields.test.ts
New tests define parameterized computed fields and verify orderBy sorting behavior with supplied args.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related issues

Poem

🐇 A bunny found a computed field,
With args at query time to wield.
orderBy hopped left, then right, then true,
While params leapt along right through.
No auto-select for fields that need a clue—
Just pass the args, and hop on through!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the core change: adding support for parameterized computed fields.
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.
✨ 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.

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.

1 participant