diff --git a/.changeset/query-once-api.md b/.changeset/query-once-api.md new file mode 100644 index 000000000..79398af27 --- /dev/null +++ b/.changeset/query-once-api.md @@ -0,0 +1,5 @@ +--- +'@tanstack/db': minor +--- + +Add `queryOnce` helper for one-shot query execution, including `findOne()` support and optional QueryBuilder configs. diff --git a/docs/guides/live-queries.md b/docs/guides/live-queries.md index b69689e16..477a51c18 100644 --- a/docs/guides/live-queries.md +++ b/docs/guides/live-queries.md @@ -32,6 +32,7 @@ The result types are automatically inferred from your query structure, providing ## Table of Contents - [Creating Live Query Collections](#creating-live-query-collections) +- [One-shot Queries with queryOnce](#one-shot-queries-with-queryonce) - [From Clause](#from-clause) - [Where Clauses](#where-clauses) - [Select Projections](#select) @@ -114,6 +115,36 @@ const activeUsers = createLiveQueryCollection((q) => ) ``` +## One-shot Queries with queryOnce + +If you need a one-time snapshot (no ongoing reactivity), use `queryOnce`. It +creates a live query collection, preloads it, extracts the results, and cleans +up automatically so you do not have to remember to call `cleanup()`. + +```ts +import { eq, queryOnce } from '@tanstack/db' + +// Basic one-shot query +const activeUsers = await queryOnce((q) => + q + .from({ user: usersCollection }) + .where(({ user }) => eq(user.active, true)) + .select(({ user }) => ({ id: user.id, name: user.name })) +) + +// Single result with findOne() +const user = await queryOnce((q) => + q + .from({ user: usersCollection }) + .where(({ user }) => eq(user.id, userId)) + .findOne() +) +``` + +Use `queryOnce` for scripts, background tasks, data export, or AI/LLM context +building. `findOne()` resolves to `undefined` when no rows match. For UI +bindings and reactive updates, use live queries instead. + ### Using with Frameworks In React, you can use the `useLiveQuery` hook: diff --git a/packages/db/src/query/index.ts b/packages/db/src/query/index.ts index 524b4dcf1..34191cc25 100644 --- a/packages/db/src/query/index.ts +++ b/packages/db/src/query/index.ts @@ -57,6 +57,9 @@ export { liveQueryCollectionOptions, } from './live-query-collection.js' +// One-shot query execution +export { queryOnce, type QueryOnceConfig } from './query-once.js' + export { type LiveQueryCollectionConfig } from './live/types.js' export { type LiveQueryCollectionUtils } from './live/collection-config-builder.js' diff --git a/packages/db/src/query/query-once.ts b/packages/db/src/query/query-once.ts new file mode 100644 index 000000000..b4420814e --- /dev/null +++ b/packages/db/src/query/query-once.ts @@ -0,0 +1,115 @@ +import { createLiveQueryCollection } from './live-query-collection.js' +import type { InitialQueryBuilder, QueryBuilder } from './builder/index.js' +import type { Context, InferResultType } from './builder/types.js' + +/** + * Configuration options for queryOnce + */ +export interface QueryOnceConfig { + /** + * Query builder function that defines the query + */ + query: + | ((q: InitialQueryBuilder) => QueryBuilder) + | QueryBuilder + // Future: timeout, signal, etc. +} + +// Overload 1: Simple query function returning array (non-single result) +/** + * Executes a one-shot query and returns the results as an array. + * + * This function creates a live query collection, preloads it, extracts the results, + * and automatically cleans up the collection. It's ideal for: + * - AI/LLM context building + * - Data export + * - Background processing + * - Testing + * + * @param queryFn - A function that receives the query builder and returns a query + * @returns A promise that resolves to an array of query results + * + * @example + * ```typescript + * // Basic query + * const users = await queryOnce((q) => + * q.from({ user: usersCollection }) + * ) + * + * // With filtering and projection + * const activeUserNames = await queryOnce((q) => + * q.from({ user: usersCollection }) + * .where(({ user }) => eq(user.active, true)) + * .select(({ user }) => ({ name: user.name })) + * ) + * ``` + */ +export function queryOnce( + queryFn: (q: InitialQueryBuilder) => QueryBuilder, +): Promise> + +// Overload 2: Config object form returning array (non-single result) +/** + * Executes a one-shot query using a configuration object. + * + * @param config - Configuration object with the query function + * @returns A promise that resolves to an array of query results + * + * @example + * ```typescript + * const recentOrders = await queryOnce({ + * query: (q) => + * q.from({ order: ordersCollection }) + * .orderBy(({ order }) => desc(order.createdAt)) + * .limit(100), + * }) + * ``` + */ +export function queryOnce( + config: QueryOnceConfig, +): Promise> + +// Implementation +export async function queryOnce( + configOrQuery: + | QueryOnceConfig + | ((q: InitialQueryBuilder) => QueryBuilder), +): Promise> { + // Normalize input + const config: QueryOnceConfig = + typeof configOrQuery === `function` + ? { query: configOrQuery } + : configOrQuery + + const query = (q: InitialQueryBuilder) => { + const queryConfig = config.query + return typeof queryConfig === `function` ? queryConfig(q) : queryConfig + } + + // Create collection with minimal GC time; preload handles sync start + const collection = createLiveQueryCollection({ + query, + gcTime: 1, // Cleanup in next tick when no subscribers (0 disables GC) + }) + + try { + // Wait for initial data load + await collection.preload() + + // Check if this is a single-result query (findOne was called) + const isSingleResult = + (collection.config as { singleResult?: boolean }).singleResult === true + + // Extract and return results + if (isSingleResult) { + const first = collection.values().next().value as + | InferResultType + | undefined + return (first ?? undefined) as InferResultType + } + return collection.toArray as InferResultType + } finally { + // Always cleanup, even on error + await collection.cleanup() + } +} diff --git a/packages/db/tests/query/query-once.test.ts b/packages/db/tests/query/query-once.test.ts new file mode 100644 index 000000000..4e289468e --- /dev/null +++ b/packages/db/tests/query/query-once.test.ts @@ -0,0 +1,366 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { CollectionImpl, createCollection } from '../../src/collection/index.js' +import { eq, queryOnce } from '../../src/query/index.js' +import { Query } from '../../src/query/builder/index.js' +import { mockSyncCollectionOptions } from '../utils.js' + +// Sample user type for tests +type User = { + id: number + name: string + active: boolean + age: number +} + +// Sample data for tests +const sampleUsers: Array = [ + { id: 1, name: `Alice`, active: true, age: 30 }, + { id: 2, name: `Bob`, active: true, age: 25 }, + { id: 3, name: `Charlie`, active: false, age: 35 }, + { id: 4, name: `Diana`, active: true, age: 28 }, + { id: 5, name: `Eve`, active: false, age: 22 }, +] + +function createUsersCollection() { + return createCollection( + mockSyncCollectionOptions({ + id: `test-users-query-once`, + getKey: (user) => user.id, + initialData: sampleUsers, + }), + ) +} + +describe(`queryOnce`, () => { + let usersCollection: ReturnType + + beforeEach(() => { + usersCollection = createUsersCollection() + }) + + describe(`basic functionality`, () => { + it(`should execute a basic query and return results as an array`, async () => { + const users = await queryOnce((q) => q.from({ user: usersCollection })) + + expect(Array.isArray(users)).toBe(true) + expect(users.length).toBe(5) + expect(users.map((u) => u.name)).toEqual( + expect.arrayContaining([`Alice`, `Bob`, `Charlie`, `Diana`, `Eve`]), + ) + }) + + it(`should accept a query function directly`, async () => { + const users = await queryOnce((q) => q.from({ user: usersCollection })) + + expect(users.length).toBe(5) + }) + + it(`should accept a config object with query property`, async () => { + const users = await queryOnce({ + query: (q) => q.from({ user: usersCollection }), + }) + + expect(users.length).toBe(5) + }) + + it(`should accept a QueryBuilder instance via config`, async () => { + const queryBuilder = new Query() + .from({ user: usersCollection }) + .where(({ user }) => eq(user.active, true)) + + const users = await queryOnce({ + query: queryBuilder, + }) + + expect(users.length).toBe(3) + expect(users.every((u) => u.active)).toBe(true) + }) + }) + + describe(`filtering with where clause`, () => { + it(`should filter results with a where clause`, async () => { + const activeUsers = await queryOnce((q) => + q + .from({ user: usersCollection }) + .where(({ user }) => eq(user.active, true)), + ) + + expect(activeUsers.length).toBe(3) + expect(activeUsers.every((u) => u.active)).toBe(true) + }) + + it(`should handle empty results from filtering`, async () => { + const noUsers = await queryOnce((q) => + q + .from({ user: usersCollection }) + .where(({ user }) => eq(user.age, 100)), + ) + + expect(noUsers.length).toBe(0) + expect(Array.isArray(noUsers)).toBe(true) + }) + }) + + describe(`projection with select clause`, () => { + it(`should project results with a select clause`, async () => { + const userNames = await queryOnce((q) => + q + .from({ user: usersCollection }) + .select(({ user }) => ({ name: user.name })), + ) + + expect(userNames.length).toBe(5) + expect(userNames[0]).toHaveProperty(`name`) + expect(userNames[0]).not.toHaveProperty(`id`) + expect(userNames[0]).not.toHaveProperty(`active`) + }) + + it(`should project multiple fields`, async () => { + const projected = await queryOnce((q) => + q.from({ user: usersCollection }).select(({ user }) => ({ + id: user.id, + name: user.name, + })), + ) + + expect(projected.length).toBe(5) + expect(projected[0]).toHaveProperty(`id`) + expect(projected[0]).toHaveProperty(`name`) + expect(projected[0]).not.toHaveProperty(`active`) + expect(projected[0]).not.toHaveProperty(`age`) + }) + }) + + describe(`ordering and limits`, () => { + it(`should order results with orderBy clause`, async () => { + const orderedUsers = await queryOnce((q) => + q + .from({ user: usersCollection }) + .orderBy(({ user }) => user.name, `asc`), + ) + + expect(orderedUsers.map((u) => u.name)).toEqual([ + `Alice`, + `Bob`, + `Charlie`, + `Diana`, + `Eve`, + ]) + }) + + it(`should order results in descending order`, async () => { + const orderedUsers = await queryOnce((q) => + q + .from({ user: usersCollection }) + .orderBy(({ user }) => user.age, `desc`), + ) + + expect(orderedUsers[0]!.age).toBe(35) // Charlie + expect(orderedUsers[orderedUsers.length - 1]!.age).toBe(22) // Eve + }) + + it(`should limit results with limit clause`, async () => { + const limitedUsers = await queryOnce((q) => + q + .from({ user: usersCollection }) + .orderBy(({ user }) => user.name, `asc`) + .limit(2), + ) + + expect(limitedUsers.length).toBe(2) + expect(limitedUsers.map((u) => u.name)).toEqual([`Alice`, `Bob`]) + }) + + it(`should support offset with limit`, async () => { + const offsetUsers = await queryOnce((q) => + q + .from({ user: usersCollection }) + .orderBy(({ user }) => user.name, `asc`) + .offset(2) + .limit(2), + ) + + expect(offsetUsers.length).toBe(2) + expect(offsetUsers.map((u) => u.name)).toEqual([`Charlie`, `Diana`]) + }) + }) + + describe(`single result with findOne`, () => { + it(`should return a single result with findOne`, async () => { + const user = await queryOnce((q) => + q + .from({ user: usersCollection }) + .where(({ user }) => eq(user.id, 1)) + .findOne(), + ) + + expect(user).toBeDefined() + expect(user?.name).toBe(`Alice`) + }) + + it(`should return undefined when findOne matches no results`, async () => { + const user = await queryOnce((q) => + q + .from({ user: usersCollection }) + .where(({ user }) => eq(user.id, 999)) + .findOne(), + ) + + expect(user).toBeUndefined() + }) + }) + + describe(`joins`, () => { + it(`should support join queries`, async () => { + type Post = { + id: number + authorId: number + title: string + } + + const postsCollection = createCollection( + mockSyncCollectionOptions({ + id: `test-posts-query-once`, + getKey: (post) => post.id, + initialData: [ + { id: 1, authorId: 1, title: `Alice Post 1` }, + { id: 2, authorId: 1, title: `Alice Post 2` }, + { id: 3, authorId: 2, title: `Bob Post 1` }, + ], + }), + ) + + const usersWithPosts = await queryOnce((q) => + q + .from({ user: usersCollection }) + .join( + { post: postsCollection }, + ({ user, post }) => eq(user.id, post.authorId), + `inner`, + ) + .select(({ user, post }) => ({ + userName: user.name, + postTitle: post.title, + })), + ) + + expect(usersWithPosts.length).toBe(3) + expect(usersWithPosts.some((r) => r.userName === `Alice`)).toBe(true) + expect(usersWithPosts.some((r) => r.userName === `Bob`)).toBe(true) + }) + }) + + describe(`empty collections`, () => { + it(`should handle empty collections`, async () => { + const emptyCollection = createCollection( + mockSyncCollectionOptions({ + id: `empty-users-query-once`, + getKey: (user) => user.id, + initialData: [], + }), + ) + + const users = await queryOnce((q) => q.from({ user: emptyCollection })) + + expect(users.length).toBe(0) + expect(Array.isArray(users)).toBe(true) + }) + }) + + describe(`cleanup`, () => { + it(`should cleanup the collection after returning results`, async () => { + // Run the query + const users = await queryOnce((q) => q.from({ user: usersCollection })) + + // Verify we got results + expect(users.length).toBe(5) + + // The collection should be cleaned up (no way to directly test this, + // but if cleanup doesn't happen, memory would leak over time) + }) + + it(`should cleanup even if preload rejects`, async () => { + const preloadError = new Error(`preload failed`) + const preloadSpy = vi + .spyOn(CollectionImpl.prototype, `preload`) + .mockRejectedValueOnce(preloadError) + const cleanupSpy = vi.spyOn(CollectionImpl.prototype, `cleanup`) + + try { + await expect( + queryOnce((q) => q.from({ user: usersCollection })), + ).rejects.toThrow(`preload failed`) + + expect(cleanupSpy).toHaveBeenCalled() + } finally { + preloadSpy.mockRestore() + cleanupSpy.mockRestore() + } + }) + }) + + describe(`combined operations`, () => { + it(`should support complex queries with multiple operations`, async () => { + const result = await queryOnce((q) => + q + .from({ user: usersCollection }) + .where(({ user }) => eq(user.active, true)) + .select(({ user }) => ({ + id: user.id, + name: user.name, + age: user.age, + })) + .orderBy(({ user }) => user.age, `desc`) + .limit(2), + ) + + expect(result.length).toBe(2) + // Active users ordered by age desc, limited to 2 + // Active users: Alice (30), Bob (25), Diana (28) + // Ordered by age desc: Alice (30), Diana (28), Bob (25) + // Limited to 2: Alice, Diana + expect(result[0]!.name).toBe(`Alice`) + expect(result[1]!.name).toBe(`Diana`) + }) + }) + + describe(`type inference`, () => { + it(`should correctly infer types for simple queries`, async () => { + const users = await queryOnce((q) => q.from({ user: usersCollection })) + + // TypeScript should infer the correct type + const firstUser = users[0] + if (firstUser) { + // These should compile without errors + const _id: number = firstUser.id + const _name: string = firstUser.name + const _active: boolean = firstUser.active + const _age: number = firstUser.age + + expect(_id).toBeDefined() + expect(_name).toBeDefined() + expect(_active).toBeDefined() + expect(_age).toBeDefined() + } + }) + + it(`should correctly infer types for projected queries`, async () => { + const users = await queryOnce((q) => + q.from({ user: usersCollection }).select(({ user }) => ({ + userName: user.name, + userAge: user.age, + })), + ) + + const firstUser = users[0] + if (firstUser) { + // These should compile without errors + const _userName: string = firstUser.userName + const _userAge: number = firstUser.userAge + + expect(_userName).toBeDefined() + expect(_userAge).toBeDefined() + } + }) + }) +})