Skip to content

Add opt-in deep exact query argument checking#2720

Open
olup wants to merge 4 commits into
zenstackhq:devfrom
olup:codex/fix-select-subset-ts6
Open

Add opt-in deep exact query argument checking#2720
olup wants to merge 4 commits into
zenstackhq:devfrom
olup:codex/fix-select-subset-ts6

Conversation

@olup

@olup olup commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes #2719 by adding an opt-in exactness check for inferred query arguments:

const db = new ZenStackClient(schema, {
    // ...
    typing: { exactQueryArgs: true },
});

With the option enabled, unknown properties are rejected recursively in inline and hoisted arguments across read, create, update, delete, count, aggregate, groupBy, and exists operations. Covered structures include nested relations, arrays, where, select, include, orderBy, cursor, connect, connectOrCreate, and nested mutation branches.

Design

NoExtraProperties<T, Shape> walks only the keys present in the argument inferred from user code. Expected input unions are consulted property by property, so the checker does not eagerly rebuild every possible schema branch. There is no arbitrary recursion-depth cap.

The check preserves property-local diagnostics and treats runtime leaf/open-value types such as Date, Decimal, Uint8Array, functions, and JSON-style open records appropriately. The empty finite-shape case also preserves a valid {} while continuing to reject supplied unknown keys.

The option remains disabled by default because the type-checking cost depends on schema and query complexity. There are no runtime client changes.

Performance

Measured on Node 26.5 using the same final e2e corpus with exactness off and on. Values are medians of three isolated sequential runs per state.

Compiler Metric Overhead
TypeScript 6.0.3 Memory +17,047K (+1.80%)
TypeScript 6.0.3 Type instantiations +31,921 (+1.34%)
TypeScript 6.0.3 Check time +0.53s (+10.19%)
TS7 native preview Memory +7,604K (+1.18%)
TS7 native preview Type instantiations +25,445 (+0.81%)
TS7 native preview Allocations +63,382 (+0.61%)
TS7 native preview Check time +0.215s (+27.42%)

The relative TS7 timing increase is larger than the structural deltas, but the absolute median check-time increase is 0.215 seconds on this corpus. The cost is workload-dependent, which is why the feature remains opt-in.

Validation

  • pnpm --filter @zenstackhq/orm build
  • pnpm --filter e2e test:typecheck (TypeScript 6.0.3)
  • pnpm exec tsgo -p tests/e2e/tsconfig.json --noEmit --pretty false (TS7 native preview)
  • pnpm --filter @zenstackhq/orm lint
  • Prettier check on all changed files

Summary by CodeRabbit

  • New Features

    • Added an opt-in “exact query arguments” mode (typing.exactQueryArgs) for stricter TypeScript validation.
    • Query argument types now enforce exact shapes (rejecting extra/unknown fields), including deeply nested relation payloads.
    • Updated selection/subset typing to support an options parameter while preserving existing compile-time safeguards (e.g., select vs include/omit).
  • Tests

    • Expanded type-level checks to confirm correct acceptance/rejection across read/write and aggregate/mutation operations in exact mode.

@coderabbitai

coderabbitai Bot commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds opt-in recursive exact query-argument typing through QueryOptions, Subset, and SelectSubset, propagates the option across CRUD method signatures, and expands compile-time tests for invalid nested arguments.

Changes

Exact query argument typing

Layer / File(s) Summary
Strict argument type helpers
packages/orm/src/client/options.ts, packages/orm/src/client/crud-types.ts, packages/orm/src/utils/type-utils.ts
Adds typing.exactQueryArgs, recursive NoExtraProperties, and conditional enforcement through StrictArgs while preserving existing selection conflict errors.
CRUD contract option propagation
packages/orm/src/client/contract.ts
Passes Options into Subset and SelectSubset across query, mutation, deletion, aggregation, grouping, existence, and return methods.
Exact-mode typecheck coverage
tests/e2e/orm/schemas/typing/typecheck.ts
Adds strict-client negative assertions for invalid nested read and write arguments, relation payloads, aggregations, grouping, and extra subset properties.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Possibly related PRs

Suggested reviewers: ymc9

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and accurately describes the main change: opt-in deep exact query-argument checking.
Linked Issues check ✅ Passed The changes add opt-in exactQueryArgs support and tests that reject invalid direct client query arguments as required by #2719.
Out of Scope Changes check ✅ Passed The edits stay focused on exact query-argument typing, tests, and supporting type utilities/options with no unrelated features.
✨ 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.

@ymc9

ymc9 commented Jun 25, 2026

Copy link
Copy Markdown
Member

Hi @olup , thanks for making the fix! I'm not sure if it's by design, but I believe the improved EPC check only works for top-level select/omit/include. Adding recursion will probably incur significant type-checking cost, so I think it's a valid trade-off. Looking good to me.

ymc9
ymc9 previously approved these changes Jun 25, 2026
@olup

olup commented Jun 25, 2026

Copy link
Copy Markdown
Contributor Author

Great ! I'll have a quick look this morning on recursion/performance.

@olup
olup force-pushed the codex/fix-select-subset-ts6 branch from 3816813 to f5fa064 Compare June 25, 2026 07:46
@olup

olup commented Jun 25, 2026

Copy link
Copy Markdown
Contributor Author

(message produces by codex under human supervision)

I updated the branch to address the recursion/performance concern with a different approach.

Instead of using a fixed recursion depth, the new version only applies strict checking along the user-provided selection tree, and only for select, include, and omit keys. When a nested relation argument is reached, it checks the keys at that FindArgs level, but it does not recursively exact-check arbitrary sub-objects like where, orderBy, or cursor; those continue to be handled by their existing types. So the recursion is bounded by the shape of the user query, not by an arbitrary depth constant, and it stays focused on selection/include/omit exactness.

I also compared this with Drizzle's model. Drizzle's KnownKeysOnly is shallow, while the recursive behavior mainly comes from DBQueryConfig recursively typing relation with configs. A purely shallow KnownKeysOnly-style helper was not enough for ZenStack's FindArgs shape, so this patch keeps a small targeted recursive helper around selection keys only.

Quick local typechecking perf check:

  • Command: cd tests/e2e && /usr/bin/time -p npx tsc --noEmit --extendedDiagnostics --pretty false
  • Ran 3 times on the current PR baseline and 3 times on this update.

Average e2e results:

Metric Baseline Updated Delta
Types 300,193 314,965 +4.9%
Instantiations 2,475,523 2,582,921 +4.3%
Memory used ~998 MB ~1009 MB +1.2%
Check time 10.64s 10.69s roughly unchanged
Wall time 15.69s 15.55s roughly unchanged/noise

I also checked packages/orm directly with tsc --extendedDiagnostics; the structural metrics were essentially flat there (Instantiations: 730,826 -> 730,833).

Validation run locally:

  • pnpm --filter @zenstackhq/orm build
  • pnpm --filter e2e test:typecheck
  • npx -p typescript@5.9.3 tsc --noEmit -p tsconfig.json --pretty false from tests/e2e

@olup
olup marked this pull request as draft June 25, 2026 08:18
@olup

olup commented Jun 25, 2026

Copy link
Copy Markdown
Contributor Author

Checking a couple of details on the where queries before reopening this one

@olup
olup force-pushed the codex/fix-select-subset-ts6 branch from 31aa89e to 371687e Compare June 25, 2026 08:25
@olup

olup commented Jun 25, 2026

Copy link
Copy Markdown
Contributor Author

(message produced by codex under human supervision)

Small follow-up: I also checked the where/orderBy concern explicitly.

The selection recursion helper intentionally does not recursively walk arbitrary sub-objects like scalar filter payloads or sort payloads. However, where, orderBy, and cursor should still be checked against the queried model's existing argument types at the level where they appear.

I added explicit type tests for:

  • invalid root where field
  • invalid root orderBy field
  • invalid nested relation where field
  • invalid nested relation orderBy field
  • unsupported field in orderBy

Implementation-wise, the patch now applies shallow exactness to where, orderBy, and cursor at each query-args level, while keeping the deeper recursive exactness focused on select, include, and omit. It still does not recursively exact-check arbitrary filter/operator internals beyond the existing WhereInput/OrderBy types.

Validation rerun:

  • pnpm --filter @zenstackhq/orm build
  • pnpm --filter e2e test:typecheck
  • npx -p typescript@5.9.3 tsc --noEmit -p tsconfig.json --pretty false from tests/e2e

@olup
olup marked this pull request as ready for review June 25, 2026 08:29
@ymc9

ymc9 commented Jul 1, 2026

Copy link
Copy Markdown
Member

Hi @olup , thanks for the update. I've been thinking about the issue and the trade-offs. So ideally, we should have a full recursive EPC for the entire query args. However, this can incur high extra type-checking costs due to the typing complexity of fields like where, orderBy, data, etc. Having a special deep visit into select/include/omit can be beneficial for that path with a reasonable extra cost, however, it can also cause confusion why some part is deeply checked, while others are not, in a query args ...

I'm wondering if it's overall better to just have a shallow, strict check for all keys. Implementation is simpler and easier to reason, behavior is consistent, at the cost of losing deep EPC. However, since everything is strictly checked at runtime, and also for IDE experience, the language server's field recommendation is still fully contextual, maybe it's a reasonable trade-off?

@olup

olup commented Jul 14, 2026

Copy link
Copy Markdown
Contributor Author

Hey - I have been away for a bit.

To state back the context of the issue : I really beleive that the interesting thing about a typed ORM is letting you rework your schema with the confidence of your type system catching any mistake. What happens now in ts 6+ is that the argument of the query are not properly typed - meaning a change in your schema can lead to an invalid query failing at run time. To me this feels completely impossible to accpet.

Now should the argument typing be only on top level? I underdtand the tradeoff but from a user perspective I would ask why ? If I use narrow selects on relationship, and a property is gone, I would 100% expect zenstack to error at compile time.

I think prisma succeed in this way by generating types in codegen, but Drizzle managed it with type only.

If there is any way to achieve a light recursive typing of the orm query arguments we should absolutely do it, don't you think ?

My present proposal might not be it, but we should explore this - especially since ts7 is so much more efficient.

@olup

olup commented Jul 14, 2026

Copy link
Copy Markdown
Contributor Author

So here are my explorations:

The key observation is that the full schema/query-argument graph does not need to be recursively expanded. Recursion can follow only the keys present in the user-provided input, making it naturally bounded by that input without an arbitrary depth limit.

here are some metrics taken from running the test suite before and after my ongiong explorations (not what's in the PR RN)

Metric Baseline Recursive prototype Delta
Types 470,297 560,182 +19.1%
Instantiations 3,119,401 3,723,645 +19.4%
Memory used 644 MB 727 MB +12.8%
Memory allocations 10.32 M 11.82 M +14.6%
Check time 0.79 s 1.27 s +0.48 s
Total time 1.01 s 1.73 s +0.72 s

This is the most complete version I could come up with.

I think its necessary in terms of feature but if the cost is too large, I ma told there is also the possibility to make it an option on the client. User could activate this if they want complete typing of queries in ts7.

example:

const db = new ZenStackClient(schema, {
  dialect,
  typing: {
    exactQueryArgs: true,
  },
});

@olup

olup commented Jul 15, 2026

Copy link
Copy Markdown
Contributor Author

The activation flag is working. I think this could make a interesting design - complicated project can get a higher overhead, but gaining strict checking in the process. The tradeoff could be the user choice. I'll update the PR with this design. Would it be interesting @ymc9 ?

@olup
olup force-pushed the codex/fix-select-subset-ts6 branch from 371687e to add2903 Compare July 15, 2026 19:25
@olup olup changed the title Fix strict select subset typing under TS6 Add opt-in deep exact query argument checking Jul 15, 2026
@coderabbitai

coderabbitai Bot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Caution

Failed to replace (edit) comment. This is likely due to insufficient permissions or the comment being deleted.

Error details
putComment timed out

@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: 1

🧹 Nitpick comments (1)
packages/orm/src/utils/type-utils.ts (1)

99-105: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Empty Keys collapses the whole object to never, even when T is also empty.

When Shape has zero keys, _NoExtraObject returns a bare never for the whole type instead of a mapped object. This is stricter than necessary: the mapped branch already rejects every key of T when Keys is never (since K extends Keys is false for every K), but returning bare never also rejects the vacuous case where T itself has no keys (T = {}), because never intersected into Subset/SelectSubset collapses the entire args type to never. Dropping the special case simplifies the code and fixes this over-rejection.

♻️ Proposed simplification
-type _NoExtraObject<T extends object, Shape, Keys extends PropertyKey = _ObjectKeys<Shape>> = [Keys] extends [never]
-    ? never
-    : string extends Keys
-      ? unknown
-      : {
-            [K in keyof T]: K extends Keys ? NoExtraProperties<T[K], _ObjectValue<Shape, K>> : never;
-        };
+type _NoExtraObject<T extends object, Shape, Keys extends PropertyKey = _ObjectKeys<Shape>> = string extends Keys
+    ? unknown
+    : {
+          [K in keyof T]: K extends Keys ? NoExtraProperties<T[K], _ObjectValue<Shape, K>> : never;
+      };
🤖 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/utils/type-utils.ts` around lines 99 - 105, Update the
_NoExtraObject type to remove the special-case branch that returns never when
Keys extends never. Always use the mapped-object branch so an empty T remains
valid while any keys in T are still rejected by the K extends Keys condition;
preserve the existing string-key handling.
🤖 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/options.ts`:
- Around line 142-152: Update the JSDoc for typing.exactQueryArgs in the options
definition to describe its current limited coverage: it applies to create,
update, and upsert argument shapes, rather than recursively rejecting unknown
properties across all query arguments. Do not imply enforcement for read,
delete, or aggregate operations until those overloads propagate Options.

---

Nitpick comments:
In `@packages/orm/src/utils/type-utils.ts`:
- Around line 99-105: Update the _NoExtraObject type to remove the special-case
branch that returns never when Keys extends never. Always use the mapped-object
branch so an empty T remains valid while any keys in T are still rejected by the
K extends Keys condition; preserve the existing string-key handling.
🪄 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: e08d5665-02ff-4bcf-8e4b-d1bdb366b47f

📥 Commits

Reviewing files that changed from the base of the PR and between 371687e and add2903.

📒 Files selected for processing (5)
  • packages/orm/src/client/contract.ts
  • packages/orm/src/client/crud-types.ts
  • packages/orm/src/client/options.ts
  • packages/orm/src/utils/type-utils.ts
  • tests/e2e/orm/schemas/typing/typecheck.ts

Comment thread packages/orm/src/client/options.ts
@ymc9

ymc9 commented Jul 19, 2026

Copy link
Copy Markdown
Member

Hi @olup , thanks for the follow up. I agree that opt-in is a good idea. Could you check the comment from @coderabbitai? Currently the option is only wired to the write APIs but not read. After including read path for recursive epc check, my observation is about 30% increase in type instantiation and memory usage (TS 6). Not too bad but for sure justifies the default-false opt-in.

@coderabbitai

coderabbitai Bot commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

@ymc9 Agreed. The current diff propagates Options only through the write-method constraints, so typing.exactQueryArgs does not yet consistently cover read calls. The read API signatures should thread the same option into their Subset/SelectSubset constraints.

Your TS 6 measurement—roughly +30% instantiations and memory once read-path recursive EPC is included—makes the default-false opt-in especially important. That preserves existing performance for most users while allowing projects that prioritize schema-refactor safety to explicitly choose stricter validation.

🐇✅


✏️ Learnings added
Learnt from: ymc9
URL: https://github.com/zenstackhq/zenstack/pull/2720

Timestamp: 2026-07-19T03:18:43.560Z
Learning: In ZenStack ORM client typing, the `typing.exactQueryArgs` strict recursive excess-property-checking option must be propagated to both read and write CRUD API argument constraints; wiring it only to write methods leaves direct read queries unprotected. Applying recursive EPC to read paths can increase TypeScript 6 type instantiations and memory by about 30%, so this feature should remain opt-in and default to false.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

@olup

olup commented Jul 19, 2026

Copy link
Copy Markdown
Contributor Author

Of course - I wanted your opinion first before taking more attention on this. Will work a proper pr in the following days

@olup

olup commented Jul 19, 2026

Copy link
Copy Markdown
Contributor Author

The read path and the remaining delete and analytical operations are now wired to the recursive guard. The empty expected-shape edge case reported by CodeRabbit is fixed as well.

A fresh three-run A/B on the identical final corpus gives:

Compiler Memory Instantiations Check time
TypeScript 6.0.3 +17,047K (+1.80%) +31,921 (+1.34%) +0.53s (+10.19%)
TS7 native preview +7,604K (+1.18%) +25,445 (+0.81%) +0.215s (+27.42%)

The current input-key-driven implementation therefore does not reproduce the approximate 30% TS6 memory/instantiation increase. TS7 timing rises more in relative terms, but by 0.215 seconds on this corpus. The option remains default-false because workload shape can change the cost.

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.

tsgo accepts invalid select keys on ZenStack client calls (with repro repo + fixing PR)

2 participants