Skip to content

fix: enable TypeScript excess-property checking on where for all CRUD methods#2565

Draft
motopods wants to merge 14 commits intozenstackhq:devfrom
motopods:fix/epc-missing-on-where
Draft

fix: enable TypeScript excess-property checking on where for all CRUD methods#2565
motopods wants to merge 14 commits intozenstackhq:devfrom
motopods:fix/epc-missing-on-where

Conversation

@motopods
Copy link
Copy Markdown
Contributor

@motopods motopods commented Apr 7, 2026

Problem

This PR contains #2559
Based on the status of this PR, decide whether to roll back the code related to it.

When passing an unknown field in a where clause, TypeScript should report an error. However, due to how generics interact with TypeScript's Excess Property Checking (EPC), no error was produced for any CRUD method — including findMany, findFirst, findUnique, findFirst/UniqueOrThrow, update, upsert, updateMany, updateManyAndReturn, delete, and deleteMany.

// All of the following should error — but TypeScript was silent before this fix
await db.user.findMany({ where: { notExistsColumn: 1 } });
await db.user.findFirst({ where: { notExistsColumn: 1 } });
await db.user.findUnique({ where: { id: 1, notExistsColumn: 1 } });
await db.user.update({ where: { id: 1, notExistsColumn: 1 }, data: { name: 'Alex' } });
await db.user.upsert({ where: { id: 1, notExistsColumn: 1 }, create: { ... }, update: { ... } });
await db.user.updateMany({ where: { notExistsColumn: 1 }, data: { name: 'Alex' } });
await db.user.delete({ where: { id: 1, notExistsColumn: 1 } });
await db.user.deleteMany({ where: { notExistsColumn: 1 } });

Root Cause

TypeScript's EPC only fires on an object literal when it is directly assigned to a position whose type is fully known at that point. Previously, all CRUD method signatures used a single generic T constrained to the full args type (e.g. FindManyArgs), and args was typed as SelectSubset<T, FindManyArgs>. TypeScript infers T from the whole argument literal — including where — so by the time it checks where, the where property is already part of T and EPC is defeated.

The fix is to separate where out of T: constrain T only to OmitWhere<XxxArgs> so TypeScript never absorbs where into T, and declare where as a direct intersection member at the call site. Because where is now a fresh object literal typed against the concrete WhereInput/WhereUniqueInput, EPC fires as intended.

Extra complication for mutation methods (update, upsert, updateMany, updateManyAndReturn)

For these methods the args type has required fields (data, create/update). TypeScript must infer T from the whole argument — including any where the caller supplies — before applying the mapped type. A naive Subset<T, OmitWhere<XxxArgs>> would map where → never in the result because where is no longer in U, and that collapses the intersection to { where: never }, breaking valid calls.

The fix introduces SubsetWithWhere<T, U> / SelectSubsetWithWhere<T, U>: these behave exactly like Subset/SelectSubset but map where → unknown (not never) when where is absent from U. Because unknown intersects transparently ({ where: WhereInput } & { where: unknown } = { where: WhereInput }), the final type is correct and EPC is preserved.

Changes

packages/orm/src/client/crud-types.ts

Added three new type helpers:

  • OmitWhere<T>Omit<T, 'where'>. Strips where from the args constraint so T never absorbs the where literal, leaving it free for EPC.
  • SubsetWithWhere<T, U> — Like Subset<T, U> but maps where → unknown when absent from U, avoiding where: never in intersections for mutation methods.
  • SelectSubsetWithWhere<T, U> — Like SelectSubset<T, U> but with the same where → unknown rule as above.

packages/orm/src/client/contract.ts

All CRUD method signatures updated to separate where from T:

Method where param Mapped type
findMany where?: WhereInput SelectSubset<T, FindManyArgs>
findFirst / findFirstOrThrow where?: WhereInput SelectSubset<T, FindFirstArgs>
findUnique / findUniqueOrThrow where: WhereUniqueInput SelectSubset<T, FindUniqueArgs>
update where: WhereUniqueInput SelectSubsetWithWhere<T, OmitWhere<UpdateArgs>>
upsert where: WhereUniqueInput SelectSubsetWithWhere<T, OmitWhere<UpsertArgs>>
updateMany where?: WhereInput SubsetWithWhere<T, OmitWhere<UpdateManyArgs>>
updateManyAndReturn where?: WhereInput SubsetWithWhere<T, OmitWhere<UpdateManyAndReturnArgs>>
delete where: WhereUniqueInput SelectSubset<T, DeleteArgs>
deleteMany where?: WhereInput Subset<T, DeleteManyArgs>

tests/e2e/orm/schemas/typing/typecheck.ts

Added // @ts-expect-error assertions for unknown where fields across:

  • findMany and findFirst (in find())
  • update, upsert, and updateMany (in update())

tests/e2e/orm/schemas/delegate/typecheck.ts

Added a new whereEPC() function covering delegate (polymorphic) models:

  • findMany and findFirst with unknown where fields
  • update with an unknown where field
  • A positive case confirming valid where fields still compile cleanly

Summary by CodeRabbit

Release Notes

  • New Features

    • Added $is filter for advanced filtering on polymorphic models with support for nested sub-model conditions and OR semantics.
    • Improved type safety for where clauses in CRUD operations with better compile-time validation.
  • Tests

    • Added comprehensive end-to-end tests validating polymorphic model filtering behavior and TypeScript type checking for query clauses.

Copilot AI and others added 13 commits April 2, 2026 05:00
…-content

feat(orm): implement discriminated union return types for delegate (polymorphic) models
…rence-for-content

Revert "feat(orm): implement discriminated union return types for delegate (polymorphic) models"
Separate `where` from the generic type parameter T in findMany/findFirst/
findUnique/update/upsert/delete and related methods. By typing `where`
directly (not through T), TypeScript's Excess Property Checking (EPC)
now catches unknown field names at compile time.

Previously: `findMany({ where: { notExistsColumn: 1 } })` - no error
Now: TypeScript reports TS2353 for unknown fields in `where`

The fix works by splitting the method signatures so that T only covers
the SelectIncludeOmit part (select/include/omit - what affects return
type), while `where` is typed as a direct WhereInput parameter, which
triggers EPC for object literal assignments.

Affected methods: findMany, findFirst, findFirstOrThrow, findUnique,
findUniqueOrThrow, update, updateMany, updateManyAndReturn, upsert,
delete, deleteMany.

Agent-Logs-Url: https://github.com/motopods/zenstack/sessions/895c8305-945e-4e45-bd13-2d89b4bc3c39

Co-authored-by: motopods <58200641+motopods@users.noreply.github.com>
…ures

The previous EPC fix used SelectSubset<T, OmitWhere<XxxArgs>> in method
signatures where 'where' was separately typed. When TypeScript inferred T
from the full argument (which includes 'where'), the SelectSubset mapped
'where -> never' (not in OmitWhere<XxxArgs>). The intersection of
{ where: WhereUniqueInput } & { where: never } resolved to { where: never },
causing valid calls like update({ where: { id: 1 }, data: {...} }) to fail.

Fix: Use the full XxxArgs type (not OmitWhere) in SelectSubset/Subset calls.
Now 'where' in T maps to WhereUniqueInput/WhereInput (not never), making the
intersection resolve correctly. T's constraint stays OmitWhere<XxxArgs>.

EPC for 'where' in findMany/findFirst (optional where) still works. For
update/upsert/delete (required data + where), EPC on where was also not
effective in the original code (structural inference limitation), so no
regression there.

Agent-Logs-Url: https://github.com/motopods/zenstack/sessions/77303725-2398-4cdc-9512-8ffad96d4f9c

Co-authored-by: motopods <58200641+motopods@users.noreply.github.com>
Add SelectSubsetWithWhere and SubsetWithWhere type helpers that map the
'where' key to 'unknown' (not 'never') when it's absent from the target
type U. This prevents the { where: WhereInput } & { where: unknown }
intersection from collapsing to { where: never }, while still preserving
TypeScript's excess-property checking on the where argument.

Apply these helpers to update, upsert, updateMany, and updateManyAndReturn
(the methods where T is inferred to include 'where' because the user must
also pass a required field like data/create/update). Find/delete methods
are unaffected: their T stays {} when no required non-where fields are
present, so EPC already works via the separate { where: W } intersection.

Also fix pre-existing misplaced @ts-expect-error directives in the e2e
typecheck tests (error is on the excess property line, not the call site)
and remove an invalid name: { not: null } filter in the valid-usage test
(null is not assignable to StringFilter for a non-nullable field).

Agent-Logs-Url: https://github.com/motopods/zenstack/sessions/619af36f-7d8e-4fa2-b834-97cdda0cc09c

Co-authored-by: motopods <58200641+motopods@users.noreply.github.com>
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 7, 2026

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 1fe9ecad-d59d-4b15-8476-0fa1741057ce

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR refactors CRUD method signatures in the ORM client to explicitly model where clause typing using new helper types (OmitWhere, SubsetWithWhere, SelectSubsetWithWhere). It introduces support for $is filtering on delegate/polymorphic base models, including runtime filter construction and validation schemas, with comprehensive E2E and type-checking tests.

Changes

Cohort / File(s) Summary
Type System Helpers
packages/orm/src/client/crud-types.ts
Added new exported types: OmitWhere<T>, SubsetWithWhere<T, U>, SelectSubsetWithWhere<T, U>, and SubModelWhereInput<...> for delegate model $is filtering. Extended WhereInput to conditionally include optional $is field when model is a delegate base.
CRUD Method Signatures
packages/orm/src/client/contract.ts
Updated 9 method signatures (updateManyAndReturn, findMany, findUnique, findUniqueOrThrow, findFirst, findFirstOrThrow, update, updateMany, upsert, delete, deleteMany) to constrain generics with OmitWhere<...> and shape args with explicit where typing via intersection types using new subset helpers.
Filter Implementation
packages/orm/src/client/crud/dialects/base-dialect.ts
Added buildIsFilter() method to handle $is filtering for delegate models. Determines discriminator field, builds equality checks per sub-model, optionally applies correlated EXISTS subqueries for sub-model predicates, and combines conditions using OR semantics.
Schema Validation
packages/orm/src/client/zod/factory.ts
Extended makeWhereSchema() to support optional $is field in where clauses for delegate models with sub-models. Each sub-model value is wrapped in z.lazy() for recursive schema generation.
E2E Test Coverage
tests/e2e/orm/client-api/delegate.test.ts, tests/e2e/orm/schemas/delegate/typecheck.ts, tests/e2e/orm/schemas/typing/typecheck.ts
Added test cases validating $is filtering behavior (matching sub-model instances, applying nested predicates, OR semantics, delegated sub-model nesting) and TypeScript type-checking for where field validity on delegate and standard models.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

A rabbit hops through types so new,
OmitWhere and $is in view! 🐰
Delegates dance with polymorphic grace,
Where clauses find their proper place,
Filter logic blooms—a refactored space!

🚥 Pre-merge checks | ✅ 2 | ❌ 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 (2 passed)
Check name Status Explanation
Title check ✅ Passed The PR title accurately summarizes the main objective: enabling TypeScript excess-property checking on where clauses for all CRUD methods, which is the core change across multiple files.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ 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 and usage tips.

@motopods motopods marked this pull request as draft April 7, 2026 12:59
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/orm/src/client/crud/dialects/base-dialect.ts`:
- Around line 344-351: The current $is EXISTS subquery builds a manual
selectFrom and uses this.eb.exists, which prevents proper alias joins for
inherited fields and skips dialect EXISTS rewrites; instead, call
buildSelectModel(subModelName, subAlias, subWhere) to produce the subquery (so
inherited fields and relation aliases are resolved) and then wrap that subquery
with buildExistsExpression(subquery) rather than this.eb.exists; replace the
selectFrom/select(this.eb.lit(1)) block and this.eb.exists call with these two
helpers (referencing buildFilter only if needed for composing subWhere) so the
discriminatorCheck + buildExistsExpression(subquery) is pushed into conditions.

In `@packages/orm/src/client/zod/factory.ts`:
- Around line 490-501: Replace the permissive z.object used to build the
subModelSchema with z.strictObject so unknown sub-model keys are rejected
(preventing typos like Vidoe from being stripped); specifically change the
creation of subModelSchema (where you call
z.object(Object.fromEntries(modelDef.subModels.map(...)) ) ) to use
z.strictObject and keep the existing z.lazy(() => this.makeWhereSchema(subModel,
false, false, false, options)).nullish().optional() entries, and ensure
fields['$is'] is still set to subModelSchema.optional() so buildIsFilter no
longer receives a silently-widened payload.
🪄 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: 4abf263f-8c93-4316-b3af-dd2c71303007

📥 Commits

Reviewing files that changed from the base of the PR and between 39a0a28 and ff982b9.

📒 Files selected for processing (7)
  • packages/orm/src/client/contract.ts
  • packages/orm/src/client/crud-types.ts
  • packages/orm/src/client/crud/dialects/base-dialect.ts
  • packages/orm/src/client/zod/factory.ts
  • tests/e2e/orm/client-api/delegate.test.ts
  • tests/e2e/orm/schemas/delegate/typecheck.ts
  • tests/e2e/orm/schemas/typing/typecheck.ts

Comment on lines +344 to +351
const subWhereFilter = this.buildFilter(subModelName, subAlias, subWhere);

const existsSubquery = this.eb
.selectFrom(`${subModelName} as ${subAlias}`)
.select(this.eb.lit(1).as('__exists'))
.where(this.and(...joinConditions, subWhereFilter));

conditions.push(this.and(discriminatorCheck, this.eb.exists(existsSubquery)));
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.

⚠️ Potential issue | 🟠 Major

Build the $is EXISTS subquery from buildSelectModel() and buildExistsExpression().

subWhere is the full WhereInput of the sub-model, so it can reference inherited base fields and relations. With selectFrom(\${subModelName} as ${subAlias}`), aliases like Asset/Videoare never joined, so a valid filter such as$is: { Video: { viewCount: { gt: 0 } } }will generate broken refs. Also,this.eb.exists(...)` bypasses the dialect hook that handles same-table EXISTS rewrites.

🛠 Proposed fix
-                const existsSubquery = this.eb
-                    .selectFrom(`${subModelName} as ${subAlias}`)
+                const existsSubquery = this.buildSelectModel(subModelName, subAlias)
                     .select(this.eb.lit(1).as('__exists'))
                     .where(this.and(...joinConditions, subWhereFilter));
 
-                conditions.push(this.and(discriminatorCheck, this.eb.exists(existsSubquery)));
+                conditions.push(this.and(discriminatorCheck, this.buildExistsExpression(existsSubquery)));
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const subWhereFilter = this.buildFilter(subModelName, subAlias, subWhere);
const existsSubquery = this.eb
.selectFrom(`${subModelName} as ${subAlias}`)
.select(this.eb.lit(1).as('__exists'))
.where(this.and(...joinConditions, subWhereFilter));
conditions.push(this.and(discriminatorCheck, this.eb.exists(existsSubquery)));
const subWhereFilter = this.buildFilter(subModelName, subAlias, subWhere);
const existsSubquery = this.buildSelectModel(subModelName, subAlias)
.select(this.eb.lit(1).as('__exists'))
.where(this.and(...joinConditions, subWhereFilter));
conditions.push(this.and(discriminatorCheck, this.buildExistsExpression(existsSubquery)));
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/orm/src/client/crud/dialects/base-dialect.ts` around lines 344 -
351, The current $is EXISTS subquery builds a manual selectFrom and uses
this.eb.exists, which prevents proper alias joins for inherited fields and skips
dialect EXISTS rewrites; instead, call buildSelectModel(subModelName, subAlias,
subWhere) to produce the subquery (so inherited fields and relation aliases are
resolved) and then wrap that subquery with buildExistsExpression(subquery)
rather than this.eb.exists; replace the selectFrom/select(this.eb.lit(1)) block
and this.eb.exists call with these two helpers (referencing buildFilter only if
needed for composing subWhere) so the discriminatorCheck +
buildExistsExpression(subquery) is pushed into conditions.

Comment on lines +490 to +501
const subModelSchema = z.object(
Object.fromEntries(
modelDef.subModels.map((subModel) => [
subModel,
z
.lazy(() => this.makeWhereSchema(subModel, false, false, false, options))
.nullish()
.optional(),
]),
),
);
fields['$is'] = subModelSchema.optional();
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.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n packages/orm/src/client/zod/factory.ts | sed -n '485,505p'

Repository: zenstackhq/zenstack

Length of output: 1123


🏁 Script executed:

rg -n "subModelSchema\|strictObject" packages/orm/src/client/zod/factory.ts -A 2 -B 2

Repository: zenstackhq/zenstack

Length of output: 45


🏁 Script executed:

rg -n "\$is" packages/orm/src/client/crud/dialects/base-dialect.ts -A 5 -B 2

Repository: zenstackhq/zenstack

Length of output: 45


🏁 Script executed:

rg -n "z\.object|z\.strictObject" packages/orm/src/client/zod/factory.ts -B 1 -A 1 | head -100

Repository: zenstackhq/zenstack

Length of output: 3776


🏁 Script executed:

fd -type f -name "*.ts" -path "*dialects*" | head -10

Repository: zenstackhq/zenstack

Length of output: 233


🏁 Script executed:

rg -n "\\\$is" packages/orm/src/client/crud/ -A 3 -B 1 | head -80

Repository: zenstackhq/zenstack

Length of output: 1984


🏁 Script executed:

rg -n "z\.object\(" packages/orm/src/client/zod/factory.ts

Repository: zenstackhq/zenstack

Length of output: 111


🏁 Script executed:

rg -n "buildIsFilter" packages/orm/src/client/crud/dialects/base-dialect.ts -A 30 | head -80

Repository: zenstackhq/zenstack

Length of output: 3365


🏁 Script executed:

rg -n "createInvalidInputError" packages/orm/src/client/crud/dialects/base-dialect.ts -B 2

Repository: zenstackhq/zenstack

Length of output: 1781


🏁 Script executed:

rg -n "buildIsFilter" packages/orm/src/client/crud/dialects/base-dialect.ts -A 50 | tail -40

Repository: zenstackhq/zenstack

Length of output: 2244


🏁 Script executed:

cat -n packages/orm/src/client/crud/dialects/base-dialect.ts | sed -n '312,360p'

Repository: zenstackhq/zenstack

Length of output: 2975


Use z.strictObject(...) to reject typos in sub-model names instead of silently widening the query.

z.object(...) strips unknown keys. With a typo like $is: { Vidoe: {} } instead of $is: { Video: {} }, the unknown key is stripped, the payload becomes empty, and buildIsFilter returns this.true() at line 355 of base-dialect.ts—turning the typo into a match-all filter instead of a validation error. All other schemas in this file use z.strictObject(...).

🔒 Proposed fix
-            const subModelSchema = z.object(
+            const subModelSchema = z.strictObject(
                 Object.fromEntries(
                     modelDef.subModels.map((subModel) => [
                         subModel,
                         z
                             .lazy(() => this.makeWhereSchema(subModel, false, false, false, options))
                             .nullish()
                             .optional(),
                     ]),
                 ),
             );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/orm/src/client/zod/factory.ts` around lines 490 - 501, Replace the
permissive z.object used to build the subModelSchema with z.strictObject so
unknown sub-model keys are rejected (preventing typos like Vidoe from being
stripped); specifically change the creation of subModelSchema (where you call
z.object(Object.fromEntries(modelDef.subModels.map(...)) ) ) to use
z.strictObject and keep the existing z.lazy(() => this.makeWhereSchema(subModel,
false, false, false, options)).nullish().optional() entries, and ensure
fields['$is'] is still set to subModelSchema.optional() so buildIsFilter no
longer receives a silently-widened payload.

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