fix: enable TypeScript excess-property checking on where for all CRUD methods#2565
fix: enable TypeScript excess-property checking on where for all CRUD methods#2565motopods wants to merge 14 commits intozenstackhq:devfrom
Conversation
…hic) models Agent-Logs-Url: https://github.com/motopods/zenstack/sessions/678a87a1-1e67-44f9-95ee-e77f3d5e81cc Co-authored-by: motopods <58200641+motopods@users.noreply.github.com>
Agent-Logs-Url: https://github.com/motopods/zenstack/sessions/678a87a1-1e67-44f9-95ee-e77f3d5e81cc Co-authored-by: motopods <58200641+motopods@users.noreply.github.com>
…-content feat(orm): implement discriminated union return types for delegate (polymorphic) models
…egate (polymorphic) models"
…rence-for-content Revert "feat(orm): implement discriminated union return types for delegate (polymorphic) models"
Agent-Logs-Url: https://github.com/motopods/zenstack/sessions/895c8305-945e-4e45-bd13-2d89b4bc3c39 Co-authored-by: motopods <58200641+motopods@users.noreply.github.com>
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>
…ct.ts 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>
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
📝 WalkthroughWalkthroughThis PR refactors CRUD method signatures in the ORM client to explicitly model Changes
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ 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
🤖 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
📒 Files selected for processing (7)
packages/orm/src/client/contract.tspackages/orm/src/client/crud-types.tspackages/orm/src/client/crud/dialects/base-dialect.tspackages/orm/src/client/zod/factory.tstests/e2e/orm/client-api/delegate.test.tstests/e2e/orm/schemas/delegate/typecheck.tstests/e2e/orm/schemas/typing/typecheck.ts
| 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))); |
There was a problem hiding this comment.
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.
| 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.
| 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(); |
There was a problem hiding this comment.
🧩 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 2Repository: zenstackhq/zenstack
Length of output: 45
🏁 Script executed:
rg -n "\$is" packages/orm/src/client/crud/dialects/base-dialect.ts -A 5 -B 2Repository: 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 -100Repository: zenstackhq/zenstack
Length of output: 3776
🏁 Script executed:
fd -type f -name "*.ts" -path "*dialects*" | head -10Repository: zenstackhq/zenstack
Length of output: 233
🏁 Script executed:
rg -n "\\\$is" packages/orm/src/client/crud/ -A 3 -B 1 | head -80Repository: zenstackhq/zenstack
Length of output: 1984
🏁 Script executed:
rg -n "z\.object\(" packages/orm/src/client/zod/factory.tsRepository: zenstackhq/zenstack
Length of output: 111
🏁 Script executed:
rg -n "buildIsFilter" packages/orm/src/client/crud/dialects/base-dialect.ts -A 30 | head -80Repository: zenstackhq/zenstack
Length of output: 3365
🏁 Script executed:
rg -n "createInvalidInputError" packages/orm/src/client/crud/dialects/base-dialect.ts -B 2Repository: zenstackhq/zenstack
Length of output: 1781
🏁 Script executed:
rg -n "buildIsFilter" packages/orm/src/client/crud/dialects/base-dialect.ts -A 50 | tail -40Repository: 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.
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
whereclause, 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 — includingfindMany,findFirst,findUnique,findFirst/UniqueOrThrow,update,upsert,updateMany,updateManyAndReturn,delete, anddeleteMany.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
Tconstrained to the full args type (e.g.FindManyArgs), andargswas typed asSelectSubset<T, FindManyArgs>. TypeScript infersTfrom the whole argument literal — includingwhere— so by the time it checkswhere, thewhereproperty is already part ofTand EPC is defeated.The fix is to separate
whereout ofT: constrainTonly toOmitWhere<XxxArgs>so TypeScript never absorbswhereintoT, and declarewhereas a direct intersection member at the call site. Becausewhereis now a fresh object literal typed against the concreteWhereInput/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 inferTfrom the whole argument — including anywherethe caller supplies — before applying the mapped type. A naiveSubset<T, OmitWhere<XxxArgs>>would mapwhere → neverin the result becausewhereis no longer inU, and that collapses the intersection to{ where: never }, breaking valid calls.The fix introduces
SubsetWithWhere<T, U>/SelectSubsetWithWhere<T, U>: these behave exactly likeSubset/SelectSubsetbut mapwhere → unknown(notnever) whenwhereis absent fromU. Becauseunknownintersects transparently ({ where: WhereInput } & { where: unknown }={ where: WhereInput }), the final type is correct and EPC is preserved.Changes
packages/orm/src/client/crud-types.tsAdded three new type helpers:
OmitWhere<T>—Omit<T, 'where'>. Stripswherefrom the args constraint soTnever absorbs thewhereliteral, leaving it free for EPC.SubsetWithWhere<T, U>— LikeSubset<T, U>but mapswhere → unknownwhen absent fromU, avoidingwhere: neverin intersections for mutation methods.SelectSubsetWithWhere<T, U>— LikeSelectSubset<T, U>but with the samewhere → unknownrule as above.packages/orm/src/client/contract.tsAll CRUD method signatures updated to separate
wherefromT:whereparamfindManywhere?: WhereInputSelectSubset<T, FindManyArgs>findFirst/findFirstOrThrowwhere?: WhereInputSelectSubset<T, FindFirstArgs>findUnique/findUniqueOrThrowwhere: WhereUniqueInputSelectSubset<T, FindUniqueArgs>updatewhere: WhereUniqueInputSelectSubsetWithWhere<T, OmitWhere<UpdateArgs>>upsertwhere: WhereUniqueInputSelectSubsetWithWhere<T, OmitWhere<UpsertArgs>>updateManywhere?: WhereInputSubsetWithWhere<T, OmitWhere<UpdateManyArgs>>updateManyAndReturnwhere?: WhereInputSubsetWithWhere<T, OmitWhere<UpdateManyAndReturnArgs>>deletewhere: WhereUniqueInputSelectSubset<T, DeleteArgs>deleteManywhere?: WhereInputSubset<T, DeleteManyArgs>tests/e2e/orm/schemas/typing/typecheck.tsAdded
// @ts-expect-errorassertions for unknownwherefields across:findManyandfindFirst(infind())update,upsert, andupdateMany(inupdate())tests/e2e/orm/schemas/delegate/typecheck.tsAdded a new
whereEPC()function covering delegate (polymorphic) models:findManyandfindFirstwith unknownwherefieldsupdatewith an unknownwherefieldwherefields still compile cleanlySummary by CodeRabbit
Release Notes
New Features
$isfilter for advanced filtering on polymorphic models with support for nested sub-model conditions and OR semantics.whereclauses in CRUD operations with better compile-time validation.Tests