diff --git a/docs/reference/classes/basequerybuilder.md b/docs/reference/classes/basequerybuilder.md index bb440aae..c519a850 100644 --- a/docs/reference/classes/basequerybuilder.md +++ b/docs/reference/classes/basequerybuilder.md @@ -200,6 +200,32 @@ query *** +### findOne() + +```ts +findOne(): QueryBuilder +``` + +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 diff --git a/packages/db/src/collection.ts b/packages/db/src/collection.ts index 97c542fb..6a7e9a25 100644 --- a/packages/db/src/collection.ts +++ b/packages/db/src/collection.ts @@ -84,6 +84,7 @@ export interface Collection< TInsertInput extends object = T, > extends CollectionImpl { readonly utils: TUtils + readonly single?: true } /** diff --git a/packages/db/src/query/builder/index.ts b/packages/db/src/query/builder/index.ts index c30b2597..21751a3b 100644 --- a/packages/db/src/query/builder/index.ts +++ b/packages/db/src/query/builder/index.ts @@ -627,6 +627,27 @@ export class BaseQueryBuilder { }) 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 { + return new BaseQueryBuilder({ + ...this.query, + limit: 1, + single: true, + }) + } + // Helper methods private _getCurrentAliases(): Array { const aliases: Array = [] diff --git a/packages/db/src/query/builder/types.ts b/packages/db/src/query/builder/types.ts index 763447d8..3726c5c1 100644 --- a/packages/db/src/query/builder/types.ts +++ b/packages/db/src/query/builder/types.ts @@ -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 @@ -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 diff --git a/packages/db/src/query/compiler/index.ts b/packages/db/src/query/compiler/index.ts index 0ee3bc16..9af303e8 100644 --- a/packages/db/src/query/compiler/index.ts +++ b/packages/db/src/query/compiler/index.ts @@ -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() } diff --git a/packages/db/src/query/ir.ts b/packages/db/src/query/ir.ts index d015ff4b..17c94aa5 100644 --- a/packages/db/src/query/ir.ts +++ b/packages/db/src/query/ir.ts @@ -17,6 +17,7 @@ export interface QueryIR { limit?: Limit offset?: Offset distinct?: true + single?: true // Functional variants fnSelect?: (row: NamespacedRow) => any diff --git a/packages/db/src/query/live-query-collection.ts b/packages/db/src/query/live-query-collection.ts index 8d0337ab..4ccacc13 100644 --- a/packages/db/src/query/live-query-collection.ts +++ b/packages/db/src/query/live-query-collection.ts @@ -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 } /** @@ -649,6 +654,7 @@ export function liveQueryCollectionOptions< onUpdate: config.onUpdate, onDelete: config.onDelete, startSync: config.startSync, + single: query.single, } } diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index b32ea93e..27d70b6a 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -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 @@ -527,6 +528,11 @@ export interface CollectionConfig< * } */ onDelete?: DeleteMutationFn + + /** + * If enabled the collection will return a single object instead of an array + */ + single?: true } export type ChangesPayload> = Array< @@ -629,3 +635,12 @@ export type ChangeListener< T extends object = Record, TKey extends string | number = string | number, > = (changes: Array>) => void + +/** + * Utility type to infer the query result size (single row or an array) + */ +export type WithResultSize = TContext extends { + single: true +} + ? GetResult | undefined + : Array> diff --git a/packages/react-db/src/useLiveQuery.ts b/packages/react-db/src/useLiveQuery.ts index a345d2de..206c9407 100644 --- a/packages/react-db/src/useLiveQuery.ts +++ b/packages/react-db/src/useLiveQuery.ts @@ -8,6 +8,7 @@ import type { InitialQueryBuilder, LiveQueryCollectionConfig, QueryBuilder, + WithResultSize, } from "@tanstack/db" /** @@ -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 @@ -66,7 +74,7 @@ export function useLiveQuery( deps?: Array ): { state: Map> - data: Array> + data: WithResultSize collection: Collection, string | number, {}> status: CollectionStatus isLoading: boolean @@ -115,7 +123,7 @@ export function useLiveQuery( deps?: Array ): { state: Map> - data: Array> + data: WithResultSize collection: Collection, string | number, {}> status: CollectionStatus isLoading: boolean @@ -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 | null = null let dataCache: Array | null = null @@ -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, diff --git a/packages/react-db/tests/useLiveQuery.test.tsx b/packages/react-db/tests/useLiveQuery.test.tsx index 903d300f..80e8183c 100644 --- a/packages/react-db/tests/useLiveQuery.test.tsx +++ b/packages/react-db/tests/useLiveQuery.test.tsx @@ -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({ + 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({