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
26 changes: 26 additions & 0 deletions docs/reference/classes/basequerybuilder.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,32 @@ query

***

### findOne()

```ts
findOne(): QueryBuilder<TContext>
```

Specify that the query should return a single row as `data` and not an array.

#### Returns

[`QueryBuilder`](../../type-aliases/querybuilder.md)\<`TContext`\>

A QueryBuilder with single return enabled

#### Example

```ts
// Get an user by ID
query
.from({ users: usersCollection })
.where(({users}) => eq(users.id, 1))
.findOne()
```

***

### from()

```ts
Expand Down
1 change: 1 addition & 0 deletions packages/db/src/collection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ export interface Collection<
TInsertInput extends object = T,
> extends CollectionImpl<T, TKey, TUtils, TSchema, TInsertInput> {
readonly utils: TUtils
readonly single?: true
}

/**
Expand Down
21 changes: 21 additions & 0 deletions packages/db/src/query/builder/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -627,6 +627,27 @@ export class BaseQueryBuilder<TContext extends Context = Context> {
}) as any
}

/**
* Specify that the query should return a single result
* @returns A QueryBuilder with single result enforced
*
* @example
* ```ts
* // Get the user matching the query
* query
* .from({ users: usersCollection })
* .where(({users}) => eq(users.id, 1))
* .findOne()
*```
*/
findOne(): QueryBuilder<TContext & { single: true }> {
return new BaseQueryBuilder({
...this.query,
limit: 1,
single: true,
})
}

// Helper methods
private _getCurrentAliases(): Array<string> {
const aliases: Array<string> = []
Expand Down
3 changes: 3 additions & 0 deletions packages/db/src/query/builder/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ export interface Context {
>
// The result type after select (if select has been called)
result?: any
// Single result only (if findOne has been called)
single?: boolean
}

export type ContextSchema = Record<string, unknown>
Expand Down Expand Up @@ -211,6 +213,7 @@ export type MergeContextWithJoinType<
[K in keyof TNewSchema & string]: TJoinType
}
result: TContext[`result`]
single: TContext[`single`]
}

// Helper type to apply join optionality when merging new schema
Expand Down
5 changes: 4 additions & 1 deletion packages/db/src/query/compiler/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,10 @@ export function compileQuery(
cache.set(rawQuery, compilationResult)

return compilationResult
} else if (query.limit !== undefined || query.offset !== undefined) {
} else if (
!query.single &&
(query.limit !== undefined || query.offset !== undefined)
) {
// If there's a limit or offset without orderBy, throw an error
throw new LimitOffsetRequireOrderByError()
}
Expand Down
1 change: 1 addition & 0 deletions packages/db/src/query/ir.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export interface QueryIR {
limit?: Limit
offset?: Offset
distinct?: true
single?: true

// Functional variants
fnSelect?: (row: NamespacedRow) => any
Expand Down
6 changes: 6 additions & 0 deletions packages/db/src/query/live-query-collection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,11 @@ export interface LiveQueryCollectionConfig<
* GC time for the collection
*/
gcTime?: number

/**
* If enabled the collection will return a single object instead of an array
*/
single?: true
}

/**
Expand Down Expand Up @@ -649,6 +654,7 @@ export function liveQueryCollectionOptions<
onUpdate: config.onUpdate,
onDelete: config.onDelete,
startSync: config.startSync,
single: query.single,
}
}

Expand Down
15 changes: 15 additions & 0 deletions packages/db/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { Transaction } from "./transactions"

import type { SingleRowRefProxy } from "./query/builder/ref-proxy"
import type { BasicExpression } from "./query/ir.js"
import type { Context, GetResult } from "./query/builder/types"

/**
* Helper type to extract the output type from a standard schema
Expand Down Expand Up @@ -527,6 +528,11 @@ export interface CollectionConfig<
* }
*/
onDelete?: DeleteMutationFn<T, TKey>

/**
* If enabled the collection will return a single object instead of an array
*/
single?: true
}

export type ChangesPayload<T extends object = Record<string, unknown>> = Array<
Expand Down Expand Up @@ -629,3 +635,12 @@ export type ChangeListener<
T extends object = Record<string, unknown>,
TKey extends string | number = string | number,
> = (changes: Array<ChangeMessage<T, TKey>>) => void

/**
* Utility type to infer the query result size (single row or an array)
*/
export type WithResultSize<TContext extends Context> = TContext extends {
single: true
}
? GetResult<TContext> | undefined
: Array<GetResult<TContext>>
15 changes: 12 additions & 3 deletions packages/react-db/src/useLiveQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
InitialQueryBuilder,
LiveQueryCollectionConfig,
QueryBuilder,
WithResultSize,
} from "@tanstack/db"

/**
Expand All @@ -30,6 +31,13 @@ import type {
* .where(({ todos }) => gt(todos.priority, minPriority)),
* [minPriority] // Re-run when minPriority changes
* )
* @example
* // Single result query
* const { data } = useLiveQuery(
* (q) => q.from({ todos: todosCollection })
* .where(({ todos }) => eq(todos.id, 1))
* .findOne()
* )
*
* @example
* // Join pattern
Expand Down Expand Up @@ -66,7 +74,7 @@ export function useLiveQuery<TContext extends Context>(
deps?: Array<unknown>
): {
state: Map<string | number, GetResult<TContext>>
data: Array<GetResult<TContext>>
data: WithResultSize<TContext>
collection: Collection<GetResult<TContext>, string | number, {}>
status: CollectionStatus
isLoading: boolean
Expand Down Expand Up @@ -115,7 +123,7 @@ export function useLiveQuery<TContext extends Context>(
deps?: Array<unknown>
): {
state: Map<string | number, GetResult<TContext>>
data: Array<GetResult<TContext>>
data: WithResultSize<TContext>
collection: Collection<GetResult<TContext>, string | number, {}>
status: CollectionStatus
isLoading: boolean
Expand Down Expand Up @@ -309,6 +317,7 @@ export function useLiveQuery(
) {
// Capture a stable view of entries for this snapshot to avoid tearing
const entries = Array.from(snapshot.collection.entries())
const single = snapshot.collection.config.single
let stateCache: Map<string | number, unknown> | null = null
let dataCache: Array<unknown> | null = null

Expand All @@ -323,7 +332,7 @@ export function useLiveQuery(
if (!dataCache) {
dataCache = entries.map(([, value]) => value)
}
return dataCache
return single ? dataCache[0] : dataCache
},
collection: snapshot.collection,
status: snapshot.collection.status,
Expand Down
34 changes: 34 additions & 0 deletions packages/react-db/tests/useLiveQuery.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,40 @@ describe(`Query Collections`, () => {
expect(data1).toBe(data2)
})

it(`should be able to return a single row`, async () => {
const collection = createCollection(
mockSyncCollectionOptions<Person>({
id: `test-persons-2`,
getKey: (person: Person) => person.id,
initialData: initialPersons,
})
)

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

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

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

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

it(`should be able to query a collection with live updates`, async () => {
const collection = createCollection(
mockSyncCollectionOptions<Person>({
Expand Down