Skip to content

Commit ca9febb

Browse files
kevin-dpautofix-ci[bot]claude
authored
fix: pass child where clauses to loadSubset in includes (#1472)
* Unit tests to check that where clauses of included collections are passed * Unit tests to reproduce the problem with where clauses on included collections not being passed * Pass child where clauses to loadSubset * ci: apply automated fixes * changeset for child where clauses fix Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 04820ed commit ca9febb

3 files changed

Lines changed: 261 additions & 0 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@tanstack/db': patch
3+
---
4+
5+
fix: pass child where clauses to loadSubset in includes
6+
7+
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.

packages/db/src/query/compiler/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -479,6 +479,9 @@ export function compileQuery(
479479
// Merge child's alias metadata into parent's
480480
Object.assign(aliasToCollectionId, childResult.aliasToCollectionId)
481481
Object.assign(aliasRemapping, childResult.aliasRemapping)
482+
for (const [alias, whereClause] of childResult.sourceWhereClauses) {
483+
sourceWhereClauses.set(alias, whereClause)
484+
}
482485

483486
includesResults.push({
484487
pipeline: childResult.pipeline,

packages/db/tests/query/includes-lazy-loading.test.ts

Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { describe, expect, it, vi } from 'vitest'
22
import {
3+
and,
34
createLiveQueryCollection,
45
eq,
6+
gte,
57
toArray,
68
} from '../../src/query/index.js'
79
import { createCollection } from '../../src/collection/index.js'
@@ -425,3 +427,252 @@ describe(`includes lazy loading`, () => {
425427
])
426428
})
427429
})
430+
431+
describe(`includes child where clauses in loadSubset`, () => {
432+
/**
433+
* Tests that pure-child WHERE clauses (not the correlation) are passed
434+
* through to the child collection's loadSubset/queryFn.
435+
*/
436+
437+
type Root = {
438+
id: number
439+
name: string
440+
}
441+
442+
type Item = {
443+
id: number
444+
rootId: number
445+
status: string
446+
priority: number
447+
title: string
448+
}
449+
450+
const sampleRoots: Array<Root> = [
451+
{ id: 1, name: `Root A` },
452+
{ id: 2, name: `Root B` },
453+
]
454+
455+
const sampleItems: Array<Item> = [
456+
{ id: 10, rootId: 1, status: `active`, priority: 3, title: `A1 active` },
457+
{
458+
id: 11,
459+
rootId: 1,
460+
status: `archived`,
461+
priority: 1,
462+
title: `A1 archived`,
463+
},
464+
{ id: 20, rootId: 2, status: `active`, priority: 5, title: `B1 active` },
465+
{ id: 21, rootId: 2, status: `active`, priority: 2, title: `B1 active2` },
466+
]
467+
468+
function createRootsCollection() {
469+
return createCollection<Root>({
470+
id: `child-where-roots`,
471+
getKey: (r) => r.id,
472+
sync: {
473+
sync: ({ begin, write, commit, markReady }) => {
474+
begin()
475+
for (const root of sampleRoots) {
476+
write({ type: `insert`, value: root })
477+
}
478+
commit()
479+
markReady()
480+
},
481+
},
482+
})
483+
}
484+
485+
function createItemsCollectionWithTracking() {
486+
const loadSubsetCalls: Array<LoadSubsetOptions> = []
487+
488+
const collection = createCollection<Item>({
489+
id: `child-where-items`,
490+
getKey: (item) => item.id,
491+
syncMode: `on-demand`,
492+
sync: {
493+
sync: ({ begin, write, commit, markReady }) => {
494+
begin()
495+
for (const item of sampleItems) {
496+
write({ type: `insert`, value: item })
497+
}
498+
commit()
499+
markReady()
500+
return {
501+
loadSubset: vi.fn((options: LoadSubsetOptions) => {
502+
loadSubsetCalls.push(options)
503+
return Promise.resolve()
504+
}),
505+
}
506+
},
507+
},
508+
})
509+
510+
return { collection, loadSubsetCalls }
511+
}
512+
513+
it(`should include pure-child where clause in loadSubset along with correlation filter`, async () => {
514+
const roots = createRootsCollection()
515+
const { collection: items, loadSubsetCalls } =
516+
createItemsCollectionWithTracking()
517+
518+
const liveQuery = createLiveQueryCollection((q) =>
519+
q.from({ r: roots }).select(({ r }) => ({
520+
id: r.id,
521+
children: toArray(
522+
q
523+
.from({ item: items })
524+
.where(({ item }) => eq(item.rootId, r.id))
525+
.where(({ item }) => eq(item.status, `active`))
526+
.select(({ item }) => ({
527+
id: item.id,
528+
title: item.title,
529+
})),
530+
),
531+
})),
532+
)
533+
534+
await liveQuery.preload()
535+
536+
expect(loadSubsetCalls.length).toBeGreaterThan(0)
537+
538+
// The loadSubset call should contain BOTH the correlation filter (inArray)
539+
// AND the pure-child filter (eq status 'active')
540+
const lastCall = loadSubsetCalls[loadSubsetCalls.length - 1]!
541+
expect(lastCall.where).toBeDefined()
542+
543+
const filters = extractSimpleComparisons(lastCall.where)
544+
const hasCorrelationFilter = filters.some(
545+
(f) => f.operator === `in` && f.field[0] === `rootId`,
546+
)
547+
const hasStatusFilter = filters.some(
548+
(f) =>
549+
f.operator === `eq` && f.field[0] === `status` && f.value === `active`,
550+
)
551+
552+
expect(hasCorrelationFilter).toBe(true)
553+
expect(hasStatusFilter).toBe(true)
554+
})
555+
556+
it(`should include multiple pure-child where clauses in loadSubset`, async () => {
557+
const roots = createRootsCollection()
558+
const { collection: items, loadSubsetCalls } =
559+
createItemsCollectionWithTracking()
560+
561+
const liveQuery = createLiveQueryCollection((q) =>
562+
q.from({ r: roots }).select(({ r }) => ({
563+
id: r.id,
564+
children: toArray(
565+
q
566+
.from({ item: items })
567+
.where(({ item }) => eq(item.rootId, r.id))
568+
.where(({ item }) => eq(item.status, `active`))
569+
.where(({ item }) => gte(item.priority, 3))
570+
.select(({ item }) => ({
571+
id: item.id,
572+
title: item.title,
573+
})),
574+
),
575+
})),
576+
)
577+
578+
await liveQuery.preload()
579+
580+
expect(loadSubsetCalls.length).toBeGreaterThan(0)
581+
582+
const lastCall = loadSubsetCalls[loadSubsetCalls.length - 1]!
583+
expect(lastCall.where).toBeDefined()
584+
585+
const filters = extractSimpleComparisons(lastCall.where)
586+
const hasCorrelationFilter = filters.some(
587+
(f) => f.operator === `in` && f.field[0] === `rootId`,
588+
)
589+
const hasStatusFilter = filters.some(
590+
(f) =>
591+
f.operator === `eq` && f.field[0] === `status` && f.value === `active`,
592+
)
593+
const hasPriorityFilter = filters.some(
594+
(f) => f.operator === `gte` && f.field[0] === `priority` && f.value === 3,
595+
)
596+
597+
expect(hasCorrelationFilter).toBe(true)
598+
expect(hasStatusFilter).toBe(true)
599+
expect(hasPriorityFilter).toBe(true)
600+
})
601+
602+
it(`should produce correct filtered results with child where clause`, async () => {
603+
const roots = createRootsCollection()
604+
const { collection: items } = createItemsCollectionWithTracking()
605+
606+
const liveQuery = createLiveQueryCollection((q) =>
607+
q.from({ r: roots }).select(({ r }) => ({
608+
id: r.id,
609+
children: toArray(
610+
q
611+
.from({ item: items })
612+
.where(({ item }) => eq(item.rootId, r.id))
613+
.where(({ item }) => eq(item.status, `active`))
614+
.select(({ item }) => ({
615+
id: item.id,
616+
title: item.title,
617+
})),
618+
),
619+
})),
620+
)
621+
622+
await liveQuery.preload()
623+
624+
// Root A: only 1 active item (id 10), the archived one (id 11) should be filtered
625+
const rootA = stripVirtualProps(liveQuery.get(1))
626+
expect(rootA).toBeDefined()
627+
expect((rootA as any).children).toHaveLength(1)
628+
expect((rootA as any).children[0].id).toBe(10)
629+
630+
// Root B: 2 active items
631+
const rootB = stripVirtualProps(liveQuery.get(2))
632+
expect(rootB).toBeDefined()
633+
expect((rootB as any).children).toHaveLength(2)
634+
})
635+
636+
it(`should include child where clause combined with correlation in and() syntax`, async () => {
637+
const roots = createRootsCollection()
638+
const { collection: items, loadSubsetCalls } =
639+
createItemsCollectionWithTracking()
640+
641+
// Use a single where with and() combining correlation + child filter
642+
const liveQuery = createLiveQueryCollection((q) =>
643+
q.from({ r: roots }).select(({ r }) => ({
644+
id: r.id,
645+
children: toArray(
646+
q
647+
.from({ item: items })
648+
.where(({ item }) =>
649+
and(eq(item.rootId, r.id), eq(item.status, `active`)),
650+
)
651+
.select(({ item }) => ({
652+
id: item.id,
653+
title: item.title,
654+
})),
655+
),
656+
})),
657+
)
658+
659+
await liveQuery.preload()
660+
661+
expect(loadSubsetCalls.length).toBeGreaterThan(0)
662+
663+
const lastCall = loadSubsetCalls[loadSubsetCalls.length - 1]!
664+
expect(lastCall.where).toBeDefined()
665+
666+
const filters = extractSimpleComparisons(lastCall.where)
667+
const hasCorrelationFilter = filters.some(
668+
(f) => f.operator === `in` && f.field[0] === `rootId`,
669+
)
670+
const hasStatusFilter = filters.some(
671+
(f) =>
672+
f.operator === `eq` && f.field[0] === `status` && f.value === `active`,
673+
)
674+
675+
expect(hasCorrelationFilter).toBe(true)
676+
expect(hasStatusFilter).toBe(true)
677+
})
678+
})

0 commit comments

Comments
 (0)