Skip to content

Commit 285fff5

Browse files
committed
Deduplicate loadSubset requests in Query collection
1 parent 860136a commit 285fff5

File tree

2 files changed

+119
-51
lines changed

2 files changed

+119
-51
lines changed

packages/query-db-collection/src/query.ts

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,12 @@
11
import { QueryObserver, hashKey } from "@tanstack/query-core"
2+
import { DeduplicatedLoadSubset } from "@tanstack/db"
23
import {
34
GetKeyRequiredError,
45
QueryClientRequiredError,
56
QueryFnRequiredError,
67
QueryKeyRequiredError,
78
} from "./errors"
89
import { createWriteUtils } from "./manual-sync"
9-
import type {
10-
QueryClient,
11-
QueryFunctionContext,
12-
QueryKey,
13-
QueryObserverOptions,
14-
} from "@tanstack/query-core"
1510
import type {
1611
BaseCollectionConfig,
1712
ChangeMessage,
@@ -23,6 +18,12 @@ import type {
2318
UpdateMutationFnParams,
2419
UtilsRecord,
2520
} from "@tanstack/db"
21+
import type {
22+
QueryClient,
23+
QueryFunctionContext,
24+
QueryKey,
25+
QueryObserverOptions,
26+
} from "@tanstack/query-core"
2627
import type { StandardSchemaV1 } from "@standard-schema/spec"
2728

2829
// Re-export for external use
@@ -476,7 +477,8 @@ export function queryCollectionOptions(
476477
let syncStarted = false
477478

478479
const createQueryFromOpts = (
479-
opts: LoadSubsetOptions
480+
opts: LoadSubsetOptions,
481+
queryFunction: typeof queryFn = queryFn
480482
): true | Promise<void> => {
481483
// Push the predicates down to the queryKey and queryFn
482484
const key = typeof queryKey === `function` ? queryKey(opts) : queryKey
@@ -519,7 +521,7 @@ export function queryCollectionOptions(
519521
any
520522
> = {
521523
queryKey: key,
522-
queryFn: queryFn,
524+
queryFn: queryFunction,
523525
meta: extendedMeta,
524526
enabled: enabled,
525527
refetchInterval: refetchInterval,
@@ -667,6 +669,21 @@ export function queryCollectionOptions(
667669
return handleQueryResult
668670
}
669671

672+
// This function is called when a loadSubset call is deduplicated
673+
// meaning that we have all the data locally available to answer the query
674+
// so we execute the query locally
675+
const createLocalQuery = (opts: LoadSubsetOptions) => {
676+
const queryFn = ({ meta }: QueryFunctionContext<any>) => {
677+
const inserts = collection.currentStateAsChanges(
678+
meta!.loadSubsetOptions as LoadSubsetOptions
679+
)!
680+
const data = inserts.map(({ value }) => value)
681+
return Promise.resolve(data)
682+
}
683+
684+
createQueryFromOpts(opts, queryFn)
685+
}
686+
670687
const isSubscribed = (hashedQueryKey: string) => {
671688
return unsubscribes.has(hashedQueryKey)
672689
}
@@ -796,8 +813,20 @@ export function queryCollectionOptions(
796813
)
797814
}
798815

816+
// Create deduplicated loadSubset wrapper for non-eager modes
817+
// This prevents redundant snapshot requests when multiple concurrent
818+
// live queries request overlapping or subset predicates
819+
const loadSubsetDedupe =
820+
syncMode === `eager`
821+
? undefined
822+
: new DeduplicatedLoadSubset(createQueryFromOpts, createLocalQuery)
823+
824+
// TODO: run the tests, probably some will fail bc different requests are made now
825+
// so fix the test expectations
826+
// then also add all the new dedup tests that Sam also added to the Electric collection
827+
799828
return {
800-
loadSubset: syncMode === `eager` ? undefined : createQueryFromOpts,
829+
loadSubset: loadSubsetDedupe?.loadSubset,
801830
cleanup,
802831
}
803832
}

packages/query-db-collection/tests/query.test.ts

Lines changed: 81 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
22
import { QueryClient } from "@tanstack/query-core"
33
import { createCollection } from "@tanstack/db"
44
import { queryCollectionOptions } from "../src/query"
5+
import { Func, PropRef, Value } from "../../db/src/query/ir"
56
import type { QueryFunctionContext } from "@tanstack/query-core"
67
import type {
78
CollectionImpl,
@@ -18,6 +19,12 @@ interface TestItem {
1819
value?: number
1920
}
2021

22+
interface CategorisedItem {
23+
id: string
24+
name: string
25+
category: string
26+
}
27+
2128
const getKey = (item: TestItem) => item.id
2229

2330
// Helper to advance timers and allow microtasks to flush
@@ -2462,10 +2469,10 @@ describe(`QueryCollection`, () => {
24622469
const queryFn = vi.fn().mockImplementation((context) => {
24632470
const { meta } = context
24642471
const loadSubsetOptions = meta?.loadSubsetOptions ?? {}
2465-
const { where, orderBy, limit } = loadSubsetOptions
2472+
const { where } = loadSubsetOptions
24662473

2467-
// Query 1: items 1, 2, 3 (no predicates)
2468-
if (!where && !orderBy && !limit) {
2474+
// Query 1: items 1, 2, 3 (where: { category: 'A' })
2475+
if (where?.category === `A`) {
24692476
return Promise.resolve([
24702477
{ id: `1`, name: `Item 1` },
24712478
{ id: `2`, name: `Item 2` },
@@ -2517,7 +2524,7 @@ describe(`QueryCollection`, () => {
25172524
expect(collection.size).toBe(0)
25182525

25192526
// Load query 1 with no predicates (items 1, 2, 3)
2520-
await collection._sync.loadSubset({})
2527+
await collection._sync.loadSubset({ where: { category: `A` } } as any)
25212528

25222529
// Wait for query 1 data to load
25232530
await vi.waitFor(() => {
@@ -2551,7 +2558,10 @@ describe(`QueryCollection`, () => {
25512558

25522559
// GC query 1 (no predicates) - should only remove item 1 (unique to query 1)
25532560
// Items 2 and 3 should remain because they're shared with other queries
2554-
queryClient.removeQueries({ queryKey: queryKey({}), exact: true })
2561+
queryClient.removeQueries({
2562+
queryKey: queryKey({ where: { category: `A` } }),
2563+
exact: true,
2564+
})
25552565

25562566
await vi.waitFor(() => {
25572567
expect(collection.size).toBe(4) // Should have items 2, 3, 4, 5
@@ -2608,13 +2618,13 @@ describe(`QueryCollection`, () => {
26082618
const queryFn = vi.fn().mockImplementation(() => {
26092619
// All queries return the same data regardless of predicates
26102620
return Promise.resolve([
2611-
{ id: `1`, name: `Item 1` },
2612-
{ id: `2`, name: `Item 2` },
2613-
{ id: `3`, name: `Item 3` },
2621+
{ id: `1`, name: `Item 1`, category: `A` },
2622+
{ id: `2`, name: `Item 2`, category: `A` },
2623+
{ id: `3`, name: `Item 3`, category: `A` },
26142624
])
26152625
})
26162626

2617-
const config: QueryCollectionConfig<TestItem> = {
2627+
const config: QueryCollectionConfig<CategorisedItem> = {
26182628
id: `identical-test`,
26192629
queryClient,
26202630
queryKey: (ctx) => {
@@ -2644,15 +2654,29 @@ describe(`QueryCollection`, () => {
26442654
})
26452655

26462656
// Add query 2 with different predicates (but returns same data)
2647-
await collection._sync.loadSubset({ where: { category: `A` } } as any)
2657+
const whereClause1 = new Func(`eq`, [
2658+
new PropRef([`category`]),
2659+
new Value(`A`),
2660+
])
2661+
2662+
await collection._sync.loadSubset({
2663+
where: whereClause1,
2664+
})
26482665

26492666
// Wait for query 2 data to load
26502667
await vi.waitFor(() => {
26512668
expect(collection.size).toBe(3) // Same data, no new items
26522669
})
26532670

26542671
// Add query 3 with different predicates (but returns same data)
2655-
await collection._sync.loadSubset({ where: { category: `B` } } as any)
2672+
const whereClause2 = new Func(`or`, [
2673+
new Func(`eq`, [new PropRef([`category`]), new Value(`A`)]),
2674+
new Func(`eq`, [new PropRef([`category`]), new Value(`B`)]),
2675+
])
2676+
2677+
await collection._sync.loadSubset({
2678+
where: whereClause2,
2679+
})
26562680

26572681
// Wait for query 3 data to load
26582682
await vi.waitFor(() => {
@@ -2676,7 +2700,7 @@ describe(`QueryCollection`, () => {
26762700

26772701
// GC query 2 - should still not remove any items (all items are shared with query 3)
26782702
queryClient.removeQueries({
2679-
queryKey: (config.queryKey as any)({ where: { category: `A` } }),
2703+
queryKey: (config.queryKey as any)({ where: whereClause1 }),
26802704
exact: true,
26812705
})
26822706

@@ -2691,7 +2715,7 @@ describe(`QueryCollection`, () => {
26912715

26922716
// GC query 3 - should remove all items (no more queries reference them)
26932717
queryClient.removeQueries({
2694-
queryKey: (config.queryKey as any)({ where: { category: `B` } }),
2718+
queryKey: (config.queryKey as any)({ where: whereClause2 }),
26952719
exact: true,
26962720
})
26972721

@@ -2712,18 +2736,13 @@ describe(`QueryCollection`, () => {
27122736
const queryFn = vi.fn().mockImplementation((context) => {
27132737
const { meta } = context
27142738
const loadSubsetOptions = meta?.loadSubsetOptions || {}
2715-
const { where, orderBy, limit } = loadSubsetOptions
2739+
const { where } = loadSubsetOptions
27162740

2717-
// Query 1: empty array (no predicates)
2718-
if (!where && !orderBy && !limit) {
2719-
return Promise.resolve([])
2720-
}
2721-
2722-
// Query 2: some items (where: { category: 'A' })
2723-
if (where?.category === `A`) {
2741+
// Query 2: some items (where: { category: 'B' })
2742+
if (where?.name === `eq` && where?.args[1].value === `B`) {
27242743
return Promise.resolve([
2725-
{ id: `1`, name: `Item 1` },
2726-
{ id: `2`, name: `Item 2` },
2744+
{ id: `1`, name: `Item 1`, category: `B` },
2745+
{ id: `2`, name: `Item 2`, category: `B` },
27272746
])
27282747
}
27292748

@@ -2752,15 +2771,23 @@ describe(`QueryCollection`, () => {
27522771
expect(collection.size).toBe(0)
27532772

27542773
// Load query 1 with no predicates (returns empty array)
2755-
await collection._sync.loadSubset({})
2774+
const whereClause1 = new Func(`eq`, [
2775+
new PropRef([`category`]),
2776+
new Value(`A`),
2777+
])
2778+
await collection._sync.loadSubset({ where: whereClause1 })
27562779

27572780
// Wait for query 1 data to load (still empty)
27582781
await vi.waitFor(() => {
27592782
expect(collection.size).toBe(0) // Empty query
27602783
})
27612784

27622785
// Add query 2 with different predicates (items 1, 2)
2763-
await collection._sync.loadSubset({ where: { category: `A` } } as any)
2786+
const whereClause2 = new Func(`eq`, [
2787+
new PropRef([`category`]),
2788+
new Value(`B`),
2789+
])
2790+
await collection._sync.loadSubset({ where: whereClause2 } as any)
27642791

27652792
// Wait for query 2 data to load
27662793
await vi.waitFor(() => {
@@ -2786,7 +2813,7 @@ describe(`QueryCollection`, () => {
27862813

27872814
// GC non-empty query 2 - should remove its items
27882815
queryClient.removeQueries({
2789-
queryKey: (config.queryKey as any)({ where: { category: `A` } }),
2816+
queryKey: (config.queryKey as any)({ where: whereClause2 }),
27902817
exact: true,
27912818
})
27922819

@@ -2805,29 +2832,29 @@ describe(`QueryCollection`, () => {
28052832
const queryFn = vi.fn().mockImplementation((context) => {
28062833
const { meta } = context
28072834
const loadSubsetOptions = meta?.loadSubsetOptions || {}
2808-
const { where, orderBy, limit } = loadSubsetOptions
2835+
const { where } = loadSubsetOptions
28092836

28102837
// Query 1: items 1, 2 (no predicates)
2811-
if (!where && !orderBy && !limit) {
2838+
if (where?.name === `eq` && where?.args[1].value === `C`) {
28122839
return Promise.resolve([
2813-
{ id: `1`, name: `Item 1` },
2814-
{ id: `2`, name: `Item 2` },
2840+
{ id: `1`, name: `Item 1`, type: `C` },
2841+
{ id: `2`, name: `Item 2`, type: `C` },
28152842
])
28162843
}
28172844

28182845
// Query 2: items 2, 3 (where: { type: 'A' })
2819-
if (where?.type === `A`) {
2846+
if (where?.name === `eq` && where?.args[1].value === `A`) {
28202847
return Promise.resolve([
2821-
{ id: `2`, name: `Item 2` },
2822-
{ id: `3`, name: `Item 3` },
2848+
{ id: `2`, name: `Item 2`, type: `A` },
2849+
{ id: `3`, name: `Item 3`, type: `A` },
28232850
])
28242851
}
28252852

28262853
// Query 3: items 3, 4 (where: { type: 'B' })
2827-
if (where?.type === `B`) {
2854+
if (where?.name === `eq` && where?.args[1].value === `B`) {
28282855
return Promise.resolve([
2829-
{ id: `3`, name: `Item 3` },
2830-
{ id: `4`, name: `Item 4` },
2856+
{ id: `3`, name: `Item 3`, type: `B` },
2857+
{ id: `4`, name: `Item 4`, type: `B` },
28312858
])
28322859
}
28332860

@@ -2856,23 +2883,35 @@ describe(`QueryCollection`, () => {
28562883
expect(collection.size).toBe(0)
28572884

28582885
// Load query 1 with no predicates (items 1, 2)
2859-
await collection._sync.loadSubset({})
2886+
const whereClause1 = new Func(`eq`, [
2887+
new PropRef([`type`]),
2888+
new Value(`C`),
2889+
])
2890+
await collection._sync.loadSubset({ where: whereClause1 })
28602891

28612892
// Wait for query 1 data to load
28622893
await vi.waitFor(() => {
28632894
expect(collection.size).toBe(2)
28642895
})
28652896

28662897
// Add query 2 with different predicates (items 2, 3)
2867-
await collection._sync.loadSubset({ where: { type: `A` } } as any)
2898+
const whereClause2 = new Func(`eq`, [
2899+
new PropRef([`type`]),
2900+
new Value(`A`),
2901+
])
2902+
await collection._sync.loadSubset({ where: whereClause2 })
28682903

28692904
// Wait for query 2 data to load
28702905
await vi.waitFor(() => {
28712906
expect(collection.size).toBe(3) // Should have items 1, 2, 3
28722907
})
28732908

28742909
// Add query 3 with different predicates
2875-
await collection._sync.loadSubset({ where: { type: `B` } } as any)
2910+
const whereClause3 = new Func(`eq`, [
2911+
new PropRef([`type`]),
2912+
new Value(`B`),
2913+
])
2914+
await collection._sync.loadSubset({ where: whereClause3 } as any)
28762915

28772916
// Wait for query 3 data to load
28782917
await vi.waitFor(() => {
@@ -2881,15 +2920,15 @@ describe(`QueryCollection`, () => {
28812920

28822921
// GC all queries concurrently
28832922
queryClient.removeQueries({
2884-
queryKey: (config.queryKey as any)({}),
2923+
queryKey: (config.queryKey as any)({ where: whereClause1 }),
28852924
exact: true,
28862925
})
28872926
queryClient.removeQueries({
2888-
queryKey: (config.queryKey as any)({ where: { type: `A` } }),
2927+
queryKey: (config.queryKey as any)({ where: whereClause2 }),
28892928
exact: true,
28902929
})
28912930
queryClient.removeQueries({
2892-
queryKey: (config.queryKey as any)({ where: { type: `B` } }),
2931+
queryKey: (config.queryKey as any)({ where: whereClause3 }),
28932932
exact: true,
28942933
})
28952934

0 commit comments

Comments
 (0)