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/prevent-js-operators-in-queries.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@tanstack/db': patch
---

Add development warnings for JavaScript operators in query callbacks

Warns developers when they mistakenly use JavaScript operators (`||`, `&&`, `??`, `?:`) in query callbacks. These operators are evaluated at query construction time rather than execution time, causing silent unexpected behavior.

Changes:

- Add `Symbol.toPrimitive` trap to RefProxy to catch primitive coercion (throws error)
- Add `checkCallbackForJsOperators()` to detect operators in callback source code (warns in dev only)
- Integrate checks into `select()`, `where()`, and `having()` methods
- Detection is disabled in production mode for zero runtime overhead
2 changes: 1 addition & 1 deletion packages/db-collection-e2e/src/suites/predicates.suite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -472,7 +472,7 @@ export function createPredicatesTestSuite(
.where(({ post }) =>
or(
like(lower(post.title), `%${searchLower}%`),
like(lower(post.content ?? ``), `%${searchLower}%`),
like(lower(post.content!), `%${searchLower}%`),
),
),
)
Expand Down
24 changes: 24 additions & 0 deletions packages/db/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,30 @@ export class QueryCompilationError extends TanStackDBError {
}
}

export class JavaScriptOperatorInQueryError extends QueryBuilderError {
constructor(operator: string, hint?: string) {
const defaultHint =
operator === `||` || operator === `??`
? `Use coalesce() instead: coalesce(value, defaultValue)`
: operator === `&&`
? `Use and() for logical conditions`
: operator === `?:`
? `Use cond() for conditional expressions: cond(condition, trueValue, falseValue)`
: `Use the appropriate query function instead`

super(
`JavaScript operator "${operator}" cannot be used in queries.\n\n` +
`Query callbacks should only use field references and query functions, not JavaScript logic.\n` +
`${hint || defaultHint}\n\n` +
`Example of incorrect usage:\n` +
` .select(({users}) => ({ data: users.data || [] }))\n\n` +
`Correct usage:\n` +
` .select(({users}) => ({ data: coalesce(users.data, []) }))`,
)
this.name = `JavaScriptOperatorInQueryError`
}
}

export class DistinctRequiresSelectError extends QueryCompilationError {
constructor() {
super(`DISTINCT requires a SELECT clause.`)
Expand Down
10 changes: 10 additions & 0 deletions packages/db/src/query/builder/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
SubQueryMustHaveFromClauseError,
} from '../../errors.js'
import {
checkCallbackForJsOperators,
createRefProxy,
createRefProxyWithSelected,
toExpression,
Expand Down Expand Up @@ -364,6 +365,9 @@ export class BaseQueryBuilder<TContext extends Context = Context> {
* ```
*/
where(callback: WhereCallback<TContext>): QueryBuilder<TContext> {
// Check for JavaScript operators that cannot be translated to query operations
checkCallbackForJsOperators(callback)

const aliases = this._getCurrentAliases()
const refProxy = createRefProxy(aliases) as RefsForContext<TContext>
const expression = callback(refProxy)
Expand Down Expand Up @@ -412,6 +416,9 @@ export class BaseQueryBuilder<TContext extends Context = Context> {
* ```
*/
having(callback: WhereCallback<TContext>): QueryBuilder<TContext> {
// Check for JavaScript operators that cannot be translated to query operations
checkCallbackForJsOperators(callback)

const aliases = this._getCurrentAliases()
// Add $selected namespace if SELECT clause exists
const refProxy = (
Expand Down Expand Up @@ -473,6 +480,9 @@ export class BaseQueryBuilder<TContext extends Context = Context> {
select<TSelectObject extends SelectObject>(
callback: (refs: RefsForContext<TContext>) => TSelectObject,
): QueryBuilder<WithResult<TContext, ResultTypeFromSelect<TSelectObject>>> {
// Check for JavaScript operators that cannot be translated to query operations
checkCallbackForJsOperators(callback)

const aliases = this._getCurrentAliases()
const refProxy = createRefProxy(aliases) as RefsForContext<TContext>
const selectObject = callback(refProxy)
Expand Down
126 changes: 126 additions & 0 deletions packages/db/src/query/builder/ref-proxy.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,37 @@
import { PropRef, Value } from '../ir.js'
import { JavaScriptOperatorInQueryError } from '../../errors.js'
import type { BasicExpression } from '../ir.js'
import type { RefLeaf } from './types.js'

/**
* Creates a handler for Symbol.toPrimitive that throws an error when
* JavaScript tries to coerce a RefProxy to a primitive value.
* This catches misuse like string concatenation, arithmetic, etc.
*/
function getOperatorTypeFromHint(hint: string): string {
switch (hint) {
case `number`:
return `arithmetic`
case `string`:
return `string concatenation`
default:
return `comparison`
}
}

function createToPrimitiveHandler(
path: Array<string>,
): (hint: string) => never {
return (hint: string) => {
const pathStr = path.length > 0 ? path.join(`.`) : `<root>`
throw new JavaScriptOperatorInQueryError(
getOperatorTypeFromHint(hint),
`Attempted to use "${pathStr}" in a JavaScript ${hint} context.\n` +
`Query references can only be used with query functions, not JavaScript operators.`,
)
}
}

export interface RefProxy<T = any> {
/** @internal */
readonly __refProxy: true
Expand Down Expand Up @@ -44,6 +74,10 @@ export function createSingleRowRefProxy<
if (prop === `__refProxy`) return true
if (prop === `__path`) return path
if (prop === `__type`) return undefined // Type is only for TypeScript inference
// Intercept Symbol.toPrimitive to catch JS coercion attempts
if (prop === Symbol.toPrimitive) {
return createToPrimitiveHandler(path)
}
if (typeof prop === `symbol`) return Reflect.get(target, prop, receiver)

const newPath = [...path, String(prop)]
Expand Down Expand Up @@ -97,6 +131,10 @@ export function createRefProxy<T extends Record<string, any>>(
if (prop === `__refProxy`) return true
if (prop === `__path`) return path
if (prop === `__type`) return undefined // Type is only for TypeScript inference
// Intercept Symbol.toPrimitive to catch JS coercion attempts
if (prop === Symbol.toPrimitive) {
return createToPrimitiveHandler(path)
}
if (typeof prop === `symbol`) return Reflect.get(target, prop, receiver)

const newPath = [...path, String(prop)]
Expand Down Expand Up @@ -140,6 +178,10 @@ export function createRefProxy<T extends Record<string, any>>(
if (prop === `__refProxy`) return true
if (prop === `__path`) return []
if (prop === `__type`) return undefined // Type is only for TypeScript inference
// Intercept Symbol.toPrimitive to catch JS coercion attempts
if (prop === Symbol.toPrimitive) {
return createToPrimitiveHandler([])
}
if (typeof prop === `symbol`) return Reflect.get(target, prop, receiver)

const propStr = String(prop)
Expand Down Expand Up @@ -204,6 +246,10 @@ export function createRefProxyWithSelected<T extends Record<string, any>>(
if (prop === `__refProxy`) return true
if (prop === `__path`) return [`$selected`, ...path]
if (prop === `__type`) return undefined
// Intercept Symbol.toPrimitive to catch JS coercion attempts
if (prop === Symbol.toPrimitive) {
return createToPrimitiveHandler([`$selected`, ...path])
}
if (typeof prop === `symbol`) return Reflect.get(target, prop, receiver)

const newPath = [...path, String(prop)]
Expand Down Expand Up @@ -303,3 +349,83 @@ export function isRefProxy(value: any): value is RefProxy {
export function val<T>(value: T): BasicExpression<T> {
return new Value(value)
}

/**
* Checks a callback function's source code for JavaScript operators that
* cannot be translated to query operations.
*
* Only runs in development mode (NODE_ENV !== 'production') and logs a warning
* instead of throwing, since regex-based detection can have false positives
* (e.g., operators inside regex literals).
*
* All detection logic is inside the dev check so bundlers can eliminate it
* entirely from production builds.
*
* @param callback - The callback function to check
*
* @example
* // This will log a warning in dev:
* checkCallbackForJsOperators(({users}) => users.data || [])
*
* // This is fine:
* checkCallbackForJsOperators(({users}) => users.data)
*/
export function checkCallbackForJsOperators<
T extends (...args: Array<any>) => any,
>(callback: T): void {
if (process.env.NODE_ENV !== `production`) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this will throw in the browser as there is no process object. Some bundlers may strip this out, but I don't think we can depend on that?

something like:

const __DEV__ =
  typeof process !== "undefined" &&
  process.env &&
  process.env.NODE_ENV !== "production";

if (__DEV__) {
  // ...
}

// Patterns that indicate JavaScript operators being used in query callbacks
const JS_OPERATOR_PATTERNS = [
{ pattern: /\|\|/, operator: `||`, description: `logical OR` },
{ pattern: /&&/, operator: `&&`, description: `logical AND` },
{ pattern: /\?\?/, operator: `??`, description: `nullish coalescing` },
{
// Matches ? followed by : with something in between,
// but not ?. (optional chaining) or ?? (nullish coalescing)
pattern: /\?[^.?][^:]*:/,
operator: `?:`,
description: `ternary`,
},
]

const getHintForOperator = (operator: string): string => {
switch (operator) {
case `||`:
case `??`:
return `Use coalesce() instead: coalesce(value, defaultValue)`
case `&&`:
return `Use and() for logical conditions`
case `?:`:
return `Use cond() for conditional expressions: cond(condition, trueValue, falseValue)`
default:
return `Use the appropriate query function instead`
}
}

// Strip string literals and comments to avoid false positives
const cleanedSource = callback
.toString()
.replace(/`(?:[^`\\]|\\.)*`/g, `""`) // template literals
.replace(/"(?:[^"\\]|\\.)*"/g, `""`) // double-quoted strings
.replace(/'(?:[^'\\]|\\.)*'/g, `""`) // single-quoted strings
.replace(/\/\/[^\n]*/g, ``) // single-line comments
.replace(/\/\*[\s\S]*?\*\//g, ``) // multi-line comments

for (const { pattern, operator, description } of JS_OPERATOR_PATTERNS) {
if (pattern.test(cleanedSource)) {
console.warn(
`[TanStack DB] JavaScript operator "${operator}" detected in query callback.\n\n` +
`Found JavaScript ${description} operator (${operator}) in query callback.\n` +
`This operator is evaluated at query construction time, not at query execution time,\n` +
`which means it will not behave as expected.\n\n` +
`${getHintForOperator(operator)}\n\n` +
`Example of incorrect usage:\n` +
` .select(({users}) => ({ data: users.data || [] }))\n\n` +
`Correct usage:\n` +
` .select(({users}) => ({ data: coalesce(users.data, []) }))`,
)
return // Only warn once per callback
}
}
}
}
125 changes: 124 additions & 1 deletion packages/db/tests/query/builder/ref-proxy.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
checkCallbackForJsOperators,
createRefProxy,
isRefProxy,
toExpression,
val,
} from '../../../src/query/builder/ref-proxy.js'
import { PropRef, Value } from '../../../src/query/ir.js'
import { JavaScriptOperatorInQueryError } from '../../../src/errors.js'

describe(`ref-proxy`, () => {
describe(`createRefProxy`, () => {
Expand Down Expand Up @@ -214,4 +216,125 @@ describe(`ref-proxy`, () => {
expect((val({ a: 1 }) as Value).value).toEqual({ a: 1 })
})
})

describe(`checkCallbackForJsOperators`, () => {
let warnSpy: ReturnType<typeof vi.spyOn>
const originalEnv = process.env.NODE_ENV

beforeEach(() => {
warnSpy = vi.spyOn(console, `warn`).mockImplementation(() => {})
// Ensure we're in dev mode for tests
process.env.NODE_ENV = `development`
})

afterEach(() => {
warnSpy.mockRestore()
process.env.NODE_ENV = originalEnv
})

it(`warns for || operator`, () => {
const callback = ({ users }: any) => ({ data: users.data || [] })
checkCallbackForJsOperators(callback)
expect(warnSpy).toHaveBeenCalledTimes(1)
expect(warnSpy.mock.calls[0]![0]).toContain(`||`)
})

it(`warns for && operator`, () => {
const callback = ({ users }: any) => users.active && users.name
checkCallbackForJsOperators(callback)
expect(warnSpy).toHaveBeenCalledTimes(1)
expect(warnSpy.mock.calls[0]![0]).toContain(`&&`)
})

it(`warns for ?? operator`, () => {
const callback = ({ users }: any) => ({ name: users.name ?? `default` })
checkCallbackForJsOperators(callback)
expect(warnSpy).toHaveBeenCalledTimes(1)
expect(warnSpy.mock.calls[0]![0]).toContain(`??`)
})

it(`warns for ternary operator`, () => {
const callback = ({ users }: any) => ({
status: users.active ? `active` : `inactive`,
})
checkCallbackForJsOperators(callback)
expect(warnSpy).toHaveBeenCalledTimes(1)
expect(warnSpy.mock.calls[0]![0]).toContain(`?:`)
})

it(`does not warn for valid query callbacks`, () => {
// Simple property access
checkCallbackForJsOperators(({ users }: any) => users.name)
expect(warnSpy).not.toHaveBeenCalled()

// Object with property access
checkCallbackForJsOperators(({ users }: any) => ({
id: users.id,
name: users.name,
}))
expect(warnSpy).not.toHaveBeenCalled()

// Optional chaining is allowed
checkCallbackForJsOperators(({ users }: any) => users.profile?.bio)
expect(warnSpy).not.toHaveBeenCalled()
})

it(`does not warn for operators in string literals`, () => {
// || in a string literal should not trigger warning
checkCallbackForJsOperators(() => ({ message: `a || b is valid` }))
expect(warnSpy).not.toHaveBeenCalled()

// && in a string literal should not trigger warning
checkCallbackForJsOperators(() => ({ message: `a && b is valid` }))
expect(warnSpy).not.toHaveBeenCalled()

// ?: in a string literal should not trigger warning
checkCallbackForJsOperators(() => ({ message: `a ? b : c is valid` }))
expect(warnSpy).not.toHaveBeenCalled()
})

it(`does not warn for optional chaining`, () => {
// Optional chaining should not be confused with ternary
checkCallbackForJsOperators(({ users }: any) => users?.name)
expect(warnSpy).not.toHaveBeenCalled()
})

it(`warns for operators in regex literals (known limitation)`, () => {
// This is a known limitation - regex literals containing operators
// will trigger false positives. Document the behavior.
const callbackWithRegexOr = () => ({ pattern: /a||b/ })
checkCallbackForJsOperators(callbackWithRegexOr)
expect(warnSpy).toHaveBeenCalledTimes(1)
})

it(`does not warn in production mode`, () => {
process.env.NODE_ENV = `production`
const callback = ({ users }: any) => ({ data: users.data || [] })
checkCallbackForJsOperators(callback)
expect(warnSpy).not.toHaveBeenCalled()
})
})

describe(`Symbol.toPrimitive trap`, () => {
it(`throws error when proxy is coerced to string`, () => {
const proxy = createRefProxy<{ users: { id: number } }>([`users`])
expect(() => String(proxy.users.id)).toThrow(
JavaScriptOperatorInQueryError,
)
})

it(`throws error when proxy is used in arithmetic`, () => {
const proxy = createRefProxy<{ users: { id: number } }>([`users`])
expect(() => Number(proxy.users.id)).toThrow(
JavaScriptOperatorInQueryError,
)
})

it(`throws error when proxy is concatenated with string`, () => {
const proxy = createRefProxy<{ users: { name: string } }>([`users`])
expect(() => `Hello ${proxy.users.name}`).toThrow(
JavaScriptOperatorInQueryError,
)
})
})
})
Loading
Loading