diff --git a/packages/db/src/query/builder/composables.ts b/packages/db/src/query/builder/composables.ts new file mode 100644 index 000000000..b51ab452b --- /dev/null +++ b/packages/db/src/query/builder/composables.ts @@ -0,0 +1,105 @@ +import { BaseQueryBuilder } from "./index.js" +import type { InitialQueryBuilder, QueryBuilder } from "./index.js" +import type { NamespacedRow } from "../../types.js" +import type { RefProxyForNamespaceRow, SelectObject } from "./types.js" + +/** + * Create a reusable query builder that can be used in multiple places + * + * @param fn - A function that receives an initial query builder and returns a configured query + * @returns A reusable query builder that can be used directly or extended further + * + * @example + * ```ts + * const activeUsersQuery = defineQuery((q) => + * q.from({ user: usersCollection }) + * .where(({ user }) => eq(user.active, true)) + * ) + * + * // Use directly + * const users = useLiveQuery(activeUsersQuery) + * + * // Extend further + * const activeAdults = activeUsersQuery.where(({ user }) => gt(user.age, 18)) + * ``` + */ +export function defineQuery>( + fn: (builder: InitialQueryBuilder) => TQueryBuilder +): TQueryBuilder { + return fn(new BaseQueryBuilder()) +} + +/** + * Create reusable, type-safe query components for specific table schemas + * + * @returns An object with `callback` and `select` methods for creating reusable components + * + * @example + * ```ts + * // Create reusable predicates + * const userIsAdult = defineForRow<{ user: User }>().callback(({ user }) => + * gt(user.age, 18) + * ) + * + * // Create reusable select transformations + * const userInfo = defineForRow<{ user: User }>().select(({ user }) => ({ + * id: user.id, + * name: upper(user.name) + * })) + * + * // Use in queries + * const query = useLiveQuery((q) => + * q.from({ user: usersCollection }) + * .where(userIsAdult) + * .select(userInfo) + * ) + * ``` + */ +export function defineForRow() { + /** + * Create a reusable callback function for WHERE, HAVING, or other expression contexts + * + * @param fn - A function that receives table references and returns an expression + * @returns A reusable callback function that can be used in query builders + * + * @example + * ```ts + * const userIsActive = defineForRow<{ user: User }>().callback(({ user }) => + * eq(user.active, true) + * ) + * + * // Use in WHERE clauses + * query.where(userIsActive) + * ``` + */ + const callback = ( + fn: (refs: RefProxyForNamespaceRow) => TResult + ) => fn + + /** + * Create a reusable select transformation for projecting and transforming data + * + * @param fn - A function that receives table references and returns a select object + * @returns A reusable select transformation that can be used in query builders + * + * @example + * ```ts + * const userBasicInfo = defineForRow<{ user: User }>().select(({ user }) => ({ + * id: user.id, + * name: user.name, + * displayName: upper(user.name) + * })) + * + * // Use in SELECT clauses + * query.select(userBasicInfo) + * ``` + */ + const select = ( + fn: (refs: RefProxyForNamespaceRow) => TSelectObject + ) => fn + + return { + callback, + select, + } +} diff --git a/packages/db/src/query/builder/types.ts b/packages/db/src/query/builder/types.ts index efa8fa959..35c6a39d0 100644 --- a/packages/db/src/query/builder/types.ts +++ b/packages/db/src/query/builder/types.ts @@ -1,3 +1,4 @@ +import type { NamespacedRow } from "../../types.js" import type { CollectionImpl } from "../../collection.js" import type { Agg, Expression } from "../ir.js" import type { QueryBuilder } from "./index.js" @@ -88,6 +89,11 @@ export type RefProxyForContext = { [K in keyof TContext[`schema`]]: RefProxyFor } +// Type for creating RefProxy objects based on a namespace row +export type RefProxyForNamespaceRow = { + [K in keyof TNamespaceRow]: RefProxyFor +} + // Helper type to check if T is exactly undefined type IsExactlyUndefined = [T] extends [undefined] ? true : false diff --git a/packages/db/src/query/compiler/index.ts b/packages/db/src/query/compiler/index.ts index a9d3e2d37..829b09889 100644 --- a/packages/db/src/query/compiler/index.ts +++ b/packages/db/src/query/compiler/index.ts @@ -17,7 +17,7 @@ import type { type QueryCache = WeakMap /** - * Compiles a query2 IR into a D2 pipeline + * Compiles a query IR into a D2 pipeline * @param query The query IR to compile * @param inputs Mapping of collection names to input streams * @param cache Optional cache for compiled subqueries (used internally for recursion) diff --git a/packages/db/src/query/index.ts b/packages/db/src/query/index.ts index 8df3990d8..68fb41bc7 100644 --- a/packages/db/src/query/index.ts +++ b/packages/db/src/query/index.ts @@ -1,15 +1,15 @@ -// Main exports for the new query builder system +// Main exports for the query builder system // Query builder exports export { BaseQueryBuilder, - buildQuery, type InitialQueryBuilder, type QueryBuilder, type Context, type Source, type GetResult, } from "./builder/index.js" +export { defineQuery, defineForRow } from "./builder/composables.js" // Expression functions exports export { @@ -40,9 +40,6 @@ export { max, } from "./builder/functions.js" -// Ref proxy utilities -export { val, toExpression, isRefProxy } from "./builder/ref-proxy.js" - // IR types (for advanced usage) export type { Query, diff --git a/packages/db/src/query/live-query-collection.ts b/packages/db/src/query/live-query-collection.ts index 3aecfb94f..64d471ec0 100644 --- a/packages/db/src/query/live-query-collection.ts +++ b/packages/db/src/query/live-query-collection.ts @@ -1,7 +1,7 @@ import { D2, MultiSet, output } from "@electric-sql/d2mini" import { createCollection } from "../collection.js" import { compileQuery } from "./compiler/index.js" -import { buildQuery } from "./builder/index.js" +import { BaseQueryBuilder, buildQuery, getQuery } from "./builder/index.js" import type { InitialQueryBuilder, QueryBuilder } from "./builder/index.js" import type { Collection } from "../collection.js" import type { @@ -53,9 +53,11 @@ export interface LiveQueryCollectionConfig< id?: string /** - * Query builder function that defines the live query + * Query builder function or predefined query builder instance that defines the live query */ - query: (q: InitialQueryBuilder) => QueryBuilder + query: + | ((q: InitialQueryBuilder) => QueryBuilder) + | QueryBuilder /** * Function to extract the key from result items @@ -114,8 +116,11 @@ export function liveQueryCollectionOptions< // Generate a unique ID if not provided const id = config.id || `live-query-${++liveQueryCollectionCounter}` - // Build the query using the provided query builder function - const query = buildQuery(config.query) + // Build the query using the provided query builder function or predefined query builder + const query = + typeof config.query === `function` + ? buildQuery(config.query) + : getQuery(config.query) // WeakMap to store the keys of the results so that we can retreve them in the // getKey function @@ -372,7 +377,13 @@ export function createLiveQueryCollection< query: (q: InitialQueryBuilder) => QueryBuilder ): Collection -// Overload 2: Accept full config object with optional utilities +// Overload 2: Accept just a predefined query builder +export function createLiveQueryCollection< + TContext extends Context, + TResult extends object = GetResult, +>(query: QueryBuilder): Collection + +// Overload 3: Accept full config object with optional utilities export function createLiveQueryCollection< TContext extends Context, TResult extends object = GetResult, @@ -390,8 +401,9 @@ export function createLiveQueryCollection< configOrQuery: | (LiveQueryCollectionConfig & { utils?: TUtils }) | ((q: InitialQueryBuilder) => QueryBuilder) + | QueryBuilder ): Collection { - // Determine if the argument is a function (query) or a config object + // Determine if the argument is a function (query), a QueryBuilder, or a config object if (typeof configOrQuery === `function`) { // Simple query function case const config: LiveQueryCollectionConfig = { @@ -399,6 +411,15 @@ export function createLiveQueryCollection< } const options = liveQueryCollectionOptions(config) + // Use a bridge function that handles the type compatibility cleanly + return bridgeToCreateCollection(options) + } else if (configOrQuery instanceof BaseQueryBuilder) { + // QueryBuilder instance case (predefined query builder) + const config: LiveQueryCollectionConfig = { + query: configOrQuery as QueryBuilder, + } + const options = liveQueryCollectionOptions(config) + // Use a bridge function that handles the type compatibility cleanly return bridgeToCreateCollection(options) } else { @@ -418,7 +439,7 @@ export function createLiveQueryCollection< } /** - * Bridge function that handles the type compatibility between query2's TResult + * Bridge function that handles the type compatibility between query's TResult * and core collection's ResolveType without exposing ugly type assertions to users */ function bridgeToCreateCollection< diff --git a/packages/db/tests/query/builder/defineQuery.test-d.ts b/packages/db/tests/query/builder/defineQuery.test-d.ts new file mode 100644 index 000000000..977d960a1 --- /dev/null +++ b/packages/db/tests/query/builder/defineQuery.test-d.ts @@ -0,0 +1,343 @@ +import { describe, expectTypeOf, test } from "vitest" +import { createCollection } from "../../../src/collection.js" +import { mockSyncCollectionOptions } from "../../utls.js" +import { defineQuery } from "../../../src/query/builder/composables.js" +import { count, eq, gt, sum } from "../../../src/query/builder/functions.js" +import type { ExtractContext } from "../../../src/query/builder/index.js" +import type { GetResult } from "../../../src/query/builder/types.js" + +// Sample data types for testing +type User = { + id: number + name: string + email: string + age: number + active: boolean + department_id: number | null + salary: number +} + +type Department = { + id: number + name: string + budget: number + location: string + active: boolean +} + +type Project = { + id: number + name: string + user_id: number + department_id: number + status: string +} + +function createTestCollections() { + const usersCollection = createCollection( + mockSyncCollectionOptions({ + id: `test-users`, + getKey: (user) => user.id, + initialData: [], + }) + ) + + const departmentsCollection = createCollection( + mockSyncCollectionOptions({ + id: `test-departments`, + getKey: (dept) => dept.id, + initialData: [], + }) + ) + + const projectsCollection = createCollection( + mockSyncCollectionOptions({ + id: `test-projects`, + getKey: (project) => project.id, + initialData: [], + }) + ) + + return { usersCollection, departmentsCollection, projectsCollection } +} + +describe(`defineQuery Type Tests`, () => { + const { usersCollection, departmentsCollection, projectsCollection } = + createTestCollections() + + test(`defineQuery return type matches callback return type`, () => { + // Test that defineQuery returns exactly the same type as the callback + const _queryBuilder = defineQuery((q) => q.from({ users: usersCollection })) + + // Test that the result type is correctly inferred as User + expectTypeOf< + GetResult> + >().toEqualTypeOf() + }) + + test(`defineQuery with simple select`, () => { + const _queryBuilder = defineQuery((q) => + q.from({ users: usersCollection }).select(({ users }) => ({ + id: users.id, + name: users.name, + email: users.email, + })) + ) + + // Test that the result type is correctly inferred + expectTypeOf< + GetResult> + >().toEqualTypeOf<{ + id: number + name: string + email: string + }>() + }) + + test(`defineQuery with join`, () => { + const _queryBuilder = defineQuery((q) => + q + .from({ users: usersCollection }) + .join({ _dept: departmentsCollection }, ({ users, _dept }) => + eq(users.department_id, _dept.id) + ) + ) + + // Test that join result type is correctly inferred + expectTypeOf< + GetResult> + >().toEqualTypeOf<{ + users: User + _dept: Department | undefined + }>() + }) + + test(`defineQuery with join and select`, () => { + const _queryBuilder = defineQuery((q) => + q + .from({ users: usersCollection }) + .join({ _dept: departmentsCollection }, ({ users, _dept }) => + eq(users.department_id, _dept.id) + ) + .select(({ users, _dept }) => ({ + userName: users.name, + deptName: _dept.name, + userEmail: users.email, + })) + ) + + // Test that join with select result type is correctly inferred + expectTypeOf< + GetResult> + >().toEqualTypeOf<{ + userName: string + deptName: string | undefined + userEmail: string + }>() + }) + + test(`defineQuery with where clause`, () => { + const _queryBuilder = defineQuery((q) => + q.from({ users: usersCollection }).where(({ users }) => gt(users.age, 18)) + ) + + // Test that where clause doesn't change the result type + expectTypeOf< + GetResult> + >().toEqualTypeOf() + }) + + test(`defineQuery with groupBy and aggregates`, () => { + const _queryBuilder = defineQuery((q) => + q + .from({ users: usersCollection }) + .groupBy(({ users }) => users.department_id) + .select(({ users }) => ({ + departmentId: users.department_id, + userCount: count(users.id), + totalSalary: sum(users.salary), + })) + ) + + // Test that groupBy with aggregates is correctly typed + expectTypeOf< + GetResult> + >().toEqualTypeOf<{ + departmentId: number | null + userCount: number + totalSalary: number + }>() + }) + + test(`defineQuery with orderBy`, () => { + const _queryBuilder = defineQuery((q) => + q.from({ users: usersCollection }).orderBy(({ users }) => users.name) + ) + + // Test that orderBy doesn't change the result type + expectTypeOf< + GetResult> + >().toEqualTypeOf() + }) + + test(`defineQuery with limit and offset`, () => { + const _queryBuilder = defineQuery((q) => + q + .from({ users: usersCollection }) + .orderBy(({ users }) => users.name) + .limit(10) + .offset(5) + ) + + // Test that limit/offset don't change the result type + expectTypeOf< + GetResult> + >().toEqualTypeOf() + }) + + test(`defineQuery with complex multi-join query`, () => { + const _queryBuilder = defineQuery((q) => + q + .from({ users: usersCollection }) + .join({ _dept: departmentsCollection }, ({ users, _dept }) => + eq(users.department_id, _dept.id) + ) + .join({ _project: projectsCollection }, ({ users, _dept, _project }) => + eq(_project.user_id, users.id) + ) + .select(({ users, _dept, _project }) => ({ + userId: users.id, + userName: users.name, + deptName: _dept.name, + projectName: _project.name, + projectStatus: _project.status, + })) + ) + + // Test complex multi-join with select + expectTypeOf< + GetResult> + >().toEqualTypeOf<{ + userId: number + userName: string + deptName: string | undefined + projectName: string | undefined + projectStatus: string | undefined + }>() + }) + + test(`defineQuery with inner join`, () => { + const _queryBuilder = defineQuery((q) => + q + .from({ users: usersCollection }) + .join( + { _dept: departmentsCollection }, + ({ users, _dept }) => eq(users.department_id, _dept.id), + `inner` + ) + ) + + // Test that inner join type is correctly inferred + expectTypeOf< + GetResult> + >().toEqualTypeOf<{ + users: User + _dept: Department + }>() + }) + + test(`defineQuery with right join`, () => { + const _queryBuilder = defineQuery((q) => + q + .from({ users: usersCollection }) + .join( + { _dept: departmentsCollection }, + ({ users, _dept }) => eq(users.department_id, _dept.id), + `right` + ) + ) + + // Test that right join type is correctly inferred + expectTypeOf< + GetResult> + >().toEqualTypeOf<{ + users: User | undefined + _dept: Department + }>() + }) + + test(`defineQuery with full join`, () => { + const _queryBuilder = defineQuery((q) => + q + .from({ users: usersCollection }) + .join( + { _dept: departmentsCollection }, + ({ users, _dept }) => eq(users.department_id, _dept.id), + `full` + ) + ) + + // Test that full join type is correctly inferred + expectTypeOf< + GetResult> + >().toEqualTypeOf<{ + users: User | undefined + _dept: Department | undefined + }>() + }) + + test(`defineQuery with functional select`, () => { + const _queryBuilder = defineQuery((q) => + q.from({ users: usersCollection }).fn.select((row) => ({ + upperName: row.users.name.toUpperCase(), + ageNextYear: row.users.age + 1, + })) + ) + + // Test that functional select result type is correctly inferred + expectTypeOf< + GetResult> + >().toEqualTypeOf<{ + upperName: string + ageNextYear: number + }>() + }) + + test(`defineQuery result can be used like any QueryBuilder`, () => { + const _baseQuery = defineQuery((q) => q.from({ users: usersCollection })) + + // Test that the result can be extended like any QueryBuilder + const _extendedQuery = _baseQuery + .where(({ users }) => gt(users.age, 18)) + .select(({ users }) => ({ + id: users.id, + name: users.name, + })) + + expectTypeOf< + GetResult> + >().toEqualTypeOf<{ + id: number + name: string + }>() + }) + + test(`defineQuery with subquery`, () => { + const _subQuery = defineQuery((q) => + q.from({ users: usersCollection }).where(({ users }) => gt(users.age, 18)) + ) + + const _mainQuery = defineQuery((q) => + q.from({ activeUsers: _subQuery }).select(({ activeUsers }) => ({ + id: activeUsers.id, + name: activeUsers.name, + })) + ) + + // Test that subquery usage is correctly typed + expectTypeOf>>().toEqualTypeOf<{ + id: number + name: string + }>() + }) +}) diff --git a/packages/db/tests/query/compiler/basic.test.ts b/packages/db/tests/query/compiler/basic.test.ts index b613c6e61..28e225b4e 100644 --- a/packages/db/tests/query/compiler/basic.test.ts +++ b/packages/db/tests/query/compiler/basic.test.ts @@ -28,7 +28,7 @@ const sampleUsers: Array = [ { id: 4, name: `Dave`, age: 22, email: `dave@example.com`, active: true }, ] -describe(`Query2 Compiler`, () => { +describe(`Query Compiler`, () => { describe(`Basic Compilation`, () => { test(`compiles a simple FROM query`, () => { // Create a mock collection diff --git a/packages/db/tests/query/compiler/subqueries.test.ts b/packages/db/tests/query/compiler/subqueries.test.ts index 0852bd8f4..95a566d6f 100644 --- a/packages/db/tests/query/compiler/subqueries.test.ts +++ b/packages/db/tests/query/compiler/subqueries.test.ts @@ -124,7 +124,7 @@ const sendUserData = (input: any, users: Array) => { ) } -describe(`Query2 Subqueries`, () => { +describe(`Query Subqueries`, () => { describe(`Subqueries in FROM clause`, () => { it(`supports simple subquery in from clause`, () => { // Create a base query that filters issues for project 1 diff --git a/packages/db/tests/query/composables.test.ts b/packages/db/tests/query/composables.test.ts new file mode 100644 index 000000000..c1b5d2988 --- /dev/null +++ b/packages/db/tests/query/composables.test.ts @@ -0,0 +1,619 @@ +import { beforeEach, describe, expect, test } from "vitest" +import { + and, + createLiveQueryCollection, + defineForRow, + defineQuery, + eq, + gt, + lower, + lt, + lte, + upper, +} from "../../src/query/index.js" +import { createCollection } from "../../src/collection.js" +import { mockSyncCollectionOptions } from "../utls.js" + +// Sample user type for tests +type User = { + id: number + name: string + age: number + email: string + active: boolean +} + +// Sample post type for tests +type Post = { + id: number + title: string + authorId: number + published: boolean + content: string +} + +// Sample data for tests +const sampleUsers: Array = [ + { id: 1, name: `Alice`, age: 25, email: `alice@example.com`, active: true }, + { id: 2, name: `Bob`, age: 19, email: `bob@example.com`, active: true }, + { + id: 3, + name: `Charlie`, + age: 30, + email: `charlie@example.com`, + active: false, + }, + { id: 4, name: `Dave`, age: 22, email: `dave@example.com`, active: true }, +] + +const samplePosts: Array = [ + { + id: 1, + title: `Alice's First Post`, + authorId: 1, + published: true, + content: `Hello World`, + }, + { + id: 2, + title: `Bob's Draft`, + authorId: 2, + published: false, + content: `Draft content`, + }, + { + id: 3, + title: `Alice's Second Post`, + authorId: 1, + published: true, + content: `More content`, + }, + { + id: 4, + title: `Dave's Article`, + authorId: 4, + published: true, + content: `Article content`, + }, + { + id: 5, + title: `Charlie's Work`, + authorId: 3, + published: false, + content: `Work in progress`, + }, +] + +function createUsersCollection() { + return createCollection( + mockSyncCollectionOptions({ + id: `test-users`, + getKey: (user) => user.id, + initialData: sampleUsers, + }) + ) +} + +function createPostsCollection() { + return createCollection( + mockSyncCollectionOptions({ + id: `test-posts`, + getKey: (post) => post.id, + initialData: samplePosts, + }) + ) +} + +describe(`Composables`, () => { + describe(`defineForRow`, () => { + let usersCollection: ReturnType + let postsCollection: ReturnType + + beforeEach(() => { + usersCollection = createUsersCollection() + postsCollection = createPostsCollection() + }) + + test(`should create reusable callback predicates`, () => { + // Define reusable predicates using defineForRow + const userIsAdult = defineForRow<{ user: User }>().callback(({ user }) => + gt(user.age, 18) + ) + + const userIsActive = defineForRow<{ user: User }>().callback(({ user }) => + eq(user.active, true) + ) + + const userIsYoung = defineForRow<{ user: User }>().callback(({ user }) => + lt(user.age, 25) + ) + + // Use the predicates in a query + const liveCollection = createLiveQueryCollection({ + query: (q) => + q + .from({ user: usersCollection }) + .where(userIsAdult) + .where(userIsActive) + .where(userIsYoung), + startSync: true, + }) + + const results = liveCollection.toArray + + // Should return Bob (19) and Dave (22) - both adult, active, and young + expect(results).toHaveLength(2) + expect(results.map((u) => u.name)).toEqual( + expect.arrayContaining([`Bob`, `Dave`]) + ) + expect(results.every((u) => u.age > 18 && u.age < 25 && u.active)).toBe( + true + ) + }) + + test(`should create reusable select objects`, () => { + // Define reusable select objects using defineForRow + const userBasicInfo = defineForRow<{ user: User }>().select( + ({ user }) => ({ + id: user.id, + name: user.name, + email: user.email, + }) + ) + + const userNameTransforms = defineForRow<{ user: User }>().select( + ({ user }) => ({ + nameUpper: upper(user.name), + nameLower: lower(user.name), + }) + ) + + // Use the select objects in a query + const liveCollection = createLiveQueryCollection({ + query: (q) => + q + .from({ user: usersCollection }) + .where(({ user }) => eq(user.active, true)) + .select(({ user }) => ({ + ...userBasicInfo({ user }), + ...userNameTransforms({ user }), + age: user.age, + })), + startSync: true, + }) + + const results = liveCollection.toArray + + expect(results).toHaveLength(3) // Alice, Bob, Dave are active + + const alice = results.find((u) => u.name === `Alice`) + expect(alice).toMatchObject({ + id: 1, + name: `Alice`, + email: `alice@example.com`, + nameUpper: `ALICE`, + nameLower: `alice`, + age: 25, + }) + + // Verify all results have the expected structure + results.forEach((result) => { + expect(result).toHaveProperty(`id`) + expect(result).toHaveProperty(`name`) + expect(result).toHaveProperty(`email`) + expect(result).toHaveProperty(`nameUpper`) + expect(result).toHaveProperty(`nameLower`) + expect(result).toHaveProperty(`age`) + }) + }) + + test(`should work with defineQuery for reusable query composition`, () => { + // Define reusable components + const userIsAdult = defineForRow<{ user: User }>().callback(({ user }) => + gt(user.age, 20) + ) + + const userDisplayInfo = defineForRow<{ user: User }>().select( + ({ user }) => ({ + userId: user.id, + displayName: upper(user.name), + contactEmail: user.email, + }) + ) + + // Create a reusable query using defineQuery that uses the components + const adultUsersQuery = defineQuery((q) => + q + .from({ user: usersCollection }) + .where(userIsAdult) + .select(userDisplayInfo) + ) + + // Use the predefined query + const liveCollection = createLiveQueryCollection({ + query: adultUsersQuery, + startSync: true, + }) + + const results = liveCollection.toArray + + expect(results).toHaveLength(3) // Alice (25), Charlie (30), Dave (22) + expect(results.map((u) => u.displayName)).toEqual( + expect.arrayContaining([`ALICE`, `CHARLIE`, `DAVE`]) + ) + + // Test that we can create a new query that combines the components differently + const activeAdultUsersQuery = defineQuery((q) => + q + .from({ user: usersCollection }) + .where(({ user }) => + and(userIsAdult({ user }), eq(user.active, true)) + ) + .select(userDisplayInfo) + ) + + const activeCollection = createLiveQueryCollection({ + query: activeAdultUsersQuery, + startSync: true, + }) + + expect(activeCollection.size).toBe(2) // Alice and Dave (Charlie is inactive) + }) + + test(`should work with joins using defineForRow components`, () => { + // Define reusable components for different namespaces + const userIsActive = defineForRow<{ user: User }>().callback(({ user }) => + eq(user.active, true) + ) + + const postIsPublished = defineForRow<{ post: Post }>().callback( + ({ post }) => eq(post.published, true) + ) + + const userPostJoinInfo = defineForRow<{ + user: User + post: Post + }>().select(({ user, post }) => ({ + authorId: user.id, + authorName: upper(user.name), + authorEmail: user.email, + postId: post.id, + postTitle: post.title, + postContent: post.content, + })) + + // Create a query that uses the components in a join + const liveCollection = createLiveQueryCollection({ + query: (q) => + q + .from({ user: usersCollection }) + .join( + { post: postsCollection }, + ({ user, post }) => eq(user.id, post.authorId), + `inner` + ) + .where(userIsActive) + .where(postIsPublished) + .select(userPostJoinInfo), + startSync: true, + }) + + const results = liveCollection.toArray + + // Should have Alice (2 posts) and Dave (1 post) with published posts + expect(results).toHaveLength(3) + + const aliceResults = results.filter((r) => r.authorName === `ALICE`) + const daveResults = results.filter((r) => r.authorName === `DAVE`) + + expect(aliceResults).toHaveLength(2) + expect(daveResults).toHaveLength(1) + + // Verify structure + results.forEach((result) => { + expect(result).toHaveProperty(`authorId`) + expect(result).toHaveProperty(`authorName`) + expect(result).toHaveProperty(`authorEmail`) + expect(result).toHaveProperty(`postId`) + expect(result).toHaveProperty(`postTitle`) + expect(result).toHaveProperty(`postContent`) + }) + }) + + test(`should allow combining multiple defineForRow callbacks with and/or`, () => { + const userIsActive = defineForRow<{ user: User }>().callback(({ user }) => + eq(user.active, true) + ) + + const userIsAdult = defineForRow<{ user: User }>().callback(({ user }) => + gt(user.age, 20) + ) + + const userIsYoung = defineForRow<{ user: User }>().callback(({ user }) => + lt(user.age, 25) + ) + + // Combine the predicates using and() + const liveCollection = createLiveQueryCollection({ + query: (q) => + q + .from({ user: usersCollection }) + .where(({ user }) => + and( + userIsActive({ user }), + userIsAdult({ user }), + userIsYoung({ user }) + ) + ), + startSync: true, + }) + + const results = liveCollection.toArray + + // Should return Bob (19 - not adult) and Dave (22) - Dave only meets all criteria + expect(results).toHaveLength(1) + const result = results[0]! + expect(result.name).toBe(`Dave`) + expect(result.age).toBe(22) + expect(result.active).toBe(true) + }) + + test(`should work with predefined queries as subqueries using defineForRow`, () => { + // Define reusable components + const userIsActive = defineForRow<{ user: User }>().callback(({ user }) => + eq(user.active, true) + ) + + const userIsJunior = defineForRow<{ user: User }>().callback(({ user }) => + lte(user.age, 25) + ) + + const userBasicWithAge = defineForRow<{ user: User }>().select( + ({ user }) => ({ + id: user.id, + name: user.name, + age: user.age, + }) + ) + + // Create a base query using defineQuery and defineForRow + const activeJuniorUsersQuery = defineQuery((q) => + q + .from({ user: usersCollection }) + .where(userIsActive) + .where(userIsJunior) + .select(userBasicWithAge) + ) + + // Use the predefined query as a subquery with defineForRow components + const enhancedJuniorUsersQuery = defineQuery((q) => + q + .from({ activeUser: activeJuniorUsersQuery }) + .select(({ activeUser }) => ({ + userId: activeUser.id, + userName: upper(activeUser.name), + userAge: activeUser.age, + category: `junior`, + })) + ) + + const liveCollection = createLiveQueryCollection({ + query: enhancedJuniorUsersQuery, + startSync: true, + }) + + const results = liveCollection.toArray + + // Alice (25 - junior), Bob (19 - junior), Dave (22 - junior) are active and junior + // Charlie (30) would not be junior even if active + expect(results).toHaveLength(3) + expect(results.every((u) => u.category === `junior`)).toBe(true) + expect(results.map((u) => u.userName)).toEqual( + expect.arrayContaining([`ALICE`, `BOB`, `DAVE`]) + ) + }) + + test(`should maintain type safety across different namespace structures`, () => { + // This test verifies that defineForRow maintains proper typing + // Different namespace structures should work correctly + + const singleUserPredicate = defineForRow<{ user: User }>().callback( + ({ user }) => gt(user.age, 20) + ) + + const joinedUserPostPredicate = defineForRow<{ + u: User + p: Post + }>().callback(({ u, p }) => + and(eq(u.active, true), eq(p.published, true)) + ) + + const singleUserSelect = defineForRow<{ user: User }>().select( + ({ user }) => ({ + name: user.name, + age: user.age, + }) + ) + + const joinedSelect = defineForRow<{ u: User; p: Post }>().select( + ({ u, p }) => ({ + userName: u.name, + postTitle: p.title, + }) + ) + + // Test single collection + const singleCollection = createLiveQueryCollection({ + query: (q) => + q + .from({ user: usersCollection }) + .where(singleUserPredicate) + .select(singleUserSelect), + startSync: true, + }) + + expect(singleCollection.size).toBe(3) // Alice, Charlie, Dave > 20 + + // Test joined collections + const joinedCollection = createLiveQueryCollection({ + query: (q) => + q + .from({ u: usersCollection }) + .join( + { p: postsCollection }, + ({ u, p }) => eq(u.id, p.authorId), + `inner` + ) + .where(joinedUserPostPredicate) + .select(joinedSelect), + startSync: true, + }) + + expect(joinedCollection.size).toBe(3) // Active users with published posts + + // Verify the results have correct structure + const joinedResults = joinedCollection.toArray + joinedResults.forEach((result) => { + expect(result).toHaveProperty(`userName`) + expect(result).toHaveProperty(`postTitle`) + expect(typeof result.userName).toBe(`string`) + expect(typeof result.postTitle).toBe(`string`) + }) + }) + }) + + describe(`defineQuery (existing tests)`, () => { + let usersCollection: ReturnType + + beforeEach(() => { + usersCollection = createUsersCollection() + }) + + test(`should accept a predefined query builder directly`, () => { + // Define a query using defineQuery + const activeUsersQuery = defineQuery((q) => + q + .from({ user: usersCollection }) + .where(({ user }) => eq(user.active, true)) + .select(({ user }) => ({ + id: user.id, + name: user.name, + email: user.email, + })) + ) + + // Use the predefined query in createLiveQueryCollection + const liveCollection = createLiveQueryCollection({ + query: activeUsersQuery, + startSync: true, + }) + + const results = liveCollection.toArray + + expect(results).toHaveLength(3) // Alice, Bob, Dave are active + expect(results.every((u) => typeof u.id === `number`)).toBe(true) + expect(results.every((u) => typeof u.name === `string`)).toBe(true) + expect(results.every((u) => typeof u.email === `string`)).toBe(true) + expect(results.map((u) => u.name)).toEqual( + expect.arrayContaining([`Alice`, `Bob`, `Dave`]) + ) + + // Insert a new active user + const newUser = { + id: 5, + name: `Eve`, + age: 28, + email: `eve@example.com`, + active: true, + } + usersCollection.utils.begin() + usersCollection.utils.write({ + type: `insert`, + value: newUser, + }) + usersCollection.utils.commit() + + expect(liveCollection.size).toBe(4) // Should include the new active user + expect(liveCollection.get(5)).toMatchObject({ + id: 5, + name: `Eve`, + email: `eve@example.com`, + }) + + // Clean up + usersCollection.utils.begin() + usersCollection.utils.write({ + type: `delete`, + value: newUser, + }) + usersCollection.utils.commit() + }) + + test(`should maintain reactivity with predefined queries`, () => { + // Define a query + const activeUsersQuery = defineQuery((q) => + q + .from({ user: usersCollection }) + .where(({ user }) => eq(user.active, true)) + .select(({ user }) => ({ + id: user.id, + name: user.name, + active: user.active, + })) + ) + + // Use the predefined query + const liveCollection = createLiveQueryCollection({ + query: activeUsersQuery, + startSync: true, + }) + + expect(liveCollection.size).toBe(3) // Alice, Bob, Dave are active + + // Insert a new active user + const newUser = { + id: 5, + name: `Eve`, + age: 28, + email: `eve@example.com`, + active: true, + } + usersCollection.utils.begin() + usersCollection.utils.write({ + type: `insert`, + value: newUser, + }) + usersCollection.utils.commit() + + expect(liveCollection.size).toBe(4) // Should include the new active user + expect(liveCollection.get(5)).toMatchObject({ + id: 5, + name: `Eve`, + active: true, + }) + + // Update the new user to inactive (should remove from active collection) + const inactiveUser = { ...newUser, active: false } + usersCollection.utils.begin() + usersCollection.utils.write({ + type: `update`, + value: inactiveUser, + }) + usersCollection.utils.commit() + + expect(liveCollection.size).toBe(3) // Should exclude the now inactive user + expect(liveCollection.get(5)).toBeUndefined() + + // Delete the new user + usersCollection.utils.begin() + usersCollection.utils.write({ + type: `delete`, + value: inactiveUser, + }) + usersCollection.utils.commit() + + expect(liveCollection.size).toBe(3) + expect(liveCollection.get(5)).toBeUndefined() + }) + }) +}) diff --git a/packages/db/tests/query/order-by.test.ts b/packages/db/tests/query/order-by.test.ts index 6e12a751e..1ba077ce8 100644 --- a/packages/db/tests/query/order-by.test.ts +++ b/packages/db/tests/query/order-by.test.ts @@ -119,7 +119,7 @@ function createDepartmentsCollection() { ) } -describe(`Query2 OrderBy Compiler`, () => { +describe(`Query OrderBy Compiler`, () => { let employeesCollection: ReturnType let departmentsCollection: ReturnType diff --git a/packages/react-db/src/useLiveQuery.ts b/packages/react-db/src/useLiveQuery.ts index d89bd84f9..2b6f835b9 100644 --- a/packages/react-db/src/useLiveQuery.ts +++ b/packages/react-db/src/useLiveQuery.ts @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from "react" -import { createLiveQueryCollection } from "@tanstack/db" +import { BaseQueryBuilder, createLiveQueryCollection } from "@tanstack/db" import type { Collection, Context, @@ -42,6 +42,16 @@ export function useLiveQuery< collection: Collection } +// Overload 4: Accept a predefined QueryBuilder +export function useLiveQuery( + queryBuilder: QueryBuilder, + deps?: Array +): { + state: Map> + data: Array> + collection: Collection, string | number, {}> +} + // Implementation - use function overloads to infer the actual collection type export function useLiveQuery( configOrQueryOrCollection: any, @@ -55,6 +65,9 @@ export function useLiveQuery( typeof configOrQueryOrCollection.startSyncImmediate === `function` && typeof configOrQueryOrCollection.id === `string` + // Check if it's a QueryBuilder instance + const isQueryBuilder = configOrQueryOrCollection instanceof BaseQueryBuilder + const collection = useMemo( () => { if (isCollection) { @@ -63,6 +76,15 @@ export function useLiveQuery( return configOrQueryOrCollection } + if (isQueryBuilder) { + // It's a predefined QueryBuilder, create a live query collection from it + const queryBuilderCollection = createLiveQueryCollection( + configOrQueryOrCollection as QueryBuilder + ) + queryBuilderCollection.startSyncImmediate() + return queryBuilderCollection + } + // Original logic for creating collections // Ensure we always start sync for React hooks if (typeof configOrQueryOrCollection === `function`) { @@ -77,7 +99,11 @@ export function useLiveQuery( }) } }, - isCollection ? [configOrQueryOrCollection] : [...deps] + isCollection + ? [configOrQueryOrCollection] + : isQueryBuilder + ? [configOrQueryOrCollection, ...deps] + : [...deps] ) // Infer types from the actual collection diff --git a/packages/react-db/tests/useLiveQuery.test.tsx b/packages/react-db/tests/useLiveQuery.test.tsx index 783b01417..91a74264d 100644 --- a/packages/react-db/tests/useLiveQuery.test.tsx +++ b/packages/react-db/tests/useLiveQuery.test.tsx @@ -5,6 +5,7 @@ import { createCollection, createLiveQueryCollection, createOptimisticAction, + defineQuery, eq, gt, } from "@tanstack/db" @@ -928,4 +929,240 @@ describe(`Query Collections`, () => { // Verify we no longer have data from the first collection expect(result.current.state.get(`3`)).toBeUndefined() }) + + it(`should accept a predefined QueryBuilder directly`, async () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `predefined-querybuilder-test`, + getKey: (person: Person) => person.id, + initialData: initialPersons, + }) + ) + + // Create a predefined query using defineQuery + const predefinedQuery = defineQuery((q) => + q + .from({ persons: collection }) + .where(({ persons }) => gt(persons.age, 30)) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + age: persons.age, + })) + ) + + const { result } = renderHook(() => { + return useLiveQuery(predefinedQuery) + }) + + // Wait for collection to sync and state to update + await waitFor(() => { + expect(result.current.state.size).toBe(1) // Only John Smith (age 35) + }) + expect(result.current.data).toHaveLength(1) + + const johnSmith = result.current.data[0] + expect(johnSmith).toMatchObject({ + id: `3`, + name: `John Smith`, + age: 35, + }) + }) + + it(`should handle reactivity with predefined QueryBuilder`, async () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `predefined-querybuilder-reactivity-test`, + getKey: (person: Person) => person.id, + initialData: initialPersons, + }) + ) + + // Create a predefined query for active team members + const activeTeamQuery = defineQuery((q) => + q + .from({ persons: collection }) + .where(({ persons }) => gt(persons.age, 25)) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + team: persons.team, + })) + ) + + const { result } = renderHook(() => { + return useLiveQuery(activeTeamQuery) + }) + + // Wait for collection to sync - should have John Doe (30) and John Smith (35) + await waitFor(() => { + expect(result.current.state.size).toBe(2) + }) + expect(result.current.state.get(`1`)).toMatchObject({ + id: `1`, + name: `John Doe`, + team: `team1`, + }) + expect(result.current.state.get(`3`)).toMatchObject({ + id: `3`, + name: `John Smith`, + team: `team1`, + }) + + // Insert a new person that matches the query + act(() => { + collection.utils.begin() + collection.utils.write({ + type: `insert`, + value: { + id: `6`, + name: `Emma Wilson`, + age: 28, + email: `emma.wilson@example.com`, + isActive: true, + team: `team2`, + }, + }) + collection.utils.commit() + }) + + await waitFor(() => { + expect(result.current.state.size).toBe(3) + }) + expect(result.current.state.get(`6`)).toMatchObject({ + id: `6`, + name: `Emma Wilson`, + team: `team2`, + }) + + // Update a person to no longer match the query (age <= 25) + act(() => { + collection.utils.begin() + collection.utils.write({ + type: `update`, + value: { + id: `6`, + name: `Emma Wilson`, + age: 24, // Now age <= 25, should be filtered out + email: `emma.wilson@example.com`, + isActive: true, + team: `team2`, + }, + }) + collection.utils.commit() + }) + + await waitFor(() => { + expect(result.current.state.size).toBe(2) // Back to just John Doe and John Smith + }) + expect(result.current.state.get(`6`)).toBeUndefined() + + // Delete a person + act(() => { + collection.utils.begin() + collection.utils.write({ + type: `delete`, + value: { + id: `1`, + name: `John Doe`, + age: 30, + email: `john.doe@example.com`, + isActive: true, + team: `team1`, + }, + }) + collection.utils.commit() + }) + + await waitFor(() => { + expect(result.current.state.size).toBe(1) // Only John Smith left + }) + expect(result.current.state.get(`1`)).toBeUndefined() + expect(result.current.state.get(`3`)).toMatchObject({ + id: `3`, + name: `John Smith`, + team: `team1`, + }) + }) + + it(`should recompile when QueryBuilder deps change`, async () => { + const collection1 = createCollection( + mockSyncCollectionOptions({ + id: `querybuilder-deps-test-1`, + getKey: (person: Person) => person.id, + initialData: initialPersons, + }) + ) + + const collection2 = createCollection( + mockSyncCollectionOptions({ + id: `querybuilder-deps-test-2`, + getKey: (person: Person) => person.id, + initialData: [ + { + id: `7`, + name: `David Johnson`, + age: 42, + email: `david.johnson@example.com`, + isActive: true, + team: `team3`, + }, + ], + }) + ) + + // Create separate queries for each collection + const query1 = defineQuery((q) => + q + .from({ persons: collection1 }) + .where(({ persons }) => gt(persons.age, 30)) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + })) + ) + + const query2 = defineQuery((q) => + q + .from({ persons: collection2 }) + .where(({ persons }) => gt(persons.age, 30)) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + })) + ) + + const { result, rerender } = renderHook( + ({ query }: { query: any }) => { + return useLiveQuery(query) + }, + { initialProps: { query: query1 } } + ) + + // Wait for first collection to sync - should have John Smith + await waitFor(() => { + expect(result.current.state.size).toBe(1) + }) + expect(result.current.state.get(`3`)).toMatchObject({ + id: `3`, + name: `John Smith`, + }) + + // Switch to second query/collection + act(() => { + rerender({ query: query2 }) + }) + + // Wait for second collection to sync - should have David Johnson + await waitFor(() => { + expect(result.current.state.size).toBe(1) + }) + expect(result.current.state.get(`7`)).toMatchObject({ + id: `7`, + name: `David Johnson`, + }) + + // Verify we no longer have data from the first collection + expect(result.current.state.get(`3`)).toBeUndefined() + }) }) diff --git a/packages/vue-db/src/useLiveQuery.ts b/packages/vue-db/src/useLiveQuery.ts index d8de00f69..ed6e1e9fb 100644 --- a/packages/vue-db/src/useLiveQuery.ts +++ b/packages/vue-db/src/useLiveQuery.ts @@ -6,7 +6,7 @@ import { toValue, watchEffect, } from "vue" -import { createLiveQueryCollection } from "@tanstack/db" +import { BaseQueryBuilder, createLiveQueryCollection } from "@tanstack/db" import type { Collection, Context, @@ -54,6 +54,12 @@ export function useLiveQuery< liveQueryCollection: MaybeRefOrGetter> ): UseLiveQueryReturnWithCollection +// Overload 4: Accept a predefined QueryBuilder (can be reactive) +export function useLiveQuery( + queryBuilder: MaybeRefOrGetter>, + deps?: Array> +): UseLiveQueryReturn> + // Implementation export function useLiveQuery( configOrQueryOrCollection: any, @@ -87,6 +93,18 @@ export function useLiveQuery( return unwrappedParam } + // Check if it's a QueryBuilder instance + const isQueryBuilder = unwrappedParam instanceof BaseQueryBuilder + + if (isQueryBuilder) { + // It's a predefined QueryBuilder, create a live query collection from it + const queryBuilderCollection = createLiveQueryCollection( + unwrappedParam as QueryBuilder + ) + queryBuilderCollection.startSyncImmediate() + return queryBuilderCollection + } + // Reference deps to make computed reactive to them deps.forEach((dep) => toValue(dep)) diff --git a/packages/vue-db/tests/useLiveQuery.test.ts b/packages/vue-db/tests/useLiveQuery.test.ts index 47d757fb7..761761a5c 100644 --- a/packages/vue-db/tests/useLiveQuery.test.ts +++ b/packages/vue-db/tests/useLiveQuery.test.ts @@ -4,6 +4,7 @@ import { createCollection, createLiveQueryCollection, createOptimisticAction, + defineQuery, eq, gt, } from "@tanstack/db" @@ -819,4 +820,225 @@ describe(`Query Collections`, () => { // Verify we no longer have data from the first collection expect(state.value.get(`3`)).toBeUndefined() }) + + it(`should accept a predefined QueryBuilder directly`, async () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `predefined-querybuilder-test-vue`, + getKey: (person: Person) => person.id, + initialData: initialPersons, + }) + ) + + // Create a predefined query using defineQuery + const predefinedQuery = defineQuery((q) => + q + .from({ persons: collection }) + .where(({ persons }) => gt(persons.age, 30)) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + age: persons.age, + })) + ) + + const { state, data } = useLiveQuery(predefinedQuery) + + // Wait for collection to sync and state to update + await waitForVueUpdate() + + expect(state.value.size).toBe(1) // Only John Smith (age 35) + expect(data.value).toHaveLength(1) + + const johnSmith = data.value[0] + expect(johnSmith).toMatchObject({ + id: `3`, + name: `John Smith`, + age: 35, + }) + }) + + it(`should handle reactivity with predefined QueryBuilder`, async () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `predefined-querybuilder-reactivity-test-vue`, + getKey: (person: Person) => person.id, + initialData: initialPersons, + }) + ) + + // Create a predefined query for active team members + const activeTeamQuery = defineQuery((q) => + q + .from({ persons: collection }) + .where(({ persons }) => gt(persons.age, 25)) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + team: persons.team, + })) + ) + + const { state } = useLiveQuery(activeTeamQuery) + + // Wait for collection to sync - should have John Doe (30) and John Smith (35) + await waitForVueUpdate() + + expect(state.value.size).toBe(2) + expect(state.value.get(`1`)).toMatchObject({ + id: `1`, + name: `John Doe`, + team: `team1`, + }) + expect(state.value.get(`3`)).toMatchObject({ + id: `3`, + name: `John Smith`, + team: `team1`, + }) + + // Insert a new person that matches the query + collection.utils.begin() + collection.utils.write({ + type: `insert`, + value: { + id: `6`, + name: `Emma Wilson`, + age: 28, + email: `emma.wilson@example.com`, + isActive: true, + team: `team2`, + }, + }) + collection.utils.commit() + + await waitForVueUpdate() + + expect(state.value.size).toBe(3) + expect(state.value.get(`6`)).toMatchObject({ + id: `6`, + name: `Emma Wilson`, + team: `team2`, + }) + + // Update a person to no longer match the query (age <= 25) + collection.utils.begin() + collection.utils.write({ + type: `update`, + value: { + id: `6`, + name: `Emma Wilson`, + age: 24, // Now age <= 25, should be filtered out + email: `emma.wilson@example.com`, + isActive: true, + team: `team2`, + }, + }) + collection.utils.commit() + + await waitForVueUpdate() + + expect(state.value.size).toBe(2) // Back to just John Doe and John Smith + expect(state.value.get(`6`)).toBeUndefined() + + // Delete a person + collection.utils.begin() + collection.utils.write({ + type: `delete`, + value: { + id: `1`, + name: `John Doe`, + age: 30, + email: `john.doe@example.com`, + isActive: true, + team: `team1`, + }, + }) + collection.utils.commit() + + await waitForVueUpdate() + + expect(state.value.size).toBe(1) // Only John Smith left + expect(state.value.get(`1`)).toBeUndefined() + expect(state.value.get(`3`)).toMatchObject({ + id: `3`, + name: `John Smith`, + team: `team1`, + }) + }) + + it(`should handle reactive QueryBuilder changes`, async () => { + const collection1 = createCollection( + mockSyncCollectionOptions({ + id: `querybuilder-deps-test-1-vue`, + getKey: (person: Person) => person.id, + initialData: initialPersons, + }) + ) + + const collection2 = createCollection( + mockSyncCollectionOptions({ + id: `querybuilder-deps-test-2-vue`, + getKey: (person: Person) => person.id, + initialData: [ + { + id: `7`, + name: `David Johnson`, + age: 42, + email: `david.johnson@example.com`, + isActive: true, + team: `team3`, + }, + ], + }) + ) + + // Create separate queries for each collection + const query1 = defineQuery((q) => + q + .from({ persons: collection1 }) + .where(({ persons }) => gt(persons.age, 30)) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + })) + ) + + const query2 = defineQuery((q) => + q + .from({ persons: collection2 }) + .where(({ persons }) => gt(persons.age, 30)) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + })) + ) + + // Use a reactive ref for the QueryBuilder + const currentQuery = ref(query1) + const { state } = useLiveQuery(currentQuery) + + // Wait for first collection to sync - should have John Smith + await waitForVueUpdate() + + expect(state.value.size).toBe(1) + expect(state.value.get(`3`)).toMatchObject({ + id: `3`, + name: `John Smith`, + }) + + // Switch to second query/collection by updating the reactive ref + currentQuery.value = query2 + + // Wait for the reactive change to propagate + await waitForVueUpdate() + + expect(state.value.size).toBe(1) + expect(state.value.get(`7`)).toMatchObject({ + id: `7`, + name: `David Johnson`, + }) + + // Verify we no longer have data from the first collection + expect(state.value.get(`3`)).toBeUndefined() + }) })