diff --git a/.changeset/wicked-grapes-thank.md b/.changeset/wicked-grapes-thank.md new file mode 100644 index 000000000..9dfbc4806 --- /dev/null +++ b/.changeset/wicked-grapes-thank.md @@ -0,0 +1,7 @@ +--- +"@tanstack/react-db": patch +"@tanstack/vue-db": patch +"@tanstack/db": patch +--- + +add support for composable queries diff --git a/packages/db/src/query/builder/ref-proxy.ts b/packages/db/src/query/builder/ref-proxy.ts index 21f643325..87300d233 100644 --- a/packages/db/src/query/builder/ref-proxy.ts +++ b/packages/db/src/query/builder/ref-proxy.ts @@ -1,4 +1,4 @@ -import { Ref, Value } from "../ir.js" +import { PropRef, Value } from "../ir.js" import type { BasicExpression } from "../ir.js" export interface RefProxy { @@ -124,7 +124,7 @@ export function toExpression(value: T): BasicExpression export function toExpression(value: RefProxy): BasicExpression export function toExpression(value: any): BasicExpression { if (isRefProxy(value)) { - return new Ref(value.__path) + return new PropRef(value.__path) } // If it's already an Expression (Func, Ref, Value) or Agg, return it directly if ( diff --git a/packages/db/src/query/builder/types.ts b/packages/db/src/query/builder/types.ts index a910f8280..f0da61672 100644 --- a/packages/db/src/query/builder/types.ts +++ b/packages/db/src/query/builder/types.ts @@ -139,6 +139,10 @@ export type RefProxyFor = OmitRefProxy< : RefProxy > +// This is the public type that is exported from the query builder +// and is used when constructing reusable query callbacks. +export type Ref = RefProxyFor + type OmitRefProxy = Omit // The core RefProxy interface diff --git a/packages/db/src/query/compiler/evaluators.ts b/packages/db/src/query/compiler/evaluators.ts index e9ab80e4c..d2fcf2ab9 100644 --- a/packages/db/src/query/compiler/evaluators.ts +++ b/packages/db/src/query/compiler/evaluators.ts @@ -1,4 +1,4 @@ -import type { BasicExpression, Func, Ref } from "../ir.js" +import type { BasicExpression, Func, PropRef } from "../ir.js" import type { NamespacedRow } from "../../types.js" /** @@ -36,7 +36,7 @@ export function compileExpression(expr: BasicExpression): CompiledExpression { /** * Compiles a reference expression into an optimized evaluator */ -function compileRef(ref: Ref): CompiledExpression { +function compileRef(ref: PropRef): CompiledExpression { const [tableAlias, ...propertyPath] = ref.path if (!tableAlias) { diff --git a/packages/db/src/query/compiler/group-by.ts b/packages/db/src/query/compiler/group-by.ts index c7d53b62e..9ad893608 100644 --- a/packages/db/src/query/compiler/group-by.ts +++ b/packages/db/src/query/compiler/group-by.ts @@ -1,5 +1,5 @@ import { filter, groupBy, groupByOperators, map } from "@electric-sql/d2mini" -import { Func, Ref } from "../ir.js" +import { Func, PropRef } from "../ir.js" import { compileExpression } from "./evaluators.js" import type { Aggregate, @@ -372,7 +372,7 @@ function transformHavingClause( for (const [alias, selectExpr] of Object.entries(selectClause)) { if (selectExpr.type === `agg` && aggregatesEqual(aggExpr, selectExpr)) { // Replace with a reference to the computed aggregate - return new Ref([`result`, alias]) + return new PropRef([`result`, alias]) } } // If no matching aggregate found in SELECT, throw error @@ -398,7 +398,7 @@ function transformHavingClause( const alias = refExpr.path[0]! if (selectClause[alias]) { // This is a reference to a SELECT alias, convert to result.alias - return new Ref([`result`, alias]) + return new PropRef([`result`, alias]) } } // Return as-is for other refs diff --git a/packages/db/src/query/index.ts b/packages/db/src/query/index.ts index 61c3d4c7f..20043a04a 100644 --- a/packages/db/src/query/index.ts +++ b/packages/db/src/query/index.ts @@ -41,17 +41,7 @@ export { } from "./builder/functions.js" // Ref proxy utilities -export { val, toExpression, isRefProxy } from "./builder/ref-proxy.js" - -// IR types (for advanced usage) -export type { - QueryIR, - BasicExpression as Expression, - Aggregate, - CollectionRef, - QueryRef, - JoinClause, -} from "./ir.js" +export type { Ref } from "./builder/types.js" // Compiler export { compileQuery } from "./compiler/index.js" diff --git a/packages/db/src/query/ir.ts b/packages/db/src/query/ir.ts index 8a96b3adb..a795cd188 100644 --- a/packages/db/src/query/ir.ts +++ b/packages/db/src/query/ir.ts @@ -84,7 +84,7 @@ export class QueryRef extends BaseExpression { } } -export class Ref extends BaseExpression { +export class PropRef extends BaseExpression { public type = `ref` as const constructor( public path: Array // path to the property in the collection, with the alias as the first element @@ -115,7 +115,7 @@ export class Func extends BaseExpression { // This is the basic expression type that is used in the majority of expression // builder callbacks (select, where, groupBy, having, orderBy, etc.) // it doesn't include aggregate functions as those are only used in the select clause -export type BasicExpression = Ref | Value | Func +export type BasicExpression = PropRef | Value | Func export class Aggregate extends BaseExpression { public type = `agg` as const diff --git a/packages/db/src/query/live-query-collection.ts b/packages/db/src/query/live-query-collection.ts index 2321e6ca8..fa31cde7c 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 { buildQuery, getQueryIR } from "./builder/index.js" import type { InitialQueryBuilder, QueryBuilder } from "./builder/index.js" import type { Collection } from "../collection.js" import type { @@ -55,7 +55,9 @@ export interface LiveQueryCollectionConfig< /** * Query builder function that defines the live query */ - query: (q: InitialQueryBuilder) => QueryBuilder + query: + | ((q: InitialQueryBuilder) => QueryBuilder) + | QueryBuilder /** * Function to extract the key from result items @@ -119,8 +121,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 instance + const query = + typeof config.query === `function` + ? buildQuery(config.query) + : getQueryIR(config.query) // WeakMap to store the keys of the results so that we can retreve them in the // getKey function @@ -401,11 +406,11 @@ export function createLiveQueryCollection< if (typeof configOrQuery === `function`) { // Simple query function case const config: LiveQueryCollectionConfig = { - query: configOrQuery, + query: configOrQuery as ( + q: InitialQueryBuilder + ) => QueryBuilder, } const options = liveQueryCollectionOptions(config) - - // Use a bridge function that handles the type compatibility cleanly return bridgeToCreateCollection(options) } else { // Config object case @@ -414,8 +419,6 @@ export function createLiveQueryCollection< TResult > & { utils?: TUtils } const options = liveQueryCollectionOptions(config) - - // Use a bridge function that handles the type compatibility cleanly return bridgeToCreateCollection({ ...options, utils: config.utils, diff --git a/packages/db/tests/query/builder/ref-proxy.test.ts b/packages/db/tests/query/builder/ref-proxy.test.ts index 9038891af..60051a428 100644 --- a/packages/db/tests/query/builder/ref-proxy.test.ts +++ b/packages/db/tests/query/builder/ref-proxy.test.ts @@ -5,7 +5,7 @@ import { toExpression, val, } from "../../../src/query/builder/ref-proxy.js" -import { Ref, Value } from "../../../src/query/ir.js" +import { PropRef, Value } from "../../../src/query/ir.js" describe(`ref-proxy`, () => { describe(`createRefProxy`, () => { @@ -170,9 +170,9 @@ describe(`ref-proxy`, () => { const userIdProxy = proxy.users.id const expr = toExpression(userIdProxy) - expect(expr).toBeInstanceOf(Ref) + expect(expr).toBeInstanceOf(PropRef) expect(expr.type).toBe(`ref`) - expect((expr as Ref).path).toEqual([`users`, `id`]) + expect((expr as PropRef).path).toEqual([`users`, `id`]) }) it(`converts literal values to Value expression`, () => { @@ -183,7 +183,7 @@ describe(`ref-proxy`, () => { }) it(`returns existing expressions unchanged`, () => { - const refExpr = new Ref([`users`, `id`]) + const refExpr = new PropRef([`users`, `id`]) const valExpr = new Value(42) expect(toExpression(refExpr)).toBe(refExpr) diff --git a/packages/db/tests/query/compiler/basic.test.ts b/packages/db/tests/query/compiler/basic.test.ts index eeae7e050..575a516be 100644 --- a/packages/db/tests/query/compiler/basic.test.ts +++ b/packages/db/tests/query/compiler/basic.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "vitest" import { D2, MultiSet, output } from "@electric-sql/d2mini" import { compileQuery } from "../../../src/query/compiler/index.js" -import { CollectionRef, Func, Ref, Value } from "../../../src/query/ir.js" +import { CollectionRef, Func, PropRef, Value } from "../../../src/query/ir.js" import type { QueryIR } from "../../../src/query/ir.js" import type { CollectionImpl } from "../../../src/collection.js" @@ -82,9 +82,9 @@ describe(`Query2 Compiler`, () => { const query: QueryIR = { from: new CollectionRef(usersCollection, `users`), select: { - id: new Ref([`users`, `id`]), - name: new Ref([`users`, `name`]), - age: new Ref([`users`, `age`]), + id: new PropRef([`users`, `id`]), + name: new PropRef([`users`, `name`]), + age: new PropRef([`users`, `age`]), }, } @@ -150,11 +150,11 @@ describe(`Query2 Compiler`, () => { const query: QueryIR = { from: new CollectionRef(usersCollection, `users`), select: { - id: new Ref([`users`, `id`]), - name: new Ref([`users`, `name`]), - age: new Ref([`users`, `age`]), + id: new PropRef([`users`, `id`]), + name: new PropRef([`users`, `name`]), + age: new PropRef([`users`, `age`]), }, - where: [new Func(`gt`, [new Ref([`users`, `age`]), new Value(20)])], + where: [new Func(`gt`, [new PropRef([`users`, `age`]), new Value(20)])], } const graph = new D2() @@ -203,13 +203,13 @@ describe(`Query2 Compiler`, () => { const query: QueryIR = { from: new CollectionRef(usersCollection, `users`), select: { - id: new Ref([`users`, `id`]), - name: new Ref([`users`, `name`]), + id: new PropRef([`users`, `id`]), + name: new PropRef([`users`, `name`]), }, where: [ new Func(`and`, [ - new Func(`gt`, [new Ref([`users`, `age`]), new Value(20)]), - new Func(`eq`, [new Ref([`users`, `active`]), new Value(true)]), + new Func(`gt`, [new PropRef([`users`, `age`]), new Value(20)]), + new Func(`eq`, [new PropRef([`users`, `active`]), new Value(true)]), ]), ], } diff --git a/packages/db/tests/query/compiler/evaluators.test.ts b/packages/db/tests/query/compiler/evaluators.test.ts index 0dfd3da29..1cb987321 100644 --- a/packages/db/tests/query/compiler/evaluators.test.ts +++ b/packages/db/tests/query/compiler/evaluators.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest" import { compileExpression } from "../../../src/query/compiler/evaluators.js" -import { Func, Ref, Value } from "../../../src/query/ir.js" +import { Func, PropRef, Value } from "../../../src/query/ir.js" import type { NamespacedRow } from "../../../src/types.js" describe(`evaluators`, () => { @@ -14,14 +14,14 @@ describe(`evaluators`, () => { describe(`ref compilation`, () => { it(`throws error for empty reference path`, () => { - const emptyRef = new Ref([]) + const emptyRef = new PropRef([]) expect(() => compileExpression(emptyRef)).toThrow( `Reference path cannot be empty` ) }) it(`handles simple table reference`, () => { - const ref = new Ref([`users`]) + const ref = new PropRef([`users`]) const compiled = compileExpression(ref) const row: NamespacedRow = { users: { id: 1, name: `John` } } @@ -29,7 +29,7 @@ describe(`evaluators`, () => { }) it(`handles single property access`, () => { - const ref = new Ref([`users`, `name`]) + const ref = new PropRef([`users`, `name`]) const compiled = compileExpression(ref) const row: NamespacedRow = { users: { id: 1, name: `John` } } @@ -37,7 +37,7 @@ describe(`evaluators`, () => { }) it(`handles single property access with undefined table`, () => { - const ref = new Ref([`users`, `name`]) + const ref = new PropRef([`users`, `name`]) const compiled = compileExpression(ref) const row: NamespacedRow = { users: undefined as any } @@ -45,7 +45,7 @@ describe(`evaluators`, () => { }) it(`handles multiple property navigation`, () => { - const ref = new Ref([`users`, `profile`, `bio`]) + const ref = new PropRef([`users`, `profile`, `bio`]) const compiled = compileExpression(ref) const row: NamespacedRow = { users: { profile: { bio: `Hello world` } }, @@ -55,7 +55,7 @@ describe(`evaluators`, () => { }) it(`handles multiple property navigation with null value`, () => { - const ref = new Ref([`users`, `profile`, `bio`]) + const ref = new PropRef([`users`, `profile`, `bio`]) const compiled = compileExpression(ref) const row: NamespacedRow = { users: { profile: null } } @@ -63,7 +63,7 @@ describe(`evaluators`, () => { }) it(`handles multiple property navigation with undefined table`, () => { - const ref = new Ref([`users`, `profile`, `bio`]) + const ref = new PropRef([`users`, `profile`, `bio`]) const compiled = compileExpression(ref) const row: NamespacedRow = { users: undefined as any } diff --git a/packages/db/tests/query/compiler/group-by.test.ts b/packages/db/tests/query/compiler/group-by.test.ts index 2288431f4..56ac3611d 100644 --- a/packages/db/tests/query/compiler/group-by.test.ts +++ b/packages/db/tests/query/compiler/group-by.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest" -import { Aggregate, Func, Ref, Value } from "../../../src/query/ir.js" +import { Aggregate, Func, PropRef, Value } from "../../../src/query/ir.js" // Import the validation function that we want to test directly // Since we can't easily mock the D2 streams, we'll test the validation logic separately @@ -56,10 +56,10 @@ describe(`group-by compiler`, () => { describe(`validation logic`, () => { describe(`validation errors`, () => { it(`throws error when non-aggregate SELECT expression is not in GROUP BY`, () => { - const groupByClause = [new Ref([`users`, `department`])] + const groupByClause = [new PropRef([`users`, `department`])] const selectClause = { - department: new Ref([`users`, `department`]), - invalidField: new Ref([`users`, `name`]), // This is not in GROUP BY + department: new PropRef([`users`, `department`]), + invalidField: new PropRef([`users`, `name`]), // This is not in GROUP BY } expect(() => { @@ -70,11 +70,11 @@ describe(`group-by compiler`, () => { }) it(`allows aggregate expressions in SELECT without GROUP BY requirement`, () => { - const groupByClause = [new Ref([`users`, `department`])] + const groupByClause = [new PropRef([`users`, `department`])] const selectClause = { - department: new Ref([`users`, `department`]), - count: new Aggregate(`count`, [new Ref([`users`, `id`])]), - avg_salary: new Aggregate(`avg`, [new Ref([`users`, `salary`])]), + department: new PropRef([`users`, `department`]), + count: new Aggregate(`count`, [new PropRef([`users`, `id`])]), + avg_salary: new Aggregate(`avg`, [new PropRef([`users`, `salary`])]), } // Should not throw @@ -86,15 +86,15 @@ describe(`group-by compiler`, () => { describe(`expression equality`, () => { it(`correctly identifies equal ref expressions`, () => { - const expr1 = new Ref([`users`, `department`]) - const expr2 = new Ref([`users`, `department`]) + const expr1 = new PropRef([`users`, `department`]) + const expr2 = new PropRef([`users`, `department`]) expect(expressionsEqual(expr1, expr2)).toBe(true) }) it(`correctly identifies different ref expressions`, () => { - const expr1 = new Ref([`users`, `department`]) - const expr2 = new Ref([`users`, `name`]) + const expr1 = new PropRef([`users`, `department`]) + const expr2 = new PropRef([`users`, `name`]) expect(expressionsEqual(expr1, expr2)).toBe(false) }) @@ -114,21 +114,21 @@ describe(`group-by compiler`, () => { }) it(`correctly identifies equal function expressions`, () => { - const expr1 = new Func(`upper`, [new Ref([`users`, `name`])]) - const expr2 = new Func(`upper`, [new Ref([`users`, `name`])]) + const expr1 = new Func(`upper`, [new PropRef([`users`, `name`])]) + const expr2 = new Func(`upper`, [new PropRef([`users`, `name`])]) expect(expressionsEqual(expr1, expr2)).toBe(true) }) it(`correctly identifies different function expressions`, () => { - const expr1 = new Func(`upper`, [new Ref([`users`, `name`])]) - const expr2 = new Func(`lower`, [new Ref([`users`, `name`])]) + const expr1 = new Func(`upper`, [new PropRef([`users`, `name`])]) + const expr2 = new Func(`lower`, [new PropRef([`users`, `name`])]) expect(expressionsEqual(expr1, expr2)).toBe(false) }) it(`correctly identifies expressions of different types as not equal`, () => { - const expr1 = new Ref([`users`, `name`]) + const expr1 = new PropRef([`users`, `name`]) const expr2 = new Value(`name`) expect(expressionsEqual(expr1, expr2)).toBe(false) diff --git a/packages/db/tests/query/compiler/select.test.ts b/packages/db/tests/query/compiler/select.test.ts index f68602c60..ab60c7d2a 100644 --- a/packages/db/tests/query/compiler/select.test.ts +++ b/packages/db/tests/query/compiler/select.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest" import { processArgument } from "../../../src/query/compiler/select.js" -import { Aggregate, Func, Ref, Value } from "../../../src/query/ir.js" +import { Aggregate, Func, PropRef, Value } from "../../../src/query/ir.js" describe(`select compiler`, () => { // Note: Most of the select compilation logic is tested through the full integration @@ -9,7 +9,7 @@ describe(`select compiler`, () => { describe(`processArgument`, () => { it(`processes non-aggregate expressions correctly`, () => { - const arg = new Ref([`users`, `name`]) + const arg = new PropRef([`users`, `name`]) const namespacedRow = { users: { name: `John` } } const result = processArgument(arg, namespacedRow) @@ -33,7 +33,7 @@ describe(`select compiler`, () => { }) it(`throws error for aggregate expressions`, () => { - const arg = new Aggregate(`count`, [new Ref([`users`, `id`])]) + const arg = new Aggregate(`count`, [new PropRef([`users`, `id`])]) const namespacedRow = { users: { id: 1 } } expect(() => { @@ -44,7 +44,7 @@ describe(`select compiler`, () => { }) it(`processes reference expressions from different tables`, () => { - const arg = new Ref([`orders`, `amount`]) + const arg = new PropRef([`orders`, `amount`]) const namespacedRow = { users: { name: `John` }, orders: { amount: 100.5 }, @@ -55,7 +55,7 @@ describe(`select compiler`, () => { }) it(`processes nested reference expressions`, () => { - const arg = new Ref([`profile`, `address`, `city`]) + const arg = new PropRef([`profile`, `address`, `city`]) const namespacedRow = { profile: { address: { @@ -69,7 +69,7 @@ describe(`select compiler`, () => { }) it(`processes function expressions with references`, () => { - const arg = new Func(`length`, [new Ref([`users`, `name`])]) + const arg = new Func(`length`, [new PropRef([`users`, `name`])]) const namespacedRow = { users: { name: `Alice` } } const result = processArgument(arg, namespacedRow) @@ -78,9 +78,9 @@ describe(`select compiler`, () => { it(`processes function expressions with multiple arguments`, () => { const arg = new Func(`concat`, [ - new Ref([`users`, `firstName`]), + new PropRef([`users`, `firstName`]), new Value(` `), - new Ref([`users`, `lastName`]), + new PropRef([`users`, `lastName`]), ]) const namespacedRow = { users: { @@ -94,7 +94,7 @@ describe(`select compiler`, () => { }) it(`handles null and undefined values in references`, () => { - const arg = new Ref([`users`, `middleName`]) + const arg = new PropRef([`users`, `middleName`]) const namespacedRow = { users: { name: `John`, middleName: null } } const result = processArgument(arg, namespacedRow) @@ -102,7 +102,7 @@ describe(`select compiler`, () => { }) it(`handles missing table references`, () => { - const arg = new Ref([`nonexistent`, `field`]) + const arg = new PropRef([`nonexistent`, `field`]) const namespacedRow = { users: { name: `John` } } const result = processArgument(arg, namespacedRow) @@ -110,7 +110,7 @@ describe(`select compiler`, () => { }) it(`handles missing field references`, () => { - const arg = new Ref([`users`, `nonexistent`]) + const arg = new PropRef([`users`, `nonexistent`]) const namespacedRow = { users: { name: `John` } } const result = processArgument(arg, namespacedRow) @@ -134,7 +134,7 @@ describe(`select compiler`, () => { }) it(`processes comparison function expressions`, () => { - const arg = new Func(`gt`, [new Ref([`users`, `age`]), new Value(18)]) + const arg = new Func(`gt`, [new PropRef([`users`, `age`]), new Value(18)]) const namespacedRow = { users: { age: 25 } } const result = processArgument(arg, namespacedRow) @@ -143,8 +143,8 @@ describe(`select compiler`, () => { it(`processes mathematical function expressions`, () => { const arg = new Func(`add`, [ - new Ref([`order`, `subtotal`]), - new Ref([`order`, `tax`]), + new PropRef([`order`, `subtotal`]), + new PropRef([`order`, `tax`]), ]) const namespacedRow = { order: { @@ -166,11 +166,11 @@ describe(`select compiler`, () => { // through the processArgument function's error handling. const aggregateExpressions = [ - new Aggregate(`count`, [new Ref([`users`, `id`])]), - new Aggregate(`sum`, [new Ref([`orders`, `amount`])]), - new Aggregate(`avg`, [new Ref([`products`, `price`])]), - new Aggregate(`min`, [new Ref([`dates`, `created`])]), - new Aggregate(`max`, [new Ref([`dates`, `updated`])]), + new Aggregate(`count`, [new PropRef([`users`, `id`])]), + new Aggregate(`sum`, [new PropRef([`orders`, `amount`])]), + new Aggregate(`avg`, [new PropRef([`products`, `price`])]), + new Aggregate(`min`, [new PropRef([`dates`, `created`])]), + new Aggregate(`max`, [new PropRef([`dates`, `updated`])]), ] const namespacedRow = { @@ -190,10 +190,10 @@ describe(`select compiler`, () => { it(`correctly identifies non-aggregate expressions`, () => { const nonAggregateExpressions = [ - new Ref([`users`, `name`]), + new PropRef([`users`, `name`]), new Value(42), new Func(`upper`, [new Value(`hello`)]), - new Func(`length`, [new Ref([`users`, `name`])]), + new Func(`length`, [new PropRef([`users`, `name`])]), ] const namespacedRow = { users: { name: `John` } } diff --git a/packages/db/tests/query/compiler/subquery-caching.test.ts b/packages/db/tests/query/compiler/subquery-caching.test.ts index 4f549250b..f07669d16 100644 --- a/packages/db/tests/query/compiler/subquery-caching.test.ts +++ b/packages/db/tests/query/compiler/subquery-caching.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest" import { D2 } from "@electric-sql/d2mini" import { compileQuery } from "../../../src/query/compiler/index.js" -import { CollectionRef, QueryRef, Ref } from "../../../src/query/ir.js" +import { CollectionRef, PropRef, QueryRef } from "../../../src/query/ir.js" import type { QueryIR } from "../../../src/query/ir.js" import type { CollectionImpl } from "../../../src/collection.js" @@ -16,8 +16,8 @@ describe(`Subquery Caching`, () => { const subquery: QueryIR = { from: new CollectionRef(usersCollection, `u`), select: { - id: new Ref([`u`, `id`]), - name: new Ref([`u`, `name`]), + id: new PropRef([`u`, `id`]), + name: new PropRef([`u`, `name`]), }, } @@ -28,13 +28,13 @@ describe(`Subquery Caching`, () => { { type: `inner`, from: new QueryRef(subquery, `joined_users`), // Same subquery object reference - left: new Ref([`main_users`, `id`]), - right: new Ref([`joined_users`, `id`]), + left: new PropRef([`main_users`, `id`]), + right: new PropRef([`joined_users`, `id`]), }, ], select: { - mainId: new Ref([`main_users`, `id`]), - joinedId: new Ref([`joined_users`, `id`]), + mainId: new PropRef([`main_users`, `id`]), + joinedId: new PropRef([`joined_users`, `id`]), }, } @@ -90,8 +90,8 @@ describe(`Subquery Caching`, () => { const subquery: QueryIR = { from: new CollectionRef(usersCollection, `u`), select: { - id: new Ref([`u`, `id`]), - name: new Ref([`u`, `name`]), + id: new PropRef([`u`, `id`]), + name: new PropRef([`u`, `name`]), }, } @@ -120,16 +120,16 @@ describe(`Subquery Caching`, () => { const subquery1: QueryIR = { from: new CollectionRef(usersCollection, `u`), select: { - id: new Ref([`u`, `id`]), - name: new Ref([`u`, `name`]), + id: new PropRef([`u`, `id`]), + name: new PropRef([`u`, `name`]), }, } const subquery: QueryIR = { from: new CollectionRef(usersCollection, `u`), select: { - id: new Ref([`u`, `id`]), - name: new Ref([`u`, `name`]), + id: new PropRef([`u`, `id`]), + name: new PropRef([`u`, `name`]), }, } @@ -163,7 +163,7 @@ describe(`Subquery Caching`, () => { const innerSubquery: QueryIR = { from: new CollectionRef(usersCollection, `u`), select: { - id: new Ref([`u`, `id`]), + id: new PropRef([`u`, `id`]), }, } @@ -173,8 +173,8 @@ describe(`Subquery Caching`, () => { { type: `left`, from: new QueryRef(innerSubquery, `inner2`), // Same innerSubquery - left: new Ref([`inner1`, `id`]), - right: new Ref([`inner2`, `id`]), + left: new PropRef([`inner1`, `id`]), + right: new PropRef([`inner2`, `id`]), }, ], } @@ -185,8 +185,8 @@ describe(`Subquery Caching`, () => { { type: `inner`, from: new QueryRef(innerSubquery, `direct`), // innerSubquery again at top level - left: new Ref([`middle`, `id`]), - right: new Ref([`direct`, `id`]), + left: new PropRef([`middle`, `id`]), + right: new PropRef([`direct`, `id`]), }, ], } diff --git a/packages/db/tests/query/composables.test.ts b/packages/db/tests/query/composables.test.ts new file mode 100644 index 000000000..674a1a434 --- /dev/null +++ b/packages/db/tests/query/composables.test.ts @@ -0,0 +1,579 @@ +import { beforeEach, describe, expect, test } from "vitest" +import { + Query, + and, + createLiveQueryCollection, + eq, + gt, + lower, + lt, + lte, + upper, +} from "../../src/query/index.js" +import { createCollection } from "../../src/collection.js" +import { mockSyncCollectionOptions } from "../utls.js" +import type { Ref } from "../../src/query/index.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 = ({ user }: { user: Ref }) => gt(user.age, 18) + + const userIsActive = ({ user }: { user: Ref }) => + eq(user.active, true) + + const userIsYoung = ({ user }: { user: Ref }) => 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 = ({ user }: { user: Ref }) => ({ + id: user.id, + name: user.name, + email: user.email, + }) + + const userNameTransforms = ({ user }: { user: Ref }) => ({ + 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 = ({ user }: { user: Ref }) => gt(user.age, 20) + + const userDisplayInfo = ({ user }: { user: Ref }) => ({ + userId: user.id, + displayName: upper(user.name), + contactEmail: user.email, + }) + + // Create a reusable query using defineQuery that uses the components + const adultUsersQuery = new Query() + .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 = new Query() + .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 = ({ user }: { user: Ref }) => + eq(user.active, true) + + const postIsPublished = ({ post }: { post: Ref }) => + eq(post.published, true) + + const userPostJoinInfo = ({ + user, + post, + }: { + user: Ref + post: Ref + }) => ({ + 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 = ({ user }: { user: Ref }) => + eq(user.active, true) + + const userIsAdult = ({ user }: { user: Ref }) => gt(user.age, 20) + + const userIsYoung = ({ user }: { user: Ref }) => 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 = ({ user }: { user: Ref }) => + eq(user.active, true) + + const userIsJunior = ({ user }: { user: Ref }) => lte(user.age, 25) + + const userBasicWithAge = ({ user }: { user: Ref }) => ({ + id: user.id, + name: user.name, + age: user.age, + }) + + // Create a base query using defineQuery and defineForRow + const activeJuniorUsersQuery = new Query() + .from({ user: usersCollection }) + .where(userIsActive) + .where(userIsJunior) + .select(userBasicWithAge) + + // Use the predefined query as a subquery with defineForRow components + const enhancedJuniorUsersQuery = new Query() + .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 = ({ user }: { user: Ref }) => + gt(user.age, 20) + + const joinedUserPostPredicate = ({ + u, + p, + }: { + u: Ref + p: Ref + }) => and(eq(u.active, true), eq(p.published, true)) + + const singleUserSelect = ({ user }: { user: Ref }) => ({ + name: user.name, + age: user.age, + }) + + const joinedSelect = ({ u, p }: { u: Ref; p: Ref }) => ({ + 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 = new Query() + .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 = new Query() + .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/live-query-collection.test.ts b/packages/db/tests/query/live-query-collection.test.ts new file mode 100644 index 000000000..8492736f7 --- /dev/null +++ b/packages/db/tests/query/live-query-collection.test.ts @@ -0,0 +1,91 @@ +import { beforeEach, describe, expect, it } from "vitest" +import { createCollection } from "../../src/collection.js" +import { createLiveQueryCollection, eq } from "../../src/query/index.js" +import { Query } from "../../src/query/builder/index.js" +import { mockSyncCollectionOptions } from "../utls.js" + +// Sample user type for tests +type User = { + id: number + name: string + active: boolean +} + +// Sample data for tests +const sampleUsers: Array = [ + { id: 1, name: `Alice`, active: true }, + { id: 2, name: `Bob`, active: true }, + { id: 3, name: `Charlie`, active: false }, +] + +function createUsersCollection() { + return createCollection( + mockSyncCollectionOptions({ + id: `test-users`, + getKey: (user) => user.id, + initialData: sampleUsers, + }) + ) +} + +describe(`createLiveQueryCollection`, () => { + let usersCollection: ReturnType + + beforeEach(() => { + usersCollection = createUsersCollection() + }) + + it(`should accept a callback function`, async () => { + const activeUsers = createLiveQueryCollection((q) => + q + .from({ user: usersCollection }) + .where(({ user }) => eq(user.active, true)) + ) + + await activeUsers.preload() + + expect(activeUsers).toBeDefined() + expect(activeUsers.size).toBe(2) // Only Alice and Bob are active + }) + + it(`should accept a QueryBuilder instance via config object`, async () => { + const queryBuilder = new Query() + .from({ user: usersCollection }) + .where(({ user }) => eq(user.active, true)) + + const activeUsers = createLiveQueryCollection({ + query: queryBuilder, + }) + + await activeUsers.preload() + + expect(activeUsers).toBeDefined() + expect(activeUsers.size).toBe(2) // Only Alice and Bob are active + }) + + it(`should work with both callback and QueryBuilder instance via config`, async () => { + // Test with callback + const activeUsers1 = createLiveQueryCollection((q) => + q + .from({ user: usersCollection }) + .where(({ user }) => eq(user.active, true)) + ) + + // Test with QueryBuilder instance via config + const queryBuilder = new Query() + .from({ user: usersCollection }) + .where(({ user }) => eq(user.active, true)) + + const activeUsers2 = createLiveQueryCollection({ + query: queryBuilder, + }) + + await activeUsers1.preload() + await activeUsers2.preload() + + expect(activeUsers1).toBeDefined() + expect(activeUsers2).toBeDefined() + expect(activeUsers1.size).toBe(2) + expect(activeUsers2.size).toBe(2) + }) +}) diff --git a/packages/react-db/tests/useLiveQuery.test.tsx b/packages/react-db/tests/useLiveQuery.test.tsx index 783b01417..04e75b2c7 100644 --- a/packages/react-db/tests/useLiveQuery.test.tsx +++ b/packages/react-db/tests/useLiveQuery.test.tsx @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest" import { act, renderHook, waitFor } from "@testing-library/react" import { + Query, count, createCollection, createLiveQueryCollection, @@ -928,4 +929,41 @@ describe(`Query Collections`, () => { // Verify we no longer have data from the first collection expect(result.current.state.get(`3`)).toBeUndefined() }) + + it(`should accept a config object with a pre-built QueryBuilder instance`, async () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `test-persons-config-querybuilder`, + getKey: (person: Person) => person.id, + initialData: initialPersons, + }) + ) + + // Create a QueryBuilder instance beforehand + const queryBuilder = new Query() + .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({ query: queryBuilder }) + }) + + // 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, + }) + }) }) diff --git a/packages/vue-db/tests/useLiveQuery.test.ts b/packages/vue-db/tests/useLiveQuery.test.ts index 47d757fb7..4e06ea1fe 100644 --- a/packages/vue-db/tests/useLiveQuery.test.ts +++ b/packages/vue-db/tests/useLiveQuery.test.ts @@ -819,4 +819,42 @@ describe(`Query Collections`, () => { // Verify we no longer have data from the first collection expect(state.value.get(`3`)).toBeUndefined() }) + + it(`should accept config object with pre-built QueryBuilder instance`, async () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `config-querybuilder-test-vue`, + getKey: (person: Person) => person.id, + initialData: initialPersons, + }) + ) + + // Create a pre-built QueryBuilder instance + const { Query } = await import(`@tanstack/db`) + const queryBuilder = new Query() + .from({ persons: collection }) + .where(({ persons }) => gt(persons.age, 30)) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + age: persons.age, + })) + + const { state, data } = useLiveQuery({ + query: queryBuilder, + }) + + // 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, + }) + }) })