Skip to content

perf(orm): use EXISTS instead of COUNT subquery for some/none/every relation filters#2455

Merged
ymc9 merged 2 commits intodevfrom
fix/issue-2440-some-filter-use-exists
Mar 7, 2026
Merged

perf(orm): use EXISTS instead of COUNT subquery for some/none/every relation filters#2455
ymc9 merged 2 commits intodevfrom
fix/issue-2440-some-filter-use-exists

Conversation

@ymc9
Copy link
Member

@ymc9 ymc9 commented Mar 6, 2026

Summary

  • Replaces correlated COUNT(*) > 0 subqueries with EXISTS for { some }, { none }, and { every } relation filters
  • someEXISTS (SELECT 1 FROM ... WHERE <filter>)
  • noneNOT EXISTS (SELECT 1 FROM ... WHERE <filter>)
  • everyNOT EXISTS (SELECT 1 FROM ... WHERE NOT <filter>)

The old COUNT pattern executed once per parent row (O(n)), causing ~20s response times on tables with 928k rows. EXISTS short-circuits on the first match, reducing execution to <1ms with proper indexes.

Fixes #2440

Test plan

  • Added regression test tests/regression/test/issue-2440.test.ts covering all three filter operators (some, none, every)
  • Existing e2e relation filter tests continue to pass

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Improved related-record filtering for some, none, and every conditions by switching to direct existence checks, yielding correct and more efficient results across dialects.
  • Tests

    • Added regression tests covering some, none, and every filtering scenarios to ensure correct behavior with various related-record setups.

…elation filters

Fixes correlated COUNT subquery that caused O(n) performance (one
subquery execution per parent row) for relation filters. Replaces
with EXISTS which short-circuits on first match.

Fixes #2440

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@claude
Copy link
Contributor

claude bot commented Mar 6, 2026

Claude finished @ymc9's task —— View job


PR Review: EXISTS optimization for relation filters

Progress

  • Read and analyze the main implementation changes
  • Review the regression test coverage
  • Check for potential bugs or edge cases
  • Evaluate performance implications
  • Assess security considerations
  • Provide comprehensive feedback

Analysis of Main Implementation

The changes in base-dialect.ts:447-476 implement a clean optimization by replacing the previous COUNT approach with EXISTS queries:

Before (lines 447-478):

const countSelect = (negate: boolean) => {
    return this.eb
        .selectFrom(/* nested count subquery with outer wrapper for MySQL scope */)
        .select('$count');
};
// Then: countSelect(false) > 0, countSelect(true) = 0, etc.

After (lines 447-455):

const existsSelect = (negate: boolean) => {
    return this.eb.exists(/* direct EXISTS query */);
};
// Then: existsSelect(false), NOT existsSelect(true), etc.

Code Quality Assessment ✅

Strengths:

  • Clean helper function approach eliminates code duplication
  • Correct logical implementation: every = NOT EXISTS (NOT filter) using De Morgan's law
  • Maintains same join logic (buildPkFkWhereRefs) and filter building
  • Removes MySQL-specific outer query wrapper (line 452-460 in old code) - simpler and more universal

Architecture: The change maintains all existing interfaces and abstractions. The buildPkFkWhereRefs function handles both regular FK relations and many-to-many join tables correctly.

Test Coverage Analysis ✅

The regression test provides excellent coverage:

  • some filter: Users with at least one matching post
  • none filter: Users with no matching posts
  • every filter: Users where all posts match, including vacuous truth for empty relations
  • Edge case: Mixed values and empty relations properly handled

Performance Analysis ✅

Significant improvement:

Potential Issues & Edge Cases ✅

Reviewed for:

  • ✅ SQL injection: Uses parameterized queries via Kysely's expression builder
  • ✅ Empty relations: every correctly returns true (vacuous truth), none returns true, some returns false
  • ✅ NULL handling: Existing filter logic handles NULLs appropriately
  • ✅ Many-to-many relations: Uses same buildPkFkWhereRefs logic as before
  • ✅ Complex filters: Nested conditions work the same way via buildFilter recursion
  • ✅ Multiple database support: EXISTS is standard SQL, more portable than the COUNT approach

Security Considerations ✅

No security concerns identified:

  • Uses Kysely's expression builder for safe SQL generation
  • No raw SQL strings or dynamic query building
  • Parameterized queries prevent SQL injection

Final Assessment

Verdict: Excellent optimization with no issues identified

This is a textbook performance optimization that:

  1. Dramatically improves performance for the target use case
  2. Maintains identical semantics and behavior
  3. Simplifies the codebase by removing database-specific workarounds
  4. Has comprehensive test coverage
  5. Uses safer, more standard SQL patterns

Recommendation: Approve and merge. This change will significantly benefit users with large datasets using relation filters.


@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 6, 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: af22adce-5174-45f6-88e1-0f53bb42f7e6

📥 Commits

Reviewing files that changed from the base of the PR and between 316f099 and ed92af9.

📒 Files selected for processing (2)
  • packages/orm/src/client/crud/dialects/base-dialect.ts
  • packages/orm/src/client/crud/dialects/mysql.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/orm/src/client/crud/dialects/base-dialect.ts

📝 Walkthrough

Walkthrough

Replaces COUNT-based correlated subqueries with EXISTS predicates for relation filters (some, every, none), adds a canonicalized buildExistsExpression hook, a MySQL-specific EXISTS wrapper, and regression tests exercising the new behavior.

Changes

Cohort / File(s) Summary
Core dialect logic
packages/orm/src/client/crud/dialects/base-dialect.ts
Replaced count-based subquery construction with an existsSelect helper and switched some/every/none cases to use EXISTS/NOT EXISTS. Added protected buildExistsExpression(innerQuery: SelectQueryBuilder<any, any, any>): Expression<SqlBool> as canonical hook.
MySQL dialect override
packages/orm/src/client/crud/dialects/mysql.ts
Added protected override buildExistsExpression(...) that wraps the inner query in a derived table (aliased '$exists_sub') and builds an EXISTS over SELECT 1 from that derived table to avoid direct self-reference issues on MySQL.
Regression tests
tests/regression/test/issue-2440.test.ts
Added tests seeding User/Post data and asserting correct results for some, none, and every relation filters to validate the EXISTS-based behavior.
Manifest
package.json
Small manifest change (lines changed: +18/-16) present in diff.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 I nibble on SQL, and hop through the groves,
COUNT made me worry; EXISTS now solves.
I wrap up a subquery, alias with care,
Faster the query — I dance in the air! ✨

🚥 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 Title accurately describes the main change: replacing COUNT subqueries with EXISTS patterns for relation filters (some/none/every).
Linked Issues check ✅ Passed All coding requirements from issue #2440 are met: COUNT replaced with EXISTS for some/none/every filters, regression tests added, and MySQL dialect override implemented.
Out of Scope Changes check ✅ Passed All changes are directly related to fixing issue #2440: refactoring COUNT to EXISTS in base dialect, MySQL-specific override, and regression tests.
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 docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix/issue-2440-some-filter-use-exists

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.

…ABLE_USED

MySQL raises "You can't specify target table for update in FROM clause"
when a DELETE/UPDATE references the same table in an EXISTS subquery.
Fix by overriding buildExistsExpression in MySqlCrudDialect to wrap the
inner SELECT in a derived table, which MySQL treats as a separate entity.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@claude
Copy link
Contributor

claude bot commented Mar 6, 2026

Claude finished @ymc9's task —— View job


PR Review: EXISTS optimization for relation filters

Progress

  • Read and analyze the main implementation changes
  • Review the MySQL-specific changes
  • Review the regression test coverage
  • Check for potential bugs or edge cases
  • Evaluate performance implications
  • Assess security considerations
  • Provide comprehensive feedback

Analysis of Main Implementation

The changes in base-dialect.ts:447-476 implement a clean optimization by replacing the previous COUNT approach with EXISTS queries:

Before (lines 447-478):

const countSelect = (negate: boolean) => {
    return this.eb
        .selectFrom(/* nested count subquery with outer wrapper for MySQL scope */)
        .select('$count');
};
// Then: countSelect(false) > 0, countSelect(true) = 0, etc.

After (lines 447-455):

const existsSelect = (negate: boolean) => {
    const filter = this.buildFilter(relationModel, relationFilterSelectAlias, subPayload);
    const innerQuery = this.buildSelectModel(relationModel, relationFilterSelectAlias)
        .select(this.eb.lit(1).as('_'))
        .where(buildPkFkWhereRefs(this.eb))
        .where(() => (negate ? this.eb.not(filter) : filter));
    return this.buildExistsExpression(innerQuery);
};
// Then: existsSelect(false), NOT existsSelect(true), etc.

Code Quality Assessment ✅

Strengths:

  • Clean helper function approach eliminates code duplication
  • Correct logical implementation: every = NOT EXISTS (NOT filter) using De Morgan's law
  • Maintains same join logic (buildPkFkWhereRefs) and filter building
  • Removes MySQL-specific outer query wrapper (line 452-460 in old code) - simpler and more universal

Architecture: The change maintains all existing interfaces and abstractions. The buildPkFkWhereRefs function handles both regular FK relations and many-to-many join tables correctly.

MySQL Dialect Enhancement ✅

The MySQL dialect adds a crucial optimization in mysql.ts:180-185:

protected override buildExistsExpression(innerQuery: SelectQueryBuilder<any, any, any>): Expression<SqlBool> {
    // MySQL doesn't allow referencing the target table of a DELETE/UPDATE in a subquery
    // directly within the same statement. Wrapping in a derived table materializes the
    // subquery, making it a separate virtual table that MySQL accepts.
    return this.eb.exists(this.eb.selectFrom(innerQuery.as('$exists_sub')).select(this.eb.lit(1).as('_')));
}

This addresses MySQL's "can't specify target table for update in FROM clause" limitation by wrapping the EXISTS subquery in a derived table.

Test Coverage Analysis ✅

The regression test provides excellent coverage:

  • some filter: Users with at least one matching post
  • none filter: Users with no matching posts
  • every filter: Users where all posts match, including vacuous truth for empty relations
  • Edge case: Mixed values and empty relations properly handled

Test Logic Validation:

  • Line 43-47: some { value: { gt: 2 } } correctly returns only userA (has posts with values 1,3)
  • Line 72-79: none { value: { gt: 2 } } correctly returns userB (value 2) and userC (no posts)
  • Line 112-117: every { value: { gt: 2 } } correctly returns userA (all > 2) and userC (vacuous truth)

Performance Analysis ✅

Significant improvement:

Theoretical performance:

  • some: EXISTS finds first matching record and stops
  • none: NOT EXISTS stops on first match (proving it's not "none")
  • every: NOT EXISTS(NOT condition) stops on first non-matching record

Potential Issues & Edge Cases ✅

Reviewed for:

  • ✅ SQL injection: Uses parameterized queries via Kysely's expression builder
  • ✅ Empty relations: every correctly returns true (vacuous truth), none returns true, some returns false
  • ✅ NULL handling: Existing filter logic handles NULLs appropriately
  • ✅ Many-to-many relations: Uses same buildPkFkWhereRefs logic as before
  • ✅ Complex filters: Nested conditions work the same way via buildFilter recursion
  • ✅ Multiple database support: EXISTS is standard SQL, more portable than the COUNT approach

Security Considerations ✅

No security concerns identified:

  • Uses Kysely's expression builder for safe SQL generation
  • No raw SQL strings or dynamic query building
  • Parameterized queries prevent SQL injection

Final Assessment

Verdict: Excellent optimization with no issues identified

This is a textbook performance optimization that:

  1. Dramatically improves performance for the target use case
  2. Maintains identical semantics and behavior
  3. Simplifies the codebase by removing database-specific workarounds
  4. Has comprehensive test coverage
  5. Uses safer, more standard SQL patterns

Recommendation: Approve and merge. This change will significantly benefit users with large datasets using relation filters.


@ymc9 ymc9 merged commit 158996c into dev Mar 7, 2026
8 checks passed
@ymc9 ymc9 deleted the fix/issue-2440-some-filter-use-exists branch March 7, 2026 04:59
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: where { relation: { some } } generates slow correlated COUNT subquery instead of EXISTS

1 participant