Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/weak-colts-walk.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/db': patch
---

Introduce $selected namespace for accessing fields from SELECT clause inside ORDER BY and HAVING clauses.
37 changes: 33 additions & 4 deletions docs/guides/live-queries.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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))
)
```

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
})
)
```
Expand Down
28 changes: 27 additions & 1 deletion docs/reference/classes/BaseQueryBuilder.md
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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 })
Expand Down Expand Up @@ -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 })
Expand Down
28 changes: 22 additions & 6 deletions packages/db/src/query/builder/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -29,6 +33,7 @@ import type {
import type {
CompareOptions,
Context,
FunctionalHavingRow,
GroupByCallback,
JoinOnCallback,
MergeContextForJoinCallback,
Expand Down Expand Up @@ -399,7 +404,12 @@ export class BaseQueryBuilder<TContext extends Context = Context> {
*/
having(callback: WhereCallback<TContext>): QueryBuilder<TContext> {
const aliases = this._getCurrentAliases()
const refProxy = createRefProxy(aliases) as RefsForContext<TContext>
// Add $selected namespace if SELECT clause exists
const refProxy = (
this.query.select
? createRefProxyWithSelected(aliases)
: createRefProxy(aliases)
) as RefsForContext<TContext>
const expression = callback(refProxy)

const existingHaving = this.query.having || []
Expand Down Expand Up @@ -490,7 +500,12 @@ export class BaseQueryBuilder<TContext extends Context = Context> {
options: OrderByDirection | OrderByOptions = `asc`,
): QueryBuilder<TContext> {
const aliases = this._getCurrentAliases()
const refProxy = createRefProxy(aliases) as RefsForContext<TContext>
// Add $selected namespace if SELECT clause exists
const refProxy = (
this.query.select
? createRefProxyWithSelected(aliases)
: createRefProxy(aliases)
) as RefsForContext<TContext>
const result = callback(refProxy)

const opts: CompareOptions =
Expand Down Expand Up @@ -755,7 +770,7 @@ export class BaseQueryBuilder<TContext extends Context = Context> {
* 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
Expand All @@ -764,11 +779,12 @@ export class BaseQueryBuilder<TContext extends Context = Context> {
* 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<TContext>) => any,
): QueryBuilder<TContext> {
return new BaseQueryBuilder({
...builder.query,
Expand Down
90 changes: 90 additions & 0 deletions packages/db/src/query/builder/ref-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,96 @@ export function createRefProxy<T extends Record<string, any>>(
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<T extends Record<string, any>>(
aliases: Array<string>,
): RefProxy<T> & T & { $selected: SingleRowRefProxy<any> } {
const baseProxy = createRefProxy(aliases)

// Create a proxy for $selected that prefixes all paths with '$selected'
const cache = new Map<string, any>()

function createSelectedProxy(path: Array<string>): 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> & T & { $selected: SingleRowRefProxy<any> }
}

/**
* Converts a value to an Expression
* If it's a RefProxy, creates a Ref, otherwise creates a Value
Expand Down
27 changes: 26 additions & 1 deletion packages/db/src/query/builder/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,26 @@ export type JoinOnCallback<TContext extends Context> = (
refs: RefsForContext<TContext>,
) => 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 extends Context> = TContext[`schema`] &
(TContext[`result`] extends object ? { $selected: TContext[`result`] } : {})

/**
* RefProxyForContext - Creates ref proxies for all tables/collections in a query context
*
Expand All @@ -364,6 +384,9 @@ export type JoinOnCallback<TContext extends Context> = (
*
* 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<TContext extends Context> = {
[K in keyof TContext[`schema`]]: IsNonExactOptional<
Expand All @@ -383,7 +406,9 @@ export type RefsForContext<TContext extends Context> = {
: // 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[`schema`][K]>
}
} & (TContext[`result`] extends object
? { $selected: Ref<TContext[`result`]> }
: {})

/**
* Type Detection Helpers
Expand Down
40 changes: 38 additions & 2 deletions packages/db/src/query/compiler/evaluators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading