diff --git a/.changeset/fix-angular-findone.md b/.changeset/fix-angular-findone.md new file mode 100644 index 000000000..f27f35f12 --- /dev/null +++ b/.changeset/fix-angular-findone.md @@ -0,0 +1,5 @@ +--- +'@tanstack/angular-db': patch +--- + +Fix `injectLiveQuery` with `findOne()` returning an array instead of a single object, and add proper type overloads so TypeScript correctly infers `Signal` for `findOne()` queries diff --git a/packages/angular-db/src/index.ts b/packages/angular-db/src/index.ts index e96a70de2..329d08800 100644 --- a/packages/angular-db/src/index.ts +++ b/packages/angular-db/src/index.ts @@ -10,12 +10,16 @@ import { BaseQueryBuilder, createLiveQueryCollection } from '@tanstack/db' import type { ChangeMessage, Collection, + CollectionConfigSingleRowOption, CollectionStatus, Context, GetResult, + InferResultType, InitialQueryBuilder, LiveQueryCollectionConfig, + NonSingleResult, QueryBuilder, + SingleResult, } from '@tanstack/db' import type { Signal } from '@angular/core' @@ -23,17 +27,17 @@ import type { Signal } from '@angular/core' * The result of calling `injectLiveQuery`. * Contains reactive signals for the query state and data. */ -export interface InjectLiveQueryResult< - TResult extends object = any, - TKey extends string | number = string | number, - TUtils extends Record = {}, -> { +export interface InjectLiveQueryResult { /** A signal containing the complete state map of results keyed by their ID */ - state: Signal> - /** A signal containing the results as an array */ - data: Signal> + state: Signal>> + /** A signal containing the results as an array, or single result for findOne queries */ + data: Signal> /** A signal containing the underlying collection instance (null for disabled queries) */ - collection: Signal | null> + collection: Signal, + string | number, + {} + > | null> /** A signal containing the current status of the collection */ status: Signal /** A signal indicating whether the collection is currently loading */ @@ -48,6 +52,38 @@ export interface InjectLiveQueryResult< isCleanedUp: Signal } +export interface InjectLiveQueryResultWithCollection< + TResult extends object = any, + TKey extends string | number = string | number, + TUtils extends Record = {}, +> { + state: Signal> + data: Signal> + collection: Signal | null> + status: Signal + isLoading: Signal + isReady: Signal + isIdle: Signal + isError: Signal + isCleanedUp: Signal +} + +export interface InjectLiveQueryResultWithSingleResultCollection< + TResult extends object = any, + TKey extends string | number = string | number, + TUtils extends Record = {}, +> { + state: Signal> + data: Signal + collection: Signal<(Collection & SingleResult) | null> + status: Signal + isLoading: Signal + isReady: Signal + isIdle: Signal + isError: Signal + isCleanedUp: Signal +} + export function injectLiveQuery< TContext extends Context, TParams extends any, @@ -57,7 +93,7 @@ export function injectLiveQuery< params: TParams q: InitialQueryBuilder }) => QueryBuilder -}): InjectLiveQueryResult> +}): InjectLiveQueryResult export function injectLiveQuery< TContext extends Context, TParams extends any, @@ -67,25 +103,34 @@ export function injectLiveQuery< params: TParams q: InitialQueryBuilder }) => QueryBuilder | undefined | null -}): InjectLiveQueryResult> +}): InjectLiveQueryResult export function injectLiveQuery( queryFn: (q: InitialQueryBuilder) => QueryBuilder, -): InjectLiveQueryResult> +): InjectLiveQueryResult export function injectLiveQuery( queryFn: ( q: InitialQueryBuilder, ) => QueryBuilder | undefined | null, -): InjectLiveQueryResult> +): InjectLiveQueryResult export function injectLiveQuery( config: LiveQueryCollectionConfig, -): InjectLiveQueryResult> +): InjectLiveQueryResult +// Pre-created collection without singleResult +export function injectLiveQuery< + TResult extends object, + TKey extends string | number, + TUtils extends Record, +>( + liveQueryCollection: Collection & NonSingleResult, +): InjectLiveQueryResultWithCollection +// Pre-created collection with singleResult export function injectLiveQuery< TResult extends object, TKey extends string | number, TUtils extends Record, >( - liveQueryCollection: Collection, -): InjectLiveQueryResult + liveQueryCollection: Collection & SingleResult, +): InjectLiveQueryResultWithSingleResultCollection export function injectLiveQuery(opts: any) { assertInInjectionContext(injectLiveQuery) const destroyRef = inject(DestroyRef) @@ -156,11 +201,23 @@ export function injectLiveQuery(opts: any) { }) const state = signal(new Map()) - const data = signal>([]) + const internalData = signal>([]) const status = signal( collection() ? `idle` : `disabled`, ) + // Returns single item for singleResult collections, array otherwise + const data = computed(() => { + const currentCollection = collection() + if (!currentCollection) { + return internalData() + } + const config = currentCollection.config as + | CollectionConfigSingleRowOption + | undefined + return config?.singleResult ? internalData()[0] : internalData() + }) + const syncDataFromCollection = ( currentCollection: Collection, ) => { @@ -168,7 +225,7 @@ export function injectLiveQuery(opts: any) { const newData = Array.from(currentCollection.values()) state.set(newState) - data.set(newData) + internalData.set(newData) status.set(currentCollection.status) } @@ -185,7 +242,7 @@ export function injectLiveQuery(opts: any) { if (!currentCollection) { status.set(`disabled` as const) state.set(new Map()) - data.set([]) + internalData.set([]) cleanup() return } diff --git a/packages/angular-db/tests/inject-live-query.test-d.ts b/packages/angular-db/tests/inject-live-query.test-d.ts new file mode 100644 index 000000000..667e11314 --- /dev/null +++ b/packages/angular-db/tests/inject-live-query.test-d.ts @@ -0,0 +1,138 @@ +import { describe, expectTypeOf, it } from 'vitest' +import { createCollection } from '../../db/src/collection/index' +import { mockSyncCollectionOptions } from '../../db/tests/utils' +import { + createLiveQueryCollection, + eq, + liveQueryCollectionOptions, +} from '../../db/src/query/index' +import { injectLiveQuery } from '../src/index' +import type { SingleResult } from '../../db/src/types' + +type Person = { + id: string + name: string + age: number + email: string + isActive: boolean + team: string +} + +describe(`injectLiveQuery type assertions`, () => { + it(`should type findOne query builder to return a single row`, () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `test-persons-findone-angular`, + getKey: (person: Person) => person.id, + initialData: [], + }), + ) + + const { data } = injectLiveQuery((q) => + q + .from({ collection }) + .where(({ collection: c }) => eq(c.id, `3`)) + .findOne(), + ) + + // findOne returns a single result or undefined + expectTypeOf(data()).toEqualTypeOf() + }) + + it(`should type findOne config object to return a single row`, () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `test-persons-findone-config-angular`, + getKey: (person: Person) => person.id, + initialData: [], + }), + ) + + const { data } = injectLiveQuery({ + params: () => ({ id: `3` }), + query: ({ params, q }) => + q + .from({ collection }) + .where(({ collection: c }) => eq(c.id, params.id)) + .findOne(), + }) + + // findOne returns a single result or undefined + expectTypeOf(data()).toEqualTypeOf() + }) + + it(`should type findOne collection using liveQueryCollectionOptions to return a single row`, () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `test-persons-findone-options-angular`, + getKey: (person: Person) => person.id, + initialData: [], + }), + ) + + const options = liveQueryCollectionOptions({ + query: (q) => + q + .from({ collection }) + .where(({ collection: c }) => eq(c.id, `3`)) + .findOne(), + }) + + const liveQueryCollection = createCollection(options) + + expectTypeOf(liveQueryCollection).toExtend() + + const { data } = injectLiveQuery(liveQueryCollection) + + // findOne returns a single result or undefined + expectTypeOf(data()).toEqualTypeOf() + }) + + it(`should type findOne collection using createLiveQueryCollection to return a single row`, () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `test-persons-findone-create-angular`, + 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 { data } = injectLiveQuery(liveQueryCollection) + + // findOne returns a single result or undefined + expectTypeOf(data()).toEqualTypeOf() + }) + + it(`should type regular query to return an array`, () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `test-persons-array-angular`, + getKey: (person: Person) => person.id, + initialData: [], + }), + ) + + const { data } = injectLiveQuery((q) => + q + .from({ collection }) + .where(({ collection: c }) => eq(c.isActive, true)) + .select(({ collection: c }) => ({ + id: c.id, + name: c.name, + })), + ) + + // Regular queries should return an array + expectTypeOf(data()).toEqualTypeOf>() + }) +}) diff --git a/packages/angular-db/tests/inject-live-query.test.ts b/packages/angular-db/tests/inject-live-query.test.ts index 2f4db01ca..81fbb12a9 100644 --- a/packages/angular-db/tests/inject-live-query.test.ts +++ b/packages/angular-db/tests/inject-live-query.test.ts @@ -14,6 +14,7 @@ import type { CollectionStatus, Context, LiveQueryCollectionConfig, + NonSingleResult, QueryBuilder, } from '@tanstack/db' @@ -66,12 +67,13 @@ async function waitForAngularUpdate() { function createMockCollection( initial: Array> = [], initialStatus: CollectionStatus = `ready`, -): Collection> & { - __setStatus: (s: CollectionStatus) => void - __replaceAll: (rows: Array>) => void - __upsert: (row: T & Record<`id`, K>) => void - __delete: (key: K) => void -} { +): Collection> & + NonSingleResult & { + __setStatus: (s: CollectionStatus) => void + __replaceAll: (rows: Array>) => void + __upsert: (row: T & Record<`id`, K>) => void + __delete: (key: K) => void + } { const map = new Map() for (const r of initial) { map.set(r.id, r) @@ -532,7 +534,7 @@ describe(`injectLiveQuery`, () => { await waitForAngularUpdate() - expect(res.collection().id).toEqual(expect.any(String)) + expect(res.collection()!.id).toEqual(expect.any(String)) expect(res.status()).toBe(`ready`) expect(Array.isArray(res.data())).toBe(true) expect(res.state() instanceof Map).toBe(true) @@ -565,7 +567,7 @@ describe(`injectLiveQuery`, () => { const res = injectLiveQuery(config) await waitForAngularUpdate() - expect(res.collection().id).toEqual(expect.any(String)) + expect(res.collection()!.id).toEqual(expect.any(String)) expect(res.isReady()).toBe(true) }) }) @@ -611,7 +613,7 @@ describe(`injectLiveQuery`, () => { await waitForAngularUpdate() - expect(res.collection().id).toEqual(expect.any(String)) + expect(res.collection()!.id).toEqual(expect.any(String)) expect(res.status()).toBe(`ready`) expect(Array.isArray(res.data())).toBe(true) expect(res.state() instanceof Map).toBe(true) @@ -752,6 +754,40 @@ describe(`injectLiveQuery`, () => { }) }) + it(`should return a single object for findOne query`, async () => { + await TestBed.runInInjectionContext(async () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `test-persons-findone-angular`, + getKey: (person: Person) => person.id, + initialData: initialPersons, + }), + ) + + const { state, data } = injectLiveQuery((q) => + q + .from({ collection }) + .where(({ collection: c }) => eq(c.id, `3`)) + .findOne(), + ) + + await waitForAngularUpdate() + + expect(state().size).toBe(1) + expect(state().get(`3`)).toMatchObject({ + id: `3`, + name: `John Smith`, + }) + + // findOne should return a single object, not an array + expect(Array.isArray(data())).toBe(false) + expect(data()).toMatchObject({ + id: `3`, + name: `John Smith`, + }) + }) + }) + describe(`eager execution during sync`, () => { it(`should show state while isLoading is true during sync`, async () => { await TestBed.runInInjectionContext(async () => { @@ -795,7 +831,7 @@ describe(`injectLiveQuery`, () => { }) // Start the live query sync manually - liveQueryCollection().preload() + liveQueryCollection()!.preload() await waitForAngularUpdate() @@ -907,7 +943,7 @@ describe(`injectLiveQuery`, () => { }) // Start the live query sync manually - liveQueryCollection().preload() + liveQueryCollection()!.preload() await waitForAngularUpdate() @@ -1007,7 +1043,7 @@ describe(`injectLiveQuery`, () => { }) // Start the live query sync manually - liveQueryCollection().preload() + liveQueryCollection()!.preload() await waitForAngularUpdate()