From 395a9a34def38ea8d6ebd35ba6d652cee85debf9 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Mon, 13 Apr 2026 16:53:24 +0200 Subject: [PATCH 1/5] Unit tests to check that where clauses of included collections are passed --- .../tests/query/includes-lazy-loading.test.ts | 257 ++++++++++++++++++ 1 file changed, 257 insertions(+) diff --git a/packages/db/tests/query/includes-lazy-loading.test.ts b/packages/db/tests/query/includes-lazy-loading.test.ts index 7fa8f8799..29d847c3e 100644 --- a/packages/db/tests/query/includes-lazy-loading.test.ts +++ b/packages/db/tests/query/includes-lazy-loading.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it, vi } from 'vitest' import { + and, createLiveQueryCollection, eq, + gte, toArray, } from '../../src/query/index.js' import { createCollection } from '../../src/collection/index.js' @@ -425,3 +427,258 @@ describe(`includes lazy loading`, () => { ]) }) }) + +describe(`includes child where clauses in loadSubset`, () => { + /** + * Tests that pure-child WHERE clauses (not the correlation) are passed + * through to the child collection's loadSubset/queryFn. + */ + + type Root = { + id: number + name: string + } + + type Item = { + id: number + rootId: number + status: string + priority: number + title: string + } + + const sampleRoots: Array = [ + { id: 1, name: `Root A` }, + { id: 2, name: `Root B` }, + ] + + const sampleItems: Array = [ + { id: 10, rootId: 1, status: `active`, priority: 3, title: `A1 active` }, + { id: 11, rootId: 1, status: `archived`, priority: 1, title: `A1 archived` }, + { id: 20, rootId: 2, status: `active`, priority: 5, title: `B1 active` }, + { id: 21, rootId: 2, status: `active`, priority: 2, title: `B1 active2` }, + ] + + function createRootsCollection() { + return createCollection({ + id: `child-where-roots`, + getKey: (r) => r.id, + sync: { + sync: ({ begin, write, commit, markReady }) => { + begin() + for (const root of sampleRoots) { + write({ type: `insert`, value: root }) + } + commit() + markReady() + }, + }, + }) + } + + function createItemsCollectionWithTracking(): { + collection: ReturnType> + loadSubsetCalls: Array + } { + const loadSubsetCalls: Array = [] + + const collection = createCollection({ + id: `child-where-items`, + getKey: (item) => item.id, + syncMode: `on-demand`, + sync: { + sync: ({ begin, write, commit, markReady }) => { + begin() + for (const item of sampleItems) { + write({ type: `insert`, value: item }) + } + commit() + markReady() + return { + loadSubset: vi.fn((options: LoadSubsetOptions) => { + loadSubsetCalls.push(options) + return Promise.resolve() + }), + } + }, + }, + }) + + return { collection, loadSubsetCalls } + } + + it(`should include pure-child where clause in loadSubset along with correlation filter`, async () => { + const roots = createRootsCollection() + const { collection: items, loadSubsetCalls } = + createItemsCollectionWithTracking() + + const liveQuery = createLiveQueryCollection((q) => + q.from({ r: roots }).select(({ r }) => ({ + id: r.id, + children: toArray( + q + .from({ item: items }) + .where(({ item }) => eq(item.rootId, r.id)) + .where(({ item }) => eq(item.status, `active`)) + .select(({ item }) => ({ + id: item.id, + title: item.title, + })), + ), + })), + ) + + await liveQuery.preload() + + expect(loadSubsetCalls.length).toBeGreaterThan(0) + + // The loadSubset call should contain BOTH the correlation filter (inArray) + // AND the pure-child filter (eq status 'active') + const lastCall = loadSubsetCalls[loadSubsetCalls.length - 1]! + expect(lastCall.where).toBeDefined() + + const filters = extractSimpleComparisons(lastCall.where) + const hasCorrelationFilter = filters.some( + (f) => f.operator === `in` && f.field[0] === `rootId`, + ) + const hasStatusFilter = filters.some( + (f) => + f.operator === `eq` && + f.field[0] === `status` && + f.value === `active`, + ) + + expect(hasCorrelationFilter).toBe(true) + expect(hasStatusFilter).toBe(true) + }) + + it(`should include multiple pure-child where clauses in loadSubset`, async () => { + const roots = createRootsCollection() + const { collection: items, loadSubsetCalls } = + createItemsCollectionWithTracking() + + const liveQuery = createLiveQueryCollection((q) => + q.from({ r: roots }).select(({ r }) => ({ + id: r.id, + children: toArray( + q + .from({ item: items }) + .where(({ item }) => eq(item.rootId, r.id)) + .where(({ item }) => eq(item.status, `active`)) + .where(({ item }) => gte(item.priority, 3)) + .select(({ item }) => ({ + id: item.id, + title: item.title, + })), + ), + })), + ) + + await liveQuery.preload() + + expect(loadSubsetCalls.length).toBeGreaterThan(0) + + const lastCall = loadSubsetCalls[loadSubsetCalls.length - 1]! + expect(lastCall.where).toBeDefined() + + const filters = extractSimpleComparisons(lastCall.where) + const hasCorrelationFilter = filters.some( + (f) => f.operator === `in` && f.field[0] === `rootId`, + ) + const hasStatusFilter = filters.some( + (f) => + f.operator === `eq` && + f.field[0] === `status` && + f.value === `active`, + ) + const hasPriorityFilter = filters.some( + (f) => + f.operator === `gte` && + f.field[0] === `priority` && + f.value === 3, + ) + + expect(hasCorrelationFilter).toBe(true) + expect(hasStatusFilter).toBe(true) + expect(hasPriorityFilter).toBe(true) + }) + + it(`should produce correct filtered results with child where clause`, async () => { + const roots = createRootsCollection() + const { collection: items } = createItemsCollectionWithTracking() + + const liveQuery = createLiveQueryCollection((q) => + q.from({ r: roots }).select(({ r }) => ({ + id: r.id, + children: toArray( + q + .from({ item: items }) + .where(({ item }) => eq(item.rootId, r.id)) + .where(({ item }) => eq(item.status, `active`)) + .select(({ item }) => ({ + id: item.id, + title: item.title, + })), + ), + })), + ) + + await liveQuery.preload() + + // Root A: only 1 active item (id 10), the archived one (id 11) should be filtered + const rootA = stripVirtualProps(liveQuery.get(1)) + expect(rootA).toBeDefined() + expect((rootA as any).children).toHaveLength(1) + expect((rootA as any).children[0].id).toBe(10) + + // Root B: 2 active items + const rootB = stripVirtualProps(liveQuery.get(2)) + expect(rootB).toBeDefined() + expect((rootB as any).children).toHaveLength(2) + }) + + it(`should include child where clause combined with correlation in and() syntax`, async () => { + const roots = createRootsCollection() + const { collection: items, loadSubsetCalls } = + createItemsCollectionWithTracking() + + // Use a single where with and() combining correlation + child filter + const liveQuery = createLiveQueryCollection((q) => + q.from({ r: roots }).select(({ r }) => ({ + id: r.id, + children: toArray( + q + .from({ item: items }) + .where(({ item }) => + and(eq(item.rootId, r.id), eq(item.status, `active`)), + ) + .select(({ item }) => ({ + id: item.id, + title: item.title, + })), + ), + })), + ) + + await liveQuery.preload() + + expect(loadSubsetCalls.length).toBeGreaterThan(0) + + const lastCall = loadSubsetCalls[loadSubsetCalls.length - 1]! + expect(lastCall.where).toBeDefined() + + const filters = extractSimpleComparisons(lastCall.where) + const hasCorrelationFilter = filters.some( + (f) => f.operator === `in` && f.field[0] === `rootId`, + ) + const hasStatusFilter = filters.some( + (f) => + f.operator === `eq` && + f.field[0] === `status` && + f.value === `active`, + ) + + expect(hasCorrelationFilter).toBe(true) + expect(hasStatusFilter).toBe(true) + }) +}) From c673871573d9089d841b0592d2543f64a32b48f5 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Mon, 13 Apr 2026 17:06:34 +0200 Subject: [PATCH 2/5] Unit tests to reproduce the problem with where clauses on included collections not being passed --- packages/db/tests/query/includes-lazy-loading.test.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/db/tests/query/includes-lazy-loading.test.ts b/packages/db/tests/query/includes-lazy-loading.test.ts index 29d847c3e..a5db64f9b 100644 --- a/packages/db/tests/query/includes-lazy-loading.test.ts +++ b/packages/db/tests/query/includes-lazy-loading.test.ts @@ -476,10 +476,7 @@ describe(`includes child where clauses in loadSubset`, () => { }) } - function createItemsCollectionWithTracking(): { - collection: ReturnType> - loadSubsetCalls: Array - } { + function createItemsCollectionWithTracking() { const loadSubsetCalls: Array = [] const collection = createCollection({ From cb9a60a76d162b2cb11bc4ffb37adc8078deb0e0 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Mon, 13 Apr 2026 17:07:53 +0200 Subject: [PATCH 3/5] Pass child where clauses to loadSubset --- packages/db/src/query/compiler/index.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/db/src/query/compiler/index.ts b/packages/db/src/query/compiler/index.ts index fe6651323..f4cff548d 100644 --- a/packages/db/src/query/compiler/index.ts +++ b/packages/db/src/query/compiler/index.ts @@ -479,6 +479,9 @@ export function compileQuery( // Merge child's alias metadata into parent's Object.assign(aliasToCollectionId, childResult.aliasToCollectionId) Object.assign(aliasRemapping, childResult.aliasRemapping) + for (const [alias, whereClause] of childResult.sourceWhereClauses) { + sourceWhereClauses.set(alias, whereClause) + } includesResults.push({ pipeline: childResult.pipeline, From 70ea4616a2726efdfe2e4afe4ee5df5fadf79580 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 13 Apr 2026 15:10:47 +0000 Subject: [PATCH 4/5] ci: apply automated fixes --- .../tests/query/includes-lazy-loading.test.ts | 25 ++++++++----------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/packages/db/tests/query/includes-lazy-loading.test.ts b/packages/db/tests/query/includes-lazy-loading.test.ts index a5db64f9b..8e8eccace 100644 --- a/packages/db/tests/query/includes-lazy-loading.test.ts +++ b/packages/db/tests/query/includes-lazy-loading.test.ts @@ -454,7 +454,13 @@ describe(`includes child where clauses in loadSubset`, () => { const sampleItems: Array = [ { id: 10, rootId: 1, status: `active`, priority: 3, title: `A1 active` }, - { id: 11, rootId: 1, status: `archived`, priority: 1, title: `A1 archived` }, + { + id: 11, + rootId: 1, + status: `archived`, + priority: 1, + title: `A1 archived`, + }, { id: 20, rootId: 2, status: `active`, priority: 5, title: `B1 active` }, { id: 21, rootId: 2, status: `active`, priority: 2, title: `B1 active2` }, ] @@ -540,9 +546,7 @@ describe(`includes child where clauses in loadSubset`, () => { ) const hasStatusFilter = filters.some( (f) => - f.operator === `eq` && - f.field[0] === `status` && - f.value === `active`, + f.operator === `eq` && f.field[0] === `status` && f.value === `active`, ) expect(hasCorrelationFilter).toBe(true) @@ -584,15 +588,10 @@ describe(`includes child where clauses in loadSubset`, () => { ) const hasStatusFilter = filters.some( (f) => - f.operator === `eq` && - f.field[0] === `status` && - f.value === `active`, + f.operator === `eq` && f.field[0] === `status` && f.value === `active`, ) const hasPriorityFilter = filters.some( - (f) => - f.operator === `gte` && - f.field[0] === `priority` && - f.value === 3, + (f) => f.operator === `gte` && f.field[0] === `priority` && f.value === 3, ) expect(hasCorrelationFilter).toBe(true) @@ -670,9 +669,7 @@ describe(`includes child where clauses in loadSubset`, () => { ) const hasStatusFilter = filters.some( (f) => - f.operator === `eq` && - f.field[0] === `status` && - f.value === `active`, + f.operator === `eq` && f.field[0] === `status` && f.value === `active`, ) expect(hasCorrelationFilter).toBe(true) From f8eebbb812c550ba94dba719ec40cff5d18625e4 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Mon, 13 Apr 2026 17:16:19 +0200 Subject: [PATCH 5/5] changeset for child where clauses fix Co-Authored-By: Claude Opus 4.6 (1M context) --- .changeset/includes-child-where-clauses.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/includes-child-where-clauses.md diff --git a/.changeset/includes-child-where-clauses.md b/.changeset/includes-child-where-clauses.md new file mode 100644 index 000000000..566dface5 --- /dev/null +++ b/.changeset/includes-child-where-clauses.md @@ -0,0 +1,7 @@ +--- +'@tanstack/db': patch +--- + +fix: pass child where clauses to loadSubset in includes + +Pure-child WHERE clauses on includes subqueries (e.g., `.where(({ item }) => eq(item.status, 'active'))`) are now passed through to the child collection's `loadSubset`/`queryFn`, enabling server-side filtering. Previously only the correlation filter reached the sync layer; additional child filters were applied client-side only.