diff --git a/.changeset/yellow-pears-rush.md b/.changeset/yellow-pears-rush.md new file mode 100644 index 000000000..e163738bf --- /dev/null +++ b/.changeset/yellow-pears-rush.md @@ -0,0 +1,5 @@ +--- +"@tanstack/query-db-collection": patch +--- + +query-collection now supports a `select` function to transform raw query results into an array of items. This is useful for APIs that return data with metadata or nested structures, ensuring metadata remains cached while collections work with the unwrapped array. diff --git a/docs/collections/query-collection.md b/docs/collections/query-collection.md index ae826b46d..6f641dbf3 100644 --- a/docs/collections/query-collection.md +++ b/docs/collections/query-collection.md @@ -55,6 +55,7 @@ The `queryCollectionOptions` function accepts the following options: ### Query Options +- `select`: Function that lets extract array items when they’re wrapped with metadata - `enabled`: Whether the query should automatically run (default: `true`) - `refetchInterval`: Refetch interval in milliseconds - `retry`: Retry configuration for failed queries diff --git a/packages/query-db-collection/src/query.ts b/packages/query-db-collection/src/query.ts index 902d81390..832f6b755 100644 --- a/packages/query-db-collection/src/query.ts +++ b/packages/query-db-collection/src/query.ts @@ -52,15 +52,14 @@ type InferSchemaInput = T extends StandardSchemaV1 */ export interface QueryCollectionConfig< T extends object = object, - TQueryFn extends ( + TQueryFn extends (context: QueryFunctionContext) => Promise = ( context: QueryFunctionContext - ) => Promise> = ( - context: QueryFunctionContext - ) => Promise>, + ) => Promise, TError = unknown, TQueryKey extends QueryKey = QueryKey, TKey extends string | number = string | number, TSchema extends StandardSchemaV1 = never, + TQueryData = Awaited>, > extends BaseCollectionConfig { /** The query key used by TanStack Query to identify this query */ queryKey: TQueryKey @@ -68,9 +67,10 @@ export interface QueryCollectionConfig< queryFn: TQueryFn extends ( context: QueryFunctionContext ) => Promise> - ? TQueryFn - : (context: QueryFunctionContext) => Promise> - + ? (context: QueryFunctionContext) => Promise> + : TQueryFn + /* Function that extracts array items from wrapped API responses (e.g metadata, pagination) */ + select?: (data: TQueryData) => Array /** The TanStack Query client instance */ queryClient: QueryClient @@ -248,7 +248,77 @@ export interface QueryCollectionUtils< * } * }) * ) + * + * @example + * // The select option extracts the items array from a response with metadata + * const todosCollection = createCollection( + * queryCollectionOptions({ + * queryKey: ['todos'], + * queryFn: async () => fetch('/api/todos').then(r => r.json()), + * select: (data) => data.items, // Extract the array of items + * queryClient, + * schema: todoSchema, + * getKey: (item) => item.id, + * }) + * ) */ +// Overload for when schema is provided and select present +export function queryCollectionOptions< + T extends StandardSchemaV1, + TQueryFn extends (context: QueryFunctionContext) => Promise, + TError = unknown, + TQueryKey extends QueryKey = QueryKey, + TKey extends string | number = string | number, + TQueryData = Awaited>, +>( + config: QueryCollectionConfig< + InferSchemaOutput, + TQueryFn, + TError, + TQueryKey, + TKey, + T + > & { + schema: T + select: (data: TQueryData) => Array> + } +): CollectionConfig, TKey, T> & { + schema: T + utils: QueryCollectionUtils< + InferSchemaOutput, + TKey, + InferSchemaInput, + TError + > +} + +// Overload for when no schema is provided and select present +export function queryCollectionOptions< + T extends object, + TQueryFn extends (context: QueryFunctionContext) => Promise = ( + context: QueryFunctionContext + ) => Promise, + TError = unknown, + TQueryKey extends QueryKey = QueryKey, + TKey extends string | number = string | number, + TQueryData = Awaited>, +>( + config: QueryCollectionConfig< + T, + TQueryFn, + TError, + TQueryKey, + TKey, + never, + TQueryData + > & { + schema?: never // prohibit schema + select: (data: TQueryData) => Array + } +): CollectionConfig & { + schema?: never // no schema in the result + utils: QueryCollectionUtils +} // Overload for when schema is provided export function queryCollectionOptions< @@ -308,6 +378,7 @@ export function queryCollectionOptions( const { queryKey, queryFn, + select, queryClient, enabled, refetchInterval, @@ -390,16 +461,18 @@ export function queryCollectionOptions( lastError = undefined errorCount = 0 - const newItemsArray = result.data + const rawData = result.data + const newItemsArray = select ? select(rawData) : rawData if ( !Array.isArray(newItemsArray) || newItemsArray.some((item) => typeof item !== `object`) ) { - console.error( - `[QueryCollection] queryFn did not return an array of objects. Skipping update.`, - newItemsArray - ) + const errorMessage = select + ? `@tanstack/query-db-collection: select() must return an array of objects. Got: ${typeof newItemsArray} for queryKey ${JSON.stringify(queryKey)}` + : `@tanstack/query-db-collection: queryFn must return an array of objects. Got: ${typeof newItemsArray} for queryKey ${JSON.stringify(queryKey)}` + + console.error(errorMessage) return } diff --git a/packages/query-db-collection/tests/query.test-d.ts b/packages/query-db-collection/tests/query.test-d.ts index 7d97d21e5..c8df9298e 100644 --- a/packages/query-db-collection/tests/query.test-d.ts +++ b/packages/query-db-collection/tests/query.test-d.ts @@ -263,10 +263,10 @@ describe(`Query collection type resolution tests`, () => { const options = queryCollectionOptions({ queryClient, queryKey: [`schema-priority`], - // @ts-expect-error – queryFn doesn't match the schema type queryFn: async () => { return [] as Array }, + // @ts-expect-error – queryFn doesn't match the schema type schema: userSchema, getKey: (item) => item.id, }) @@ -301,4 +301,106 @@ describe(`Query collection type resolution tests`, () => { expectTypeOf(collection.toArray).toEqualTypeOf>() }) }) + + describe(`select type inference`, () => { + it(`queryFn type inference`, () => { + const dataSchema = z.object({ + id: z.string(), + name: z.string(), + email: z.string(), + }) + + const options = queryCollectionOptions({ + queryClient, + queryKey: [`x-queryFn-infer`], + queryFn: async (): Promise>> => { + return [] as Array> + }, + schema: dataSchema, + getKey: (item) => item.id, + }) + + type ExpectedType = z.infer + expectTypeOf(options.getKey).parameters.toEqualTypeOf<[ExpectedType]>() + }) + + it(`should error when queryFn returns wrapped data without select`, () => { + const userData = z.object({ + id: z.string(), + name: z.string(), + email: z.string(), + }) + + type UserDataType = z.infer + + type WrappedResponse = { + metadata: string + data: Array + } + + queryCollectionOptions({ + queryClient, + queryKey: [`wrapped-no-select`], + // @ts-expect-error - queryFn returns wrapped data but no select provided + queryFn: (): Promise => { + return Promise.resolve({ + metadata: `example`, + data: [], + }) + }, + // @ts-expect-error - schema type conflicts with queryFn return type + schema: userData, + // @ts-expect-error - item type is inferred as object due to type mismatch + getKey: (item) => item.id, + }) + }) + + it(`select properly extracts array from wrapped response`, () => { + const userData = z.object({ + id: z.string(), + name: z.string(), + email: z.string(), + }) + + type UserDataType = z.infer + + type MetaDataType = { + metaDataOne: string + metaDataTwo: string + data: T + } + + const metaDataObject: ResponseType = { + metaDataOne: `example meta data`, + metaDataTwo: `example meta data`, + data: [ + { + id: `1`, + name: `carter`, + email: `c@email.com`, + }, + ], + } + + type ResponseType = MetaDataType> + + const selectUserData = (data: ResponseType) => { + return data.data + } + + queryCollectionOptions({ + queryClient, + queryKey: [`x-queryFn-infer`], + queryFn: async (): Promise => { + return metaDataObject + }, + select: selectUserData, + schema: userData, + getKey: (item) => item.id, + }) + + // Should infer ResponseType as select parameter type + expectTypeOf(selectUserData).parameters.toEqualTypeOf<[ResponseType]>() + }) + }) }) diff --git a/packages/query-db-collection/tests/query.test.ts b/packages/query-db-collection/tests/query.test.ts index c7085a857..9a380547b 100644 --- a/packages/query-db-collection/tests/query.test.ts +++ b/packages/query-db-collection/tests/query.test.ts @@ -257,14 +257,13 @@ describe(`QueryCollection`, () => { await vi.waitFor(() => { const errorCallArgs = consoleErrorSpy.mock.calls.find((call) => call[0].includes( - `[QueryCollection] queryFn did not return an array of objects` + `@tanstack/query-db-collection: queryFn must return an array of objects` ) ) expect(errorCallArgs).toBeDefined() }) // The collection state should remain empty or unchanged - // Since we're not setting any initial data, we expect the state to be empty expect(collection.size).toBe(0) // Clean up the spy @@ -435,6 +434,128 @@ describe(`QueryCollection`, () => { expect(queryFn).toHaveBeenCalledWith(expect.objectContaining({ meta })) }) + describe(`Select method testing`, () => { + type MetaDataType = { + metaDataOne: string + metaDataTwo: string + data: Array + } + + const initialMetaData: MetaDataType = { + metaDataOne: `example metadata`, + metaDataTwo: `example metadata`, + data: [ + { + id: `1`, + name: `First Item`, + }, + { + id: `2`, + name: `Second Item`, + }, + ], + } + + it(`Select extracts array from metadata`, async () => { + const queryKey = [`select-test`] + + const queryFn = vi.fn().mockResolvedValue(initialMetaData) + const select = vi.fn().mockReturnValue(initialMetaData.data) + + const options = queryCollectionOptions({ + id: `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).toBeGreaterThan(0) + }) + + expect(collection.size).toBe(initialMetaData.data.length) + expect(collection.get(`1`)).toEqual(initialMetaData.data[0]) + expect(collection.get(`2`)).toEqual(initialMetaData.data[1]) + }) + + it(`Throws error if select returns non array`, async () => { + const queryKey = [`select-test`] + const consoleErrorSpy = vi + .spyOn(console, `error`) + .mockImplementation(() => {}) + + const queryFn = vi.fn().mockResolvedValue(initialMetaData) + // Returns non-array + const select = vi.fn().mockReturnValue(initialMetaData) + + const options = queryCollectionOptions({ + id: `test`, + queryClient, + queryKey, + queryFn, + select, + getKey, + startSync: true, + }) + const collection = createCollection(options) + + await vi.waitFor(() => { + expect(queryFn).toHaveBeenCalledTimes(1) + expect(select).toHaveBeenCalledTimes(1) + }) + + // Verify the validation error was logged + await vi.waitFor(() => { + const errorCallArgs = consoleErrorSpy.mock.calls.find((call) => + call[0].includes( + `@tanstack/query-db-collection: select() must return an array of objects` + ) + ) + expect(errorCallArgs).toBeDefined() + }) + + expect(collection.size).toBe(0) + + // Clean up the spy + consoleErrorSpy.mockRestore() + }) + + it(`Whole response is cached in QueryClient when used with select option`, async () => { + const queryKey = [`select-test`] + + const queryFn = vi.fn().mockResolvedValue(initialMetaData) + const select = vi.fn().mockReturnValue(initialMetaData.data) + + const options = queryCollectionOptions({ + id: `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(2) + }) + + // Verify that the query cache state exists along with its metadata + const initialCache = queryClient.getQueryData( + queryKey + ) as MetaDataType + expect(initialCache).toEqual(initialMetaData) + }) + }) describe(`Direct persistence handlers`, () => { it(`should pass through direct persistence handlers to collection options`, () => { const queryKey = [`directPersistenceTest`] @@ -1423,7 +1544,6 @@ describe(`QueryCollection`, () => { const updatedItem = cacheAfterUpdate.find((item) => item.id === `1`) expect(updatedItem?.name).toBe(`Updated Item 1`) expect(updatedItem?.value).toBe(10) // Original value preserved - // Test writeDelete updates cache collection.utils.writeDelete(`2`)