diff --git a/.changeset/clarify-query-select-semantics.md b/.changeset/clarify-query-select-semantics.md new file mode 100644 index 0000000000..632c15c281 --- /dev/null +++ b/.changeset/clarify-query-select-semantics.md @@ -0,0 +1,5 @@ +--- +'@tanstack/query-db-collection': patch +--- + +Clarify that `select` extracts rows for DB materialization while preserving the wrapped TanStack Query cache response. diff --git a/docs/collections/query-collection.md b/docs/collections/query-collection.md index 73f3511edc..825087cf65 100644 --- a/docs/collections/query-collection.md +++ b/docs/collections/query-collection.md @@ -56,7 +56,7 @@ The `queryCollectionOptions` function accepts the following options: ### Query Options -- `select`: Function that lets extract array items when they're wrapped with metadata +- `select`: Function that extracts the row array TanStack DB materializes from a wrapped Query response - `enabled`: Whether the query should automatically run (default: `true`) - `refetchInterval`: Refetch interval in milliseconds (default: 0 — set an interval to enable polling refetching) - `retry`: Retry configuration for failed queries @@ -96,6 +96,45 @@ const todosCollection = createCollection( If `queryFn` is missing at runtime, `queryCollectionOptions` throws `QueryFnRequiredError`. +### Selecting Rows from Wrapped Responses + +Many APIs return rows inside a response envelope that also contains metadata such as pagination cursors, totals, or request information. Use `select` to extract the row array that TanStack DB should materialize: + +```typescript +interface TodosResponse { + items: Array<{ id: string; title: string }> + nextCursor?: string + total: number +} + +const todosCollection = createCollection( + queryCollectionOptions({ + queryKey: ["todos"], + queryFn: async (): Promise => { + const response = await fetch("/api/todos") + return response.json() + }, + select: (response) => response.items, + queryClient, + getKey: (item) => item.id, + }), +) +``` + +`select` is a query-db-collection row extraction hook. It tells TanStack DB which rows to materialize while the TanStack Query cache keeps the original query response shape. In the example above, `queryClient.getQueryData(["todos"])` still returns the full `TodosResponse`, including `nextCursor` and `total`. + +This differs from TanStack Query's observer-level `select`: query-db-collection uses this option to bridge Query's response object into DB's normalized row store. + +Direct write utilities such as `writeInsert`, `writeUpdate`, and `writeDelete` make a best-effort attempt to update the matching row array inside wrapped Query cache entries while preserving wrapper metadata. + +This works automatically for simple wrappers such as: + +- `{ data: [...] }` +- `{ items: [...] }` +- `{ results: [...] }` + +Derived projections, such as `select: (response) => response.edges.map((edge) => edge.node)`, are read-side row extraction only. query-db-collection cannot generally reconstruct the original response envelope from updated rows. Refetch or invalidate the query if the wrapped cache must exactly reflect direct writes for a derived projection. + ### Collection Options - `id`: Unique identifier for the collection diff --git a/packages/query-db-collection/src/query.ts b/packages/query-db-collection/src/query.ts index ec3396117e..ea303b8ddb 100644 --- a/packages/query-db-collection/src/query.ts +++ b/packages/query-db-collection/src/query.ts @@ -77,7 +77,10 @@ export interface QueryCollectionConfig< ) => Promise> | Array ? (context: QueryFunctionContext) => Promise> | Array : TQueryFn - /* Function that extracts array items from wrapped API responses (e.g metadata, pagination) */ + /** + * Extracts the row array TanStack DB materializes from the Query response. + * The Query cache keeps the original response shape. + */ select?: (data: TQueryData) => Array /** The TanStack Query client instance */ queryClient: QueryClient diff --git a/packages/query-db-collection/tests/query.test.ts b/packages/query-db-collection/tests/query.test.ts index aa9ee24063..376ae319a4 100644 --- a/packages/query-db-collection/tests/query.test.ts +++ b/packages/query-db-collection/tests/query.test.ts @@ -898,6 +898,46 @@ describe(`QueryCollection`, () => { expect(initialCache).toEqual(initialMetaData) }) + it(`materializes selected rows while preserving wrapped Query cache response`, async () => { + const queryKey = [`select-row-extraction-test`] + const wrappedResponse = { + items: initialMetaData.data, + meta: { page: 1, total: initialMetaData.data.length }, + } + const expectedCacheResponse = { + items: initialMetaData.data.map((item) => ({ ...item })), + meta: { page: 1, total: initialMetaData.data.length }, + } + + const queryFn = vi.fn().mockResolvedValue(wrappedResponse) + const select = vi.fn((data: typeof wrappedResponse) => data.items) + + const options = queryCollectionOptions({ + id: `select-row-extraction-test`, + queryClient, + queryKey, + queryFn, + select, + getKey, + startSync: true, + }) + const collection = createCollection(options) + + await vi.waitFor(() => { + expect(queryFn).toHaveBeenCalledTimes(1) + expect(select).toHaveBeenCalledTimes(1) + expect(collection.size).toBe(wrappedResponse.items.length) + }) + + expect(stripVirtualProps(collection.get(`1`))).toEqual( + wrappedResponse.items[0], + ) + expect(stripVirtualProps(collection.get(`2`))).toEqual( + wrappedResponse.items[1], + ) + expect(queryClient.getQueryData(queryKey)).toEqual(expectedCacheResponse) + }) + it(`should not throw error when using writeInsert with select option`, async () => { const queryKey = [`select-writeInsert-test`] const consoleErrorSpy = vi