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
14 changes: 14 additions & 0 deletions .changeset/fix-invalid-where-expression.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@tanstack/db': patch
---

Add validation for where() and having() expressions to catch JavaScript operator usage

When users accidentally use JavaScript's comparison operators (`===`, `!==`, `<`, `>`, etc.) in `where()` or `having()` callbacks instead of query builder functions (`eq`, `gt`, etc.), the query builder now throws a helpful `InvalidWhereExpressionError` with clear guidance.

Previously, this mistake would result in a confusing "Unknown expression type: undefined" error at query compilation time. Now users get immediate feedback with an example of the correct syntax:

```
❌ .where(({ user }) => user.id === 'abc')
✅ .where(({ user }) => eq(user.id, 'abc'))
```
13 changes: 13 additions & 0 deletions packages/db/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,19 @@ export class QueryMustHaveFromClauseError extends QueryBuilderError {
}
}

export class InvalidWhereExpressionError extends QueryBuilderError {
constructor(valueType: string) {
super(
`Invalid where() expression: Expected a query expression, but received a ${valueType}. ` +
`This usually happens when using JavaScript's comparison operators (===, !==, <, >, etc.) directly. ` +
`Instead, use the query builder functions:\n\n` +
` ❌ .where(({ user }) => user.id === 'abc')\n` +
` ✅ .where(({ user }) => eq(user.id, 'abc'))\n\n` +
`Available comparison functions: eq, gt, gte, lt, lte, and, or, not, like, ilike, isNull, isUndefined`,
)
}
}

// Query Compilation Errors
export class QueryCompilationError extends TanStackDBError {
constructor(message: string) {
Expand Down
31 changes: 31 additions & 0 deletions packages/db/src/query/builder/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
import {
InvalidSourceError,
InvalidSourceTypeError,
InvalidWhereExpressionError,
JoinConditionMustBeEqualityError,
OnlyOneSourceAllowedError,
QueryMustHaveFromClauseError,
Expand Down Expand Up @@ -361,6 +362,21 @@ export class BaseQueryBuilder<TContext extends Context = Context> {
const refProxy = createRefProxy(aliases) as RefsForContext<TContext>
const expression = callback(refProxy)

// Validate that the callback returned a valid expression
// This catches common mistakes like using JavaScript comparison operators (===, !==, etc.)
// which return boolean primitives instead of expression objects
if (!isExpressionLike(expression)) {
const valueType =
expression === null
? `null`
: expression === undefined
? `undefined`
: typeof expression === `object`
? `object`
: typeof expression
throw new InvalidWhereExpressionError(valueType)
}

const existingWhere = this.query.where || []

return new BaseQueryBuilder({
Expand Down Expand Up @@ -402,6 +418,21 @@ export class BaseQueryBuilder<TContext extends Context = Context> {
const refProxy = createRefProxy(aliases) as RefsForContext<TContext>
const expression = callback(refProxy)

// Validate that the callback returned a valid expression
// This catches common mistakes like using JavaScript comparison operators (===, !==, etc.)
// which return boolean primitives instead of expression objects
if (!isExpressionLike(expression)) {
const valueType =
expression === null
? `null`
: expression === undefined
? `undefined`
: typeof expression === `object`
? `object`
: typeof expression
throw new InvalidWhereExpressionError(valueType)
}

const existingHaving = this.query.having || []

return new BaseQueryBuilder({
Expand Down
54 changes: 54 additions & 0 deletions packages/db/tests/query/builder/where.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
not,
or,
} from '../../../src/query/builder/functions.js'
import { InvalidWhereExpressionError } from '../../../src/errors.js'

// Test schema
interface Employee {
Expand Down Expand Up @@ -185,4 +186,57 @@ describe(`QueryBuilder.where`, () => {
expect((builtQuery.where as any)[0]?.name).toBe(`eq`)
expect((builtQuery.where as any)[1]?.name).toBe(`gt`)
})

describe(`error handling`, () => {
it(`throws InvalidWhereExpressionError when using JavaScript === operator`, () => {
const builder = new Query()
expect(() =>
builder
.from({ employees: employeesCollection })
// This is a common mistake - using JavaScript's === instead of eq()
.where(({ employees }) => (employees.id as any) === 1),
).toThrow(InvalidWhereExpressionError)
})

it(`throws InvalidWhereExpressionError when callback returns a boolean`, () => {
const builder = new Query()
expect(() =>
builder
.from({ employees: employeesCollection })
.where(() => true as any),
).toThrow(InvalidWhereExpressionError)
})

it(`throws InvalidWhereExpressionError when callback returns undefined`, () => {
const builder = new Query()
expect(() =>
builder
.from({ employees: employeesCollection })
.where(() => undefined as any),
).toThrow(InvalidWhereExpressionError)
})

it(`throws InvalidWhereExpressionError when callback returns null`, () => {
const builder = new Query()
expect(() =>
builder
.from({ employees: employeesCollection })
.where(() => null as any),
).toThrow(InvalidWhereExpressionError)
})

it(`throws InvalidWhereExpressionError with helpful message mentioning eq()`, () => {
const builder = new Query()
try {
builder
.from({ employees: employeesCollection })
.where(({ employees }) => (employees.id as any) === 1)
expect.fail(`Expected error to be thrown`)
} catch (e) {
expect(e).toBeInstanceOf(InvalidWhereExpressionError)
expect((e as Error).message).toContain(`eq(`)
expect((e as Error).message).toContain(`===`)
}
})
})
})
Loading