Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/fix-angular-findone.md
Original file line number Diff line number Diff line change
@@ -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<T | undefined>` for `findOne()` queries
95 changes: 76 additions & 19 deletions packages/angular-db/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,30 +10,34 @@ 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'

/**
* 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<string, any> = {},
> {
export interface InjectLiveQueryResult<TContext extends Context> {
/** A signal containing the complete state map of results keyed by their ID */
state: Signal<Map<TKey, TResult>>
/** A signal containing the results as an array */
data: Signal<Array<TResult>>
state: Signal<Map<string | number, GetResult<TContext>>>
/** A signal containing the results as an array, or single result for findOne queries */
data: Signal<InferResultType<TContext>>
/** A signal containing the underlying collection instance (null for disabled queries) */
collection: Signal<Collection<TResult, TKey, TUtils> | null>
collection: Signal<Collection<
GetResult<TContext>,
string | number,
{}
> | null>
/** A signal containing the current status of the collection */
status: Signal<CollectionStatus | `disabled`>
/** A signal indicating whether the collection is currently loading */
Expand All @@ -48,6 +52,38 @@ export interface InjectLiveQueryResult<
isCleanedUp: Signal<boolean>
}

export interface InjectLiveQueryResultWithCollection<
TResult extends object = any,
TKey extends string | number = string | number,
TUtils extends Record<string, any> = {},
> {
state: Signal<Map<TKey, TResult>>
data: Signal<Array<TResult>>
collection: Signal<Collection<TResult, TKey, TUtils> | null>
status: Signal<CollectionStatus | `disabled`>
isLoading: Signal<boolean>
isReady: Signal<boolean>
isIdle: Signal<boolean>
isError: Signal<boolean>
isCleanedUp: Signal<boolean>
}

export interface InjectLiveQueryResultWithSingleResultCollection<
TResult extends object = any,
TKey extends string | number = string | number,
TUtils extends Record<string, any> = {},
> {
state: Signal<Map<TKey, TResult>>
data: Signal<TResult | undefined>
collection: Signal<(Collection<TResult, TKey, TUtils> & SingleResult) | null>
status: Signal<CollectionStatus | `disabled`>
isLoading: Signal<boolean>
isReady: Signal<boolean>
isIdle: Signal<boolean>
isError: Signal<boolean>
isCleanedUp: Signal<boolean>
}

export function injectLiveQuery<
TContext extends Context,
TParams extends any,
Expand All @@ -57,7 +93,7 @@ export function injectLiveQuery<
params: TParams
q: InitialQueryBuilder
}) => QueryBuilder<TContext>
}): InjectLiveQueryResult<GetResult<TContext>>
}): InjectLiveQueryResult<TContext>
export function injectLiveQuery<
TContext extends Context,
TParams extends any,
Expand All @@ -67,25 +103,34 @@ export function injectLiveQuery<
params: TParams
q: InitialQueryBuilder
}) => QueryBuilder<TContext> | undefined | null
}): InjectLiveQueryResult<GetResult<TContext>>
}): InjectLiveQueryResult<TContext>
export function injectLiveQuery<TContext extends Context>(
queryFn: (q: InitialQueryBuilder) => QueryBuilder<TContext>,
): InjectLiveQueryResult<GetResult<TContext>>
): InjectLiveQueryResult<TContext>
export function injectLiveQuery<TContext extends Context>(
queryFn: (
q: InitialQueryBuilder,
) => QueryBuilder<TContext> | undefined | null,
): InjectLiveQueryResult<GetResult<TContext>>
): InjectLiveQueryResult<TContext>
export function injectLiveQuery<TContext extends Context>(
config: LiveQueryCollectionConfig<TContext>,
): InjectLiveQueryResult<GetResult<TContext>>
): InjectLiveQueryResult<TContext>
// Pre-created collection without singleResult
export function injectLiveQuery<
TResult extends object,
TKey extends string | number,
TUtils extends Record<string, any>,
>(
liveQueryCollection: Collection<TResult, TKey, TUtils> & NonSingleResult,
): InjectLiveQueryResultWithCollection<TResult, TKey, TUtils>
// Pre-created collection with singleResult
export function injectLiveQuery<
TResult extends object,
TKey extends string | number,
TUtils extends Record<string, any>,
>(
liveQueryCollection: Collection<TResult, TKey, TUtils>,
): InjectLiveQueryResult<TResult, TKey, TUtils>
liveQueryCollection: Collection<TResult, TKey, TUtils> & SingleResult,
): InjectLiveQueryResultWithSingleResultCollection<TResult, TKey, TUtils>
export function injectLiveQuery(opts: any) {
assertInInjectionContext(injectLiveQuery)
const destroyRef = inject(DestroyRef)
Expand Down Expand Up @@ -156,19 +201,31 @@ export function injectLiveQuery(opts: any) {
})

const state = signal(new Map<string | number, any>())
const data = signal<Array<any>>([])
const internalData = signal<Array<any>>([])
const status = signal<CollectionStatus | `disabled`>(
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<any, any, any>
| undefined
return config?.singleResult ? internalData()[0] : internalData()
})

const syncDataFromCollection = (
currentCollection: Collection<any, any, any>,
) => {
const newState = new Map(currentCollection.entries())
const newData = Array.from(currentCollection.values())

state.set(newState)
data.set(newData)
internalData.set(newData)
status.set(currentCollection.status)
}

Expand All @@ -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
}
Expand Down
138 changes: 138 additions & 0 deletions packages/angular-db/tests/inject-live-query.test-d.ts
Original file line number Diff line number Diff line change
@@ -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<Person>({
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<Person | undefined>()
})

it(`should type findOne config object to return a single row`, () => {
const collection = createCollection(
mockSyncCollectionOptions<Person>({
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<Person | undefined>()
})

it(`should type findOne collection using liveQueryCollectionOptions to return a single row`, () => {
const collection = createCollection(
mockSyncCollectionOptions<Person>({
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<SingleResult>()

const { data } = injectLiveQuery(liveQueryCollection)

// findOne returns a single result or undefined
expectTypeOf(data()).toEqualTypeOf<Person | undefined>()
})

it(`should type findOne collection using createLiveQueryCollection to return a single row`, () => {
const collection = createCollection(
mockSyncCollectionOptions<Person>({
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<SingleResult>()

const { data } = injectLiveQuery(liveQueryCollection)

// findOne returns a single result or undefined
expectTypeOf(data()).toEqualTypeOf<Person | undefined>()
})

it(`should type regular query to return an array`, () => {
const collection = createCollection(
mockSyncCollectionOptions<Person>({
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<Array<{ id: string; name: string }>>()
})
})
Loading
Loading