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
38 changes: 38 additions & 0 deletions .changeset/whole-pants-strive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
---
"@tanstack/db": major
---

Implement 3-valued logic (true/false/unknown) for all comparison and logical operators.
Queries with null/undefined values now behave consistently with SQL databases, where UNKNOWN results exclude rows from WHERE clauses.

**Breaking Change**: This changes the behavior of `WHERE` and `HAVING` clauses when dealing with `null` and `undefined` values.

**Example 1: Equality checks with null**

Previously, this query would return all persons with `age = null`:

```ts
q.from(...).where(({ person }) => eq(person.age, null))
```

With 3-valued logic, `eq(anything, null)` evaluates to `null` (UNKNOWN) and is filtered out. Use `isNull()` instead:

```ts
q.from(...).where(({ person }) => isNull(person.age))
```

**Example 2: Comparisons with null values**

Previously, this query would return persons with `age < 18` OR `age = null`:

```ts
q.from(...).where(({ person }) => lt(person.age, 18))
```

With 3-valued logic, `lt(null, 18)` evaluates to `null` (UNKNOWN) and is filtered out. The same applies to `undefined` values. To include null values, combine with `isNull()`:

```ts
q.from(...).where(({ person }) =>
or(lt(person.age, 18), isNull(person.age))
)
```
11 changes: 7 additions & 4 deletions packages/db/src/collection/change-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ import {
createSingleRowRefProxy,
toExpression,
} from "../query/builder/ref-proxy"
import { compileSingleRowExpression } from "../query/compiler/evaluators.js"
import {
compileSingleRowExpression,
toBooleanPredicate,
} from "../query/compiler/evaluators.js"
import {
findIndexForField,
optimizeExpressionWithIndexes,
Expand Down Expand Up @@ -199,7 +202,7 @@ export function createFilterFunction<T extends object>(
const evaluator = compileSingleRowExpression(expression)
const result = evaluator(item as Record<string, unknown>)
// WHERE clauses should always evaluate to boolean predicates (Kevin's feedback)
return result
return toBooleanPredicate(result)
} catch {
// If RefProxy approach fails (e.g., arithmetic operations), fall back to direct evaluation
try {
Expand All @@ -211,7 +214,7 @@ export function createFilterFunction<T extends object>(
}) as SingleRowRefProxy<T>

const result = whereCallback(simpleProxy)
return result
return toBooleanPredicate(result)
} catch {
// If both approaches fail, exclude the item
return false
Expand All @@ -232,7 +235,7 @@ export function createFilterFunctionFromExpression<T extends object>(
try {
const evaluator = compileSingleRowExpression(expression)
const result = evaluator(item as Record<string, unknown>)
return Boolean(result)
return toBooleanPredicate(result)
} catch {
// If evaluation fails, exclude the item
return false
Expand Down
103 changes: 100 additions & 3 deletions packages/db/src/query/compiler/evaluators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,29 @@ import { normalizeValue } from "../../utils/comparison.js"
import type { BasicExpression, Func, PropRef } from "../ir.js"
import type { NamespacedRow } from "../../types.js"

/**
* Helper function to check if a value is null or undefined (represents UNKNOWN in 3-valued logic)
*/
function isUnknown(value: any): boolean {
return value === null || value === undefined
}

/**
* Converts a 3-valued logic result to a boolean for use in WHERE/HAVING filters.
* In SQL, UNKNOWN (null) values in WHERE clauses exclude rows, matching false behavior.
*
* @param result - The 3-valued logic result: true, false, or null (UNKNOWN)
* @returns true only if result is explicitly true, false otherwise
*
* Truth table:
* - true → true (include row)
* - false → false (exclude row)
* - null (UNKNOWN) → false (exclude row, matching SQL behavior)
*/
export function toBooleanPredicate(result: boolean | null): boolean {
return result === true
}

/**
* Compiled expression evaluator function type
*/
Expand Down Expand Up @@ -145,6 +168,10 @@ function compileFunction(func: Func, isSingleRow: boolean): (data: any) => any {
return (data) => {
const a = normalizeValue(argA(data))
const b = normalizeValue(argB(data))
// In 3-valued logic, any comparison with null/undefined returns UNKNOWN
if (isUnknown(a) || isUnknown(b)) {
return null
}
return a === b
}
}
Expand All @@ -154,6 +181,10 @@ function compileFunction(func: Func, isSingleRow: boolean): (data: any) => any {
return (data) => {
const a = argA(data)
const b = argB(data)
// In 3-valued logic, any comparison with null/undefined returns UNKNOWN
if (isUnknown(a) || isUnknown(b)) {
return null
}
return a > b
}
}
Expand All @@ -163,6 +194,10 @@ function compileFunction(func: Func, isSingleRow: boolean): (data: any) => any {
return (data) => {
const a = argA(data)
const b = argB(data)
// In 3-valued logic, any comparison with null/undefined returns UNKNOWN
if (isUnknown(a) || isUnknown(b)) {
return null
}
return a >= b
}
}
Expand All @@ -172,6 +207,10 @@ function compileFunction(func: Func, isSingleRow: boolean): (data: any) => any {
return (data) => {
const a = argA(data)
const b = argB(data)
// In 3-valued logic, any comparison with null/undefined returns UNKNOWN
if (isUnknown(a) || isUnknown(b)) {
return null
}
return a < b
}
}
Expand All @@ -181,32 +220,78 @@ function compileFunction(func: Func, isSingleRow: boolean): (data: any) => any {
return (data) => {
const a = argA(data)
const b = argB(data)
// In 3-valued logic, any comparison with null/undefined returns UNKNOWN
if (isUnknown(a) || isUnknown(b)) {
return null
}
return a <= b
}
}

// Boolean operators
case `and`:
return (data) => {
// 3-valued logic for AND:
// - false AND anything = false (short-circuit)
// - null AND false = false
// - null AND anything (except false) = null
// - anything (except false) AND null = null
// - true AND true = true
let hasUnknown = false
for (const compiledArg of compiledArgs) {
if (!compiledArg(data)) {
const result = compiledArg(data)
if (result === false) {
return false
}
if (isUnknown(result)) {
hasUnknown = true
}
}
// If we got here, no operand was false
// If any operand was null, return null (UNKNOWN)
if (hasUnknown) {
return null
}

return true
}
case `or`:
return (data) => {
// 3-valued logic for OR:
// - true OR anything = true (short-circuit)
// - null OR anything (except true) = null
// - false OR false = false
let hasUnknown = false
for (const compiledArg of compiledArgs) {
if (compiledArg(data)) {
const result = compiledArg(data)
if (result === true) {
return true
}
if (isUnknown(result)) {
hasUnknown = true
}
}
// If we got here, no operand was true
// If any operand was null, return null (UNKNOWN)
if (hasUnknown) {
return null
}

return false
}
case `not`: {
const arg = compiledArgs[0]!
return (data) => !arg(data)
return (data) => {
// 3-valued logic for NOT:
// - NOT null = null
// - NOT true = false
// - NOT false = true
const result = arg(data)
if (isUnknown(result)) {
return null
}
return !result
}
}

// Array operators
Expand All @@ -216,6 +301,10 @@ function compileFunction(func: Func, isSingleRow: boolean): (data: any) => any {
return (data) => {
const value = valueEvaluator(data)
const array = arrayEvaluator(data)
// In 3-valued logic, if the value is null/undefined, return UNKNOWN
if (isUnknown(value)) {
return null
}
if (!Array.isArray(array)) {
return false
}
Expand All @@ -230,6 +319,10 @@ function compileFunction(func: Func, isSingleRow: boolean): (data: any) => any {
return (data) => {
const value = valueEvaluator(data)
const pattern = patternEvaluator(data)
// In 3-valued logic, if value or pattern is null/undefined, return UNKNOWN
if (isUnknown(value) || isUnknown(pattern)) {
return null
}
return evaluateLike(value, pattern, false)
}
}
Expand All @@ -239,6 +332,10 @@ function compileFunction(func: Func, isSingleRow: boolean): (data: any) => any {
return (data) => {
const value = valueEvaluator(data)
const pattern = patternEvaluator(data)
// In 3-valued logic, if value or pattern is null/undefined, return UNKNOWN
if (isUnknown(value) || isUnknown(pattern)) {
return null
}
return evaluateLike(value, pattern, true)
}
}
Expand Down
8 changes: 4 additions & 4 deletions packages/db/src/query/compiler/group-by.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
UnknownHavingExpressionTypeError,
UnsupportedAggregateFunctionError,
} from "../../errors.js"
import { compileExpression } from "./evaluators.js"
import { compileExpression, toBooleanPredicate } from "./evaluators.js"
import type {
Aggregate,
BasicExpression,
Expand Down Expand Up @@ -140,7 +140,7 @@ export function processGroupBy(
filter(([, row]) => {
// Create a namespaced row structure for HAVING evaluation
const namespacedRow = { result: (row as any).__select_results }
return compiledHaving(namespacedRow)
return toBooleanPredicate(compiledHaving(namespacedRow))
})
)
}
Expand All @@ -153,7 +153,7 @@ export function processGroupBy(
filter(([, row]) => {
// Create a namespaced row structure for functional HAVING evaluation
const namespacedRow = { result: (row as any).__select_results }
return fnHaving(namespacedRow)
return toBooleanPredicate(fnHaving(namespacedRow))
})
)
}
Expand Down Expand Up @@ -288,7 +288,7 @@ export function processGroupBy(
filter(([, row]) => {
// Create a namespaced row structure for functional HAVING evaluation
const namespacedRow = { result: (row as any).__select_results }
return fnHaving(namespacedRow)
return toBooleanPredicate(fnHaving(namespacedRow))
})
)
}
Expand Down
6 changes: 3 additions & 3 deletions packages/db/src/query/compiler/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
UnsupportedFromTypeError,
} from "../../errors.js"
import { PropRef, Value as ValClass, getWhereExpression } from "../ir.js"
import { compileExpression } from "./evaluators.js"
import { compileExpression, toBooleanPredicate } from "./evaluators.js"
import { processJoins } from "./joins.js"
import { processGroupBy } from "./group-by.js"
import { processOrderBy } from "./order-by.js"
Expand Down Expand Up @@ -195,7 +195,7 @@ export function compileQuery(
const compiledWhere = compileExpression(whereExpression)
pipeline = pipeline.pipe(
filter(([_key, namespacedRow]) => {
return compiledWhere(namespacedRow)
return toBooleanPredicate(compiledWhere(namespacedRow))
})
)
}
Expand All @@ -206,7 +206,7 @@ export function compileQuery(
for (const fnWhere of query.fnWhere) {
pipeline = pipeline.pipe(
filter(([_key, namespacedRow]) => {
return fnWhere(namespacedRow)
return toBooleanPredicate(fnWhere(namespacedRow))
})
)
}
Expand Down
Loading
Loading