Skip to content

Commit 271960d

Browse files
0xlakshanKyleAMathewsclaude
authored
feat: Add select option to extract items while preserving metadata (#551)
* feat: Add select option to extract items while preserving metadata * test: add tests * test: add more tests * throw type mismatch error schema level * chore: remove a trailing comment * chore: add implementation examples * doc: add select to options * add changeset * Improve select validation with better error messages and type tests - Enhanced error messages to include package name, error type, received value type, and queryKey for easier debugging - Added negative type test to verify TypeScript catches wrapped response data without select function - Updated existing runtime tests to handle improved error messaging - Fixed eslint warnings for unnecessary conditions 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> --------- Co-authored-by: Kyle Mathews <[email protected]> Co-authored-by: Claude <[email protected]>
1 parent a064cb4 commit 271960d

File tree

5 files changed

+317
-16
lines changed

5 files changed

+317
-16
lines changed

.changeset/yellow-pears-rush.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@tanstack/query-db-collection": patch
3+
---
4+
5+
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.

docs/collections/query-collection.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ The `queryCollectionOptions` function accepts the following options:
5555

5656
### Query Options
5757

58+
- `select`: Function that lets extract array items when they’re wrapped with metadata
5859
- `enabled`: Whether the query should automatically run (default: `true`)
5960
- `refetchInterval`: Refetch interval in milliseconds
6061
- `retry`: Retry configuration for failed queries

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

Lines changed: 85 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -52,25 +52,25 @@ type InferSchemaInput<T> = T extends StandardSchemaV1
5252
*/
5353
export interface QueryCollectionConfig<
5454
T extends object = object,
55-
TQueryFn extends (
55+
TQueryFn extends (context: QueryFunctionContext<any>) => Promise<any> = (
5656
context: QueryFunctionContext<any>
57-
) => Promise<Array<any>> = (
58-
context: QueryFunctionContext<any>
59-
) => Promise<Array<any>>,
57+
) => Promise<any>,
6058
TError = unknown,
6159
TQueryKey extends QueryKey = QueryKey,
6260
TKey extends string | number = string | number,
6361
TSchema extends StandardSchemaV1 = never,
62+
TQueryData = Awaited<ReturnType<TQueryFn>>,
6463
> extends BaseCollectionConfig<T, TKey, TSchema> {
6564
/** The query key used by TanStack Query to identify this query */
6665
queryKey: TQueryKey
6766
/** Function that fetches data from the server. Must return the complete collection state */
6867
queryFn: TQueryFn extends (
6968
context: QueryFunctionContext<TQueryKey>
7069
) => Promise<Array<any>>
71-
? TQueryFn
72-
: (context: QueryFunctionContext<TQueryKey>) => Promise<Array<T>>
73-
70+
? (context: QueryFunctionContext<TQueryKey>) => Promise<Array<T>>
71+
: TQueryFn
72+
/* Function that extracts array items from wrapped API responses (e.g metadata, pagination) */
73+
select?: (data: TQueryData) => Array<T>
7474
/** The TanStack Query client instance */
7575
queryClient: QueryClient
7676

@@ -248,7 +248,77 @@ export interface QueryCollectionUtils<
248248
* }
249249
* })
250250
* )
251+
*
252+
* @example
253+
* // The select option extracts the items array from a response with metadata
254+
* const todosCollection = createCollection(
255+
* queryCollectionOptions({
256+
* queryKey: ['todos'],
257+
* queryFn: async () => fetch('/api/todos').then(r => r.json()),
258+
* select: (data) => data.items, // Extract the array of items
259+
* queryClient,
260+
* schema: todoSchema,
261+
* getKey: (item) => item.id,
262+
* })
263+
* )
251264
*/
265+
// Overload for when schema is provided and select present
266+
export function queryCollectionOptions<
267+
T extends StandardSchemaV1,
268+
TQueryFn extends (context: QueryFunctionContext<any>) => Promise<any>,
269+
TError = unknown,
270+
TQueryKey extends QueryKey = QueryKey,
271+
TKey extends string | number = string | number,
272+
TQueryData = Awaited<ReturnType<TQueryFn>>,
273+
>(
274+
config: QueryCollectionConfig<
275+
InferSchemaOutput<T>,
276+
TQueryFn,
277+
TError,
278+
TQueryKey,
279+
TKey,
280+
T
281+
> & {
282+
schema: T
283+
select: (data: TQueryData) => Array<InferSchemaInput<T>>
284+
}
285+
): CollectionConfig<InferSchemaOutput<T>, TKey, T> & {
286+
schema: T
287+
utils: QueryCollectionUtils<
288+
InferSchemaOutput<T>,
289+
TKey,
290+
InferSchemaInput<T>,
291+
TError
292+
>
293+
}
294+
295+
// Overload for when no schema is provided and select present
296+
export function queryCollectionOptions<
297+
T extends object,
298+
TQueryFn extends (context: QueryFunctionContext<any>) => Promise<any> = (
299+
context: QueryFunctionContext<any>
300+
) => Promise<any>,
301+
TError = unknown,
302+
TQueryKey extends QueryKey = QueryKey,
303+
TKey extends string | number = string | number,
304+
TQueryData = Awaited<ReturnType<TQueryFn>>,
305+
>(
306+
config: QueryCollectionConfig<
307+
T,
308+
TQueryFn,
309+
TError,
310+
TQueryKey,
311+
TKey,
312+
never,
313+
TQueryData
314+
> & {
315+
schema?: never // prohibit schema
316+
select: (data: TQueryData) => Array<T>
317+
}
318+
): CollectionConfig<T, TKey> & {
319+
schema?: never // no schema in the result
320+
utils: QueryCollectionUtils<T, TKey, T, TError>
321+
}
252322

253323
// Overload for when schema is provided
254324
export function queryCollectionOptions<
@@ -308,6 +378,7 @@ export function queryCollectionOptions(
308378
const {
309379
queryKey,
310380
queryFn,
381+
select,
311382
queryClient,
312383
enabled,
313384
refetchInterval,
@@ -390,16 +461,18 @@ export function queryCollectionOptions(
390461
lastError = undefined
391462
errorCount = 0
392463

393-
const newItemsArray = result.data
464+
const rawData = result.data
465+
const newItemsArray = select ? select(rawData) : rawData
394466

395467
if (
396468
!Array.isArray(newItemsArray) ||
397469
newItemsArray.some((item) => typeof item !== `object`)
398470
) {
399-
console.error(
400-
`[QueryCollection] queryFn did not return an array of objects. Skipping update.`,
401-
newItemsArray
402-
)
471+
const errorMessage = select
472+
? `@tanstack/query-db-collection: select() must return an array of objects. Got: ${typeof newItemsArray} for queryKey ${JSON.stringify(queryKey)}`
473+
: `@tanstack/query-db-collection: queryFn must return an array of objects. Got: ${typeof newItemsArray} for queryKey ${JSON.stringify(queryKey)}`
474+
475+
console.error(errorMessage)
403476
return
404477
}
405478

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

Lines changed: 103 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -263,10 +263,10 @@ describe(`Query collection type resolution tests`, () => {
263263
const options = queryCollectionOptions({
264264
queryClient,
265265
queryKey: [`schema-priority`],
266-
// @ts-expect-error – queryFn doesn't match the schema type
267266
queryFn: async () => {
268267
return [] as Array<UserType>
269268
},
269+
// @ts-expect-error – queryFn doesn't match the schema type
270270
schema: userSchema,
271271
getKey: (item) => item.id,
272272
})
@@ -301,4 +301,106 @@ describe(`Query collection type resolution tests`, () => {
301301
expectTypeOf(collection.toArray).toEqualTypeOf<Array<TodoType>>()
302302
})
303303
})
304+
305+
describe(`select type inference`, () => {
306+
it(`queryFn type inference`, () => {
307+
const dataSchema = z.object({
308+
id: z.string(),
309+
name: z.string(),
310+
email: z.string(),
311+
})
312+
313+
const options = queryCollectionOptions({
314+
queryClient,
315+
queryKey: [`x-queryFn-infer`],
316+
queryFn: async (): Promise<Array<z.infer<typeof dataSchema>>> => {
317+
return [] as Array<z.infer<typeof dataSchema>>
318+
},
319+
schema: dataSchema,
320+
getKey: (item) => item.id,
321+
})
322+
323+
type ExpectedType = z.infer<typeof dataSchema>
324+
expectTypeOf(options.getKey).parameters.toEqualTypeOf<[ExpectedType]>()
325+
})
326+
327+
it(`should error when queryFn returns wrapped data without select`, () => {
328+
const userData = z.object({
329+
id: z.string(),
330+
name: z.string(),
331+
email: z.string(),
332+
})
333+
334+
type UserDataType = z.infer<typeof userData>
335+
336+
type WrappedResponse = {
337+
metadata: string
338+
data: Array<UserDataType>
339+
}
340+
341+
queryCollectionOptions({
342+
queryClient,
343+
queryKey: [`wrapped-no-select`],
344+
// @ts-expect-error - queryFn returns wrapped data but no select provided
345+
queryFn: (): Promise<WrappedResponse> => {
346+
return Promise.resolve({
347+
metadata: `example`,
348+
data: [],
349+
})
350+
},
351+
// @ts-expect-error - schema type conflicts with queryFn return type
352+
schema: userData,
353+
// @ts-expect-error - item type is inferred as object due to type mismatch
354+
getKey: (item) => item.id,
355+
})
356+
})
357+
358+
it(`select properly extracts array from wrapped response`, () => {
359+
const userData = z.object({
360+
id: z.string(),
361+
name: z.string(),
362+
email: z.string(),
363+
})
364+
365+
type UserDataType = z.infer<typeof userData>
366+
367+
type MetaDataType<T> = {
368+
metaDataOne: string
369+
metaDataTwo: string
370+
data: T
371+
}
372+
373+
const metaDataObject: ResponseType = {
374+
metaDataOne: `example meta data`,
375+
metaDataTwo: `example meta data`,
376+
data: [
377+
{
378+
id: `1`,
379+
name: `carter`,
380+
381+
},
382+
],
383+
}
384+
385+
type ResponseType = MetaDataType<Array<UserDataType>>
386+
387+
const selectUserData = (data: ResponseType) => {
388+
return data.data
389+
}
390+
391+
queryCollectionOptions({
392+
queryClient,
393+
queryKey: [`x-queryFn-infer`],
394+
queryFn: async (): Promise<ResponseType> => {
395+
return metaDataObject
396+
},
397+
select: selectUserData,
398+
schema: userData,
399+
getKey: (item) => item.id,
400+
})
401+
402+
// Should infer ResponseType as select parameter type
403+
expectTypeOf(selectUserData).parameters.toEqualTypeOf<[ResponseType]>()
404+
})
405+
})
304406
})

0 commit comments

Comments
 (0)