Skip to content

perf(orm): use EXISTS instead of COUNT subquery for to-one relation filters#2579

Merged
ymc9 merged 1 commit intozenstackhq:devfrom
evgenovalov:fix/issue-2578-to-one-relation-exists
Apr 15, 2026
Merged

perf(orm): use EXISTS instead of COUNT subquery for to-one relation filters#2579
ymc9 merged 1 commit intozenstackhq:devfrom
evgenovalov:fix/issue-2578-to-one-relation-exists

Conversation

@evgenovalov
Copy link
Copy Markdown
Contributor

@evgenovalov evgenovalov commented Apr 15, 2026

Summary

  • Replaces correlated COUNT(*) > 0 subqueries with EXISTS in buildToOneRelationFilter for relation predicates like { relation: { field: value } }, { relation: { is: {...} } }, and { relation: { isNot: {...} } }
  • Sibling fix to perf(orm): use EXISTS instead of COUNT subquery for some/none/every relation filters #2455, which applied the same change to buildToManyRelationFilter (some/none/every)
  • is: {...} / default payload → EXISTS (...)
  • is: nullNOT EXISTS (...)
  • isNot: nullEXISTS (...)
  • isNot: {...}NOT EXISTS (...) OR NOT EXISTS(... WHERE filter)

The old COUNT pattern prevents PostgreSQL from converting the subquery into a semi-join, so the aggregate executes once per parent row. On a production dataset with 2.6M product_site rows and an ~8.5M-row join table, execution time drops from ~2100 ms to ~49 ms (~43×) with the same indexes and the same filter set. EXISTS short-circuits on the first match and the planner picks a Parallel Hash Semi Join driven by the existing covering index.

Fixes #2578.

Test plan

  • Added regression test tests/regression/test/issue-2578.test.ts covering:
    • default to-one field predicate ({ user: { name: 'A' } })
    • { user: { is: {...} } }
    • { user: { is: null } }
    • { user: { isNot: null } }
    • { user: { isNot: {...} } } (including rows with no related record)
  • Existing issue-2440.test.ts (the some/none/every sibling) still passes
  • MySQL's buildExistsExpression override carries over unchanged (derived-table wrapping is handled by the existing helper)

🤖 Generated with Claude Code

Summary by CodeRabbit

Release Notes

  • Bug Fixes
    • Fixed to-one relation filtering to correctly handle various filter conditions including null checks, value predicates, and negated filters.

…ilters

Sibling fix to zenstackhq#2455. `buildToOneRelationFilter` was emitting
`(select count(1) ...) > 0` for relation predicates like
`{ relation: { field: value } }` and `{ relation: { is: {...} } }`.
PostgreSQL cannot convert that into a semi-join, so the aggregate
fires once per parent row and performance collapses on large tables.

Switch to the `buildExistsExpression` helper already used by
`buildToManyRelationFilter`, mapping:

- `is: {...}` / default payload  -> EXISTS
- `is: null`                     -> NOT EXISTS
- `isNot: null`                  -> EXISTS
- `isNot: {...}`                 -> NOT EXISTS OR NOT EXISTS(with filter)

Same semantics, but the planner can now turn it into a proper
semi-join. On a 2.6M-row product_site table with the right index,
execution drops from ~2100 ms to ~49 ms (~43x).

Fixes zenstackhq#2578
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 15, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 9fac673f-b612-414f-a285-98e4e5556beb

📥 Commits

Reviewing files that changed from the base of the PR and between ad8d5fb and 2553389.

📒 Files selected for processing (2)
  • packages/orm/src/client/crud/dialects/base-dialect.ts
  • tests/regression/test/issue-2578.test.ts

📝 Walkthrough

Walkthrough

This PR refactors the to-one relation filter generation in the base dialect to replace COUNT-based subqueries with EXISTS-based semantics, improving query optimization. A regression test is added to validate the correctness of various to-one relation filter scenarios.

Changes

Cohort / File(s) Summary
Core Logic Refactoring
packages/orm/src/client/crud/dialects/base-dialect.ts
Replaced COUNT-based join subqueries with EXISTS-based semantics in buildToOneRelationFilter. Introduced baseJoin (selecting a constant) and existsSelect helper for optional relation filters. Updated control flow for { is: null }, { is: <value> }, { isNot: null }, { isNot: <value> }, and direct filters to use NOT EXISTS and EXISTS expressions instead of numeric comparisons.
Regression Test Coverage
tests/regression/test/issue-2578.test.ts
Added new test suite for issue #2578 verifying to-one relation filter correctness across five scenarios: direct field predicates, is and isNot filters with values/null, and combinations testing expected result sets.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related issues

Possibly related PRs

Poem

🐰 ✨ A hop and a skip through databases vast,
COUNT joins are slow, but EXISTS is fast!
No more correlated SubPlans take flight,
Just semi-joins now—blazing through night! 🚀

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: replacing COUNT subqueries with EXISTS for to-one relation filters, which is the core optimization described in the changeset.
Linked Issues check ✅ Passed The code changes successfully implement the proposed fix from issue #2578: refactoring buildToOneRelationFilter to use EXISTS/NOT EXISTS instead of COUNT comparisons, with comprehensive regression tests covering all filter patterns.
Out of Scope Changes check ✅ Passed All changes are scoped to the stated objectives: modifications to buildToOneRelationFilter logic and a new regression test file for issue #2578, with no extraneous alterations.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

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

LGTM. Thanks for making this great fix @evgenovalov !

@ymc9 ymc9 merged commit 36da18a into zenstackhq:dev Apr 15, 2026
8 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.

Performance: to-one relation filter generates slow COUNT subquery instead of EXISTS (sibling of #2440)

2 participants