Skip to content

Commit 72f2a1c

Browse files
claude[bot]github-actions[bot]claudekevin-dpautofix-ci[bot]
authored
[fix] findOne returns array instead of single object in angular-db (issue #1261) (#1273)
* test: assert findOne returns single object in angular-db (issue #1261) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: add type assertions for injectLiveQuery findOne in angular-db Adds inject-live-query.test-d.ts with expectTypeOf assertions verifying that injectLiveQuery with findOne() types data as Signal<Person | undefined> and regular queries type data as Signal<Array<T>>. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: fix type errors in inject-live-query test helpers - Add NonSingleResult to createMockCollection return type since mock collections are never singleResult - Add non-null assertions for collection() signal access in tests Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: return single object from findOne in angular-db instead of array (issue #1261) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * ci: apply automated fixes * chore: add changeset for angular-db findOne fix Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Kevin De Porre <kevin@electric-sql.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent 85acc4c commit 72f2a1c

File tree

4 files changed

+267
-31
lines changed

4 files changed

+267
-31
lines changed

.changeset/fix-angular-findone.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@tanstack/angular-db': patch
3+
---
4+
5+
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

packages/angular-db/src/index.ts

Lines changed: 76 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -10,30 +10,34 @@ import { BaseQueryBuilder, createLiveQueryCollection } from '@tanstack/db'
1010
import type {
1111
ChangeMessage,
1212
Collection,
13+
CollectionConfigSingleRowOption,
1314
CollectionStatus,
1415
Context,
1516
GetResult,
17+
InferResultType,
1618
InitialQueryBuilder,
1719
LiveQueryCollectionConfig,
20+
NonSingleResult,
1821
QueryBuilder,
22+
SingleResult,
1923
} from '@tanstack/db'
2024
import type { Signal } from '@angular/core'
2125

2226
/**
2327
* The result of calling `injectLiveQuery`.
2428
* Contains reactive signals for the query state and data.
2529
*/
26-
export interface InjectLiveQueryResult<
27-
TResult extends object = any,
28-
TKey extends string | number = string | number,
29-
TUtils extends Record<string, any> = {},
30-
> {
30+
export interface InjectLiveQueryResult<TContext extends Context> {
3131
/** A signal containing the complete state map of results keyed by their ID */
32-
state: Signal<Map<TKey, TResult>>
33-
/** A signal containing the results as an array */
34-
data: Signal<Array<TResult>>
32+
state: Signal<Map<string | number, GetResult<TContext>>>
33+
/** A signal containing the results as an array, or single result for findOne queries */
34+
data: Signal<InferResultType<TContext>>
3535
/** A signal containing the underlying collection instance (null for disabled queries) */
36-
collection: Signal<Collection<TResult, TKey, TUtils> | null>
36+
collection: Signal<Collection<
37+
GetResult<TContext>,
38+
string | number,
39+
{}
40+
> | null>
3741
/** A signal containing the current status of the collection */
3842
status: Signal<CollectionStatus | `disabled`>
3943
/** A signal indicating whether the collection is currently loading */
@@ -48,6 +52,38 @@ export interface InjectLiveQueryResult<
4852
isCleanedUp: Signal<boolean>
4953
}
5054

55+
export interface InjectLiveQueryResultWithCollection<
56+
TResult extends object = any,
57+
TKey extends string | number = string | number,
58+
TUtils extends Record<string, any> = {},
59+
> {
60+
state: Signal<Map<TKey, TResult>>
61+
data: Signal<Array<TResult>>
62+
collection: Signal<Collection<TResult, TKey, TUtils> | null>
63+
status: Signal<CollectionStatus | `disabled`>
64+
isLoading: Signal<boolean>
65+
isReady: Signal<boolean>
66+
isIdle: Signal<boolean>
67+
isError: Signal<boolean>
68+
isCleanedUp: Signal<boolean>
69+
}
70+
71+
export interface InjectLiveQueryResultWithSingleResultCollection<
72+
TResult extends object = any,
73+
TKey extends string | number = string | number,
74+
TUtils extends Record<string, any> = {},
75+
> {
76+
state: Signal<Map<TKey, TResult>>
77+
data: Signal<TResult | undefined>
78+
collection: Signal<(Collection<TResult, TKey, TUtils> & SingleResult) | null>
79+
status: Signal<CollectionStatus | `disabled`>
80+
isLoading: Signal<boolean>
81+
isReady: Signal<boolean>
82+
isIdle: Signal<boolean>
83+
isError: Signal<boolean>
84+
isCleanedUp: Signal<boolean>
85+
}
86+
5187
export function injectLiveQuery<
5288
TContext extends Context,
5389
TParams extends any,
@@ -57,7 +93,7 @@ export function injectLiveQuery<
5793
params: TParams
5894
q: InitialQueryBuilder
5995
}) => QueryBuilder<TContext>
60-
}): InjectLiveQueryResult<GetResult<TContext>>
96+
}): InjectLiveQueryResult<TContext>
6197
export function injectLiveQuery<
6298
TContext extends Context,
6399
TParams extends any,
@@ -67,25 +103,34 @@ export function injectLiveQuery<
67103
params: TParams
68104
q: InitialQueryBuilder
69105
}) => QueryBuilder<TContext> | undefined | null
70-
}): InjectLiveQueryResult<GetResult<TContext>>
106+
}): InjectLiveQueryResult<TContext>
71107
export function injectLiveQuery<TContext extends Context>(
72108
queryFn: (q: InitialQueryBuilder) => QueryBuilder<TContext>,
73-
): InjectLiveQueryResult<GetResult<TContext>>
109+
): InjectLiveQueryResult<TContext>
74110
export function injectLiveQuery<TContext extends Context>(
75111
queryFn: (
76112
q: InitialQueryBuilder,
77113
) => QueryBuilder<TContext> | undefined | null,
78-
): InjectLiveQueryResult<GetResult<TContext>>
114+
): InjectLiveQueryResult<TContext>
79115
export function injectLiveQuery<TContext extends Context>(
80116
config: LiveQueryCollectionConfig<TContext>,
81-
): InjectLiveQueryResult<GetResult<TContext>>
117+
): InjectLiveQueryResult<TContext>
118+
// Pre-created collection without singleResult
119+
export function injectLiveQuery<
120+
TResult extends object,
121+
TKey extends string | number,
122+
TUtils extends Record<string, any>,
123+
>(
124+
liveQueryCollection: Collection<TResult, TKey, TUtils> & NonSingleResult,
125+
): InjectLiveQueryResultWithCollection<TResult, TKey, TUtils>
126+
// Pre-created collection with singleResult
82127
export function injectLiveQuery<
83128
TResult extends object,
84129
TKey extends string | number,
85130
TUtils extends Record<string, any>,
86131
>(
87-
liveQueryCollection: Collection<TResult, TKey, TUtils>,
88-
): InjectLiveQueryResult<TResult, TKey, TUtils>
132+
liveQueryCollection: Collection<TResult, TKey, TUtils> & SingleResult,
133+
): InjectLiveQueryResultWithSingleResultCollection<TResult, TKey, TUtils>
89134
export function injectLiveQuery(opts: any) {
90135
assertInInjectionContext(injectLiveQuery)
91136
const destroyRef = inject(DestroyRef)
@@ -156,19 +201,31 @@ export function injectLiveQuery(opts: any) {
156201
})
157202

158203
const state = signal(new Map<string | number, any>())
159-
const data = signal<Array<any>>([])
204+
const internalData = signal<Array<any>>([])
160205
const status = signal<CollectionStatus | `disabled`>(
161206
collection() ? `idle` : `disabled`,
162207
)
163208

209+
// Returns single item for singleResult collections, array otherwise
210+
const data = computed(() => {
211+
const currentCollection = collection()
212+
if (!currentCollection) {
213+
return internalData()
214+
}
215+
const config = currentCollection.config as
216+
| CollectionConfigSingleRowOption<any, any, any>
217+
| undefined
218+
return config?.singleResult ? internalData()[0] : internalData()
219+
})
220+
164221
const syncDataFromCollection = (
165222
currentCollection: Collection<any, any, any>,
166223
) => {
167224
const newState = new Map(currentCollection.entries())
168225
const newData = Array.from(currentCollection.values())
169226

170227
state.set(newState)
171-
data.set(newData)
228+
internalData.set(newData)
172229
status.set(currentCollection.status)
173230
}
174231

@@ -185,7 +242,7 @@ export function injectLiveQuery(opts: any) {
185242
if (!currentCollection) {
186243
status.set(`disabled` as const)
187244
state.set(new Map())
188-
data.set([])
245+
internalData.set([])
189246
cleanup()
190247
return
191248
}
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import { describe, expectTypeOf, it } from 'vitest'
2+
import { createCollection } from '../../db/src/collection/index'
3+
import { mockSyncCollectionOptions } from '../../db/tests/utils'
4+
import {
5+
createLiveQueryCollection,
6+
eq,
7+
liveQueryCollectionOptions,
8+
} from '../../db/src/query/index'
9+
import { injectLiveQuery } from '../src/index'
10+
import type { SingleResult } from '../../db/src/types'
11+
12+
type Person = {
13+
id: string
14+
name: string
15+
age: number
16+
email: string
17+
isActive: boolean
18+
team: string
19+
}
20+
21+
describe(`injectLiveQuery type assertions`, () => {
22+
it(`should type findOne query builder to return a single row`, () => {
23+
const collection = createCollection(
24+
mockSyncCollectionOptions<Person>({
25+
id: `test-persons-findone-angular`,
26+
getKey: (person: Person) => person.id,
27+
initialData: [],
28+
}),
29+
)
30+
31+
const { data } = injectLiveQuery((q) =>
32+
q
33+
.from({ collection })
34+
.where(({ collection: c }) => eq(c.id, `3`))
35+
.findOne(),
36+
)
37+
38+
// findOne returns a single result or undefined
39+
expectTypeOf(data()).toEqualTypeOf<Person | undefined>()
40+
})
41+
42+
it(`should type findOne config object to return a single row`, () => {
43+
const collection = createCollection(
44+
mockSyncCollectionOptions<Person>({
45+
id: `test-persons-findone-config-angular`,
46+
getKey: (person: Person) => person.id,
47+
initialData: [],
48+
}),
49+
)
50+
51+
const { data } = injectLiveQuery({
52+
params: () => ({ id: `3` }),
53+
query: ({ params, q }) =>
54+
q
55+
.from({ collection })
56+
.where(({ collection: c }) => eq(c.id, params.id))
57+
.findOne(),
58+
})
59+
60+
// findOne returns a single result or undefined
61+
expectTypeOf(data()).toEqualTypeOf<Person | undefined>()
62+
})
63+
64+
it(`should type findOne collection using liveQueryCollectionOptions to return a single row`, () => {
65+
const collection = createCollection(
66+
mockSyncCollectionOptions<Person>({
67+
id: `test-persons-findone-options-angular`,
68+
getKey: (person: Person) => person.id,
69+
initialData: [],
70+
}),
71+
)
72+
73+
const options = liveQueryCollectionOptions({
74+
query: (q) =>
75+
q
76+
.from({ collection })
77+
.where(({ collection: c }) => eq(c.id, `3`))
78+
.findOne(),
79+
})
80+
81+
const liveQueryCollection = createCollection(options)
82+
83+
expectTypeOf(liveQueryCollection).toExtend<SingleResult>()
84+
85+
const { data } = injectLiveQuery(liveQueryCollection)
86+
87+
// findOne returns a single result or undefined
88+
expectTypeOf(data()).toEqualTypeOf<Person | undefined>()
89+
})
90+
91+
it(`should type findOne collection using createLiveQueryCollection to return a single row`, () => {
92+
const collection = createCollection(
93+
mockSyncCollectionOptions<Person>({
94+
id: `test-persons-findone-create-angular`,
95+
getKey: (person: Person) => person.id,
96+
initialData: [],
97+
}),
98+
)
99+
100+
const liveQueryCollection = createLiveQueryCollection({
101+
query: (q) =>
102+
q
103+
.from({ collection })
104+
.where(({ collection: c }) => eq(c.id, `3`))
105+
.findOne(),
106+
})
107+
108+
expectTypeOf(liveQueryCollection).toExtend<SingleResult>()
109+
110+
const { data } = injectLiveQuery(liveQueryCollection)
111+
112+
// findOne returns a single result or undefined
113+
expectTypeOf(data()).toEqualTypeOf<Person | undefined>()
114+
})
115+
116+
it(`should type regular query to return an array`, () => {
117+
const collection = createCollection(
118+
mockSyncCollectionOptions<Person>({
119+
id: `test-persons-array-angular`,
120+
getKey: (person: Person) => person.id,
121+
initialData: [],
122+
}),
123+
)
124+
125+
const { data } = injectLiveQuery((q) =>
126+
q
127+
.from({ collection })
128+
.where(({ collection: c }) => eq(c.isActive, true))
129+
.select(({ collection: c }) => ({
130+
id: c.id,
131+
name: c.name,
132+
})),
133+
)
134+
135+
// Regular queries should return an array
136+
expectTypeOf(data()).toEqualTypeOf<Array<{ id: string; name: string }>>()
137+
})
138+
})

0 commit comments

Comments
 (0)