Skip to content

fix(orm): unify zenstack v3 and prisma behavior on empty branches in OR filter#2528

Merged
ymc9 merged 7 commits into
zenstackhq:devfrom
eredzik:fix/or-queries
Apr 3, 2026
Merged

fix(orm): unify zenstack v3 and prisma behavior on empty branches in OR filter#2528
ymc9 merged 7 commits into
zenstackhq:devfrom
eredzik:fix/or-queries

Conversation

@eredzik
Copy link
Copy Markdown
Contributor

@eredzik eredzik commented Mar 30, 2026

Summary

I found this issue in our migration to v3 where behavior differed from previous prisma based v2 version.
For example queries such as:

await client.testItem.findMany({
    where: { OR: [{ name: undefined }] },
  });

Is treated by zenstack as:

select "TestItem"."id" as "id", "TestItem"."name" as "name", "TestItem"."createdAt" as "createdAt" from "TestItem" where 1 

but prisma interpreted it as:

SELECT `main`.`TestItem`.`id`, `main`.`TestItem`.`name`, `main`.`TestItem`.`createdAt` FROM `main`.`TestItem` WHERE 1=0 LIMIT ? OFFSET ?

Work done

  • Added test to verify such case
  • Added fix in code to unify the behavior to how prisma treated such cases
  • Ran all tests locally and they passed

Possible further discussions

The way zenstack interprets these queries seems more intuitive than prisma. Question is whether project aims for 1:1 behavior parity with how prisma queries data (I assume so)?

Summary by CodeRabbit

  • Bug Fixes

    • Query filters now ignore wholly-undefined branches and skip undefined operator values; AND/OR/NOT with no effective branches behave consistently (AND/NOT act as no-op/true, OR yields no matches).
    • Relation operations: empty relation payloads are treated as “match all” for disconnect/delete; single-delete vs multi-delete behavior and validation tightened so delete vs delete-many act predictably.
  • Tests

    • Added/updated E2E tests covering these filter and relation behaviors.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Mar 30, 2026

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

Updated filter construction to ignore payload branches and operator entries that are entirely undefined; adjusted relation delete/disconnect semantics and added an isEmptyObject utility; new E2E tests validate logical-combinator behavior with undefined and empty filters.

Changes

Cohort / File(s) Summary
Filter dialect changes
packages/orm/src/client/crud/dialects/base-dialect.ts
Normalize composite logical payloads by removing branch objects whose fields are all undefined; treat AND/OR/NOT with zero branches as true/false/true respectively (per implementation); skip operator entries with value === undefined in standard and string filters.
Operation handler updates
packages/orm/src/client/crud/operations/base.ts
Use isEmptyObject for empty-data checks; when where is absent, default combined where to true; propagate uniqueDelete flag into deleteRelation(...); adjust delete-condition mapping and expected-delete-count logic so only unique deletes enforce row-count checks.
Utility addition
packages/orm/src/utils/object-utils.ts
Added exported isEmptyObject(x: unknown) which returns true only for plain objects with zero own keys (uses isPlainObject).
E2E tests — filter behavior
tests/e2e/orm/client-api/filter.test.ts
Added tests covering: ignoring undefined branches inside OR, stripping undefined operator values inside OR branches, and AND/OR/NOT behavior with empty or all-undefined filters.
E2E tests — NOT behavior expectation
tests/e2e/orm/client-api/find.test.ts
Adjusted expectation for NOT: [] to match updated semantics (now expects 2 results).

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

I nibble through branches, light and spry,
Where undefined leaves once blocked the sky.
I toss the blanks, let true filters play,
ANDs hum along, ORs find their way.
A tidy hop — the queries cheer, hooray! 🐇✨

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: unifying zenstack v3 and Prisma behavior for OR filters with empty/undefined branches, which is the core fix implemented across multiple files.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ 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.

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

🧹 Nitpick comments (1)
tests/e2e/orm/client-api/filter.test.ts (1)

804-832: Please add the single-undefined-branch OR case to this test.

This test validates mixed branches well, but it misses OR: [{ id: undefined }], which is the core parity edge case.

Suggested addition
     const withUndefinedBranch = await client.user.findFirst({
         where: {
             OR: [{ id: undefined }, { id: user2.id }],
         } as any,
         orderBy: { createdAt: 'asc' },
     });

+    const onlyUndefinedBranch = await client.user.findFirst({
+        where: {
+            OR: [{ id: undefined }],
+        } as any,
+        orderBy: { createdAt: 'asc' },
+    });
+
     expect(baseline?.email).toBe(user2.email);
     expect(withUndefinedBranch?.email).toBe(baseline?.email);
+    expect(onlyUndefinedBranch).toBeNull();
 });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/e2e/orm/client-api/filter.test.ts` around lines 804 - 832, Add a new
assertion that checks the single-undefined-branch OR case by calling
client.user.findFirst with where: { OR: [{ id: undefined }] } (similar to the
existing baseline and withUndefinedBranch calls) and assert its email (or
result) equals the baseline; locate the test "ignores undefined branch inside OR
filter" and add a call like the baseline/withUndefinedBranch patterns and an
expect comparing the single-undefined result to baseline?.email so the OR: [{
id: undefined }] parity edge case is covered.
🤖 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 267-274: The OR branch currently returns this.true() when all
sub-branches are filtered out, causing things like OR: [{ id: undefined }] to
match-all; update the OR handler in with('OR', ...) (within buildFilter /
enumerate / isTrue logic) so that when meaningfulBranches.length === 0 it
returns this.false() (not this.true()), i.e., treat an OR of
only-empty/undefined branches as no-match; keep the existing behavior for an
empty array (allBranches.length === 0) as false as well.

In `@packages/orm/src/client/crud/operations/base.ts`:
- Around line 2539-2544: The loop in doNormalizeArgs only recurses when
isPlainObject(value), so array-valued properties (e.g., where.OR) never get
normalized; update the logic inside the for (const [key, value] of
Object.entries(args)) loop to detect arrays (Array.isArray(value)) and iterate
over their elements, calling this.doNormalizeArgs(elem) for each element that is
a plain object (and optionally removing undefined elements), while keeping the
existing deletion of undefined for non-array entries; reference doNormalizeArgs,
isPlainObject, args, and the where.OR case to locate the change.

---

Nitpick comments:
In `@tests/e2e/orm/client-api/filter.test.ts`:
- Around line 804-832: Add a new assertion that checks the
single-undefined-branch OR case by calling client.user.findFirst with where: {
OR: [{ id: undefined }] } (similar to the existing baseline and
withUndefinedBranch calls) and assert its email (or result) equals the baseline;
locate the test "ignores undefined branch inside OR filter" and add a call like
the baseline/withUndefinedBranch patterns and an expect comparing the
single-undefined result to baseline?.email so the OR: [{ id: undefined }] parity
edge case is covered.
🪄 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: 779ec2fb-2da5-49f5-87b1-766b8b51806f

📥 Commits

Reviewing files that changed from the base of the PR and between 9346fe9 and 2b2aef2.

📒 Files selected for processing (3)
  • packages/orm/src/client/crud/dialects/base-dialect.ts
  • packages/orm/src/client/crud/operations/base.ts
  • tests/e2e/orm/client-api/filter.test.ts

Comment thread packages/orm/src/client/crud/dialects/base-dialect.ts
Comment thread packages/orm/src/client/crud/operations/base.ts Outdated
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.

♻️ Duplicate comments (1)
packages/orm/src/client/crud/dialects/base-dialect.ts (1)

267-271: ⚠️ Potential issue | 🟠 Major

Prune OR branches after normalization, not by raw payload shape.

isAllUndefinedFilter() only catches top-level { field: undefined }. A branch like OR: [{ name: { contains: undefined } }] survives Line 269, then normalizes to true, so Line 271 still becomes match-all instead of no-match. The pruning needs to happen on the built expression (!this.isTrue(expr)), while keeping an explicit literal true branch.

Suggested fix
 .with('OR', () => {
-    const branches = enumerate(payload)
-        .filter((subPayload) => !this.isAllUndefinedFilter(subPayload))
-        .map((subPayload) => this.buildFilter(model, modelAlias, subPayload));
-    return this.or(...branches);
+    const branches = enumerate(payload).map((subPayload) => ({
+        subPayload,
+        expr: this.buildFilter(model, modelAlias, subPayload),
+    }));
+
+    const meaningfulBranches = branches.filter(
+        ({ subPayload, expr }) => subPayload === true || !this.isTrue(expr),
+    );
+
+    return this.or(...meaningfulBranches.map(({ expr }) => expr));
 })
-    private isAllUndefinedFilter(payload: unknown): boolean {
-        if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
-            return false;
-        }
-        const entries = Object.entries(payload);
-        return entries.length > 0 && entries.every(([, v]) => v === undefined);
-    }

Also applies to: 1313-1319

🤖 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 267 -
271, The OR branch pruning currently uses isAllUndefinedFilter on the raw
payload which misses cases that normalize to a true expression; update the
.with('OR', ...) handler so you build each branch via this.buildFilter(model,
modelAlias, subPayload) first, then filter out branches where this.isTrue(expr)
is true (but preserve explicit literal true branches), and finally pass the
remaining normalized branches into this.or(...branches); use this.isTrue to
decide pruning instead of this.isAllUndefinedFilter and keep the explicit true
branch handling consistent with other dialect code (see isAllUndefinedFilter,
buildFilter, isTrue, enumerate, and or).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@packages/orm/src/client/crud/dialects/base-dialect.ts`:
- Around line 267-271: The OR branch pruning currently uses isAllUndefinedFilter
on the raw payload which misses cases that normalize to a true expression;
update the .with('OR', ...) handler so you build each branch via
this.buildFilter(model, modelAlias, subPayload) first, then filter out branches
where this.isTrue(expr) is true (but preserve explicit literal true branches),
and finally pass the remaining normalized branches into this.or(...branches);
use this.isTrue to decide pruning instead of this.isAllUndefinedFilter and keep
the explicit true branch handling consistent with other dialect code (see
isAllUndefinedFilter, buildFilter, isTrue, enumerate, and or).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 47112bf7-5d02-4524-87e3-5c3ff34dd123

📥 Commits

Reviewing files that changed from the base of the PR and between fab0901 and 97df5de.

📒 Files selected for processing (1)
  • packages/orm/src/client/crud/dialects/base-dialect.ts

Copy link
Copy Markdown
Member

@ymc9 ymc9 left a comment

Choose a reason for hiding this comment

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

Hi @eredzik , thanks for working on the fix! Please see my comments attached.

Comment thread packages/orm/src/client/crud/dialects/base-dialect.ts
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.

🧹 Nitpick comments (1)
packages/orm/src/client/crud/operations/base.ts (1)

1155-1155: Type annotation mismatch with boolean default value.

The variable combinedWhere has type Record<string, unknown>, but where ?? true can assign a boolean. While buildFilter correctly handles boolean inputs per its signature (where: boolean | object | undefined), the type annotation here should reflect that to maintain type safety.

💡 Suggested fix
-let combinedWhere: Record<string, unknown> = where ?? true;
+let combinedWhere: Record<string, unknown> | boolean = where ?? true;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/orm/src/client/crud/operations/base.ts` at line 1155, The local
variable combinedWhere is incorrectly typed as Record<string, unknown> while it
is initialized from where ?? true which can be a boolean; change the type
annotation to boolean | Record<string, unknown> (or the exact union used by
buildFilter) so combinedWhere's type matches buildFilter's signature and callers
(refer to combinedWhere and buildFilter in base.ts) and update any narrowings if
necessary.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@packages/orm/src/client/crud/operations/base.ts`:
- Line 1155: The local variable combinedWhere is incorrectly typed as
Record<string, unknown> while it is initialized from where ?? true which can be
a boolean; change the type annotation to boolean | Record<string, unknown> (or
the exact union used by buildFilter) so combinedWhere's type matches
buildFilter's signature and callers (refer to combinedWhere and buildFilter in
base.ts) and update any narrowings if necessary.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 5df59c9d-0fa8-4014-8ea7-27080dd99b27

📥 Commits

Reviewing files that changed from the base of the PR and between 97df5de and 09a0ee1.

📒 Files selected for processing (5)
  • packages/orm/src/client/crud/dialects/base-dialect.ts
  • packages/orm/src/client/crud/operations/base.ts
  • packages/orm/src/utils/object-utils.ts
  • tests/e2e/orm/client-api/filter.test.ts
  • tests/e2e/orm/client-api/find.test.ts
✅ Files skipped from review due to trivial changes (1)
  • tests/e2e/orm/client-api/filter.test.ts

@ymc9 ymc9 merged commit c6c8ad1 into zenstackhq:dev Apr 3, 2026
12 checks passed
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