Skip to content
Merged
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/boolean-column-filters.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/db': patch
---

Support bare boolean column references in `where()` and `having()` clauses. Previously, filtering on a boolean column required `eq(col.active, true)`. Now you can write `.where(({ u }) => u.active)` and `.where(({ u }) => not(u.active))` directly.
19 changes: 17 additions & 2 deletions packages/db/src/query/builder/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
import {
createRefProxy,
createRefProxyWithSelected,
isRefProxy,
toExpression,
} from './ref-proxy.js'
import type { NamespacedRow, SingleResult } from '../../types.js'
Expand Down Expand Up @@ -366,7 +367,14 @@ export class BaseQueryBuilder<TContext extends Context = Context> {
where(callback: WhereCallback<TContext>): QueryBuilder<TContext> {
const aliases = this._getCurrentAliases()
const refProxy = createRefProxy(aliases) as RefsForContext<TContext>
const expression = callback(refProxy)
const rawExpression = callback(refProxy)

// Allow bare boolean column references like `.where(({ u }) => u.active)`
// by converting ref proxies to PropRef expressions, the same way helper
// functions like `not()` and `eq()` do via `toExpression()`.
const expression = isRefProxy(rawExpression)
? toExpression(rawExpression)
: rawExpression

// Validate that the callback returned a valid expression
// This catches common mistakes like using JavaScript comparison operators (===, !==, etc.)
Expand Down Expand Up @@ -419,7 +427,14 @@ export class BaseQueryBuilder<TContext extends Context = Context> {
? createRefProxyWithSelected(aliases)
: createRefProxy(aliases)
) as RefsForContext<TContext>
const expression = callback(refProxy)
const rawExpression = callback(refProxy)

// Allow bare boolean column references like `.having(({ $selected }) => $selected.isActive)`
// by converting ref proxies to PropRef expressions, the same way helper
// functions like `not()` and `eq()` do via `toExpression()`.
const expression = isRefProxy(rawExpression)
? toExpression(rawExpression)
: rawExpression

// Validate that the callback returned a valid expression
// This catches common mistakes like using JavaScript comparison operators (===, !==, etc.)
Expand Down
46 changes: 46 additions & 0 deletions packages/db/tests/query/group-by.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -864,6 +864,52 @@ function createGroupByTests(autoIndex: `off` | `eager`): void {
// No customer has total > 1000 (max is 700)
expect(impossibleFilter.size).toBe(0)
})

test(`having with bare boolean selected field`, () => {
// Select a computed boolean into the result, then use it directly in having
const highVolumeCustomers = createLiveQueryCollection({
startSync: true,
query: (q) =>
q
.from({ orders: ordersCollection })
.groupBy(({ orders }) => orders.customer_id)
.select(({ orders }) => ({
customer_id: orders.customer_id,
order_count: count(orders.id),
is_high_volume: gt(count(orders.id), 2),
}))
.having(({ $selected }) => $selected.is_high_volume),
})

// Only customer 1 has more than 2 orders (3 orders)
expect(highVolumeCustomers.size).toBe(1)
expect(highVolumeCustomers.get(1)?.customer_id).toBe(1)
expect(highVolumeCustomers.get(1)?.is_high_volume).toBe(true)
})

test(`having with negated boolean selected field`, () => {
// Using not() with a bare boolean selected field
const lowVolumeCustomers = createLiveQueryCollection({
startSync: true,
query: (q) =>
q
.from({ orders: ordersCollection })
.groupBy(({ orders }) => orders.customer_id)
.select(({ orders }) => ({
customer_id: orders.customer_id,
order_count: count(orders.id),
is_high_volume: gt(count(orders.id), 2),
}))
.having(({ $selected }) => not($selected.is_high_volume)),
})

// Customers 2 and 3 have 2 orders each (not > 2)
expect(lowVolumeCustomers.size).toBe(2)
expect(lowVolumeCustomers.get(2)?.customer_id).toBe(2)
expect(lowVolumeCustomers.get(3)?.customer_id).toBe(3)
expect(lowVolumeCustomers.get(2)?.is_high_volume).toBe(false)
expect(lowVolumeCustomers.get(3)?.is_high_volume).toBe(false)
})
})

describe(`Live Updates with GROUP BY`, () => {
Expand Down
46 changes: 46 additions & 0 deletions packages/db/tests/query/where.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -669,6 +669,52 @@ function createWhereTests(autoIndex: `off` | `eager`): void {

expect(complexQuery.size).toBe(2) // Alice (dept 1, 75k), Eve (dept 2, age 25)
})

test(`bare boolean column reference as where filter`, () => {
// Using a boolean column directly (without eq()) should filter truthy rows
const activeEmployees = createLiveQueryCollection({
startSync: true,
query: (q) =>
q
.from({ emp: employeesCollection })
.where(({ emp }) => emp.active)
.select(({ emp }) => ({
id: emp.id,
name: emp.name,
active: emp.active,
})),
})

expect(activeEmployees.size).toBe(4) // Alice, Bob, Diana, Eve
expect(activeEmployees.toArray.every((emp) => emp.active)).toBe(true)

// Verify the correct employees are included
const ids = activeEmployees.toArray.map((emp) => emp.id).sort()
expect(ids).toEqual([1, 2, 4, 5])
})

test(`negated boolean column reference as where filter`, () => {
// Using not() with a bare boolean column should filter falsy rows
const inactiveEmployees = createLiveQueryCollection({
startSync: true,
query: (q) =>
q
.from({ emp: employeesCollection })
.where(({ emp }) => not(emp.active))
.select(({ emp }) => ({
id: emp.id,
name: emp.name,
active: emp.active,
})),
})

expect(inactiveEmployees.size).toBe(2) // Charlie, Frank
expect(inactiveEmployees.toArray.every((emp) => !emp.active)).toBe(true)

// Verify the correct employees are included
const ids = inactiveEmployees.toArray.map((emp) => emp.id).sort()
expect(ids).toEqual([3, 6])
})
})

describe(`String Operators`, () => {
Expand Down
Loading