Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/fix-solid-db-findone.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@tanstack/solid-db': patch
---

fix(solid-db): support findOne in useLiveQuery

`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.

Fixes #1399
57 changes: 49 additions & 8 deletions packages/solid-db/src/useLiveQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,16 @@ import type { Accessor } from 'solid-js'
import type {
ChangeMessage,
Collection,
CollectionConfigSingleRowOption,
CollectionStatus,
Context,
GetResult,
InferResultType,
InitialQueryBuilder,
LiveQueryCollectionConfig,
NonSingleResult,
QueryBuilder,
SingleResult,
} from '@tanstack/db'

/**
Expand Down Expand Up @@ -97,12 +101,12 @@ import type {
// Overload 1: Accept query function that always returns QueryBuilder
export function useLiveQuery<TContext extends Context>(
queryFn: (q: InitialQueryBuilder) => QueryBuilder<TContext>,
): Accessor<Array<GetResult<TContext>>> & {
): Accessor<InferResultType<TContext>> & {
/**
* @deprecated use function result instead
* query.data -> query()
*/
data: Array<GetResult<TContext>>
data: InferResultType<TContext>
state: ReactiveMap<string | number, GetResult<TContext>>
collection: Collection<GetResult<TContext>, string | number, {}>
status: CollectionStatus
Expand All @@ -118,12 +122,12 @@ export function useLiveQuery<TContext extends Context>(
queryFn: (
q: InitialQueryBuilder,
) => QueryBuilder<TContext> | undefined | null,
): Accessor<Array<GetResult<TContext>>> & {
): Accessor<InferResultType<TContext>> & {
/**
* @deprecated use function result instead
* query.data -> query()
*/
data: Array<GetResult<TContext>>
data: InferResultType<TContext>
state: ReactiveMap<string | number, GetResult<TContext>>
collection: Collection<GetResult<TContext>, string | number, {}> | null
status: CollectionStatus | `disabled`
Expand Down Expand Up @@ -177,12 +181,12 @@ export function useLiveQuery<TContext extends Context>(
// Overload 2: Accept config object
export function useLiveQuery<TContext extends Context>(
config: Accessor<LiveQueryCollectionConfig<TContext>>,
): Accessor<Array<GetResult<TContext>>> & {
): Accessor<InferResultType<TContext>> & {
/**
* @deprecated use function result instead
* query.data -> query()
*/
data: Array<GetResult<TContext>>
data: InferResultType<TContext>
state: ReactiveMap<string | number, GetResult<TContext>>
collection: Collection<GetResult<TContext>, string | number, {}>
status: CollectionStatus
Expand Down Expand Up @@ -228,13 +232,15 @@ export function useLiveQuery<TContext extends Context>(
* </Switch>
* )
*/
// Overload 3: Accept pre-created live query collection
// Overload 3: Accept pre-created live query collection (non-single result)
export function useLiveQuery<
TResult extends object,
TKey extends string | number,
TUtils extends Record<string, any>,
>(
liveQueryCollection: Accessor<Collection<TResult, TKey, TUtils>>,
liveQueryCollection: Accessor<
Collection<TResult, TKey, TUtils> & NonSingleResult
>,
): Accessor<Array<TResult>> & {
/**
* @deprecated use function result instead
Expand All @@ -251,6 +257,31 @@ export function useLiveQuery<
isCleanedUp: boolean
}

// Overload 3b: Accept pre-created live query collection with singleResult: true
export function useLiveQuery<
TResult extends object,
TKey extends string | number,
TUtils extends Record<string, any>,
>(
liveQueryCollection: Accessor<
Collection<TResult, TKey, TUtils> & SingleResult
>,
): Accessor<TResult | undefined> & {
/**
* @deprecated use function result instead
* query.data -> query()
*/
data: TResult | undefined
state: ReactiveMap<TKey, TResult>
collection: Collection<TResult, TKey, TUtils> & SingleResult
status: CollectionStatus
isLoading: boolean
isReady: boolean
isIdle: boolean
isError: boolean
isCleanedUp: boolean
}

// Implementation - use function overloads to infer the actual collection type
export function useLiveQuery(
configOrQueryOrCollection: (queryFn?: any) => any,
Expand Down Expand Up @@ -393,6 +424,16 @@ export function useLiveQuery(

// We have to remove getters from the resource function so we wrap it
function getData() {
const currentCollection = collection()
if (currentCollection) {
const config: CollectionConfigSingleRowOption<any, any, any> =
currentCollection.config
if (config.singleResult) {
// Force resource tracking so Suspense works
getDataResource()
return data[0]
}
}
return getDataResource()
}

Expand Down
88 changes: 88 additions & 0 deletions packages/solid-db/tests/useLiveQuery.test-d.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { describe, expectTypeOf, it } from 'vitest'
import { renderHook } from '@solidjs/testing-library'
import { createCollection } from '../../db/src/collection/index'
import { mockSyncCollectionOptions } from '../../db/tests/utils'
import { createLiveQueryCollection, eq } from '../../db/src/query/index'
import { useLiveQuery } from '../src/useLiveQuery'
import type { OutputWithVirtual } from '../../db/tests/utils'
import type { SingleResult } from '../../db/src/types'

type Person = {
id: string
name: string
age: number
email: string
isActive: boolean
team: string
}

describe(`useLiveQuery type assertions`, () => {
it(`should type findOne query builder to return a single row`, () => {
const collection = createCollection(
mockSyncCollectionOptions<Person>({
id: `test-persons-2`,
getKey: (person: Person) => person.id,
initialData: [],
}),
)

const rendered = renderHook(() => {
return useLiveQuery((q) =>
q
.from({ collection })
.where(({ collection: c }) => eq(c.id, `3`))
.findOne(),
)
})

expectTypeOf(rendered.result()).toMatchTypeOf<
OutputWithVirtual<Person> | undefined
>()
})

it(`should type findOne collection using createLiveQueryCollection to return a single row`, () => {
const collection = createCollection(
mockSyncCollectionOptions<Person>({
id: `test-persons-2`,
getKey: (person: Person) => person.id,
initialData: [],
}),
)

const liveQueryCollection = createLiveQueryCollection({
query: (q) =>
q
.from({ collection })
.where(({ collection: c }) => eq(c.id, `3`))
.findOne(),
})

expectTypeOf(liveQueryCollection).toExtend<SingleResult>()

const rendered = renderHook(() => {
return useLiveQuery(() => liveQueryCollection)
})

expectTypeOf(rendered.result()).toMatchTypeOf<
OutputWithVirtual<Person> | undefined
>()
})

it(`should type non-findOne queries to return an array`, () => {
const collection = createCollection(
mockSyncCollectionOptions<Person>({
id: `test-persons-2`,
getKey: (person: Person) => person.id,
initialData: [],
}),
)

const rendered = renderHook(() => {
return useLiveQuery((q) => q.from({ collection }))
})

expectTypeOf(rendered.result()).toMatchTypeOf<
Array<OutputWithVirtual<Person>>
>()
})
})
134 changes: 134 additions & 0 deletions packages/solid-db/tests/useLiveQuery.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2453,4 +2453,138 @@ describe(`Query Collections`, () => {
expect(finalIds).toEqual([`1`, `2`, `3`, `4`])
})
})

describe(`findOne`, () => {
it(`should return a single row with query builder`, async () => {
const collection = createCollection(
mockSyncCollectionOptions<Person>({
id: `test-persons-findone-qb`,
getKey: (person: Person) => person.id,
initialData: initialPersons,
}),
)

const rendered = renderHook(() => {
return useLiveQuery((q) =>
q
.from({ collection })
.where(({ collection: c }) => eq(c.id, `3`))
.findOne(),
)
})

// Wait for collection to sync
await waitFor(() => {
expect(rendered.result.state.size).toBe(1)
})

expect(rendered.result.state.get(`3`)).toMatchObject({
id: `3`,
name: `John Smith`,
})

expect(rendered.result()).toMatchObject({
id: `3`,
name: `John Smith`,
})
})

it(`should return a single row with config object`, async () => {
const collection = createCollection(
mockSyncCollectionOptions<Person>({
id: `test-persons-findone-config`,
getKey: (person: Person) => person.id,
initialData: initialPersons,
}),
)

const rendered = renderHook(() => {
return useLiveQuery(() => ({
query: (q: any) =>
q
.from({ collection })
.where(({ collection: c }: any) => eq(c.id, `3`))
.findOne(),
}))
})

// Wait for collection to sync
await waitFor(() => {
expect(rendered.result.state.size).toBe(1)
})

expect(rendered.result.state.get(`3`)).toMatchObject({
id: `3`,
name: `John Smith`,
})

expect(rendered.result()).toMatchObject({
id: `3`,
name: `John Smith`,
})
})

it(`should return a single row with pre-created collection`, async () => {
const collection = createCollection(
mockSyncCollectionOptions<Person>({
id: `test-persons-findone-collection`,
getKey: (person: Person) => person.id,
initialData: initialPersons,
}),
)

const liveQueryCollection = createLiveQueryCollection({
query: (q) =>
q
.from({ collection })
.where(({ collection: c }) => eq(c.id, `3`))
.findOne(),
})

const rendered = renderHook(() => {
return useLiveQuery(() => liveQueryCollection)
})

// Wait for collection to sync
await waitFor(() => {
expect(rendered.result.state.size).toBe(1)
})

expect(rendered.result.state.get(`3`)).toMatchObject({
id: `3`,
name: `John Smith`,
})

expect(rendered.result()).toMatchObject({
id: `3`,
name: `John Smith`,
})
})

it(`should return undefined when findOne matches no rows`, async () => {
const collection = createCollection(
mockSyncCollectionOptions<Person>({
id: `test-persons-findone-empty`,
getKey: (person: Person) => person.id,
initialData: initialPersons,
}),
)

const rendered = renderHook(() => {
return useLiveQuery((q) =>
q
.from({ collection })
.where(({ collection: c }) => eq(c.id, `nonexistent`))
.findOne(),
)
})

// Wait for collection to be ready
await waitFor(() => {
expect(rendered.result.isReady).toBe(true)
})

expect(rendered.result()).toBeUndefined()
})
})
})
Loading