diff --git a/.changeset/fix-solid-db-findone.md b/.changeset/fix-solid-db-findone.md new file mode 100644 index 000000000..2c6bc4281 --- /dev/null +++ b/.changeset/fix-solid-db-findone.md @@ -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` so findOne queries return `T | undefined`, and added a runtime `singleResult` check to return the first element instead of the full array. + +Fixes #1399 diff --git a/packages/solid-db/src/useLiveQuery.ts b/packages/solid-db/src/useLiveQuery.ts index 7a5ba9595..5380bae00 100644 --- a/packages/solid-db/src/useLiveQuery.ts +++ b/packages/solid-db/src/useLiveQuery.ts @@ -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' /** @@ -97,12 +101,12 @@ import type { // Overload 1: Accept query function that always returns QueryBuilder export function useLiveQuery( queryFn: (q: InitialQueryBuilder) => QueryBuilder, -): Accessor>> & { +): Accessor> & { /** * @deprecated use function result instead * query.data -> query() */ - data: Array> + data: InferResultType state: ReactiveMap> collection: Collection, string | number, {}> status: CollectionStatus @@ -118,12 +122,12 @@ export function useLiveQuery( queryFn: ( q: InitialQueryBuilder, ) => QueryBuilder | undefined | null, -): Accessor>> & { +): Accessor> & { /** * @deprecated use function result instead * query.data -> query() */ - data: Array> + data: InferResultType state: ReactiveMap> collection: Collection, string | number, {}> | null status: CollectionStatus | `disabled` @@ -177,12 +181,12 @@ export function useLiveQuery( // Overload 2: Accept config object export function useLiveQuery( config: Accessor>, -): Accessor>> & { +): Accessor> & { /** * @deprecated use function result instead * query.data -> query() */ - data: Array> + data: InferResultType state: ReactiveMap> collection: Collection, string | number, {}> status: CollectionStatus @@ -228,13 +232,15 @@ export function useLiveQuery( * * ) */ -// 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, >( - liveQueryCollection: Accessor>, + liveQueryCollection: Accessor< + Collection & NonSingleResult + >, ): Accessor> & { /** * @deprecated use function result instead @@ -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, +>( + liveQueryCollection: Accessor< + Collection & SingleResult + >, +): Accessor & { + /** + * @deprecated use function result instead + * query.data -> query() + */ + data: TResult | undefined + state: ReactiveMap + collection: Collection & 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, @@ -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 = + currentCollection.config + if (config.singleResult) { + // Force resource tracking so Suspense works + getDataResource() + return data[0] + } + } return getDataResource() } diff --git a/packages/solid-db/tests/useLiveQuery.test-d.tsx b/packages/solid-db/tests/useLiveQuery.test-d.tsx new file mode 100644 index 000000000..29c5061ea --- /dev/null +++ b/packages/solid-db/tests/useLiveQuery.test-d.tsx @@ -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({ + 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 | undefined + >() + }) + + it(`should type findOne collection using createLiveQueryCollection to return a single row`, () => { + const collection = createCollection( + mockSyncCollectionOptions({ + 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() + + const rendered = renderHook(() => { + return useLiveQuery(() => liveQueryCollection) + }) + + expectTypeOf(rendered.result()).toMatchTypeOf< + OutputWithVirtual | undefined + >() + }) + + it(`should type non-findOne queries to return an array`, () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `test-persons-2`, + getKey: (person: Person) => person.id, + initialData: [], + }), + ) + + const rendered = renderHook(() => { + return useLiveQuery((q) => q.from({ collection })) + }) + + expectTypeOf(rendered.result()).toMatchTypeOf< + Array> + >() + }) +}) diff --git a/packages/solid-db/tests/useLiveQuery.test.tsx b/packages/solid-db/tests/useLiveQuery.test.tsx index 2e7452693..378c2a0fc 100644 --- a/packages/solid-db/tests/useLiveQuery.test.tsx +++ b/packages/solid-db/tests/useLiveQuery.test.tsx @@ -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({ + 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({ + 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({ + 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({ + 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() + }) + }) })