Skip to content

fix(db): left join where filter on right-side field incorrectly includes unmatched rows#1254

Merged
kevin-dp merged 5 commits intomainfrom
left-join-is-undefined-filter
Feb 17, 2026
Merged

fix(db): left join where filter on right-side field incorrectly includes unmatched rows#1254
kevin-dp merged 5 commits intomainfrom
left-join-is-undefined-filter

Conversation

@kevin-dp
Copy link
Contributor

@kevin-dp kevin-dp commented Feb 17, 2026

Summary

  • Adds a failing test that reproduces a bug where .where(({ r }) => isUndefined(r?.payload)) after a leftJoin returns all left rows with the right side stripped to undefined, instead of excluding rows whose right-side payload is defined.
  • Fixes the query optimizer to stop pushing single-source WHERE clauses into the nullable side of outer join subqueries, which was changing join semantics (turning excluded rows into unmatched left-join rows that then pass the residual filter).

Fixes #685

Test plan

  • New unit test where with isUndefined on right-side field should filter entire joined rows passes
  • Existing join tests continue to pass
  • Verify no regressions in optimizer push-down for inner joins

🤖 Generated with Claude Code

Verifies that `.where(({ r }) => isUndefined(r?.payload))` after a
leftJoin correctly filters out rows where the right side matched but
the field has a defined value, rather than stripping the right side
to undefined and keeping all rows.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@changeset-bot
Copy link

changeset-bot bot commented Feb 17, 2026

🦋 Changeset detected

Latest commit: 3134019

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 12 packages
Name Type
@tanstack/db Patch
@tanstack/angular-db Patch
@tanstack/electric-db-collection Patch
@tanstack/offline-transactions Patch
@tanstack/powersync-db-collection Patch
@tanstack/query-db-collection Patch
@tanstack/react-db Patch
@tanstack/rxdb-db-collection Patch
@tanstack/solid-db Patch
@tanstack/svelte-db Patch
@tanstack/trailbase-db-collection Patch
@tanstack/vue-db Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new
Copy link

pkg-pr-new bot commented Feb 17, 2026

More templates

@tanstack/angular-db

npm i https://pkg.pr.new/@tanstack/angular-db@1254

@tanstack/db

npm i https://pkg.pr.new/@tanstack/db@1254

@tanstack/db-ivm

npm i https://pkg.pr.new/@tanstack/db-ivm@1254

@tanstack/electric-db-collection

npm i https://pkg.pr.new/@tanstack/electric-db-collection@1254

@tanstack/offline-transactions

npm i https://pkg.pr.new/@tanstack/offline-transactions@1254

@tanstack/powersync-db-collection

npm i https://pkg.pr.new/@tanstack/powersync-db-collection@1254

@tanstack/query-db-collection

npm i https://pkg.pr.new/@tanstack/query-db-collection@1254

@tanstack/react-db

npm i https://pkg.pr.new/@tanstack/react-db@1254

@tanstack/rxdb-db-collection

npm i https://pkg.pr.new/@tanstack/rxdb-db-collection@1254

@tanstack/solid-db

npm i https://pkg.pr.new/@tanstack/solid-db@1254

@tanstack/svelte-db

npm i https://pkg.pr.new/@tanstack/svelte-db@1254

@tanstack/trailbase-db-collection

npm i https://pkg.pr.new/@tanstack/trailbase-db-collection@1254

@tanstack/vue-db

npm i https://pkg.pr.new/@tanstack/vue-db@1254

commit: 3134019

@github-actions
Copy link
Contributor

github-actions bot commented Feb 17, 2026

Size Change: +60 B (+0.07%)

Total Size: 92.1 kB

Filename Size Change
./packages/db/dist/esm/query/optimizer.js 2.62 kB +60 B (+2.34%)
ℹ️ View Unchanged
Filename Size
./packages/db/dist/esm/collection/change-events.js 1.39 kB
./packages/db/dist/esm/collection/changes.js 1.22 kB
./packages/db/dist/esm/collection/events.js 388 B
./packages/db/dist/esm/collection/index.js 3.32 kB
./packages/db/dist/esm/collection/indexes.js 1.1 kB
./packages/db/dist/esm/collection/lifecycle.js 1.75 kB
./packages/db/dist/esm/collection/mutations.js 2.34 kB
./packages/db/dist/esm/collection/state.js 3.49 kB
./packages/db/dist/esm/collection/subscription.js 3.71 kB
./packages/db/dist/esm/collection/sync.js 2.41 kB
./packages/db/dist/esm/deferred.js 207 B
./packages/db/dist/esm/errors.js 4.7 kB
./packages/db/dist/esm/event-emitter.js 748 B
./packages/db/dist/esm/index.js 2.69 kB
./packages/db/dist/esm/indexes/auto-index.js 742 B
./packages/db/dist/esm/indexes/base-index.js 766 B
./packages/db/dist/esm/indexes/btree-index.js 2.17 kB
./packages/db/dist/esm/indexes/lazy-index.js 1.1 kB
./packages/db/dist/esm/indexes/reverse-index.js 538 B
./packages/db/dist/esm/local-only.js 808 B
./packages/db/dist/esm/local-storage.js 2.1 kB
./packages/db/dist/esm/optimistic-action.js 359 B
./packages/db/dist/esm/paced-mutations.js 496 B
./packages/db/dist/esm/proxy.js 3.75 kB
./packages/db/dist/esm/query/builder/functions.js 733 B
./packages/db/dist/esm/query/builder/index.js 4.09 kB
./packages/db/dist/esm/query/builder/ref-proxy.js 1.05 kB
./packages/db/dist/esm/query/compiler/evaluators.js 1.43 kB
./packages/db/dist/esm/query/compiler/expressions.js 430 B
./packages/db/dist/esm/query/compiler/group-by.js 1.81 kB
./packages/db/dist/esm/query/compiler/index.js 2.02 kB
./packages/db/dist/esm/query/compiler/joins.js 2.11 kB
./packages/db/dist/esm/query/compiler/order-by.js 1.45 kB
./packages/db/dist/esm/query/compiler/select.js 1.06 kB
./packages/db/dist/esm/query/expression-helpers.js 1.43 kB
./packages/db/dist/esm/query/ir.js 673 B
./packages/db/dist/esm/query/live-query-collection.js 360 B
./packages/db/dist/esm/query/live/collection-config-builder.js 5.44 kB
./packages/db/dist/esm/query/live/collection-registry.js 264 B
./packages/db/dist/esm/query/live/collection-subscriber.js 2.42 kB
./packages/db/dist/esm/query/live/internal.js 145 B
./packages/db/dist/esm/query/predicate-utils.js 2.97 kB
./packages/db/dist/esm/query/subset-dedupe.js 921 B
./packages/db/dist/esm/scheduler.js 1.3 kB
./packages/db/dist/esm/SortedMap.js 1.3 kB
./packages/db/dist/esm/strategies/debounceStrategy.js 247 B
./packages/db/dist/esm/strategies/queueStrategy.js 428 B
./packages/db/dist/esm/strategies/throttleStrategy.js 246 B
./packages/db/dist/esm/transactions.js 2.9 kB
./packages/db/dist/esm/utils.js 924 B
./packages/db/dist/esm/utils/browser-polyfills.js 304 B
./packages/db/dist/esm/utils/btree.js 5.61 kB
./packages/db/dist/esm/utils/comparison.js 952 B
./packages/db/dist/esm/utils/cursor.js 457 B
./packages/db/dist/esm/utils/index-optimization.js 1.51 kB
./packages/db/dist/esm/utils/type-guards.js 157 B

compressed-size-action::db-package-size

@github-actions
Copy link
Contributor

github-actions bot commented Feb 17, 2026

Size Change: 0 B

Total Size: 3.7 kB

ℹ️ View Unchanged
Filename Size
./packages/react-db/dist/esm/index.js 225 B
./packages/react-db/dist/esm/useLiveInfiniteQuery.js 1.17 kB
./packages/react-db/dist/esm/useLiveQuery.js 1.34 kB
./packages/react-db/dist/esm/useLiveSuspenseQuery.js 559 B
./packages/react-db/dist/esm/usePacedMutations.js 401 B

compressed-size-action::react-db-package-size

kevin-dp and others added 3 commits February 17, 2026 13:52
The query optimizer was pushing single-source WHERE clauses into
subqueries and sourceWhereClauses for the nullable side of outer
joins (right side of LEFT, left side of RIGHT, both for FULL).
This pre-filtered the data before the join, converting rows that
should have been excluded by the WHERE into unmatched outer-join
rows that then incorrectly survived the residual filter.

The fix identifies nullable join sources and excludes them from
predicate pushdown and index optimization extraction. Clauses for
those sources are kept as post-join filters on the main pipeline.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@kevin-dp
Copy link
Contributor Author

Root cause

The query optimizer has two mechanisms that push single-source WHERE clauses closer to the data:

  1. Predicate pushdown (applyOptimizations) — wraps a collection reference in a filtering subquery inside the IVM pipeline
  2. Source WHERE extraction (extractSourceWhereClauses) — passes the clause to the collection subscriber for loadSubset index optimization

Both are correct for inner joins, but break outer join semantics when applied to the nullable side (right side of LEFT, left side of RIGHT, both for FULL).

Why it breaks

Consider .leftJoin({ r: right }, ...).where(({ r }) => isUndefined(r?.payload)):

Without pushdown (correct): the join matches all right rows, then the WHERE filters the result:

  • l1 ↔ r1 (payload='ok') → isUndefined('ok') → false → excluded ✓
  • l2 ↔ r2 (payload=null) → isUndefined(null) → false → excluded ✓
  • l3 ↔ r3 (payload=undefined) → isUndefined(undefined) → true → kept ✓
  • l4 ↔ (no match) → isUndefined(undefined) → true → kept ✓

With pushdown (broken): the right side is pre-filtered to only r3, so the join can't find r1/r2:

  • l1 ↔ (no match!) → isUndefined(undefined) → true → kept ✗
  • l2 ↔ (no match!) → isUndefined(undefined) → true → kept ✗
  • l3 ↔ r3 → isUndefined(undefined) → true → kept ✓
  • l4 ↔ (no match) → isUndefined(undefined) → true → kept ✓

The optimizer keeps a residual WHERE for outer joins to handle exactly this, but it can't distinguish "genuinely unmatched" rows (l4) from "had a match but it was pre-filtered away" rows (l1, l2) — they look identical. When the WHERE condition happens to be true for unmatched rows (like isUndefined), the residual passes them through incorrectly.

Fix

A new getNullableJoinSources() helper identifies which source aliases are on the nullable side of each outer join. Both applyOptimizations and extractSourceWhereClauses now exclude these sources, keeping their WHERE clauses as post-join filters on the main pipeline.

This is conservative — it also blocks pushdown for cases where the residual would work correctly (e.g. eq(other.status, 'active') where the residual evaluates to false for unmatched rows). But statically determining which expressions are safe is complex, and correctness is more important than the optimization.

Copy link
Collaborator

@samwillis samwillis left a comment

Choose a reason for hiding this comment

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

:shipit:

@kevin-dp kevin-dp merged commit 802550f into main Feb 17, 2026
7 checks passed
@kevin-dp kevin-dp deleted the left-join-is-undefined-filter branch February 17, 2026 13:33
@github-actions github-actions bot mentioned this pull request Feb 18, 2026
@github-actions
Copy link
Contributor

🎉 This PR has been released!

Thank you for your contribution!

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.

leftJoin + where isUndefined on right field filters out relation but not the row

2 participants