diff --git a/.changeset/weak-colts-walk.md b/.changeset/weak-colts-walk.md new file mode 100644 index 000000000..1f8d2ca9b --- /dev/null +++ b/.changeset/weak-colts-walk.md @@ -0,0 +1,5 @@ +--- +'@tanstack/db': patch +--- + +Introduce $selected namespace for accessing fields from SELECT clause inside ORDER BY and HAVING clauses. diff --git a/docs/guides/live-queries.md b/docs/guides/live-queries.md index 409b87cac..b69689e16 100644 --- a/docs/guides/live-queries.md +++ b/docs/guides/live-queries.md @@ -1108,10 +1108,19 @@ having( ``` **Parameters:** -- `condition` - A callback function that receives the aggregated row object and returns a boolean expression +- `condition` - A callback function that receives table references (and `$selected` if the query contains a `select()` clause) and returns a boolean expression ```ts +// Using aggregate functions directly const highValueCustomers = createLiveQueryCollection((q) => + q + .from({ order: ordersCollection }) + .groupBy(({ order }) => order.customerId) + .having(({ order }) => gt(sum(order.amount), 1000)) +) + +// Using SELECT fields via $selected (recommended when select() is used) +const highValueCustomersWithSelect = createLiveQueryCollection((q) => q .from({ order: ordersCollection }) .groupBy(({ order }) => order.customerId) @@ -1120,7 +1129,7 @@ const highValueCustomers = createLiveQueryCollection((q) => totalSpent: sum(order.amount), orderCount: count(order.id), })) - .having(({ order }) => gt(sum(order.amount), 1000)) + .having(({ $selected }) => gt($selected.totalSpent, 1000)) ) ``` @@ -1388,6 +1397,26 @@ const sortedUsers = createLiveQueryCollection((q) => ) ``` +### Ordering by SELECT Fields + +When you use `select()` with aggregates or computed values, you can order by those fields using the `$selected` namespace: + +```ts +const topCustomers = createLiveQueryCollection((q) => + q + .from({ order: ordersCollection }) + .groupBy(({ order }) => order.customerId) + .select(({ order }) => ({ + customerId: order.customerId, + totalSpent: sum(order.amount), + orderCount: count(order.id), + latestOrder: max(order.createdAt), + })) + .orderBy(({ $selected }) => $selected.totalSpent, 'desc') + .limit(10) +) +``` + ### Descending Order Use `desc` for descending order: @@ -1908,8 +1937,8 @@ const highValueCustomers = createLiveQueryCollection((q) => totalSpent: sum(order.amount), orderCount: count(order.id), })) - .fn.having((row) => { - return row.totalSpent > 1000 && row.orderCount >= 3 + .fn.having(({ $selected }) => { + return $selected.totalSpent > 1000 && $selected.orderCount >= 3 }) ) ``` diff --git a/docs/reference/classes/BaseQueryBuilder.md b/docs/reference/classes/BaseQueryBuilder.md index 57b094f15..9ff8df7d3 100644 --- a/docs/reference/classes/BaseQueryBuilder.md +++ b/docs/reference/classes/BaseQueryBuilder.md @@ -91,7 +91,11 @@ A QueryBuilder with functional having filter applied query .from({ posts: postsCollection }) .groupBy(({posts}) => posts.userId) - .fn.having(row => row.count > 5) + .select(({posts}) => ({ + userId: posts.userId, + postCount: count(posts.id), + })) + .fn.having(({ $selected }) => $selected.postCount > 5) ``` ###### select() @@ -428,6 +432,17 @@ query .groupBy(({orders}) => orders.customerId) .having(({orders}) => gt(avg(orders.total), 100)) +// Filter using SELECT fields via $selected +query + .from({ orders: ordersCollection }) + .groupBy(({orders}) => orders.customerId) + .select(({orders}) => ({ + customerId: orders.customerId, + totalSpent: sum(orders.amount), + orderCount: count(orders.id), + })) + .having(({ $selected }) => gt($selected.totalSpent, 1000)) + // Multiple having calls are ANDed together query .from({ orders: ordersCollection }) @@ -719,6 +734,17 @@ query .from({ users: usersCollection }) .orderBy(({users}) => users.createdAt, 'desc') +// Sort by SELECT fields via $selected +query + .from({ posts: postsCollection }) + .groupBy(({posts}) => posts.userId) + .select(({posts}) => ({ + userId: posts.userId, + postCount: count(posts.id), + latestPost: max(posts.createdAt), + })) + .orderBy(({ $selected }) => $selected.postCount, 'desc') + // Multiple sorts (chain orderBy calls) query .from({ users: usersCollection }) diff --git a/packages/db/src/query/builder/index.ts b/packages/db/src/query/builder/index.ts index e7d2be0c4..075730442 100644 --- a/packages/db/src/query/builder/index.ts +++ b/packages/db/src/query/builder/index.ts @@ -16,7 +16,11 @@ import { QueryMustHaveFromClauseError, SubQueryMustHaveFromClauseError, } from '../../errors.js' -import { createRefProxy, toExpression } from './ref-proxy.js' +import { + createRefProxy, + createRefProxyWithSelected, + toExpression, +} from './ref-proxy.js' import type { NamespacedRow, SingleResult } from '../../types.js' import type { Aggregate, @@ -29,6 +33,7 @@ import type { import type { CompareOptions, Context, + FunctionalHavingRow, GroupByCallback, JoinOnCallback, MergeContextForJoinCallback, @@ -399,7 +404,12 @@ export class BaseQueryBuilder { */ having(callback: WhereCallback): QueryBuilder { const aliases = this._getCurrentAliases() - const refProxy = createRefProxy(aliases) as RefsForContext + // Add $selected namespace if SELECT clause exists + const refProxy = ( + this.query.select + ? createRefProxyWithSelected(aliases) + : createRefProxy(aliases) + ) as RefsForContext const expression = callback(refProxy) const existingHaving = this.query.having || [] @@ -490,7 +500,12 @@ export class BaseQueryBuilder { options: OrderByDirection | OrderByOptions = `asc`, ): QueryBuilder { const aliases = this._getCurrentAliases() - const refProxy = createRefProxy(aliases) as RefsForContext + // Add $selected namespace if SELECT clause exists + const refProxy = ( + this.query.select + ? createRefProxyWithSelected(aliases) + : createRefProxy(aliases) + ) as RefsForContext const result = callback(refProxy) const opts: CompareOptions = @@ -755,7 +770,7 @@ export class BaseQueryBuilder { * Filter grouped rows using a function that operates on each aggregated row * Warning: This cannot be optimized by the query compiler * - * @param callback - A function that receives an aggregated row and returns a boolean + * @param callback - A function that receives an aggregated row (with $selected when select() was called) and returns a boolean * @returns A QueryBuilder with functional having filter applied * * @example @@ -764,11 +779,12 @@ export class BaseQueryBuilder { * query * .from({ posts: postsCollection }) * .groupBy(({posts}) => posts.userId) - * .fn.having(row => row.count > 5) + * .select(({posts}) => ({ userId: posts.userId, count: count(posts.id) })) + * .fn.having(({ $selected }) => $selected.count > 5) * ``` */ having( - callback: (row: TContext[`schema`]) => any, + callback: (row: FunctionalHavingRow) => any, ): QueryBuilder { return new BaseQueryBuilder({ ...builder.query, diff --git a/packages/db/src/query/builder/ref-proxy.ts b/packages/db/src/query/builder/ref-proxy.ts index 2f060b5f2..b3f79ef51 100644 --- a/packages/db/src/query/builder/ref-proxy.ts +++ b/packages/db/src/query/builder/ref-proxy.ts @@ -175,6 +175,96 @@ export function createRefProxy>( return rootProxy } +/** + * Creates a ref proxy with $selected namespace for SELECT fields + * + * Adds a $selected property that allows accessing SELECT fields via $selected.fieldName syntax. + * The $selected proxy creates paths like ['$selected', 'fieldName'] which directly reference + * the $selected property on the namespaced row. + * + * @param aliases - Array of table aliases to create proxies for + * @returns A ref proxy with table aliases and $selected namespace + */ +export function createRefProxyWithSelected>( + aliases: Array, +): RefProxy & T & { $selected: SingleRowRefProxy } { + const baseProxy = createRefProxy(aliases) + + // Create a proxy for $selected that prefixes all paths with '$selected' + const cache = new Map() + + function createSelectedProxy(path: Array): any { + const pathKey = path.join(`.`) + if (cache.has(pathKey)) { + return cache.get(pathKey) + } + + const proxy = new Proxy({} as any, { + get(target, prop, receiver) { + if (prop === `__refProxy`) return true + if (prop === `__path`) return [`$selected`, ...path] + if (prop === `__type`) return undefined + if (typeof prop === `symbol`) return Reflect.get(target, prop, receiver) + + const newPath = [...path, String(prop)] + return createSelectedProxy(newPath) + }, + + has(target, prop) { + if (prop === `__refProxy` || prop === `__path` || prop === `__type`) + return true + return Reflect.has(target, prop) + }, + + ownKeys(target) { + return Reflect.ownKeys(target) + }, + + getOwnPropertyDescriptor(target, prop) { + if (prop === `__refProxy` || prop === `__path` || prop === `__type`) { + return { enumerable: false, configurable: true } + } + return Reflect.getOwnPropertyDescriptor(target, prop) + }, + }) + + cache.set(pathKey, proxy) + return proxy + } + + const wrappedSelectedProxy = createSelectedProxy([]) + + // Wrap the base proxy to also handle $selected access + return new Proxy(baseProxy, { + get(target, prop, receiver) { + if (prop === `$selected`) { + return wrappedSelectedProxy + } + return Reflect.get(target, prop, receiver) + }, + + has(target, prop) { + if (prop === `$selected`) return true + return Reflect.has(target, prop) + }, + + ownKeys(target) { + return [...Reflect.ownKeys(target), `$selected`] + }, + + getOwnPropertyDescriptor(target, prop) { + if (prop === `$selected`) { + return { + enumerable: true, + configurable: true, + value: wrappedSelectedProxy, + } + } + return Reflect.getOwnPropertyDescriptor(target, prop) + }, + }) as RefProxy & T & { $selected: SingleRowRefProxy } +} + /** * Converts a value to an Expression * If it's a RefProxy, creates a Ref, otherwise creates a Value diff --git a/packages/db/src/query/builder/types.ts b/packages/db/src/query/builder/types.ts index 62130c0b3..11360dd82 100644 --- a/packages/db/src/query/builder/types.ts +++ b/packages/db/src/query/builder/types.ts @@ -345,6 +345,26 @@ export type JoinOnCallback = ( refs: RefsForContext, ) => any +/** + * FunctionalHavingRow - Type for the row parameter in functional having callbacks + * + * Functional having callbacks receive a namespaced row that includes: + * - Table data from the schema (when available) + * - $selected: The SELECT result fields (when select() has been called) + * + * After `select()` is called, this type includes `$selected` which provides access + * to the SELECT result fields via `$selected.fieldName` syntax. + * + * Note: When used with GROUP BY, functional having receives `{ $selected: ... }` with the + * aggregated SELECT results. When used without GROUP BY, it receives the full namespaced row + * which includes both table data and `$selected`. + * + * Example: `({ $selected }) => $selected.sessionCount > 2` + * Example (no GROUP BY): `(row) => row.user.salary > 70000 && row.$selected.user_count > 2` + */ +export type FunctionalHavingRow = TContext[`schema`] & + (TContext[`result`] extends object ? { $selected: TContext[`result`] } : {}) + /** * RefProxyForContext - Creates ref proxies for all tables/collections in a query context * @@ -364,6 +384,9 @@ export type JoinOnCallback = ( * * The logic prioritizes optional chaining by always placing `undefined` outside when * a type is both optional and nullable (e.g., `string | null | undefined`). + * + * After `select()` is called, this type also includes `$selected` which provides access + * to the SELECT result fields via `$selected.fieldName` syntax. */ export type RefsForContext = { [K in keyof TContext[`schema`]]: IsNonExactOptional< @@ -383,7 +406,9 @@ export type RefsForContext = { : // T is exactly undefined, exactly null, or neither optional nor nullable // Wrap in RefProxy as-is (includes exact undefined, exact null, and normal types) Ref -} +} & (TContext[`result`] extends object + ? { $selected: Ref } + : {}) /** * Type Detection Helpers diff --git a/packages/db/src/query/compiler/evaluators.ts b/packages/db/src/query/compiler/evaluators.ts index 8a2168781..5e44e5bcd 100644 --- a/packages/db/src/query/compiler/evaluators.ts +++ b/packages/db/src/query/compiler/evaluators.ts @@ -95,12 +95,48 @@ function compileExpressionInternal( * Compiles a reference expression into an optimized evaluator */ function compileRef(ref: PropRef): CompiledExpression { - const [tableAlias, ...propertyPath] = ref.path + const [namespace, ...propertyPath] = ref.path - if (!tableAlias) { + if (!namespace) { throw new EmptyReferencePathError() } + // Handle $selected namespace - references SELECT result fields + if (namespace === `$selected`) { + // Access $selected directly + if (propertyPath.length === 0) { + // Just $selected - return entire $selected object + return (namespacedRow) => (namespacedRow as any).$selected + } else if (propertyPath.length === 1) { + // Single property access - most common case + const prop = propertyPath[0]! + return (namespacedRow) => { + const selectResults = (namespacedRow as any).$selected + return selectResults?.[prop] + } + } else { + // Multiple property navigation (nested SELECT fields) + return (namespacedRow) => { + const selectResults = (namespacedRow as any).$selected + if (selectResults === undefined) { + return undefined + } + + let value: any = selectResults + for (const prop of propertyPath) { + if (value == null) { + return value + } + value = value[prop] + } + return value + } + } + } + + // Handle table alias namespace (existing logic) + const tableAlias = namespace + // Pre-compile the property path navigation if (propertyPath.length === 0) { // Simple table reference diff --git a/packages/db/src/query/compiler/group-by.ts b/packages/db/src/query/compiler/group-by.ts index e9c8fa436..f2ed661d0 100644 --- a/packages/db/src/query/compiler/group-by.ts +++ b/packages/db/src/query/compiler/group-by.ts @@ -66,7 +66,7 @@ function validateAndCreateMapping( /** * Processes the GROUP BY clause with optional HAVING and SELECT - * Works with the new __select_results structure from early SELECT processing + * Works with the new $selected structure from early SELECT processing */ export function processGroupBy( pipeline: NamespacedAndKeyedStream, @@ -98,11 +98,11 @@ export function processGroupBy( groupBy(keyExtractor, aggregates), ) as NamespacedAndKeyedStream - // Update __select_results to include aggregate values + // Update $selected to include aggregate values pipeline = pipeline.pipe( map(([, aggregatedRow]) => { - // Start with the existing __select_results from early SELECT processing - const selectResults = (aggregatedRow as any).__select_results || {} + // Start with the existing $selected from early SELECT processing + const selectResults = (aggregatedRow as any).$selected || {} const finalResults: Record = { ...selectResults } if (selectClause) { @@ -115,12 +115,12 @@ export function processGroupBy( } } - // Use a single key for the result and update __select_results + // Use a single key for the result and update $selected return [ `single_group`, { ...aggregatedRow, - __select_results: finalResults, + $selected: finalResults, }, ] as [unknown, Record] }), @@ -133,13 +133,14 @@ export function processGroupBy( const transformedHavingClause = replaceAggregatesByRefs( havingExpression, selectClause || {}, + `$selected`, ) const compiledHaving = compileExpression(transformedHavingClause) pipeline = pipeline.pipe( filter(([, row]) => { // Create a namespaced row structure for HAVING evaluation - const namespacedRow = { result: (row as any).__select_results } + const namespacedRow = { $selected: (row as any).$selected } return toBooleanPredicate(compiledHaving(namespacedRow)) }), ) @@ -152,7 +153,7 @@ export function processGroupBy( pipeline = pipeline.pipe( filter(([, row]) => { // Create a namespaced row structure for functional HAVING evaluation - const namespacedRow = { result: (row as any).__select_results } + const namespacedRow = { $selected: (row as any).$selected } return toBooleanPredicate(fnHaving(namespacedRow)) }), ) @@ -174,11 +175,11 @@ export function processGroupBy( // Create a key extractor function using simple __key_X format const keyExtractor = ([, row]: [ string, - NamespacedRow & { __select_results?: any }, + NamespacedRow & { $selected?: any }, ]) => { - // Use the original namespaced row for GROUP BY expressions, not __select_results + // Use the original namespaced row for GROUP BY expressions, not $selected const namespacedRow = { ...row } - delete (namespacedRow as any).__select_results + delete (namespacedRow as any).$selected const key: Record = {} @@ -208,11 +209,11 @@ export function processGroupBy( // Apply the groupBy operator pipeline = pipeline.pipe(groupBy(keyExtractor, aggregates)) - // Update __select_results to handle GROUP BY results + // Update $selected to handle GROUP BY results pipeline = pipeline.pipe( map(([, aggregatedRow]) => { - // Start with the existing __select_results from early SELECT processing - const selectResults = (aggregatedRow as any).__select_results || {} + // Start with the existing $selected from early SELECT processing + const selectResults = (aggregatedRow as any).$selected || {} const finalResults: Record = {} if (selectClause) { @@ -255,7 +256,7 @@ export function processGroupBy( finalKey, { ...aggregatedRow, - __select_results: finalResults, + $selected: finalResults, }, ] as [unknown, Record] }), @@ -274,7 +275,7 @@ export function processGroupBy( pipeline = pipeline.pipe( filter(([, row]) => { // Create a namespaced row structure for HAVING evaluation - const namespacedRow = { result: (row as any).__select_results } + const namespacedRow = { $selected: (row as any).$selected } return compiledHaving(namespacedRow) }), ) @@ -287,7 +288,7 @@ export function processGroupBy( pipeline = pipeline.pipe( filter(([, row]) => { // Create a namespaced row structure for functional HAVING evaluation - const namespacedRow = { result: (row as any).__select_results } + const namespacedRow = { $selected: (row as any).$selected } return toBooleanPredicate(fnHaving(namespacedRow)) }), ) @@ -385,12 +386,28 @@ function getAggregateFunction(aggExpr: Aggregate) { } /** - * Transforms basic expressions and aggregates to replace Agg expressions with references to computed values + * Transforms expressions to replace aggregate functions with references to computed values. + * + * This function is used in both ORDER BY and HAVING clauses to transform expressions that reference: + * 1. Aggregate functions (e.g., `max()`, `count()`) - replaces with references to computed aggregates in SELECT + * 2. SELECT field references via $selected namespace (e.g., `$selected.latestActivity`) - validates and passes through unchanged + * + * For aggregate expressions, it finds matching aggregates in the SELECT clause and replaces them with + * PropRef([resultAlias, alias]) to reference the computed aggregate value. + * + * For ref expressions using the $selected namespace, it validates that the field exists in the SELECT clause + * and passes them through unchanged (since $selected is already the correct namespace). All other ref expressions + * are passed through unchanged (treating them as table column references). + * + * @param havingExpr - The expression to transform (can be aggregate, ref, func, or val) + * @param selectClause - The SELECT clause containing aliases and aggregate definitions + * @param resultAlias - The namespace alias for SELECT results (default: '$selected', used for aggregate references) + * @returns A transformed BasicExpression that references computed values instead of raw expressions */ export function replaceAggregatesByRefs( havingExpr: BasicExpression | Aggregate, selectClause: Select, - resultAlias: string = `result`, + resultAlias: string = `$selected`, ): BasicExpression { switch (havingExpr.type) { case `agg`: { @@ -417,7 +434,38 @@ export function replaceAggregatesByRefs( } case `ref`: { - // Non-aggregate refs are passed through unchanged (they reference table columns) + const refExpr = havingExpr + const path = refExpr.path + + if (path.length === 0) { + // Empty path - pass through + return havingExpr as BasicExpression + } + + // Check if this is a $selected reference + if (path.length > 0 && path[0] === `$selected`) { + // Extract the field path after $selected + const fieldPath = path.slice(1) + + if (fieldPath.length === 0) { + // Just $selected without a field - pass through unchanged + return havingExpr as BasicExpression + } + + // Verify the field exists in SELECT clause + const alias = fieldPath.join(`.`) + if (alias in selectClause) { + // Pass through unchanged - $selected is already the correct namespace + return havingExpr as BasicExpression + } + + // Field doesn't exist in SELECT - this is an error, but we'll pass through for now + // (Could throw an error here in the future) + return havingExpr as BasicExpression + } + + // Not a $selected reference - this is a table column reference, pass through unchanged + // SELECT fields should only be accessed via $selected namespace return havingExpr as BasicExpression } diff --git a/packages/db/src/query/compiler/index.ts b/packages/db/src/query/compiler/index.ts index 7d5995871..30513ba85 100644 --- a/packages/db/src/query/compiler/index.ts +++ b/packages/db/src/query/compiler/index.ts @@ -216,7 +216,7 @@ export function compileQuery( throw new DistinctRequiresSelectError() } - // Process the SELECT clause early - always create __select_results + // Process the SELECT clause early - always create $selected // This eliminates duplication and allows for DISTINCT implementation if (query.fnSelect) { // Handle functional select - apply the function to transform the row @@ -227,15 +227,15 @@ export function compileQuery( key, { ...namespacedRow, - __select_results: selectResults, + $selected: selectResults, }, - ] as [string, typeof namespacedRow & { __select_results: any }] + ] as [string, typeof namespacedRow & { $selected: any }] }), ) } else if (query.select) { pipeline = processSelect(pipeline, query.select, allInputs) } else { - // If no SELECT clause, create __select_results with the main table data + // If no SELECT clause, create $selected with the main table data pipeline = pipeline.pipe( map(([key, namespacedRow]) => { const selectResults = @@ -247,9 +247,9 @@ export function compileQuery( key, { ...namespacedRow, - __select_results: selectResults, + $selected: selectResults, }, - ] as [string, typeof namespacedRow & { __select_results: any }] + ] as [string, typeof namespacedRow & { $selected: any }] }), ) } @@ -310,7 +310,7 @@ export function compileQuery( // Process the DISTINCT clause if it exists if (query.distinct) { - pipeline = pipeline.pipe(distinct(([_key, row]) => row.__select_results)) + pipeline = pipeline.pipe(distinct(([_key, row]) => row.$selected)) } // Process orderBy parameter if it exists @@ -327,11 +327,11 @@ export function compileQuery( query.offset, ) - // Final step: extract the __select_results and include orderBy index + // Final step: extract the $selected and include orderBy index const resultPipeline = orderedPipeline.pipe( map(([key, [row, orderByIndex]]) => { - // Extract the final results from __select_results and include orderBy index - const raw = (row as any).__select_results + // Extract the final results from $selected and include orderBy index + const raw = (row as any).$selected const finalResults = unwrapValue(raw) return [key, [finalResults, orderByIndex]] as [unknown, [any, string]] }), @@ -354,11 +354,11 @@ export function compileQuery( throw new LimitOffsetRequireOrderByError() } - // Final step: extract the __select_results and return tuple format (no orderBy) + // Final step: extract the $selected and return tuple format (no orderBy) const resultPipeline: ResultStream = pipeline.pipe( map(([key, row]) => { - // Extract the final results from __select_results and return [key, [results, undefined]] - const raw = (row as any).__select_results + // Extract the final results from $selected and return [key, [results, undefined]] + const raw = (row as any).$selected const finalResults = unwrapValue(raw) return [key, [finalResults, undefined]] as [ unknown, diff --git a/packages/db/src/query/compiler/order-by.ts b/packages/db/src/query/compiler/order-by.ts index 4362ff635..0ced0081c 100644 --- a/packages/db/src/query/compiler/order-by.ts +++ b/packages/db/src/query/compiler/order-by.ts @@ -38,7 +38,7 @@ export type OrderByOptimizationInfo = { /** * Processes the ORDER BY clause - * Works with the new structure that has both namespaced row data and __select_results + * Works with the new structure that has both namespaced row data and $selected * Always uses fractional indexing and adds the index as __ordering_index to the result */ export function processOrderBy( @@ -57,7 +57,7 @@ export function processOrderBy( const clauseWithoutAggregates = replaceAggregatesByRefs( clause.expression, selectClause, - `__select_results`, + `$selected`, ) return { @@ -67,12 +67,13 @@ export function processOrderBy( }) // Create a value extractor function for the orderBy operator - const valueExtractor = (row: NamespacedRow & { __select_results?: any }) => { + const valueExtractor = (row: NamespacedRow & { $selected?: any }) => { // The namespaced row contains: // 1. Table aliases as top-level properties (e.g., row["tableName"]) - // 2. SELECT results in __select_results (e.g., row.__select_results["aggregateAlias"]) - // The replaceAggregatesByRefs function has already transformed any aggregate expressions - // that match SELECT aggregates to use the __select_results namespace. + // 2. SELECT results in $selected (e.g., row.$selected["aggregateAlias"]) + // The replaceAggregatesByRefs function has already transformed: + // - Aggregate expressions that match SELECT aggregates to use the $selected namespace + // - $selected ref expressions are passed through unchanged (already using the correct namespace) const orderByContext = row if (orderByClause.length > 1) { diff --git a/packages/db/src/query/compiler/select.ts b/packages/db/src/query/compiler/select.ts index 88e7663b0..94c8ac8f2 100644 --- a/packages/db/src/query/compiler/select.ts +++ b/packages/db/src/query/compiler/select.ts @@ -100,7 +100,7 @@ function processNonMergeOp( function processRow( [key, namespacedRow]: [unknown, NamespacedRow], ops: Array, -): [unknown, typeof namespacedRow & { __select_results: any }] { +): [unknown, typeof namespacedRow & { $selected: any }] { const selectResults: Record = {} for (const op of ops) { @@ -111,21 +111,18 @@ function processRow( } } - // Return the namespaced row with __select_results added + // Return the namespaced row with $selected added return [ key, { ...namespacedRow, - __select_results: selectResults, + $selected: selectResults, }, - ] as [ - unknown, - typeof namespacedRow & { __select_results: typeof selectResults }, - ] + ] as [unknown, typeof namespacedRow & { $selected: typeof selectResults }] } /** - * Processes the SELECT clause and places results in __select_results + * Processes the SELECT clause and places results in $selected * while preserving the original namespaced row for ORDER BY access */ export function processSelect( diff --git a/packages/db/tests/query/builder/callback-types.test-d.ts b/packages/db/tests/query/builder/callback-types.test-d.ts index 95c2c13ce..0e901b7fb 100644 --- a/packages/db/tests/query/builder/callback-types.test-d.ts +++ b/packages/db/tests/query/builder/callback-types.test-d.ts @@ -570,4 +570,208 @@ describe(`Query Builder Callback Types`, () => { }) }) }) + + describe(`ORDER BY and HAVING with SELECT fields`, () => { + test(`orderBy callback can access aggregate fields from SELECT`, () => { + new Query() + .from({ user: usersCollection }) + .groupBy(({ user }) => user.department_id) + .select(({ user }) => ({ + department_id: user.department_id, + user_count: count(user.id), + avg_age: avg(user.age), + max_salary: max(user.salary), + })) + .orderBy(({ user, $selected }) => { + expectTypeOf(user.id).toEqualTypeOf>() + expectTypeOf( + user.department_id, + ).toEqualTypeOf | null>() + + expectTypeOf($selected.user_count).toEqualTypeOf>() + expectTypeOf($selected.avg_age).toEqualTypeOf>() + expectTypeOf($selected.max_salary).toEqualTypeOf>() + expectTypeOf( + $selected.department_id, + ).toEqualTypeOf | null>() + + // Can now order by SELECT fields + return $selected.user_count + }) + }) + + test(`orderBy callback can access non-aggregate fields from SELECT`, () => { + new Query() + .from({ user: usersCollection }) + .groupBy(({ user }) => user.department_id) + .select(({ user }) => ({ + taskId: user.department_id, + department_name: user.name, // Non-aggregate field + user_count: count(user.id), + })) + .orderBy(({ user, $selected }) => { + expectTypeOf(user.id).toEqualTypeOf>() + + expectTypeOf($selected.taskId).toEqualTypeOf | null>() + expectTypeOf($selected.department_name).toEqualTypeOf< + RefLeaf + >() + expectTypeOf($selected.user_count).toEqualTypeOf>() + + // Can now order by SELECT fields + return $selected.taskId + }) + }) + + test(`having callback can access aggregate fields from SELECT`, () => { + new Query() + .from({ user: usersCollection }) + .groupBy(({ user }) => user.department_id) + .select(({ user }) => ({ + department_id: user.department_id, + user_count: count(user.id), + avg_age: avg(user.age), + total_salary: sum(user.salary), + })) + .having(({ user, $selected }) => { + expectTypeOf(user.id).toEqualTypeOf>() + + expectTypeOf($selected.user_count).toEqualTypeOf>() + expectTypeOf($selected.avg_age).toEqualTypeOf>() + expectTypeOf($selected.total_salary).toEqualTypeOf>() + expectTypeOf( + $selected.department_id, + ).toEqualTypeOf | null>() + + // Can now use SELECT aliases in HAVING + return gt($selected.user_count, 5) + }) + }) + + test(`having callback can access non-aggregate fields from SELECT`, () => { + new Query() + .from({ user: usersCollection }) + .groupBy(({ user }) => user.department_id) + .select(({ user }) => ({ + taskId: user.department_id, + department_name: user.name, + user_count: count(user.id), + })) + .having(({ user, $selected }) => { + expectTypeOf(user.id).toEqualTypeOf>() + + expectTypeOf($selected.taskId).toEqualTypeOf | null>() + expectTypeOf($selected.department_name).toEqualTypeOf< + RefLeaf + >() + expectTypeOf($selected.user_count).toEqualTypeOf>() + + // Can now use SELECT fields in HAVING + return gt($selected.user_count, 2) + }) + }) + + test(`orderBy can access nested SELECT fields`, () => { + new Query() + .from({ user: usersCollection }) + .select(({ user }) => ({ + id: user.id, + profile: { + name: user.name, + email: user.email, + }, + stats: { + age: user.age, + salary: user.salary, + }, + })) + .orderBy(({ user, $selected }) => { + expectTypeOf(user.id).toEqualTypeOf>() + + expectTypeOf($selected.profile.name).toEqualTypeOf>() + expectTypeOf($selected.stats.age).toEqualTypeOf>() + + return $selected.stats.age + }) + }) + + test(`orderBy has access to SELECT fields via $selected`, () => { + new Query() + .from({ user: usersCollection }) + .groupBy(({ user }) => user.department_id) + .select(({ user }) => ({ + taskId: user.department_id, + latestActivity: max(user.created_at), + sessionCount: count(user.id), + })) + .orderBy(({ user, $selected }) => { + expectTypeOf(user.id).toEqualTypeOf>() + + expectTypeOf($selected.taskId).toEqualTypeOf | null>() + expectTypeOf($selected.sessionCount).toEqualTypeOf>() + + return $selected.latestActivity + }) + }) + + test(`having has access to SELECT fields via $selected`, () => { + new Query() + .from({ user: usersCollection }) + .groupBy(({ user }) => user.department_id) + .select(({ user }) => ({ + taskId: user.department_id, + user_count: count(user.id), + avg_salary: avg(user.salary), + })) + .having(({ user, $selected }) => { + expectTypeOf(user.id).toEqualTypeOf>() + + expectTypeOf($selected.taskId).toEqualTypeOf | null>() + expectTypeOf($selected.user_count).toEqualTypeOf>() + expectTypeOf($selected.avg_salary).toEqualTypeOf>() + + return gt($selected.user_count, 5) + }) + }) + + test(`fn.having has access to SELECT fields via $selected`, () => { + new Query() + .from({ user: usersCollection }) + .groupBy(({ user }) => user.department_id) + .select(({ user }) => ({ + taskId: user.department_id, + user_count: count(user.id), + avg_salary: avg(user.salary), + total_salary: sum(user.salary), + })) + .fn.having(({ $selected }) => { + expectTypeOf($selected.taskId).toEqualTypeOf() + expectTypeOf($selected.user_count).toEqualTypeOf() + expectTypeOf($selected.avg_salary).toEqualTypeOf() + expectTypeOf($selected.total_salary).toEqualTypeOf() + + return $selected.user_count > 5 && $selected.avg_salary > 50000 + }) + }) + + test(`fn.having can access nested SELECT fields`, () => { + new Query() + .from({ user: usersCollection }) + .groupBy(({ user }) => user.department_id) + .select(({ user }) => ({ + taskId: user.department_id, + stats: { + user_count: count(user.id), + avg_salary: avg(user.salary), + }, + })) + .fn.having(({ $selected }) => { + expectTypeOf($selected.taskId).toEqualTypeOf() + expectTypeOf($selected.stats.user_count).toEqualTypeOf() + expectTypeOf($selected.stats.avg_salary).toEqualTypeOf() + + return $selected.stats.user_count > 2 + }) + }) + }) }) diff --git a/packages/db/tests/query/functional-variants.test.ts b/packages/db/tests/query/functional-variants.test.ts index 5878d2490..62f985654 100644 --- a/packages/db/tests/query/functional-variants.test.ts +++ b/packages/db/tests/query/functional-variants.test.ts @@ -366,7 +366,7 @@ describe(`Functional Variants Query`, () => { department_id: user.department_id, employee_count: count(user.id), })) - .fn.having((row) => (row as any).result.employee_count > 1), + .fn.having((row) => (row as any).$selected.employee_count > 1), }) const results = liveCollection.toArray diff --git a/packages/db/tests/query/group-by.test.ts b/packages/db/tests/query/group-by.test.ts index e21f7be2c..81c2b0ba3 100644 --- a/packages/db/tests/query/group-by.test.ts +++ b/packages/db/tests/query/group-by.test.ts @@ -1380,6 +1380,208 @@ function createGroupByTests(autoIndex: `off` | `eager`): void { expect(goldTier?.order_count).toBe(initialGoldCount) }) }) + + describe(`ORDER BY and HAVING with SELECT fields`, () => { + let sessionsCollection: ReturnType + + beforeEach(() => { + // Reuse ordersCollection as sessionsCollection for testing + sessionsCollection = createOrdersCollection(autoIndex) + }) + + test(`orderBy can reference aggregate field from SELECT`, () => { + const sessionStats = createLiveQueryCollection({ + startSync: true, + query: (q) => + q + .from({ sessions: sessionsCollection }) + .where(({ sessions }) => eq(sessions.status, `completed`)) + .groupBy(({ sessions }) => sessions.customer_id) + .select(({ sessions }) => ({ + taskId: sessions.customer_id, + latestActivity: max(sessions.date), + sessionCount: count(sessions.id), + })) + .orderBy(({ $selected }) => $selected.latestActivity), + }) + + expect(sessionStats.toArray).toEqual([ + { + taskId: 2, + latestActivity: new Date(`2023-02-01`), + sessionCount: 1, + }, + { + taskId: 1, + latestActivity: new Date(`2023-03-01`), + sessionCount: 3, + }, + ]) + }) + + test(`orderBy can reference non-aggregate field from SELECT`, () => { + const sessionStats = createLiveQueryCollection({ + startSync: true, + query: (q) => + q + .from({ sessions: sessionsCollection }) + .where(({ sessions }) => eq(sessions.status, `completed`)) + .groupBy(({ sessions }) => sessions.customer_id) + .select(({ sessions }) => ({ + taskId: sessions.customer_id, + latestActivity: max(sessions.date), + sessionCount: count(sessions.id), + })) + .orderBy(({ $selected }) => $selected.taskId, { + direction: `desc`, + }), + }) + + expect(sessionStats.toArray).toEqual([ + { + taskId: 2, + latestActivity: new Date(`2023-02-01`), + sessionCount: 1, + }, + { + taskId: 1, + latestActivity: new Date(`2023-03-01`), + sessionCount: 3, + }, + ]) + }) + + test(`HAVING can reference aggregate field from SELECT`, () => { + const sessionStats = createLiveQueryCollection({ + startSync: true, + query: (q) => + q + .from({ sessions: sessionsCollection }) + .where(({ sessions }) => eq(sessions.status, `completed`)) + .groupBy(({ sessions }) => sessions.customer_id) + .select(({ sessions }) => ({ + taskId: sessions.customer_id, + latestActivity: max(sessions.date), + sessionCount: count(sessions.id), + })) + .having(({ $selected }) => gt($selected.sessionCount, 2)), + }) + + expect(sessionStats.toArray).toEqual([ + { + taskId: 1, + latestActivity: new Date(`2023-03-01`), + sessionCount: 3, + }, + ]) + }) + + test(`HAVING can reference non-aggregate field from SELECT`, () => { + const sessionStats = createLiveQueryCollection({ + startSync: true, + query: (q) => + q + .from({ sessions: sessionsCollection }) + .where(({ sessions }) => eq(sessions.status, `completed`)) + .groupBy(({ sessions }) => sessions.customer_id) + .select(({ sessions }) => ({ + taskId: sessions.customer_id, + latestActivity: max(sessions.date), + sessionCount: count(sessions.id), + })) + .having(({ $selected }) => gt($selected.taskId, 0)), + }) + + // Once bug is fixed, this should filter groups where taskId > 0 + expect(sessionStats.size).toBe(2) + }) + + test(`fn.having can reference aggregate field from SELECT`, () => { + const sessionStats = createLiveQueryCollection({ + startSync: true, + query: (q) => + q + .from({ sessions: sessionsCollection }) + .where(({ sessions }) => eq(sessions.status, `completed`)) + .groupBy(({ sessions }) => sessions.customer_id) + .select(({ sessions }) => ({ + taskId: sessions.customer_id, + latestActivity: max(sessions.date), + sessionCount: count(sessions.id), + totalAmount: sum(sessions.amount), + })) + .fn.having(({ $selected }) => $selected.sessionCount > 2), + }) + + // Should only include groups where sessionCount > 2 + // Customer 1 has 3 completed sessions, Customer 2 has 1 + expect(sessionStats.size).toBe(1) + + const result = sessionStats.toArray[0] + expect(result).toBeDefined() + expect(result?.taskId).toBe(1) + expect(result?.sessionCount).toBe(3) + expect(result?.totalAmount).toBe(700) // 100 + 200 + 400 + }) + + test(`fn.having can reference non-aggregate field from SELECT`, () => { + const sessionStats = createLiveQueryCollection({ + startSync: true, + query: (q) => + q + .from({ sessions: sessionsCollection }) + .where(({ sessions }) => eq(sessions.status, `completed`)) + .groupBy(({ sessions }) => sessions.customer_id) + .select(({ sessions }) => ({ + taskId: sessions.customer_id, + latestActivity: max(sessions.date), + sessionCount: count(sessions.id), + })) + .fn.having(({ $selected }) => $selected.taskId > 1), + }) + + // Should only include groups where taskId > 1 + // Customer 1 has taskId 1, Customer 2 has taskId 2 + expect(sessionStats.size).toBe(1) + + const result = sessionStats.toArray[0] + expect(result).toBeDefined() + expect(result?.taskId).toBe(2) + expect(result?.sessionCount).toBe(1) + }) + + test(`fn.having can use multiple SELECT fields in complex conditions`, () => { + const sessionStats = createLiveQueryCollection({ + startSync: true, + query: (q) => + q + .from({ sessions: sessionsCollection }) + .where(({ sessions }) => eq(sessions.status, `completed`)) + .groupBy(({ sessions }) => sessions.customer_id) + .select(({ sessions }) => ({ + taskId: sessions.customer_id, + latestActivity: max(sessions.date), + sessionCount: count(sessions.id), + totalAmount: sum(sessions.amount), + })) + .fn.having( + ({ $selected }) => + $selected.sessionCount >= 2 && $selected.totalAmount > 300, + ), + }) + + // Should include groups where sessionCount >= 2 AND totalAmount > 300 + // Customer 1: 3 sessions, 700 total ✓ + // Customer 2: 1 session ✗ + expect(sessionStats.size).toBe(1) + + const result = sessionStats.toArray[0] + expect(result).toBeDefined() + expect(result?.taskId).toBe(1) + expect(result?.sessionCount).toBe(3) + expect(result?.totalAmount).toBe(700) + }) + }) }) }