Skip to content

Commit 7626097

Browse files
kevin-dpclaudeautofix-ci[bot]
authored
fix(solid-db): support findOne in useLiveQuery (#1403)
* test(solid-db): add findOne tests for useLiveQuery Add runtime and type tests for findOne support in solid-db's useLiveQuery. These tests currently fail, reproducing #1399. Fixes #1399 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * ci: apply automated fixes * fix(solid-db): support findOne in useLiveQuery Update type overloads to use InferResultType<TContext> so findOne queries return T | undefined instead of Array<T>. Add SingleResult/NonSingleResult overloads for pre-created collections. Check config.singleResult at runtime to return the first element instead of the full array. Fixes #1399 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: add changeset for solid-db findOne fix Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- 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>
1 parent e21ef32 commit 7626097

4 files changed

Lines changed: 280 additions & 8 deletions

File tree

.changeset/fix-solid-db-findone.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@tanstack/solid-db': patch
3+
---
4+
5+
fix(solid-db): support findOne in useLiveQuery
6+
7+
`useLiveQuery` with `.findOne()` returned an array instead of a single object. Updated type overloads to use `InferResultType<TContext>` so findOne queries return `T | undefined`, and added a runtime `singleResult` check to return the first element instead of the full array.
8+
9+
Fixes #1399

packages/solid-db/src/useLiveQuery.ts

Lines changed: 49 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,16 @@ import type { Accessor } from 'solid-js'
1717
import type {
1818
ChangeMessage,
1919
Collection,
20+
CollectionConfigSingleRowOption,
2021
CollectionStatus,
2122
Context,
2223
GetResult,
24+
InferResultType,
2325
InitialQueryBuilder,
2426
LiveQueryCollectionConfig,
27+
NonSingleResult,
2528
QueryBuilder,
29+
SingleResult,
2630
} from '@tanstack/db'
2731

2832
/**
@@ -97,12 +101,12 @@ import type {
97101
// Overload 1: Accept query function that always returns QueryBuilder
98102
export function useLiveQuery<TContext extends Context>(
99103
queryFn: (q: InitialQueryBuilder) => QueryBuilder<TContext>,
100-
): Accessor<Array<GetResult<TContext>>> & {
104+
): Accessor<InferResultType<TContext>> & {
101105
/**
102106
* @deprecated use function result instead
103107
* query.data -> query()
104108
*/
105-
data: Array<GetResult<TContext>>
109+
data: InferResultType<TContext>
106110
state: ReactiveMap<string | number, GetResult<TContext>>
107111
collection: Collection<GetResult<TContext>, string | number, {}>
108112
status: CollectionStatus
@@ -118,12 +122,12 @@ export function useLiveQuery<TContext extends Context>(
118122
queryFn: (
119123
q: InitialQueryBuilder,
120124
) => QueryBuilder<TContext> | undefined | null,
121-
): Accessor<Array<GetResult<TContext>>> & {
125+
): Accessor<InferResultType<TContext>> & {
122126
/**
123127
* @deprecated use function result instead
124128
* query.data -> query()
125129
*/
126-
data: Array<GetResult<TContext>>
130+
data: InferResultType<TContext>
127131
state: ReactiveMap<string | number, GetResult<TContext>>
128132
collection: Collection<GetResult<TContext>, string | number, {}> | null
129133
status: CollectionStatus | `disabled`
@@ -177,12 +181,12 @@ export function useLiveQuery<TContext extends Context>(
177181
// Overload 2: Accept config object
178182
export function useLiveQuery<TContext extends Context>(
179183
config: Accessor<LiveQueryCollectionConfig<TContext>>,
180-
): Accessor<Array<GetResult<TContext>>> & {
184+
): Accessor<InferResultType<TContext>> & {
181185
/**
182186
* @deprecated use function result instead
183187
* query.data -> query()
184188
*/
185-
data: Array<GetResult<TContext>>
189+
data: InferResultType<TContext>
186190
state: ReactiveMap<string | number, GetResult<TContext>>
187191
collection: Collection<GetResult<TContext>, string | number, {}>
188192
status: CollectionStatus
@@ -228,13 +232,15 @@ export function useLiveQuery<TContext extends Context>(
228232
* </Switch>
229233
* )
230234
*/
231-
// Overload 3: Accept pre-created live query collection
235+
// Overload 3: Accept pre-created live query collection (non-single result)
232236
export function useLiveQuery<
233237
TResult extends object,
234238
TKey extends string | number,
235239
TUtils extends Record<string, any>,
236240
>(
237-
liveQueryCollection: Accessor<Collection<TResult, TKey, TUtils>>,
241+
liveQueryCollection: Accessor<
242+
Collection<TResult, TKey, TUtils> & NonSingleResult
243+
>,
238244
): Accessor<Array<TResult>> & {
239245
/**
240246
* @deprecated use function result instead
@@ -251,6 +257,31 @@ export function useLiveQuery<
251257
isCleanedUp: boolean
252258
}
253259

260+
// Overload 3b: Accept pre-created live query collection with singleResult: true
261+
export function useLiveQuery<
262+
TResult extends object,
263+
TKey extends string | number,
264+
TUtils extends Record<string, any>,
265+
>(
266+
liveQueryCollection: Accessor<
267+
Collection<TResult, TKey, TUtils> & SingleResult
268+
>,
269+
): Accessor<TResult | undefined> & {
270+
/**
271+
* @deprecated use function result instead
272+
* query.data -> query()
273+
*/
274+
data: TResult | undefined
275+
state: ReactiveMap<TKey, TResult>
276+
collection: Collection<TResult, TKey, TUtils> & SingleResult
277+
status: CollectionStatus
278+
isLoading: boolean
279+
isReady: boolean
280+
isIdle: boolean
281+
isError: boolean
282+
isCleanedUp: boolean
283+
}
284+
254285
// Implementation - use function overloads to infer the actual collection type
255286
export function useLiveQuery(
256287
configOrQueryOrCollection: (queryFn?: any) => any,
@@ -393,6 +424,16 @@ export function useLiveQuery(
393424

394425
// We have to remove getters from the resource function so we wrap it
395426
function getData() {
427+
const currentCollection = collection()
428+
if (currentCollection) {
429+
const config: CollectionConfigSingleRowOption<any, any, any> =
430+
currentCollection.config
431+
if (config.singleResult) {
432+
// Force resource tracking so Suspense works
433+
getDataResource()
434+
return data[0]
435+
}
436+
}
396437
return getDataResource()
397438
}
398439

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { describe, expectTypeOf, it } from 'vitest'
2+
import { renderHook } from '@solidjs/testing-library'
3+
import { createCollection } from '../../db/src/collection/index'
4+
import { mockSyncCollectionOptions } from '../../db/tests/utils'
5+
import { createLiveQueryCollection, eq } from '../../db/src/query/index'
6+
import { useLiveQuery } from '../src/useLiveQuery'
7+
import type { OutputWithVirtual } from '../../db/tests/utils'
8+
import type { SingleResult } from '../../db/src/types'
9+
10+
type Person = {
11+
id: string
12+
name: string
13+
age: number
14+
email: string
15+
isActive: boolean
16+
team: string
17+
}
18+
19+
describe(`useLiveQuery type assertions`, () => {
20+
it(`should type findOne query builder to return a single row`, () => {
21+
const collection = createCollection(
22+
mockSyncCollectionOptions<Person>({
23+
id: `test-persons-2`,
24+
getKey: (person: Person) => person.id,
25+
initialData: [],
26+
}),
27+
)
28+
29+
const rendered = renderHook(() => {
30+
return useLiveQuery((q) =>
31+
q
32+
.from({ collection })
33+
.where(({ collection: c }) => eq(c.id, `3`))
34+
.findOne(),
35+
)
36+
})
37+
38+
expectTypeOf(rendered.result()).toMatchTypeOf<
39+
OutputWithVirtual<Person> | undefined
40+
>()
41+
})
42+
43+
it(`should type findOne collection using createLiveQueryCollection to return a single row`, () => {
44+
const collection = createCollection(
45+
mockSyncCollectionOptions<Person>({
46+
id: `test-persons-2`,
47+
getKey: (person: Person) => person.id,
48+
initialData: [],
49+
}),
50+
)
51+
52+
const liveQueryCollection = createLiveQueryCollection({
53+
query: (q) =>
54+
q
55+
.from({ collection })
56+
.where(({ collection: c }) => eq(c.id, `3`))
57+
.findOne(),
58+
})
59+
60+
expectTypeOf(liveQueryCollection).toExtend<SingleResult>()
61+
62+
const rendered = renderHook(() => {
63+
return useLiveQuery(() => liveQueryCollection)
64+
})
65+
66+
expectTypeOf(rendered.result()).toMatchTypeOf<
67+
OutputWithVirtual<Person> | undefined
68+
>()
69+
})
70+
71+
it(`should type non-findOne queries to return an array`, () => {
72+
const collection = createCollection(
73+
mockSyncCollectionOptions<Person>({
74+
id: `test-persons-2`,
75+
getKey: (person: Person) => person.id,
76+
initialData: [],
77+
}),
78+
)
79+
80+
const rendered = renderHook(() => {
81+
return useLiveQuery((q) => q.from({ collection }))
82+
})
83+
84+
expectTypeOf(rendered.result()).toMatchTypeOf<
85+
Array<OutputWithVirtual<Person>>
86+
>()
87+
})
88+
})

packages/solid-db/tests/useLiveQuery.test.tsx

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2453,4 +2453,138 @@ describe(`Query Collections`, () => {
24532453
expect(finalIds).toEqual([`1`, `2`, `3`, `4`])
24542454
})
24552455
})
2456+
2457+
describe(`findOne`, () => {
2458+
it(`should return a single row with query builder`, async () => {
2459+
const collection = createCollection(
2460+
mockSyncCollectionOptions<Person>({
2461+
id: `test-persons-findone-qb`,
2462+
getKey: (person: Person) => person.id,
2463+
initialData: initialPersons,
2464+
}),
2465+
)
2466+
2467+
const rendered = renderHook(() => {
2468+
return useLiveQuery((q) =>
2469+
q
2470+
.from({ collection })
2471+
.where(({ collection: c }) => eq(c.id, `3`))
2472+
.findOne(),
2473+
)
2474+
})
2475+
2476+
// Wait for collection to sync
2477+
await waitFor(() => {
2478+
expect(rendered.result.state.size).toBe(1)
2479+
})
2480+
2481+
expect(rendered.result.state.get(`3`)).toMatchObject({
2482+
id: `3`,
2483+
name: `John Smith`,
2484+
})
2485+
2486+
expect(rendered.result()).toMatchObject({
2487+
id: `3`,
2488+
name: `John Smith`,
2489+
})
2490+
})
2491+
2492+
it(`should return a single row with config object`, async () => {
2493+
const collection = createCollection(
2494+
mockSyncCollectionOptions<Person>({
2495+
id: `test-persons-findone-config`,
2496+
getKey: (person: Person) => person.id,
2497+
initialData: initialPersons,
2498+
}),
2499+
)
2500+
2501+
const rendered = renderHook(() => {
2502+
return useLiveQuery(() => ({
2503+
query: (q: any) =>
2504+
q
2505+
.from({ collection })
2506+
.where(({ collection: c }: any) => eq(c.id, `3`))
2507+
.findOne(),
2508+
}))
2509+
})
2510+
2511+
// Wait for collection to sync
2512+
await waitFor(() => {
2513+
expect(rendered.result.state.size).toBe(1)
2514+
})
2515+
2516+
expect(rendered.result.state.get(`3`)).toMatchObject({
2517+
id: `3`,
2518+
name: `John Smith`,
2519+
})
2520+
2521+
expect(rendered.result()).toMatchObject({
2522+
id: `3`,
2523+
name: `John Smith`,
2524+
})
2525+
})
2526+
2527+
it(`should return a single row with pre-created collection`, async () => {
2528+
const collection = createCollection(
2529+
mockSyncCollectionOptions<Person>({
2530+
id: `test-persons-findone-collection`,
2531+
getKey: (person: Person) => person.id,
2532+
initialData: initialPersons,
2533+
}),
2534+
)
2535+
2536+
const liveQueryCollection = createLiveQueryCollection({
2537+
query: (q) =>
2538+
q
2539+
.from({ collection })
2540+
.where(({ collection: c }) => eq(c.id, `3`))
2541+
.findOne(),
2542+
})
2543+
2544+
const rendered = renderHook(() => {
2545+
return useLiveQuery(() => liveQueryCollection)
2546+
})
2547+
2548+
// Wait for collection to sync
2549+
await waitFor(() => {
2550+
expect(rendered.result.state.size).toBe(1)
2551+
})
2552+
2553+
expect(rendered.result.state.get(`3`)).toMatchObject({
2554+
id: `3`,
2555+
name: `John Smith`,
2556+
})
2557+
2558+
expect(rendered.result()).toMatchObject({
2559+
id: `3`,
2560+
name: `John Smith`,
2561+
})
2562+
})
2563+
2564+
it(`should return undefined when findOne matches no rows`, async () => {
2565+
const collection = createCollection(
2566+
mockSyncCollectionOptions<Person>({
2567+
id: `test-persons-findone-empty`,
2568+
getKey: (person: Person) => person.id,
2569+
initialData: initialPersons,
2570+
}),
2571+
)
2572+
2573+
const rendered = renderHook(() => {
2574+
return useLiveQuery((q) =>
2575+
q
2576+
.from({ collection })
2577+
.where(({ collection: c }) => eq(c.id, `nonexistent`))
2578+
.findOne(),
2579+
)
2580+
})
2581+
2582+
// Wait for collection to be ready
2583+
await waitFor(() => {
2584+
expect(rendered.result.isReady).toBe(true)
2585+
})
2586+
2587+
expect(rendered.result()).toBeUndefined()
2588+
})
2589+
})
24562590
})

0 commit comments

Comments
 (0)